Skip to main content

heatsync_core/
room.rs

1//! room-mode changes → system notes. twitch re-sends ROOMSTATE on join and on
2//! every mode change; the first state per channel is only remembered, later
3//! ones say what changed (`slow mode 30s`, `emote-only on`, …).
4
5use std::collections::HashMap;
6
7use crate::proto::{fmt_secs, note_line, ChatLine, RoomState};
8use crate::{NoteKind, Platform};
9
10/// the full set of modes, built up from partial ROOMSTATEs.
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12struct Modes {
13    slow: u32,
14    followers: i32,
15    emote_only: bool,
16    subs_only: bool,
17}
18
19impl Modes {
20    /// the modes after `s`: tags it carried replace ours, absent ones stay.
21    fn with(self, s: RoomState) -> Modes {
22        Modes {
23            slow: s.slow.unwrap_or(self.slow),
24            followers: s.followers.unwrap_or(self.followers),
25            emote_only: s.emote_only.unwrap_or(self.emote_only),
26            subs_only: s.subs_only.unwrap_or(self.subs_only),
27        }
28    }
29}
30
31/// twitch's default when we have not seen a mode yet: everything off.
32const OFF: Modes = Modes {
33    slow: 0,
34    followers: -1,
35    emote_only: false,
36    subs_only: false,
37};
38
39#[derive(Default)]
40pub struct RoomModes {
41    seen: HashMap<String, Modes>,
42}
43
44impl RoomModes {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// fold in a channel's new state; returns a note line per change. the first
50    /// state per channel is only remembered.
51    pub fn update(&mut self, channel: &str, now: RoomState) -> Vec<ChatLine> {
52        let key = channel.to_lowercase();
53        let Some(was) = self.seen.get(&key).copied() else {
54            self.seen.insert(key, OFF.with(now));
55            return Vec::new();
56        };
57        let next = was.with(now);
58        self.seen.insert(key, next);
59        changes(was, next)
60            .into_iter()
61            .filter_map(|what| {
62                note_line(
63                    Platform::Twitch,
64                    channel.to_string(),
65                    String::new(),
66                    None,
67                    NoteKind::Mode,
68                    what,
69                    String::new(),
70                )
71            })
72            .collect()
73    }
74}
75
76fn on_off(label: &str, on: bool) -> String {
77    format!("{label} {}", if on { "on" } else { "off" })
78}
79
80fn changes(was: Modes, now: Modes) -> Vec<String> {
81    let mut out = Vec::new();
82    if was.slow != now.slow {
83        out.push(if now.slow == 0 {
84            "slow mode off".to_string()
85        } else {
86            format!("slow mode {}", fmt_secs(now.slow as u64))
87        });
88    }
89    if was.followers != now.followers {
90        out.push(match now.followers {
91            n if n < 0 => "followers-only off".to_string(),
92            0 => "followers-only on".to_string(),
93            n => format!("followers-only {}", fmt_secs(n as u64 * 60)),
94        });
95    }
96    if was.emote_only != now.emote_only {
97        out.push(on_off("emote-only", now.emote_only));
98    }
99    if was.subs_only != now.subs_only {
100        out.push(on_off("sub-only", now.subs_only));
101    }
102    out
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use crate::proto::{parse, Event};
109
110    /// a full state (the join ROOMSTATE) with every tag present.
111    fn full(slow: u32, followers: i32, emote_only: bool, subs_only: bool) -> RoomState {
112        RoomState {
113            slow: Some(slow),
114            followers: Some(followers),
115            emote_only: Some(emote_only),
116            subs_only: Some(subs_only),
117        }
118    }
119    const NONE: RoomState = RoomState {
120        slow: None,
121        followers: None,
122        emote_only: None,
123        subs_only: None,
124    };
125
126    fn whats(v: &[ChatLine]) -> Vec<String> {
127        v.iter()
128            .map(|l| l.note.as_ref().unwrap().what.clone())
129            .collect()
130    }
131
132    #[test]
133    fn first_state_is_silent_then_changes_speak() {
134        let mut r = RoomModes::new();
135        assert!(r.update("c", full(0, -1, false, false)).is_empty());
136        for (next, want) in [
137            (full(30, -1, false, false), vec!["slow mode 30s"]),
138            (full(120, -1, false, false), vec!["slow mode 2m"]),
139            (full(0, -1, false, false), vec!["slow mode off"]),
140            (full(0, 10, false, false), vec!["followers-only 10m"]),
141            (full(0, 0, false, false), vec!["followers-only on"]),
142            (full(0, 90, false, false), vec!["followers-only 90m"]),
143            (full(0, -1, false, false), vec!["followers-only off"]),
144            (full(0, -1, true, false), vec!["emote-only on"]),
145            (full(0, -1, false, false), vec!["emote-only off"]),
146            (full(0, -1, false, true), vec!["sub-only on"]),
147            (
148                full(5, 1, true, true),
149                vec!["slow mode 5s", "followers-only 1m", "emote-only on"],
150            ),
151        ] {
152            let got = r.update("c", next);
153            assert_eq!(whats(&got), want, "{next:?}");
154            assert!(got
155                .iter()
156                .all(|l| l.note.as_ref().unwrap().kind == NoteKind::Mode));
157        }
158    }
159
160    #[test]
161    fn a_partial_update_changes_only_what_it_names() {
162        let mut r = RoomModes::new();
163        r.update("c", full(0, 10, true, true));
164        // only slow changed: the other three stay, no spurious "off" notes.
165        let got = r.update(
166            "c",
167            RoomState {
168                slow: Some(30),
169                ..NONE
170            },
171        );
172        assert_eq!(whats(&got), ["slow mode 30s"]);
173        assert!(
174            r.update("c", NONE).is_empty(),
175            "an empty update says nothing"
176        );
177        let got = r.update(
178            "c",
179            RoomState {
180                emote_only: Some(false),
181                ..NONE
182            },
183        );
184        assert_eq!(whats(&got), ["emote-only off"]);
185        // slow is still 30, followers still 10, subs-only still on
186        let got = r.update("c", full(30, 10, false, true));
187        assert!(got.is_empty(), "state was carried through: {got:?}");
188    }
189
190    #[test]
191    fn a_partial_first_state_fills_the_rest_with_off() {
192        let mut r = RoomModes::new();
193        assert!(r
194            .update(
195                "c",
196                RoomState {
197                    slow: Some(30),
198                    ..NONE
199                }
200            )
201            .is_empty());
202        assert_eq!(
203            whats(&r.update("c", full(30, -1, true, false))),
204            ["emote-only on"]
205        );
206    }
207
208    #[test]
209    fn an_unchanged_state_and_other_channels_say_nothing() {
210        let mut r = RoomModes::new();
211        let off = full(0, -1, false, false);
212        let slow9 = full(9, -1, false, false);
213        r.update("a", off);
214        assert!(r.update("a", off).is_empty());
215        assert!(r.update("b", slow9).is_empty(), "b's first");
216        assert!(r.update("B", slow9).is_empty(), "case-blind");
217        assert_eq!(r.update("a", slow9).len(), 1);
218    }
219
220    #[test]
221    fn the_frame_parses() {
222        let raw = r#"{"type":"irc:message","channel":"Chan","message":{"type":"roomstate",
223            "emoteOnly":true,"followersOnly":10,"slow":30,"subsOnly":false}}"#;
224        match parse(raw) {
225            Event::Room(c, s) => {
226                assert_eq!(c, "Chan");
227                assert_eq!(s, full(30, 10, true, false));
228            }
229            e => panic!("{e:?}"),
230        }
231        assert_eq!(
232            parse(r#"{"type":"irc:message","message":{"type":"roomstate"}}"#),
233            Event::Ignore
234        );
235        // a partial frame: absent tags stay None
236        let part =
237            r#"{"type":"irc:message","channel":"c","message":{"type":"roomstate","slow":30}}"#;
238        assert!(matches!(
239            parse(part),
240            Event::Room(
241                _,
242                RoomState {
243                    slow: Some(30),
244                    followers: None,
245                    emote_only: None,
246                    subs_only: None
247                }
248            )
249        ));
250    }
251}