Chatter List Providers
The chatter list knows no platform. Every platform is a provider that implements ChatterProvider (apps/api/src/services/chatters/provider.rs) and is registered in the ProviderRegistry built by ChattersService::build (apps/api/src/services/chatters/mod.rs). TwitchHelixProvider (services/chatters/twitch.rs) reads Helix; KickWebProvider (services/chatters/kick.rs) reads kick.com's web list.
What a provider does not do: it never writes Redis state, never publishes deltas and never decides who is in or out. The poller (apps/api/src/workers/chatters.rs) and the pure state machine (services/chatters/model.rs) do that for every platform alike. People who write in the chat are fed in automatically from the normalised chat:message stream (lumio:chat:\{account_id\}, platform + user_id + badges), so a platform whose chat already reaches that stream gets its writers listed with no extra work.
The trait
| Method | Contract |
|---|---|
platform() | The platform slug, e.g. twitch. It becomes the platform field of the API contract. |
listed_only_when_connected() | Whether to omit the platform from an account's supported_platforms, statuses and poller until it has a usable connection. Kick uses this; Twitch remains listed without one and reports not_connected. |
is_available(account) | Connection + scope check, DB only (it runs on the GraphQL/REST/bootstrap read path). Returns Available, MissingScope or NotConnected; SourceUnavailable is learned at poll time. |
snapshot(account, max) | The chatter list (at most max), platform total, truncated and role inputs. Ok(None) when a platform has no list endpoint — presence then comes from chat messages alone. Return ProviderError::MissingScope for a rejected grant or SourceUnavailable for a failed or invalid list source. |
role_of(badges) | Map one chat message's badges JSON onto broadcaster | moderator | bot | vip (None = viewer). |
known_bots() | Curated, lower-cased bot logins for this platform. |
base_role_inputs(account) | Broadcaster id + known bots (incl. the account's own bot account) without a platform call. |
avatars(account, ids) | Optional. user_id → url; cached by the poller under lumio:avatar:\{platform\}:\{uid\} for 24 h. |
Providers that call an OAuth API get tokens only from crate::oauth::get_fresh_connection_token on the account's channel connection — never the login token, never an inline refresh. Kick's website list is read without an OAuth token.
Kick provider
KickWebProvider is listed only for accounts with a usable Kick channel connection. It takes the channel slug from channel_login (or resolves it from the channel identity), caches the kick.com channel id by slug for 24 hours, then reads the undocumented web.kick.com/api/v1/channels/{channel_id}/chat/active-chatters endpoint every 60 seconds. Kick's chatters group contains at most 100 recent writers, not lurkers; its moderators, VIPs, OGs and bots groups are complete. total_count is the platform's full count, so the snapshot reports truncated when that count exceeds the listed entries.
The web list gives usernames, not stable user ids. The provider looks up each username at kick.com/api/v2/channels/{channel_slug}/users/{username} (at most 50 lookups per poll, five concurrent), caches found ids for 24 hours and misses for one hour. An unresolved entry waits for a later poll; it is never keyed by a name. This lets the snapshot and a chat message update the same (platform, user_id) entry. Role groups are resolved first; a partial moderator or VIP id list falls back to chat badges. The channel owner is broadcaster, moderator/VIP/bot groups map directly, and OGs and ordinary chatters are viewer. Known and account-owned bots take precedence over moderator and VIP.
A blocked request, challenge, non-JSON response or unexpected shape is ProviderError::SourceUnavailable. The poller reports reason = "source_unavailable", clears the platform total and truncation flag, and counts each failed poll as a snapshot miss. After two misses, entries kept only by the web list move to recently left; recent writers remain present. The next successful snapshot clears the reason. The Twitch session is independent. Kick requires no new OAuth scope or reconnection for this source.
Add a platform — 6 steps
- Write the provider. Implement
ChatterProviderinapps/api/src/services/chatters/<platform>.rsand add it to the registry inChattersService::build. Add a[chatters.<platform>]table (enabled,poll_interval_secs,max_chatters) toChattersSettings::platformandconfig/default.toml; without one the provider runs on the defaults. Keep every platform-specific name inside the provider: the contract (Chatter,ChatterPlatformStatus,ChattersSnapshot) must not grow a platform field. - Check the scopes. For OAuth list/role endpoints, add required scopes to the platform's channel scopes in
apps/api/src/platforms.rsand the own-app guide, which readschannelOAuthScopesfrom the API. A new scope makesmissing_scopes()ask existing connections to reconnect; bundle related scopes into one change. Makeis_availablereturnMissingScopefor a grant without them, and treat an unknown (NULL) stored grant as "try and see". A public web source such as Kick's needs no new scope; handle its failures asSourceUnavailableinstead. - Map badges to roles. Implement
role_offor the platform's badge format; take the bot badgeset_ids fromservices/chatters/bots.rs(is_bot_badge), never a second literal list. Identity (broadcaster, known bot) beats lists, lists beat badges, and bot beats moderator/VIP. Add unit tests next to the provider likeservices/chatters/twitch.rs. - Curate the bot list. Add the platform's well-known bots to
services/chatters/bots.rs(known_bots(platform), lower-case logins, or channel ids for a platform matched byIdentityKind::UserIdsuch as YouTube) and return them fromknown_bots(). Include Lumio's own bot account for that platform plus the account's custom bot (bots::own_bot_identity). The stream history reads the same module (BotFilter,bot_message_sql) to leave bot messages out of its chat counts, so the list and the history never disagree. - Wire the live signal. Live polling (and clearing the list at stream end) reads the account's online set
lumio:channel_online:\{account_id\}. The platform's go-live / go-offline path must callservices::channel_status::set_online/set_offlinewith the provider's platform string; Twitch, Kick and YouTube already do. Without it the platform is polled only while a panel is open, and its list is never cleared at stream end (it still expires 5 minutes after the last poll). - Document it. Add the platform to the roles and availability notes in Chatter List, any required scopes to Channel Connections OAuth Scopes, and its config table to Installation.
The frontend needs no change: it renders whatever supported_platforms lists, and shows platform chips and per-row icons once two or more platforms are connected.