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