Skip to main content

mur_chat/
slack.rs

1//! Slack integration — Bot API, slash commands, interactive buttons, progress threads.
2
3use crate::platform::{
4    ApprovalRequest, ChatPlatform, OutgoingMessage, ProgressStatus, ProgressUpdate,
5    WorkflowNotification,
6};
7use anyhow::{Context, Result};
8use serde::Deserialize;
9
10/// Slack bot configuration.
11#[derive(Debug, Clone)]
12pub struct SlackConfig {
13    pub bot_token: String,
14    pub signing_secret: Option<String>,
15    pub default_channel: Option<String>,
16}
17
18impl SlackConfig {
19    /// Create from environment variables.
20    pub fn from_env() -> Result<Self> {
21        let bot_token =
22            std::env::var("SLACK_BOT_TOKEN").context("SLACK_BOT_TOKEN not set")?;
23        let signing_secret = std::env::var("SLACK_SIGNING_SECRET").ok();
24        let default_channel = std::env::var("SLACK_DEFAULT_CHANNEL").ok();
25
26        Ok(Self {
27            bot_token,
28            signing_secret,
29            default_channel,
30        })
31    }
32}
33
34/// Slack bot client.
35pub struct SlackBot {
36    config: SlackConfig,
37    client: reqwest::Client,
38}
39
40#[derive(Deserialize)]
41struct SlackApiResponse {
42    ok: bool,
43    #[serde(default)]
44    error: Option<String>,
45    #[serde(default)]
46    ts: Option<String>,
47}
48
49impl SlackBot {
50    pub fn new(config: SlackConfig) -> Self {
51        Self {
52            config,
53            client: reqwest::Client::builder().connect_timeout(std::time::Duration::from_secs(10)).timeout(std::time::Duration::from_secs(30)).build().unwrap_or_else(|_| reqwest::Client::new()),
54        }
55    }
56
57    async fn api_call(
58        &self,
59        method: &str,
60        body: serde_json::Value,
61    ) -> Result<SlackApiResponse> {
62        // NOTE: Slack API base URL is hardcoded. Consider making it configurable
63        // for testing or enterprise Slack instances.
64        let url = format!("https://slack.com/api/{}", method);
65        let response = self
66            .client
67            .post(&url)
68            .header(
69                "Authorization",
70                format!("Bearer {}", self.config.bot_token),
71            )
72            .json(&body)
73            .send()
74            .await
75            .context("Slack API request failed")?;
76
77        let api_response: SlackApiResponse = response.json().await?;
78        if !api_response.ok {
79            anyhow::bail!(
80                "Slack API error: {}",
81                api_response.error.unwrap_or_default()
82            );
83        }
84        Ok(api_response)
85    }
86
87    /// Build a progress bar string: [████░░░░░░] 2/5
88    fn progress_bar(current: usize, total: usize) -> String {
89        let filled = if total > 0 {
90            (current * 10) / total
91        } else {
92            0
93        };
94        let empty = 10 - filled;
95        format!(
96            "[{}{}] {}/{}",
97            "█".repeat(filled),
98            "░".repeat(empty),
99            current,
100            total
101        )
102    }
103}
104
105impl ChatPlatform for SlackBot {
106    async fn send_message(&self, msg: &OutgoingMessage) -> Result<String> {
107        let mut body = serde_json::json!({
108            "channel": msg.channel_id,
109            "text": msg.text,
110        });
111
112        if let Some(ref thread) = msg.thread_id {
113            body["thread_ts"] = serde_json::Value::String(thread.clone());
114        }
115
116        if let Some(ref blocks) = msg.blocks {
117            body["blocks"] = blocks.clone();
118        }
119
120        let response = self.api_call("chat.postMessage", body).await?;
121        Ok(response.ts.unwrap_or_default())
122    }
123
124    async fn send_approval(
125        &self,
126        channel_id: &str,
127        request: &ApprovalRequest,
128    ) -> Result<String> {
129        let blocks = serde_json::json!([
130            {
131                "type": "section",
132                "text": {
133                    "type": "mrkdwn",
134                    "text": format!(
135                        "⚠️ *Approval Required*\n\nStep: `{}`\n{}\n\nCommand: `{}`",
136                        request.step_name, request.description, request.action
137                    )
138                }
139            },
140            {
141                "type": "actions",
142                "block_id": format!("approve_{}", request.execution_id),
143                "elements": [
144                    {
145                        "type": "button",
146                        "text": { "type": "plain_text", "text": "✅ Approve" },
147                        "style": "primary",
148                        "action_id": "approve",
149                        "value": request.execution_id,
150                    },
151                    {
152                        "type": "button",
153                        "text": { "type": "plain_text", "text": "❌ Deny" },
154                        "style": "danger",
155                        "action_id": "deny",
156                        "value": request.execution_id,
157                    }
158                ]
159            }
160        ]);
161
162        let msg = OutgoingMessage {
163            channel_id: channel_id.to_string(),
164            text: format!("Approval needed for step: {}", request.step_name),
165            thread_id: None,
166            blocks: Some(blocks),
167        };
168
169        self.send_message(&msg).await
170    }
171
172    async fn update_message(
173        &self,
174        channel_id: &str,
175        message_id: &str,
176        text: &str,
177    ) -> Result<()> {
178        self.api_call(
179            "chat.update",
180            serde_json::json!({
181                "channel": channel_id,
182                "ts": message_id,
183                "text": text,
184            }),
185        )
186        .await?;
187        Ok(())
188    }
189
190    async fn add_reaction(
191        &self,
192        channel_id: &str,
193        message_id: &str,
194        emoji: &str,
195    ) -> Result<()> {
196        self.api_call(
197            "reactions.add",
198            serde_json::json!({
199                "channel": channel_id,
200                "timestamp": message_id,
201                "name": emoji,
202            }),
203        )
204        .await?;
205        Ok(())
206    }
207
208    async fn send_progress(
209        &self,
210        channel_id: &str,
211        thread_id: &str,
212        progress: &ProgressUpdate,
213    ) -> Result<String> {
214        let (icon, status_text) = match progress.status {
215            ProgressStatus::Started => ("🚀", "Workflow started"),
216            ProgressStatus::StepRunning => ("⏳", "Running"),
217            ProgressStatus::StepDone => ("✅", "Completed"),
218            ProgressStatus::StepFailed => ("❌", "Failed"),
219            ProgressStatus::Completed => ("🎉", "Workflow completed"),
220            ProgressStatus::Failed => ("💥", "Workflow failed"),
221        };
222
223        let bar = Self::progress_bar(progress.step_index + 1, progress.total_steps);
224        let duration = progress
225            .duration_ms
226            .map(|ms| format!(" ({}ms)", ms))
227            .unwrap_or_default();
228
229        let text = format!(
230            "{} *{}* — `{}`{}\n{}",
231            icon, status_text, progress.step_name, duration, bar
232        );
233
234        let msg = OutgoingMessage {
235            channel_id: channel_id.to_string(),
236            text,
237            thread_id: Some(thread_id.to_string()),
238            blocks: None,
239        };
240
241        self.send_message(&msg).await
242    }
243
244    async fn send_notification(
245        &self,
246        channel_id: &str,
247        thread_id: Option<&str>,
248        notification: &WorkflowNotification,
249    ) -> Result<String> {
250        let (icon, title) = if notification.success {
251            ("✅", "Workflow Completed")
252        } else {
253            ("❌", "Workflow Failed")
254        };
255
256        let error_line = notification
257            .error
258            .as_ref()
259            .map(|e| format!("\nError: `{}`", e))
260            .unwrap_or_default();
261
262        let text = format!(
263            "{} *{}*\n\nWorkflow: `{}`\nSteps: {}/{}\nDuration: {}ms{}",
264            icon,
265            title,
266            notification.workflow_id,
267            notification.steps_completed,
268            notification.total_steps,
269            notification.duration_ms,
270            error_line,
271        );
272
273        let blocks = serde_json::json!([
274            {
275                "type": "section",
276                "text": {
277                    "type": "mrkdwn",
278                    "text": text
279                }
280            }
281        ]);
282
283        let msg = OutgoingMessage {
284            channel_id: channel_id.to_string(),
285            text: format!(
286                "{} {} — {}",
287                icon, title, notification.workflow_id
288            ),
289            thread_id: thread_id.map(String::from),
290            blocks: Some(blocks),
291        };
292
293        self.send_message(&msg).await
294    }
295
296    async fn start_thread(
297        &self,
298        channel_id: &str,
299        execution_id: &str,
300        workflow_id: &str,
301        total_steps: usize,
302        shadow: bool,
303    ) -> Result<String> {
304        let mode = if shadow { "shadow" } else { "live" };
305        let text = format!(
306            "🚀 *Workflow `{}` started* ({} mode, {} steps)\nExecution: `{}`",
307            workflow_id, mode, total_steps, execution_id
308        );
309
310        let blocks = serde_json::json!([
311            {
312                "type": "section",
313                "text": {
314                    "type": "mrkdwn",
315                    "text": text
316                }
317            }
318        ]);
319
320        let msg = OutgoingMessage {
321            channel_id: channel_id.to_string(),
322            text: format!("Workflow {} started ({})", workflow_id, mode),
323            thread_id: None,
324            blocks: Some(blocks),
325        };
326
327        // The returned message timestamp becomes the thread ID
328        self.send_message(&msg).await
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn test_slack_config_fields() {
338        let config = SlackConfig {
339            bot_token: "xoxb-test".into(),
340            signing_secret: Some("secret".into()),
341            default_channel: Some("#general".into()),
342        };
343        assert_eq!(config.bot_token, "xoxb-test");
344    }
345
346    #[test]
347    fn test_progress_bar_empty() {
348        let bar = SlackBot::progress_bar(0, 5);
349        assert!(bar.contains("░░░░░░░░░░"));
350        assert!(bar.contains("0/5"));
351    }
352
353    #[test]
354    fn test_progress_bar_half() {
355        let bar = SlackBot::progress_bar(5, 10);
356        assert!(bar.contains("█████"));
357        assert!(bar.contains("5/10"));
358    }
359
360    #[test]
361    fn test_progress_bar_full() {
362        let bar = SlackBot::progress_bar(10, 10);
363        assert!(bar.contains("██████████"));
364        assert!(bar.contains("10/10"));
365    }
366
367    #[test]
368    fn test_progress_bar_zero_total() {
369        let bar = SlackBot::progress_bar(0, 0);
370        assert!(bar.contains("0/0"));
371    }
372}