Events
Overview
The Events module handles platform event logging and alerting for streaming events such as followers, subscribers, cheers, raids, channel point redemptions, and tips. Events are stored in a TimescaleDB hypertable with automatic compression for efficient time-series queries. Events are broadcast in real time via Redis pub/sub to WebSocket clients for overlay alerts and dashboard panels. The module supports paginated queries with filters by type, platform, date range, and full-text search.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/events.rs) -- Queries for listing/fetching events with filters. Mutation for emitting test/manual events. - Crate (
crates/lo-events/src/) -- Core event logic includingEventFilter,EventInput,EventRowtypes,list_events,get_event,insert_event, andbroadcast_eventfunctions. - Database -- Events are stored in TimescaleDB (
platform_event_logshypertable) with time-based partitioning and compression. - Real-time -- Events are broadcast via
RedisPubSubusing theGqlPubSubwrapper to all WebSocket subscribers for the account.
Frontend
- Next.js API proxy routes call GraphQL internally.
- A shared
EventsShellcomponent (apps/web/src/components/events-shell.tsx) renders the live feed with filters by type and platform. It backs three surfaces: the dedicated Events page (/dashboard/events), the compact panel on the Dashboard home, and the/popout/eventswindow (cookie or popout-token auth). - Event categories are feature- and permission-gated (ZAF-622 §72). A category's
filter chips and its rendered rows appear only when the account holds the feature
that category depends on and any RBAC permission its backend guard requires. The rule is
the shared
EVENT_GROUP_ACCESSmap inapps/web/src/lib/event-access.ts, read by both the events feed and the chat event settings so the two never drift. Copyright is the only category with a permission today: its chips and lines show only withfeature:copyright_detectionandcopyright:read(the exact pair the copyright GraphQL/REST guard enforces). The API still returns a disabled category's historical rows (GET /v1/eventsfilters only onfeature:events+events:read, never per event type), so the feed drops them client-side in the rendered list, not just from the filter chips — a category the account cannot see never appears. - Expired-connection alert. Events are produced by the account's channel connections, so a
dead grant makes the feed stop silently. When a channel connection is flagged
reconnect_required, the header's per-platform pill shows an icon-only warning marker and a red entry appears in the connection notices directly under the topbar, carrying a Reconnect button that starts that platform's OAuth flow in place. It is shown only to viewers holdingconnections:create(Owner / Administrator among the default roles). See Connections → Connection notices. - User info card. Clicking a username opens
EventUserInfoModal(apps/web/src/components/user-info-modal.tsx), which resolves the event's user via the sharedresolveEventUserand renders the same full-page user info card the chat uses (see Chat and User Info Card) - without the chat-only Highlight/Hide filter buttons. Moderation from the card goes through/api/chat/moderate, gated by the session's own permission subset (also in the events popout). - Overlay alerts consume events via WebSocket for real-time display.
- Localized subject names show the login (ZAF-1452). A row's subject renders as
display (login)when the platform display name is genuinely localized - the reported Twitch case isこさむん (pocom_2026), where the display name is non-Latin but the login stays the ASCII handle. The decision is the shared three-condition AND rule in@lumio/ui'sformatPlatformName: the display name differs from the login by more than case, and carries a non-Latin letter, and the sourceplatformis one where the display name is a freely-editable field over an immutable login — Twitch only today (founder ruling ZAF-1452, 2026-09-27). So an ordinary Latin follower likeCruexstays un-bracketed, umlaut and accent names (Ömer,Renée) are not bracketed, and — crucially — the same localized values render asこさむん(no bracket) on a YouTube event, where "channel title ≠ handle" is the normal case and bracketing would fire on every non-Latin channel. The events feed (event-row.tsx) and the inline chat event line (chat-event-row.tsx) read one shared resolver (apps/web/src/lib/event-user.ts→resolveEventDisplayName, which returns both the display name and the login) and threadevent.platforminto the helper, so the two surfaces can never drift and an unknown platform is fail-closed (no bracket). - First-time chatter & watch streak (Twitch). Both appear as rows in the feed
and are toggleable in the type filter. A
twitch:first_messagerow shows the chatter and their first message. Atwitch:watch_streakrow shows the viewer, a flame streak-count badge, and - resub-style - their attached chat message when they left one (otherwise Twitch's "watched N streams in a row" line). The first-time-chatter signal additionally tags the matching inline row in Multichat.
API
GraphQL Queries
Every account-facing event operation additionally requires the feature:events
feature flag. On GraphQL the guard is FeatureGuard("feature:events") combined
with the permission below; the four REST handlers call the same
require_feature after their permission check.
| Query | Permission | Description |
|---|---|---|
events(filter: EventFilterInput) | feature:events + events:read | Paginated event list with filters for types, platform, search, date range. Returns PaginatedEventResult!. Without from it reads the 30 days before to (or now). total is capped at 10 000: totalCapped: true means more events match, and pages covers the capped total. The count runs only when total, totalCapped or pages is selected. |
event(id: UUID!) | feature:events + events:read | Get a single event by ID (account-scoped) |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
emitEvent(input: EmitEventInput!) | feature:events + events:create | Emit a broadcast-only test event without persisting it. Returns EmitEventResult!. |
emitBotEvent(accountId: ID!, eventType: String!, platform: String!, username: String!, raw: JSON!) | System key with bots:internal | Bot-worker ingest path with an explicit account id. Broadcast-only - like a test event, it is not persisted. |
REST Endpoints
All paths live under /v1. Bodies are snake_case.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/events | feature:events + events:read | Paginated event list (accepts the same filters as EventFilterInput). Same 30-day default window and 10 000 cap as GraphQL; _meta.total_capped: true when more events match |
GET | /v1/events/{id} | feature:events + events:read | Fetch a single event by ID (account-scoped) |
POST | /v1/events/emit | feature:events + events:create | Emit a manual event (insert + broadcast) |
POST | /v1/events/test | feature:events + events:create | Emit a test event - broadcast only, never persisted |
The dashboard's Test Event side sheet and /popout/test-events send through the broadcast-only test path. The popout's delegated token must contain events:create; without it the catalog is replaced by a permission notice. Both surfaces list only the event sources the session could receive: the category's feature and RBAC rule (the same map that gates the events feed) and, for Twitch, YouTube, Kick, Spotify and StreamElements, a connected source. An unreadable connection list keeps those sources listed. This is display filtering only; the send path itself checks feature:events + events:create. Tests appear in the account's overlays and dashboard activity with a TEST badge, but the Events page and Events popout filter them out. They do not enter stored history, metrics or milestones. POST /v1/events/emit is the separate persisted manual-event route.
WebSocket
| Channel | Gate | Feature flag |
|---|---|---|
events:{account_id} | events:read on the account in the channel key | none |
Subscribing streams every event inserted or broadcast for that account in real
time - the same payloads the events query returns. The gate is resolved by
channel_gate_for / channel_feature_for in crates/lo-websocket/src/gate.rs;
events carries no feature flag, so an account without feature:events can
still stream (the REST/GraphQL read paths remain flag-gated).
Filter Options (EventFilterInput)
| Field | Type | Description |
|---|---|---|
types | [String!] | Filter by event types (e.g., ["twitch:follower", "twitch:subscribe"]) |
platform | String | Filter by platform (comma-separated for multiple) |
userId | String | Filter by platform user ID (matches raw->>'user_id') |
search | String | Case-insensitive text search in raw JSON data |
from | String | ISO 8601 timestamp -- only events after this time |
to | String | ISO 8601 timestamp -- only events before this time |
page | Int | Page number (1-based, default 1) |
limit | Int | Items per page (default 25, max 100) |
Permissions
| Permission | Description |
|---|---|
events:read | Read/list events. Enforced on events, event, GET /v1/events, GET /v1/events/{id}. |
events:create | Create events (test events, emit). Enforced on emitEvent, POST /v1/events/emit, POST /v1/events/test. |
events:userinfo | View user cards/profiles from event entries. Enforced client-side by the EventsShell component (<Gate permission="events:userinfo">); the underlying profile lookup is guarded by chat:userinfo. |
There is no events:delete permission: no manual event-deletion surface exists on
either protocol. Events are aged out automatically by the per-plan retention sweep
(see Database below), not on demand - so the former events:delete account
permission was removed in ZAF-1094 (it gated nothing).
Database
| Table | Database | Description |
|---|---|---|
platform_event_logs | TimescaleDB | Hypertable, segmented by account_id, compressed after 7 days. Columns: id (UUID), account_id, type (the platform:action string - GraphQL exposes it as eventType), platform, external_id, raw (JSONB), created_at. Primary key (id, created_at). A partial unique index on (account_id, external_id, created_at) where external_id IS NOT NULL makes webhook re-delivery idempotent. |
Retention
Events are retained on the same per-plan window as chat messages - the privacy policy groups "chat messages and events" under one rolling, plan-dependent window. A background sweep (the chat/event retention worker, gated by chat.retention_enforcement_enabled) hard-deletes each account's platform_event_logs rows older than its effective plans.chat_retention_days window (a per-account account_limits.chat_retention_days override takes precedence). Because the window is per-plan, it is enforced by a per-account DELETE sweep rather than a table-wide TimescaleDB retention policy. An account whose effective window is 0 keeps its events indefinitely. Events belong to the channel: the channel's instructed data-subject erasure (POST /v1/admin/privacy/chat/erase and the YouTube member erasure, see Chat › Erasure) does not delete them, so they leave only through this window or channel dissolution (founder ruling ZAF-2626).
Event Types
Events follow the platform:action naming convention. There are two distinct
lists and they are not identical - quote the right one for your use case.
Extension-subscribable catalogue
LUMIO_EVENT_TYPES in packages/protocol/src/events.ts is the closed enum an
extension may pass to useLumioEvent(). It is validated by a Zod schema, so a
type outside this list is rejected at the SDK boundary:
- Twitch:
twitch:follower,twitch:subscribe,twitch:gift_sub,twitch:resub,twitch:cheer,twitch:raid,twitch:reward,twitch:reward_status,twitch:reward_add,twitch:reward_update,twitch:reward_remove,twitch:hype_train,twitch:hype_train_end,twitch:poll,twitch:poll_end,twitch:prediction,twitch:prediction_lock,twitch:prediction_end,twitch:goal,twitch:goal_end,twitch:ad_break,twitch:ban,twitch:unban,twitch:stream_online,twitch:stream_offline,twitch:stream_update - YouTube:
youtube:subscribe,youtube:member,youtube:superchat,youtube:supersticker,youtube:gift_membership,youtube:gift_membership_received,youtube:poll,youtube:stream_online,youtube:stream_offline - Kick:
kick:follower,kick:subscribe,kick:resub,kick:gift,kick:kicks,kick:reward,kick:reward_status,kick:stream_online,kick:stream_offline,kick:stream_update - Spotify:
spotify:track,spotify:play,spotify:pause,spotify:skip,spotify:volume,spotify:queue_add,spotify:device,spotify:playlist_add,spotify:playlist_remove,spotify:playlist_create,spotify:playlist_edit,spotify:playlist_delete - StreamElements:
streamelements:tip - Chat (cross-platform):
chat:message(fires for all platforms)
Reward lifecycle and redemption status (raw)
| Lumio type | Source | Stored | raw |
|---|---|---|---|
twitch:reward | EventSub channel.channel_points_custom_reward_redemption.add | yes | id, user_id, user_login, user_name, user_input, status (unfulfilled), redeemed_at, reward { id, title, cost, prompt } |
twitch:reward_status | EventSub channel.channel_points_custom_reward_redemption.update | yes | the twitch:reward shape with status fulfilled or canceled |
twitch:reward_add / twitch:reward_update / twitch:reward_remove | EventSub channel.channel_points_custom_reward.add / .update / .remove | no (broadcast_only) | the Twitch reward: id, title, cost, prompt, is_enabled, is_paused, is_in_stock, is_user_input_required, should_redemptions_skip_request_queue, max_per_stream, max_per_user_per_stream, global_cooldown, cooldown_expires_at, redemptions_redeemed_current_stream, background_color, image, default_image, broadcaster_user_*. .remove carries the reward as it was. |
The three lifecycle types are configuration changes, not audience events: they
reach extensions, overlays and automations live on events:{account_id} and
are never written to the event history, so the dashboard feed and chat (which
drop broadcast_only frames) do not show them. The Twitch subscriptions need
channel:read:redemptions or channel:manage:redemptions; Lumio already
requests both for every Twitch channel connection, so no reconnect is needed. Kick sends no reward lifecycle webhook, so
there is no kick:reward_add/update/remove. Lumio's own rewards module
emits no events; changes made there are not platform rewards.
Kick payloads (raw)
The Kick webhook receiver normalizes every delivery before it is stored and
broadcast (lo_kick_api::normalize_event). The original Kick payload stays
complete, and Lumio's canonical fields are added at the top level, so Kick
events render and filter through the same fields as Twitch:
| Lumio type | Kick event | Top-level fields added |
|---|---|---|
| every type with an actor | — | user_id (string), user_login (channel slug), user_name, avatarUrl (omitted when Kick sends none), is_anonymous |
kick:follower | channel.followed | only the actor fields |
kick:subscribe / kick:resub | channel.subscription.new / .renewal | cumulative_months, expires_at |
kick:gift | channel.subscription.gifts | giftCount, total, is_gift, giftRecipients; recipient_user_{id,login,name} for exactly one giftee; gifter_is_anonymous for an anonymous gifter |
kick:kicks | kicks.gifted | kicks (amount, a counter like Twitch bits, not money), message, kick_gift_name, kick_gift_type, kick_gift_tier, kick_pinned_time_seconds; for a known gift also kick_gift_id, kick_gift_image and kick_gift_color (see below) |
kick:reward / kick:reward_status | channel.reward.redemption.updated | id, status, user_input, redeemed_at, reward { id, title, cost, prompt }. The first delivery of a redemption id is kick:reward, every later status change is kick:reward_status. |
kick:stream_online / kick:stream_offline | livestream.status.updated | none for kick:stream_online (title, started_at, ended_at are already top level); kick:stream_offline adds duration_seconds, and started_at when Kick sent none (see Stream offline payloads) |
kick:stream_update | livestream.metadata.updated | title, category_id, category_name, language, has_mature_content, title_changed, category_changed, language_changed, and old_title / old_category_name / old_language for a changed field (see below) |
Mapping, deduplication and live-status handling: Webhooks → Kick.
KICKs gift graphics. Kick's webhook names the gift but carries no gift id
and no image, and Kick has no image API. lo_kick_api::kicks_gifts holds a
static table of Kick's 10 gifts; normalize_event matches gift.name against
it (case-insensitive) and adds:
| Field | Example | Meaning |
|---|---|---|
kick_gift_id | "rage_quit" | Kick's gift id |
kick_gift_image | {"static": "https://files.kick.com/kicks/gifts/rage-quit-static.webp", "animated": "https://files.kick.com/kicks/gifts/rage-quit.webp"} | Gift sticker. animated is null for the four BASIC gifts, which have a static image only |
kick_gift_color | "#45ADFC" | Kick's accent colour for the gift |
A gift name the table does not know adds none of the three fields; the event
then renders as before (coin icon). An #[ignore] smoke test
(cargo test -p lumio-api --test kick_gifts_smoke -- --ignored) compares the
table with Kick's catalog.
Twitch cheer payloads (twitch:cheer)
channel.cheer delivers message as plain text without fragments. The EventSub
worker matches every word of the form <prefix><amount> (case-insensitive,
only prefixes the channel's cheermote catalog
knows) and adds:
| Field | Meaning |
|---|---|
emotes | One entry per cheermote word in message, in the chat cheermote entry shape (kind: "cheermote"). Absent when nothing matched |
cheermote | The image set for the total bits: the highest global Cheer tier with min_bits <= bits, as {prefix, bits, min_bits, color, urls{dark,light}{animated,static}}; a theme also carries the scale-4 animated_hd / static_hd pair when Twitch has both (see Twitch cheermotes) |
Both fields are render data and fail-open: when the catalog cannot be loaded they are absent and the event is stored unchanged.
Twitch Shared Chat session events
The four Twitch-only session types are twitch:shared_chat_begin,
twitch:shared_chat_join, twitch:shared_chat_leave and
twitch:shared_chat_end. They appear at their event time in both chat and the
Events feed. Start shows the host-first channel stack, the channel sum and
partner details; join shows the joining channel; leave shows its duration and
message count; end shows the duration, peak sum and every partner seen in the
session. Missing Helix enrichment omits only that value. A join found during
reconciliation is marked Detected later. Repeated pushes do not create
duplicate rows.
The four switches live in the existing Twitch type group in chat, the Events feed and popout settings and are on by default. The feed also offers a Shared Chat filter chip. Chat shows session rows only in All and Twitch, with start/end details collapsed; the feed expands those details. These events do not open chat alerts. They are separate from partner-channel subs, Bits and raids described below.
Twitch Shared Chat partner events
During a Twitch Shared Chat session, events that happen in a partner channel of the session are stored and broadcast as the same event types as the own channel's, labelled with the channel they happened in:
| Partner event | Twitch source | Lumio event type |
|---|---|---|
| Sub, resub | channel.chat.notification shared_chat_sub / shared_chat_resub | twitch:subscribe / twitch:resub |
| Gift sub, community gift | shared_chat_sub_gift / shared_chat_community_sub_gift | twitch:gift (bundled like an own community gift) |
| Gift / Prime upgrade | shared_chat_gift_paid_upgrade / shared_chat_prime_paid_upgrade | twitch:gift_upgrade / twitch:prime_upgrade |
| Pay it forward | shared_chat_pay_it_forward | twitch:pay_it_forward |
| Raid into the partner | shared_chat_raid | twitch:raid (raider in user_* / from_broadcaster_user_*) |
| Bits | the channel.chat.message cheer field of a partner-channel message (channel.cheer fires only for the own channel) | twitch:cheer (external_id chat_cheer:<message_id>) |
raw carries the same flattened fields as the own-channel event plus:
| Field | Meaning |
|---|---|
is_shared_chat | true |
source_channel | The partner channel, {platform_channel_id, login, display_name, avatar_url} - exactly the chat-message source_channel shape |
notice_type | The base type (sub_gift, not shared_chat_sub_gift), so gift bundling and the bundled-recipient history filter treat it like an own notice |
shared_chat_notice_type | Twitch's original shared_chat_* notice type (notices only) |
A notice counts as a partner event only when its notice_type starts with
shared_chat_ and source_broadcaster_user_id is set and differs from the
receiving channel. An own cheer derives nothing from the chat message - it stays
the single channel.cheer event. Shared-chat announcements, like own
announcements, produce no event.
Some documented channel.chat.notification notice types produce no event,
whether they come from the own channel or as a shared_chat_* twin:
announcements, unraids, charity donations, modiversaries and gifted Drops
summaries (gifted_drops_summary; its payload is only a recipient_count).
Watch streaks reach Lumio through the IRC path as twitch:watch_streak. Only a
notice type Twitch has not documented is logged for capture (see
Logging).
Partner events are displayed only. They are not the own channel's revenue or activity, so they never count toward stream history and summary stats, never fire automation event triggers, and never touch the own channel's gift-sub straggler safeguard. Channel-points redemptions in a partner channel are not delivered by Twitch; see Channel-points messages.
In the dashboard and popouts a partner event (a row whose raw carries is_shared_chat
and a named source_channel) leads with a channel chip: the square origin avatar plus the
channel name, truncated at about 12em, with the full name in the tooltip and accessible name.
It renders on the chat event line, in the events feed (full and compact, and the dashboard
home activity) and, as name next to the avatar, in the stream report's announcement list,
independent of the label, badge and avatar settings. Shared-chat messages keep the bare
origin avatar. Twitch does not deliver follows of partner channels, so there is no partner
follow row.
The display setting Events from Shared Chat partners (showSharedChatPartnerEvents in
the popout-settings:chat and popout-settings:events blobs, absent = on) hides every event
with raw.is_shared_chat: true in the chat event lines and the events feed. The four session
types twitch:shared_chat_begin|join|leave|end are always shown. It filters at display time
only: buffered events reappear when it is switched back on, and history, counts and alerts
are unaffected.
Stream update payloads (twitch:stream_update, kick:stream_update)
Both platforms report a channel-metadata change as the new state only. Lumio diffs it against the last known state (the baseline) and adds what changed, so the Events page and the chat inline event can show it:
| Field | Twitch | Kick | Meaning |
|---|---|---|---|
title, category_name | yes | yes | New title / category (game) |
language | yes | yes | New broadcast language as the platform sends it (Twitch ISO 639-1 or other, Kick e.g. en) |
title_changed, category_changed, language_changed | yes | yes | Whether that field changed |
old_title, old_category_name, old_language | yes | yes | Previous value — only present when that field changed |
tags | yes | — | Current channel tags (Helix GET /channels) |
tags_changed | yes | — | Whether the tags changed (compared as a set — reordering alone is no change) |
old_tags | yes | — | Previous tags — only present when they changed |
Rules (apps/api/src/services/stream_update_diff.rs):
- No empty events. An update where every
*_changedflag isfalse(a redelivery, a change Lumio does not diff such as Kick's mature flag or Twitch's content-classification labels) is neither stored nor broadcast. - First observation is not a change. A field is compared only when the baseline already knows it; the first observation records the baseline and emits nothing.
- Twitch baseline is seeded from Helix
GET /channels(title, category, language, tags) every time the EventSub session (re)connects, so the first change after an API restart is reported. If that seed fails, the previous baseline stays. - Twitch tags are not part of the
channel.updatepayload; Lumio reads them from HelixGET /channelswhenchannel.updatearrives. If that call fails, thetags*fields are omitted — the title/category/language diff is still emitted. Known limitation: Twitch fireschannel.updateonly for a change to the category, title, content classification labels or broadcast language — a tag-only edit fires nothing, so it is reported together with the next change that does fire. - Kick baseline lives in Postgres (
platform_channel_metadata, one row per account and platform), independent of the live status: Kick lets a streamer edit title and category while offline, and those edits are reported too. The baseline survives restarts; the start reconcile seeds title and category for a new connection (never overwriting an existing baseline), the first webhook fills the language. Kick has no tags.
Stream offline payloads (*:stream_offline)
twitch:stream_offline, youtube:stream_offline and kick:stream_offline
carry how long the stream ran. The two fields are added to the platform's
raw payload; no other field changes:
| Field | Type | Meaning |
|---|---|---|
started_at | string, RFC 3339 (UTC) | When the stream started. For Kick it is Kick's own value, kept exactly as Kick sent it. |
duration_seconds | integer ≥ 0 | ended − started, in whole seconds. For Kick, ended is Kick's ended_at; for Twitch and YouTube it is the moment Lumio processed the offline transition. |
Both or neither. When the start is unknown, or the computed duration would
be negative, both fields are absent - never null and never 0. (For Kick, a
started_at Kick sent itself stays even then; only duration_seconds is
absent.)
Where the start comes from:
- Twitch / YouTube - the
channel_status.started_atof the platform's online rows (MINover them), read before the offline write clears it. Twitch sets it from EventSubstream.onlinestarted_atand, when the API starts mid-stream, from HelixGET /streamsstarted_at, so a restart keeps the duration. YouTube discovery (InnerTube, no Data API quota) has no start time; Lumio stores the moment it first sees the broadcast live, so the YouTube duration can be up to one discovery poll short. The stored start does not move while the broadcast stays online (a title change does not reset it). After an API downtime in which a YouTube stream ended, the startup correction emits the offline event with the duration up to the correction. - Kick - Kick's own
started_at/ended_atfrom thelivestream.status.updatedwebhook. If Kick'sstarted_atis missing or unusable (Kick's zero timestamp), the start captured inchannel_statusfills it; ifended_atis missing, the time of receipt is the end.
The chat line and the events feed show the duration as a clock plaque
(3h 12m).
Types the platform workers actually emit
The type column on a platform_event_logs row is a free-form VARCHAR(100),
so the ingest workers write several types that are not in the extension
catalogue above:
| Source | Additional emitted types |
|---|---|
Twitch EventSub (workers/twitch_eventsub.rs) | twitch:gift (sub gifts - the worker's name for what the extension catalogue calls twitch:gift_sub), twitch:gift_upgrade, twitch:prime_upgrade, twitch:pay_it_forward, twitch:bits_badge, twitch:poll_delete, twitch:raid_out, twitch:raid_done, twitch:unraid, twitch:mod_added, twitch:mod_removed, twitch:vip_added, twitch:vip_removed |
| Chat pin worker / Kick chat socket | twitch:pin_added, twitch:pin_updated, twitch:pin_removed, kick:pin_added, kick:pin_removed |
Twitch IRC-tags listener (workers/twitch_irc_tags.rs) | twitch:first_message (first-time chatter - PRIVMSG first-msg=1), twitch:watch_streak (USERNOTICE viewermilestone / watch-streak; see payload) |
Discord bot (apps/discord-bot) | discord:member_join, discord:member_leave, discord:boost |
7TV EventAPI (workers/seventv_events.rs) | 7tv:emote_added, 7tv:emote_removed, 7tv:emote_renamed, 7tv:emote_set_switched |
7TV emote-set changes (raw)
The 7TV listener watches active emote sets belonging to connected Twitch, Kick,
and YouTube channels. Each change is stored once per account with
platform = "7tv" and an external_id that deduplicates replayed dispatches. These types
are stream events in platform_event_logs, not extension-subscribable types in
LUMIO_EVENT_TYPES. They use the existing events:{account_id} WebSocket
channel and its events:read guard; account-facing reads also require
feature:events. They are not audit events.
| Type | raw fields |
|---|---|
7tv:emote_added, 7tv:emote_removed, 7tv:emote_renamed | emote_id, emote_name, animated, emote_set_id, emote_set_name, actor, platforms; rename also has old_name |
7tv:emote_set_switched | New emote_set_id, emote_set_name, emote_count, actor, platforms |
actor contains the 7TV editor's display_name and login when known, without
an avatar or linked Lumio user. platforms lists the connected chat platforms
whose channels use the set. A 7TV row appears under All and those platform
tabs in chat, including server-merged history. Without platforms it appears
only under All. On the Events page, the 7TV platform filter matches
these rows; a Twitch, Kick, or YouTube Events-page filter does not. The
Events-page type filters include all four types. The 7TV listener guide
explains the subscription and cache behaviour.
History keeps each change separately and follows the account's normal event retention window. Changes made before the listener ran, or during a complete listener outage, are not backfilled by 7TV.
Twitch watch-streak payload
The raw payload of a twitch:watch_streak event:
| Key | Type | Notes |
|---|---|---|
user_id, user_login, user_name | string | The viewer who shared the streak. |
streak_count | number | Consecutive streams watched (msg-param-value). |
category | string | Always watch-streak. |
message_id | string | The IRC id tag. |
message_text | string | null | The viewer's optional shared message. |
emotes | array | null | Twitch emotes in the attached message, in the same shape as a chat message. |
system_message | string | Twitch's system text for the notice. |
badges | ChatBadge[] | Same shape as chat messages: set_id, id, info, image_url. Built from the badges / badge-info tags; info holds the badge-info value (the subscriber's cumulative months), "" otherwise. Always present - a viewer without badges gets []. image_url comes from the channel's Twitch badge cache (the one the EventSub worker builds) and is null when no image is known. |
color | string | null | Name colour as #RRGGBB; null when the viewer never set one. |
avatar_url | string | Best effort, from the profile cache. Omitted when the profile is unknown, has no avatar, or the lookup fails or times out - the event is emitted either way. |
Events stored before badges existed have no badges key; consumers that gate
on badges (for example moderation actions) treat a missing key as "unknown" and
[] as "no badges".
Pin and Twitch role events
The five pin types are twitch:pin_added, twitch:pin_updated,
twitch:pin_removed, kick:pin_added and kick:pin_removed. The platform
worker writes a row only on a state transition; the platform_event_logs
rows are also the pin history protocol. They follow the account's ordinary
event retention. Every pin event has raw.sender; an added event also has
raw.pinned_by and, on replacement, raw.replaced_sender. Those people use
the shape
{platform, user_id, login, display_name, avatar_url, color, badges}.
Every event has raw.message_id and starts_at. An added event also has
text, emotes and ends_at; raw.sent_at is the message's write time
(Twitch stored chat message or Kick socket event), or null when unknown.
An update has old_ends_at, new_ends_at and updated_at. A removal has
ends_at, ended_at, duration_seconds and reason:
unpinned, expired or message_deleted; replacement identifies the old
message. Twitch removals observed by polling may have an approximate event
time. A viewer's support-tool erasure does not delete these channel-owned
event rows. See Stream History for the frozen
report and its separate person-data masking rules.
Twitch channel.moderate v2 yields twitch:mod_added,
twitch:mod_removed, twitch:vip_added and twitch:vip_removed for the
connected channel. Shared Chat partner actions are ignored. Every grant and
removal gets its own event, including quick toggles; the actor's action on
Twitch creates no Lumio audit event. raw carries the target as top-level
user_id, user_login, user_name, avatarUrl and color, and the actor as
initiated_by, initiated_by_login and initiated_by_id. Twitch does not
expose the Lead Moderator distinction, so the role is always Moderator.
Conversely, twitch:ban and twitch:unban appear in the extension catalogue
but are not written to platform_event_logs - Twitch ban/unban is handled on the
chat path (see Chat).
Community gift-sub bundling (Twitch)
A community gift of N subs arrives as N+1 EventSub notifications: one
community_sub_gift header plus N individual sub_gift notices that each carry
the same community_gift_id. The events panel and popout show only the header row -
the individual sub_gift rows are persisted for raw data but hidden by the history
filter (a sub_gift row with a community_gift_id). The header line itself is
broadcast the moment it is stored, so a bundle always reaches the live feed even if
no per-recipient notice ever arrives. The recipient names render from a
giftRecipients array patched onto the header - all of them, on every surface
(events feed, inline chat event line, and the user card): the list is never shortened
to the first few names with a "+N", and never clipped. It wraps and grows the row
instead, so a gift of any size names everyone who received one. The header also carries
an index-aligned giftRecipientLogins array with each recipient's login/handle, so the
client can show name (login) per recipient (Twitch only - YouTube gift memberships have
no per-recipient handle, so their bundles stay display-name only).
YouTube login twin (user_login)
Every InnerTube-sourced YouTube event (youtube:superchat, youtube:supersticker,
youtube:member, youtube:gift_membership, youtube:gift_membership_received)
carries a user_login field next to displayName, mirroring the Twitch login twin
so the events feed resolves the subject's login the same way on both platforms
(apps/web/src/lib/event-user.ts). It holds the channel @handle with the @
stripped. This twin still feeds bot variables, automations and moderation
(ZAF-1478); it is not shown as a render bracket, because the localized-name
bracket is gated to Twitch (ZAF-1452 / ZAF-1549 — see the localized-subject-names
note above). YouTube "channel title ≠ handle" is the normal case, so surfacing
こさむん (@kosamun) would fire on every non-Latin channel title — exactly the "not
on every chatter" the founder rejected. formatPlatformName therefore renders a
YouTube subject as the bare display name (こさむん). The @-prefix rendering is
retained in the helper (decorateLoginForPlatform) but unreachable while the
allowlist is Twitch-only; adding "youtube" to LOCALIZED_DISPLAY_NAME_PLATFORMS
would restore it with no other change.
That recipient list is derived from the database, not from in-memory correlation
state: every individual sub_gift row is persisted with its community_gift_id
regardless of arrival order, and finalisation reads the names back from those rows
(lo_events::gift_recipient_names, with the index-aligned logins from
lo_events::gift_recipient_logins). The worker keeps only a lightweight in-memory
arrival counter per bundle; it patches giftRecipients (and giftRecipientLogins)
onto the stored header and
re-broadcasts it - reusing the header's id + created_at so the live line and the
history row stay one event - when the counter reaches the header's total or when
a periodic timer ages the bundle out (so the enrichment fires even on a quiet channel
with no further traffic). The bundle tracker outlives WebSocket reconnects. A bundle
therefore lands its complete recipient list even when the individual notices
arrive before the header, when Twitch under-delivers them, or across a reconnect. If
no individual rows are persisted yet when a bundle is finalised, the header is left
un-enriched (its line already showed) and a startup backfill
(lo_events::backfill_gift_recipients, paginated over the full backlog) repairs it
once the rows land.
Internal WebSocket channel messages
These share the namespace:action shape but are transient pub/sub payloads, not
platform_event_logs rows: chat:message, chat:delete, chat:clear_user,
chat:moderation_log, chat:user_treatment_update, channel:online,
channel:offline, copyright:blocked/:warning/:clear,
obs:action/:connection_lost/:low_bitrate/:high_dropped_frames/:recovery,
history:session_started/:session_updated, sound:play, overlay:update,
widget:update, storage:update, extension:updated.
Data Flow
- Platform adapter detects an event (e.g., Twitch EventSub webhook receives a follow).
- Event is inserted into TimescaleDB via
lo_events::insert_event(). - Event is broadcast via
lo_events::broadcast_event()through Redis pub/sub. - WebSocket server forwards the event to all connected clients for the account.
- Overlay alerts render the event. The Events page, home event panel, and popout update in real time.
- Users can query historical events with filters and pagination via
eventsquery.
Motion preferences
Events follows the user's Animations in Lumio preference. Its settings panel can tighten motion for this view per browser and user/account pair, and ?motion=reduced|off can tighten one popout URL. New event rows fade briefly at Reduced and appear immediately at Off; scrolling is instant at either level. The seasonal animation row affects decoration only. See the Motion developer guide.
Settings export/import (browser-local)
The events display options (popout-settings:events) and the event filters
(lumio:event-filters, which platforms/types are shown) are part of the shared,
browser-local settings export/import feature (ZAF-622). A user can save them and
re-import them on another device or an OBS source; it is purely client-side (no
API/RBAC/audit). Four transports carry the same envelope - a copy-pasteable LS1…
code string, a share link (the same code in a URL fragment, so still no
server), Copy JSON, and a downloaded file - and the import auto-detects a
pasted file, code, or link in one field. The events.filters payload keeps its
allPlatforms/allTypes markers so newly shipped event types auto-enable on the
next load rather than being silently hidden by an older file. See the full format,
transports, section list, exclusions and import semantics in the
Multichat feature doc.
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/events.rs | GraphQL queries and mutations |
apps/api/src/routes/events.rs | REST endpoints |
crates/lo-events/src/ | Core events crate - TimescaleDB insert/list plus Redis broadcast and the filter types |
apps/api/src/workers/event_relay.rs | Relays platform-worker events onto the account's WebSocket channel |
packages/protocol/src/events.ts | LUMIO_EVENT_TYPES / ACTION_TYPES enums consumed by the extension SDK |
apps/web/src/components/events-shell.tsx | Shared live-feed component behind the Events page, dashboard panel, and popout |
apps/web/src/hooks/use-event-filters.ts | Browser-local event-filter state; also exports parseStoredEventFilters/expandEventFilters reused by the settings export/import registry |