1use serde_json::{json, Value};
7
8use crate::{sanitize, Platform};
9
10#[derive(Clone, Debug, PartialEq)]
12pub struct ChatLine {
13 pub platform: Platform,
14 pub channel: String,
15 pub user: String,
16 pub color: Option<String>,
17 pub content: String,
18}
19
20#[derive(Clone, Debug, PartialEq)]
22pub enum Event {
23 Chat(ChatLine),
24 Backfill(Vec<ChatLine>),
25 Auth(bool),
27 SendResult {
29 ok: bool,
30 error: Option<String>,
31 },
32 Pong,
33 Ignore,
34}
35
36pub fn parse(raw: &str) -> Event {
39 let v: Value = match serde_json::from_str(raw) {
40 Ok(v) => v,
41 Err(_) => return Event::Ignore,
42 };
43 match v.get("type").and_then(Value::as_str).unwrap_or("") {
44 "irc:message" => v
45 .get("message")
46 .and_then(|m| line_from(m, Platform::Twitch, v.get("channel")))
47 .map(Event::Chat)
48 .unwrap_or(Event::Ignore),
49 "kick-chat-message" => v
50 .get("data")
51 .and_then(|d| line_from(d, Platform::Kick, d.get("channel")))
52 .map(Event::Chat)
53 .unwrap_or(Event::Ignore),
54 "kick-chat-backfill" => {
55 let lines = v
56 .get("messages")
57 .and_then(Value::as_array)
58 .map(|arr| {
59 arr.iter()
60 .filter_map(|d| line_from(d, Platform::Kick, d.get("channel")))
61 .collect()
62 })
63 .unwrap_or_default();
64 Event::Backfill(lines)
65 }
66 "authenticated" => Event::Auth(true),
67 "authentication_failed" => Event::Auth(false),
68 "chat:send_kick_result" | "chat:send_youtube_result" => Event::SendResult {
69 ok: v.get("ok").and_then(Value::as_bool).unwrap_or(false),
70 error: v
71 .get("error")
72 .and_then(Value::as_str)
73 .map(|s| s.to_string()),
74 },
75 "pong" => Event::Pong,
76 _ => Event::Ignore,
77 }
78}
79
80fn line_from(m: &Value, platform: Platform, channel: Option<&Value>) -> Option<ChatLine> {
82 let user_raw = m
83 .get("displayName")
84 .and_then(Value::as_str)
85 .filter(|s| !s.is_empty())
86 .or_else(|| m.get("username").and_then(Value::as_str))
87 .unwrap_or("");
88 let content_raw = m.get("content").and_then(Value::as_str).unwrap_or("");
89 let user = sanitize::clean(user_raw);
90 let content = sanitize::clean(content_raw);
91 if user.is_empty() && content.is_empty() {
92 return None;
93 }
94 let color = m
95 .get("color")
96 .and_then(Value::as_str)
97 .filter(|s| s.starts_with('#') && (s.len() == 7 || s.len() == 4))
98 .map(|s| s.to_string());
99 let channel = channel.and_then(Value::as_str).unwrap_or("").to_string();
100 Some(ChatLine {
101 platform,
102 channel,
103 user,
104 color,
105 content,
106 })
107}
108
109pub fn join(platform: Platform, channel: &str) -> String {
111 match platform {
112 Platform::Twitch => json!({ "type": "irc:join", "channel": channel }),
113 Platform::Kick => {
114 json!({ "type": "channel:join", "platform": "kick", "channel": channel })
115 }
116 }
117 .to_string()
118}
119
120pub fn part(platform: Platform, channel: &str) -> String {
122 match platform {
123 Platform::Twitch => json!({ "type": "irc:part", "channel": channel }),
124 Platform::Kick => {
125 json!({ "type": "channel:leave", "platform": "kick", "channel": channel })
126 }
127 }
128 .to_string()
129}
130
131pub fn heartbeat() -> String {
133 json!({ "type": "presence:heartbeat" }).to_string()
134}
135
136pub fn authenticate(token: &str) -> String {
139 json!({ "type": "authenticate", "token": token }).to_string()
140}
141
142pub fn send_kick(channel: &str, text: &str, req: u64) -> String {
146 json!({
147 "type": "chat:send_kick",
148 "channel": channel,
149 "text": text,
150 "reqId": req.to_string(),
151 })
152 .to_string()
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158
159 #[test]
160 fn parses_twitch_message() {
161 let raw = r##"{"type":"irc:message","channel":"xqc","message":{
162 "username":"chat_user","displayName":"ChatUser","content":"hello GAMBA",
163 "color":"#ff8700","id":"abc"}}"##;
164 let ev = parse(raw);
165 assert_eq!(
166 ev,
167 Event::Chat(ChatLine {
168 platform: Platform::Twitch,
169 channel: "xqc".into(),
170 user: "ChatUser".into(),
171 color: Some("#ff8700".into()),
172 content: "hello GAMBA".into(),
173 })
174 );
175 }
176
177 #[test]
178 fn parses_kick_message_and_sanitizes() {
179 let raw = "{\"type\":\"kick-chat-message\",\"data\":{\
182 \"channel\":\"trainwreckstv\",\"username\":\"u\",\"displayName\":\"U\",\
183 \"content\":\"hi\\u001b]0;evil\\u0007\",\"color\":\"#53fc18\"}}";
184 let ev = parse(raw);
185 match ev {
186 Event::Chat(l) => {
187 assert_eq!(l.platform, Platform::Kick);
188 assert_eq!(l.content, "hi]0;evil"); assert!(!l.content.contains('\u{1b}'));
190 }
191 _ => panic!("expected chat"),
192 }
193 }
194
195 #[test]
196 fn backfill_yields_all_lines() {
197 let raw = r#"{"type":"kick-chat-backfill","channel":"c","replay":true,"messages":[
198 {"channel":"c","username":"a","content":"one"},
199 {"channel":"c","username":"b","content":"two"}]}"#;
200 match parse(raw) {
201 Event::Backfill(v) => assert_eq!(v.len(), 2),
202 _ => panic!("expected backfill"),
203 }
204 }
205
206 #[test]
207 fn junk_is_ignored_not_fatal() {
208 assert_eq!(parse("not json"), Event::Ignore);
209 assert_eq!(parse(r#"{"type":"whatever"}"#), Event::Ignore);
210 assert_eq!(parse(r#"{"type":"pong"}"#), Event::Pong);
211 }
212
213 #[test]
214 fn join_frames_are_correct() {
215 assert_eq!(
216 join(Platform::Twitch, "xqc"),
217 r#"{"channel":"xqc","type":"irc:join"}"#
218 );
219 assert!(join(Platform::Kick, "x").contains("channel:join"));
220 }
221}