1use std::collections::HashMap;
4use std::sync::Arc;
5
6use brain_core::metrics::SubsystemMetrics;
7
8use crate::resilience::{resilient_send, CircuitBreaker};
9
10pub const DEFAULT_MESSAGE_BODY: &str = r#"{"channel":"{{channel}}","recipient":"{{recipient}}","content":"{{content}}","namespace":"{{namespace}}","timestamp":"{{timestamp}}"}"#;
11
12pub fn json_escape(s: &str) -> String {
14 let escaped = serde_json::to_string(s).unwrap_or_else(|_| format!("\"{}\"", s));
15 escaped[1..escaped.len() - 1].to_string()
16}
17
18pub fn render_message_template(
20 template: &str,
21 channel: &str,
22 recipient: &str,
23 content: &str,
24 namespace: &str,
25 timestamp: &str,
26) -> String {
27 template
28 .replace("{{channel}}", &json_escape(channel))
29 .replace("{{recipient}}", &json_escape(recipient))
30 .replace("{{content}}", &json_escape(content))
31 .replace("{{namespace}}", &json_escape(namespace))
32 .replace("{{timestamp}}", &json_escape(timestamp))
33}
34
35pub struct WebhookMessageBackend {
36 channels: HashMap<String, brain_core::config::ChannelConfig>,
37 client: reqwest::Client,
38 circuit_breaker: Arc<CircuitBreaker>,
39 max_retries: u32,
40 retry_base_ms: u64,
41}
42
43impl WebhookMessageBackend {
44 pub fn new(
45 channels: &HashMap<String, brain_core::config::ChannelConfig>,
46 timeout_ms: u64,
47 resilience: &brain_core::config::ResilienceConfig,
48 ) -> anyhow::Result<Self> {
49 Self::new_with_metrics(channels, timeout_ms, resilience, None)
50 }
51
52 pub fn new_with_metrics(
53 channels: &HashMap<String, brain_core::config::ChannelConfig>,
54 timeout_ms: u64,
55 resilience: &brain_core::config::ResilienceConfig,
56 metrics: Option<Arc<SubsystemMetrics>>,
57 ) -> anyhow::Result<Self> {
58 let client = reqwest::Client::builder()
59 .timeout(std::time::Duration::from_millis(timeout_ms.max(1)))
60 .build()
61 .map_err(|e| anyhow::anyhow!("message client init failed: {e}"))?;
62 let cb = CircuitBreaker::new(
63 "webhook-message",
64 resilience.circuit_breaker_threshold,
65 resilience.circuit_breaker_cooldown_secs,
66 );
67 let cb = if let Some(m) = metrics {
68 cb.with_metrics(m)
69 } else {
70 cb
71 };
72 Ok(Self {
73 channels: channels
74 .iter()
75 .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
76 .collect(),
77 client,
78 circuit_breaker: Arc::new(cb),
79 max_retries: resilience.max_retries,
80 retry_base_ms: resilience.retry_base_ms,
81 })
82 }
83}
84
85#[async_trait::async_trait]
86impl signal::notification::WebhookSender for WebhookMessageBackend {
87 async fn send_notification(
88 &self,
89 channel: &str,
90 content: &str,
91 namespace: &str,
92 ) -> Result<(), String> {
93 let channel_cfg = self
94 .channels
95 .get(&channel.to_ascii_lowercase())
96 .ok_or_else(|| format!("No webhook mapping for channel '{channel}'"))?
97 .clone();
98
99 let client = self.client.clone();
100 let template = if channel_cfg.body.is_empty() {
101 DEFAULT_MESSAGE_BODY.to_string()
102 } else {
103 channel_cfg.body.clone()
104 };
105 let url = channel_cfg.url.clone();
106 let headers = channel_cfg.headers.clone();
107 let channel_owned = channel.to_string();
108 let content_owned = content.to_string();
109 let namespace_owned = namespace.to_string();
110
111 let response = resilient_send(
112 || {
113 let timestamp = chrono::Utc::now().to_rfc3339();
114 let rendered = render_message_template(
115 &template,
116 &channel_owned,
117 "",
118 &content_owned,
119 &namespace_owned,
120 ×tamp,
121 );
122 let mut req = client
123 .post(&url)
124 .header("Content-Type", "application/json")
125 .body(rendered);
126 for (key, value) in &headers {
127 req = req.header(key.as_str(), value.as_str());
128 }
129 req
130 },
131 &self.circuit_breaker,
132 self.max_retries,
133 self.retry_base_ms,
134 )
135 .await
136 .map_err(|e| format!("webhook send failed: {e}"))?;
137
138 if !response.status().is_success() {
139 return Err(format!(
140 "webhook for channel '{}' returned HTTP {}",
141 channel,
142 response.status()
143 ));
144 }
145 Ok(())
146 }
147}
148
149#[async_trait::async_trait]
150impl cortex::actions::MessageBackend for WebhookMessageBackend {
151 async fn send(
152 &self,
153 channel: &str,
154 recipient: &str,
155 content: &str,
156 namespace: &str,
157 ) -> Result<cortex::actions::MessageOutcome, cortex::actions::ActionError> {
158 let channel_cfg = self
159 .channels
160 .get(&channel.to_ascii_lowercase())
161 .ok_or_else(|| {
162 cortex::actions::ActionError::InvalidArguments(format!(
163 "No webhook mapping for channel '{}'",
164 channel
165 ))
166 })?
167 .clone();
168
169 let client = self.client.clone();
170 let template = if channel_cfg.body.is_empty() {
171 DEFAULT_MESSAGE_BODY.to_string()
172 } else {
173 channel_cfg.body.clone()
174 };
175 let url = channel_cfg.url.clone();
176 let headers = channel_cfg.headers.clone();
177 let channel_owned = channel.to_string();
178 let recipient_owned = recipient.to_string();
179 let content_owned = content.to_string();
180 let namespace_owned = namespace.to_string();
181
182 let response = resilient_send(
183 || {
184 let timestamp = chrono::Utc::now().to_rfc3339();
185 let rendered = render_message_template(
186 &template,
187 &channel_owned,
188 &recipient_owned,
189 &content_owned,
190 &namespace_owned,
191 ×tamp,
192 );
193 let mut req = client
194 .post(&url)
195 .header("Content-Type", "application/json")
196 .body(rendered);
197 for (key, value) in &headers {
198 req = req.header(key.as_str(), value.as_str());
199 }
200 req
201 },
202 &self.circuit_breaker,
203 self.max_retries,
204 self.retry_base_ms,
205 )
206 .await?;
207
208 if !response.status().is_success() {
209 return Err(cortex::actions::ActionError::ExecutionFailed(format!(
210 "webhook for channel '{}' returned HTTP {}",
211 channel,
212 response.status()
213 )));
214 }
215
216 let body = response.text().await.unwrap_or_default();
217 let mut delivery_id = format!("msg-{}", chrono::Utc::now().timestamp_micros());
218 let mut status = "accepted".to_string();
219 if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) {
220 if let Some(id) = value
221 .get("id")
222 .or_else(|| value.get("delivery_id"))
223 .and_then(serde_json::Value::as_str)
224 {
225 delivery_id = id.to_string();
226 }
227 if let Some(s) = value.get("status").and_then(serde_json::Value::as_str) {
228 status = s.to_string();
229 }
230 }
231
232 Ok(cortex::actions::MessageOutcome {
233 delivery_id,
234 status,
235 })
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn test_render_message_template_default() {
245 let rendered = render_message_template(
246 DEFAULT_MESSAGE_BODY,
247 "alerts",
248 "alice",
249 "deploy done",
250 "work",
251 "2026-03-08T12:00:00Z",
252 );
253 let parsed: serde_json::Value =
254 serde_json::from_str(&rendered).expect("default template should produce valid JSON");
255 assert_eq!(parsed["channel"], "alerts");
256 assert_eq!(parsed["recipient"], "alice");
257 assert_eq!(parsed["content"], "deploy done");
258 assert_eq!(parsed["namespace"], "work");
259 assert_eq!(parsed["timestamp"], "2026-03-08T12:00:00Z");
260 }
261
262 #[test]
263 fn test_render_message_template_custom_slack() {
264 let template = r#"{"text": "[{{channel}}] {{content}}"}"#;
265 let rendered = render_message_template(
266 template,
267 "ops",
268 "bob",
269 "server is down",
270 "personal",
271 "2026-03-08T12:00:00Z",
272 );
273 let parsed: serde_json::Value =
274 serde_json::from_str(&rendered).expect("custom template should produce valid JSON");
275 assert_eq!(parsed["text"], "[ops] server is down");
276 }
277
278 #[test]
279 fn test_render_message_template_escapes_quotes() {
280 let rendered = render_message_template(
281 DEFAULT_MESSAGE_BODY,
282 "alerts",
283 "alice",
284 r#"He said "hello""#,
285 "work",
286 "2026-03-08T12:00:00Z",
287 );
288 let parsed: serde_json::Value =
289 serde_json::from_str(&rendered).expect("escaped content should produce valid JSON");
290 assert_eq!(parsed["content"], r#"He said "hello""#);
291 }
292
293 #[test]
294 fn test_render_message_template_escapes_newlines() {
295 let rendered = render_message_template(
296 DEFAULT_MESSAGE_BODY,
297 "alerts",
298 "alice",
299 "line1\nline2",
300 "work",
301 "2026-03-08T12:00:00Z",
302 );
303 let parsed: serde_json::Value =
304 serde_json::from_str(&rendered).expect("newline content should produce valid JSON");
305 assert_eq!(parsed["content"], "line1\nline2");
306 }
307
308 #[test]
309 fn test_json_escape() {
310 assert_eq!(json_escape("hello"), "hello");
311 assert_eq!(json_escape(r#"say "hi""#), r#"say \"hi\""#);
312 assert_eq!(json_escape("a\nb"), r#"a\nb"#);
313 assert_eq!(json_escape("back\\slash"), r#"back\\slash"#);
314 }
315
316 fn fast_resilience() -> brain_core::config::ResilienceConfig {
319 brain_core::config::ResilienceConfig {
320 max_retries: 0,
321 retry_base_ms: 10,
322 circuit_breaker_threshold: 5,
323 circuit_breaker_cooldown_secs: 60,
324 }
325 }
326
327 fn build_channels(name: &str, url: &str) -> HashMap<String, brain_core::config::ChannelConfig> {
328 let mut map = HashMap::new();
329 map.insert(
330 name.to_string(),
331 brain_core::config::ChannelConfig {
332 url: url.to_string(),
333 body: String::new(),
334 headers: HashMap::new(),
335 },
336 );
337 map
338 }
339
340 #[tokio::test]
341 async fn test_webhook_send_success() {
342 use cortex::actions::MessageBackend;
343
344 let mut server = mockito::Server::new_async().await;
345 let mock = server
346 .mock("POST", "/hook")
347 .match_header("content-type", "application/json")
348 .with_status(200)
349 .with_header("content-type", "application/json")
350 .with_body(r#"{"id": "delivery-123", "status": "sent"}"#)
351 .create_async()
352 .await;
353
354 let webhook_url = format!("{}/hook", server.url());
355 let channels = build_channels("alerts", &webhook_url);
356 let backend = WebhookMessageBackend::new(&channels, 5000, &fast_resilience()).unwrap();
357 let result = backend
358 .send("alerts", "alice", "server is down", "work")
359 .await
360 .unwrap();
361 assert_eq!(result.delivery_id, "delivery-123");
362 assert_eq!(result.status, "sent");
363 mock.assert_async().await;
364 }
365
366 #[tokio::test]
367 async fn test_webhook_send_5xx_fails() {
368 use cortex::actions::MessageBackend;
369
370 let mut server = mockito::Server::new_async().await;
371 let _mock = server
372 .mock("POST", "/hook")
373 .with_status(500)
374 .with_body("server error")
375 .expect_at_least(1)
376 .create_async()
377 .await;
378
379 let webhook_url = format!("{}/hook", server.url());
380 let channels = build_channels("alerts", &webhook_url);
381 let backend = WebhookMessageBackend::new(&channels, 5000, &fast_resilience()).unwrap();
382 let result = backend.send("alerts", "alice", "boom", "work").await;
383 assert!(result.is_err(), "expected 5xx to fail");
384 }
385
386 #[tokio::test]
387 async fn test_webhook_unknown_channel_returns_invalid_args() {
388 use cortex::actions::MessageBackend;
389
390 let channels = build_channels("alerts", "http://unused");
391 let backend = WebhookMessageBackend::new(&channels, 5000, &fast_resilience()).unwrap();
392 let err = backend
393 .send("unknown-channel", "alice", "hi", "work")
394 .await
395 .unwrap_err();
396 assert!(
397 matches!(err, cortex::actions::ActionError::InvalidArguments(_)),
398 "expected InvalidArguments error, got {err:?}"
399 );
400 }
401
402 #[tokio::test]
403 async fn test_webhook_fallback_delivery_id_when_no_body_id() {
404 use cortex::actions::MessageBackend;
405
406 let mut server = mockito::Server::new_async().await;
407 let _mock = server
408 .mock("POST", "/hook")
409 .with_status(200)
410 .with_body(r#"{"other": "field"}"#)
411 .create_async()
412 .await;
413
414 let webhook_url = format!("{}/hook", server.url());
415 let channels = build_channels("alerts", &webhook_url);
416 let backend = WebhookMessageBackend::new(&channels, 5000, &fast_resilience()).unwrap();
417 let result = backend.send("alerts", "alice", "hi", "work").await.unwrap();
418 assert!(result.delivery_id.starts_with("msg-"));
420 assert_eq!(result.status, "accepted");
421 }
422}