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