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, YouTube, Kick, 7TV, FFZ, BTTV)
| 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 native emotes: the channel set, Kick's Global set and Kick's Emojis set |
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 + global), FFZ (channel), BTTV (channel), FFZ (global) |
| YouTube | YouTube (native standard emojis), 7TV (channel + global), BTTV (channel), FFZ (channel + global) |
| Kick | Kick (channel, Global, Emojis), 7TV (channel + global), BTTV (channel), FFZ (global). FFZ has no Kick channel support, so only its global set is fetched. |
Empty sets are filtered out -- only providers with at least one emote are included in the response.
Kick native sets
Kick's official API has no emote endpoint, so the Kick sets come from Kick's
undocumented website endpoint https://kick.com/emotes/{slug} (no token is
sent). It returns three sets, each returned as its own EmoteSet with
provider = "kick" and a section field:
section | Content | Catalog scope |
|---|---|---|
channel | The channel's own emotes | the channel (channel_id = Kick broadcaster id) |
global | Kick's global emotes | channel_id = NULL |
emojis | Kick's emoji set | channel_id = NULL |
Every Kick emote carries subscribers_only (true = usable only by the
channel's subscribers; the field is omitted when false) and the image URL
https://files.kick.com/emotes/{id}/fullsize. The endpoint is keyed by the
channel slug: it comes from channel_connections.channel_login (set on
connect and back-filled from the chat webhook's broadcaster.channel_slug),
falling back to the official GET /public/v1/channels?broadcaster_user_id= with
the connection's token. The fetch is fail-open: an unknown slug, a timeout,
a Cloudflare 403 or a format change yields no Kick sets, never an error, and
the resolver falls back to the durable catalog. Sets rebuilt from the catalog
carry no section / subscribers_only (the catalog does not store them).
Channel owner. Kick's channel set carries the broadcaster as
user: {id, username, profile_pic}. Every emote of the channel section gets
owner_id = "kick:<user id>" (for example kick:676 for xQc), and the response's
owners map carries that id's display_name (Kick username) and avatar_url
(Kick profile_pic on files.kick.com, https:// only). The emote library uses
them to show one section per Kick channel, labelled with the streamer's name
and avatar, the same way it groups Twitch channel emotes. Kick owner ids are
namespaced with kick: because Kick and Twitch ids are both bare numbers and
the frontend keys owners by id. global and emojis emotes have no
owner_id. A channel set without a usable user yields no owner and no error,
and so does a response rebuilt from the catalog: the catalog keeps owner_id
on the rows but not the owner's name or avatar, so owners is then null and
the frontend falls back to its generic Channel label. The owner is part
of the fetched sets, so it is cached with them in the Redis hot cache; it costs
no extra upstream call.
No cross-channel subscriber emotes on Kick. Twitch's GET /v1/emotes/user
(Helix chat/emotes/user) also lists emotes from other channels the viewer is
subscribed to. Kick has no official equivalent: the Kick website gets that list
through the viewer's session cookie, and Lumio never sends its OAuth token to
undocumented Kick hosts. The Kick menu therefore shows the current channel(s)
plus Kick's Global and Emojis sets, not the viewer's subscriptions to other
channels.
Fetch outcomes are counted in chat_emote_set_fetch_total{platform, outcome}. 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 | POST https://7tv.io/v4/gql, platform TWITCH | POST https://7tv.io/v4/gql, platform GOOGLE |
| 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 7TV: emotes come from 7TV's v4 GraphQL API, not the v3 REST API (in
maintenance mode, and it rejects YouTube with 400 invalid platform). The
channel lookup is users.userByConnection(platform, platformId) with platform
TWITCH, KICK or GOOGLE. For YouTube, platformId is the UC… channel id.
The query reads the channel's active emote set. The global set comes from
emoteSets.global in a second request, so one failing never empties the other.
Both requests ask only for the 7TV user id, the active set id and each emote's
id, set alias and animated flag. The emote name is the set alias, which is
the name chat types, not the emote's default name. 7TV reports query errors with
HTTP 200: an errors array or data: null is logged and treated as a failed
fetch (fail-open), and userByConnection: null means no 7TV account is linked
to the channel.
Note on FFZ: while BTTV uses 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.
Chat emote and badge cards
Hover an emote or badge in Multichat, its chat popout, an event line, or the user card to open a larger card. A touch tap opens a bottom sheet on a phone; on a wider tablet it opens a card beside the item. The same interaction works in a chat popout used as an OBS dock or browser source. The card uses a larger provider image when available and falls back to the chat image if it fails to load. A badge without an image shows its drawn badge artwork instead.
An emote card shows its name, provider, image, origin when known, and ID when present. It can show the creator name for 7TV, BTTV, or FFZ, and a YouTube member-emote label. The Animated or Static chip appears only when that state is known: Twitch, 7TV, and BTTV use their emote flag; FFZ uses a flag from a live provider fetch; Kick and YouTube use the server-side image check in emoteInfo. Catalog fallback values of animated: false for older FFZ rows and for Kick or YouTube do not establish that an image is static. An unknown result shows no chip.
Origin is read from the message's emote identity and the channel catalog. Twitch message fragments can carry the owning broadcaster ID; YouTube channel-emote IDs contain a UC… channel ID; Kick channel sets carry an owner ID when Kick supplies one. The card labels global emotes and the current or another channel when it can establish the scope. For another channel it shows the channel name or ID; a Twitch login or YouTube channel ID supplies a channel link. Kick does not reliably supply a foreign channel slug, so its channel name can appear without a usable link. Third-party catalog sets supply channel or global scope and, where available, a creator name. A 7TV, BTTV, or FFZ emote also links to its provider page when its ID is valid.
Copy name copies text for pasting into chat: the exact case-sensitive name for Twitch, 7TV, BTTV, and FFZ; a colon-wrapped shortcode such as :yt: for YouTube; and [emote:37226:KEKW] for a Kick emote with a numeric ID. If that Kick ID is unavailable, it copies the name. Kick's native chat renders the markup as an emote; a bare name remains text. Copy ID copies the emote ID when present. Badge cards show the badge image, title including a known tier or month count, and platform, with no ID or copy controls. In an OBS browser source, external popups may be blocked, so a Copy link control appears beside channel and provider links for opening them in a browser.
In stream reports
The top emotes and top GIFs of a stream report open the same card, on the dashboard and on a shared report. An emote card shows the full name the tile cuts off, the larger image, the provider, the use count and — for reports finalized with them — the emote ID, the Animated/Static chip, the chat platform it was used on (when it differs from the provider) and the owning channel. A report has no live chat around it, so the card names the owning channel without a "this channel" or "other channel" label. Older reports without these fields show name, provider, image and count only, with no empty ID line.
A GIF card shows a larger preview, the caption, the platform, the source (GIPHY for Twitch GIFs) and how often it was posted. It links to the GIF's GIPHY page only when both the stored ID and the image address identify a GIPHY GIF. At Animations in Lumio: Reduced or Off (or a reduced-motion system setting on a shared report), the card shows a still placeholder and plays the GIF only after Play is pressed. On touch devices a tap on a GIF tile opens the card or sheet; the tile itself does not start playing, so the GIF plays from the card's Play button.
A shared report is public and has no session, so its cards make no server requests: they show only what the report stored. The owning channel's name therefore stays unknown there; the channel row appears only when the stored ID alone gives a safe link (a YouTube UC… channel ID).
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 |
GET | /v1/emotes/info | Resolve owner and Kick/YouTube image animation for up to 50 emotes | Authenticated, including popout tokens; overlay, shared overlay, widget and extension tokens refused; no RBAC permission |
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 |
emoteInfo(platform: String!, items: [GqlEmoteInfoItemInput!]!) | [GqlEmoteInfo!]! | Authenticated, including popout tokens; overlay, shared overlay, widget and extension tokens refused; no RBAC permission |
The channel query and its REST twin resolve through the same
resolve_channel_emotes function, so their returned sets, fields and errors are
identical. 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 are public data. (The web app reads
emotes through GraphQL only.) GraphQL exposes the Kick fields as section on
GqlEmoteSet and subscribersOnly on GqlEmote; REST returns the same values
as section / subscribers_only. Both protocols return the same owners
(Kick channel owners, see Kick native sets): REST as a map
keyed by owner id, {"kick:676": {"display_name": "xQc", "avatar_url": "https://files.kick.com/..."}},
GraphQL as the list owners: [GqlEmoteOwner { id, displayName, avatarUrl }].
owners is null when no set has an owner (every non-Kick channel request, a
Kick body without user, a catalog fallback).
emoteInfo and GET /v1/emotes/info share validation and resolution. They accept twitch, youtube, or kick, return one result per item in request order, and cap each request at 50 items. Overlay, shared overlay, widget and extension tokens are refused with Emote details are not available to overlay, widget or extension tokens (ZAF-1922). Per request at most 10 distinct owners are resolved and at most 10 images probed; items beyond come back owner: null / animated: null. Probe results are cached by the fetched image URL (hashed), not by the client's emote id. Each result has an emote ID, a nullable owner (owner_id, login, display name, avatar URL), and a nullable animated state. The image check applies only to Kick and YouTube; see the GraphQL reference and REST reference.
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{ display_name, avatar_url }for grouping emotes by channel (GraphQL:[{ id, displayName, avatarUrl }])
Types
EmoteSet
struct EmoteSet {
provider: EmoteProvider, // twitch, 7tv, ffz, bttv, youtube, kick
emotes: Vec<Emote>,
section: Option<String>, // Kick: "channel" | "global" | "emojis"; omitted otherwise
}
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 for grouping: Twitch broadcaster id, or "kick:<user id>" (Kick channel emotes)
subscribers_only: bool, // Kick sub-only emote; omitted from JSON when false
owner_name: Option<String>, // 7TV, FFZ or BTTV creator name when known
scope: Option<String>, // "channel" or "global" for third-party sets
}
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 Kick webhook path normalises Kick's raw
[emote:ID:NAME] markup into the same shape — see Kick chat emotes.
Twitch cheermotes
A cheer in Twitch chat (Cheer100) is a normal chat message whose cheer words
arrive as cheermote fragments. The EventSub worker turns each one into an
entry of the message's existing emotes array, so live WebSocket delivery,
persistence and chat history (GraphQL and REST pass the JSONB through
unchanged) carry it without a new column:
{
"id": "cheermote:cheer:100",
"name": "Cheer100",
"url": "https://d3aqoihi2n8ty8.cloudfront.net/actions/cheer/dark/animated/100/2.gif",
"provider": "twitch",
"animated": true,
"kind": "cheermote",
"bits": 100,
"color": "#9c3ee8",
"urls": {
"dark": {
"animated": "…/dark/animated/100/2.gif", "static": "…/dark/static/100/2.png",
"animated_hd": "…/dark/animated/100/4.gif", "static_hd": "…/dark/static/100/4.png"
},
"light": {
"animated": "…/light/animated/100/2.gif", "static": "…/light/static/100/2.png",
"animated_hd": "…/light/animated/100/4.gif", "static_hd": "…/light/static/100/4.png"
}
}
}
nameis the word as typed,bitsthe amount of that occurrence,colorthe tier colour.urlis the dark animated image, so a reader that only knows emote entries still shows a graphic.- Every theme carries an animated and a still image per density:
animated/staticare Helix scale2(56 px) and always present;animated_hd/static_hdare scale4(112 px) for large chat fonts. The HD pair is optional — it is only written when Twitch has both scale-4 images for that theme, never one without the other; a tier without scale 4 keeps only the scale-2 keys. Entries stored before the HD pair existed have no*_hdkeys. - The dashboard picks the scale-4 file on a 2× screen (
srcset1x/2x) and whenever the rendered graphic is larger than 28 CSS px, the scale-2 file otherwise. It never uses an animation as the still: without motion an entry that has nostaticimage (or only the legacyurl) renders as text. - Images come from Helix
GET /bits/cheermotes?broadcaster_id=(global plus the channel's custom cheermotes), scale2plus optional scale4, cached in Redis underlumio:cheermotes:v2:twitch:{broadcaster_id}for 6 h; after a failed fetch an empty placeholder is cached for 5 min (apps/api/src/services/cheermotes.rs). The worker's channel token is used; Helix accepts an app or a user token. - The catalog is only looked up for messages that contain a cheer, and the
cache-miss fetch is capped at 2 s. Fail-open: without a catalog, or for a
prefix the catalog does not know, no entry is written and
Cheer100stays text. A Shared Chat cheer uses the origin channel's catalog. - Cheermote entries are not emotes for statistics: the stream report's
emote_total/top_emotesskip entries withkind = "cheermote".
The twitch:cheer event gets the same entries from its plain-text message, plus
a total-amount image set — see Events → Twitch cheer payloads.
Kick chat emotes
Kick's chat.message.sent webhook carries emotes inline in content as
[emote:ID:NAME] markers, for example
Hello [emote:4148074:HYPERCLAP] [emote:37226:KEKW]. The accompanying
emotes[].positions {s, e} span the whole marker and e is inclusive.
Ingest (services::kick_emotes::extract_kick_emotes): the marker is the
source of truth. Each marker (\[emote:(\d+):([^\]\s]+)\]) is replaced by its
name in the stored message text, and the message gets one emotes entry per
occurrence, {id, name, url, provider: "kick", animated: false}, with the URL
frozen at ingest as https://files.kick.com/emotes/{id}/fullsize — the same
pattern as Twitch. A message without a marker has no emotes field; a malformed
marker stays text. The quoted reply parent (reply_parent_message) is converted
to plain text the same way. emotes[] is only cross-checked.
Stored rows from before this parser were rewritten once by the TimescaleDB
migration 20260930000001_kick_chat_emote_markup_backfill (text + emotes).
Sending: Kick renders an emote only when it receives the marker. The Kick
send path (services::chat_send::send_kick, shared by GraphQL
sendChatToPlatform and REST POST /v1/chat/send) converts every whitespace-
separated word that exactly matches a known Kick emote (Kick-provider rows of
the channel's catalog, including Kick's global and emoji sets) into
[emote:ID:NAME]. 7TV/BTTV/FFZ names stay text.
Rendering: Kick needs no special case in MessageContent. The message
text holds the emote names and the per-message emotes array holds their frozen
URLs, as for Twitch. Kick markers may sit back to back or touch punctuation
([emote:1:A][emote:1:A]! is stored as AA!). So a per-message emote is also
matched inside a longer whitespace-token, but only for the occurrences that
were not already matched as a whole word. This keeps a Twitch LUL sent once
from turning the word LULW into an image, and channel-map emotes are never
split out of a word.
Emote menu: the Emote Library in the chat input orders Kick's sets like
Twitch's. Kick channel emotes get one section per Kick channel, grouped by
owner_id and labelled with the owner's display_name, with the avatar in the
section header and the sidebar. These sections sit in the same block as the
Twitch channel sections: after Recently Used, before 7TV/BTTV/FFZ. A channel
emote whose owner has no owners entry (for example the catalog fallback) shows
under the generic Channel label with the Kick icon. Kick Global and
Kick Emojis follow the providers, like Twitch Global. When Kick's own
emote endpoint cannot be reached, the menu still shows those sections from the
durable catalog (the last known state), and the server retries Kick within 5
minutes instead of keeping the incomplete menu for 6 hours. The Kick platform
tab shows the Kick channel sections, Kick Global, Kick Emojis and the
7TV/BTTV/FFZ emotes fetched for the Kick channel; Twitch sections never appear
there, and Kick sections never appear on the Twitch tab. Emotes with
subscribers_only carry a lock marker and a "Subscribers only" tooltip. Clicking
an emote inserts its name, and the send path above turns it into the marker. The
web proxy (/api/emotes/{platform}/{channelId}) requests
owners { id displayName avatarUrl } and returns them as the same
Record<ownerId, { display_name, avatar_url }> map as /api/emotes/user; the
browser merges the owners of every channel response over the user endpoint's and
caches sets plus owners in localStorage under lumio:emotes:v4:* for 6 hours.
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.
Super Chats and membership milestones: a youtube:superchat or youtube:member event stores the emotes in the viewer's message as raw.emotes, in the same shape chat messages use; an event without emotes has no emotes key, and its text keeps the shortcodes unchanged. On the InnerTube path every emoji YouTube sent with the message resolves, standard and channel-custom, from the image in the message itself; channel-catalog emotes (for example a 7TV name typed as text) resolve too. On the Data API and gRPC path the message is text only, so its emotes resolve from the channel catalog. The event therefore carries its own emote images and does not depend on the channel emote map. A new member's welcome text is YouTube's own text, not the viewer's, and is not scanned.
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 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 versioned lumio:emotes:v4:{platform}:{channel_id} key used by
emote_service::get_cached_emotes / cache_emotes, 6 h - 5 min for a degraded
response), checked before any external API call. The version segment
(EMOTE_CACHE_VERSION) is bumped whenever the cached sets change shape, so a
deploy never serves the previous build's sets for the rest of their TTL. 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 fetch fails, or only the platform-native one (Kick's own sets, Twitch Helix) - reads the durable catalog back (emote_catalog::load_channel_emote_sets / load_channel_provider_emote_sets)
so an outage degrades to the last known state instead of an empty map. See Serving the catalog on an upstream outage.
For connected Twitch, Kick and YouTube channels, the 7TV EventAPI listener deletes the channel hot-cache key when its active 7TV set changes. Additions and renames also update the catalog map; the browser refreshes the picker and autocomplete after a matching live event, debounced by two seconds. The six-hour hot-cache TTL is the fallback if event delivery is unavailable. Removals clear the hot cache but do not delete old catalog rows; a removed shortcode can remain in the chat-ingest map until that map expires.
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/cheermotes.rs | Twitch cheermote catalog (Helix bits/cheermotes, Redis 6 h / 5 min) and the cheermote entry shape |
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 |