chat-aggregator

Twitch and YouTube live chat in one overlay for your stream.

View the Project on GitHub iSneeze/chat-aggregator

Architecture

How chat-aggregator is put together, and why it’s built this way. For building and running, see the README; for the current state and next steps, ROADMAP.md.

The big picture

chat-aggregator reads live chat from several platforms, turns every message into one common format, and hands it to whoever wants it: the OBS overlay, other programs through a JSON API, and the desktop app’s status display.

 Twitch (IRC) ──┐
 YouTube (gRPC)─┤                          ┌─ SSE /events ──────→ overlay page in OBS
 Demo ──────────┼─→ forwarder ─→  Hub  ────┤
 Test window ───┘   (counts)     (fan-out  └─ WebSocket /api/v1/ws → games, bots
                                  + history)
        ▲                                    ▲
        │ start / stop / restart             │ theme, pacing, API on/off, direction
 ┌──────┴─────────── engine (actor) ─────────┴──┐
 │  EngineHandle commands in, watch<Status> out │ ←── desktop app (GPUI) or headless `run`
 └──────────────────────────────────────────────┘

Two planes, kept apart on purpose:

Everything runs on the streamer’s own computer. There is no server of ours: the overlay and the API are served on 127.0.0.1 only.

Crates

chat-app ──→ chat-engine ──→ chat-server ──→ chat-render ──→ chat-core
                         ├─→ chat-twitch ─────────────────→ chat-core
                         └─→ chat-youtube ────────────────→ chat-core
crate responsibility
chat-core the shared vocabulary: ChatEvent, ChatMessage, MessageKind, the ChatSource trait, the Hub, activity reports, demo data
chat-twitch Twitch chat over IRC (anonymous, read-only), converted to ChatEvents
chat-youtube YouTube live chat: finding the broadcast (REST), reading it (gRPC), OAuth login, quota handling, custom emoji
chat-render one message → HTML, through a theme (template + CSS); named theme folders
chat-server HTTP: overlay page, theme files, SSE stream, JSON WebSocket API, burst pacing
chat-engine owns everything at runtime: the control-plane actor, source restarts, status, config file
chat-app the desktop app (GPUI): windows, settings, login, test messages
chat-rplay placeholder for a future source

Why so many crates? Each one has one job and only knows what it must. chat-server never sees Twitch or YouTube, only ChatEvents; the platform crates never see HTML. That keeps each piece testable on its own, lets the compiler enforce the boundaries (a crate can only use what it depends on), and makes adding a platform a matter of adding a crate. Separate crates also compile in parallel and only rebuild when they change.

chat-engine knows nothing about GUIs. The same engine runs headless from a config file (cargo run -p chat-engine --example run) and inside the app. chat-app is the only crate that depends on GPUI, and it’s left out of the workspace’s default members because GPUI adds hundreds of dependencies.

Life of a chat message

Follow one Twitch message from a viewer’s keyboard to OBS:

  1. Source. chat-twitch receives an IRC PRIVMSG with tags (display name, colour, badges, emote positions) and converts it into a ChatEvent::Message(ChatMessage { … }). Emote positions become EmoteRef { code, url }s; the URL is built from Twitch’s emote id.
  2. Channel. The source sends it into an mpsc channel (many producers, one consumer) that the engine created for this source.
  3. Forwarder. A small task per source reads the channel, counts the message for the status display (“12 msgs”) and publishes it to the hub.
  4. Hub. Hub::publish records it in the replay history and sends it on a broadcast channel: every subscriber gets its own copy.
  5. Overlay stream. Each connected overlay has an SSE stream subscribed to the hub. The message goes through the pacer (bursts are spread out), is rendered to HTML by the theme’s template, and is sent as a named chat event.
  6. Overlay page. The page’s small script inserts the HTML into the chat list and removes the oldest messages beyond --max-messages.
  7. JSON API (if switched on): the same event, as JSON, to every WebSocket client. No pacing, no HTML.

Moderation (a deleted message, a banned user, a cleared chat) takes the same path as ChatEvent::Delete, ClearUser or ClearAll: the hub removes the messages from its history, and overlays remove them from the page.

The data model (chat-core)

ChatEvent
├── Message(ChatMessage)        id, platform, author, text, emotes, timestamp, kind
├── Delete { platform, message_id }
├── ClearUser { platform, user_id }
└── ClearAll { platform }

MessageKind: Text | EmoteOnly | Donation { amount, tier } | Special { image_url, amount, info, tier }
           | MembershipJoin { info, months } | MembershipGift { count } | SystemNotice { info }

Every platform is normalised into these types. Their JSON form (defined by serde attributes, e.g. {"type": "message", …}) is the public JSON API, so it’s treated as a contract: a test pins the format, a JSON Schema is generated from the types (docs/schema/chat-event.json, checked by a test), and only additive changes are allowed within v1. Missing values are null rather than omitted, so typed languages get a stable shape.

Amounts stay display strings ("€5.00", "100 bits"): platforms format them in the viewer’s currency, and the overlay only shows them. How big an amount is comes as a tier on the platform’s own scale instead: YouTube reports one with every Super Chat (so no exchange rates on our side), and Twitch bits are sorted into Twitch’s cheer steps. Themes colour paid messages by it.

Sources

pub trait ChatSource {
    fn run(
        self,
        tx: mpsc::Sender<ChatEvent>,
        activity: Reporter,
    ) -> impl Future<Output = anyhow::Result<()>> + Send;
}

(Implementations just write async fn run(…). The trait spells out impl Future + Send because the engine spawns each source on tokio’s worker threads, and only Send futures may move between threads; a plain async fn in a trait can’t promise that.)

The engine picks the concrete source from an enum (SourceSpec), not a trait object (Box<dyn ChatSource>). There are few source types, all known at compile time, and a trait whose method returns impl Future can’t be used as a trait object anyway; an enum is simpler and costs nothing at runtime.

Twitch uses the twitch-irc crate with an anonymous login: public chat needs no account. Gift subs sent to many viewers at once arrive as one summary plus one notice per recipient; the per-recipient ones are skipped so the total isn’t counted twice.

YouTube is the most involved source, because of how the API is billed:

Demo sends sample messages of every kind, for styling themes without a live chat. Manual is fed by the app’s test window; it exists exactly as long as that window (see below).

The hub (chat-core)

The hub fans events out to any number of consumers and keeps the last N messages (20 by default) so a consumer that connects late, like OBS reloading the overlay, doesn’t start from an empty chat.

Rendering and themes (chat-render)

A theme is a message template (message.html, MiniJinja) plus a stylesheet (overlay.css). The built-in themes (Builtin: Default and Minimal) are compiled into the binary; a theme folder overrides any file of Default it has. Named themes live in themes/<name>/ next to the config file. Which one the overlay uses is a ThemeSource: a built-in theme or a folder (their names can’t clash: folder names that are built-in names are refused).

The server (chat-server)

Built on axum. All routes share a ServerState, cloned per request (cheap: every field is an Arc or a small handle around one).

route what
/ the overlay page (an OBS Browser Source)
/theme/overlay.css, /theme/… the theme’s CSS and files (images, fonts)
/themes/<name>/… the same for a theme picked in the overlay URL (/?theme=<name>)
/events Server-Sent Events: history, then live chat as rendered HTML
/api/v1/ws WebSocket: live events as JSON (off by default)

The engine (chat-engine)

The actor

The control plane is an actor: one task owns all source state (which sources exist, their settings, their running task, retry timers), and everyone else talks to it through messages.

EngineHandle ──Command + oneshot reply──→ actor task ──→ watch<Status> ──→ app, headless
 (cheap clone)                              │
                                            ├─ starts/stops source tasks
                                            └─ receives "ended", "retry due", "activity changed"

Settings

The engine owns the settings (Settings: everything in config.toml except the sources). EngineHandle::settings() is a watch of them, so every window reads the current ones instead of keeping a copy.

Restarts and health

Sources are built by a factory

The actor is generic over a SourceFactory: production builds real Twitch, YouTube and demo sources; tests use scripted ones (“fails twice, then runs”). This is static dispatch: the compiler creates one actor per factory type, and the production code carries no test hooks.

The config file

config.toml in the per-OS settings folder (~/.config/chat-aggregator/ on Linux) with [server], [youtube], [app] and [[sources]]:

The desktop app (chat-app)

Two runtimes side by side

They only talk through tokio::sync channels (EngineHandle commands, the watch status), which can be awaited from either side: awaiting only needs a waker, not the tokio runtime. So a GPUI task can await engine.add_source(…) directly. Work that needs tokio itself (the YouTube login’s local server, the keyring calls) the engine spawns on its own runtime (EngineHandle::youtube_login and friends), so the windows never need a tokio handle. The app reaches the platform crates only through chat-engine, which re-exports what it needs (e.g. EmojiMap).

Views and shared state

Security model

The threat that matters most is chat itself: anyone can type anything. OBS runs browser sources without Chromium’s sandbox, so script injected into an overlay can be one browser bug away from the streamer’s computer (this happened to another overlay: CVE-2024-7971).

  1. Chat never becomes markup. It reaches HTML only through the auto-escaping template; the body is pre-split into text and emote parts; author colours must be plain #hex; ids go through CSS.escape in the page’s selectors. A test feeds classic attack strings into every field of every message kind and checks the markup doesn’t change.
  2. The overlay only runs its own script. The page sends a Content-Security-Policy whose script-src is the SHA-256 hash of its inline script (computed from the served text, so they can’t drift apart). Injected handlers like onerror=, foreign scripts, frames and plugins are blocked, even from a hostile theme. Theme files get nosniff and script-src 'none'.
  3. Localhost only. The server binds 127.0.0.1: other machines can’t reach it. Web pages in the streamer’s browser still could, so (chat-server/src/local.rs): every request whose Host isn’t 127.0.0.1/localhost/[::1] is refused (DNS rebinding), and the JSON API only accepts clients without an Origin (programs), with a localhost origin, OBS’s local-file origin http://absolute, or a web site the streamer allowed (approvals.rs); not null, which any website can send from a sandboxed frame. An unknown site’s opening request waits while the app asks (its question is in Status::api_requests, the answer goes back through EngineHandle::answer_api_request), so “Allow” lets the same connection through. One question per site, at most 3 open, “Deny” until restart, 2 minutes to answer; an RAII guard withdraws a question when its last waiting connection is gone. Headless mode doesn’t ask. The API is also off by default and closes its clients when switched off.
  4. No reading outside the theme. /theme/… accepts only plain path segments, so ../config.toml (with the client secret) can’t be fetched.
  5. Secrets: the refresh token in the OS keyring, the config file 0600, written atomically (chat_core::fs::write_private: temp file, sync, rename, so a crash never leaves it half-written), redacted debug output, API keys sent as headers (never in URLs, which end up in logs).

Testing

Rust idioms you’ll meet in the code