1use std::collections::HashMap;
6
7use crate::proto::{fmt_secs, note_line, ChatLine, RoomState};
8use crate::{NoteKind, Platform};
9
10#[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 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
31const 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 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 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 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 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 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}