Skip to main content

mockforge_core/security/
slack.rs

1//! Slack notification service for security notifications
2//!
3//! Supports multiple Slack integration methods:
4//! - Incoming Webhooks (simpler, just POST to webhook URL)
5//! - Web API (chat.postMessage, requires bot token)
6//! - Disabled (logs only, for development/testing)
7
8use anyhow::{Context, Result};
9use serde::Serialize;
10use std::time::Duration;
11use tracing::{debug, error, info};
12
13/// Slack integration method
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum SlackMethod {
16    /// Incoming webhook (POST to webhook URL)
17    Webhook,
18    /// Web API (chat.postMessage with bot token)
19    WebApi,
20    /// Slack disabled (logs only)
21    Disabled,
22}
23
24impl SlackMethod {
25    /// Parse method from string
26    #[allow(clippy::should_implement_trait)]
27    pub fn from_str(s: &str) -> Self {
28        match s.to_lowercase().as_str() {
29            "webhook" | "incoming_webhook" => SlackMethod::Webhook,
30            "webapi" | "web_api" | "api" => SlackMethod::WebApi,
31            _ => SlackMethod::Disabled,
32        }
33    }
34}
35
36/// Slack configuration
37#[derive(Debug, Clone)]
38pub struct SlackConfig {
39    /// Integration method to use
40    pub method: SlackMethod,
41    /// Webhook URL (for incoming webhook method)
42    pub webhook_url: Option<String>,
43    /// Bot token (for Web API method)
44    pub bot_token: Option<String>,
45    /// Default channel to send messages to (for Web API)
46    pub default_channel: Option<String>,
47}
48
49impl SlackConfig {
50    /// Create Slack config from environment variables
51    pub fn from_env() -> Self {
52        let method = std::env::var("SLACK_METHOD").unwrap_or_else(|_| "disabled".to_string());
53
54        Self {
55            method: SlackMethod::from_str(&method),
56            webhook_url: std::env::var("SLACK_WEBHOOK_URL").ok(),
57            bot_token: std::env::var("SLACK_BOT_TOKEN").ok(),
58            default_channel: std::env::var("SLACK_DEFAULT_CHANNEL")
59                .or_else(|_| std::env::var("SLACK_CHANNEL"))
60                .ok(),
61        }
62    }
63}
64
65/// Slack message
66#[derive(Debug, Clone)]
67pub struct SlackMessage {
68    /// Channel to send message to (optional, uses default if not provided)
69    pub channel: Option<String>,
70    /// Message text
71    pub text: String,
72    /// Message title/subject (optional)
73    pub title: Option<String>,
74    /// Additional fields for rich formatting (optional)
75    pub fields: Vec<(String, String)>,
76}
77
78/// Slack service for sending notifications
79pub struct SlackService {
80    config: SlackConfig,
81    client: reqwest::Client,
82}
83
84impl SlackService {
85    /// Create a new Slack service
86    pub fn new(config: SlackConfig) -> Self {
87        let client = reqwest::Client::builder()
88            .timeout(Duration::from_secs(10))
89            .build()
90            .expect("Failed to create HTTP client for Slack service");
91
92        Self { config, client }
93    }
94
95    /// Create Slack service from environment variables
96    pub fn from_env() -> Self {
97        Self::new(SlackConfig::from_env())
98    }
99
100    /// Send a Slack message
101    pub async fn send(&self, message: SlackMessage) -> Result<()> {
102        match &self.config.method {
103            SlackMethod::Webhook => self.send_via_webhook(message).await,
104            SlackMethod::WebApi => self.send_via_webapi(message).await,
105            SlackMethod::Disabled => {
106                info!("Slack disabled, would send: '{}'", message.text);
107                debug!("Slack message details: {:?}", message);
108                Ok(())
109            }
110        }
111    }
112
113    /// Send message to multiple channels/recipients
114    pub async fn send_to_multiple(
115        &self,
116        message: SlackMessage,
117        recipients: &[String],
118    ) -> Result<()> {
119        let mut errors = Vec::new();
120
121        for recipient in recipients {
122            let mut msg = message.clone();
123            msg.channel = Some(recipient.clone());
124
125            match self.send(msg).await {
126                Ok(()) => {
127                    debug!("Slack message sent successfully to {}", recipient);
128                }
129                Err(e) => {
130                    let error_msg = format!("Failed to send Slack message to {}: {}", recipient, e);
131                    error!("{}", error_msg);
132                    errors.push(error_msg);
133                }
134            }
135        }
136
137        if !errors.is_empty() {
138            anyhow::bail!(
139                "Failed to send Slack messages to some recipients: {}",
140                errors.join("; ")
141            );
142        }
143
144        Ok(())
145    }
146
147    /// Send message via Slack Incoming Webhook
148    async fn send_via_webhook(&self, message: SlackMessage) -> Result<()> {
149        let webhook_url = self
150            .config
151            .webhook_url
152            .as_ref()
153            .context("Slack webhook requires SLACK_WEBHOOK_URL environment variable")?;
154
155        #[derive(Serialize)]
156        struct SlackWebhookPayload {
157            text: String,
158            #[serde(skip_serializing_if = "Option::is_none")]
159            channel: Option<String>,
160            #[serde(skip_serializing_if = "Option::is_none")]
161            attachments: Option<Vec<SlackAttachment>>,
162        }
163
164        #[derive(Serialize)]
165        struct SlackAttachment {
166            #[serde(skip_serializing_if = "Option::is_none")]
167            title: Option<String>,
168            text: String,
169            color: String,
170            #[serde(skip_serializing_if = "Option::is_none")]
171            fields: Option<Vec<SlackField>>,
172        }
173
174        #[derive(Serialize)]
175        struct SlackField {
176            title: String,
177            value: String,
178            short: bool,
179        }
180
181        let mut attachments = Vec::new();
182        let mut attachment = SlackAttachment {
183            title: message.title.clone(),
184            text: message.text.clone(),
185            color: "#36a64f".to_string(), // Green color for notifications
186            fields: None,
187        };
188
189        if !message.fields.is_empty() {
190            attachment.fields = Some(
191                message
192                    .fields
193                    .iter()
194                    .map(|(title, value)| SlackField {
195                        title: title.clone(),
196                        value: value.clone(),
197                        short: true,
198                    })
199                    .collect(),
200            );
201        }
202
203        attachments.push(attachment);
204
205        let payload = SlackWebhookPayload {
206            text: message.text.clone(),
207            channel: message.channel.clone(),
208            attachments: Some(attachments),
209        };
210
211        let response = self
212            .client
213            .post(webhook_url)
214            .header("Content-Type", "application/json")
215            .json(&payload)
216            .send()
217            .await
218            .context("Failed to send Slack message via webhook")?;
219
220        let status = response.status();
221        if !status.is_success() {
222            let error_text = response.text().await.unwrap_or_default();
223            anyhow::bail!("Slack webhook error ({}): {}", status, error_text);
224        }
225
226        info!("Slack message sent via webhook");
227        Ok(())
228    }
229
230    /// Send message via Slack Web API (chat.postMessage)
231    async fn send_via_webapi(&self, message: SlackMessage) -> Result<()> {
232        let bot_token = self
233            .config
234            .bot_token
235            .as_ref()
236            .context("Slack Web API requires SLACK_BOT_TOKEN environment variable")?;
237
238        let channel = message.channel.as_ref().or(self.config.default_channel.as_ref()).context(
239            "Slack Web API requires channel (set SLACK_DEFAULT_CHANNEL or provide in message)",
240        )?;
241
242        #[derive(Serialize)]
243        struct SlackApiPayload {
244            channel: String,
245            text: String,
246            #[serde(skip_serializing_if = "Option::is_none")]
247            blocks: Option<Vec<serde_json::Value>>,
248        }
249
250        // Build rich message blocks if we have title or fields
251        let mut blocks = Vec::new();
252        if let Some(ref title) = message.title {
253            blocks.push(serde_json::json!({
254                "type": "header",
255                "text": {
256                    "type": "plain_text",
257                    "text": title
258                }
259            }));
260        }
261
262        blocks.push(serde_json::json!({
263            "type": "section",
264            "text": {
265                "type": "mrkdwn",
266                "text": message.text
267            }
268        }));
269
270        if !message.fields.is_empty() {
271            let fields: Vec<serde_json::Value> = message
272                .fields
273                .iter()
274                .map(|(title, value)| {
275                    serde_json::json!({
276                        "type": "mrkdwn",
277                        "text": format!("*{}:*\n{}", title, value)
278                    })
279                })
280                .collect();
281
282            blocks.push(serde_json::json!({
283                "type": "section",
284                "fields": fields
285            }));
286        }
287
288        let payload = SlackApiPayload {
289            channel: channel.clone(),
290            text: message.text.clone(),
291            blocks: if blocks.is_empty() {
292                None
293            } else {
294                Some(blocks)
295            },
296        };
297
298        let response = self
299            .client
300            .post("https://slack.com/api/chat.postMessage")
301            .header("Authorization", format!("Bearer {}", bot_token))
302            .header("Content-Type", "application/json")
303            .json(&payload)
304            .send()
305            .await
306            .context("Failed to send Slack message via Web API")?;
307
308        let status = response.status();
309        if !status.is_success() {
310            let error_text = response.text().await.unwrap_or_default();
311            anyhow::bail!("Slack Web API error ({}): {}", status, error_text);
312        }
313
314        // Check Slack API response for errors
315        let api_response: serde_json::Value =
316            response.json().await.context("Failed to parse Slack API response")?;
317
318        if let Some(ok) = api_response.get("ok").and_then(|v| v.as_bool()) {
319            if !ok {
320                let error_msg =
321                    api_response.get("error").and_then(|v| v.as_str()).unwrap_or("Unknown error");
322                anyhow::bail!("Slack API returned error: {}", error_msg);
323            }
324        }
325
326        info!("Slack message sent via Web API to channel {}", channel);
327        Ok(())
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_slack_method_from_str() {
337        assert_eq!(SlackMethod::from_str("webhook"), SlackMethod::Webhook);
338        assert_eq!(SlackMethod::from_str("incoming_webhook"), SlackMethod::Webhook);
339        assert_eq!(SlackMethod::from_str("webapi"), SlackMethod::WebApi);
340        assert_eq!(SlackMethod::from_str("web_api"), SlackMethod::WebApi);
341        assert_eq!(SlackMethod::from_str("api"), SlackMethod::WebApi);
342        assert_eq!(SlackMethod::from_str("disabled"), SlackMethod::Disabled);
343        assert_eq!(SlackMethod::from_str("unknown"), SlackMethod::Disabled);
344    }
345
346    #[tokio::test]
347    async fn test_slack_service_disabled() {
348        let config = SlackConfig {
349            method: SlackMethod::Disabled,
350            webhook_url: None,
351            bot_token: None,
352            default_channel: None,
353        };
354
355        let service = SlackService::new(config);
356        let message = SlackMessage {
357            channel: None,
358            text: "Test message".to_string(),
359            title: None,
360            fields: Vec::new(),
361        };
362
363        // Should not fail when disabled
364        let result = service.send(message).await;
365        assert!(result.is_ok());
366    }
367}