Skip to main content

capsula_notify_slack/
lib.rs

1mod error;
2use crate::error::SlackNotifyError;
3use capsula_core::captured::Captured;
4use capsula_core::error::CapsulaResult;
5use capsula_core::hook::{Hook, PostRun, PreRun, RuntimeParams};
6use capsula_core::run::PreparedRun;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use std::path::{Path, PathBuf};
10use tracing::{debug, warn};
11
12/// Maximum number of files that can be attached to a Slack message (API limit)
13const MAX_SLACK_ATTACHMENTS: usize = 10;
14
15/// Configuration for the Slack notification hook
16///
17/// # Fields
18/// * `channel` - The Slack channel to send notifications to (e.g., "#random")
19/// * `token` - The Slack bot token. If not provided, will read from `SLACK_BOT_TOKEN` environment variable
20/// * `attachment_globs` - Optional list of glob patterns to match files for attachment (up to 10 files)
21///
22/// # Example
23/// ```toml
24/// [[post_run.hooks]]
25/// id = "notify-slack"
26/// channel = "#random"
27/// attachment_globs = ["*.png", "outputs/*.jpg"]
28/// ```
29#[derive(Debug, Clone, Deserialize, Serialize)]
30pub struct SlackNotifyHookConfig {
31    channel: String,
32    #[serde(default = "token_from_env")]
33    token: String,
34    #[serde(default)]
35    attachment_globs: Vec<String>,
36}
37
38fn token_from_env() -> String {
39    std::env::var("SLACK_BOT_TOKEN").unwrap_or_default()
40}
41
42/// Build Slack Block Kit blocks for a run notification
43///
44/// This is a pure function that constructs the rich-formatted Slack message blocks.
45/// It creates a structured message with header, divider, and run details section.
46///
47/// # Arguments
48/// * `header_text` - The header text to display (e.g., "🚀 Capsula Run Starting")
49/// * `run_name` - The name of the run
50/// * `run_id` - The unique ID of the run
51/// * `timestamp_unix` - Unix timestamp for Slack's date formatting
52/// * `timestamp_rfc3339` - RFC3339 formatted timestamp as fallback
53/// * `command_display` - The formatted command string to display
54fn build_slack_blocks(
55    header_text: &str,
56    run_name: &str,
57    run_id: impl std::fmt::Display,
58    timestamp_unix: i64,
59    timestamp_rfc3339: &str,
60    command_display: &str,
61) -> Vec<serde_json::Value> {
62    vec![
63        // Header
64        json!({
65            "type": "header",
66            "text": {
67                "type": "plain_text",
68                "text": header_text,
69                "emoji": true
70            }
71        }),
72        // Divider
73        json!({
74            "type": "divider"
75        }),
76        // Run details
77        json!({
78            "type": "section",
79            "fields": [
80                {
81                    "type": "mrkdwn",
82                    "text": format!("*Run Name:*\n{}", run_name)
83                },
84                {
85                    "type": "mrkdwn",
86                    "text": format!("*Run ID:*\n`{}`", run_id)
87                },
88                {
89                    "type": "mrkdwn",
90                    "text": format!("*Timestamp:*\n<!date^{}^{{date_num}} {{time_secs}}|{}>",
91                        timestamp_unix, timestamp_rfc3339)
92                },
93                {
94                    "type": "mrkdwn",
95                    "text": format!("*Command:*\n```{}```", command_display)
96                }
97            ]
98        }),
99    ]
100}
101
102/// Resolve glob patterns and collect matching file paths
103/// Globs are resolved relative to the provided base directory
104#[doc(hidden)]
105pub fn resolve_attachment_globs(
106    globs: &[String],
107    base_dir: &Path,
108) -> Result<Vec<PathBuf>, SlackNotifyError> {
109    debug!(
110        "Resolving {} attachment glob patterns from base: {}",
111        globs.len(),
112        base_dir.display()
113    );
114
115    let mut files = Vec::new();
116
117    for pattern in globs {
118        debug!("Processing glob pattern: {}", pattern);
119        // Resolve pattern relative to base_dir
120        let full_pattern = base_dir.join(pattern);
121        let pattern_str = full_pattern
122            .to_str()
123            .ok_or_else(|| SlackNotifyError::GlobPattern {
124                pattern: pattern.clone(),
125                source: glob::PatternError {
126                    pos: 0,
127                    msg: "Invalid UTF-8 in path",
128                },
129            })?;
130
131        // Execute glob
132        let paths = glob::glob(pattern_str).map_err(|e| SlackNotifyError::GlobPattern {
133            pattern: pattern.clone(),
134            source: e,
135        })?;
136
137        // Collect successful matches, skip errors
138        let mut pattern_matches = 0;
139        for entry in paths {
140            match entry {
141                Ok(path) if path.is_file() => {
142                    files.push(path);
143                    pattern_matches += 1;
144                }
145                Ok(_) | Err(_) => {} // Skip directories and glob errors (e.g., permission denied)
146            }
147        }
148        debug!("Pattern '{}' matched {} files", pattern, pattern_matches);
149    }
150
151    // Limit to MAX_SLACK_ATTACHMENTS files (Slack API limit)
152    let original_count = files.len();
153    files.truncate(MAX_SLACK_ATTACHMENTS);
154
155    if original_count > MAX_SLACK_ATTACHMENTS {
156        debug!(
157            "Truncated attachment list from {} to {} files (Slack API limit)",
158            original_count, MAX_SLACK_ATTACHMENTS
159        );
160    }
161
162    debug!("Total files resolved: {}", files.len());
163    Ok(files)
164}
165
166/// Upload a single file to Slack and return its `file_id`
167fn upload_file_to_slack(
168    client: &reqwest::blocking::Client,
169    token: &str,
170    path: &Path,
171) -> Result<(String, String), SlackNotifyError> {
172    let file_name = path
173        .file_name()
174        .and_then(|n| n.to_str())
175        .unwrap_or("file")
176        .to_string();
177
178    debug!("Reading file content: {}", path.display());
179    let file_content = std::fs::read(path).map_err(|e| SlackNotifyError::FileIo {
180        path: path.display().to_string(),
181        source: e,
182    })?;
183
184    let file_size = file_content.len();
185    debug!("File size: {} bytes", file_size);
186
187    // Get upload URL (must use form parameters, not JSON)
188    debug!("Requesting upload URL from Slack for file: {}", file_name);
189    let form = reqwest::blocking::multipart::Form::new()
190        .text("filename", file_name.clone())
191        .text("length", file_size.to_string());
192
193    let upload_url_res = client
194        .post("https://slack.com/api/files.getUploadURLExternal")
195        .bearer_auth(token)
196        .multipart(form)
197        .send()
198        .map_err(SlackNotifyError::from)?;
199
200    let upload_url_json: serde_json::Value =
201        upload_url_res.json().map_err(SlackNotifyError::from)?;
202
203    debug!("Received upload URL response");
204
205    if !upload_url_json
206        .get("ok")
207        .and_then(serde_json::Value::as_bool)
208        .unwrap_or(false)
209    {
210        let error_msg = upload_url_json
211            .get("error")
212            .and_then(|v| v.as_str())
213            .unwrap_or("unknown error");
214
215        // Provide helpful message for missing_scope error
216        let message = if error_msg == "missing_scope" {
217            "Failed to upload file: missing_scope. The Slack bot needs the 'files:write' OAuth scope. \
218             Please add this scope in your Slack app settings under OAuth & Permissions."
219                .to_string()
220        } else {
221            format!("Failed to get upload URL: {error_msg}")
222        };
223
224        return Err(SlackNotifyError::SlackApi { message });
225    }
226
227    let upload_url = upload_url_json
228        .get("upload_url")
229        .and_then(|v| v.as_str())
230        .ok_or_else(|| SlackNotifyError::SlackApi {
231            message: "Missing upload_url in response".to_string(),
232        })?;
233
234    let file_id = upload_url_json
235        .get("file_id")
236        .and_then(|v| v.as_str())
237        .ok_or_else(|| SlackNotifyError::SlackApi {
238            message: "Missing file_id in response".to_string(),
239        })?;
240
241    // Upload file to the URL
242    debug!("Uploading file content to URL (file_id: {})", file_id);
243    client
244        .post(upload_url)
245        .body(file_content)
246        .send()
247        .map_err(SlackNotifyError::from)?;
248
249    debug!("File upload completed successfully");
250    Ok((file_id.to_string(), file_name))
251}
252
253/// Send a simple message to Slack without attachments using Block Kit
254/// Returns the response JSON and optionally the thread timestamp
255fn send_simple_message(
256    client: &reqwest::blocking::Client,
257    token: &str,
258    channel: &str,
259    text: &str,
260    blocks: &[serde_json::Value],
261    thread_ts: Option<&str>,
262) -> Result<(String, Option<String>), SlackNotifyError> {
263    debug!(
264        "Sending simple message to channel: {} (thread: {})",
265        channel,
266        thread_ts.unwrap_or("none")
267    );
268
269    let mut payload = json!({
270        "channel": channel,
271        "text": text,
272        "blocks": blocks,
273    });
274
275    // Add thread_ts if provided
276    if let Some(ts) = thread_ts {
277        payload["thread_ts"] = json!(ts);
278    }
279
280    let res = client
281        .post("https://slack.com/api/chat.postMessage")
282        .bearer_auth(token)
283        .json(&payload)
284        .send()
285        .map_err(SlackNotifyError::from)?;
286
287    debug!("Received response from chat.postMessage");
288
289    if !res.status().is_success() {
290        return Err(SlackNotifyError::SlackApi {
291            message: format!("Failed to send Slack notification: {}", res.status()),
292        });
293    }
294
295    let response_text = res.text().unwrap_or_else(|_| String::from("{}"));
296
297    // Extract the timestamp from the response for threading
298    let ts = serde_json::from_str::<serde_json::Value>(&response_text)
299        .ok()
300        .and_then(|v| v.get("ts").and_then(|t| t.as_str()).map(String::from));
301
302    Ok((response_text, ts))
303}
304
305/// Send a message with optional file attachments to Slack
306#[expect(
307    clippy::too_many_lines,
308    reason = "TODO: Refactor into smaller functions"
309)]
310fn send_slack_message(
311    token: &str,
312    channel: &str,
313    text: &str,
314    blocks: &[serde_json::Value],
315    attachment_paths: &[PathBuf],
316    run_name: &str,
317) -> Result<(String, Vec<String>), SlackNotifyError> {
318    tracing::debug!("Sending Slack message to channel: {}", channel);
319    tracing::debug!("Attachment paths: {} files", attachment_paths.len());
320
321    let client = reqwest::blocking::Client::new();
322
323    // If no attachments, use simple chat.postMessage
324    if attachment_paths.is_empty() {
325        tracing::debug!("No attachments, sending simple message");
326        let (response, _ts) = send_simple_message(&client, token, channel, text, blocks, None)?;
327        return Ok((response, Vec::new()));
328    }
329
330    // With attachments, use the new Slack files API workflow:
331    // 1. Get upload URLs
332    // 2. Upload files to those URLs
333    // 3. Complete the upload and share to channel
334
335    let mut file_ids = Vec::new();
336    let mut attached_files = Vec::new();
337
338    // Step 1 & 2: Get upload URL and upload each file
339    tracing::debug!("Uploading {} files to Slack", attachment_paths.len());
340    for path in attachment_paths {
341        tracing::debug!("Uploading file: {}", path.display());
342        let (file_id, file_name) = upload_file_to_slack(&client, token, path)?;
343        tracing::debug!(
344            "File uploaded successfully: {} (ID: {})",
345            file_name,
346            file_id
347        );
348        file_ids.push(file_id);
349        attached_files.push(file_name);
350    }
351
352    // Step 4: Send the main message with blocks first
353    tracing::debug!("Sending main message with blocks");
354    let (message_response, thread_ts) =
355        send_simple_message(&client, token, channel, text, blocks, None)?;
356
357    // Step 5: Share files in thread with initial_comment and thread_ts
358    let ts = thread_ts.ok_or_else(|| SlackNotifyError::SlackApi {
359        message: "Failed to get timestamp from message response".to_string(),
360    })?;
361    tracing::debug!("Main message sent, thread timestamp: {}", ts);
362
363    let files_json = serde_json::to_string(
364        &file_ids
365            .iter()
366            .map(|id| json!({"id": id}))
367            .collect::<Vec<_>>(),
368    )
369    .map_err(SlackNotifyError::Serialization)?;
370
371    // Complete upload WITHOUT sharing to channel - just finalize the upload
372    // This way the file is uploaded but not posted anywhere yet
373    tracing::debug!("Completing file upload without sharing to channel");
374    let complete_form = reqwest::blocking::multipart::Form::new().text("files", files_json);
375
376    let complete_res = client
377        .post("https://slack.com/api/files.completeUploadExternal")
378        .bearer_auth(token)
379        .multipart(complete_form)
380        .send()
381        .map_err(SlackNotifyError::from)?;
382
383    if !complete_res.status().is_success() {
384        return Err(SlackNotifyError::SlackApi {
385            message: format!("Failed to share files in thread: {}", complete_res.status()),
386        });
387    }
388
389    let complete_json: serde_json::Value = complete_res.json().map_err(SlackNotifyError::from)?;
390
391    if !complete_json
392        .get("ok")
393        .and_then(serde_json::Value::as_bool)
394        .unwrap_or(false)
395    {
396        let error_msg = complete_json
397            .get("error")
398            .and_then(|v| v.as_str())
399            .unwrap_or("unknown error");
400        return Err(SlackNotifyError::SlackApi {
401            message: format!("Failed to share files in thread: {error_msg}"),
402        });
403    }
404
405    // Step 6: Extract file permalinks from response for broadcast message
406    tracing::debug!("Extracting file permalinks from response");
407    let mut file_permalinks = Vec::new();
408
409    if let Some(files_info) = complete_json.get("files").and_then(|v| v.as_array()) {
410        for file in files_info {
411            if let Some(permalink) = file.get("permalink").and_then(|v| v.as_str()) {
412                tracing::debug!("Found permalink: {}", permalink);
413                file_permalinks.push(permalink.to_string());
414            }
415        }
416    }
417    tracing::debug!("Extracted {} permalinks", file_permalinks.len());
418
419    // Step 7: Post a broadcast message with file links
420    tracing::debug!(
421        "Posting broadcast message with file links for run: {}",
422        run_name
423    );
424    let file_links_text = if file_permalinks.is_empty() {
425        format!(
426            "📎 `{}`: {} file(s) attached",
427            run_name,
428            attached_files.len()
429        )
430    } else {
431        let links = file_permalinks
432            .iter()
433            .zip(&attached_files)
434            .map(|(link, name)| format!("<{link}|{name}>"))
435            .collect::<Vec<_>>()
436            .join("\n");
437        format!("📎 Attachments (Run Name: `{run_name}`):\n{links}")
438    };
439
440    let broadcast_payload = json!({
441        "channel": channel,
442        "thread_ts": ts,
443        "reply_broadcast": true,
444        "text": file_links_text,
445        "unfurl_links": true,
446        "unfurl_media": true,
447    });
448
449    let broadcast_res = client
450        .post("https://slack.com/api/chat.postMessage")
451        .bearer_auth(token)
452        .json(&broadcast_payload)
453        .send()
454        .map_err(SlackNotifyError::from)?;
455
456    if broadcast_res.status().is_success() {
457        debug!("Broadcast message sent successfully");
458    } else {
459        // Don't fail the whole operation if broadcast fails
460        warn!(
461            "Warning: Failed to broadcast file message: {}",
462            broadcast_res.status()
463        );
464    }
465
466    Ok((message_response, attached_files))
467}
468
469#[derive(Debug)]
470pub struct SlackNotifyHook {
471    config: SlackNotifyHookConfig,
472    project_root: PathBuf,
473}
474
475#[derive(Debug, Serialize)]
476pub struct SlackNotifyCaptured {
477    message: String,
478    response: Option<String>,
479    #[serde(skip_serializing_if = "Vec::is_empty")]
480    attached_files: Vec<String>,
481}
482
483impl Captured for SlackNotifyCaptured {
484    fn serialize_json(&self) -> Result<serde_json::Value, serde_json::Error> {
485        serde_json::to_value(self)
486    }
487}
488
489impl Hook<PreRun> for SlackNotifyHook {
490    const ID: &'static str = "notify-slack";
491
492    type Config = SlackNotifyHookConfig;
493    type Output = SlackNotifyCaptured;
494
495    fn from_config(
496        config: &serde_json::Value,
497        project_root: &std::path::Path,
498    ) -> CapsulaResult<Self> {
499        let config = serde_json::from_value::<SlackNotifyHookConfig>(config.clone())?;
500        if config.token.is_empty() {
501            return Err(SlackNotifyError::MissingToken.into());
502        }
503        Ok(Self {
504            config,
505            project_root: project_root.to_path_buf(),
506        })
507    }
508
509    fn config(&self) -> &Self::Config {
510        &self.config
511    }
512
513    fn run(
514        &self,
515        metadata: &PreparedRun,
516        _params: &RuntimeParams<PreRun>,
517    ) -> CapsulaResult<Self::Output> {
518        debug!(
519            "SlackNotifyHook (PreRun): Sending notification for run '{}' (ID: {})",
520            metadata.name, metadata.id
521        );
522
523        // Create fallback text for notifications
524        let fallback_text = format!(
525            "Run `{}` (ID: `{}`) is starting.",
526            metadata.name, metadata.id
527        );
528
529        // Format command for display
530        let command_display = shlex::try_join(metadata.command.iter().map(String::as_str))
531            .unwrap_or_else(|_| metadata.command.join(" "));
532
533        // Build Block Kit message
534        let blocks = build_slack_blocks(
535            "🚀 Capsula Run Starting",
536            &metadata.name,
537            metadata.id,
538            metadata.timestamp().timestamp(),
539            &metadata.timestamp().to_rfc3339(),
540            &command_display,
541        );
542
543        // Resolve attachment globs relative to project root
544        let attachment_paths =
545            resolve_attachment_globs(&self.config.attachment_globs, &self.project_root)?;
546
547        // Send message with attachments
548        let (response, attached_files) = send_slack_message(
549            &self.config.token,
550            &self.config.channel,
551            &fallback_text,
552            &blocks,
553            &attachment_paths,
554            &metadata.name,
555        )?;
556
557        debug!(
558            "SlackNotifyHook (PreRun): Notification sent successfully with {} attachments",
559            attached_files.len()
560        );
561
562        Ok(SlackNotifyCaptured {
563            message: "Slack notification sent successfully".to_string(),
564            response: Some(response),
565            attached_files,
566        })
567    }
568}
569
570impl Hook<PostRun> for SlackNotifyHook {
571    const ID: &'static str = "notify-slack";
572
573    type Config = SlackNotifyHookConfig;
574    type Output = SlackNotifyCaptured;
575
576    fn from_config(
577        config: &serde_json::Value,
578        project_root: &std::path::Path,
579    ) -> CapsulaResult<Self> {
580        let config = serde_json::from_value::<SlackNotifyHookConfig>(config.clone())?;
581        if config.token.is_empty() {
582            return Err(SlackNotifyError::MissingToken.into());
583        }
584        Ok(Self {
585            config,
586            project_root: project_root.to_path_buf(),
587        })
588    }
589
590    fn config(&self) -> &Self::Config {
591        &self.config
592    }
593
594    fn run(
595        &self,
596        metadata: &PreparedRun,
597        _params: &RuntimeParams<PostRun>,
598    ) -> CapsulaResult<Self::Output> {
599        debug!(
600            "SlackNotifyHook (PostRun): Sending notification for run '{}' (ID: {})",
601            metadata.name, metadata.id
602        );
603
604        // Create fallback text for notifications
605        let fallback_text = format!(
606            "Run `{}` (ID: `{}`) has completed.",
607            metadata.name, metadata.id
608        );
609
610        // Format command for display
611        let command_display = shlex::try_join(metadata.command.iter().map(String::as_str))
612            .unwrap_or_else(|_| metadata.command.join(" "));
613
614        // Build Block Kit message
615        let blocks = build_slack_blocks(
616            "✅ Capsula Run Completed",
617            &metadata.name,
618            metadata.id,
619            metadata.timestamp().timestamp(),
620            &metadata.timestamp().to_rfc3339(),
621            &command_display,
622        );
623
624        // Resolve attachment globs relative to project root
625        let attachment_paths =
626            resolve_attachment_globs(&self.config.attachment_globs, &self.project_root)?;
627
628        // Send message with attachments
629        let (response, attached_files) = send_slack_message(
630            &self.config.token,
631            &self.config.channel,
632            &fallback_text,
633            &blocks,
634            &attachment_paths,
635            &metadata.name,
636        )?;
637
638        debug!(
639            "SlackNotifyHook (PostRun): Notification sent successfully with {} attachments",
640            attached_files.len()
641        );
642
643        Ok(SlackNotifyCaptured {
644            message: "Slack notification sent successfully".to_string(),
645            response: Some(response),
646            attached_files,
647        })
648    }
649}