1use serde_json::{json, Value};
6
7use crate::proto::{field, platform_or_twitch, special_chat, str_or_num, SpecialLine};
8use crate::{Platform, TabKind, Tag, TagKind};
9
10#[derive(Clone, Debug, PartialEq)]
13pub struct Contact {
14 pub name: String,
15 pub login: String,
17 pub id: String,
18 pub dm: bool,
19}
20
21#[derive(Clone, Debug, PartialEq)]
23pub struct Row {
24 pub at: i64,
25 pub line: SpecialLine,
26}
27
28#[derive(Clone, Debug, PartialEq)]
30pub struct Hit {
31 pub id: Option<String>,
33 pub platform: Platform,
34 pub channel: String,
35 pub user: String,
36 pub text: String,
37 pub at: i64,
38}
39
40pub fn post_body(content: &str, reply_to: Option<&str>) -> Value {
42 let mut b = json!({ "content": content });
43 if let Some(r) = reply_to.filter(|r| !r.is_empty()) {
44 b["reply_to"] = json!(r);
45 }
46 b
47}
48
49pub fn dm_body(to_user_id: &str, content: &str) -> Value {
51 json!({ "toUserId": to_user_id, "content": content })
52}
53
54pub fn whisper_body(to_user_id: &str, message: &str) -> Value {
56 json!({ "to_user_id": to_user_id, "message": message })
57}
58
59pub(crate) fn contact(name: String, login: &str, id: String, dm: bool) -> Option<Contact> {
60 let login = crate::sanitize::clean(login).to_lowercase();
61 let id_ok = !id.is_empty()
64 && id.len() <= 50
65 && id.bytes().all(|b| {
66 b.is_ascii_digit() || (dm && (b.is_ascii_alphabetic() || b == b'_' || b == b'-'))
67 });
68 (id_ok && !login.is_empty()).then_some(Contact {
69 name: if name.is_empty() { login.clone() } else { name },
70 login,
71 id,
72 dm,
73 })
74}
75
76pub fn whisper_contacts(v: &Value) -> Vec<Contact> {
79 let mut out: Vec<Contact> = v
80 .get("conversations")
81 .and_then(Value::as_array)
82 .into_iter()
83 .flatten()
84 .filter_map(|c| {
85 let login = field(c, "other_twitch_login");
86 let name = field(c, "other_twitch_display");
87 contact(name, &login, str_or_num(c, &["other_twitch_id"])?, false)
88 })
89 .collect();
90 if let Some(l) = v.get("last_received") {
91 let login = field(l, "login");
92 if let Some(c) =
93 str_or_num(l, &["twitch_id"]).and_then(|id| contact(String::new(), &login, id, false))
94 {
95 if !out.iter().any(|o| o.id == c.id) {
96 out.push(c);
97 }
98 }
99 }
100 out
101}
102
103pub fn dm_contacts(v: &Value) -> Vec<Contact> {
105 v.get("conversations")
106 .and_then(Value::as_array)
107 .into_iter()
108 .flatten()
109 .filter_map(|c| {
110 let login = field(c, "username");
111 let name = field(c, "display_name");
112 contact(name, &login, str_or_num(c, &["other_user_id"])?, true)
113 })
114 .collect()
115}
116
117pub fn lookup_contact(v: &Value, login: &str, dm: bool) -> Option<Contact> {
120 let id = str_or_num(v, &[if dm { "heatsync_user_id" } else { "twitch_id" }])?;
121 contact(field(v, "display_name"), login, id, dm)
122}
123
124pub fn following_contacts(v: &Value) -> Vec<Contact> {
128 v.get("following")
129 .and_then(Value::as_array)
130 .into_iter()
131 .flatten()
132 .filter_map(|c| {
133 let login = field(c, "username");
134 let name = field(c, "display_name");
135 contact(name, &login, str_or_num(c, &["id"])?, true)
136 })
137 .collect()
138}
139
140fn at(m: &Value) -> i64 {
141 m.get("created_at")
142 .and_then(Value::as_str)
143 .and_then(crate::iso::parse)
144 .unwrap_or(0)
145}
146
147fn color(m: &Value) -> Option<String> {
148 m.get("from_color")
149 .and_then(Value::as_str)
150 .filter(|s| s.starts_with('#') && (s.len() == 7 || s.len() == 4))
151 .map(str::to_string)
152}
153
154fn thread_row(m: &Value, other: &Contact, incoming: bool, name: String) -> Row {
155 let (tag, user, color) = match (incoming, other.dm) {
156 (true, false) => ("[whisper]".to_string(), name, color(m)),
157 (true, true) => ("[DM]".to_string(), name, color(m)),
158 (false, false) => (format!("[whisper → {}]", other.login), "you".into(), None),
159 (false, true) => (format!("[DM → {}]", other.login), "you".into(), None),
160 };
161 let kind = if other.dm {
162 TagKind::Dm
163 } else {
164 TagKind::Whisper
165 };
166 Row {
167 at: at(m),
168 line: SpecialLine {
169 tab: TabKind::Whispers,
170 line: special_chat(
171 Platform::Twitch,
172 String::new(),
173 None,
174 user,
175 color,
176 field(m, "content"),
177 Vec::new(),
178 ),
179 tags: vec![Tag::new(&tag, kind)],
180 heat: 0.0,
181 peer: None,
182 },
183 }
184}
185
186pub fn whisper_thread(v: &Value, other: &Contact) -> Vec<Row> {
188 let mut rows: Vec<Row> = v
189 .get("messages")
190 .and_then(Value::as_array)
191 .into_iter()
192 .flatten()
193 .map(|m| {
194 let incoming = str_or_num(m, &["from_twitch_id"]).as_deref() == Some(other.id.as_str());
195 let name = {
196 let d = field(m, "from_twitch_display");
197 if d.is_empty() {
198 field(m, "from_twitch_login")
199 } else {
200 d
201 }
202 };
203 thread_row(m, other, incoming, name)
204 })
205 .collect();
206 rows.sort_by_key(|r| r.at);
207 rows
208}
209
210pub fn dm_thread(v: &Value, other: &Contact) -> Vec<Row> {
212 let mut rows: Vec<Row> = v
213 .get("messages")
214 .and_then(Value::as_array)
215 .into_iter()
216 .flatten()
217 .map(|m| {
218 let incoming = str_or_num(m, &["from_user_id"]).as_deref() == Some(other.id.as_str());
219 thread_row(m, other, incoming, other.name.clone())
220 })
221 .collect();
222 rows.sort_by_key(|r| r.at);
223 rows
224}
225
226pub fn hits(v: &Value) -> Vec<Hit> {
228 v.get("hits")
229 .and_then(Value::as_array)
230 .into_iter()
231 .flatten()
232 .filter_map(|h| {
233 let user = {
234 let d = field(h, "display_name");
235 if d.is_empty() {
236 field(h, "username")
237 } else {
238 d
239 }
240 };
241 let text = field(h, "snippet");
242 if user.is_empty() && text.is_empty() {
243 return None;
244 }
245 Some(Hit {
246 id: str_or_num(h, &["message_id"]),
247 platform: platform_or_twitch(
248 h.get("platform").and_then(Value::as_str).unwrap_or(""),
249 ),
250 channel: field(h, "channel"),
251 user,
252 text,
253 at: at_of(h, "matched_at"),
254 })
255 })
256 .collect()
257}
258
259fn at_of(v: &Value, k: &str) -> i64 {
260 v.get(k)
261 .and_then(Value::as_str)
262 .and_then(crate::iso::parse)
263 .unwrap_or(0)
264}
265
266pub fn resolve<'a>(contacts: &'a [Contact], dm: bool, who: &str) -> Option<&'a Contact> {
268 let w = who.trim().trim_start_matches('@').to_lowercase();
269 contacts
270 .iter()
271 .find(|c| c.dm == dm && (c.login == w || c.name.to_lowercase() == w))
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277
278 fn wc() -> Contact {
279 Contact {
280 name: "Bob".into(),
281 login: "bob".into(),
282 id: "42".into(),
283 dm: false,
284 }
285 }
286
287 #[test]
288 fn bodies_carry_exactly_the_documented_fields() {
289 assert_eq!(post_body("gm", None), json!({"content": "gm"}));
290 assert_eq!(
291 post_body("gm", Some("abc12")),
292 json!({"content": "gm", "reply_to": "abc12"})
293 );
294 assert_eq!(post_body("gm", Some("")), json!({"content": "gm"}));
295 assert_eq!(
296 dm_body("7", "hi"),
297 json!({"toUserId": "7", "content": "hi"})
298 );
299 assert_eq!(
300 whisper_body("42", "hi"),
301 json!({"to_user_id": "42", "message": "hi"})
302 );
303 }
304
305 #[test]
306 fn whisper_contacts_read_conversations_and_the_last_sender_once() {
307 let v = json!({
308 "conversations": [
309 {"other_twitch_id": "42", "other_twitch_login": "Bob", "other_twitch_display": "Bob", "content": "x"},
310 {"other_twitch_id": "x/../9", "other_twitch_login": "evil"},
311 {"other_twitch_id": 77, "other_twitch_login": "amy"}
312 ],
313 "last_received": {"twitch_id": "42", "login": "bob"}
314 });
315 let c = whisper_contacts(&v);
316 let ids: Vec<_> = c.iter().map(|c| c.id.as_str()).collect();
317 assert_eq!(ids, ["42", "77"]); assert_eq!(c[1].login, "amy");
319 let only_last = whisper_contacts(
320 &json!({"conversations": [], "last_received": {"twitch_id": "5", "login": "Zed"}}),
321 );
322 assert_eq!(only_last[0].name, "zed");
323 }
324
325 #[test]
326 fn dm_contacts_read_user_ids() {
327 let v = json!({"conversations": [
328 {"other_user_id": "12", "username": "cat", "display_name": "Cat"},
329 {"username": "no-id"}
330 ]});
331 let c = dm_contacts(&v);
332 assert_eq!(c.len(), 1);
333 assert_eq!(
334 (c[0].id.as_str(), c[0].name.as_str(), c[0].dm),
335 ("12", "Cat", true)
336 );
337 }
338
339 #[test]
340 fn lookup_picks_the_id_for_the_side_asked() {
341 let v = json!({"twitch_id": "42", "heatsync_user_id": "u_7", "display_name": "Bob"});
342 let w = lookup_contact(&v, "Bob", false).unwrap();
343 assert_eq!(
344 (w.id.as_str(), w.name.as_str(), w.login.as_str()),
345 ("42", "Bob", "bob")
346 );
347 assert_eq!(lookup_contact(&v, "bob", true).unwrap().id, "u_7");
348 let only_twitch = json!({"twitch_id": 42});
349 assert!(lookup_contact(&only_twitch, "bob", true).is_none());
350 assert!(lookup_contact(&json!({"twitch_id": "a/b"}), "bob", false).is_none());
351 }
352
353 #[test]
354 fn following_makes_dm_contacts_and_skips_kick_style_ids_it_cannot_route() {
355 let v = json!({"following": [
356 {"id": "u1", "username": "streamer", "display_name": "Streamer"},
357 {"id": "bad/id", "username": "x"}
358 ]});
359 let c = following_contacts(&v);
360 assert_eq!(c.len(), 1);
361 assert_eq!((c[0].id.as_str(), c[0].dm), ("u1", true));
362 }
363
364 #[test]
365 fn a_whisper_thread_reads_oldest_first_with_direction() {
366 let v = json!({"messages": [
367 {"from_twitch_id": "9", "from_twitch_login": "me", "to_twitch_login": "bob", "content": "second", "created_at": "2026-10-04T20:00:05.000Z"},
368 {"from_twitch_id": "42", "from_twitch_login": "bob", "from_twitch_display": "Bob", "from_color": "#ff0000", "content": "first", "created_at": "2026-10-04T20:00:00.000Z"}
369 ]});
370 let r = whisper_thread(&v, &wc());
371 assert_eq!(r[0].line.line.content, "first");
372 assert_eq!(r[0].line.line.user, "Bob");
373 assert_eq!(r[0].line.line.color.as_deref(), Some("#ff0000"));
374 assert_eq!(r[0].line.tags[0].text, "[whisper]");
375 assert_eq!(r[1].line.line.user, "you");
376 assert_eq!(r[1].line.tags[0].text, "[whisper → bob]");
377 assert!(r[0].at < r[1].at);
378 }
379
380 #[test]
381 fn a_dm_thread_marks_which_side_spoke() {
382 let other = Contact {
383 name: "Cat".into(),
384 login: "cat".into(),
385 id: "12".into(),
386 dm: true,
387 };
388 let v = json!({"messages": [
389 {"from_user_id": "5", "to_user_id": "12", "content": "yo", "created_at": "2026-10-04T20:00:02.000Z"},
390 {"from_user_id": 12, "to_user_id": "5", "content": "hey", "created_at": "2026-10-04T20:00:01.000Z"}
391 ]});
392 let r = dm_thread(&v, &other);
393 assert_eq!(r[0].line.line.user, "Cat");
394 assert_eq!(r[0].line.tags[0].text, "[DM]");
395 assert_eq!(r[1].line.tags[0].text, "[DM → cat]");
396 }
397
398 #[test]
399 fn hits_keep_the_message_id_and_skip_empty_rows() {
400 let v = json!({"hits": [
401 {"id": 1, "rule_id": 3, "platform": "kick", "channel": "xqc", "username": "u", "display_name": "U", "message_id": "m1", "snippet": "hey mellen", "matched_at": "2026-10-04T20:00:00.000Z"},
402 {"id": 2, "platform": "twitch", "channel": "c"}
403 ], "kept": 200});
404 let h = hits(&v);
405 assert_eq!(h.len(), 1);
406 assert_eq!(
407 (
408 h[0].id.as_deref(),
409 h[0].platform,
410 h[0].user.as_str(),
411 h[0].at
412 ),
413 (Some("m1"), Platform::Kick, "U", 1791144000)
414 );
415 }
416
417 #[test]
418 fn resolve_matches_login_or_display_and_the_right_kind() {
419 let mut dm = wc();
420 dm.dm = true;
421 dm.id = "7".into();
422 let all = vec![wc(), dm];
423 assert_eq!(resolve(&all, false, "@BOB").unwrap().id, "42");
424 assert_eq!(resolve(&all, true, "bob").unwrap().id, "7");
425 assert!(resolve(&all, false, "nobody").is_none());
426 }
427}