Skip to main content

imessage_webhooks/
service.rs

1/// Webhook dispatch service.
2///
3/// Fire-and-forget HTTP POST to all registered webhooks whose event filter matches.
4/// Webhooks with event filter ["*"] match all events.
5use std::sync::Arc;
6use std::time::Duration;
7
8use serde_json::{Value, json};
9use tokio::sync::Mutex;
10use tracing::{info, warn};
11
12use imessage_core::config::AppConfig;
13
14use crate::WebhookTarget;
15use crate::event_cache::EventCache;
16
17/// The webhook dispatch service.
18pub struct WebhookService {
19    client: reqwest::Client,
20    targets: Arc<Mutex<Vec<WebhookTarget>>>,
21    event_cache: Arc<Mutex<EventCache>>,
22    server_address: String,
23}
24
25impl WebhookService {
26    pub fn new(config: &AppConfig) -> Self {
27        let client = reqwest::Client::builder()
28            .timeout(Duration::from_secs(30))
29            .build()
30            .unwrap_or_default();
31
32        Self {
33            client,
34            targets: Arc::new(Mutex::new(Vec::new())),
35            event_cache: Arc::new(Mutex::new(EventCache::new())),
36            server_address: config.server_address.clone(),
37        }
38    }
39
40    /// Get the configured server address.
41    pub fn server_address(&self) -> &str {
42        &self.server_address
43    }
44
45    /// Set the list of webhook targets (called on startup).
46    pub async fn set_targets(&self, targets: Vec<WebhookTarget>) {
47        let mut t = self.targets.lock().await;
48        *t = targets;
49    }
50
51    /// Get a snapshot of the current webhook targets.
52    pub async fn get_targets(&self) -> Vec<WebhookTarget> {
53        self.targets.lock().await.clone()
54    }
55
56    /// Dispatch an event to all matching webhooks.
57    ///
58    /// - `event_type`: e.g., "new-message", "typing-indicator"
59    /// - `data`: the event payload
60    /// - `dedup_key`: optional key for event deduplication
61    pub async fn dispatch(&self, event_type: &str, data: Value, dedup_key: Option<&str>) {
62        // Check dedup
63        if let Some(key) = dedup_key {
64            let mut cache = self.event_cache.lock().await;
65            if cache.is_duplicate(key) {
66                return;
67            }
68        }
69
70        let targets = self.targets.lock().await.clone();
71        if targets.is_empty() {
72            return;
73        }
74
75        let payload = json!({
76            "type": event_type,
77            "data": data,
78        });
79
80        for target in &targets {
81            if !Self::matches_event(target, event_type) {
82                continue;
83            }
84
85            let client = self.client.clone();
86            let url = target.url.clone();
87            let payload = payload.clone();
88
89            // Fire and forget
90            tokio::spawn(async move {
91                match client.post(&url).json(&payload).send().await {
92                    Ok(resp) => {
93                        if !resp.status().is_success() {
94                            warn!("Webhook {url} returned status {}", resp.status());
95                        }
96                    }
97                    Err(e) => {
98                        warn!("Webhook {url} failed: {e}");
99                    }
100                }
101            });
102        }
103
104        info!(
105            "Dispatched '{event_type}' to {} matching webhooks",
106            targets
107                .iter()
108                .filter(|t| Self::matches_event(t, event_type))
109                .count()
110        );
111    }
112
113    /// Check if a webhook's event filter matches the given event type.
114    fn matches_event(target: &WebhookTarget, event_type: &str) -> bool {
115        target.events.iter().any(|e| e == "*" || e == event_type)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn test_webhook(events: Vec<String>) -> WebhookTarget {
124        WebhookTarget {
125            url: "http://example.com".to_string(),
126            events,
127        }
128    }
129
130    #[test]
131    fn wildcard_matches_all() {
132        let target = test_webhook(vec!["*".to_string()]);
133        assert!(WebhookService::matches_event(&target, "new-message"));
134        assert!(WebhookService::matches_event(&target, "typing-indicator"));
135        assert!(WebhookService::matches_event(&target, "anything"));
136    }
137
138    #[test]
139    fn specific_events_filter() {
140        let target = test_webhook(vec![
141            "new-message".to_string(),
142            "updated-message".to_string(),
143        ]);
144        assert!(WebhookService::matches_event(&target, "new-message"));
145        assert!(WebhookService::matches_event(&target, "updated-message"));
146        assert!(!WebhookService::matches_event(&target, "typing-indicator"));
147    }
148
149    #[test]
150    fn empty_events_matches_nothing() {
151        let target = test_webhook(vec![]);
152        assert!(!WebhookService::matches_event(&target, "new-message"));
153    }
154}