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