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