Skip to main content

muxi_rust/
webhook.rs

1use hmac::{Hmac, Mac};
2use sha2::Sha256;
3use serde::{Deserialize, Serialize};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6type HmacSha256 = Hmac<Sha256>;
7
8pub struct Webhook;
9
10impl Webhook {
11    pub fn verify_signature(payload: &str, header: Option<&str>, secret: &str) -> Result<bool, &'static str> {
12        Self::verify_signature_with_tolerance(payload, header, secret, 300)
13    }
14    
15    pub fn verify_signature_with_tolerance(payload: &str, header: Option<&str>, secret: &str, tolerance: u64) -> Result<bool, &'static str> {
16        if secret.is_empty() { return Err("Webhook secret is required"); }
17        
18        let header = match header {
19            Some(h) if !h.is_empty() => h,
20            _ => return Ok(false),
21        };
22        
23        let mut timestamp: Option<&str> = None;
24        let mut signature: Option<&str> = None;
25        
26        for part in header.split(',') {
27            let kv: Vec<&str> = part.splitn(2, '=').collect();
28            if kv.len() == 2 {
29                match kv[0].trim() {
30                    "t" => timestamp = Some(kv[1].trim()),
31                    "v1" => signature = Some(kv[1].trim()),
32                    _ => {}
33                }
34            }
35        }
36        
37        let (ts, sig) = match (timestamp, signature) {
38            (Some(t), Some(s)) => (t, s),
39            _ => return Ok(false),
40        };
41        
42        let ts_num: u64 = ts.parse().map_err(|_| "Invalid timestamp")?;
43        let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
44        if now.abs_diff(ts_num) > tolerance { return Ok(false); }
45        
46        let message = format!("{}.{}", ts, payload);
47        let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap();
48        mac.update(message.as_bytes());
49        let result = mac.finalize();
50        let expected: String = result.into_bytes().iter().map(|b| format!("{:02x}", b)).collect();
51        
52        Ok(expected == sig)
53    }
54    
55    pub fn parse(payload: &str) -> Result<WebhookEvent, serde_json::Error> {
56        serde_json::from_str(payload)
57    }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct WebhookEvent {
62    #[serde(rename = "requestId")]
63    pub request_id: Option<String>,
64    #[serde(rename = "sessionId")]
65    pub session_id: Option<String>,
66    #[serde(rename = "userId")]
67    pub user_id: Option<String>,
68    pub status: Option<String>,
69    pub content: Option<Vec<ContentItem>>,
70    pub error: Option<ErrorInfo>,
71    pub clarification: Option<ClarificationInfo>,
72    pub timestamp: Option<String>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ContentItem {
77    #[serde(rename = "type")]
78    pub content_type: Option<String>,
79    pub text: Option<String>,
80    pub url: Option<String>,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct ErrorInfo {
85    pub code: Option<String>,
86    pub message: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct ClarificationInfo {
91    pub question: Option<String>,
92    pub options: Option<Vec<String>>,
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    
99    #[test]
100    fn test_verify_signature_missing_secret() {
101        assert!(Webhook::verify_signature("payload", Some("t=123,v1=abc"), "").is_err());
102    }
103    
104    #[test]
105    fn test_verify_signature_null_header() {
106        assert_eq!(Webhook::verify_signature("payload", None, "secret").unwrap(), false);
107    }
108    
109    #[test]
110    fn test_verify_signature_empty_header() {
111        assert_eq!(Webhook::verify_signature("payload", Some(""), "secret").unwrap(), false);
112    }
113    
114    #[test]
115    fn test_parse_completed_payload() {
116        let payload = r#"{"status":"completed","content":[{"type":"text","text":"Hello"}]}"#;
117        let event = Webhook::parse(payload).unwrap();
118        assert_eq!(event.status, Some("completed".to_string()));
119        assert!(event.content.is_some());
120    }
121    
122    #[test]
123    fn test_parse_failed_payload() {
124        let payload = r#"{"status":"failed","error":{"code":"ERROR","message":"Something went wrong"}}"#;
125        let event = Webhook::parse(payload).unwrap();
126        assert_eq!(event.status, Some("failed".to_string()));
127        assert!(event.error.is_some());
128    }
129}