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