Skip to main content

heatsync_core/
social.rs

1//! the account side of the virtual tabs: dms, whispers and mention-rule hits
2//! as the `/api/cli/*` routes return them, and the request bodies for sending.
3//! pure — no network; the tui does the calls and feeds the answers here.
4
5use 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/// someone you can whisper or dm: `id` is the twitch id (whisper) or the
12/// heatsync user id (dm) — what the send routes want instead of a name.
13#[derive(Clone, Debug, PartialEq)]
14pub struct Contact {
15    pub name: String,
16    /// lowercase match key (the twitch login, or the heatsync username).
17    pub login: String,
18    pub id: String,
19    pub dm: bool,
20}
21
22/// a history row with the time it happened, so threads merge chronologically.
23#[derive(Clone, Debug, PartialEq)]
24pub struct Row {
25    pub at: i64,
26    pub line: SpecialLine,
27}
28
29/// one server-side mention-rule hit.
30#[derive(Clone, Debug, PartialEq)]
31pub struct Hit {
32    /// the platform message id, when the rule match knew it (dedupe key).
33    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
41/// `POST /api/cli/messages`: a feed post, or a reply when `reply_to` is set.
42pub 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
50/// `POST /api/cli/dm`.
51pub fn dm_body(to_user_id: &str, content: &str) -> Value {
52    json!({ "toUserId": to_user_id, "content": content })
53}
54
55/// `POST /api/cli/twitch/whisper` (the id must be digits).
56pub 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    // ids go into a URL path: a twitch id is digits, a heatsync id letters,
63    // digits, `_` and `-` — refuse anything else before it gets there
64    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
77/// `GET /api/cli/whispers`: who you have whispered with, plus the last person
78/// who whispered you (what `/r` would answer).
79pub 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
104/// `GET /api/cli/dm`.
105pub 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
118/// `GET /api/cli/users/lookup` → the id `/w` (twitch) or `/dm` (heatsync) needs.
119/// `None` when the person has no account on that side.
120pub 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
125/// `GET /api/cli/following`: the people you follow on heatsync — the only
126/// directory a terminal login can read, so it is how `/dm <user>` finds an id
127/// for someone you have not messaged yet.
128pub 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
195/// `GET /api/cli/whispers/:id` → rows, oldest first (the server sends newest first).
196pub 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
219/// `GET /api/cli/dm/:id` → rows, oldest first.
220pub 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
235/// `GET /api/cli/mention-rules/hits` → hits as the server lists them (newest first).
236pub 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
275/// the contact a typed name means: login or display name, `@` and case ignored.
276pub 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"]); // non-digit id refused, last_received not doubled
327        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}