Skip to main content

heatsync_core/
proto.rs

1//! HeatSync `/ws` relay protocol — parse inbound frames, build outbound ones.
2//! anon read is allowed, so no auth is required to consume chat. content and
3//! usernames are sanitized HERE, at the trust boundary, before anything else
4//! touches them (terminal-injection defense).
5
6use serde_json::{json, Value};
7
8use crate::emote::{self, Emote};
9use crate::{sanitize, Badge, Extra, Note, NoteKind, Pin, Platform, TabKind, Tag, TagKind, Tint};
10
11/// a normalized chat line, ready for a channel buffer.
12#[derive(Clone, Debug, PartialEq)]
13pub struct ChatLine {
14    pub platform: Platform,
15    pub channel: String,
16    /// the platform's own message id — what a later delete points at.
17    pub id: Option<String>,
18    pub user: String,
19    pub color: Option<String>,
20    pub badges: Vec<Badge>,
21    pub reply_to: Option<String>,
22    /// set when this line is an event notice, not plain chat.
23    pub note: Option<Note>,
24    /// sender id in paint space (see `Message::uid`).
25    pub uid: Option<String>,
26    pub extra: Extra,
27    /// raw stream lifecycle facts behind a go-live / offline / game-switch
28    /// note — what `streamev` pairs and dedupes on.
29    pub stream: Option<StreamEv>,
30    pub content: String,
31    /// this message's own emote refs (relay `hsEmotes`, twitch `emotes` tag,
32    /// kick `[emote:id:name]` tokens) — highest priority when parsing, see
33    /// [`emote::segments_with`].
34    pub emotes: Vec<Emote>,
35}
36
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum StreamKind {
39    Online,
40    Offline,
41    Update,
42}
43
44#[derive(Clone, Debug, PartialEq)]
45pub struct StreamEv {
46    pub kind: StreamKind,
47    pub game: String,
48    pub prev_game: String,
49    /// from a followed-channel push (`follow:stream:*`), not a joined room.
50    pub follow: bool,
51}
52
53/// a line bound for a virtual tab: the row itself plus its labels. `heat` is
54/// the server's number (feed rows only).
55#[derive(Clone, Debug, PartialEq)]
56pub struct SpecialLine {
57    pub tab: TabKind,
58    pub line: ChatLine,
59    pub tags: Vec<Tag>,
60    pub heat: f64,
61    /// who a whisper / dm came from, with the id a reply needs.
62    pub peer: Option<crate::social::Contact>,
63}
64
65/// whose lines a moderation event removes.
66#[derive(Clone, Debug, PartialEq)]
67pub enum Target {
68    /// one message, by platform id.
69    Msg(String),
70    /// every line from one user (login or display name, case-insensitive).
71    User(String),
72    /// the whole channel.
73    All,
74}
75
76/// a moderation event: the lines stay on screen, struck through and tagged
77/// with `label` ("deleted", "timed out 10m", "banned", "cleared").
78#[derive(Clone, Debug, PartialEq)]
79pub struct Delete {
80    pub platform: Platform,
81    /// the tab's channel; empty = every channel of `platform` (youtube's
82    /// delete frame is keyed by message id alone).
83    pub channel: String,
84    pub target: Target,
85    pub label: String,
86}
87
88/// what an inbound frame turned into.
89#[derive(Clone, Debug, PartialEq)]
90pub enum Event {
91    Chat(ChatLine),
92    Backfill(Vec<ChatLine>),
93    /// lines removed by a mod, plus the mod-log note to show (clearchat has
94    /// one; a single deleted message doesn't).
95    Delete {
96        line: Option<ChatLine>,
97        del: Delete,
98    },
99    /// a mention-rule hit, whisper, DM or feed row for a virtual tab.
100    Special(SpecialLine),
101    /// a feed row's heat moved (`heat:updated`).
102    FeedHeat {
103        id: String,
104        heat: f64,
105    },
106    /// a feed row was hidden by moderation (`message:hidden`).
107    FeedHidden {
108        id: String,
109    },
110    /// several events from one frame, in order.
111    Batch(Vec<Event>),
112    /// authentication outcome (true = ok).
113    Auth(bool),
114    /// result of an outbound send: ok, or an error reason.
115    SendResult {
116        ok: bool,
117        error: Option<String>,
118    },
119    /// followed channels already live at connect (`follow:live:snapshot`) —
120    /// catch-up, never lines.
121    FollowLive(Vec<(Platform, String)>),
122    /// the user's last day of follow events (`follow:history`), oldest first:
123    /// each a follow stream line plus its unix-ms time.
124    FollowHistory(Vec<(ChatLine, u64)>),
125    /// `follow:colors`: channel login (lowercase) → `#rrggbb`.
126    FollowColors(Vec<(String, String)>),
127    /// a channel pin was set (`Some`) or cleared (`None`).
128    Pin(Platform, String, Option<Pin>),
129    /// a twitch ROOMSTATE: the channel's current chat modes.
130    Room(String, RoomState),
131    Pong,
132    /// `profile:color`: a user's picked name color, by paint-space id.
133    Color {
134        /// paint-space id, when the push named one.
135        id: Option<String>,
136        /// the user's linked usernames, lowercased (platform not stated).
137        names: Vec<String>,
138        color: String,
139    },
140    /// `cosmetic:changed`: paints changed for these paint-space ids.
141    Cosmetic(Vec<String>),
142    /// `message-edited` / `message-updated`: a feed row's text (and the emote
143    /// refs it now renders with) changed.
144    FeedEdit {
145        id: String,
146        text: String,
147        emotes: Vec<Emote>,
148    },
149    /// `account:changed`: linked platforms or scopes moved — re-ask who I am.
150    Account,
151    /// `follows:changed`: the follow list moved — refetch it.
152    Follows,
153    Ignore,
154}
155
156/// a channel's chat modes as one ROOMSTATE carried them: `None` = the tag was
157/// absent, i.e. unchanged (twitch's later ROOMSTATEs list only what changed).
158/// `followers` is minutes (-1 = off, 0 = any follower).
159#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
160pub struct RoomState {
161    pub slow: Option<u32>,
162    pub followers: Option<i32>,
163    pub emote_only: Option<bool>,
164    pub subs_only: Option<bool>,
165}
166
167/// parse one inbound text frame. unknown / malformed frames become `Ignore`
168/// rather than erroring — a hostile or new server can't crash the client.
169pub fn parse(raw: &str) -> Event {
170    let v: Value = match serde_json::from_str(raw) {
171        Ok(v) => v,
172        Err(_) => return Event::Ignore,
173    };
174    match v.get("type").and_then(Value::as_str).unwrap_or("") {
175        // the irc:message frame multiplexes every twitch IRC record type —
176        // chat, usernotice (subs/gifts/raids/announcements), moderation.
177        // roomstate/clearmsg are state we don't surface as lines.
178        "irc:message" if is_irc(&v, "clearmsg") => clearmsg_event(&v),
179        "irc:message" if is_irc(&v, "clearchat") => clearchat_event(&v),
180        "irc:message" if is_irc(&v, "roomstate") => roomstate_event(&v),
181        "irc:message" => v
182            .get("message")
183            .and_then(|m| match m.get("type").and_then(Value::as_str) {
184                Some("usernotice") => usernotice_line(m, v.get("channel")),
185                Some("notice") => notice_line(m, v.get("channel")),
186                Some("privmsg") | None => line_from(m, Platform::Twitch, v.get("channel")),
187                _ => None,
188            })
189            .map(Event::Chat)
190            .unwrap_or(Event::Ignore),
191        // stream lifecycle + hype + redemptions, all platforms (the frame
192        // carries its platform). twitch stream:sub / stream:sub-gift are
193        // deliberately NOT parsed — the IRC usernotice already covers every
194        // channel, and connected broadcasters would double-post.
195        "stream:online" | "stream:offline" | "stream:update" | "stream:raid" | "stream:redeem"
196        | "stream:hype-start" | "stream:hype-end" | "moment:spike" => {
197            stream_line(&v).map(Event::Chat).unwrap_or(Event::Ignore)
198        }
199        "follow:stream:online" | "follow:stream:offline" | "follow:stream:update" => {
200            stream_line(&v).map(Event::Chat).unwrap_or(Event::Ignore)
201        }
202        "follow:history" => follow_history(&v),
203        "follow:colors" => follow_colors(&v),
204        "pin:set" | "pin:clear" => pin_event(&v),
205        "follow:live:snapshot" => follow_snapshot(&v),
206        "kick-sub-event" => kick_sub_line(&v).map(Event::Chat).unwrap_or(Event::Ignore),
207        "kick-kicks-event" => kick_kicks_line(&v)
208            .map(Event::Chat)
209            .unwrap_or(Event::Ignore),
210        "youtube:status" => yt_status_line(&v).map(Event::Chat).unwrap_or(Event::Ignore),
211        "kick-chat-message" => v
212            .get("data")
213            .and_then(|d| line_from(d, Platform::Kick, d.get("channel")))
214            .map(Event::Chat)
215            .unwrap_or(Event::Ignore),
216        "kick-chat-backfill" => {
217            let lines = v
218                .get("messages")
219                .and_then(Value::as_array)
220                .map(|arr| {
221                    arr.iter()
222                        .filter_map(|d| line_from(d, Platform::Kick, d.get("channel")))
223                        .collect()
224                })
225                .unwrap_or_default();
226            Event::Backfill(lines)
227        }
228        // youtube arrives batched — live and replay share one frame type, so a
229        // batch is always a Backfill (the receiver records lines in order).
230        "youtube:chat" => {
231            let video = v.get("videoId").and_then(Value::as_str).unwrap_or("");
232            let lines = v
233                .get("messages")
234                .and_then(Value::as_array)
235                .map(|arr| arr.iter().filter_map(|m| yt_line(m, video)).collect())
236                .unwrap_or_default();
237            Event::Backfill(lines)
238        }
239        "youtube:delete" => youtube_delete_event(&v),
240        "mention:rule-match" => mention_event(&v),
241        "whisper:new" => whisper_event(&v, TabKind::Whispers),
242        "dm:new" => whisper_event(&v, TabKind::Whispers),
243        "new-message" => feed_row(&v).map(Event::Special).unwrap_or(Event::Ignore),
244        "heat:updated" => match (
245            str_or_num(&v, &["messageId", "message_id", "id"]),
246            v.get("newHeat").and_then(Value::as_f64),
247        ) {
248            (Some(id), Some(heat)) => Event::FeedHeat { id, heat },
249            _ => Event::Ignore,
250        },
251        "message:hidden" => match str_or_num(&v, &["id", "messageId"]) {
252            Some(id) => Event::FeedHidden { id },
253            None => Event::Ignore,
254        },
255        "authenticated" => Event::Auth(true),
256        "authentication_failed" => Event::Auth(false),
257        "chat:send_kick_result" | "chat:send_youtube_result" => Event::SendResult {
258            ok: v.get("ok").and_then(Value::as_bool).unwrap_or(false),
259            error: v
260                .get("error")
261                .and_then(Value::as_str)
262                .map(|s| s.to_string()),
263        },
264        "pong" => Event::Pong,
265        // a user's name color changed on heatsync (paint space id).
266        "profile:color" => {
267            let id = v
268                .get("userId")
269                .and_then(Value::as_str)
270                .and_then(|s| paint_id("", s));
271            // bounded: a hostile frame can't make us hold a wall of names
272            let names: Vec<String> = v
273                .get("usernames")
274                .and_then(Value::as_array)
275                .map(|a| {
276                    a.iter()
277                        .filter_map(Value::as_str)
278                        .filter(|s| !s.is_empty() && s.len() <= 64)
279                        .map(|s| sanitize::clean(s).to_lowercase())
280                        .take(16)
281                        .collect()
282                })
283                .unwrap_or_default();
284            match v.get("color").and_then(color_str) {
285                Some(color) if id.is_some() || !names.is_empty() => Event::Color {
286                    id,
287                    names,
288                    color: color.to_string(),
289                },
290                _ => Event::Ignore,
291            }
292        }
293        // paints / badges changed for these ids — refetch what's cached.
294        "cosmetic:changed" => Event::Cosmetic(
295            v.get("ids")
296                .and_then(Value::as_array)
297                .map(|a| {
298                    a.iter()
299                        .filter_map(Value::as_str)
300                        .filter_map(|s| paint_id("", s))
301                        .collect()
302                })
303                .unwrap_or_default(),
304        ),
305        "message-edited" | "message-updated" => feed_edit(&v),
306        "account:changed" => Event::Account,
307        "follows:changed" => Event::Follows,
308        _ => Event::Ignore,
309    }
310}
311
312/// longest edited feed text we take (the site caps posts well below this).
313const MAX_EDIT_TEXT: usize = 8_000;
314/// longest message id we take.
315const MAX_ID: usize = 128;
316
317/// `message-edited` {message_id, content, emote_refs} or `message-updated` (the
318/// whole row, `base36_id`), bare or wrapped in `data`. no id or no text → nothing.
319fn feed_edit(v: &Value) -> Event {
320    let m = v.get("data").filter(|d| d.is_object()).unwrap_or(v);
321    let Some(id) = str_or_num(m, &["base36_id", "message_id", "id"]).filter(|s| s.len() <= MAX_ID)
322    else {
323        return Event::Ignore;
324    };
325    let text = field(m, "content");
326    if text.is_empty() {
327        return Event::Ignore;
328    }
329    let text = text.chars().take(MAX_EDIT_TEXT).collect();
330    let emotes = refs_from(m.get("emote_refs").or_else(|| m.get("emoteRefs")));
331    Event::FeedEdit { id, text, emotes }
332}
333
334/// pull a ChatLine out of a message object. drops lines with empty user+text.
335fn line_from(m: &Value, platform: Platform, channel: Option<&Value>) -> Option<ChatLine> {
336    let user_raw = m
337        .get("displayName")
338        .and_then(Value::as_str)
339        .filter(|s| !s.is_empty())
340        .or_else(|| m.get("username").and_then(Value::as_str))
341        .unwrap_or("");
342    let content_raw = m.get("content").and_then(Value::as_str).unwrap_or("");
343    // native emote positions / tokens read the RAW text (positions are in the
344    // sender's code points), before sanitizing shifts anything.
345    let (content_raw, emotes) = msg_emotes(m, platform, content_raw);
346    let user = sanitize::clean(user_raw);
347    let content = sanitize::clean(&content_raw);
348    let content = crate::dupe::strip(&content).to_string();
349    if user.is_empty() && content.is_empty() {
350        return None;
351    }
352    let color = color_of(m);
353    let channel = channel.and_then(Value::as_str).unwrap_or("").to_string();
354    // a chat line can still be an event: cheered bits, a reward redemption
355    // with text, a highlighted message. the note marks it, the text stays chat.
356    let note = if let Some(bits) = m.get("bits").and_then(Value::as_u64).filter(|b| *b > 0) {
357        Some(Note {
358            kind: NoteKind::Cheer,
359            what: format!("cheered {bits} bits"),
360        })
361    } else if m
362        .get("isRedemption")
363        .and_then(Value::as_bool)
364        .unwrap_or(false)
365    {
366        Some(Note {
367            kind: NoteKind::Redeem,
368            what: "redeemed".into(),
369        })
370    } else {
371        None
372    };
373    Some(ChatLine {
374        platform,
375        channel,
376        id: id_of(m),
377        user,
378        color,
379        badges: badges_from(m.get("badges")),
380        reply_to: m
381            .get("replyTo")
382            .and_then(|r| r.get("username"))
383            .and_then(Value::as_str)
384            .map(sanitize::clean)
385            .filter(|s| !s.is_empty()),
386        note,
387        extra: extra_of(m),
388        stream: None,
389        content,
390        uid: uid_of(m, platform),
391        emotes,
392    })
393}
394
395/// a `{name: {url, provider, zeroWidth}}` ref map → refs (relay `hsEmotes`,
396/// feed `emote_refs`). cw stubs ({name, cw}, no url) carry nothing to draw —
397/// the word stays text rather than leak the hidden image.
398fn refs_from(v: Option<&Value>) -> Vec<Emote> {
399    let Some(map) = v.and_then(Value::as_object) else {
400        return Vec::new();
401    };
402    map.iter()
403        .take(MAX_REFS)
404        .filter_map(|(name, r)| {
405            let url = r.get("url").and_then(Value::as_str)?;
406            let provider = r
407                .get("provider")
408                .and_then(Value::as_str)
409                .filter(|p| !p.is_empty())
410                .unwrap_or("heatsync");
411            let zw = r.get("zeroWidth").and_then(Value::as_bool).unwrap_or(false);
412            emote::msg_ref(name, url, provider, zw)
413        })
414        .collect()
415}
416
417/// most refs one message may carry — a hostile relay can't make us hold more.
418const MAX_REFS: usize = 64;
419
420/// a message's emote refs, plus its text (kick tokens are rewritten to names).
421/// order is priority: the relay's `hsEmotes` (the sender's inventory emotes,
422/// resolved server-side) first, then the platform's native ids.
423fn msg_emotes(m: &Value, platform: Platform, content: &str) -> (String, Vec<Emote>) {
424    let mut refs: Vec<Emote> = Vec::new();
425    let mut add = |e: Emote| {
426        if refs.len() < MAX_REFS && !refs.iter().any(|r| r.name == e.name) {
427            refs.push(e);
428        }
429    };
430    for e in refs_from(m.get("hsEmotes")) {
431        add(e);
432    }
433    let mut text = content.to_string();
434    match platform {
435        Platform::Twitch => {
436            if let Some(tag) = m.get("emotes").and_then(Value::as_str) {
437                for e in emote::twitch_native(tag, content) {
438                    add(e);
439                }
440            }
441            // relays that pre-resolve the tag send name → url instead
442            if let Some(map) = m.get("twitchEmotes").and_then(Value::as_object) {
443                for (name, url) in map.iter().take(MAX_REFS) {
444                    if let Some(e) = url
445                        .as_str()
446                        .and_then(|u| emote::msg_ref(name, u, "twitch", false))
447                    {
448                        add(e);
449                    }
450                }
451            }
452        }
453        Platform::Kick => {
454            let (rewritten, native) = emote::kick_native(content);
455            text = rewritten;
456            for e in native {
457                add(e);
458            }
459        }
460        _ => {}
461    }
462    (text, refs)
463}
464
465/// a `#rrggbb`/`#rgb` user color, if the object carries a valid one.
466fn color_of(m: &Value) -> Option<String> {
467    m.get("color")
468        .and_then(Value::as_str)
469        .filter(|s| s.starts_with('#') && (s.len() == 7 || s.len() == 4))
470        .map(|s| s.to_string())
471}
472
473/// a `#rrggbb` string.
474fn color_str(v: &Value) -> Option<&str> {
475    v.as_str().filter(|s| {
476        s.len() == 7 && s.starts_with('#') && s[1..].bytes().all(|b| b.is_ascii_hexdigit())
477    })
478}
479
480/// a paint-space id: `prefix` + a safe token (the server's id charset), or
481/// `None` for anything that isn't one.
482fn paint_id(prefix: &str, raw: &str) -> Option<String> {
483    let ok = !raw.is_empty()
484        && raw.len() <= 64
485        && raw
486            .bytes()
487            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
488    ok.then(|| format!("{prefix}{raw}"))
489}
490
491/// a stream frame's streamer id in paint space: the platform's own id as a
492/// string (twitch id, kick id, youtube channel id), prefixed like chat's.
493/// anything that isn't a short safe token is dropped.
494pub(crate) fn stream_uid(v: &Value, platform: Platform) -> Option<String> {
495    let id = v.get("userId").and_then(Value::as_str)?;
496    match platform {
497        Platform::Twitch => paint_id("", id),
498        Platform::Kick => paint_id("kick_", id),
499        Platform::Youtube => paint_id("yt_", id),
500    }
501}
502
503/// the sender's paint-space id: twitch's bare user id, kick's `kick_<id>`
504/// (kick ids overlap twitch's range, so they must never go bare). the relay
505/// sends `userId`; kick frames may name it `senderId`.
506fn uid_of(m: &Value, platform: Platform) -> Option<String> {
507    let id = ["userId", "senderId"]
508        .iter()
509        .find_map(|k| match m.get(*k)? {
510            Value::String(s) => Some(s.clone()),
511            Value::Number(n) => Some(n.to_string()),
512            _ => None,
513        })?;
514    match platform {
515        Platform::Twitch => paint_id("", &id),
516        Platform::Kick => paint_id("kick_", &id),
517        Platform::Youtube => None,
518    }
519}
520
521/// reply snippet + action / first / highlight flags off a message object.
522fn extra_of(m: &Value) -> Extra {
523    let flag = |k: &str| m.get(k).and_then(Value::as_bool).unwrap_or(false);
524    Extra {
525        reply_text: m
526            .get("replyTo")
527            .map(|r| field(r, "content"))
528            .unwrap_or_default(),
529        action: flag("isAction"),
530        first: flag("isFirstMsg"),
531        highlight: flag("isHighlighted"),
532        ts: 0,
533    }
534}
535
536/// sanitized string field, empty when absent.
537pub(crate) fn field(v: &Value, k: &str) -> String {
538    sanitize::clean(v.get(k).and_then(Value::as_str).unwrap_or(""))
539}
540
541/// platform system messages open with the actor's name ("RealCK3 subscribed
542/// at Tier 1…"). strip it so the colored username isn't printed twice; a
543/// headline that doesn't open with the actor is self-contained (raid counts,
544/// mystery-gift totals), so the separate actor is dropped instead.
545fn split_headline(user: &mut String, what: &str) -> String {
546    if user.is_empty() || what.is_empty() {
547        return what.to_string();
548    }
549    if let Some(rest) = what.strip_prefix(user.as_str()) {
550        // only a word boundary counts — user "a" must not eat "announcement".
551        if rest.chars().next().is_some_and(|c| !c.is_alphanumeric()) {
552            let rest = rest.trim_start_matches(':').trim_start();
553            if !rest.is_empty() {
554                return rest.to_string();
555            }
556        }
557    }
558    user.clear();
559    what.to_string()
560}
561
562/// terse duration for timeouts: 45s, 10m, 2h, 1d.
563pub(crate) fn fmt_secs(s: u64) -> String {
564    if s >= 86_400 && s.is_multiple_of(86_400) {
565        format!("{}d", s / 86_400)
566    } else if s >= 3_600 && s.is_multiple_of(3_600) {
567        format!("{}h", s / 3_600)
568    } else if s >= 60 && s.is_multiple_of(60) {
569        format!("{}m", s / 60)
570    } else {
571        format!("{s}s")
572    }
573}
574
575/// a bare note line — no chat text, just the event.
576pub(crate) fn note_line(
577    platform: Platform,
578    channel: String,
579    user: String,
580    color: Option<String>,
581    kind: NoteKind,
582    what: String,
583    content: String,
584) -> Option<ChatLine> {
585    if what.is_empty() && content.is_empty() {
586        return None;
587    }
588    Some(ChatLine {
589        platform,
590        channel,
591        id: None,
592        user,
593        color,
594        badges: Vec::new(),
595        reply_to: None,
596        note: Some(Note { kind, what }),
597        extra: Extra::default(),
598        stream: None,
599        content,
600        uid: None,
601        emotes: Vec::new(),
602    })
603}
604
605/// twitch USERNOTICE → inline event. the system message is the headline;
606/// `content` (a resub message) stays chat text under it.
607fn usernotice_line(m: &Value, channel: Option<&Value>) -> Option<ChatLine> {
608    let sub = m.get("subType").and_then(Value::as_str).unwrap_or("");
609    let kind = match sub {
610        "sub" | "resub" => NoteKind::Sub,
611        "subgift"
612        | "submysterygift"
613        | "giftpaidupgrade"
614        | "primepaidupgrade"
615        | "standardpayforward"
616        | "communitypayforward" => NoteKind::Gift,
617        "raid" | "unraid" => NoteKind::Raid,
618        "announcement" => NoteKind::Announce(Tint::from_name(&field(m, "announceColor"))),
619        "watchstreak" => NoteKind::Streak,
620        "viewermilestone" => NoteKind::Milestone,
621        "charitydonation" | "charity_donation" => NoteKind::Charity,
622        "ritual" | "new_chatter" => NoteKind::Ritual,
623        _ => NoteKind::Notice, // future subtypes
624    };
625    let mut user = {
626        let d = field(m, "displayName");
627        if d.is_empty() {
628            field(m, "username")
629        } else {
630            d
631        }
632    };
633    let text = field(m, "content");
634    let sys = field(m, "systemMessage");
635    let mut what = split_headline(&mut user, &sys);
636    if what.is_empty() && sub == "announcement" {
637        what = "announced".into();
638    }
639    let channel = channel.and_then(Value::as_str).unwrap_or("").to_string();
640    let color = color_of(m);
641    note_line(Platform::Twitch, channel, user, color, kind, what, text)
642}
643
644/// a pin object (`pin:set`'s `pin`, or `/api/pin`'s) → [`Pin`]; none without text.
645pub fn pin_from(p: &Value) -> Option<Pin> {
646    let user = {
647        let d = field(p, "display_name");
648        if d.is_empty() {
649            field(p, "username")
650        } else {
651            d
652        }
653    };
654    let text = field(p, "content");
655    (!text.is_empty()).then(|| Pin {
656        id: field(p, "id"),
657        user,
658        text,
659    })
660}
661
662fn platform_of(s: &str) -> Option<Platform> {
663    match s {
664        "twitch" => Some(Platform::Twitch),
665        "kick" => Some(Platform::Kick),
666        "youtube" => Some(Platform::Youtube),
667        _ => None,
668    }
669}
670
671fn pin_event(v: &Value) -> Event {
672    let (Some(p), ch) = (platform_of(&field(v, "platform")), field(v, "channel")) else {
673        return Event::Ignore;
674    };
675    if ch.is_empty() {
676        return Event::Ignore;
677    }
678    match v.get("type").and_then(Value::as_str) {
679        Some("pin:set") => match v.get("pin").and_then(pin_from) {
680            Some(pin) => Event::Pin(p, ch, Some(pin)),
681            None => Event::Ignore,
682        },
683        _ => Event::Pin(p, ch, None),
684    }
685}
686
687fn roomstate_event(v: &Value) -> Event {
688    let (Some(m), Some(ch)) = (v.get("message"), v.get("channel").and_then(Value::as_str)) else {
689        return Event::Ignore;
690    };
691    let flag = |k: &str| m.get(k).and_then(Value::as_bool);
692    let n = |k: &str| m.get(k).and_then(Value::as_i64);
693    Event::Room(
694        ch.to_string(),
695        RoomState {
696            slow: n("slow").map(|x| x.clamp(0, u32::MAX as i64) as u32),
697            followers: n("followersOnly").map(|x| x.clamp(-1, i32::MAX as i64) as i32),
698            emote_only: flag("emoteOnly"),
699            subs_only: flag("subsOnly"),
700        },
701    )
702}
703
704/// first present key as a string (numbers allowed — ids arrive either way).
705pub(crate) fn str_or_num(v: &Value, keys: &[&str]) -> Option<String> {
706    keys.iter().find_map(|k| match v.get(*k)? {
707        Value::String(s) if !s.is_empty() => Some(s.clone()),
708        Value::Number(n) => Some(n.to_string()),
709        _ => None,
710    })
711}
712
713pub(crate) fn platform_or_twitch(s: &str) -> Platform {
714    match s {
715        "kick" => Platform::Kick,
716        "youtube" | "yt" => Platform::Youtube,
717        _ => Platform::Twitch,
718    }
719}
720
721/// a bare chat line for a virtual tab.
722pub(crate) fn special_chat(
723    platform: Platform,
724    channel: String,
725    id: Option<String>,
726    user: String,
727    color: Option<String>,
728    content: String,
729    emotes: Vec<Emote>,
730) -> ChatLine {
731    ChatLine {
732        platform,
733        channel,
734        id,
735        user,
736        color,
737        badges: Vec::new(),
738        reply_to: None,
739        note: None,
740        content,
741        emotes,
742        stream: None,
743        extra: Extra::default(),
744        uid: None,
745    }
746}
747
748/// `mention:rule-match` {ruleId, pattern, channel, platform, username, snippet,
749/// url} → a mentions-tab line. the url is the server's deep link; we have no
750/// use for it in a terminal.
751fn mention_event(v: &Value) -> Event {
752    let platform = platform_or_twitch(v.get("platform").and_then(Value::as_str).unwrap_or(""));
753    let channel = field(v, "channel");
754    let user = field(v, "username");
755    let snippet = field(v, "snippet");
756    if user.is_empty() && snippet.is_empty() {
757        return Event::Ignore;
758    }
759    let tags = vec![
760        Tag::platform(),
761        Tag::new(&format!("[{channel}]"), TagKind::Origin),
762    ];
763    Event::Special(SpecialLine {
764        tab: TabKind::Mentions,
765        line: special_chat(platform, channel, None, user, None, snippet, Vec::new()),
766        tags,
767        heat: 0.0,
768        peer: None,
769    })
770}
771
772/// `whisper:new` {from_twitch_login, from_display_name, from_color,
773/// to_twitch_login, content, created_at} and `dm:new` {from_display_name,
774/// from_color, content, created_at, from_user_id}. the frame type picks the
775/// label; both land in the whispers tab.
776fn whisper_event(v: &Value, tab: TabKind) -> Event {
777    let is_dm = v.get("type").and_then(Value::as_str) == Some("dm:new");
778    let name = {
779        let d = field(v, "from_display_name");
780        if d.is_empty() {
781            field(v, "from_twitch_login")
782        } else {
783            d
784        }
785    };
786    let content = field(v, "content");
787    if name.is_empty() && content.is_empty() {
788        return Event::Ignore;
789    }
790    let color = v
791        .get("from_color")
792        .and_then(Value::as_str)
793        .filter(|s| s.starts_with('#') && (s.len() == 7 || s.len() == 4))
794        .map(str::to_string);
795    let peer = if is_dm {
796        str_or_num(v, &["from_user_id"])
797    } else {
798        str_or_num(v, &["from_twitch_id"])
799    }
800    .and_then(|id| {
801        let login = match field(v, "from_twitch_login") {
802            l if l.is_empty() => name.clone(),
803            l => l,
804        };
805        crate::social::contact(name.clone(), &login, id, is_dm)
806    });
807    let tag = if is_dm {
808        Tag::new("[DM]", TagKind::Dm)
809    } else {
810        Tag::new("[whisper]", TagKind::Whisper)
811    };
812    Event::Special(SpecialLine {
813        tab,
814        line: special_chat(
815            Platform::Twitch,
816            String::new(),
817            None,
818            name,
819            color,
820            content,
821            Vec::new(),
822        ),
823        tags: vec![tag],
824        heat: 0.0,
825        peer,
826    })
827}
828
829/// the heatsync feed's `new-message` row (flat payload; `GET /api/messages`
830/// rows share the shape): base36_id, content, username / display_name,
831/// user_color, platform, heat, is_op, is_thread_op, emote_refs.
832pub fn feed_row(v: &Value) -> Option<SpecialLine> {
833    let id = str_or_num(v, &["base36_id", "id"])?;
834    let user = {
835        let d = field(v, "display_name");
836        if d.is_empty() {
837            field(v, "username")
838        } else {
839            d
840        }
841    };
842    let content = field(v, "content");
843    if user.is_empty() && content.is_empty() {
844        return None;
845    }
846    let flag = |k: &str| v.get(k).and_then(Value::as_bool).unwrap_or(false);
847    let role = if flag("is_thread_op") {
848        Tag::new("[OP]", TagKind::ThreadOp)
849    } else if flag("is_op") {
850        Tag::new("[OP]", TagKind::Op)
851    } else {
852        Tag::new("[RE]", TagKind::Reply)
853    };
854    let emotes = refs_from(v.get("emote_refs").or_else(|| v.get("emoteRefs")));
855    Some(SpecialLine {
856        tab: TabKind::Feed,
857        line: special_chat(
858            platform_or_twitch(v.get("platform").and_then(Value::as_str).unwrap_or("")),
859            String::new(),
860            Some(id),
861            user,
862            color_of(&json!({"color": v.get("user_color").cloned().unwrap_or(Value::Null)})),
863            content,
864            emotes,
865        ),
866        tags: vec![role, Tag::new("", TagKind::Heat)],
867        heat: v.get("heat").and_then(Value::as_f64).unwrap_or(0.0),
868        peer: None,
869    })
870}
871
872fn is_irc(v: &Value, kind: &str) -> bool {
873    v.get("message")
874        .and_then(|m| m.get("type"))
875        .and_then(Value::as_str)
876        == Some(kind)
877}
878
879/// a message's platform id, if the frame carries one.
880fn id_of(m: &Value) -> Option<String> {
881    m.get("id")
882        .and_then(Value::as_str)
883        .filter(|s| !s.is_empty() && s.len() <= 128)
884        .map(str::to_string)
885}
886
887/// twitch CLEARMSG → one message struck through.
888fn clearmsg_event(v: &Value) -> Event {
889    let m = &v["message"];
890    let id = field(m, "targetMsgId");
891    if id.is_empty() {
892        return Event::Ignore;
893    }
894    Event::Delete {
895        line: None,
896        del: Delete {
897            platform: Platform::Twitch,
898            channel: field(v, "channel"),
899            target: Target::Msg(id),
900            label: "deleted".into(),
901        },
902    }
903}
904
905/// twitch CLEARCHAT → ban / timeout / full clear: a mod-log note, and the
906/// target's lines struck through.
907fn clearchat_event(v: &Value) -> Event {
908    let m = &v["message"];
909    let target = field(m, "targetUsername");
910    let (label, t) = if target.is_empty() {
911        ("cleared".to_string(), Target::All)
912    } else {
913        let label = match m.get("banDuration").and_then(Value::as_u64) {
914            Some(s) => format!("timed out {}", fmt_secs(s)),
915            None => "banned".to_string(),
916        };
917        (label, Target::User(target.clone()))
918    };
919    let channel = field(v, "channel");
920    let line = note_line(
921        Platform::Twitch,
922        channel.clone(),
923        target,
924        None,
925        NoteKind::Mod,
926        if matches!(t, Target::All) {
927            "chat cleared".into()
928        } else {
929            label.clone()
930        },
931        String::new(),
932    );
933    Event::Delete {
934        line,
935        del: Delete {
936            platform: Platform::Twitch,
937            channel,
938            target: t,
939            label,
940        },
941    }
942}
943
944/// youtube delete frame: message ids only (no author handle we can match).
945fn youtube_delete_event(v: &Value) -> Event {
946    let ids: Vec<&str> = v
947        .get("messageIds")
948        .and_then(Value::as_array)
949        .map(|a| a.iter().filter_map(Value::as_str).collect())
950        .unwrap_or_default();
951    let dels: Vec<Event> = ids
952        .into_iter()
953        .map(|id| Event::Delete {
954            line: None,
955            del: Delete {
956                platform: Platform::Youtube,
957                channel: field(v, "videoId"),
958                target: Target::Msg(id.to_string()),
959                label: "deleted".into(),
960            },
961        })
962        .collect();
963    Event::Batch(dels)
964}
965
966/// twitch NOTICE → server line ("this room is now in slow mode").
967fn notice_line(m: &Value, channel: Option<&Value>) -> Option<ChatLine> {
968    let text = field(m, "content");
969    if text.is_empty() {
970        return None;
971    }
972    let channel = channel.and_then(Value::as_str).unwrap_or("").to_string();
973    note_line(
974        Platform::Twitch,
975        channel,
976        String::new(),
977        None,
978        NoteKind::Notice,
979        text,
980        String::new(),
981    )
982}
983
984fn stream_ev(kind: StreamKind, game: String, prev_game: String, follow: bool) -> StreamEv {
985    StreamEv {
986        kind,
987        game,
988        prev_game,
989        follow,
990    }
991}
992
993/// `follow:live:snapshot` → the (platform, channel) pairs already live.
994fn follow_snapshot(v: &Value) -> Event {
995    let live = v
996        .get("streams")
997        .and_then(Value::as_array)
998        .map(|a| {
999            a.iter()
1000                .filter_map(|s| {
1001                    let platform = match field(s, "platform").to_lowercase().as_str() {
1002                        "twitch" => Platform::Twitch,
1003                        "kick" => Platform::Kick,
1004                        "youtube" => Platform::Youtube,
1005                        _ => return None,
1006                    };
1007                    let user = field(s, "username").to_lowercase();
1008                    (!user.is_empty()).then_some((platform, user))
1009                })
1010                .collect()
1011        })
1012        .unwrap_or_default();
1013    Event::FollowLive(live)
1014}
1015
1016/// `follow:history` → the stored events as follow stream lines. only the three
1017/// lifecycle kinds count (stored `type` mirrors `follow:stream:*`, bare
1018/// `stream:*` is accepted too); a bad row never costs the others.
1019fn follow_history(v: &Value) -> Event {
1020    let mut out: Vec<(ChatLine, u64)> = v
1021        .get("events")
1022        .and_then(Value::as_array)
1023        .map(|a| {
1024            a.iter()
1025                .filter_map(|e| {
1026                    let ty = e.get("type").and_then(Value::as_str)?;
1027                    let ty = ty.strip_prefix("follow:").unwrap_or(ty);
1028                    if !matches!(ty, "stream:online" | "stream:offline" | "stream:update") {
1029                        return None;
1030                    }
1031                    let at = e.get("time").and_then(Value::as_f64).filter(|t| *t > 0.0)?;
1032                    Some((stream_line_as(e, ty, true)?, at as u64))
1033                })
1034                .collect()
1035        })
1036        .unwrap_or_default();
1037    out.sort_by_key(|(_, at)| *at); // stable: oldest first
1038    if out.is_empty() {
1039        Event::Ignore
1040    } else {
1041        Event::FollowHistory(out)
1042    }
1043}
1044
1045/// `follow:colors` → (login, `#rrggbb`) pairs; anything else in the map is dropped.
1046fn follow_colors(v: &Value) -> Event {
1047    let colors: Vec<(String, String)> = v
1048        .get("colors")
1049        .and_then(Value::as_object)
1050        .map(|m| {
1051            m.iter()
1052                .filter_map(|(k, c)| {
1053                    let c = color_str(c)?;
1054                    (!k.is_empty()).then(|| (k.to_lowercase(), c.to_string()))
1055                })
1056                .collect()
1057        })
1058        .unwrap_or_default();
1059    if colors.is_empty() {
1060        Event::Ignore
1061    } else {
1062        Event::FollowColors(colors)
1063    }
1064}
1065
1066/// stream lifecycle / hype / redemption / heat-spike frames. these carry
1067/// their platform + channel — the receiver routes them like chat.
1068fn stream_line(v: &Value) -> Option<ChatLine> {
1069    let ty = v.get("type").and_then(Value::as_str).unwrap_or("");
1070    match ty.strip_prefix("follow:") {
1071        Some(t) => stream_line_as(v, t, true),
1072        None => stream_line_as(v, ty, false),
1073    }
1074}
1075
1076/// [`stream_line`] with the (prefix-free) kind and follow flag already decided.
1077fn stream_line_as(v: &Value, ty: &str, follow: bool) -> Option<ChatLine> {
1078    let platform = match v.get("platform").and_then(Value::as_str)? {
1079        "twitch" => Platform::Twitch,
1080        "kick" => Platform::Kick,
1081        "youtube" => Platform::Youtube,
1082        _ => return None,
1083    };
1084    let channel = field(v, "channel");
1085    if channel.is_empty() {
1086        return None;
1087    }
1088    let n = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
1089    // the streamer's own chat colour and paint-space id ride on every stream
1090    // frame; the three lifecycle rows wear them, the rest ignore them.
1091    let color = color_of(v);
1092    let who = channel.to_lowercase();
1093    let uid = stream_uid(v, platform);
1094    match ty {
1095        "stream:online" => {
1096            let game = field(v, "game");
1097            let what = if game.is_empty() {
1098                "went live".to_string()
1099            } else {
1100                format!("went live — {game}")
1101            };
1102            let mut l = note_line(
1103                platform,
1104                channel,
1105                who,
1106                color,
1107                NoteKind::Live,
1108                what,
1109                field(v, "title"),
1110            )?;
1111            l.uid = uid;
1112            l.stream = Some(stream_ev(StreamKind::Online, game, String::new(), follow));
1113            Some(l)
1114        }
1115        "stream:offline" => {
1116            let mut l = note_line(
1117                platform,
1118                channel,
1119                who,
1120                color,
1121                NoteKind::Offline,
1122                "went offline".into(),
1123                String::new(),
1124            )?;
1125            l.uid = uid;
1126            l.stream = Some(stream_ev(
1127                StreamKind::Offline,
1128                String::new(),
1129                String::new(),
1130                follow,
1131            ));
1132            Some(l)
1133        }
1134        // only a new game is worth a line; a title-only change is dropped
1135        // (the extension drops it too).
1136        "stream:update" => {
1137            let (game, prev) = (field(v, "game"), field(v, "prevGame"));
1138            if game.is_empty() || game == prev {
1139                return None;
1140            }
1141            let mut l = note_line(
1142                platform,
1143                channel,
1144                who,
1145                color,
1146                NoteKind::Category,
1147                format!("switched to {game}"),
1148                String::new(),
1149            )?;
1150            l.uid = uid;
1151            l.stream = Some(stream_ev(StreamKind::Update, game, prev, follow));
1152            Some(l)
1153        }
1154        "stream:raid" => {
1155            let target = field(v, "target");
1156            if target.is_empty() {
1157                return None;
1158            }
1159            let viewers = n("viewers");
1160            let what = if viewers > 0 {
1161                format!("raided {target} with {viewers} viewers")
1162            } else {
1163                format!("raided {target}")
1164            };
1165            note_line(
1166                platform,
1167                channel,
1168                String::new(),
1169                None,
1170                NoteKind::Raid,
1171                what,
1172                String::new(),
1173            )
1174        }
1175        "stream:redeem" => {
1176            let title = field(v, "title");
1177            let cost = n("cost");
1178            let what = match (title.is_empty(), cost) {
1179                (false, c) if c > 0 => format!("redeemed {title} ({c})"),
1180                (false, _) => format!("redeemed {title}"),
1181                _ => "redeemed".to_string(),
1182            };
1183            note_line(
1184                platform,
1185                channel,
1186                field(v, "user"),
1187                None,
1188                NoteKind::Redeem,
1189                what,
1190                String::new(),
1191            )
1192        }
1193        "stream:hype-start" => note_line(
1194            platform,
1195            channel,
1196            String::new(),
1197            None,
1198            NoteKind::Spike,
1199            format!("hype train — level {}", n("level").max(1)),
1200            String::new(),
1201        ),
1202        "stream:hype-end" => note_line(
1203            platform,
1204            channel,
1205            String::new(),
1206            None,
1207            NoteKind::Spike,
1208            format!("hype train ended — level {}", n("level").max(1)),
1209            String::new(),
1210        ),
1211        "moment:spike" => {
1212            let rate = v.get("rate").and_then(Value::as_f64)?;
1213            let base = v.get("baseline").and_then(Value::as_f64).unwrap_or(0.0);
1214            let what = if base > 0.0 {
1215                format!("chat spike — {rate:.0}/s (avg {base:.0})")
1216            } else {
1217                format!("chat spike — {rate:.0}/s")
1218            };
1219            note_line(
1220                platform,
1221                channel,
1222                String::new(),
1223                None,
1224                NoteKind::Spike,
1225                what,
1226                String::new(),
1227            )
1228        }
1229        _ => None,
1230    }
1231}
1232
1233/// kick subscription webhook → sub / resub / gift bomb. the `message` field
1234/// is kick's system text ("slowlux resubscribed for 39 months!").
1235fn kick_sub_line(v: &Value) -> Option<ChatLine> {
1236    let channel = field(v, "channel");
1237    if channel.is_empty() {
1238        return None;
1239    }
1240    let mut user = field(v, "username");
1241    let et = v.get("eventType").and_then(Value::as_str).unwrap_or("");
1242    let kind = if et == "gift" {
1243        NoteKind::Gift
1244    } else {
1245        NoteKind::Sub
1246    };
1247    let msg = field(v, "message");
1248    let mut what = split_headline(&mut user, &msg);
1249    if what.is_empty() {
1250        what = match et {
1251            "renewal" => match v.get("months").and_then(Value::as_u64) {
1252                Some(mo) if mo > 0 => format!("resubscribed — {mo} months"),
1253                _ => "resubscribed".into(),
1254            },
1255            "gift" => {
1256                let n = v
1257                    .get("giftees")
1258                    .and_then(Value::as_array)
1259                    .map(Vec::len)
1260                    .unwrap_or(0);
1261                if n > 1 {
1262                    format!("gifted {n} subs")
1263                } else {
1264                    "gifted a sub".into()
1265                }
1266            }
1267            _ => "subscribed".into(),
1268        };
1269    }
1270    note_line(
1271        Platform::Kick,
1272        channel,
1273        user,
1274        None,
1275        kind,
1276        what,
1277        String::new(),
1278    )
1279}
1280
1281/// kick "kicks" gifted (kick's bits) → cheer with the user's message.
1282fn kick_kicks_line(v: &Value) -> Option<ChatLine> {
1283    let channel = field(v, "channel");
1284    if channel.is_empty() {
1285        return None;
1286    }
1287    let amount = v.get("amount").and_then(Value::as_u64).unwrap_or(0);
1288    let what = if amount > 0 {
1289        format!("gifted {amount} kicks")
1290    } else {
1291        "gifted kicks".to_string()
1292    };
1293    note_line(
1294        Platform::Kick,
1295        channel,
1296        field(v, "username"),
1297        None,
1298        NoteKind::Cheer,
1299        what,
1300        field(v, "message"),
1301    )
1302}
1303
1304/// youtube poller status → connected (with the channel's actual name — the
1305/// tab label is just a video id) / ended / error.
1306fn yt_status_line(v: &Value) -> Option<ChatLine> {
1307    let video = v.get("videoId").and_then(Value::as_str).unwrap_or("");
1308    if video.is_empty() {
1309        return None;
1310    }
1311    let (kind, what, text) = match v.get("status").and_then(Value::as_str).unwrap_or("") {
1312        "ended" => (NoteKind::Offline, "stream ended".to_string(), String::new()),
1313        "connected" => {
1314            let name = field(v, "channelName");
1315            let what = if name.is_empty() {
1316                "connected".to_string()
1317            } else {
1318                format!("connected — {name}")
1319            };
1320            (NoteKind::Notice, what, field(v, "title"))
1321        }
1322        "error" => (
1323            NoteKind::Notice,
1324            "connection error".to_string(),
1325            field(v, "error"),
1326        ),
1327        _ => return None,
1328    };
1329    note_line(
1330        Platform::Youtube,
1331        video.to_string(),
1332        String::new(),
1333        None,
1334        kind,
1335        what,
1336        text,
1337    )
1338}
1339
1340/// one message out of a `youtube:chat` batch. field names differ from the
1341/// twitch/kick shape (`user`/`text`, not `username`/`content`); paid and
1342/// membership types become inline notes (`amount` is a display string like
1343/// "$5.00", passed through as-is).
1344fn yt_line(m: &Value, video: &str) -> Option<ChatLine> {
1345    let mut user = field(m, "user");
1346    let content = field(m, "text");
1347    let amount = field(m, "amount");
1348    let note = match m.get("type").and_then(Value::as_str).unwrap_or("message") {
1349        "superchat" => Some(Note {
1350            kind: NoteKind::Cheer,
1351            what: if amount.is_empty() {
1352                "superchat".into()
1353            } else {
1354                format!("superchat {amount}")
1355            },
1356        }),
1357        "supersticker" => Some(Note {
1358            kind: NoteKind::Cheer,
1359            what: if amount.is_empty() {
1360                "supersticker".into()
1361            } else {
1362                format!("supersticker {amount}")
1363            },
1364        }),
1365        "membership" => {
1366            let sys = field(m, "systemMsg");
1367            let what = split_headline(&mut user, &sys);
1368            Some(Note {
1369                kind: NoteKind::Sub,
1370                what: if what.is_empty() {
1371                    "became a member".into()
1372                } else {
1373                    what
1374                },
1375            })
1376        }
1377        "giftpurchase" | "giftredemption" => {
1378            let sys = field(m, "systemMsg");
1379            let what = split_headline(&mut user, &sys);
1380            Some(Note {
1381                kind: NoteKind::Gift,
1382                what: if what.is_empty() {
1383                    "gifted memberships".into()
1384                } else {
1385                    what
1386                },
1387            })
1388        }
1389        _ => None,
1390    };
1391    if user.is_empty() && content.is_empty() && note.is_none() {
1392        return None;
1393    }
1394    Some(ChatLine {
1395        platform: Platform::Youtube,
1396        channel: video.to_string(),
1397        id: id_of(m),
1398        user,
1399        color: color_of(m),
1400        badges: Vec::new(),
1401        reply_to: None,
1402        note,
1403        extra: Extra::default(),
1404        stream: None,
1405        content,
1406        uid: m
1407            .get("authorChannelId")
1408            .and_then(Value::as_str)
1409            .and_then(|c| paint_id("yt_", c)),
1410        emotes: Vec::new(),
1411    })
1412}
1413
1414/// badges in either platform shape → normalized [`Badge`]s, deduped, order kept.
1415/// twitch: `"broadcaster/1,subscriber/12"` (a comma string, name before `/`);
1416/// kick and the archive API: `[{ "name": "moderator", "version": "1" }, …]`.
1417/// anything else → empty. public because the archive backfill reuses it.
1418pub fn badges_from(v: Option<&Value>) -> Vec<Badge> {
1419    let mut out: Vec<Badge> = Vec::new();
1420    let mut push = |name: &str, ver: u16| {
1421        if let Some(b) = Badge::from_name(name, ver) {
1422            if !out.iter().any(|e| e.kind == b.kind) {
1423                out.push(b);
1424            }
1425        }
1426    };
1427    match v {
1428        Some(Value::String(s)) => {
1429            for part in s.split(',') {
1430                let (name, ver) = part.split_once('/').unwrap_or((part, ""));
1431                push(name.trim(), ver.trim().parse().unwrap_or(0));
1432            }
1433        }
1434        Some(Value::Array(arr)) => {
1435            for b in arr {
1436                if let Some(name) = b.get("name").and_then(Value::as_str) {
1437                    let ver = match b.get("version") {
1438                        Some(Value::Number(n)) => n.as_u64().unwrap_or(0),
1439                        Some(Value::String(s)) => s.parse().unwrap_or(0),
1440                        _ => 0,
1441                    };
1442                    push(name, ver.min(u16::MAX as u64) as u16);
1443                }
1444            }
1445        }
1446        _ => {}
1447    }
1448    out
1449}
1450
1451/// outbound: subscribe to a channel's live chat. youtube subscribes by watch
1452/// url (the server resolves it); our channel name IS the video id.
1453/// twitch needs BOTH: `irc:join` for chat, `channel:join` for the room the
1454/// server pushes stream:online/offline/update/raid/redeem into.
1455pub fn join(platform: Platform, channel: &str) -> Vec<String> {
1456    match platform {
1457        Platform::Twitch => vec![
1458            json!({ "type": "channel:join", "platform": "twitch", "channel": channel }),
1459            json!({ "type": "irc:join", "channel": channel }),
1460        ],
1461        Platform::Kick => {
1462            vec![json!({ "type": "channel:join", "platform": "kick", "channel": channel })]
1463        }
1464        Platform::Youtube => vec![json!({
1465            "type": "youtube:subscribe",
1466            "url": format!("https://www.youtube.com/watch?v={channel}"),
1467        })],
1468    }
1469    .iter()
1470    .map(Value::to_string)
1471    .collect()
1472}
1473
1474/// outbound: unsubscribe.
1475pub fn part(platform: Platform, channel: &str) -> Vec<String> {
1476    match platform {
1477        Platform::Twitch => vec![
1478            json!({ "type": "channel:leave", "platform": "twitch", "channel": channel }),
1479            json!({ "type": "irc:part", "channel": channel }),
1480        ],
1481        Platform::Kick => {
1482            vec![json!({ "type": "channel:leave", "platform": "kick", "channel": channel })]
1483        }
1484        Platform::Youtube => vec![json!({ "type": "youtube:unsubscribe", "videoId": channel })],
1485    }
1486    .iter()
1487    .map(Value::to_string)
1488    .collect()
1489}
1490
1491/// outbound: subscribe to the heatsync site feed ("new") — its `new-message`,
1492/// `heat:updated` and `message:hidden` frames only reach joined sockets.
1493pub fn feed_join() -> String {
1494    json!({ "type": "feed:join", "feed": "new" }).to_string()
1495}
1496
1497pub fn feed_leave() -> String {
1498    json!({ "type": "feed:leave", "feed": "new" }).to_string()
1499}
1500
1501/// outbound: keepalive, send every ~10s (server replies `pong`).
1502pub fn heartbeat() -> String {
1503    json!({ "type": "presence:heartbeat" }).to_string()
1504}
1505
1506/// outbound: authenticate a session so sends are accepted. server replies
1507/// `authenticated` / `authentication_failed`.
1508pub fn authenticate(token: &str) -> String {
1509    json!({ "type": "authenticate", "token": token }).to_string()
1510}
1511
1512/// outbound: post a message to a kick channel (relayed via the user's extension).
1513/// text is 1–500 chars server-side; `req` correlates the result frame.
1514/// note: twitch has NO send path — the relay ingests twitch read-only.
1515pub fn send_kick(channel: &str, text: &str, req: u64) -> String {
1516    json!({
1517        "type": "chat:send_kick",
1518        "channel": channel,
1519        "text": text,
1520        "reqId": req.to_string(),
1521    })
1522    .to_string()
1523}
1524
1525/// outbound: post to a youtube live chat (relayed via the user's extension).
1526/// text is 1–200 chars server-side; requires an authenticated session.
1527pub fn send_youtube(video: &str, text: &str, req: u64) -> String {
1528    json!({
1529        "type": "chat:send_youtube",
1530        "videoId": video,
1531        "text": text,
1532        "reqId": req.to_string(),
1533    })
1534    .to_string()
1535}
1536
1537#[cfg(test)]
1538mod tests {
1539    use super::*;
1540    use crate::BadgeKind;
1541
1542    #[test]
1543    fn parses_twitch_message() {
1544        let raw = r##"{"type":"irc:message","channel":"xqc","message":{
1545            "username":"chat_user","displayName":"ChatUser","content":"hello GAMBA",
1546            "color":"#ff8700","id":"abc"}}"##;
1547        let ev = parse(raw);
1548        assert_eq!(
1549            ev,
1550            Event::Chat(ChatLine {
1551                platform: Platform::Twitch,
1552                channel: "xqc".into(),
1553                id: Some("abc".into()),
1554                user: "ChatUser".into(),
1555                color: Some("#ff8700".into()),
1556                badges: vec![],
1557                reply_to: None,
1558                note: None,
1559                extra: Default::default(),
1560                stream: None,
1561                content: "hello GAMBA".into(),
1562                uid: None,
1563                emotes: vec![],
1564            })
1565        );
1566    }
1567
1568    fn chat(raw: &str) -> ChatLine {
1569        match parse(raw) {
1570            Event::Chat(l) => l,
1571            e => panic!("expected chat, got {e:?}"),
1572        }
1573    }
1574
1575    fn pairs(l: &ChatLine) -> Vec<(&str, &str, &str, bool)> {
1576        l.emotes
1577            .iter()
1578            .map(|e| {
1579                (
1580                    e.name.as_str(),
1581                    e.url.as_str(),
1582                    e.provider.as_str(),
1583                    e.zero_width,
1584                )
1585            })
1586            .collect()
1587    }
1588
1589    #[test]
1590    fn twitch_emotes_tag_becomes_refs() {
1591        let l = chat(
1592            r##"{"type":"irc:message","channel":"c","message":{
1593            "username":"u","content":"Kappa hi LUL","emotes":"25:0-4/425618:9-11"}}"##,
1594        );
1595        assert_eq!(l.content, "Kappa hi LUL");
1596        assert_eq!(
1597            pairs(&l),
1598            vec![
1599                (
1600                    "Kappa",
1601                    "https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/2.0",
1602                    "twitch",
1603                    false
1604                ),
1605                (
1606                    "LUL",
1607                    "https://static-cdn.jtvnw.net/emoticons/v2/425618/default/dark/2.0",
1608                    "twitch",
1609                    false
1610                ),
1611            ]
1612        );
1613    }
1614
1615    #[test]
1616    fn twitch_emote_positions_are_code_points() {
1617        // two astral emoji precede the emote: utf-16 / byte offsets would miss it
1618        let l = chat(
1619            "{\"type\":\"irc:message\",\"channel\":\"c\",\"message\":{\"username\":\"u\",\
1620             \"content\":\"\u{1f600}\u{1f600} Kappa\",\"emotes\":\"25:3-7\"}}",
1621        );
1622        assert_eq!(l.emotes.len(), 1);
1623        assert_eq!(l.emotes[0].name, "Kappa");
1624    }
1625
1626    #[test]
1627    fn twitch_pre_resolved_map_is_accepted() {
1628        let l = chat(
1629            r##"{"type":"irc:message","channel":"c","message":{"username":"u","content":"hi Sub",
1630            "twitchEmotes":{"Sub":"https://static-cdn.jtvnw.net/emoticons/v2/9/default/dark/2.0","Bad":"http://x/y"}}}"##,
1631        );
1632        assert_eq!(l.emotes.len(), 1);
1633        assert_eq!(l.emotes[0].name, "Sub");
1634    }
1635
1636    #[test]
1637    fn hs_emotes_map_becomes_refs_and_outranks_native() {
1638        let l = chat(
1639            r##"{"type":"irc:message","channel":"c","message":{
1640            "username":"u","content":"Kappa nope",
1641            "emotes":"25:0-4",
1642            "hsEmotes":{
1643              "Kappa":{"url":"https://cdn.hs/k.webp","provider":"heatsync"},
1644              "notL":{"url":"https://cdn.7tv.app/n.webp","provider":"7tv","zeroWidth":true},
1645              "Hidden":{"cw":"sexual"},
1646              "Evil":{"url":"javascript:alert(1)"},
1647              "two words":{"url":"https://cdn.hs/x.webp"}}}}"##,
1648        );
1649        // inventory Kappa first (native Kappa dedupes behind it); stubs/bad dropped
1650        assert_eq!(
1651            pairs(&l),
1652            vec![
1653                ("Kappa", "https://cdn.hs/k.webp", "heatsync", false),
1654                ("notL", "https://cdn.7tv.app/n.webp", "7tv", true),
1655            ]
1656        );
1657    }
1658
1659    #[test]
1660    fn kick_emote_tokens_are_rewritten_and_ref_d() {
1661        let l = chat(
1662            r##"{"type":"kick-chat-message","data":{"channel":"c","username":"u",
1663            "content":"hi [emote:37226:KEKW][emote:1:Clap] go"}}"##,
1664        );
1665        assert_eq!(l.content, "hi KEKW Clap go");
1666        assert_eq!(
1667            pairs(&l),
1668            vec![
1669                (
1670                    "KEKW",
1671                    "https://files.kick.com/emotes/37226/fullsize",
1672                    "kick",
1673                    false
1674                ),
1675                (
1676                    "Clap",
1677                    "https://files.kick.com/emotes/1/fullsize",
1678                    "kick",
1679                    false
1680                ),
1681            ]
1682        );
1683    }
1684
1685    #[test]
1686    fn kick_tokens_are_kick_only_and_hs_emotes_work_there_too() {
1687        // a twitch user typing the token stays literal
1688        let l = chat(
1689            r##"{"type":"irc:message","channel":"c","message":{"username":"u","content":"[emote:1:X]"}}"##,
1690        );
1691        assert_eq!(l.content, "[emote:1:X]");
1692        assert!(l.emotes.is_empty());
1693        let l = chat(
1694            r##"{"type":"kick-chat-message","data":{"channel":"c","username":"u","content":"hi Pog",
1695            "hsEmotes":{"Pog":{"url":"https://cdn.hs/p.webp","provider":"7tv"}}}}"##,
1696        );
1697        assert_eq!(l.emotes.len(), 1);
1698    }
1699
1700    #[test]
1701    fn ref_count_is_capped() {
1702        let map: Vec<String> = (0..200)
1703            .map(|i| format!(r#""E{i}":{{"url":"https://cdn.hs/{i}.webp"}}"#))
1704            .collect();
1705        let raw = format!(
1706            r#"{{"type":"irc:message","channel":"c","message":{{"username":"u","content":"x","hsEmotes":{{{}}}}}}}"#,
1707            map.join(",")
1708        );
1709        assert_eq!(chat(&raw).emotes.len(), MAX_REFS);
1710    }
1711
1712    fn special(raw: &str) -> SpecialLine {
1713        match parse(raw) {
1714            Event::Special(s) => s,
1715            e => panic!("expected special, got {e:?}"),
1716        }
1717    }
1718
1719    #[test]
1720    fn mention_rule_match_becomes_a_mentions_line() {
1721        let s = special(
1722            r##"{"type":"mention:rule-match","ruleId":7,"pattern":"mellen","channel":"xqc",
1723            "platform":"kick","username":"bob","snippet":"hey mellen look","url":"https://heatsync.org/x"}"##,
1724        );
1725        assert_eq!(s.tab, TabKind::Mentions);
1726        assert_eq!(s.line.platform, Platform::Kick);
1727        assert_eq!(s.line.user, "bob");
1728        assert_eq!(s.line.content, "hey mellen look");
1729        assert_eq!(
1730            s.tags,
1731            vec![Tag::platform(), Tag::new("[xqc]", TagKind::Origin)]
1732        );
1733        assert!(matches!(
1734            parse(r#"{"type":"mention:rule-match"}"#),
1735            Event::Ignore
1736        ));
1737    }
1738
1739    #[test]
1740    fn whisper_and_dm_land_in_the_whispers_tab_with_their_labels() {
1741        let w = special(
1742            r##"{"type":"whisper:new","from_twitch_login":"bob","from_display_name":"Bob",
1743            "from_color":"#ff0000","to_twitch_login":"me","content":"psst","created_at":"x"}"##,
1744        );
1745        assert_eq!((w.tab, w.line.user.as_str()), (TabKind::Whispers, "Bob"));
1746        assert_eq!(w.line.color.as_deref(), Some("#ff0000"));
1747        assert_eq!(w.tags, vec![Tag::new("[whisper]", TagKind::Whisper)]);
1748        let d = special(
1749            r##"{"type":"dm:new","from_display_name":"Al","from_color":"nope","content":"yo","from_user_id":3}"##,
1750        );
1751        assert_eq!(d.tags, vec![Tag::new("[DM]", TagKind::Dm)]);
1752        assert_eq!(d.line.color, None); // malformed color dropped
1753                                        // login stands in when there's no display name
1754        let l = special(r#"{"type":"whisper:new","from_twitch_login":"bob","content":"x"}"#);
1755        assert_eq!(l.line.user, "bob");
1756    }
1757
1758    #[test]
1759    fn feed_row_parses_roles_heat_and_refs() {
1760        let s = special(
1761            r##"{"type":"new-message","base36_id":"00004a","content":"hi Pog","username":"u",
1762            "display_name":"U","user_color":"#FF4500","platform":"twitch","heat":42.5,
1763            "is_op":true,"is_thread_op":true,"reply_to":null,
1764            "emote_refs":{"Pog":{"url":"https://cdn.hs/p.webp","provider":"7tv"}}}"##,
1765        );
1766        assert_eq!(s.tab, TabKind::Feed);
1767        assert_eq!(s.line.id.as_deref(), Some("00004a"));
1768        assert_eq!(s.line.user, "U");
1769        assert_eq!(s.line.color.as_deref(), Some("#FF4500"));
1770        assert_eq!(s.heat, 42.5);
1771        assert_eq!(s.tags[0], Tag::new("[OP]", TagKind::ThreadOp));
1772        assert_eq!(s.tags[1].kind, TagKind::Heat);
1773        assert_eq!(s.line.emotes.len(), 1);
1774        for (flags, want) in [
1775            (r#""is_op":true"#, TagKind::Op),
1776            (r#""is_op":false"#, TagKind::Reply),
1777            (r#""x":1"#, TagKind::Reply),
1778        ] {
1779            let raw = format!(
1780                r#"{{"type":"new-message","base36_id":"1","content":"c","username":"u",{flags}}}"#
1781            );
1782            assert_eq!(special(&raw).tags[0].kind, want, "{flags}");
1783        }
1784        // no id → nothing to update later → dropped
1785        assert!(matches!(
1786            parse(r#"{"type":"new-message","content":"c","username":"u"}"#),
1787            Event::Ignore
1788        ));
1789    }
1790
1791    #[test]
1792    fn feed_edits_carry_id_text_and_refs() {
1793        let raw = r##"{"type":"message-edited","message_id":"00004a","content":"fixed Pog",
1794            "emote_refs":{"Pog":{"url":"https://cdn.hs/p.webp"}}}"##;
1795        match parse(raw) {
1796            Event::FeedEdit { id, text, emotes } => {
1797                assert_eq!((id.as_str(), text.as_str()), ("00004a", "fixed Pog"));
1798                assert_eq!(emotes.len(), 1);
1799            }
1800            e => panic!("expected edit, got {e:?}"),
1801        }
1802        // the whole row, wrapped in data, keyed by base36_id; control bytes cleaned
1803        let raw = "{\"type\":\"message-updated\",\"data\":{\"base36_id\":\"b\",\"id\":9,\
1804            \"content\":\"a\\u001b[31mb\"}}";
1805        match parse(raw) {
1806            Event::FeedEdit { id, text, .. } => {
1807                assert_eq!(id, "b");
1808                assert!(!text.contains('\u{1b}'));
1809            }
1810            e => panic!("expected edit, got {e:?}"),
1811        }
1812        // malformed: no id, no text, id too long → ignored
1813        for raw in [
1814            r#"{"type":"message-edited","content":"x"}"#,
1815            r#"{"type":"message-edited","message_id":"a"}"#,
1816            r#"{"type":"message-edited","message_id":"a","content":5}"#,
1817            r#"{"type":"message-updated","data":7}"#,
1818        ] {
1819            assert_eq!(parse(raw), Event::Ignore, "{raw}");
1820        }
1821        let long = format!(
1822            r#"{{"type":"message-edited","message_id":"{}","content":"x"}}"#,
1823            "a".repeat(129)
1824        );
1825        assert_eq!(parse(&long), Event::Ignore);
1826        // oversized text is cut, not refused
1827        let big = format!(
1828            r#"{{"type":"message-edited","message_id":"a","content":"{}"}}"#,
1829            "y".repeat(50_000)
1830        );
1831        match parse(&big) {
1832            Event::FeedEdit { text, .. } => assert_eq!(text.chars().count(), MAX_EDIT_TEXT),
1833            e => panic!("expected edit, got {e:?}"),
1834        }
1835    }
1836
1837    #[test]
1838    fn account_and_follow_pushes_are_bare_signals() {
1839        assert_eq!(parse(r#"{"type":"account:changed"}"#), Event::Account);
1840        assert_eq!(
1841            parse(r#"{"type":"follows:changed","junk":[1,2,3]}"#),
1842            Event::Follows
1843        );
1844    }
1845
1846    #[test]
1847    fn feed_heat_and_hidden_name_the_row() {
1848        assert_eq!(
1849            parse(r#"{"type":"heat:updated","messageId":"00004a","newHeat":9.5}"#),
1850            Event::FeedHeat {
1851                id: "00004a".into(),
1852                heat: 9.5
1853            }
1854        );
1855        assert_eq!(
1856            parse(r#"{"type":"heat:updated","messageId":77,"newHeat":1}"#),
1857            Event::FeedHeat {
1858                id: "77".into(),
1859                heat: 1.0
1860            }
1861        );
1862        assert_eq!(
1863            parse(r#"{"type":"message:hidden","id":"00004a"}"#),
1864            Event::FeedHidden {
1865                id: "00004a".into()
1866            }
1867        );
1868        assert!(matches!(parse(r#"{"type":"heat:updated"}"#), Event::Ignore));
1869    }
1870
1871    #[test]
1872    fn special_text_is_sanitized() {
1873        let s = special(
1874            "{\"type\":\"mention:rule-match\",\"channel\":\"c\",\"username\":\"u\",\
1875             \"snippet\":\"hi\\u001b]0;x\\u0007\"}",
1876        );
1877        assert_eq!(s.line.content, "hi]0;x");
1878    }
1879
1880    #[test]
1881    fn twitch_badge_string_parses_and_dedupes() {
1882        let raw = r##"{"type":"irc:message","channel":"c","message":{
1883            "username":"u","content":"hi",
1884            "badges":"broadcaster/1,subscriber/12,sub-gifter/5,broadcaster/1"}}"##;
1885        match parse(raw) {
1886            Event::Chat(l) => {
1887                assert_eq!(
1888                    l.badges.iter().map(|b| b.kind).collect::<Vec<_>>(),
1889                    [
1890                        BadgeKind::Broadcaster,
1891                        BadgeKind::Subscriber,
1892                        BadgeKind::Gifter
1893                    ]
1894                );
1895                assert_eq!(l.badges[1].ver, 12);
1896            }
1897            _ => panic!("expected chat"),
1898        }
1899    }
1900
1901    #[test]
1902    fn kick_badge_array_parses() {
1903        let raw = r##"{"type":"kick-chat-message","data":{
1904            "channel":"c","username":"u","content":"hi",
1905            "badges":[{"name":"moderator","version":"1"},{"name":"og","version":"1"},
1906                      {"name":"mystery","version":"1"}]}}"##;
1907        match parse(raw) {
1908            Event::Chat(l) => assert_eq!(
1909                l.badges.iter().map(|b| b.kind).collect::<Vec<_>>(),
1910                [BadgeKind::Moderator, BadgeKind::Og, BadgeKind::Other('m')]
1911            ),
1912            _ => panic!("expected chat"),
1913        }
1914    }
1915
1916    #[test]
1917    fn reply_to_username_is_extracted_and_sanitized() {
1918        let raw = "{\"type\":\"irc:message\",\"channel\":\"c\",\"message\":{
1919            \"username\":\"u\",\"content\":\"hi\",
1920            \"replyTo\":{\"username\":\"tar\\u001bget\",\"content\":\"orig\"}}}";
1921        match parse(raw) {
1922            Event::Chat(l) => assert_eq!(l.reply_to.as_deref(), Some("target")),
1923            _ => panic!("expected chat"),
1924        }
1925    }
1926
1927    #[test]
1928    fn parses_kick_message_and_sanitizes() {
1929        // control chars arrive JSON-escaped (backslash-u) — valid JSON, then
1930        // stripped by sanitize: \u001b => ESC, \u0007 => BEL.
1931        let raw = "{\"type\":\"kick-chat-message\",\"data\":{\
1932            \"channel\":\"trainwreckstv\",\"username\":\"u\",\"displayName\":\"U\",\
1933            \"content\":\"hi\\u001b]0;evil\\u0007\",\"color\":\"#53fc18\"}}";
1934        let ev = parse(raw);
1935        match ev {
1936            Event::Chat(l) => {
1937                assert_eq!(l.platform, Platform::Kick);
1938                assert_eq!(l.content, "hi]0;evil"); // ESC + BEL stripped
1939                assert!(!l.content.contains('\u{1b}'));
1940            }
1941            _ => panic!("expected chat"),
1942        }
1943    }
1944
1945    #[test]
1946    fn backfill_yields_all_lines() {
1947        let raw = r#"{"type":"kick-chat-backfill","channel":"c","replay":true,"messages":[
1948            {"channel":"c","username":"a","content":"one"},
1949            {"channel":"c","username":"b","content":"two"}]}"#;
1950        match parse(raw) {
1951            Event::Backfill(v) => assert_eq!(v.len(), 2),
1952            _ => panic!("expected backfill"),
1953        }
1954    }
1955
1956    #[test]
1957    fn junk_is_ignored_not_fatal() {
1958        assert_eq!(parse("not json"), Event::Ignore);
1959        assert_eq!(parse(r#"{"type":"whatever"}"#), Event::Ignore);
1960        assert_eq!(parse(r#"{"type":"pong"}"#), Event::Pong);
1961    }
1962
1963    #[test]
1964    fn pin_frames_set_and_clear() {
1965        let set = r#"{"type":"pin:set","platform":"twitch","channel":"c","pin":{
1966            "id":"p1","message_id":"m","username":"bob","display_name":"Bob",
1967            "content":"read the rules","ts":1,"pinned_at":2}}"#;
1968        match parse(set) {
1969            Event::Pin(Platform::Twitch, c, Some(p)) => {
1970                assert_eq!(c, "c");
1971                assert_eq!((p.id.as_str(), p.user.as_str()), ("p1", "Bob"));
1972                assert_eq!(p.text, "read the rules");
1973            }
1974            e => panic!("{e:?}"),
1975        }
1976        assert_eq!(
1977            parse(r#"{"type":"pin:clear","platform":"kick","channel":"c"}"#),
1978            Event::Pin(Platform::Kick, "c".into(), None)
1979        );
1980        for bad in [
1981            r#"{"type":"pin:set","platform":"mars","channel":"c","pin":{"content":"x"}}"#,
1982            r#"{"type":"pin:set","platform":"twitch","channel":"c","pin":{"content":""}}"#,
1983            r#"{"type":"pin:set","platform":"twitch","channel":"c"}"#,
1984            r#"{"type":"pin:clear","platform":"twitch"}"#,
1985        ] {
1986            assert_eq!(parse(bad), Event::Ignore, "{bad}");
1987        }
1988        let anon = serde_json::json!({"id":"i","username":"bob","content":"hey\u{1b}[2J"});
1989        assert_eq!(pin_from(&anon).unwrap().text, "hey[2J");
1990    }
1991
1992    #[test]
1993    fn badges_table_keeps_version_and_falls_back_to_first_letter() {
1994        let got = badges_from(Some(&json!(
1995            "subscriber/12,sub-gifter/5,partner/1,hype-train/1,enchantedbigboi/1,moderator/1,subscriber/6,/1"
1996        )));
1997        let seen: Vec<(char, u16)> = got.iter().map(|b| (b.glyph(), b.ver)).collect();
1998        assert_eq!(
1999            seen,
2000            [('S', 12), ('✦', 5), ('✓', 1), ('H', 1), ('E', 1), ('M', 1)],
2001            "dupe subscriber dropped, nameless dropped"
2002        );
2003        let kick = badges_from(Some(&json!([
2004            {"name":"og","version":"1"},
2005            {"name":"sub_gifter","version":3},
2006            {"name":"mystery"}
2007        ])));
2008        let seen: Vec<(char, u16)> = kick.iter().map(|b| (b.glyph(), b.ver)).collect();
2009        assert_eq!(seen, [('O', 1), ('✦', 3), ('M', 0)]);
2010        assert!(badges_from(Some(&json!(7))).is_empty());
2011    }
2012
2013    #[test]
2014    fn feed_frames_are_correct() {
2015        assert_eq!(feed_join(), r#"{"feed":"new","type":"feed:join"}"#);
2016        assert_eq!(feed_leave(), r#"{"feed":"new","type":"feed:leave"}"#);
2017    }
2018
2019    #[test]
2020    fn join_frames_are_correct() {
2021        assert_eq!(
2022            join(Platform::Twitch, "xqc"),
2023            [
2024                r#"{"channel":"xqc","platform":"twitch","type":"channel:join"}"#,
2025                r#"{"channel":"xqc","type":"irc:join"}"#,
2026            ]
2027        );
2028        assert_eq!(
2029            part(Platform::Twitch, "xqc"),
2030            [
2031                r#"{"channel":"xqc","platform":"twitch","type":"channel:leave"}"#,
2032                r#"{"channel":"xqc","type":"irc:part"}"#,
2033            ]
2034        );
2035        assert_eq!(join(Platform::Kick, "x").len(), 1);
2036        assert!(join(Platform::Kick, "x")[0].contains("channel:join"));
2037        let yt = join(Platform::Youtube, "dQw4w9WgXcQ");
2038        assert_eq!(yt.len(), 1);
2039        assert!(yt[0].contains("youtube:subscribe"));
2040        assert!(yt[0].contains("watch?v=dQw4w9WgXcQ"));
2041        assert!(part(Platform::Youtube, "dQw4w9WgXcQ")[0].contains(r#""videoId":"dQw4w9WgXcQ""#));
2042    }
2043
2044    #[test]
2045    fn youtube_batch_parses_chat_superchat_and_membership() {
2046        let raw = r##"{"type":"youtube:chat","videoId":"vid123abc","messages":[
2047            {"user":"alice","text":"hi chat","color":"#ff0000"},
2048            {"type":"superchat","user":"bob","text":"take my money","amount":"$5.00"},
2049            {"type":"membership","user":"carl","text":"","systemMsg":"carl: Member for 2 months"},
2050            {"user":"","text":""}]}"##;
2051        match parse(raw) {
2052            Event::Backfill(lines) => {
2053                assert_eq!(lines.len(), 3, "empty line dropped");
2054                assert_eq!(lines[0].platform, Platform::Youtube);
2055                assert_eq!(lines[0].channel, "vid123abc");
2056                assert_eq!(lines[0].user, "alice");
2057                assert_eq!(lines[0].color.as_deref(), Some("#ff0000"));
2058                assert!(lines[0].note.is_none());
2059                let sc = lines[1].note.as_ref().expect("superchat note");
2060                assert_eq!(sc.kind, NoteKind::Cheer);
2061                assert_eq!(sc.what, "superchat $5.00");
2062                assert_eq!(lines[1].content, "take my money");
2063                let mem = lines[2].note.as_ref().expect("membership note");
2064                assert_eq!(mem.kind, NoteKind::Sub);
2065                assert_eq!(mem.what, "Member for 2 months");
2066                assert_eq!(lines[2].user, "carl");
2067            }
2068            _ => panic!("expected backfill"),
2069        }
2070    }
2071
2072    #[test]
2073    fn usernotice_resub_strips_actor_from_headline_and_keeps_message() {
2074        let raw = r##"{"type":"irc:message","channel":"xqc","message":{
2075            "type":"usernotice","subType":"resub","username":"realck3","displayName":"RealCK3",
2076            "content":"what the heck",
2077            "systemMessage":"RealCK3 subscribed at Tier 1. They've subscribed for 44 months!",
2078            "color":"#3AEEFF"}}"##;
2079        match parse(raw) {
2080            Event::Chat(l) => {
2081                assert_eq!(l.user, "RealCK3");
2082                let n = l.note.expect("note");
2083                assert_eq!(n.kind, NoteKind::Sub);
2084                assert_eq!(
2085                    n.what,
2086                    "subscribed at Tier 1. They've subscribed for 44 months!"
2087                );
2088                assert_eq!(l.content, "what the heck");
2089                assert_eq!(l.color.as_deref(), Some("#3AEEFF"));
2090            }
2091            _ => panic!("expected chat"),
2092        }
2093    }
2094
2095    #[test]
2096    fn usernotice_raid_headline_is_self_contained_so_actor_drops() {
2097        let raw = r##"{"type":"irc:message","channel":"c","message":{
2098            "type":"usernotice","subType":"raid","username":"someraider",
2099            "displayName":"SomeRaider","content":"",
2100            "systemMessage":"15 raiders from SomeRaider have joined!"}}"##;
2101        match parse(raw) {
2102            Event::Chat(l) => {
2103                assert_eq!(l.user, "");
2104                let n = l.note.expect("note");
2105                assert_eq!(n.kind, NoteKind::Raid);
2106                assert_eq!(n.what, "15 raiders from SomeRaider have joined!");
2107            }
2108            _ => panic!("expected chat"),
2109        }
2110    }
2111
2112    #[test]
2113    fn usernotice_kinds_and_announcement_tints() {
2114        use crate::Tint;
2115        for (sub, color, want) in [
2116            ("announcement", "PURPLE", NoteKind::Announce(Tint::Magenta)),
2117            ("announcement", "ORANGE", NoteKind::Announce(Tint::Yellow)),
2118            ("announcement", "BLUE", NoteKind::Announce(Tint::Blue)),
2119            ("announcement", "GREEN", NoteKind::Announce(Tint::Green)),
2120            ("announcement", "PRIMARY", NoteKind::Announce(Tint::Plain)),
2121            ("announcement", "", NoteKind::Announce(Tint::Plain)),
2122            ("watchstreak", "", NoteKind::Streak),
2123            ("viewermilestone", "", NoteKind::Milestone),
2124            ("charitydonation", "", NoteKind::Charity),
2125            ("ritual", "", NoteKind::Ritual),
2126            ("new_chatter", "", NoteKind::Ritual),
2127            ("somethingnew", "", NoteKind::Notice),
2128        ] {
2129            let raw = format!(
2130                r#"{{"type":"irc:message","channel":"c","message":{{"type":"usernotice",
2131                "subType":"{sub}","announceColor":"{color}","username":"u","displayName":"U",
2132                "systemMessage":"U did a thing"}}}}"#
2133            );
2134            match parse(&raw) {
2135                Event::Chat(l) => assert_eq!(l.note.unwrap().kind, want, "{sub} {color}"),
2136                e => panic!("{sub}: {e:?}"),
2137            }
2138        }
2139    }
2140
2141    #[test]
2142    fn reply_action_first_and_highlight_ride_along() {
2143        let raw = r#"{"type":"irc:message","channel":"c","message":{
2144            "username":"u","displayName":"U","content":"yeah","isAction":true,
2145            "isFirstMsg":true,"isHighlighted":true,
2146            "replyTo":{"username":"Tar","content":"is it \u001b[31mreal","messageId":"m1"}}}"#;
2147        match parse(raw) {
2148            Event::Chat(l) => {
2149                assert_eq!(l.reply_to.as_deref(), Some("Tar"));
2150                assert_eq!(l.extra.reply_text, "is it [31mreal");
2151                assert!(l.extra.action && l.extra.first && l.extra.highlight);
2152                assert!(l.note.is_none(), "highlight is a style, not a note");
2153            }
2154            e => panic!("{e:?}"),
2155        }
2156        // kick shape: replyTo without content still yields the user.
2157        let kick = r#"{"type":"kick-chat-message","data":{"channel":"c","username":"u",
2158            "content":"hi","replyTo":{"messageId":"m","username":"bob","content":"orig"}}}"#;
2159        match parse(kick) {
2160            Event::Chat(l) => {
2161                assert_eq!(l.reply_to.as_deref(), Some("bob"));
2162                assert_eq!(l.extra.reply_text, "orig");
2163                assert!(!l.extra.action);
2164            }
2165            e => panic!("{e:?}"),
2166        }
2167    }
2168
2169    #[test]
2170    fn usernotice_announcement_keeps_author_and_text() {
2171        let raw = r##"{"type":"irc:message","channel":"c","message":{
2172            "type":"usernotice","subType":"announcement","username":"modguy",
2173            "displayName":"ModGuy","content":"drops are live","systemMessage":""}}"##;
2174        match parse(raw) {
2175            Event::Chat(l) => {
2176                assert_eq!(l.user, "ModGuy");
2177                let n = l.note.expect("note");
2178                assert_eq!(n.kind, NoteKind::Announce(crate::Tint::Plain));
2179                assert_eq!(n.what, "announced");
2180                assert_eq!(l.content, "drops are live");
2181            }
2182            _ => panic!("expected chat"),
2183        }
2184    }
2185
2186    fn del(raw: &str) -> (Option<ChatLine>, Delete) {
2187        match parse(raw) {
2188            Event::Delete { line, del } => (line, del),
2189            e => panic!("expected delete, got {e:?}"),
2190        }
2191    }
2192
2193    #[test]
2194    fn clearchat_maps_to_timeout_ban_and_clear() {
2195        let (l, d) = del(r#"{"type":"irc:message","channel":"c","message":{
2196            "type":"clearchat","targetUsername":"troll","banDuration":600}}"#);
2197        let l = l.unwrap();
2198        assert_eq!(l.user, "troll");
2199        assert_eq!(l.note.unwrap().what, "timed out 10m");
2200        assert_eq!(d.target, Target::User("troll".into()));
2201        assert_eq!(d.label, "timed out 10m");
2202        assert_eq!(d.channel, "c");
2203
2204        let (l, d) = del(r#"{"type":"irc:message","channel":"c","message":{
2205            "type":"clearchat","targetUsername":"troll"}}"#);
2206        assert_eq!(l.unwrap().note.unwrap().what, "banned");
2207        assert_eq!(d.label, "banned");
2208
2209        let (l, d) = del(r#"{"type":"irc:message","channel":"c","message":{"type":"clearchat"}}"#);
2210        let l = l.unwrap();
2211        assert_eq!(l.user, "");
2212        let n = l.note.unwrap();
2213        assert_eq!(n.kind, NoteKind::Mod);
2214        assert_eq!(n.what, "chat cleared");
2215        assert_eq!(d.target, Target::All);
2216    }
2217
2218    #[test]
2219    fn clearmsg_targets_one_message_by_id() {
2220        let (l, d) = del(r#"{"type":"irc:message","channel":"c","message":{
2221            "type":"clearmsg","targetMsgId":"abc-123","login":"troll"}}"#);
2222        assert!(l.is_none(), "a single delete adds no mod-log line");
2223        assert_eq!(d.target, Target::Msg("abc-123".into()));
2224        assert_eq!(d.platform, Platform::Twitch);
2225        assert_eq!(d.label, "deleted");
2226    }
2227
2228    #[test]
2229    fn youtube_delete_batches_message_ids() {
2230        match parse(r#"{"type":"youtube:delete","videoId":"v1","messageIds":["a","b"]}"#) {
2231            Event::Batch(es) => {
2232                assert_eq!(es.len(), 2);
2233                match &es[1] {
2234                    Event::Delete { del, .. } => {
2235                        assert_eq!(del.target, Target::Msg("b".into()));
2236                        assert_eq!(del.channel, "v1");
2237                        assert_eq!(del.platform, Platform::Youtube);
2238                    }
2239                    e => panic!("{e:?}"),
2240                }
2241            }
2242            e => panic!("{e:?}"),
2243        }
2244    }
2245
2246    #[test]
2247    fn kick_and_youtube_lines_carry_their_ids() {
2248        let raw = r##"{"type":"kick-chat-message","data":{
2249            "id":"k-1","channel":"c","username":"u","content":"hi"}}"##;
2250        match parse(raw) {
2251            Event::Chat(l) => assert_eq!(l.id.as_deref(), Some("k-1")),
2252            e => panic!("{e:?}"),
2253        }
2254        let raw = r##"{"type":"youtube:chat","videoId":"v","messages":[{"id":"y-9","user":"a","text":"x"}]}"##;
2255        match parse(raw) {
2256            Event::Backfill(l) => assert_eq!(l[0].id.as_deref(), Some("y-9")),
2257            e => panic!("{e:?}"),
2258        }
2259    }
2260
2261    #[test]
2262    fn roomstate_is_state_not_a_line_and_clearmsg_needs_a_target() {
2263        let rs = r#"{"type":"irc:message","channel":"c","message":{
2264            "type":"roomstate","emoteOnly":false,"slow":0}}"#;
2265        assert!(matches!(parse(rs), Event::Room(..)));
2266        let cm = r#"{"type":"irc:message","channel":"c","message":{
2267            "type":"clearmsg","targetMsgId":""}}"#;
2268        assert_eq!(parse(cm), Event::Ignore, "no target id, nothing to strike");
2269    }
2270
2271    #[test]
2272    fn privmsg_bits_become_a_cheer_note() {
2273        let raw = r##"{"type":"irc:message","channel":"c","message":{
2274            "type":"privmsg","username":"u","content":"Cheer100 gg","bits":100}}"##;
2275        match parse(raw) {
2276            Event::Chat(l) => {
2277                let n = l.note.expect("note");
2278                assert_eq!(n.kind, NoteKind::Cheer);
2279                assert_eq!(n.what, "cheered 100 bits");
2280                assert_eq!(l.content, "Cheer100 gg");
2281            }
2282            _ => panic!("expected chat"),
2283        }
2284    }
2285
2286    #[test]
2287    fn stream_frames_route_by_platform_and_channel() {
2288        let online = r##"{"type":"stream:online","platform":"kick","channel":"xqc",
2289            "game":"Just Chatting","title":"BIG DAY"}"##;
2290        match parse(online) {
2291            Event::Chat(l) => {
2292                assert_eq!(l.platform, Platform::Kick);
2293                assert_eq!(l.channel, "xqc");
2294                let n = l.note.unwrap();
2295                assert_eq!(n.kind, NoteKind::Live);
2296                assert_eq!(n.what, "went live — Just Chatting");
2297                assert_eq!(l.content, "BIG DAY");
2298            }
2299            _ => panic!(),
2300        }
2301        let update = r##"{"type":"stream:update","platform":"twitch","channel":"c",
2302            "isLive":true,"game":"Elden Ring","title":"t","prevGame":"Just Chatting","prevTitle":"t"}"##;
2303        match parse(update) {
2304            Event::Chat(l) => {
2305                let n = l.note.unwrap();
2306                assert_eq!(n.kind, NoteKind::Category);
2307                assert_eq!(n.what, "switched to Elden Ring");
2308                assert_eq!(l.content, "", "unchanged title not repeated");
2309            }
2310            _ => panic!(),
2311        }
2312        let redeem = r##"{"type":"stream:redeem","platform":"twitch","channel":"c",
2313            "user":"viewer","title":"Hydrate","cost":500}"##;
2314        match parse(redeem) {
2315            Event::Chat(l) => {
2316                assert_eq!(l.user, "viewer");
2317                assert_eq!(l.note.unwrap().what, "redeemed Hydrate (500)");
2318            }
2319            _ => panic!(),
2320        }
2321        match parse(r#"{"type":"stream:offline","platform":"twitch","channel":"c"}"#) {
2322            Event::Chat(l) => {
2323                let n = l.note.unwrap();
2324                assert_eq!(n.kind, NoteKind::Offline);
2325                assert_eq!(n.what, "went offline");
2326            }
2327            _ => panic!(),
2328        }
2329    }
2330
2331    #[test]
2332    fn title_only_update_and_same_game_make_no_line() {
2333        let t = r#"{"type":"stream:update","platform":"twitch","channel":"c",
2334            "game":"A","prevGame":"A","title":"new","prevTitle":"old"}"#;
2335        assert_eq!(parse(t), Event::Ignore);
2336        let e = r#"{"type":"stream:update","platform":"twitch","channel":"c",
2337            "game":"","prevGame":"A","title":"new","prevTitle":"old"}"#;
2338        assert_eq!(parse(e), Event::Ignore);
2339    }
2340
2341    #[test]
2342    fn online_without_game_and_first_game_update() {
2343        let on = r#"{"type":"stream:online","platform":"twitch","channel":"c","game":""}"#;
2344        match parse(on) {
2345            Event::Chat(l) => assert_eq!(l.note.unwrap().what, "went live"),
2346            _ => panic!(),
2347        }
2348        let up = r#"{"type":"stream:update","platform":"twitch","channel":"c",
2349            "game":"X","prevGame":""}"#;
2350        match parse(up) {
2351            Event::Chat(l) => {
2352                assert_eq!(l.note.unwrap().what, "switched to X");
2353                let s = l.stream.unwrap();
2354                assert_eq!(
2355                    (s.kind, s.game.as_str(), s.prev_game.as_str()),
2356                    (StreamKind::Update, "X", "")
2357                );
2358                assert!(!s.follow);
2359            }
2360            _ => panic!(),
2361        }
2362    }
2363
2364    #[test]
2365    fn follow_events_carry_flag_and_color() {
2366        let raw = r##"{"type":"follow:stream:online","platform":"twitch","channel":"cyr",
2367            "game":"Chess","title":"t","color":"#ff0000"}"##;
2368        match parse(raw) {
2369            Event::Chat(l) => {
2370                assert_eq!(l.color.as_deref(), Some("#ff0000"));
2371                assert_eq!(l.note.unwrap().what, "went live — Chess");
2372                let s = l.stream.unwrap();
2373                assert!(s.follow);
2374                assert_eq!(s.kind, StreamKind::Online);
2375            }
2376            _ => panic!(),
2377        }
2378        match parse(r#"{"type":"follow:stream:offline","platform":"kick","channel":"cyr"}"#) {
2379            Event::Chat(l) => {
2380                assert_eq!(l.platform, Platform::Kick);
2381                assert!(l.stream.unwrap().follow);
2382            }
2383            _ => panic!(),
2384        }
2385        match parse(
2386            r#"{"type":"follow:stream:update","platform":"twitch","channel":"c","game":"B","prevGame":"A"}"#,
2387        ) {
2388            Event::Chat(l) => assert_eq!(l.note.unwrap().what, "switched to B"),
2389            _ => panic!(),
2390        }
2391    }
2392
2393    #[test]
2394    fn follow_history_becomes_ordered_follow_lines() {
2395        let raw = r##"{"type":"follow:history","events":[
2396          {"type":"follow:stream:offline","platform":"kick","channel":"b","time":2000000},
2397          {"type":"follow:stream:online","platform":"twitch","channel":"a","game":"Doom","title":"t","color":"#ff0000","time":1000000},
2398          {"type":"follow:stream:update","platform":"twitch","channel":"a","game":"Quake","prevGame":"Doom","time":1500000},
2399          {"type":"follow:stream:update","platform":"twitch","channel":"a","game":"Quake","prevGame":"Quake","time":1600000},
2400          {"type":"stream:raid","platform":"twitch","channel":"a","time":1700000},
2401          {"type":"follow:stream:online","platform":"twitch","channel":"a","time":0},
2402          {"type":"follow:stream:online","platform":"nope","channel":"a","time":5},
2403          {"type":"follow:stream:online","channel":"a","time":5},
2404          7]}"##;
2405        match parse(raw) {
2406            Event::FollowHistory(h) => {
2407                let got: Vec<_> = h
2408                    .iter()
2409                    .map(|(l, at)| {
2410                        let n = l.note.as_ref().unwrap();
2411                        (
2412                            l.channel.as_str(),
2413                            n.what.as_str(),
2414                            *at,
2415                            l.stream.as_ref().unwrap().follow,
2416                        )
2417                    })
2418                    .collect();
2419                assert_eq!(
2420                    got,
2421                    vec![
2422                        ("a", "went live — Doom", 1_000_000, true),
2423                        ("a", "switched to Quake", 1_500_000, true),
2424                        ("b", "went offline", 2_000_000, true),
2425                    ]
2426                );
2427                assert_eq!(h[0].0.color.as_deref(), Some("#ff0000"));
2428                assert_eq!(h[0].0.content, "t");
2429            }
2430            e => panic!("{e:?}"),
2431        }
2432    }
2433
2434    #[test]
2435    fn stream_rows_name_the_streamer_with_colour_and_a_safe_id() {
2436        let line = |raw: &str| match parse(raw) {
2437            Event::Chat(l) => l,
2438            e => panic!("{e:?}"),
2439        };
2440        let l = line(
2441            r##"{"type":"stream:online","platform":"twitch","channel":"Cyr","game":"Doom","color":"#ff00aa","userId":"12345"}"##,
2442        );
2443        assert_eq!(
2444            (l.user.as_str(), l.color.as_deref()),
2445            ("cyr", Some("#ff00aa"))
2446        );
2447        assert_eq!(l.uid.as_deref(), Some("12345"));
2448        let l = line(
2449            r#"{"type":"follow:stream:offline","platform":"kick","channel":"b","userId":"77"}"#,
2450        );
2451        assert_eq!(l.uid.as_deref(), Some("kick_77"));
2452        let l = line(
2453            r#"{"type":"stream:update","platform":"youtube","channel":"y","game":"B","prevGame":"A","userId":"UCabc_-1"}"#,
2454        );
2455        assert_eq!(l.uid.as_deref(), Some("yt_UCabc_-1"));
2456        // hostile ids are dropped, the row still lands
2457        let long = "9".repeat(65);
2458        for bad in [
2459            "../x".to_string(),
2460            "a,b".to_string(),
2461            "1 2".to_string(),
2462            String::new(),
2463            long,
2464        ] {
2465            let l = line(&format!(
2466                r#"{{"type":"stream:offline","platform":"twitch","channel":"c","userId":"{bad}"}}"#
2467            ));
2468            assert_eq!(l.uid, None, "{bad:?}");
2469            assert_eq!(l.note.unwrap().what, "went offline");
2470        }
2471        let l = line(r#"{"type":"stream:offline","platform":"twitch","channel":"c","userId":5}"#);
2472        assert_eq!(l.uid, None, "the id is a string");
2473        // other stream rows carry neither
2474        let l = line(
2475            r##"{"type":"stream:raid","platform":"twitch","channel":"c","target":"t","userId":"1","color":"#ff0000"}"##,
2476        );
2477        assert_eq!((l.uid, l.color, l.user.as_str()), (None, None, ""));
2478    }
2479
2480    #[test]
2481    fn follow_colors_keep_only_valid_hex_lowercased() {
2482        let raw = r##"{"type":"follow:colors","colors":{"Cyr":"#ff00aa","bad":"red","short":"#fff","n":5}}"##;
2483        assert_eq!(
2484            parse(raw),
2485            Event::FollowColors(vec![("cyr".into(), "#ff00aa".into())])
2486        );
2487    }
2488
2489    #[test]
2490    fn follow_history_and_colors_garbage_is_ignored() {
2491        for raw in [
2492            r#"{"type":"follow:history"}"#,
2493            r#"{"type":"follow:history","events":"x"}"#,
2494            r#"{"type":"follow:history","events":[]}"#,
2495            r#"{"type":"follow:history","events":[{"type":"follow:stream:online"}]}"#,
2496            r#"{"type":"follow:colors"}"#,
2497            r#"{"type":"follow:colors","colors":[1,2]}"#,
2498            r#"{"type":"follow:colors","colors":{}}"#,
2499        ] {
2500            assert_eq!(parse(raw), Event::Ignore, "{raw}");
2501        }
2502    }
2503
2504    #[test]
2505    fn follow_snapshot_lists_live_channels() {
2506        let raw = r#"{"type":"follow:live:snapshot","streams":[
2507            {"username":"Cyr","platform":"twitch","viewerCount":5},
2508            {"username":"xqc","platform":"KICK"},
2509            {"username":"","platform":"twitch"},
2510            {"username":"y","platform":"mars"}]}"#;
2511        assert_eq!(
2512            parse(raw),
2513            Event::FollowLive(vec![
2514                (Platform::Twitch, "cyr".into()),
2515                (Platform::Kick, "xqc".into())
2516            ])
2517        );
2518    }
2519
2520    #[test]
2521    fn spike_and_raid_frames_read_terse() {
2522        let spike = r##"{"type":"moment:spike","platform":"twitch","channel":"c",
2523            "rate":34,"baseline":4.2,"title":"x"}"##;
2524        match parse(spike) {
2525            Event::Chat(l) => {
2526                let n = l.note.unwrap();
2527                assert_eq!(n.kind, NoteKind::Spike);
2528                assert_eq!(n.what, "chat spike — 34/s (avg 4)");
2529            }
2530            _ => panic!(),
2531        }
2532        let raid = r##"{"type":"stream:raid","platform":"twitch","channel":"c",
2533            "target":"friend","viewers":500}"##;
2534        match parse(raid) {
2535            Event::Chat(l) => {
2536                assert_eq!(l.note.unwrap().what, "raided friend with 500 viewers");
2537            }
2538            _ => panic!(),
2539        }
2540    }
2541
2542    #[test]
2543    fn twitch_eventsub_subs_are_dropped_as_usernotice_dupes() {
2544        assert_eq!(
2545            parse(r#"{"type":"stream:sub","platform":"twitch","channel":"c","user":"u"}"#),
2546            Event::Ignore
2547        );
2548        assert_eq!(
2549            parse(
2550                r#"{"type":"stream:sub-gift","platform":"twitch","channel":"c","user":"u","count":5}"#
2551            ),
2552            Event::Ignore
2553        );
2554    }
2555
2556    #[test]
2557    fn kick_sub_events_use_system_text_with_fallbacks() {
2558        // live-captured shape: message is kick's own system sentence.
2559        let renewal = r##"{"type":"kick-sub-event","channel":"xqc","eventType":"renewal",
2560            "username":"slowlux","months":39,"message":"slowlux resubscribed for 39 months!"}"##;
2561        match parse(renewal) {
2562            Event::Chat(l) => {
2563                assert_eq!(l.platform, Platform::Kick);
2564                assert_eq!(l.user, "slowlux");
2565                let n = l.note.unwrap();
2566                assert_eq!(n.kind, NoteKind::Sub);
2567                assert_eq!(n.what, "resubscribed for 39 months!");
2568            }
2569            _ => panic!(),
2570        }
2571        let gift = r##"{"type":"kick-sub-event","channel":"c","eventType":"gift",
2572            "username":"rich","gifter":"rich","giftees":["a","b","c"],"message":""}"##;
2573        match parse(gift) {
2574            Event::Chat(l) => {
2575                let n = l.note.unwrap();
2576                assert_eq!(n.kind, NoteKind::Gift);
2577                assert_eq!(n.what, "gifted 3 subs");
2578            }
2579            _ => panic!(),
2580        }
2581        let kicks = r##"{"type":"kick-kicks-event","channel":"c","username":"fan",
2582            "amount":100,"giftName":"Kicks","message":"take it"}"##;
2583        match parse(kicks) {
2584            Event::Chat(l) => {
2585                let n = l.note.unwrap();
2586                assert_eq!(n.kind, NoteKind::Cheer);
2587                assert_eq!(n.what, "gifted 100 kicks");
2588                assert_eq!(l.content, "take it");
2589            }
2590            _ => panic!(),
2591        }
2592    }
2593
2594    #[test]
2595    fn youtube_status_maps_to_connected_and_ended() {
2596        let conn = r##"{"type":"youtube:status","videoId":"vid123abc","status":"connected",
2597            "channelName":"Northernlion","title":"the best stream"}"##;
2598        match parse(conn) {
2599            Event::Chat(l) => {
2600                assert_eq!(l.platform, Platform::Youtube);
2601                assert_eq!(l.channel, "vid123abc");
2602                let n = l.note.unwrap();
2603                assert_eq!(n.kind, NoteKind::Notice);
2604                assert_eq!(n.what, "connected — Northernlion");
2605                assert_eq!(l.content, "the best stream");
2606            }
2607            _ => panic!(),
2608        }
2609        let ended = r#"{"type":"youtube:status","videoId":"vid123abc","status":"ended"}"#;
2610        match parse(ended) {
2611            Event::Chat(l) => {
2612                let n = l.note.unwrap();
2613                assert_eq!(n.kind, NoteKind::Offline);
2614                assert_eq!(n.what, "stream ended");
2615            }
2616            _ => panic!(),
2617        }
2618    }
2619
2620    #[test]
2621    fn headline_split_respects_word_boundaries() {
2622        let mut user = "a".to_string();
2623        assert_eq!(
2624            split_headline(&mut user, "announcement time"),
2625            "announcement time"
2626        );
2627        assert_eq!(user, "", "non-prefixed headline drops the actor");
2628        let mut user = "Ann".to_string();
2629        assert_eq!(split_headline(&mut user, "Ann subscribed!"), "subscribed!");
2630        assert_eq!(user, "Ann");
2631    }
2632
2633    #[test]
2634    fn timeout_durations_format_terse() {
2635        assert_eq!(fmt_secs(45), "45s");
2636        assert_eq!(fmt_secs(600), "10m");
2637        assert_eq!(fmt_secs(7200), "2h");
2638        assert_eq!(fmt_secs(86_400), "1d");
2639        assert_eq!(fmt_secs(90), "90s");
2640    }
2641
2642    #[test]
2643    fn send_youtube_frame_shape() {
2644        let f = send_youtube("vid123abc", "hi", 7);
2645        assert!(f.contains(r#""type":"chat:send_youtube""#));
2646        assert!(f.contains(r#""videoId":"vid123abc""#));
2647        assert!(f.contains(r#""reqId":"7""#));
2648    }
2649    #[test]
2650    fn sender_ids_land_in_paint_space() {
2651        let tw = r#"{"type":"irc:message","channel":"c","message":{
2652            "username":"u","content":"x","userId":"12345"}}"#;
2653        let Event::Chat(l) = parse(tw) else { panic!() };
2654        assert_eq!(l.uid.as_deref(), Some("12345"));
2655        // kick ids overlap twitch's range — they never go bare
2656        let kick = r#"{"type":"kick-chat-message","data":{"channel":"c","username":"u",
2657            "content":"x","senderId":99}}"#;
2658        let Event::Chat(l) = parse(kick) else {
2659            panic!()
2660        };
2661        assert_eq!(l.uid.as_deref(), Some("kick_99"));
2662        let yt = r#"{"type":"youtube:chat","videoId":"v","messages":[
2663            {"user":"u","text":"x","authorChannelId":"UCabc-1"}]}"#;
2664        let Event::Backfill(ls) = parse(yt) else {
2665            panic!()
2666        };
2667        assert_eq!(ls[0].uid.as_deref(), Some("yt_UCabc-1"));
2668        let bad = r#"{"type":"irc:message","channel":"c","message":{
2669            "username":"u","content":"x","userId":"1,2"}}"#;
2670        let Event::Chat(l) = parse(bad) else { panic!() };
2671        assert_eq!(l.uid, None);
2672    }
2673
2674    #[test]
2675    fn color_and_cosmetic_pushes_parse() {
2676        let c = r##"{"type":"profile:color","userId":"7","usernames":["a"],"color":"#aabbcc"}"##;
2677        assert_eq!(
2678            parse(c),
2679            Event::Color {
2680                id: Some("7".into()),
2681                names: vec!["a".into()],
2682                color: "#aabbcc".into()
2683            }
2684        );
2685        let bad = r#"{"type":"profile:color","userId":"7","color":"red"}"#;
2686        assert_eq!(parse(bad), Event::Ignore);
2687        let names =
2688            r##"{"type":"profile:color","usernames":["MixedCase","kicker"],"color":"#010203"}"##;
2689        assert_eq!(
2690            parse(names),
2691            Event::Color {
2692                id: None,
2693                names: vec!["mixedcase".into(), "kicker".into()],
2694                color: "#010203".into()
2695            }
2696        );
2697        let empty = r##"{"type":"profile:color","usernames":[],"color":"#010203"}"##;
2698        assert_eq!(parse(empty), Event::Ignore);
2699        let ch = r#"{"type":"cosmetic:changed","ids":["7","kick_9","bad id"]}"#;
2700        assert_eq!(
2701            parse(ch),
2702            Event::Cosmetic(vec!["7".into(), "kick_9".into()])
2703        );
2704    }
2705
2706    #[test]
2707    fn live_whispers_and_dms_remember_who_to_answer() {
2708        let w = special(
2709            r#"{"type":"whisper:new","from_twitch_id":"42","from_twitch_login":"Bob","from_display_name":"Bob","content":"hi"}"#,
2710        );
2711        let p = w.peer.unwrap();
2712        assert_eq!(
2713            (p.id.as_str(), p.login.as_str(), p.dm),
2714            ("42", "bob", false)
2715        );
2716        let d = special(
2717            r#"{"type":"dm:new","from_user_id":12,"from_display_name":"Cat","content":"yo"}"#,
2718        );
2719        let p = d.peer.unwrap();
2720        assert_eq!((p.id.as_str(), p.name.as_str(), p.dm), ("12", "Cat", true));
2721        // no id in the frame: still shown, just not answerable by name
2722        let n = special(r#"{"type":"whisper:new","from_display_name":"Bob","content":"hi"}"#);
2723        assert!(n.peer.is_none());
2724    }
2725}