imessage_webhooks/
service.rs1use 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
17pub 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 pub fn server_address(&self) -> &str {
42 &self.server_address
43 }
44
45 pub async fn set_targets(&self, targets: Vec<WebhookTarget>) {
47 let mut t = self.targets.lock().await;
48 *t = targets;
49 }
50
51 pub async fn get_targets(&self) -> Vec<WebhookTarget> {
53 self.targets.lock().await.clone()
54 }
55
56 pub async fn dispatch(&self, event_type: &str, data: Value, dedup_key: Option<&str>) {
62 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 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 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}