1use crate::{Result, SockudoError, Token, WebhookError};
2use serde::{Deserialize, Deserializer, Serialize, de::Error as DeError};
3use sonic_rs::{JsonValueTrait, Value};
4use std::collections::{BTreeMap, HashMap};
5
6#[derive(Debug)]
8pub struct Webhook {
9 token: Token,
10 key: Option<String>,
11 signature: Option<String>,
12 content_type: Option<String>,
13 body: String,
14 data: Option<WebhookData>,
15 raw_json_events: Option<Vec<HashMap<String, Value>>>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct WebhookData {
21 pub time_ms: i64,
23 #[serde(default, deserialize_with = "deserialize_webhook_events")]
25 pub events: Vec<HashMap<String, String>>,
26}
27
28#[derive(Debug, Deserialize)]
29struct RawWebhookData {
30 #[serde(default)]
31 events: Vec<HashMap<String, Value>>,
32}
33
34fn deserialize_webhook_events<'de, D>(
35 deserializer: D,
36) -> std::result::Result<Vec<HashMap<String, String>>, D::Error>
37where
38 D: Deserializer<'de>,
39{
40 let raw_events = Vec::<HashMap<String, Value>>::deserialize(deserializer)?;
41 project_webhook_events(&raw_events)
42}
43
44fn project_webhook_events<E>(
45 raw_events: &[HashMap<String, Value>],
46) -> std::result::Result<Vec<HashMap<String, String>>, E>
47where
48 E: DeError,
49{
50 raw_events
51 .iter()
52 .map(|event| {
53 event
54 .iter()
55 .map(|(key, value)| {
56 stringify_webhook_value(value).map(|value| (key.clone(), value))
57 })
58 .collect::<std::result::Result<HashMap<_, _>, E>>()
59 })
60 .collect()
61}
62
63fn stringify_webhook_value<E>(value: &Value) -> std::result::Result<String, E>
64where
65 E: DeError,
66{
67 value
68 .as_str()
69 .map(ToOwned::to_owned)
70 .map(Ok)
71 .unwrap_or_else(|| sonic_rs::to_string(&value).map_err(E::custom))
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub enum WebhookEvent {
77 ChannelOccupied {
78 channel: String,
79 },
80 ChannelVacated {
81 channel: String,
82 },
83 MemberAdded {
84 channel: String,
85 user_id: String,
86 },
87 MemberRemoved {
88 channel: String,
89 user_id: String,
90 },
91 ClientEvent {
92 channel: String,
93 event: String,
94 data: String,
95 socket_id: String,
96 user_id: Option<String>,
97 },
98 CacheMiss {
99 channel: String,
100 event: String,
101 },
102 Unknown(HashMap<String, String>),
103}
104
105impl Webhook {
106 pub fn new(token: &Token, headers: &BTreeMap<String, String>, body: &str) -> Self {
108 let normalized_headers: BTreeMap<String, String> = headers
110 .iter()
111 .map(|(k, v)| (k.to_lowercase(), v.clone()))
112 .collect();
113
114 let key = normalized_headers.get("x-pusher-key").cloned();
115 let signature = normalized_headers.get("x-pusher-signature").cloned();
116 let content_type = normalized_headers.get("content-type").cloned();
117
118 let (data, raw_json_events) = if Self::validate_content_type(&content_type) {
119 (
120 sonic_rs::from_str::<WebhookData>(body).ok(),
121 sonic_rs::from_str::<RawWebhookData>(body)
122 .ok()
123 .map(|raw| raw.events),
124 )
125 } else {
126 (None, None)
127 };
128
129 Self {
130 token: token.clone(),
131 key,
132 signature,
133 content_type,
134 body: body.to_string(),
135 data,
136 raw_json_events,
137 }
138 }
139
140 pub fn is_valid(&self, extra_tokens: Option<&[Token]>) -> bool {
142 if !self.is_body_valid() {
143 return false;
144 }
145
146 let tokens_to_check = if let Some(extra) = extra_tokens {
147 let mut tokens = vec![&self.token];
148 tokens.extend(extra.iter());
149 tokens
150 } else {
151 vec![&self.token]
152 };
153
154 if let (Some(key), Some(signature)) = (&self.key, &self.signature) {
155 for token in tokens_to_check {
156 if key == &token.key && token.verify(&self.body, signature) {
157 return true;
158 }
159 }
160 }
161
162 false
163 }
164
165 pub fn is_content_type_valid(&self) -> bool {
167 Self::validate_content_type(&self.content_type)
168 }
169
170 fn validate_content_type(content_type: &Option<String>) -> bool {
172 match content_type {
173 Some(ct) => ct.starts_with("application/json"),
174 None => false,
175 }
176 }
177
178 pub fn is_body_valid(&self) -> bool {
180 self.data.is_some()
181 }
182
183 pub fn get_data(&self) -> Result<&WebhookData> {
185 self.data.as_ref().ok_or_else(|| {
186 SockudoError::Webhook(WebhookError::new(
187 "Invalid webhook body",
188 self.content_type.clone(),
189 &self.body,
190 self.signature.clone(),
191 ))
192 })
193 }
194
195 pub fn get_raw_events(&self) -> Result<&Vec<HashMap<String, String>>> {
197 Ok(&self.get_data()?.events)
198 }
199
200 pub fn get_raw_json_events(&self) -> Result<&Vec<HashMap<String, Value>>> {
202 self.raw_json_events.as_ref().ok_or_else(|| {
203 SockudoError::Webhook(WebhookError::new(
204 "Invalid webhook body",
205 self.content_type.clone(),
206 &self.body,
207 self.signature.clone(),
208 ))
209 })
210 }
211
212 pub fn get_events(&self) -> Result<Vec<WebhookEvent>> {
214 let raw_events = self.get_raw_events()?;
215 Ok(raw_events.iter().map(parse_webhook_event).collect())
216 }
217
218 pub fn get_time(&self) -> Result<std::time::SystemTime> {
220 let time_ms = self.get_data()?.time_ms;
221 if time_ms < 0 {
222 return Err(SockudoError::Webhook(WebhookError::new(
223 "Invalid negative timestamp",
224 self.content_type.clone(),
225 &self.body,
226 self.signature.clone(),
227 )));
228 }
229 let duration = std::time::Duration::from_millis(time_ms as u64);
230 Ok(std::time::UNIX_EPOCH + duration)
231 }
232
233 pub fn body(&self) -> &str {
235 &self.body
236 }
237
238 pub fn signature(&self) -> Option<&str> {
240 self.signature.as_deref()
241 }
242
243 pub fn key(&self) -> Option<&str> {
245 self.key.as_deref()
246 }
247
248 pub fn find_events_by_type(&self, event_type: &str) -> Result<Vec<WebhookEvent>> {
250 let events = self.get_events()?;
251 Ok(events
252 .into_iter()
253 .filter(|e| e.event_name() == event_type)
254 .collect())
255 }
256
257 pub fn find_events_by_channel(&self, channel: &str) -> Result<Vec<WebhookEvent>> {
259 let events = self.get_events()?;
260 Ok(events
261 .into_iter()
262 .filter(|e| e.channel() == Some(channel))
263 .collect())
264 }
265}
266
267fn parse_webhook_event(raw: &HashMap<String, String>) -> WebhookEvent {
269 match raw.get("name").map(|s| s.as_str()) {
270 Some("channel_occupied") => {
271 if let Some(channel) = raw.get("channel") {
272 WebhookEvent::ChannelOccupied {
273 channel: channel.clone(),
274 }
275 } else {
276 WebhookEvent::Unknown(raw.clone())
277 }
278 }
279 Some("channel_vacated") => {
280 if let Some(channel) = raw.get("channel") {
281 WebhookEvent::ChannelVacated {
282 channel: channel.clone(),
283 }
284 } else {
285 WebhookEvent::Unknown(raw.clone())
286 }
287 }
288 Some("member_added") => {
289 if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
290 WebhookEvent::MemberAdded {
291 channel: channel.clone(),
292 user_id: user_id.clone(),
293 }
294 } else {
295 WebhookEvent::Unknown(raw.clone())
296 }
297 }
298 Some("member_removed") => {
299 if let (Some(channel), Some(user_id)) = (raw.get("channel"), raw.get("user_id")) {
300 WebhookEvent::MemberRemoved {
301 channel: channel.clone(),
302 user_id: user_id.clone(),
303 }
304 } else {
305 WebhookEvent::Unknown(raw.clone())
306 }
307 }
308 Some("client_event") => {
309 if let (Some(channel), Some(event), Some(data), Some(socket_id)) = (
310 raw.get("channel"),
311 raw.get("event"),
312 raw.get("data"),
313 raw.get("socket_id"),
314 ) {
315 WebhookEvent::ClientEvent {
316 channel: channel.clone(),
317 event: event.clone(),
318 data: data.clone(),
319 socket_id: socket_id.clone(),
320 user_id: raw.get("user_id").cloned(),
321 }
322 } else {
323 WebhookEvent::Unknown(raw.clone())
324 }
325 }
326 Some("cache_miss") => {
327 if let (Some(channel), Some(event)) = (raw.get("channel"), raw.get("event")) {
328 WebhookEvent::CacheMiss {
329 channel: channel.clone(),
330 event: event.clone(),
331 }
332 } else {
333 WebhookEvent::Unknown(raw.clone())
334 }
335 }
336 _ => WebhookEvent::Unknown(raw.clone()),
337 }
338}
339
340impl WebhookEvent {
341 pub fn event_name(&self) -> &str {
343 match self {
344 WebhookEvent::ChannelOccupied { .. } => "channel_occupied",
345 WebhookEvent::ChannelVacated { .. } => "channel_vacated",
346 WebhookEvent::MemberAdded { .. } => "member_added",
347 WebhookEvent::MemberRemoved { .. } => "member_removed",
348 WebhookEvent::ClientEvent { .. } => "client_event",
349 WebhookEvent::CacheMiss { .. } => "cache_miss",
350 WebhookEvent::Unknown(map) => map.get("name").map(|s| s.as_str()).unwrap_or("unknown"),
351 }
352 }
353
354 pub fn channel(&self) -> Option<&str> {
356 match self {
357 WebhookEvent::ChannelOccupied { channel }
358 | WebhookEvent::ChannelVacated { channel }
359 | WebhookEvent::MemberAdded { channel, .. }
360 | WebhookEvent::MemberRemoved { channel, .. }
361 | WebhookEvent::ClientEvent { channel, .. }
362 | WebhookEvent::CacheMiss { channel, .. } => Some(channel),
363 WebhookEvent::Unknown(map) => map.get("channel").map(|s| s.as_str()),
364 }
365 }
366
367 pub fn user_id(&self) -> Option<&str> {
369 match self {
370 WebhookEvent::MemberAdded { user_id, .. }
371 | WebhookEvent::MemberRemoved { user_id, .. } => Some(user_id),
372 WebhookEvent::ClientEvent { user_id, .. } => user_id.as_deref(),
373 WebhookEvent::Unknown(map) => map.get("user_id").map(|s| s.as_str()),
374 _ => None,
375 }
376 }
377
378 pub fn to_hashmap(&self) -> HashMap<String, String> {
380 let mut map = HashMap::new();
381
382 match self {
383 WebhookEvent::ChannelOccupied { channel } => {
384 map.insert("name".to_string(), "channel_occupied".to_string());
385 map.insert("channel".to_string(), channel.clone());
386 }
387 WebhookEvent::ChannelVacated { channel } => {
388 map.insert("name".to_string(), "channel_vacated".to_string());
389 map.insert("channel".to_string(), channel.clone());
390 }
391 WebhookEvent::MemberAdded { channel, user_id } => {
392 map.insert("name".to_string(), "member_added".to_string());
393 map.insert("channel".to_string(), channel.clone());
394 map.insert("user_id".to_string(), user_id.clone());
395 }
396 WebhookEvent::MemberRemoved { channel, user_id } => {
397 map.insert("name".to_string(), "member_removed".to_string());
398 map.insert("channel".to_string(), channel.clone());
399 map.insert("user_id".to_string(), user_id.clone());
400 }
401 WebhookEvent::ClientEvent {
402 channel,
403 event,
404 data,
405 socket_id,
406 user_id,
407 } => {
408 map.insert("name".to_string(), "client_event".to_string());
409 map.insert("channel".to_string(), channel.clone());
410 map.insert("event".to_string(), event.clone());
411 map.insert("data".to_string(), data.clone());
412 map.insert("socket_id".to_string(), socket_id.clone());
413 if let Some(uid) = user_id {
414 map.insert("user_id".to_string(), uid.clone());
415 }
416 }
417 WebhookEvent::CacheMiss { channel, event } => {
418 map.insert("name".to_string(), "cache_miss".to_string());
419 map.insert("channel".to_string(), channel.clone());
420 map.insert("event".to_string(), event.clone());
421 }
422 WebhookEvent::Unknown(original) => {
423 return original.clone();
424 }
425 }
426
427 map
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use sonic_rs::JsonContainerTrait;
435
436 #[test]
437 fn test_webhook_data_parsing() {
438 let json_str = r#"{
439 "time_ms": 1234567890,
440 "events": [
441 {"name": "channel_occupied", "channel": "test-channel"},
442 {"name": "member_added", "channel": "presence-channel", "user_id": "user123"}
443 ]
444 }"#;
445
446 let data: WebhookData = sonic_rs::from_str(json_str).unwrap();
447 assert_eq!(data.time_ms, 1234567890);
448 assert_eq!(data.events.len(), 2);
449 assert_eq!(
450 data.events[0].get("name"),
451 Some(&"channel_occupied".to_string())
452 );
453 }
454
455 #[test]
456 fn test_webhook_data_accepts_nested_future_values() {
457 let json_str = r#"{
458 "time_ms": 1234567890,
459 "events": [
460 {"name": "ai_turn_started", "channel": "private-ai", "data": {"turn_id": "turn-1", "tokens": ["hello", "world"], "done": false, "nullable": null}}
461 ]
462 }"#;
463
464 let data: WebhookData = sonic_rs::from_str(json_str).unwrap();
465
466 assert_eq!(
467 data.events[0].get("name"),
468 Some(&"ai_turn_started".to_string())
469 );
470 assert_eq!(
471 data.events[0].get("data"),
472 Some(
473 &r#"{"turn_id":"turn-1","tokens":["hello","world"],"done":false,"nullable":null}"#
474 .to_string()
475 )
476 );
477 }
478
479 #[test]
480 fn test_webhook_preserves_raw_nested_future_values() {
481 let token = Token::new("test_key", "test_secret");
482 let body = r#"{"time_ms":1710000000000,"events":[{"name":"ai_turn_started","channel":"private-ai-forward","data":{"turn_id":"turn-1","tokens":["hello","world"],"done":false,"nullable":null},"future_field":{"nested":true}}]}"#;
483 let signature = token.sign(body);
484
485 let mut headers = BTreeMap::new();
486 headers.insert("content-type".to_string(), "application/json".to_string());
487 headers.insert("x-pusher-key".to_string(), "test_key".to_string());
488 headers.insert("x-pusher-signature".to_string(), signature);
489
490 let webhook = Webhook::new(&token, &headers, body);
491 let raw_events = webhook.get_raw_json_events().unwrap();
492 let raw_data = raw_events[0].get("data").unwrap();
493 let future_field = raw_events[0].get("future_field").unwrap();
494
495 assert!(webhook.is_valid(None));
496 assert_eq!(
497 raw_data.get("turn_id").and_then(|value| value.as_str()),
498 Some("turn-1")
499 );
500 assert_eq!(
501 raw_data
502 .get("tokens")
503 .and_then(|value| value.as_array())
504 .map(|items| items.len()),
505 Some(2)
506 );
507 assert_eq!(
508 raw_data.get("done").and_then(|value| value.as_bool()),
509 Some(false)
510 );
511 assert_eq!(
512 sonic_rs::to_string(raw_data.get("nullable").unwrap()).unwrap(),
513 "null"
514 );
515 assert_eq!(
516 future_field.get("nested").and_then(|value| value.as_bool()),
517 Some(true)
518 );
519 }
520
521 #[test]
522 fn test_webhook_forward_compat_fixture() {
523 let fixture = include_str!(
524 "../../../tests/ai-conformance/fixtures/forward-compat/future-webhook-events.json"
525 );
526
527 let data: WebhookData = sonic_rs::from_str(fixture).unwrap();
528
529 assert_eq!(data.time_ms, 1710000000000);
530 assert_eq!(data.events.len(), 3);
531 assert_eq!(
532 data.events[0].get("name"),
533 Some(&"member_updated".to_string())
534 );
535 assert_eq!(
536 data.events[0].get("future_field"),
537 Some(&"must-pass-through".to_string())
538 );
539 assert_eq!(
540 data.events[1].get("name"),
541 Some(&"ai_run_started".to_string())
542 );
543 assert_eq!(data.events[1].get("run_id"), Some(&"run-1".to_string()));
544 assert_eq!(
545 data.events[2].get("version_serial"),
546 Some(&"ver-1".to_string())
547 );
548 }
549
550 #[test]
551 fn test_webhook_validation() {
552 let token = Token::new("test_key", "test_secret");
553 let body = r#"{"time_ms": 1234567890, "events": []}"#;
554 let signature = token.sign(body);
555
556 let mut headers = BTreeMap::new();
557 headers.insert("content-type".to_string(), "application/json".to_string());
558 headers.insert("x-pusher-key".to_string(), "test_key".to_string());
559 headers.insert("x-pusher-signature".to_string(), signature);
560
561 let webhook = Webhook::new(&token, &headers, body);
562 assert!(webhook.is_valid(None));
563 }
564
565 #[test]
566 fn test_event_parsing() {
567 let mut event_map = HashMap::new();
568 event_map.insert("name".to_string(), "channel_occupied".to_string());
569 event_map.insert("channel".to_string(), "test-channel".to_string());
570
571 let event = parse_webhook_event(&event_map);
572 assert!(matches!(event, WebhookEvent::ChannelOccupied { .. }));
573 assert_eq!(event.channel(), Some("test-channel"));
574 }
575
576 #[test]
577 fn test_event_round_trip() {
578 let event = WebhookEvent::MemberAdded {
579 channel: "presence-test".to_string(),
580 user_id: "user123".to_string(),
581 };
582
583 let map = event.to_hashmap();
584 let parsed = parse_webhook_event(&map);
585
586 assert_eq!(event, parsed);
587 }
588}