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