Chat
Overview
The Chat module provides a unified, multi-platform chat system that aggregates messages from Twitch, YouTube, Kick, and Trovo into a single real-time stream. It supports message history with pagination, moderation actions (bans, timeouts, message deletion), user profiles with enrichment data from a ProfileService, moderator notes, emote rendering (Twitch, 7TV, BTTV, FFZ), polls, predictions, and raid management. Messages are buffered in Redis, flushed to TimescaleDB in batches, and broadcast to all connected WebSocket clients in real time.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/chat.rs) -- Queries for chat history, message count, user profiles, emotes, moderation log, and moderator notes. Mutations for sending messages, moderation actions, and CRUD on user notes. - Crate (
crates/lo-chat/src/) -- Core chat logic includingChatBuffer(Redis-backed message buffering),ChatFilter/ChatMessageInputtypes,ProfileService(caching + circuit breaking for platform API enrichment), emote set types, moderation log, and platform user management. - Database -- Chat messages are stored in TimescaleDB (
platform_chat_messageshypertable). Platform users are tracked in PostgreSQL (platform_users). Moderator notes inplatform_user_notes. Moderation log entries inmoderation_log. - YouTube Member Badges -- Custom-tier-badge image URLs are sourced from YouTube's internal InnerTube endpoint (the public Data API does not expose them) and cached in Redis. Inline-enriched into
platform_chat_messages.badgesat INSERT time so chat history scrollback renders correctly. See Member Badges for the full architecture, configuration, and GDPR considerations. - Real-time -- Messages are broadcast via Redis pub/sub to all WebSocket subscribers for the account. The
PendingChatAccountsstate tracks which accounts have buffered messages needing flush.
Frontend
- Next.js API proxy routes call GraphQL internally; browser hooks use the
/api/chat/*proxy and keep popout-token support on every fetch. - The Multichat UI renders messages with emotes, badges, colors, reply threading, platform/broadcast filters, keyword tabs, rule colors, per-user highlight colors (each highlighted user carries its own colour — the shared highlight token by default, a custom colour, or switched off to park the entry — edited in a colour dialog opened from the chip's colour button, with a live preview and an explicit Apply/Cancel), notification sounds (per keyword rule, plus one fixed sound for the whole highlighted-user list — a keyword rule's sound wins when a message matches both), and hidden/deleted-message display controls.
- First-time chatter tag (Twitch). When a viewer sends their first-ever message in a connected Twitch channel, the row shows a green First-time chatter tag next to the badges — Twitch's native first-time-chatter highlight. It is driven by the
twitch:first_messageevent (from the IRC-tags listener) correlated to the chat row by message id, so the tag appears even when the event arrives just after the message. It shows in both the dashboard chat and the chat pop-out/overlay. - The tab bar (platform + keyword tabs) is a single horizontally-scrolling row: it never wraps to a second line, so a crowded bar or a narrow pop-out costs no chat height. Desktop shows edge arrows and fades only while the row overflows; touch swipes instead. The + (new rule) and gear (settings) are anchored outside the scroll area. The active tab scrolls into view on selection and on reload-restore. Keyword tabs are drag-sortable on a mouse (
@dnd-kit, 5px activation so a click still selects); touch keeps the Filters-list up/down arrows — both persist into the samekeywordTabsorder. - User info modal fetches a unified profile combining DB data with live platform API enrichment. Its message history still shows rows that the feed hides as deleted, so moderators keep audit context. From the chat (not the event feed), it also carries one-click Highlight / Hide buttons that write the same name-based, cross-platform rule as the Filters tab via the shared
@lumio/chat-filterstoggle helpers (toggleHighlightUseris three-valued — add / switch a parked entry back on / remove — so a second click never stacks a duplicate); the buttons are prop-gated on a filters setter, so the event-feed variant of the card never offers a folgenlos toggle. - Chat filters and keyword tabs live in
localStoragewith an account-scoped key plus an unscoped mirror for token popouts; the shared@lumio/chat-filtersmatcher keeps dashboard and popout behavior identical. Astorage-event listener re-reads the filter/tab keys when another document writes them, so a Highlight/Hide toggle made in the OBS popout reaches the dashboard tab (and vice-versa) without a reload. - Reconnect-required banner. When a grant the Multichat send path depends on can no longer be refreshed — the same
reconnect_requiredstate the dashboard shows as Neuverbindung erforderlich — the Multichat surfaces a warning above the message input, one row per affected send platform, before a send fails at the provider instead of only afterwards. One banner, two sources: it fires when either the platform's channel connection (connectionStatuses.reconnectRequired) or the acting user's personal login grant (connectionStatuses.loginReconnectRequired, ZAF-1045) is flagged — the send actually uses the login grant, so a dead login grant is exactly the case the founder hit (aead::Erroron send). The banner sits in the normal layout flow (it shortens the message feed and never overlaps messages), directly above the failed-send banner. It appears only for platforms the user can actually send on (a flagged platform the user has no send access to raises no banner), and the affected platform's send toggle is disabled (with a reconnect tooltip) and dropped from the active send set, so no message is dispatched to a grant that would 401. Each row distinguishes which connection is dead (ZAF-1048): a dead personal login grant shows a login-specific line and routes its Jetzt neu verbinden button to Account → Profile (where the login is re-authorised), while a dead channel connection routes to the connections page — so the CTA never sends the user to the wrong repair surface. When both are flagged for one platform the row shows the login case, because the send path uses the login grant. A delegated pop-out guest (token session, not a first-party user) sees the warning text without the button, since they cannot repair the connection. It works in both the dashboard chat and the chat pop-out. Channel rows are suppressed for viewers who holdconnections:create, because those viewers already get the stronger sticky expired-connection alert under the topbar, which covers the same connections and starts the OAuth flow directly; login rows are never suppressed. - Expired-connection alert under the topbar. Independently of the send path, a flagged channel connection raises a non-dismissible error alert pinned directly beneath the Multichat topbar, with a Reconnect button that starts that platform's OAuth flow in place. It is shown only to viewers holding
connections:create(Owner / Administrator among the default roles). See Connections. - Failed-send messages are translated, never raw. A rejected send now carries a machine-readable
errorCode(login_reconnect_required|internal_error|send_failed) on bothsendChatToPlatform(GraphQL) andPOST /v1/chat/send(REST). The failed-send banner renders a translated line off that code — thelogin_reconnect_requiredcode is always a dead personal login grant, so it shows the login-specific reconnect line and a Jetzt neu verbinden button that opens Account → Profile — and an unrecognised failure shows only "Send failed (status)". The raw backend error chain (crypto/DB internals such asaead::Error) is never rendered in the UI (ZAF-1047, ZAF-1048). - The alert box (hype train, ad break, poll, prediction, outgoing raid) is event-sourced through one pure reducer (
applyAlertEvent) and persisted across refreshes by two layers. Layer A writes the five card slots plus adismissedregistry tolocalStorage(lumio:chat-alerts, same account-scoped-key + unscoped-mirror Pattern-B storage as the filters). On mount it reconciles every slot against the wall clock — countdowns are absolute epoch-ms deadlines, so an ad break/raid resumes with the correct remaining time, an alert whose end already passed returns ended (with its wind-down window) or is dropped, and anything past its age limit (prediction locked 30 min; hype train / YouTube poll 15 min) is discarded, never shown as running. Layer B is a server replay of the last 30 minutes of Twitch event history viaGET /api/events, gated client-side onevents:read(so a token dock without that scope never fires the request) and fail-soft (a502/empty result is additive-only and can never blank Layer A). One merge reconciles both sources: Layer A is the source of truth for live values, Layer B for existence and end — so an alert whose end the browser missed is restored ended with the last live numbers. A dismissed card is filtered out of both sources and the dismiss propagates cross-tab through the samestoragelistener as the filters. A restored card renders identically to a live one — the alert box carries no restored/stale badge.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
chatHistory(filter: ChatFilterInput) | chat:read | Paginated chat message history with filters for platform, user, date range, keyword search, and YouTube liveChatId (broadcast-level) |
chatMessageCount | chat:read | Total message count for the account |
platformUserProfile(platform, platformUserId) | chat:userinfo | Unified user profile with platform API enrichment via ProfileService |
searchPlatformUsers(query, platforms, limit) | chat:userinfo | Prefix-search the account's known chatters by username/display name for the filter name-completion (min 2 chars, else empty; capped at 25) |
emotes(platform, channelId) | chat:read | Emote sets for a platform/channel (7TV, BTTV, FFZ, YouTube native, Trovo native) |
userEmotes | chat:read | Twitch emotes available to the authenticated user (subs, globals, follower) |
platformUserNotes(platform, platformUserId) | chat:notes | List moderator notes for a platform user |
moderationLog(platform, platformUserId, page, limit) | chat:userinfo | Moderation action history for a user |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
sendChatMessage(input: ChatMessageInputGql!) | chat:write | Buffer a chat message in Redis and broadcast via pub/sub |
sendChatToPlatform(input: SendToPlatformInput!) | chat:write | Send a chat message to a platform (twitch, youtube, kick, trovo). The message is authored by the logged-in user's platform identity (their Twitch/Google login), so they need a login connection for the target provider. The target chat is the broadcaster's stream — for YouTube the active liveChatId is read from the polling worker's Redis cache (same source as youtubeActiveStreams), so invited members can post even though they themselves have no active stream. Resolution prefers a currently-live broadcast; when nothing is live yet it falls back to the open waiting-room chat of a scheduled (upcoming) broadcast, so bots and commands can respond in the pre-stream chat that the worker already reads. With multiple concurrent broadcasts (of the same status) the caller must pass liveChatId to choose which one to post to. |
createPlatformUserNote(platform, platformUserId, note) | chat:notes | Create a moderator note on a user |
updatePlatformUserNote(noteId, note) | chat:notes | Update an existing moderator note |
deletePlatformUserNote(noteId) | chat:notes | Delete a moderator note |
updateUserTreatment(platform, platformUserId, treatment) | chat:ban | Update chat user treatment (none, active_monitoring, restricted); Twitch syncs to Helix best-effort |
moderateChat(input: ModerationInput!) | chat:ban / chat:timeout / chat:delete | Perform a moderation action (ban, timeout, delete) on Twitch, YouTube, Kick, or Trovo. Permission checked per action type. YouTube auto-resolves the active liveChatId from the polling worker's Redis cache when not supplied. After a successful ban/timeout the server soft-deletes every message from the affected user and broadcasts a chat:clear_user WebSocket event so all connected clients grey those messages out — same UX as the platform's native chat. Platform capability differs: Twitch and YouTube support delete + timeout + ban; Kick supports delete only; Trovo's moderation API is not publicly available, so no moderation action succeeds there. When a platform rejects an action the mutation returns success: false with a human-readable details reason (as HTTP 200, not an error) — the client surfaces that reason and leaves the user's messages untouched. |
cancelRaid | chat:raid | Cancel a pending Twitch raid |
endPoll(pollId, status?) | chat:poll | End an active Twitch poll (status defaults to TERMINATED) |
endPrediction(predictionId, status, winningOutcomeId?) | chat:prediction | End/cancel a Twitch prediction (RESOLVED requires winningOutcomeId) |
refreshPlatformUserProfile(platform: String!, platformUserId: String!) | chat:refresh_user | Force a ProfileService re-enrichment for one platform user, bypassing the Redis cache. Rate-limited to once per 10 minutes per user via a Redis SETNX. |
Every chat query and mutation additionally requires the feature:multichat
feature flag; the GraphQL guards combine FeatureGuard("feature:multichat")
with the permission above, and the REST handlers call the same
require_feature. moderateChat is the one exception to the guard shape: its
declared guard is AuthGuard + FeatureGuard, because the required permission
(chat:ban / chat:timeout / chat:delete) depends on the action in the
input and is therefore resolved inside the resolver.
Sending: result shape and reconnect signalling
sendChatToPlatform and POST /v1/chat/send author the message with the
logged-in user's personal login grant for the platform (twitch→twitch,
youtube→google, kick→kick, trovo→trovo) — not the account's channel
connection. That grant is acquired through the central refresh chokepoint, so an
expired-but-refreshable token is refreshed transparently before sending; a grant
that can no longer be used is flagged reconnect_required and reported as a
typed signal.
The send result carries a machine-readable errorCode (error_code on REST) so
the client renders a translated message instead of parsing backend strings.
Both protocols return the identical shape, codes and platform, as HTTP 200 in
every case below (only a hard validation error — unsupported platform, no channel
connection — is a protocol error):
errorCode | Meaning |
|---|---|
absent (sent: true) | Sent; messageId is set when the platform returns one. |
login_reconnect_required | The user's personal login grant is dead — undecryptable ciphertext, already flagged, or expired with no refresh token. The connection is flagged reconnect_required and the user must reconnect that login. No crypto/DB internals are ever returned in error. |
internal_error | An internal failure reading the credential; the human-readable error is scrubbed and the cause is logged, never shipped. |
send_failed | The platform rejected the send (slow-mode, ban, invalid content, …); error carries the platform's message. |
A dead login grant also surfaces on the connection-status read model as
loginReconnectRequired (see Connections),
so the Multichat can warn before the first send attempt.
REST Endpoints
All paths live under /v1. Bodies are snake_case and mirror the GraphQL inputs.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/chat/history | chat:read | Paginated chat history (filter by platform, user, date, keyword search, YouTube live_chat_id) |
GET | /v1/chat/history/count | chat:read | Total message count for the account |
POST | /v1/chat/message | chat:write | Ingest/buffer a message (bot-facing) |
POST | /v1/chat/send | chat:write | Send a chat message to a connected platform |
POST | /v1/chat/moderate | chat:ban / chat:timeout / chat:delete | Ban, timeout, or delete on twitch / youtube / kick / trovo (permission resolved per action). Mirrors the GraphQL moderateChat mutation 1:1 — same fields, same auto-resolve of live_chat_id for YouTube, same chat:clear_user broadcast on ban/timeout. |
DELETE | /v1/chat/raid | chat:raid | Cancel the current Twitch raid |
DELETE | /v1/chat/poll | chat:poll | End the current Twitch poll |
DELETE | /v1/chat/prediction | chat:prediction | Lock/resolve the current Twitch prediction |
GET | /v1/chat/users/search | chat:userinfo | Prefix-search known chatters by username/display name for the filter name-completion. ?q= (min 2 chars, else empty), optional ?platform= (comma-separated), ?limit= (capped at 25). Mirrors GraphQL searchPlatformUsers 1:1. |
GET | /v1/chat/users/{platform}/{platform_user_id} | chat:userinfo | Unified user profile (DB + enrichment) |
GET | /v1/chat/users/{platform}/{platform_user_id}/moderation-log | chat:userinfo | Moderation action history |
GET | /v1/chat/users/{platform}/{platform_user_id}/notes | chat:notes | List moderator notes |
POST | /v1/chat/users/{platform}/{platform_user_id}/notes | chat:notes | Create a moderator note |
PATCH | /v1/chat/users/{platform}/{platform_user_id}/notes/{note_id} | chat:notes | Update a moderator note |
DELETE | /v1/chat/users/{platform}/{platform_user_id}/notes/{note_id} | chat:notes | Delete a moderator note |
PUT | /v1/chat/users/{platform}/{platform_user_id}/treatment | chat:ban | Set user treatment (none, active_monitoring, restricted) |
POST | /v1/chat/users/{platform}/{platform_user_id}/refresh | chat:refresh_user | Force-refresh the enriched profile, bypassing the cache. Mirrors GraphQL refreshPlatformUserProfile 1:1, including the 10-minute per-user rate limit. |
WebSocket
| Channel | Gate | Feature flag |
|---|---|---|
chat:{account_id} | chat:read on the account in the channel key | feature:multichat |
The channel carries the live message stream plus the moderation side-events:
chat:message, chat:delete, chat:clear_user, chat:moderation_log, and
chat:user_treatment_update. The feature flag is enforced at subscribe time —
without feature:multichat a crafted chat:{account_id} subscription is
rejected, matching the REST/GraphQL gate.
Keyword search
chatHistory accepts a keyword search filter so a keyword tab can pull older matches from TimescaleDB instead of only what is left in the in-browser buffer. It is exposed as ChatFilterInput.search (GraphQL, [String!]) and as the comma-separated search query parameter on GET /v1/chat/history (REST, e.g. ?search=hello,world). A message is a candidate when its body contains any of the keywords (case-insensitive substring). The server predicate is a coarse message ILIKE-any-keyword — a deliberate superset of the shared @lumio/chat-filters matcher, which the client re-checks so live and back-loaded messages judge identically.
- Always time-bounded. A keyword search never scans the whole hypertable: it always carries a lower time bound — the caller's
fromwhen supplied, otherwise the last 12 hours. Plain (non-search) history queries are unchanged and impose no implicit floor. Because a keyword search is capped to a window and the daily retention sweep bounds the data, "no older matches in the window" is an expected, ordinary end state. - Bounded input. At most 20 keywords, each at most 100 characters (after trimming and dropping blanks). A violation returns
400 BAD_REQUESTwith the identical message on GraphQL and REST — the sharedlo_chat::validate_search_termsbacks both. Wildcard metacharacters (%,_) inside a keyword match literally. REST splits the singlesearchquery parameter on commas, so a keyword that itself contains a comma cannot be expressed through REST; use GraphQLChatFilterInput.search: [String!]for comma-containing keywords. - No new permission or flag.
searchonly narrows the existing history surface, which is already gated onchat:read+feature:multichat.
The client side (useMultichat / Multichat) drives this window:
- Per-tab paging.
loadMoreruns in two modes: for a keyword tab it pages the keywordsearchwindow (fromseeded once at 12 h on first open); otherwise it pages the general history. Paging state (page/hasMore) is kept per view so one tab never inherits another's exhausted end. - Capped follow-on. A candidate page can yield zero visible rows (the server pre-filter is a superset of the matcher). The infinite-scroll keeps loading — hard-capped at 5 pages per trigger — until a row matches or the window is exhausted, so a keyword with no recent hits never loops. Back-loaded rows are tagged
__source: "history"(client-only) and merged bycreated_atwith dedupe onplatform_message_id ?? id, keeping the list chronological and duplicate-free; the sameMAX_MESSAGES = 500cap applies to the prepend path, trimmed at the newest end. - Reliable end state. The BFF (
/api/chat/history) passes the GraphQLtotal/page/pagesthrough, sohasMoreis read frompage < pagesrather than guessed from the page length; a failed first fetch leaves paging retryable instead of permanently off.
Name completion (searchPlatformUsers)
The Hidden users and Highlighted users filter lists offer an @-completion bound to the account's real chatter base — the platform_users table, which upsert_platform_user writes on every incoming message. searchPlatformUsers (GraphQL) and GET /v1/chat/users/search (REST) back it, returning the slim { platform, platformUserId, username, displayName, avatarUrl, lastSeenAt, messageCount } shape.
- Prefix match, both name columns. Case-insensitive prefix (
prefix%) onusernameanddisplay_name.%,_, and\in the query match literally (the same escaping as member search). Orderedlast_seen_at DESC, message_count DESC— whoever wrote most recently ranks first. - Minimum two characters. A query shorter than 2 characters (after trimming) returns an empty list, not an error — the UI queries on every keystroke.
platforms(GraphQL[String!], REST comma-separated?platform=) restricts the platforms; omit for all.limitis hard-capped at 25 regardless of the requested value. - Same guard as the profile query.
feature:multichat+chat:userinfo, identical toplatformUserProfile, so the completion never surfaces a user the caller could not already inspect — and withoutchat:userinfothe search returns nothing (no data leak). The account is always derived server-side from the (cookie or popout) token, never from a client parameter, so a popout with?token=lm_pop_*gets results without sending an account id. - Indexed prefix. Backed by
(account_id, lower(username) text_pattern_ops)and the matchingdisplay_nameindex (20260805000001_platform_users_search_prefix_index) so a per-keystroke prefix lookup range-scans instead of full-scanning the monotonically growing, never-pruned table. The predicate useslower(col) LIKE …(notILIKE) to match the index expressions exactly. - All four platforms populate it. Twitch, YouTube, Kick, and Trovo all
upsert_platform_useron their chat path, so a chatter on any platform appears in the completion after their first message. (Trovo's chat-path upsert was added in ZAF-294; it previously only broadcast chat.)
Twitch Shared Chat
Twitch Shared Chat lets several channels share one chat session — a message sent in channel B also appears in channel A's chat. Lumio recognises these cross-channel messages and attributes each to its origin channel so the dashboard/overlay can show which channel a message actually came from.
-
Source fields. On the Twitch EventSub
channel.chat.messagepath (crates/lo-twitch-apiChatMessageEvent), Lumio capturessource_broadcaster_user_id/login/name,source_message_id,is_source_only, andsource_badges. When the source broadcaster differs from the host broadcaster, ingest (process_chat_message) resolves the source channel's avatar throughProfileService::get_profile(a channel is a user on Twitch, keyed byplatform_user_id) and populates two fields on the chat message. -
Contract fields (identical on all three protocols).
is_shared_chat(isSharedChatin GraphQL) —truewhen the message originated in a different channel's room during a shared-chat session.source_channel(sourceChannelin GraphQL) —{ platform_channel_id, login, display_name, avatar_url }for the origin channel;null/absent for normal messages and for host-origin messages in a shared session.
These ship on the
chat:messageWebSocket payload, the GraphQLchatHistoryChatMessagetype, and the RESTGET /v1/chat/historyresponse. Non-shared messages are byte-for-byte unchanged (fields absent/null). -
Persistence. The
platform_chat_messageshypertable storessource_channel_id,source_channel_login,source_channel_display_name, andsource_channel_avatar_url(nullable) via the buffer→flush path, so chat-history scrollback keeps the origin-channel attribution. -
Dashboard & overlay display. In Multichat (dashboard and OBS popout alike), a shared-chat message renders the origin channel's avatar immediately before the platform icon in its row (a purple-ringed avatar, falling back to the channel initial until enrichment resolves the picture). Above the message list — only when the header is shown, so the chrome-free popout stays clean — a full-width gradient "Shared Chat" banner names the participant channels with a badge and half-overlapping channel avatars, and the Twitch and All tabs show the same overlapping participant avatars. The participant roster is derived client-side from the distinct
source_channels observed in the message buffer (most-recent first) — no separate session-roster field is sent to the client — so it clears automatically as those rows scroll out. -
Session participants (backend).
HelixClient::get_shared_chat_session(access_token, broadcaster_id)returns the active session's host + participant channels (GET /helix/shared_chat/session); the frontend derives its banner/tab roster from observedsource_channels rather than this endpoint. -
No new gate. Shared-chat display is intrinsic to Twitch chat — it adds no RBAC permission, feature flag, or
plan_featuresrow and emits no audit event (chat is high-volume telemetry). Attribution is by channel (public broadcaster identity), never by viewer.
Permissions
| Permission | Description |
|---|---|
chat:read | Read chat messages, view emotes |
chat:write | Send/ingest chat messages |
chat:userinfo | View user profiles, follow status, moderation log |
chat:delete | Delete chat messages |
chat:ban | Ban/unban chat users |
chat:timeout | Timeout chat users |
chat:notes | Manage moderator notes on platform users |
chat:raid | Cancel raids |
chat:poll | End polls |
chat:prediction | End predictions |
chat:refresh_user | Force a cache-bypassing profile re-enrichment |
Database
| Table | Database | Description |
|---|---|---|
platform_chat_messages | TimescaleDB | Chat messages hypertable with compression. Fields: id, account_id, platform, channel_name, user_id, username, display_name, message, emotes (JSONB), gifs (JSONB), badges (JSONB), color, is_mod, is_sub, is_vip, sub_tier, platform_message_id, reply fields, deleted_at, deleted_by, action, action_duration_secs. created_at is populated from the platform timestamp (YouTube publishedAt, Twitch metadata.message_timestamp, Kick webhook created_at, Trovo send_time) so re-ingested backlog stays chronologically aligned across platforms. A partial UNIQUE index on (account_id, platform, platform_message_id, created_at) makes ingestion idempotent — workers re-fetching the live-chat backlog after a restart no longer create duplicates. The action column records why a row was hidden ("ban", "timeout", "delete"); action_duration_secs carries the timeout length. Both survive a page refresh so the UI keeps rendering "Hidden by X" / "Blocked 5min by X" instead of the generic "Deleted by X". The live_chat_id column stores the YouTube live chat ID for broadcast-level filtering, enabling the Multichat to show messages from a specific broadcast when multiple YouTube streams are active simultaneously. The gifs column stores the per-message Twitch GIF-keyboard fragments (an ordered JSONB array of {id, url, provider, alt}) in its own field — never folded into emotes, whose per-emote counts feed already-frozen stream-history reports. |
platform_users | PostgreSQL | Platform user profiles with ban status, treatment, follower info, enrichment timestamps |
platform_user_notes | PostgreSQL | Moderator notes on platform users (created_by, created_by_name) |
moderation_log | PostgreSQL | Log of moderation actions (ban, timeout, delete) with target user/message, moderator, reason, duration |
Data Flow
- Platform adapter (Twitch IRC, YouTube live chat, etc.) receives a message.
- Message is sent to the API via
sendChatMessageGraphQL mutation. - Message is buffered in Redis via
ChatBuffer::push(). - Message is broadcast via Redis pub/sub to all connected WebSocket clients.
PendingChatAccountstracks the account for the background flush worker.- Flush worker periodically (every 5s) writes buffered messages from Redis to TimescaleDB, splitting each account's batch into bind-parameter-safe chunks (see Flush durability).
- Frontend receives the message via WebSocket and renders it with emotes, GIFs, and badges.
Flush durability
ChatBuffer::flush() reads and deletes an account's Redis buffer atomically, so the drained batch is briefly the only surviving copy. Several safeguards keep that copy from being lost and keep a bad batch from wedging the account:
- Chunked INSERT.
insert_messages_batchsplits the batch into fixed-size chunks (1000 rows) before writing. Each chat row binds 28 parameters and Postgres caps a single statement at 65535 bind parameters, so an un-chunked flush of ≥ 2341 messages — trivially reached by a raid or emote wave in one 5s window — would fail as a protocol error (the batch then re-queued forever). Chunking keeps every statement well under the ceiling regardless of batch size. - Re-queue on failed INSERT. If a chunk INSERT fails, the flush worker re-queues the drained messages onto the Redis buffer (
ChatBuffer::requeue) and re-arms the account so the next tick retries, instead of dropping them. Because ingestion is idempotent (the partial UNIQUE index →ON CONFLICT DO NOTHING), a retry after a partial insert never duplicates rows — including across chunk boundaries. Only if Redis is also unreachable are the messages genuinely lost, and that path logs loudly. - Dead-letter on repeated failure. A batch that keeps failing to persist must not re-queue forever — that "poison pill" would silently stop the account's chat from ever being stored. After
CHAT_FLUSH_MAX_RETRIES(5) consecutive failed flush cycles for an account, the worker parks the batch in a dead-letter list (lumio:chat:deadletter:{account_id}) instead of re-queuing it, resets the failure counter, and logs aterror. No data is lost — the batch is preserved for out-of-band inspection/replay — and live chat keeps buffering normally. The dead-letter list is deliberately not drained by the flush worker or startup recovery, so a genuinely un-insertable batch cannot re-wedge the account. - Backlog visibility.
ChatBuffer::push()emits awarn(withaccount_idand length) whenever an account's buffer crosses a multiple ofCHAT_BUFFER_WARN_THRESHOLD(10 000) messages — the early signal of a buffer filling faster than it drains, before anyone has to report it. - Restart recovery. The pending-account set is in-process, so an API restart would otherwise orphan any buffer whose account does not chat again. On startup the flush worker scans Redis (
SCAN lumio:chat:buffer:*) and re-seeds the pending set (ChatBuffer::pending_account_ids), so buffers written before the restart are still drained.
ChatMessageInput.user_treatment is a live-broadcast-only snapshot and is intentionally not persisted per message — the durable source is the platform_users.user_treatment column, read back by the profile/GraphQL layer on load.
Retention
Each plan advertises a chat retention window via plans.chat_retention_days (shown in Admin and on billing). Enforcement is handled by a background sweep (apps/api/src/workers/chat_retention.rs) that hard-deletes rows in the platform_chat_messages hypertable older than each account's plan window.
- Per-account, not a table-wide TimescaleDB policy. Retention is plan-dependent while
add_retention_policyis table-wide, so the sweep resolves each account's window (api::db::plans::list_account_chat_retention) and issues a per-account delete (lo_chat::delete_old_messages). The(account_id, created_at DESC)index keeps each delete cheap. - Per-account override respected. The effective window is the account's
account_limits.chat_retention_daysoverride when set, otherwise the plan'splans.chat_retention_days— the sameCOALESCE(override, plan)precedence the admin limits API applies. An account granted extended retention is never purged on the shorter plan default. chat_retention_days = 0= keep forever. Those accounts are excluded from the sweep entirely.- Config-gated. The worker only runs when
chat.retention_enforcement_enabledis true. It is disabled by default (dev/test/staging keep their history so test data is not purged too quickly) and enabled in production (config/production.toml). Cadence ischat.retention_sweep_interval_secs(default 86400 = daily); a sweep also runs once on startup.
| Setting | ENV | Default |
|---|---|---|
chat.retention_enforcement_enabled | LUMIO__CHAT__RETENTION_ENFORCEMENT_ENABLED | false (dev) · true (production) |
chat.retention_sweep_interval_secs | LUMIO__CHAT__RETENTION_SWEEP_INTERVAL_SECS | 86400 |
Deletion is permanent — retention runs in addition to moderation soft-deletes (which only set deleted_at/deleted_by and keep the row).
Erasure (GDPR Art. 17)
Retention is time-driven and plan-scoped; erasure is event-driven and removes a data subject's chat PII immediately, independent of any retention window. platform_chat_messages stores the raw message text, username/display_name, avatar_url, the platform user_id, and identity badges/emotes (JSONB) — all personal data. Two paths hard-delete it (api::services::chat_privacy):
- On account dissolution.
DELETE /v1/accounts/{id}(and the GraphQLdissolveAccount) erase everyplatform_chat_messagesrow for the account before deleting the account itself. The table lives in TimescaleDB, so the main-databaseDELETE FROM accounts … CASCADEnever reaches it; purging first also avoids orphaned rows that the retention sweep — which only resolves windows for accounts that still exist — could never reach. Chat PII is the only account data purged on dissolution: the account's stream-history statistics are retained, not erased, because they are broadcast statistics rather than personal data of the account holder (founder ruling ZAF-568; see Accounts › Account Dissolution Flow). - On data-subject request.
POST /v1/admin/privacy/chat/erase(GraphQLeraseChatSubjectData), gated byadmin:privacy-erase, hard-deletes every row authored by a subject across all accounts. The subject is identified by exactly one oflumio_user_id, orplatform+user_id. The YouTube member-erasure endpoint (DELETE /v1/admin/privacy/youtube/member/{id}) additionally runs this same chat purge for the member's(platform = 'youtube', user_id)rows.
Every erasure writes a chat_pii_erasure audit event (account-scoped for a dissolution; one global event plus one per affected account for a subject request). Erasure is a hard delete, distinct from moderation soft-deletes that keep the plaintext.
Moderation Permission Matrix
Moderation availability is gated by two independent layers:
- Actor → target hierarchy —
getChatPermissions(apps/web/src/app/(main)/(app)/dashboard/chat/chat-permissions.ts) encodes each platform's role model (who may act on whom). The matrix below mirrors it. - Platform capability —
platformSupportsModeration(apps/web/src/lib/moderation.ts) greys out actions the platform's API simply cannot perform, regardless of role. Twitch and YouTube support delete + timeout + ban; Kick supports delete only (timeout and ban are greyed out); Trovo supports no moderation actions (its API is not publicly available, so delete, timeout, and ban are all greyed out).
If an action still reaches the backend and is rejected, the mutation returns success: false with a human-readable details reason; the client shows that reason as an error toast and does not hide the user's messages. The matrix below mirrors the actor → target hierarchy:
Twitch — broadcaster, lead-mod, mod hierarchy (Twitch's three-tier model):
| Actor → Target | Self | Broadcaster | Lead Mod | Mod | User |
|---|---|---|---|---|---|
| Broadcaster | only delete (own) | — | delete + ban + timeout | delete + ban + timeout | delete + ban + timeout |
| Lead Mod | only delete (own) | — | delete only | delete only | delete + ban + timeout |
| Mod | — | — | — | — | delete + ban + timeout |
Lead Mods and Mods cannot ban or timeout other moderators (only the broadcaster can). Lead Mods can delete other mods' messages but not ban them.
YouTube — strict three-tier (broadcaster + mod = "staff"). YouTube's Data API rejects liveChatBans.insert / liveChatMessages.delete against staff with HTTP 403 even when the broadcaster is the actor. Lumio therefore hides the moderation dropdown entirely when the target is a moderator or the broadcaster:
| Actor → Target | Self | Broadcaster | Mod | User |
|---|---|---|---|---|
| Any role | only broadcaster can delete own | — | — | delete + ban + timeout |
UI labels also differ on YouTube — "Timeout" reads as "Vorübergehend blockieren" / "Block temporarily" and "Ban" reads as "Auf diesem Kanal ausblenden" / "Hide on this channel" to mirror YouTube Studio's wording.
Kick & Trovo — the actor → target hierarchy follows the same lenient pattern as Twitch's broadcaster ↔ mod tier (broadcaster can act against mods, mods cannot act against staff). On top of that hierarchy the platform-capability layer applies: Kick exposes only message deletion — the timeout and ban controls are greyed out; Trovo has no publicly available moderation API — delete, timeout, and ban are all greyed out. A moderator therefore sees at a glance what each platform can do instead of clicking into a server error.
Ban / Timeout Sweep (chat:clear_user)
A successful ban or timeout on any platform triggers a server-side sweep that mirrors the platform's native chat behaviour:
lo_chat::soft_delete_user_messagessetsdeleted_at,deleted_by,action("ban"|"timeout"), andaction_duration_secson every undeleted row of the affected user.- The server publishes a
chat:clear_userevent tolumio:chat:{account_id}with payload{platform, platform_user_id, deleted_by, action, duration_secs}. - All connected clients (dashboard + popouts + member sessions) immediately grey out the matching messages and append the localised suffix:
- Twitch ban → "Banned by X" / "Gebannt von X"
- Twitch timeout → "Timed out 5min by X" / "Timeout 5min von X"
- YouTube ban → "Hidden by X" / "Ausgeblendet von X"
- YouTube timeout → "Blocked 5min by X" / "Vorübergehend blockiert 5min von X"
After a page refresh the suffix survives because action and action_duration_secs are persisted in platform_chat_messages.
External ban/timeout events (e.g. issued from a platform's own UI) flow through the same code path: apps/api/src/workers/twitch_eventsub.rs reacts to Twitch channel.ban notifications, and apps/api/src/routes/webhooks.rs reacts to Kick moderation.banned webhooks, each performing the identical sweep + broadcast. (Chat messages live in TimescaleDB, so the sweep targets tsdb, not the main Postgres pool.)
Single-message delete ingest
Beyond bans, individual message removals made outside Lumio are ingested so the Multichat stays in sync. Every ingested deletion follows the same invariant as Lumio's own moderation: the message is never hard-deleted — it is soft-deleted (deleted_at marker) via lo_chat::soft_delete_message and additionally recorded in the affected user's moderation log (platform, target_user_id, action, target_message_id). The soft-delete's deleted_at IS NULL guard makes ingest idempotent: a duplicate delete (e.g. an EventSub echo of a Lumio-initiated delete) flips no row and is skipped, so no duplicate log entry is written.
| Platform | Single-message delete ingest | Source |
|---|---|---|
| Twitch | ✅ moderator delete | EventSub channel.chat.message_delete (twitch_eventsub.rs). The event carries the target user + message id but not the acting moderator, so the removal is attributed generically ("moderator"). |
| YouTube | ✅ moderator delete + author retract | gRPC live-chat MessageDeletedEvent / MessageRetractedEvent and the InnerTube MessageDeleted path (youtube.rs). |
| Lumio | ✅ | moderateChat / POST /v1/chat/moderate (graphql/chat.rs, routes/chat.rs). |
| Kick | ❌ — platform limitation | Kick's public webhook catalog exposes no message-delete event (only chat.message.sent, follows, subs, livestream status, moderation.banned). Bans still sweep the user's messages via moderation.banned. |
| Trovo | ❌ — platform limitation | Trovo's Open Platform delivers no moderation/delete events (webhooks only channel.subscribe / channel.spell; the chat WebSocket carries only follower/subscriber/gift/spell/chat types). |
YouTube Channel Emotes (display only)
Channel-custom emotes (:_yourchannel-purr: style) live inside InnerTube messageRuns and are not exposed by YouTube's Data API or our gRPC streamList. Lumio harvests them out-of-band:
| Component | Role |
|---|---|
apps/api/src/workers/youtube.rs (poll cycle) | The YouTube chat worker polls get_live_chat for chat + member badges and, from the same response, extracts channel-custom emotes (emojiId of form <channelId>/<emoteId>) via parse_emote_observations |
crates/lo-chat/src/emote_catalog.rs::EmoteCatalog::upsert | Upserts the observations into the durable chat_emotes table (the emote catalog) and invalidates the Redis map lumio:emotes:map:youtube:{channel_id}. Postgres is the truth; Redis (6h) and an in-process LRU (~60s) are caches in front of it. |
apps/api/src/workers/youtube.rs::process_text_chat | At chat ingest, scans displayMessage for :shortcut: patterns, resolves them through the catalog's three-tier read-through, attaches matching entries to ChatMessageInput.emotes in the same JSON shape as Twitch ingest |
apps/web/src/app/(main)/(app)/dashboard/chat/message-content.tsx | Emote renderer; per-message channel emotes take priority over the static standard-emote set (:smile:, :cry:, etc.) so a streamer's :smile: upload wins over the platform fallback |
Lumio is read-only on this — we never upload emotes/badges to YouTube and we don't host the images (URLs point at yt3.ggpht.com). Cold-start caveat: the very first occurrence of an emote on a channel may render as plain :shortcut: text until the chat worker's next InnerTube poll has recorded it. A cache expiry no longer has that effect — the mapping is durable, so a cold or flushed Redis falls through to Postgres instead of dropping enrichment.
Twitch GIFs (display only)
Twitch's GIF keyboard (Tier 2/3 subscriber feature) delivers each posted GIF as a gif fragment on the EventSub channel.chat.message payload — a gif object carrying the GIPHY url and a gif_id, with the bracketed GIF caption (e.g. [Y A Y Yes GIF by Djemilah Birnie]) in the fragment's text, not the URL. Lumio ingests this into its own per-message field:
| Component | Role |
|---|---|
apps/api/src/workers/twitch_eventsub.rs | At chat ingest, collects every gif fragment into an ordered list of {id, url, provider, alt} (id = gif_id, alt = caption). A fragment missing its gif object or url is skipped, never a panic. |
platform_chat_messages.gifs (JSONB) | The persisted per-message GIF list — its own column, deliberately never folded into emotes (which feeds frozen stream-history emote_total/top_emotes counts). NULL for every non-GIF message. |
ChatMessage.gifs (GraphQL) / ChatMessageResponse.gifs (REST) | The read field, exposed identically on both protocols; the live WebSocket chat:message payload carries it automatically via the ChatMessageInput broadcast. |
The GIF url is stored and rendered verbatim — Twitch's terms forbid rewriting, proxying, or resizing it. GIF support is Twitch-only (no YouTube/Kick/Trovo equivalent) and read-only (Lumio never sends GIFs). GIFs are intentionally not surfaced in the automation template context (like emotes and badges).
Rendering is gated by the Show GIFs display setting (General settings tab, default on, per-browser localStorage["popout-settings:chat"]). With it off the image is not rendered and nothing is fetched from GIPHY — enforced at every render surface: the Multichat feed and reply preview, and the user-info modal's message history (which has no PopoutSettings host and reads the same store via readChatShowGifs). A GIF is likewise never rendered on a moderation placeholder or a deleted row.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/chat.rs | GraphQL queries and mutations |
crates/lo-chat/src/ | Core chat crate (buffer, filter, profile service, emotes, moderation) |
apps/api/src/services/emotes.rs | Emote fetching from Twitch, 7TV, BTTV, FFZ, and Trovo |
apps/api/src/services/moderation.rs | Per-platform Twitch/YouTube/Kick moderation HTTP calls invoked from moderateChat and POST /v1/chat/moderate |
apps/api/src/workers/chat_retention.rs | Config-gated background sweep that enforces per-plan chat_retention_days by deleting expired platform_chat_messages |
crates/lo-chat/src/emote_catalog.rs | Durable chat_emotes catalog + three-tier (LRU → Redis → Postgres) read-through — written and read by the YouTube chat worker |
crates/lo-chat/src/youtube_emote_cache.rs | YouTube :shortcut: tokenizer and message enrichment (storage-independent) |
apps/api/src/workers/youtube.rs | The single YouTube chat worker: harvests member-tier badges and channel-custom emotes inline from its InnerTube poll cycle and enriches chat messages |
apps/api/src/state.rs | PendingChatAccounts shared state |
shared/chat-filters/src/ | Shared hide/highlight and keyword-tab matcher used by dashboard, popout, and tests |
apps/web/src/hooks/use-multichat.ts | Browser hook for live chat, history paging, keyword-search back-fill, token fetches, and per-tab paging state |
apps/web/src/lib/chat-display.ts | Pure display filter for platform/broadcast scoping, deleted-message hiding, keyword-tab views, and hidden-message removal (default) or placeholders |
apps/web/src/lib/chat-persistence.ts | Defensive localStorage persistence for chat filters, keyword tabs, and active platform selection (exports the Pattern-B readScoped/writeScoped helpers reused by the alert box) |
apps/web/src/lib/chat-alerts-reducer.ts | Pure applyAlertEvent reducer + live-state shapes for the five alert cards; folds live WS events and replayed history identically |
apps/web/src/lib/chat-alert-persistence.ts | Alert-box persistence: types, defensive parsing, reconciliation, the GET /api/events server replay (build/fetch/fold), the Layer-A/Layer-B merge, and the cross-tab dismiss registry |
apps/web/src/app/(main)/(app)/dashboard/chat/chat-alerts.tsx | The alert-box component: renders the five cards and wires the load/save, replay, and cross-tab dismiss effects |
apps/web/src/components/keyword-tab-dialog.tsx | Keyword-rule editor for tab visibility, platform scope, match mode, color, and notification sound |
apps/web/src/components/popout-settings.tsx | General/Filter settings tabs for display toggles, deleted-message visibility, hidden-message style, notification volume, and keyword-rule management |