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, 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​

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 native emotes: the channel set, Kick's Global set and Kick's Emojis set

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 + global), FFZ (channel), BTTV (channel), FFZ (global)
YouTubeYouTube (native standard emojis), 7TV (channel + global), BTTV (channel), FFZ (channel + global)
KickKick (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:

sectionContentCatalog scope
channelThe channel's own emotesthe channel (channel_id = Kick broadcaster id)
globalKick's global emoteschannel_id = NULL
emojisKick's emoji setchannel_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​

ProviderTwitchYouTube
7TVPOST https://7tv.io/v4/gql, platform TWITCHPOST https://7tv.io/v4/gql, platform GOOGLE
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 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​

MethodPathDescriptionAuth
GET/v1/emotes/{platform}/{channel_id}Fetch channel emotesAuthenticated (no RBAC permission)
GET/v1/emotes/userFetch user's Twitch subscriber emotesAuthenticated + active account
GET/v1/emotes/infoResolve owner and Kick/YouTube image animation for up to 50 emotesAuthenticated, 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:

QueryReturnsPermission
emotes(platform: String!, channelId: String!)GqlEmoteSetsResponse!feature:multichat + chat:read
userEmotesGqlEmoteSetsResponse!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 of EmoteSet with the user's available emotes
  • owners -- 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 (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 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"
}
}
}
  • name is the word as typed, bits the amount of that occurrence, color the tier colour. url is 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 / static are Helix scale 2 (56 px) and always present; animated_hd / static_hd are scale 4 (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 *_hd keys.
  • The dashboard picks the scale-4 file on a 2× screen (srcset 1x/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 no static image (or only the legacy url) renders as text.
  • Images come from Helix GET /bits/cheermotes?broadcaster_id= (global plus the channel's custom cheermotes), scale 2 plus optional scale 4, cached in Redis under lumio: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 Cheer100 stays 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_emotes skip entries with kind = "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​

FilePurpose
apps/api/src/routes/emotes.rsREST endpoints for channel and user emotes
apps/api/src/services/cheermotes.rsTwitch cheermote catalog (Helix bits/cheermotes, Redis 6 h / 5 min) and the cheermote entry shape
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