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