Chat
Overview
The Chat module provides a unified, multi-platform chat system that aggregates messages from Twitch, YouTube, Kick, Trovo, and Discord 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.
- The Multichat UI renders messages with emotes, badges, colors, and reply threading.
- User info modal fetches a unified profile combining DB data with live platform API enrichment.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
chatHistory(filter: ChatFilterInput) | chat:read | Paginated chat message history with filters for platform, user, date range, 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 |
emotes(platform, channelId) | chat:read | Emote sets for a platform/channel (Twitch, 7TV, BTTV, FFZ, Trovo, Discord) |
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. With multiple concurrent broadcasts 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. Kick supports delete only; Trovo supports ban only (platform API limitations). |
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) |
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, 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/{platform}/{platform_user_id} | chat:userinfo | Unified user profile (DB + enrichment) |
GET | /v1/chat/users/{platform}/{platform_user_id}/follow-status | chat:userinfo | Follow relationship for the user |
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) |
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 |
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), 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. |
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 writes buffered messages from Redis to TimescaleDB in batches.
- Frontend receives the message via WebSocket and renders it with emotes and badges.
Moderation Permission Matrix
Lumios getChatPermissions (in apps/web/src/app/(app)/dashboard/chat/multichat.tsx) hides the moderation-dropdown buttons whenever the platform's API would refuse the request. The matrix below mirrors what each platform actually accepts:
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 — same lenient pattern as Twitch's broadcaster ↔ mod tier (broadcaster can act against mods, mods cannot act against staff). Actual platform-side restrictions are not yet documented for us; kept lenient until we confirm.
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 Twitch ban/timeout events (e.g. issued from Twitch's own UI) flow through the same code path: apps/api/src/workers/twitch_eventsub.rs reacts to channel.ban notifications by performing the identical sweep + broadcast.
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_innertube_observer.rs | Already polls get_live_chat for member badges; now also extracts channel-custom emotes (emojiId of form <channelId>/<emoteId>) via parse_emote_observations |
crates/lo-chat/src/youtube_emote_cache.rs::merge_channel_emotes | Writes lumio:yt:channel_emotes:{channel_id} — JSON map :shortcut: → { id, url }, TTL identical to the badge cache (cache_ttl_seconds, default 14d). The Redis-key namespace aligns with the existing lumio:yt:tier_badges:* / lumio:yt:member:* family. |
apps/api/src/workers/youtube.rs::process_text_chat | At chat ingest, scans displayMessage for :shortcut: patterns, looks up the cache, attaches matching entries to ChatMessageInput.emotes in the same JSON shape as Twitch ingest |
apps/web/src/app/(app)/dashboard/chat/message-content.tsx | Existing 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-cache caveat: the very first occurrence of an emote on a channel may render as plain :shortcut: text until the InnerTube observer's next poll has populated the cache.
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, Trovo, Discord |
apps/api/src/services/moderation.rs | Per-platform Twitch/YouTube/Kick moderation HTTP calls invoked from moderateChat and POST /v1/chat/moderate |
crates/lo-chat/src/youtube_emote_cache.rs | Channel-custom emote cache (lumio:yt:channel_emotes:*) — read by the gRPC chat worker, written by the InnerTube observer |
apps/api/src/workers/youtube_innertube_observer.rs | Harvests both member-tier observations and channel-emote observations from one InnerTube poll cycle |
apps/api/src/state.rs | PendingChatAccounts shared state |