pylon_runtime/
presence.rs1use std::collections::HashMap;
2use std::sync::Mutex;
3use std::time::{Duration, Instant};
4
5#[derive(Debug, Clone)]
7pub struct PresenceEntry {
8 pub user_id: String,
9 pub room: String,
10 pub data: serde_json::Value,
11 pub last_seen: Instant,
12}
13
14pub struct PresenceTracker {
19 entries: Mutex<HashMap<String, PresenceEntry>>,
20 timeout: Duration,
21}
22
23impl PresenceTracker {
24 pub fn new(timeout_secs: u64) -> Self {
27 Self {
28 entries: Mutex::new(HashMap::new()),
29 timeout: Duration::from_secs(timeout_secs),
30 }
31 }
32
33 pub fn set(&self, room: &str, user_id: &str, data: serde_json::Value) {
35 let key = format!("{room}:{user_id}");
36 let entry = PresenceEntry {
37 user_id: user_id.to_string(),
38 room: room.to_string(),
39 data,
40 last_seen: Instant::now(),
41 };
42 self.entries
43 .lock()
44 .expect("presence lock poisoned")
45 .insert(key, entry);
46 }
47
48 pub fn remove(&self, room: &str, user_id: &str) {
50 let key = format!("{room}:{user_id}");
51 self.entries
52 .lock()
53 .expect("presence lock poisoned")
54 .remove(&key);
55 }
56
57 pub fn get_room(&self, room: &str) -> Vec<PresenceEntry> {
59 let now = Instant::now();
60 let entries = self.entries.lock().expect("presence lock poisoned");
61 entries
62 .values()
63 .filter(|e| e.room == room && now.duration_since(e.last_seen) < self.timeout)
64 .cloned()
65 .collect()
66 }
67
68 pub fn cleanup(&self) {
70 let now = Instant::now();
71 let timeout = self.timeout;
72 self.entries
73 .lock()
74 .expect("presence lock poisoned")
75 .retain(|_, e| now.duration_since(e.last_seen) < timeout);
76 }
77
78 pub fn is_present(&self, room: &str, user_id: &str) -> bool {
80 let key = format!("{room}:{user_id}");
81 let now = Instant::now();
82 let entries = self.entries.lock().expect("presence lock poisoned");
83 entries
84 .get(&key)
85 .map(|e| now.duration_since(e.last_seen) < self.timeout)
86 .unwrap_or(false)
87 }
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 #[test]
95 fn set_and_get_room() {
96 let tracker = PresenceTracker::new(60);
97 tracker.set("lobby", "alice", serde_json::json!({"status": "online"}));
98 tracker.set("lobby", "bob", serde_json::json!({"status": "away"}));
99
100 let members = tracker.get_room("lobby");
101 assert_eq!(members.len(), 2);
102
103 let user_ids: Vec<&str> = members.iter().map(|e| e.user_id.as_str()).collect();
104 assert!(user_ids.contains(&"alice"));
105 assert!(user_ids.contains(&"bob"));
106 }
107
108 #[test]
109 fn get_room_excludes_other_rooms() {
110 let tracker = PresenceTracker::new(60);
111 tracker.set("lobby", "alice", serde_json::json!({}));
112 tracker.set("kitchen", "bob", serde_json::json!({}));
113
114 let lobby = tracker.get_room("lobby");
115 assert_eq!(lobby.len(), 1);
116 assert_eq!(lobby[0].user_id, "alice");
117
118 let kitchen = tracker.get_room("kitchen");
119 assert_eq!(kitchen.len(), 1);
120 assert_eq!(kitchen[0].user_id, "bob");
121 }
122
123 #[test]
124 fn upsert_refreshes_data() {
125 let tracker = PresenceTracker::new(60);
126 tracker.set("lobby", "alice", serde_json::json!({"status": "online"}));
127 tracker.set("lobby", "alice", serde_json::json!({"status": "away"}));
128
129 let members = tracker.get_room("lobby");
130 assert_eq!(members.len(), 1);
131 assert_eq!(members[0].data, serde_json::json!({"status": "away"}));
132 }
133
134 #[test]
135 fn remove_explicit() {
136 let tracker = PresenceTracker::new(60);
137 tracker.set("lobby", "alice", serde_json::json!({}));
138 assert!(tracker.is_present("lobby", "alice"));
139
140 tracker.remove("lobby", "alice");
141 assert!(!tracker.is_present("lobby", "alice"));
142 assert!(tracker.get_room("lobby").is_empty());
143 }
144
145 #[test]
146 fn is_present_returns_false_for_unknown() {
147 let tracker = PresenceTracker::new(60);
148 assert!(!tracker.is_present("lobby", "nobody"));
149 }
150
151 #[test]
152 fn timeout_expires_entries() {
153 let tracker = PresenceTracker::new(0);
155 tracker.set("lobby", "alice", serde_json::json!({}));
156
157 assert!(!tracker.is_present("lobby", "alice"));
160 assert!(tracker.get_room("lobby").is_empty());
161 }
162
163 #[test]
164 fn cleanup_removes_stale_entries() {
165 let tracker = PresenceTracker::new(0);
166 tracker.set("lobby", "alice", serde_json::json!({}));
167 tracker.set("lobby", "bob", serde_json::json!({}));
168
169 tracker.cleanup();
170
171 let entries = tracker.entries.lock().unwrap();
173 assert!(entries.is_empty());
174 }
175
176 #[test]
177 fn multiple_rooms_same_user() {
178 let tracker = PresenceTracker::new(60);
179 tracker.set("lobby", "alice", serde_json::json!({"room": "lobby"}));
180 tracker.set("kitchen", "alice", serde_json::json!({"room": "kitchen"}));
181
182 assert!(tracker.is_present("lobby", "alice"));
183 assert!(tracker.is_present("kitchen", "alice"));
184
185 tracker.remove("lobby", "alice");
186 assert!(!tracker.is_present("lobby", "alice"));
187 assert!(tracker.is_present("kitchen", "alice"));
188 }
189}