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::proto::{field, platform_or_twitch, special_chat, str_or_num, SpecialLine};
8use crate::{Platform, TabKind, Tag, TagKind};
9
10/// someone you can whisper or dm: `id` is the twitch id (whisper) or the
11/// heatsync user id (dm) — what the send routes want instead of a name.
12#[derive(Clone, Debug, PartialEq)]
13pub struct Contact {
14    pub name: String,
15    /// lowercase match key (the twitch login, or the heatsync username).
16    pub login: String,
17    pub id: String,
18    pub dm: bool,
19}
20
21/// a history row with the time it happened, so threads merge chronologically.
22#[derive(Clone, Debug, PartialEq)]
23pub struct Row {
24    pub at: i64,
25    pub line: SpecialLine,
26}
27
28/// one server-side mention-rule hit.
29#[derive(Clone, Debug, PartialEq)]
30pub struct Hit {
31    /// the platform message id, when the rule match knew it (dedupe key).
32    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
40/// `POST /api/cli/messages`: a feed post, or a reply when `reply_to` is set.
41pub 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
49/// `POST /api/cli/dm`.
50pub fn dm_body(to_user_id: &str, content: &str) -> Value {
51    json!({ "toUserId": to_user_id, "content": content })
52}
53
54/// `POST /api/cli/twitch/whisper` (the id must be digits).
55pub 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    // ids go into a URL path: a twitch id is digits, a heatsync id letters,
62    // digits, `_` and `-` — refuse anything else before it gets there
63    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
76/// `GET /api/cli/whispers`: who you have whispered with, plus the last person
77/// who whispered you (what `/r` would answer).
78pub 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
103/// `GET /api/cli/dm`.
104pub 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
117/// `GET /api/cli/users/lookup` → the id `/w` (twitch) or `/dm` (heatsync) needs.
118/// `None` when the person has no account on that side.
119pub 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
124/// `GET /api/cli/following`: the people you follow on heatsync — the only
125/// directory a terminal login can read, so it is how `/dm <user>` finds an id
126/// for someone you have not messaged yet.
127pub 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
186/// `GET /api/cli/whispers/:id` → rows, oldest first (the server sends newest first).
187pub 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
210/// `GET /api/cli/dm/:id` → rows, oldest first.
211pub 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
226/// `GET /api/cli/mention-rules/hits` → hits as the server lists them (newest first).
227pub 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
266/// the contact a typed name means: login or display name, `@` and case ignored.
267pub 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"]); // non-digit id refused, last_received not doubled
318        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}