1use std::collections::{HashMap, HashSet};
12
13use crate::proto::{ChatLine, StreamKind};
14use crate::Platform;
15
16pub const HOLD_MS: u64 = 2_000;
18pub const ECHO_MS: u64 = 30_000;
20pub const DEDUPE_MS: u64 = 60_000;
22pub const REPLAY_WINDOW_MS: u64 = 15 * 60 * 1000;
24
25type Key = (Platform, String);
26
27fn key(l: &ChatLine) -> Key {
28 (l.platform, l.channel.to_lowercase())
29}
30
31#[derive(Default)]
32pub struct StreamEvents {
33 held: HashMap<Key, (ChatLine, u64)>,
35 online: HashMap<Key, (String, u64)>,
37 seen: HashSet<Key>,
39 recent: HashMap<(Key, String), u64>,
41}
42
43impl StreamEvents {
44 pub fn new() -> Self {
45 Self::default()
46 }
47
48 pub fn seed_live(&mut self, live: impl IntoIterator<Item = (Platform, String)>) {
51 for (p, c) in live {
52 self.seen.insert((p, c.to_lowercase()));
53 }
54 }
55
56 pub fn push(&mut self, line: ChatLine, now_ms: u64) -> Vec<ChatLine> {
58 let Some(ev) = line.stream.clone() else {
59 return vec![line];
60 };
61 let k = key(&line);
62 match ev.kind {
63 StreamKind::Update => {
64 if !ev.prev_game.is_empty() {
65 return self.show(line, now_ms);
66 }
67 if let Some((g, at)) = self.online.get(&k) {
68 if *g == ev.game && now_ms.saturating_sub(*at) <= ECHO_MS {
69 return Vec::new();
70 }
71 }
72 self.held.insert(k, (line, now_ms + HOLD_MS));
74 Vec::new()
75 }
76 StreamKind::Online => {
77 if self
78 .held
79 .get(&k)
80 .is_some_and(|(h, _)| h.stream.as_ref().is_some_and(|s| s.game == ev.game))
81 {
82 self.held.remove(&k);
83 }
84 if !self.seen.insert(k.clone()) {
85 return Vec::new();
86 }
87 self.online.insert(k, (ev.game, now_ms));
88 self.show(line, now_ms)
89 }
90 StreamKind::Offline => {
91 self.held.remove(&k);
92 self.online.remove(&k);
93 self.seen.remove(&k);
94 self.show(line, now_ms)
95 }
96 }
97 }
98
99 pub fn replay_keeps(line: &ChatLine, age_ms: Option<u64>) -> bool {
103 match line.stream.as_ref().map(|s| s.kind) {
104 Some(StreamKind::Online) => false,
105 Some(_) => age_ms.is_none_or(|a| a <= REPLAY_WINDOW_MS),
106 None => true,
107 }
108 }
109
110 pub fn tick(&mut self, now_ms: u64) -> Vec<ChatLine> {
112 let due: Vec<Key> = self
113 .held
114 .iter()
115 .filter(|(_, (_, at))| *at <= now_ms)
116 .map(|(k, _)| k.clone())
117 .collect();
118 let mut out = Vec::new();
119 for k in due {
120 if let Some((line, _)) = self.held.remove(&k) {
121 out.extend(self.show(line, now_ms));
122 }
123 }
124 out
125 }
126
127 fn show(&mut self, line: ChatLine, now_ms: u64) -> Vec<ChatLine> {
129 let text = line
130 .note
131 .as_ref()
132 .map(|n| n.what.clone())
133 .unwrap_or_default();
134 let id = (key(&line), text);
135 if self
136 .recent
137 .get(&id)
138 .is_some_and(|at| now_ms.saturating_sub(*at) < DEDUPE_MS)
139 {
140 return Vec::new();
141 }
142 if self.recent.len() >= 256 {
143 self.recent
144 .retain(|_, at| now_ms.saturating_sub(*at) < DEDUPE_MS);
145 }
146 self.recent.insert(id, now_ms);
147 vec![line]
148 }
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::proto::{parse, Event};
155
156 fn line(json: &str) -> ChatLine {
157 match parse(json) {
158 Event::Chat(l) => l,
159 e => panic!("not a line: {e:?}"),
160 }
161 }
162 fn online(c: &str, g: &str) -> ChatLine {
163 line(&format!(
164 r#"{{"type":"stream:online","platform":"twitch","channel":"{c}","game":"{g}"}}"#
165 ))
166 }
167 fn update(c: &str, g: &str, prev: &str) -> ChatLine {
168 line(&format!(
169 r#"{{"type":"stream:update","platform":"twitch","channel":"{c}","game":"{g}","prevGame":"{prev}"}}"#
170 ))
171 }
172 fn offline(c: &str) -> ChatLine {
173 line(&format!(
174 r#"{{"type":"stream:offline","platform":"twitch","channel":"{c}"}}"#
175 ))
176 }
177 fn whats(v: &[ChatLine]) -> Vec<String> {
178 v.iter()
179 .map(|l| l.note.as_ref().unwrap().what.clone())
180 .collect()
181 }
182
183 #[test]
184 fn plain_chat_passes_through() {
185 let mut s = StreamEvents::new();
186 let l = line(
187 r#"{"type":"stream:redeem","platform":"twitch","channel":"c","user":"u","title":"t","cost":1}"#,
188 );
189 assert_eq!(s.push(l.clone(), 0), vec![l]);
190 }
191
192 #[test]
193 fn real_switch_is_shown_at_once() {
194 let mut s = StreamEvents::new();
195 assert_eq!(whats(&s.push(update("c", "B", "A"), 0)), ["switched to B"]);
196 }
197
198 #[test]
199 fn first_game_update_waits_then_releases() {
200 let mut s = StreamEvents::new();
201 assert!(s.push(update("c", "X", ""), 1_000).is_empty());
202 assert!(s.tick(2_999).is_empty());
203 assert_eq!(whats(&s.tick(3_000)), ["switched to X"]);
204 assert!(s.tick(9_000).is_empty(), "released once");
205 }
206
207 #[test]
208 fn went_live_for_same_game_swallows_held_update() {
209 let mut s = StreamEvents::new();
210 assert!(s.push(update("c", "X", ""), 0).is_empty());
211 assert_eq!(whats(&s.push(online("c", "X"), 500)), ["went live — X"]);
212 assert!(s.tick(5_000).is_empty());
213 }
214
215 #[test]
216 fn went_live_for_other_game_keeps_held_update() {
217 let mut s = StreamEvents::new();
218 s.push(update("c", "X", ""), 0);
219 assert_eq!(whats(&s.push(online("c", "Y"), 500)), ["went live — Y"]);
220 assert_eq!(whats(&s.tick(2_000)), ["switched to X"]);
221 }
222
223 #[test]
224 fn same_game_update_just_after_go_live_is_dropped() {
225 let mut s = StreamEvents::new();
226 s.push(online("c", "X"), 0);
227 assert!(s.push(update("c", "X", ""), 30_000).is_empty());
228 assert!(s.tick(40_000).is_empty());
229 assert!(s.push(update("c", "X", ""), 30_001).is_empty());
231 assert_eq!(whats(&s.tick(32_001)), ["switched to X"]);
232 }
233
234 #[test]
235 fn one_went_live_per_session_until_offline() {
236 let mut s = StreamEvents::new();
237 assert_eq!(s.push(online("c", "X"), 0).len(), 1);
238 assert!(s.push(online("c", "X"), 100_000).is_empty());
239 assert!(s.push(online("c", "Y"), 200_000).is_empty());
240 assert_eq!(whats(&s.push(offline("c"), 300_000)), ["went offline"]);
241 assert_eq!(s.push(online("c", "X"), 400_000).len(), 1);
242 }
243
244 #[test]
245 fn channels_are_independent_and_case_blind() {
246 let mut s = StreamEvents::new();
247 assert_eq!(s.push(online("Cyr", "X"), 0).len(), 1);
248 assert_eq!(s.push(online("other", "X"), 0).len(), 1);
249 assert!(s.push(online("cyr", "X"), 1).is_empty());
250 }
251
252 #[test]
253 fn identical_text_inside_a_minute_is_dropped() {
254 let mut s = StreamEvents::new();
255 assert_eq!(s.push(update("c", "B", "A"), 0).len(), 1);
256 assert!(s.push(update("c", "B", "A"), 59_999).is_empty());
257 assert_eq!(s.push(update("c", "B", "A"), 60_000).len(), 1);
258 assert_eq!(s.push(update("d", "B", "A"), 1).len(), 1);
260 }
261
262 #[test]
263 fn offline_drops_a_held_update_and_resets_echo() {
264 let mut s = StreamEvents::new();
265 s.push(online("c", "X"), 0);
266 s.push(offline("c"), 1_000);
267 s.push(update("c", "X", ""), 2_000);
268 s.push(offline("c"), 2_500);
269 assert!(s.tick(10_000).is_empty());
270 s.push(update("c", "X", ""), 11_000);
272 assert_eq!(s.tick(13_000).len(), 1);
273 }
274
275 #[test]
276 fn newer_held_update_replaces_older() {
277 let mut s = StreamEvents::new();
278 s.push(update("c", "X", ""), 0);
279 s.push(update("c", "Y", ""), 1_000);
280 assert_eq!(whats(&s.tick(3_000)), ["switched to Y"]);
281 }
282
283 #[test]
284 fn snapshot_seeds_already_live_so_rebroadcast_is_silent() {
285 let mut s = StreamEvents::new();
286 s.seed_live([(Platform::Twitch, "Cyr".to_string())]);
287 assert!(s.push(online("cyr", "X"), 0).is_empty());
288 s.push(offline("cyr"), 1);
290 assert_eq!(s.push(online("cyr", "X"), 2).len(), 1);
291 }
292
293 #[test]
294 fn follow_snapshot_event_feeds_seed() {
295 let mut s = StreamEvents::new();
296 let raw =
297 r#"{"type":"follow:live:snapshot","streams":[{"username":"a","platform":"twitch"}]}"#;
298 match parse(raw) {
299 Event::FollowLive(v) => s.seed_live(v),
300 _ => panic!(),
301 }
302 assert!(s.push(online("a", ""), 0).is_empty());
303 }
304
305 #[test]
306 fn replay_never_brings_back_went_live() {
307 let fresh = Some(1_000);
308 let old = Some(REPLAY_WINDOW_MS + 1);
309 let on = online("c", "X");
310 let up = update("c", "B", "A");
311 let off = offline("c");
312 let chat = line(
313 r#"{"type":"stream:redeem","platform":"twitch","channel":"c","user":"u","title":"t"}"#,
314 );
315 for (l, age, want) in [
316 (&on, fresh, false),
317 (&on, None, false),
318 (&up, fresh, true),
319 (&up, old, false),
320 (&off, fresh, true),
321 (&off, old, false),
322 (&up, Some(REPLAY_WINDOW_MS), true),
323 (&chat, old, true),
324 ] {
325 assert_eq!(
326 StreamEvents::replay_keeps(l, age),
327 want,
328 "{:?} {age:?}",
329 l.note
330 );
331 }
332 }
333}