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