Emotes
Overview
The emotes module fetches, caches, and parses chat emotes from multiple providers across platforms. It supports both channel-specific emotes (fetched per platform + channel ID) and user-specific emotes (Twitch subscriber emotes for the authenticated user). Emotes are used in chat message rendering and overlay alerts.
Architecture
Dashboard UI / Overlay
|
v
Next.js API Proxy (/api/emotes)
|
v
REST API (GET /v1/emotes/{platform}/{channel_id}, GET /v1/emotes/user)
GraphQL (emotes(platform, channelId), userEmotes)
|
v
services::emotes::resolve_channel_emotes (one shared code path)
|
+--> Redis hot-cache check
| Hit? --> Return cached EmoteSet[]
|
+--> Fetch from provider APIs (Twitch, 7TV, FFZ, BTTV, Trovo)
| success --> upsert rows into the durable chat_emotes catalog
| (invalidates the catalog map key; never fails the request)
|
+--> Assemble response
| any provider returned emotes? --> use the fetched sets
| total upstream outage? --> fall back to the chat_emotes catalog
| (a failed fetch never empties the map)
|
+--> Warm the Redis hot cache
|
v
EmoteSet[] response
The channel-emote path (emotes / GET /v1/emotes/{platform}/{channel_id}) is a
single function shared by REST and GraphQL, so both protocols return the same
set, the same fields and the same errors and both read/write the one Redis
hot-cache key — neither can poison the other (ZAF-577).
Emote Providers
| Provider | Enum Value | Platforms | Description |
|---|---|---|---|
| Twitch | twitch | Twitch | Native Twitch emotes (global + channel) |
| 7TV | 7tv | Twitch, YouTube, Kick | Third-party emote service |
| FrankerFaceZ | ffz | Twitch, YouTube | Third-party emote extension |
| BetterTTV | bttv | Twitch, YouTube, Kick | Third-party emote extension |
| YouTube | youtube | YouTube | YouTube native chat emojis (~100 standard shortcodes such as :yt:, :hand-pink-waving:) |
| Kick | kick | Kick | Kick platform emotes |
| Trovo | trovo | Trovo | Trovo platform emotes |
Per-Platform Fetch Strategy
| Platform | Providers Fetched |
|---|---|
| Twitch | Twitch (native channel and global, fetched as two separately-scoped sets; needs the twitch system credential's client_id and client_secret), 7TV (channel), FFZ (channel), BTTV (channel), FFZ (global) |
| YouTube | YouTube (native standard emojis), 7TV (channel), BTTV (channel), FFZ (channel + global) |
| Kick | 7TV (channel), BTTV (channel), FFZ (global). FFZ has no Kick channel support, so only its global set is fetched. |
| Trovo | Trovo (native channel + global + event; needs the trovo system credential's client_id), 7TV (global), BTTV (global), FFZ (global) |
Empty sets are filtered out -- only providers with at least one emote are included in the response. Any other platform value returns an empty set list.
The Twitch app access token
Helix GET /chat/emotes and GET /chat/emotes/global require a real bearer
token even though the data is public, so the native Twitch sets are fetched with
an app access token minted from the twitch system credential via the
OAuth2 client-credentials grant (api::oauth::get_app_access_token). The token
is app-scoped, not user-scoped — one Redis key per platform
(lumio:oauth:app_token:{platform}), cached for the grant's lifetime minus a
5-minute safety margin and capped at 24h so a rotated or revoked credential
cannot linger for the full ~60 days Twitch hands out.
If the system credential is missing, carries no client_secret, or the grant
fails, the two Twitch fetches are skipped entirely and the rest of the
providers are unaffected. Skipping is deliberate: sending an empty bearer would
cost two round-trips per cache miss to be told 401 and still yield nothing.
Every successful fetch is upserted into the durable chat_emotes catalog
(source = 'api_fetch'; provider is who hosts the emote, platform is the
chat platform it is usable on, channel_id is NULL for a provider's global
set). If every third-party fetch fails — a total upstream outage — the
response is served from the catalog instead of collapsing to an empty set, so a
provider outage can no longer wipe a channel's emote map. The YouTube static
standard-emoji set is exempt: it is always present and is deliberately not
counted when deciding whether to fall back. All catalog reads and writes are
fail-open: a Postgres or Redis error is logged and degrades, it never fails the
request. See the Emote Catalog developer guide.
Provider Channel-Lookup URLs
| Provider | Twitch | YouTube |
|---|---|---|
| 7TV | https://7tv.io/v3/users/twitch/{channel_id} | https://7tv.io/v3/users/youtube/{channel_id} |
| BTTV | https://api.betterttv.net/3/cached/users/twitch/{channel_id} | https://api.betterttv.net/3/cached/users/youtube/{channel_id} |
| FFZ | https://api.frankerfacez.com/v1/room/id/{channel_id} | https://api.frankerfacez.com/v1/room/yt/{channel_id} |
Note on FFZ: while 7TV/BTTV use a /users/... URL pattern, FFZ uses /room/.... Both are equivalent in semantics — they return the channel-active emote set. FFZ's /user/... endpoint returns user-profile data (uploaded sets, badges) and is not what the chat renders.
API
REST Endpoints
| Method | Path | Description | Auth |
|---|---|---|---|
GET | /v1/emotes/{platform}/{channel_id} | Fetch channel emotes | Authenticated (no RBAC permission) |
GET | /v1/emotes/user | Fetch user's Twitch subscriber emotes | Authenticated + active account |
GraphQL
The same data is exposed as two queries in the chat schema, both guarded by
feature:multichat + chat:read:
| Query | Returns | Permission |
|---|---|---|
emotes(platform: String!, channelId: String!) | GqlEmoteSetsResponse! | feature:multichat + chat:read |
userEmotes | GqlEmoteSetsResponse! | feature:multichat + chat:read |
Both queries resolve through the same resolve_channel_emotes function, so
the returned sets, fields and errors are identical on the two protocols. The one
deliberate asymmetry is the guard: the GraphQL surface carries the chat feature
flag and the chat:read permission, while the REST surface only requires an
authenticated principal — emote catalogues from 7TV/BTTV/FFZ are public data and
the REST route is what the overlay renderer calls.
GET /v1/emotes/{platform}/{channel_id}
Fetches all available emote sets for a given platform and channel. Results are cached in Redis.
Response:
{
"data": {
"sets": [
{
"provider": "7tv",
"emotes": [
{
"id": "60ae958e...",
"name": "LULW",
"url": "https://cdn.7tv.app/emote/.../1x.webp",
"provider": "7tv",
"animated": false
}
]
}
],
"owners": null
}
}
GET /v1/emotes/user
Fetches all Twitch emotes available to the authenticated user (subscriber emotes from all channels). Requires a Twitch login connection with user:read:emotes scope. Automatically refreshes the token if expired.
Also resolves emote owner information (channel names and avatars) via batch Twitch user lookup.
Response includes:
sets-- Array ofEmoteSetwith the user's available emotesowners-- Map of owner IDs to{ displayName, profileImageUrl }for grouping emotes by channel
Types
EmoteSet
struct EmoteSet {
provider: EmoteProvider, // twitch, 7tv, ffz, bttv, youtube, kick, trovo
emotes: Vec<Emote>,
}
Emote
struct Emote {
id: String, // Provider-specific emote ID
name: String, // Emote name (e.g., "Kappa", "LULW")
url: String, // CDN URL for the emote image
provider: EmoteProvider, // Source provider
animated: bool, // Whether the emote is animated (GIF/WEBP)
owner_id: Option<String>, // Owner user/channel ID (Twitch-specific)
}
Message Parsing
The parse_emotes() function splits a chat message into segments of text and emotes:
fn parse_emotes(message: &str, emote_sets: &[EmoteSet]) -> Vec<EmotePart>
Behavior:
- Builds a name-to-emote lookup from all provided sets
- Splits message on whitespace boundaries
- Exact, case-sensitive matching only (
Kappamatches,kappadoes not,KappaRossdoes not) - Consecutive text words are merged into a single
EmotePart::Text - Returns
Vec<EmotePart>where each part is eitherText { text }orEmote { id, name, url, provider }
A sibling helper, enrich_message_emotes_from_sets(message, sets), applies the
same word-based matching but returns the standard chat emotes JSON array
([{id,name,url,provider,animated}], deduped by name) or None. The Trovo chat
worker uses it to enrich incoming messages from the durable catalog (ZAF-578)
instead of storing no emotes. The Kick webhook path normalises Kick's raw
[{emote_id, positions:[{s,e}]}] array into the same shape, deriving each name
from content[s..e] and filling the url best-effort from the catalog.
YouTube Standard Emojis
YouTube Live Chat ships with ~100 platform-native standard emojis (e.g. :yt:, :hand-pink-waving:, :dothefive:). YouTube does not expose an API for the catalogue, so the list is curated manually in crates/lo-chat/src/youtube_emotes.rs and embedded in the REST/GraphQL response for platform = "youtube" as an additional EmoteSet with provider = "youtube". The set is pushed first in the response so the frontend's first-write-wins emote map gives YouTube native emojis priority over any 7TV/BTTV/FFZ entry that happens to share a shortcode.
Sending: YouTube Live Chat renders :shortcode: tokens server-side, so chat messages sent through Lumio with YouTube shortcodes are forwarded as plain text in messageText and YouTube turns them into emoji images on delivery. No client-side conversion is required.
Rendering: Incoming YouTube messages keep the raw shortcode in message_text. The MessageContent renderer (apps/web/src/app/(main)/(app)/dashboard/chat/message-content.tsx) and the lighter EmoteText component split each whitespace-token by the shortcode regex :[a-zA-Z0-9_-]+: and replace runs of back-to-back shortcodes (e.g. :hand-pink-waving::hand-pink-waving:) with separate <img> elements separated by visible spaces.
URL freshness (ZAF-578): each stored message freezes its emotes' CDN URLs at write time, so a rotated URL (e.g. a dead yt3.ggpht.com link) would render broken forever. When a name resolves in both the per-message array and the live channel-emote map, the renderer prefers the channel-map URL for the image (keeping the per-message entry's identity), and falls back to the frozen URL only when the catalog has no row. A name with no usable URL on either side — a Kick emote stored with a name but no URL — renders as plain text rather than a broken image.
Adding new emojis: Append a (shortcode, cdn_url) tuple to YOUTUBE_STANDARD_EMOTES in crates/lo-chat/src/youtube_emotes.rs and rebuild the API binary.
Caching and durable storage
Emote definitions are persisted in the emote catalog — the chat_emotes table
in the primary PostgreSQL — with Redis and an in-process LRU acting as caches in
front of it:
LruCache (in-process, ~60s) -> Redis (6h) -> Postgres chat_emotes
A cache expiry therefore only empties a cache; the shortcode → URL mapping itself survives. See the Emote Catalog developer guide for the tier semantics, the schema, the invalidation rule, and how to add a writer.
The channel-emote fetch path is both a reader and a writer of the catalog.
It still keeps its assembled provider sets in a Redis hot cache per platform +
channel ID (the lumio:emotes:{platform}:{channel_id} key used by
emote_service::get_cached_emotes / cache_emotes), checked before any external
API call. On a cache miss it fetches from the providers, upserts every success
into chat_emotes (source = 'api_fetch', via
emote_catalog::upsert_and_invalidate), and — when every third-party fetch fails
— reads the durable catalog back through emote_catalog::load_channel_emote_sets
so an outage degrades to the last known state instead of an empty map.
User emotes are cached separately under twitch-user:user:{user_id} with owner data cached under lumio:emotes:owners:{user_id} (1 hour TTL).
Key Files
| File | Purpose |
|---|---|
apps/api/src/routes/emotes.rs | REST endpoints for channel and user emotes |
apps/api/src/services/emotes.rs | Provider fetch functions, owner resolution, and the shared resolve_channel_emotes orchestrator (hot cache → fetch → catalog upsert → outage fallback) used by both REST and GraphQL |
crates/lo-chat/src/emote_catalog.rs | Durable chat_emotes catalog + three-tier read-through; upsert_and_invalidate (API-fetch writer) and load_channel_emote_sets (outage fallback read) |
crates/lo-chat/src/emotes.rs | Core types (Emote, EmoteSet, EmoteProvider, EmotePart) and parse_emotes() |
crates/lo-chat/src/youtube_emotes.rs | Static catalogue of YouTube native standard-emoji shortcodes |
apps/web/src/app/(main)/(app)/dashboard/chat/message-content.tsx | Multichat renderer with shortcode-splitting + colon-fallback |
apps/web/src/app/(main)/(app)/dashboard/chat/emote-library.tsx | Emote picker with platform tabs and the youtube-global section |