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