Skip to main content

opssignal_core/providers/
teams.rs

1use crate::delivery::Provider;
2use crate::signal::{Severity, Signal, SignalError};
3use crate::template::{self, Templates};
4
5/// Posts an Adaptive Card to a Microsoft Teams channel webhook. The
6/// message shape works with both Workflows (Power Automate) webhooks,
7/// which replace Office 365 connectors, and legacy incoming webhooks.
8pub struct TeamsProvider {
9    pub webhook_url: String,
10    client: reqwest::Client,
11    templates: Option<Templates>,
12}
13
14impl TeamsProvider {
15    pub fn new(webhook_url: String) -> Self {
16        TeamsProvider {
17            webhook_url,
18            client: reqwest::Client::new(),
19            templates: None,
20        }
21    }
22
23    /// Uses `templates` for the parts they set; see [`crate::template`].
24    pub fn with_templates(mut self, templates: Option<Templates>) -> Self {
25        self.templates = templates;
26        self
27    }
28
29    /// Adaptive Card text colours.
30    fn color_for(severity: Severity) -> &'static str {
31        match severity {
32            Severity::Info => "default",
33            Severity::Success => "good",
34            Severity::Warning => "warning",
35            Severity::Error | Severity::Critical => "attention",
36        }
37    }
38
39    fn card_for(&self, signal: &Signal) -> serde_json::Value {
40        let title = template::render(&self.templates, "title", signal, &[])
41            .unwrap_or_else(|| signal.title.clone());
42        let message = template::render(&self.templates, "text", signal, &[])
43            .or_else(|| signal.message.clone());
44        let mut body = vec![serde_json::json!({
45            "type": "TextBlock",
46            "text": title,
47            "weight": "bolder",
48            "size": "medium",
49            "color": Self::color_for(signal.severity),
50            "wrap": true,
51        })];
52        if let Some(message) = message {
53            body.push(serde_json::json!({
54                "type": "TextBlock",
55                "text": message,
56                "wrap": true,
57            }));
58        }
59
60        let mut facts = vec![
61            fact("Severity", &signal.severity.to_string()),
62            fact("Source", &signal.source),
63            fact("Event", &signal.event_type),
64        ];
65        let optional = [
66            ("Environment", &signal.environment),
67            ("Service", &signal.service),
68            ("Component", &signal.component),
69            ("Team", &signal.team),
70            ("Run ID", &signal.run_id),
71        ];
72        for (title, value) in optional {
73            if let Some(value) = value {
74                facts.push(fact(title, value));
75            }
76        }
77        body.push(serde_json::json!({ "type": "FactSet", "facts": facts }));
78
79        let mut card = serde_json::json!({
80            "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
81            "type": "AdaptiveCard",
82            "version": "1.4",
83            "body": body,
84        });
85        if let Some(log_url) = &signal.log_url {
86            card["actions"] = serde_json::json!([{
87                "type": "Action.OpenUrl",
88                "title": "View logs",
89                "url": log_url,
90            }]);
91        }
92
93        serde_json::json!({
94            "type": "message",
95            "attachments": [{
96                "contentType": "application/vnd.microsoft.card.adaptive",
97                "contentUrl": null,
98                "content": card,
99            }],
100        })
101    }
102}
103
104fn fact(title: &str, value: &str) -> serde_json::Value {
105    serde_json::json!({ "title": title, "value": value })
106}
107
108#[async_trait::async_trait]
109impl Provider for TeamsProvider {
110    fn name(&self) -> &'static str {
111        "teams"
112    }
113
114    fn preview(&self, signal: &Signal) -> Option<serde_json::Value> {
115        Some(self.card_for(signal))
116    }
117
118    async fn send(&self, signal: &Signal) -> Result<(), SignalError> {
119        let resp = self
120            .client
121            .post(&self.webhook_url)
122            .json(&self.card_for(signal))
123            .send()
124            .await
125            .map_err(|e| SignalError::Delivery(e.to_string()))?;
126
127        if !resp.status().is_success() {
128            return Err(SignalError::Delivery(format!(
129                "teams webhook returned status {}",
130                resp.status()
131            )));
132        }
133        Ok(())
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::signal::SignalInput;
141    use wiremock::matchers::{body_partial_json, header, method, path};
142    use wiremock::{Mock, MockServer, ResponseTemplate};
143
144    fn signal(severity: Severity) -> Signal {
145        Signal::from_input(SignalInput {
146            source: "airflow".into(),
147            event_type: "task_failed".into(),
148            title: "dbt model failed".into(),
149            message: Some("model orders timed out".into()),
150            severity,
151            environment: Some("prod".into()),
152            log_url: Some("https://logs.example.test/run/1".into()),
153            ..Default::default()
154        })
155    }
156
157    fn facts(card: &serde_json::Value) -> Vec<(String, String)> {
158        card["attachments"][0]["content"]["body"]
159            .as_array()
160            .unwrap()
161            .iter()
162            .find(|b| b["type"] == "FactSet")
163            .unwrap()["facts"]
164            .as_array()
165            .unwrap()
166            .iter()
167            .map(|f| {
168                (
169                    f["title"].as_str().unwrap().to_string(),
170                    f["value"].as_str().unwrap().to_string(),
171                )
172            })
173            .collect()
174    }
175
176    #[test]
177    fn severities_map_to_card_colours() {
178        assert_eq!(TeamsProvider::color_for(Severity::Info), "default");
179        assert_eq!(TeamsProvider::color_for(Severity::Success), "good");
180        assert_eq!(TeamsProvider::color_for(Severity::Warning), "warning");
181        assert_eq!(TeamsProvider::color_for(Severity::Error), "attention");
182        assert_eq!(TeamsProvider::color_for(Severity::Critical), "attention");
183    }
184
185    #[test]
186    fn card_has_title_message_facts_and_log_link() {
187        let card = TeamsProvider::new(String::new()).card_for(&signal(Severity::Error));
188        assert_eq!(card["type"], "message");
189        let attachment = &card["attachments"][0];
190        assert_eq!(
191            attachment["contentType"],
192            "application/vnd.microsoft.card.adaptive"
193        );
194        let content = &attachment["content"];
195        assert_eq!(content["type"], "AdaptiveCard");
196        assert_eq!(content["body"][0]["text"], "dbt model failed");
197        assert_eq!(content["body"][0]["color"], "attention");
198        assert_eq!(content["body"][1]["text"], "model orders timed out");
199        assert_eq!(
200            content["actions"][0]["url"],
201            "https://logs.example.test/run/1"
202        );
203        assert_eq!(
204            facts(&card),
205            vec![
206                ("Severity".to_string(), "error".to_string()),
207                ("Source".to_string(), "airflow".to_string()),
208                ("Event".to_string(), "task_failed".to_string()),
209                ("Environment".to_string(), "prod".to_string()),
210            ]
211        );
212    }
213
214    #[test]
215    fn minimal_signal_has_no_message_block_or_actions() {
216        let card = TeamsProvider::new(String::new()).card_for(&Signal::from_input(SignalInput {
217            source: "ci".into(),
218            event_type: "build_failed".into(),
219            title: "main is red".into(),
220            ..Default::default()
221        }));
222        let content = &card["attachments"][0]["content"];
223        let body = content["body"].as_array().unwrap();
224        assert_eq!(body.len(), 2, "title and facts only");
225        assert_eq!(body[1]["type"], "FactSet");
226        assert!(content.get("actions").is_none());
227    }
228
229    #[tokio::test]
230    async fn posts_the_card_to_the_webhook() {
231        let server = MockServer::start().await;
232        Mock::given(method("POST"))
233            .and(path("/workflows/abc"))
234            .and(header("content-type", "application/json"))
235            .and(body_partial_json(serde_json::json!({ "type": "message" })))
236            .respond_with(ResponseTemplate::new(202))
237            .expect(1)
238            .mount(&server)
239            .await;
240
241        TeamsProvider::new(format!("{}/workflows/abc", server.uri()))
242            .send(&signal(Severity::Warning))
243            .await
244            .expect("202 is success");
245    }
246
247    #[tokio::test]
248    async fn non_success_status_is_a_delivery_error() {
249        let server = MockServer::start().await;
250        Mock::given(method("POST"))
251            .respond_with(ResponseTemplate::new(403))
252            .mount(&server)
253            .await;
254
255        let err = TeamsProvider::new(server.uri())
256            .send(&signal(Severity::Error))
257            .await
258            .expect_err("403 must fail");
259        assert!(matches!(err, SignalError::Delivery(_)));
260        assert!(err.to_string().contains("403"));
261    }
262
263    #[test]
264    fn title_and_text_templates_replace_the_text_blocks() {
265        let provider = TeamsProvider::new(String::new()).with_templates(template::for_test(
266            "teams",
267            &[
268                ("title", "{{ severity|upper }}: {{ title }}"),
269                ("text", "{{ message }} - see {{ log_url }}"),
270            ],
271        ));
272        let card = provider.card_for(&signal(Severity::Error));
273        let body = &card["attachments"][0]["content"]["body"];
274        assert_eq!(body[0]["text"], "ERROR: dbt model failed");
275        assert_eq!(
276            body[1]["text"],
277            "model orders timed out - see https://logs.example.test/run/1"
278        );
279        // The facts are not templated.
280        assert!(facts(&card).contains(&("Source".to_string(), "airflow".to_string())));
281    }
282}