1use crate::{Result, SockudoError, Token, WebhookError};
2use serde::{Deserialize, Serialize};
3use std::collections::{BTreeMap, HashMap};
4
5#[derive(Debug)]
7pub struct Webhook {
8 token: Token,
9 key: Option<String>,
10 signature: Option<String>,
11 content_type: Option<String>,
12 body: String,
13 data: Option<WebhookData>,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WebhookData {
19 pub time_ms: i64,
21 pub events: Vec<HashMap<String, String>>,
23}
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum WebhookEvent {
28 ChannelOccupied {
29 channel: String,
30 },
31 ChannelVacated {
32 channel: String,
33 },
34 MemberAdded {
35 channel: String,
36 user_id: String,
37 },
38 MemberRemoved {
39 channel: String,
40 user_id: String,
41 },
42 ClientEvent {
43 channel: String,
44 event: String,
45 data: String,
46 socket_id: String,
47 user_id: Option<String>,
48 },
49 CacheMiss {
50 channel: String,
51 event: String,
52 },
53 Unknown(HashMap<String, String>),
54}
55
56impl Webhook {
57 pub fn new(token: &Token, headers: &BTreeMap<String, String>, body: &str) -> Self {
59 let normalized_headers: BTreeMap<String, String> = headers
61 .iter()
62 .map(|(k, v)| (k.to_lowercase(), v.clone()))
63 .collect();
64
65 let key = normalized_headers.get("x-sockudo-key").cloned();
66 let signature = normalized_headers.get("x-sockudo-signature").cloned();
67 let content_type = normalized_headers.get("content-type").cloned();
68
69 let data = if Self::validate_content_type(&content_type) {
70 sonic_rs::from_str::<WebhookData>(body).ok()
71 } else {
72 None
73 };
74
75 Self {
76 token: token.clone(),
77 key,
78 signature,
79 content_type,
80 body: body.to_string(),
81 data,
82 }
83 }
84
85 pub fn is_valid(&self, extra_tokens: Option<&[Token]>) -> bool {
87 if !self.is_body_valid() {
88 return false;
89 }
90
91 let tokens_to_check = if let Some(extra) = extra_tokens {
92 let mut tokens = vec![&self.token];
93 tokens.extend(extra.iter());
94 tokens
95 } else {
96 vec![&self.token]
97 };
98
99 if let (Some(key), Some(signature)) = (&self.key, &self.signature) {
100 for token in tokens_to_check {
101 if key == &token.key && token.verify(&self.body, signature) {
102 return true;
103 }
104 }
105 }
106
107 false
108 }
109
110 pub fn is_content_type_valid(&self) -> bool {
112 Self::validate_content_type(&self.content_type)
113 }
114
115 fn validate_content_type(content_type: &Option<String>) -> bool {
117 match content_type {
118 Some(ct) => ct.starts_with("application/json"),
119 None => false,
120 }
121 }
122
123 pub fn is_body_valid(&self) -> bool {
125 self.data.is_some()
126 }
127
128 pub fn get_data(&self) -> Result<&WebhookData> {
130 self.data.as_ref().ok_or_else(|| {
131 SockudoError::Webhook(WebhookError::new(
132 "Invalid webhook body",
133 self.content_type.clone(),
134 &self.body,
135 self.signature.clone(),
136 ))
137 })
138 }
139
140 pub fn get_raw_events(&self) -> Result<&Vec<HashMap<String, String>>> {
142 Ok(&self.get_data()?.events)
143 }
144
145 pub fn get_events(&self) -> Result<Vec<WebhookEvent>> {
147 let raw_events = self.get_raw_events()?;
148 Ok(raw_events.iter().map(parse_webhook_event).collect())
149 }
150
151 pub fn get_time(&self) -> Result<std::time::SystemTime> {
153 let time_ms = self.get_data()?.time_ms;
154 if time_ms < 0 {
155 return Err(SockudoError::Webhook(WebhookError::new(
156 "Invalid negative timestamp",
157 self.content_type.clone(),
158 &self.body,
159 self.signature.clone(),
160 )));
161 }
162 let duration = std::time::Duration::from_millis(time_ms as u64);
163 Ok(std::time::UNIX_EPOCH + duration)
164 }
165
166 pub fn body(&self) -> &str {
168 &self.body
169 }
170
171 pub fn signature(&self) -> Option<&str> {
173 self.signature.as_deref()
174 }
175
176 pub fn key(&self) -> Option<&str> {
178 self.key.as_deref()
179 }
180
181 pub fn find_events_by_type(&self, event_type: &str) -> Result<Vec<WebhookEvent>> {
183 let events = self.get_events()?;
184 Ok(events
185 .into_iter()
186 .filter(|e| e.event_name() == event_type)
187 .collect())
188 }
189
190 pub fn find_events_by_channel(&self, channel: &str) -> Result<Vec<WebhookEvent>> {
192 let events = self.get_events()?;
193 Ok(events
194 .into_iter()
195 .filter(|e| e.channel() == Some(channel))
196 .collect())
197 }
198}
199
200fn parse_webhook_event(raw: &HashMap<String, String>) -> WebhookEvent {
202 match raw.get("name").map(|s| s.as_str()) {
203 Some("channel_occupied") => {
204 if let Some(channel) = raw.get("channel") {
205 WebhookEvent::ChannelOccupied {
206 channel: channel.clone(),
207 }
208 } else {
209 WebhookEvent::Unknown(raw.clone())
210 }
211 }
212 Some("channel_vacated") => {
213 if let Some(channel) = raw.get("channel") {
214 WebhookEvent::ChannelVacated {
215 channel: channel.clone(),
216 }
217 } else {
218 WebhookEvent::Unknown(raw.clone())
219 }
220 }
221 Some("member_added") => {
222 if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
223 WebhookEvent::MemberAdded {
224 channel: channel.clone(),
225 user_id: user_id.clone(),
226 }
227 } else {
228 WebhookEvent::Unknown(raw.clone())
229 }
230 }
231 Some("member_removed") => {
232 if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
233 WebhookEvent::MemberRemoved {
234 channel: channel.clone(),
235 user_id: user_id.clone(),
236 }
237 } else {
238 WebhookEvent::Unknown(raw.clone())
239 }
240 }
241 Some("client_event") => {
242 if let (Some(channel), Some(event), Some(data), Some(socket_id)) = (
243 raw.get("channel"),
244 raw.get("event"),
245 raw.get("data"),
246 raw.get("socket_id"),
247 ) {
248 WebhookEvent::ClientEvent {
249 channel: channel.clone(),
250 event: event.clone(),
251 data: data.clone(),
252 socket_id: socket_id.clone(),
253 user_id: raw.get("user_id").cloned(),
254 }
255 } else {
256 WebhookEvent::Unknown(raw.clone())
257 }
258 }
259 Some("cache_miss") => {
260 if let (Some(channel), Some(event)) = (raw.get("channel"), raw.get("event")) {
261 WebhookEvent::CacheMiss {
262 channel: channel.clone(),
263 event: event.clone(),
264 }
265 } else {
266 WebhookEvent::Unknown(raw.clone())
267 }
268 }
269 _ => WebhookEvent::Unknown(raw.clone()),
270 }
271}
272
273impl WebhookEvent {
274 pub fn event_name(&self) -> &str {
276 match self {
277 WebhookEvent::ChannelOccupied { .. } => "channel_occupied",
278 WebhookEvent::ChannelVacated { .. } => "channel_vacated",
279 WebhookEvent::MemberAdded { .. } => "member_added",
280 WebhookEvent::MemberRemoved { .. } => "member_removed",
281 WebhookEvent::ClientEvent { .. } => "client_event",
282 WebhookEvent::CacheMiss { .. } => "cache_miss",
283 WebhookEvent::Unknown(map) => map.get("name").map(|s| s.as_str()).unwrap_or("unknown"),
284 }
285 }
286
287 pub fn channel(&self) -> Option<&str> {
289 match self {
290 WebhookEvent::ChannelOccupied { channel }
291 | WebhookEvent::ChannelVacated { channel }
292 | WebhookEvent::MemberAdded { channel, .. }
293 | WebhookEvent::MemberRemoved { channel, .. }
294 | WebhookEvent::ClientEvent { channel, .. }
295 | WebhookEvent::CacheMiss { channel, .. } => Some(channel),
296 WebhookEvent::Unknown(map) => map.get("channel").map(|s| s.as_str()),
297 }
298 }
299
300 pub fn user_id(&self) -> Option<&str> {
302 match self {
303 WebhookEvent::MemberAdded { user_id, .. }
304 | WebhookEvent::MemberRemoved { user_id, .. } => Some(user_id),
305 WebhookEvent::ClientEvent { user_id, .. } => user_id.as_deref(),
306 WebhookEvent::Unknown(map) => map.get("user_id").map(|s| s.as_str()),
307 _ => None,
308 }
309 }
310
311 pub fn to_hashmap(&self) -> HashMap<String, String> {
313 let mut map = HashMap::new();
314
315 match self {
316 WebhookEvent::ChannelOccupied { channel } => {
317 map.insert("name".to_string(), "channel_occupied".to_string());
318 map.insert("channel".to_string(), channel.clone());
319 }
320 WebhookEvent::ChannelVacated { channel } => {
321 map.insert("name".to_string(), "channel_vacated".to_string());
322 map.insert("channel".to_string(), channel.clone());
323 }
324 WebhookEvent::MemberAdded { channel, user_id } => {
325 map.insert("name".to_string(), "member_added".to_string());
326 map.insert("channel".to_string(), channel.clone());
327 map.insert("user_id".to_string(), user_id.clone());
328 }
329 WebhookEvent::MemberRemoved { channel, user_id } => {
330 map.insert("name".to_string(), "member_removed".to_string());
331 map.insert("channel".to_string(), channel.clone());
332 map.insert("user_id".to_string(), user_id.clone());
333 }
334 WebhookEvent::ClientEvent {
335 channel,
336 event,
337 data,
338 socket_id,
339 user_id,
340 } => {
341 map.insert("name".to_string(), "client_event".to_string());
342 map.insert("channel".to_string(), channel.clone());
343 map.insert("event".to_string(), event.clone());
344 map.insert("data".to_string(), data.clone());
345 map.insert("socket_id".to_string(), socket_id.clone());
346 if let Some(uid) = user_id {
347 map.insert("user_id".to_string(), uid.clone());
348 }
349 }
350 WebhookEvent::CacheMiss { channel, event } => {
351 map.insert("name".to_string(), "cache_miss".to_string());
352 map.insert("channel".to_string(), channel.clone());
353 map.insert("event".to_string(), event.clone());
354 }
355 WebhookEvent::Unknown(original) => {
356 return original.clone();
357 }
358 }
359
360 map
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 #[test]
368 fn test_webhook_data_parsing() {
369 let json_str = r#"{
370 "time_ms": 1234567890,
371 "events": [
372 {"name": "channel_occupied", "channel": "test-channel"},
373 {"name": "member_added", "channel": "presence-channel", "user_id": "user123"}
374 ]
375 }"#;
376
377 let data: WebhookData = sonic_rs::from_str(json_str).unwrap();
378 assert_eq!(data.time_ms, 1234567890);
379 assert_eq!(data.events.len(), 2);
380 assert_eq!(
381 data.events[0].get("name"),
382 Some(&"channel_occupied".to_string())
383 );
384 }
385
386 #[test]
387 fn test_webhook_validation() {
388 let token = Token::new("test_key", "test_secret");
389 let body = r#"{"time_ms": 1234567890, "events": []}"#;
390 let signature = token.sign(body);
391
392 let mut headers = BTreeMap::new();
393 headers.insert("content-type".to_string(), "application/json".to_string());
394 headers.insert("x-sockudo-key".to_string(), "test_key".to_string());
395 headers.insert("x-sockudo-signature".to_string(), signature);
396
397 let webhook = Webhook::new(&token, &headers, body);
398 assert!(webhook.is_valid(None));
399 }
400
401 #[test]
402 fn test_event_parsing() {
403 let mut event_map = HashMap::new();
404 event_map.insert("name".to_string(), "channel_occupied".to_string());
405 event_map.insert("channel".to_string(), "test-channel".to_string());
406
407 let event = parse_webhook_event(&event_map);
408 assert!(matches!(event, WebhookEvent::ChannelOccupied { .. }));
409 assert_eq!(event.channel(), Some("test-channel"));
410 }
411
412 #[test]
413 fn test_event_round_trip() {
414 let event = WebhookEvent::MemberAdded {
415 channel: "presence-test".to_string(),
416 user_id: "user123".to_string(),
417 };
418
419 let map = event.to_hashmap();
420 let parsed = parse_webhook_event(&map);
421
422 assert_eq!(event, parsed);
423 }
424}