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