Skip to main content

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

ProviderEnum ValuePlatformsDescription
TwitchtwitchTwitchNative Twitch emotes (global + channel)
7TV7tvTwitch, YouTube, KickThird-party emote service
FrankerFaceZffzTwitch, YouTubeThird-party emote extension
BetterTTVbttvTwitch, YouTube, KickThird-party emote extension
YouTubeyoutubeYouTubeYouTube native chat emojis (~100 standard shortcodes such as :yt:, :hand-pink-waving:)
KickkickKickKick platform emotes
TrovotrovoTrovoTrovo platform emotes

Per-Platform Fetch Strategy

PlatformProviders Fetched
TwitchTwitch (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)
YouTubeYouTube (native standard emojis), 7TV (channel), BTTV (channel), FFZ (channel + global)
Kick7TV (channel), BTTV (channel), FFZ (global). FFZ has no Kick channel support, so only its global set is fetched.
TrovoTrovo (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

ProviderTwitchYouTube
7TVhttps://7tv.io/v3/users/twitch/{channel_id}https://7tv.io/v3/users/youtube/{channel_id}
BTTVhttps://api.betterttv.net/3/cached/users/twitch/{channel_id}https://api.betterttv.net/3/cached/users/youtube/{channel_id}
FFZhttps://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

MethodPathDescriptionAuth
GET/v1/emotes/{platform}/{channel_id}Fetch channel emotesAuthenticated (no RBAC permission)
GET/v1/emotes/userFetch user's Twitch subscriber emotesAuthenticated + active account

GraphQL

The same data is exposed as two queries in the chat schema, both guarded by feature:multichat + chat:read:

QueryReturnsPermission
emotes(platform: String!, channelId: String!)GqlEmoteSetsResponse!feature:multichat + chat:read
userEmotesGqlEmoteSetsResponse!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 of EmoteSet with the user's available emotes
  • owners -- 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 (Kappa matches, kappa does not, KappaRoss does not)
  • Consecutive text words are merged into a single EmotePart::Text
  • Returns Vec<EmotePart> where each part is either Text { text } or Emote { 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

FilePurpose
apps/api/src/routes/emotes.rsREST endpoints for channel and user emotes
apps/api/src/services/emotes.rsProvider 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.rsDurable 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.rsCore types (Emote, EmoteSet, EmoteProvider, EmotePart) and parse_emotes()
crates/lo-chat/src/youtube_emotes.rsStatic catalogue of YouTube native standard-emoji shortcodes
apps/web/src/app/(main)/(app)/dashboard/chat/message-content.tsxMultichat renderer with shortcode-splitting + colon-fallback
apps/web/src/app/(main)/(app)/dashboard/chat/emote-library.tsxEmote picker with platform tabs and the youtube-global section