Twitch and YouTube live chat in one overlay for your stream.
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.
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.
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.
Follow one Twitch message from a viewer’s keyboard to OBS:
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.mpsc channel (many producers,
one consumer) that the engine created for this source.Hub::publish records it in the replay history and sends it on
a broadcast channel: every subscriber gets its own copy.chat event.--max-messages.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.
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.
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.)
self by value: a source is consumed by the task that runs it. A
restart builds a fresh one from its settings (SourceConfig), so no half-
used state carries over.Ok(()) means the stream ended normally (the broadcast is over),
Err means it failed and the engine may retry.activity lets the source say what it’s doing (connecting, receiving,
idle, degraded, blocked until the quota resets), because only the source
knows: a YouTube source waiting for its quota is “running” but delivers
nothing for hours. This drives the status lights.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:
liveBroadcasts.list?mine=true for your
own broadcasts, videos.list for a given video): 1 unit per call. While
nothing is live, the source polls this cheaply (“scan mode”) instead of
holding an expensive chat connection open: every 2 min with nothing
coming up, every 30 s from 5 min before a scheduled start. A scheduled
broadcast more than 3 h late counts as stale (a “free chat” waiting
room, a forgotten draft) and isn’t waited for; otherwise it would look
like “starting any minute” forever.streamList call. Google currently
closes it every ~10 seconds (a bug on their side); the source reconnects
with the last page_token, so nothing is lost. Each reconnect costs 5
units, which is why a day’s quota lasts roughly 5–6 streaming hours.
While connections end without delivering anything (a quiet chat), the
wait before the next one grows from 2 s to at most 20 s, which saves
quota; it drops back to 2 s once messages flow. A closed chat
(FailedPrecondition) is the normal end of your broadcast: back to scan
mode, not an error. A connection that dies without a word (Wi-Fi switch, laptop sleep) would
leave that read waiting forever, so a stream that stays completely silent
for 60 s counts as dead: the source opens a fresh connection and resumes
with the same page_token.127.0.0.1 (with PKCE, so
an intercepted code is useless). The long-lived refresh token goes into
the OS keyring (a file only the user can read if there’s none);
TokenProvider hands out access tokens and renews them before they
expire. The app asks the engine for all of it (youtube_account,
youtube_login, youtube_logout): the engine has the client in its
settings, runs the login on its own tokio runtime and restarts the
YouTube sources afterwards. Cancelling a login is dropping it
(YouTubeLogin), which stops the local server.:codes:. A userscript exports the
channel’s codes and image URLs from YouTube’s emoji picker; the source
maps codes to images with it.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).
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.
tokio::sync::broadcast channel carries live events. A consumer that
falls more than 256 events behind gets a “lagged” notice and skips ahead,
instead of slowing everyone down.VecDeque behind a std::sync::Mutex. publish records
and sends while holding the lock, and subscribe takes the snapshot and
subscribes under the same lock. So every event is either in the snapshot
or arrives live: never both (a duplicate) and never neither (a gap)..await.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).
ThemeSource::load gives the template as a Theme, ThemeSource::css
the stylesheet), so a broken one never takes the other down: a template
with a mistake falls back to the built-in one (logged), while the
theme’s CSS still applies.MessageView), not ChatMessage
itself, so the core types can evolve without breaking people’s templates.
It borrows from the message instead of copying (&'a str): it only lives
for one render call.<img> tags and chat text never reaches the
page as HTML (see Security).seed filter turns a value (usually
author.id) into a stable number for the template, e.g. a hue or one of
six shapes. It’s our own fixed hash (FNV-1a plus MurmurHash3’s
finalizer), not Rust’s standard hasher, which is randomised per process
and may change between versions: themes rely on a chatter’s look never
changing. A test pins the results.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) |
stagger.rs) spreads bursts (YouTube’s reconnects deliver
several messages at once) a few hundred milliseconds apart, but never
delays a message by more than the configured maximum (≤ 5 s). Moderation
is never delayed, and it removes matching messages still waiting. The API
and the history replay are never paced.tokio::sync::watch channels: one value, readable any
time, plus a notification when it changes. A theme change makes every
overlay stream send a reload event; switching the API off closes the
connected clients. The engine sets them when the settings change (see
Settings).newest = bottom or top) is written into
the page as data-newest when it loads; ?newest= in the overlay URL
overrides it, so each OBS scene can have its own. The page flips the list
with column-reverse (the page order stays oldest-first, so inserting and
trimming don’t change) and pins the view to the newest message after every
insert, with the browser’s scroll anchoring switched off.?theme=<name> in the overlay URL pins
that overlay to a theme from the themes folder or a built-in one,
whatever the app’s theme. The page links /themes/<name>/overlay.css
(so url(bg.png) in it finds the theme’s own files) and passes its query
on to /events, which renders with that theme’s template. Only valid
theme names of existing folders count (so no ..); anything else gets
the app’s theme, logged. The server knows the themes folder from
startup; it’s the only piece of the config it needs for this.Drop counts down again, so the count is right however the connection
ends (see Rust idioms).chat-engine)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"
Mutex<State> locked by
every caller, is easy to get wrong across .awaits.EngineHandle is a cheap-to-clone front: each method sends a
Command with a oneshot channel (a channel used exactly once) and awaits
the answer. The app, headless mode and tests each hold one.watch<Status>: the status (every source’s state, health, message
count, connected overlays and API clients) is published after every
change. A watch always holds the latest value, so a slow reader just
sees the newest state instead of a backlog.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.
edit_settings(|s| s.server.newest = Top).
The actor runs it on the current settings, so two windows changing
different things at the same moment both get their change in. Sending
whole new settings instead would let a window undo another one’s change
with an older copy.Controls::apply in
settings.rs): a new replay history goes to the hub, pacing, the JSON API
switch, the chat direction and the theme go to the server’s watch
channels (a new direction or theme makes the overlays reload once), the
allowed sites to the API’s gate, new YouTube settings restart the
YouTube sources, and a new port waits for the next start.emojis as it was, so
use_youtube_emojis restarts the YouTube sources anyway; a login made
or removed does the same internally. (Edited theme files have “Reload
overlays”.)Box<dyn FnOnce(&mut Settings) + Send>: a trait object, because here
(unlike the sources) the set of types isn’t known in advance.config.toml after every change to the settings
or the sources (EngineConfig::save_to, the app’s file; headless mode
has none). A failed save goes into the status (save_error), and the
main window says so.edit_settings sends
its command right away, not when its future is first awaited, and the
actor handles the commands already waiting before it stops. The app
starts the engine’s shutdown only after GPUI has closed the windows, so
the settings window’s pending edit (applied when it closes) is saved.SetupError such as an invalid channel
name, LoginRequired) are not retried: the source waits in
NeedsAttention until the settings change.chat-engine, so the
app and headless mode always agree.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.
config.toml in the per-OS settings folder (~/.config/chat-aggregator/ on
Linux) with [server], [youtube], [app] and [[sources]]:
deny_unknown_fields everywhere: a typo like vidoe_id is an error
naming the line, instead of being silently ignored (which would turn
“this video” into “your own broadcast”).enabled = false, only written
when off).theme is a built-in theme’s name, a folder name in themes/, or
the full path of a theme folder anywhere.chat-app)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).
Entity<AppConfig>: the windows’ read-only view of the settings,
shared by all windows. A GPUI entity is shared, reference-counted state
(like Rc<RefCell<…>>, but GPUI hands out access and tracks changes). It
keeps no copy: it reads the engine’s (settings()), so no window works
on a stale one, and a small task notifies its observers whenever they
change, so they redraw. Windows change settings with
app_config::edit_settings and sources with the engine’s commands; the
engine saves.Task cancels it. Closing the window applies
what’s pending. Switches (appearance, JSON API) apply at once.windows_subsystem = "windows") and to chat-aggregator.log in the
settings folder (the previous start’s kept as chat-aggregator.old.log,
so restarting after a crash doesn’t erase it). A panic hook and the
error main returns are logged too: the file is what a tester sends.Theme: System follows the OS
light/dark mode; Light, Dark and High contrast (our own theme file) stay
put.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).
#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.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'.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./theme/… accepts only plain path
segments, so ../config.toml (with the client secret) can’t be fetched.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).127.0.0.1:0,
so the OS picks a free port and tests run in parallel.#[tokio::test(start_paused = true)]): “the 3rd retry
after backoff” takes no real time and is exact.#[gpui_kit::test]): real windows rendered
invisibly, driven by simulated clicks and typing.mpsc (many senders, one receiver: sources →
forwarder, commands → actor), broadcast (one sender, every receiver gets
a copy: the hub), watch (the latest value plus change notifications:
status and server settings), oneshot (one reply to one command)..await. A guard held while a task is paused
blocks everyone else and makes the future non-Send. The code copies
values out first (*watch.borrow() for Copy types), limits guards to a
block, or moves the waiting into a small async fn so the guard is gone
before the next .await.Drop undoes something: connection
counters, AbortOnDropHandle (dropping it stops the task), GPUI
Subscriptions and Tasks. Cleanup can’t be forgotten, whatever path the
code takes.WeakEntity) in callbacks so they don’t keep a closed window’s view
alive.SourceSpec): static dispatch, no allocation, no
object-safety limits. And a trait object where it isn’t: a settings
change is a Box<dyn FnOnce(&mut Settings) + Send>, since every closure
has its own type.Arc where several owners share read-mostly data across threads
(the hub, the server state).LazyLock for values computed once on first use (the overlay’s CSP
header).impl Trait + use<>: says a returned value borrows
nothing, so UI rows can be built in a loop.