Skip to main content

heatsync_core/
proto.rs

1//! HeatSync `/ws` relay protocol — parse inbound frames, build outbound ones.
2//! anon read is allowed, so no auth is required to consume chat. content and
3//! usernames are sanitized HERE, at the trust boundary, before anything else
4//! touches them (terminal-injection defense).
5
6use serde_json::{json, Value};
7
8use crate::{sanitize, Platform};
9
10/// a normalized chat line, ready for a channel buffer.
11#[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/// what an inbound frame turned into.
21#[derive(Clone, Debug, PartialEq)]
22pub enum Event {
23    Chat(ChatLine),
24    Backfill(Vec<ChatLine>),
25    /// authentication outcome (true = ok).
26    Auth(bool),
27    /// result of an outbound send: ok, or an error reason.
28    SendResult {
29        ok: bool,
30        error: Option<String>,
31    },
32    Pong,
33    Ignore,
34}
35
36/// parse one inbound text frame. unknown / malformed frames become `Ignore`
37/// rather than erroring — a hostile or new server can't crash the client.
38pub 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
80/// pull a ChatLine out of a message object. drops lines with empty user+text.
81fn 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
109/// outbound: subscribe to a channel's live chat.
110pub 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
120/// outbound: unsubscribe.
121pub 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
131/// outbound: keepalive, send every ~10s (server replies `pong`).
132pub fn heartbeat() -> String {
133    json!({ "type": "presence:heartbeat" }).to_string()
134}
135
136/// outbound: authenticate a session so sends are accepted. server replies
137/// `authenticated` / `authentication_failed`.
138pub fn authenticate(token: &str) -> String {
139    json!({ "type": "authenticate", "token": token }).to_string()
140}
141
142/// outbound: post a message to a kick channel (relayed via the user's extension).
143/// text is 1–500 chars server-side; `req` correlates the result frame.
144/// note: twitch has NO send path — the relay ingests twitch read-only.
145pub 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        // control chars arrive JSON-escaped (backslash-u) — valid JSON, then
180        // stripped by sanitize: \u001b => ESC, \u0007 => BEL.
181        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"); // ESC + BEL stripped
189                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}