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
12const MAX_SLACK_ATTACHMENTS: usize = 10;
14
15#[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
42fn 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 json!({
65 "type": "header",
66 "text": {
67 "type": "plain_text",
68 "text": header_text,
69 "emoji": true
70 }
71 }),
72 json!({
74 "type": "divider"
75 }),
76 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#[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 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 let paths = glob::glob(pattern_str).map_err(|e| SlackNotifyError::GlobPattern {
133 pattern: pattern.clone(),
134 source: e,
135 })?;
136
137 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(_) => {} }
147 }
148 debug!("Pattern '{}' matched {} files", pattern, pattern_matches);
149 }
150
151 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
166fn 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 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 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 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
253fn 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 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 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#[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 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 let mut file_ids = Vec::new();
336 let mut attached_files = Vec::new();
337
338 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 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 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 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 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 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 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 let fallback_text = format!(
525 "Run `{}` (ID: `{}`) is starting.",
526 metadata.name, metadata.id
527 );
528
529 let command_display = shlex::try_join(metadata.command.iter().map(String::as_str))
531 .unwrap_or_else(|_| metadata.command.join(" "));
532
533 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 let attachment_paths =
545 resolve_attachment_globs(&self.config.attachment_globs, &self.project_root)?;
546
547 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 let fallback_text = format!(
606 "Run `{}` (ID: `{}`) has completed.",
607 metadata.name, metadata.id
608 );
609
610 let command_display = shlex::try_join(metadata.command.iter().map(String::as_str))
612 .unwrap_or_else(|_| metadata.command.join(" "));
613
614 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 let attachment_paths =
626 resolve_attachment_globs(&self.config.attachment_globs, &self.project_root)?;
627
628 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}