1use crate::platform::{
4 ApprovalRequest, ChatPlatform, OutgoingMessage, ProgressStatus, ProgressUpdate,
5 WorkflowNotification,
6};
7use anyhow::{Context, Result};
8use serde::Deserialize;
9
10#[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 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
34pub 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 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 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 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}