1use crate::platform::{
4 ApprovalRequest, ChatPlatform, OutgoingMessage, ProgressStatus, ProgressUpdate,
5 WorkflowNotification,
6};
7use anyhow::{Context, Result};
8use serde::Deserialize;
9use std::collections::HashMap;
10
11#[derive(Debug, Clone)]
13pub struct DiscordConfig {
14 pub bot_token: String,
16 pub application_id: String,
18 pub default_channel_id: Option<String>,
20 pub command_prefix: String,
22}
23
24impl DiscordConfig {
25 pub fn from_env() -> Result<Self> {
27 let bot_token =
28 std::env::var("DISCORD_BOT_TOKEN").context("DISCORD_BOT_TOKEN not set")?;
29 let application_id =
30 std::env::var("DISCORD_APPLICATION_ID").context("DISCORD_APPLICATION_ID not set")?;
31 let default_channel_id = std::env::var("DISCORD_CHANNEL_ID").ok();
32 let command_prefix = std::env::var("DISCORD_PREFIX").unwrap_or_else(|_| "!".to_string());
33
34 Ok(Self {
35 bot_token,
36 application_id,
37 default_channel_id,
38 command_prefix,
39 })
40 }
41}
42
43pub struct DiscordBot {
45 config: DiscordConfig,
46 client: reqwest::Client,
47}
48
49#[derive(Deserialize)]
51struct DiscordMessage {
52 id: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum DiscordCommand {
61 Run {
63 workflow: String,
64 shadow: bool,
65 variables: HashMap<String, String>,
66 },
67 Workflows,
69 Audit { workflow_id: Option<String> },
71 Status,
73 Help,
75 Unknown { text: String },
77}
78
79#[derive(Debug, Clone, serde::Serialize)]
81pub struct SlashCommand {
82 pub name: String,
83 pub description: String,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
85 pub options: Vec<SlashCommandOption>,
86}
87
88#[derive(Debug, Clone, serde::Serialize)]
90pub struct SlashCommandOption {
91 pub name: String,
92 pub description: String,
93 #[serde(rename = "type")]
95 pub option_type: u8,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 pub required: Option<bool>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum ApprovalReaction {
103 Approved,
104 Denied,
105 Unknown,
106}
107
108impl DiscordBot {
109 pub fn new(config: DiscordConfig) -> Self {
110 Self {
111 config,
112 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()),
113 }
114 }
115
116 fn api_url(path: &str) -> String {
121 format!("https://discord.com/api/v10{}", path)
122 }
123
124 async fn api_request(
126 &self,
127 method: reqwest::Method,
128 path: &str,
129 body: Option<serde_json::Value>,
130 ) -> Result<serde_json::Value> {
131 let url = Self::api_url(path);
132 let mut request = self
133 .client
134 .request(method, &url)
135 .header(
136 "Authorization",
137 format!("Bot {}", self.config.bot_token),
138 )
139 .header("Content-Type", "application/json");
140
141 if let Some(body) = body {
142 request = request.json(&body);
143 }
144
145 let response = request.send().await.context("Discord API request failed")?;
146
147 let status = response.status();
148 let text = response.text().await?;
149
150 if !status.is_success() {
151 anyhow::bail!("Discord API error ({}): {}", status, text);
152 }
153
154 if text.is_empty() {
156 return Ok(serde_json::Value::Null);
157 }
158
159 serde_json::from_str(&text).context("Failed to parse Discord API response")
160 }
161
162 pub fn parse_command(&self, text: &str) -> DiscordCommand {
164 Self::parse_command_with_prefix(text, &self.config.command_prefix)
165 }
166
167 pub fn parse_command_with_prefix(text: &str, prefix: &str) -> DiscordCommand {
169 let text = text.trim();
170
171 if !text.starts_with(prefix) {
172 return DiscordCommand::Unknown {
173 text: text.to_string(),
174 };
175 }
176
177 let without_prefix = &text[prefix.len()..];
178 let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect();
179 let cmd = parts[0].to_lowercase();
180 let args = parts.get(1).unwrap_or(&"").trim();
181
182 match cmd.as_str() {
183 "run" => {
184 let mut workflow = String::new();
185 let mut shadow = false;
186 let mut variables = HashMap::new();
187
188 for token in args.split_whitespace() {
192 if token == "--shadow" {
193 shadow = true;
194 } else if let Some(var) = token.strip_prefix("--var=") {
195 if let Some((k, v)) = var.split_once('=') {
196 variables.insert(k.to_string(), v.to_string());
197 }
198 } else if workflow.is_empty() {
199 workflow = token.to_string();
200 }
201 }
202
203 if workflow.is_empty() {
204 DiscordCommand::Unknown {
205 text: text.to_string(),
206 }
207 } else {
208 DiscordCommand::Run {
209 workflow,
210 shadow,
211 variables,
212 }
213 }
214 }
215 "workflows" | "wf" => DiscordCommand::Workflows,
216 "audit" => DiscordCommand::Audit {
217 workflow_id: if args.is_empty() {
218 None
219 } else {
220 Some(args.to_string())
221 },
222 },
223 "status" => DiscordCommand::Status,
224 "help" => DiscordCommand::Help,
225 _ => DiscordCommand::Unknown {
226 text: text.to_string(),
227 },
228 }
229 }
230
231 pub fn slash_commands() -> Vec<SlashCommand> {
233 vec![
234 SlashCommand {
235 name: "run".to_string(),
236 description: "Run a MUR workflow".to_string(),
237 options: vec![
238 SlashCommandOption {
239 name: "workflow".to_string(),
240 description: "Workflow name or ID to run".to_string(),
241 option_type: 3, required: Some(true),
243 },
244 SlashCommandOption {
245 name: "shadow".to_string(),
246 description: "Dry-run mode (no real execution)".to_string(),
247 option_type: 5, required: None,
249 },
250 ],
251 },
252 SlashCommand {
253 name: "workflows".to_string(),
254 description: "List available MUR workflows".to_string(),
255 options: vec![],
256 },
257 ]
258 }
259
260 pub async fn register_slash_commands(&self, guild_id: Option<&str>) -> Result<()> {
262 let commands = Self::slash_commands();
263
264 let path = match guild_id {
265 Some(gid) => format!(
266 "/applications/{}/guilds/{}/commands",
267 self.config.application_id, gid
268 ),
269 None => format!("/applications/{}/commands", self.config.application_id),
270 };
271
272 self.api_request(
273 reqwest::Method::PUT,
274 &path,
275 Some(serde_json::to_value(&commands)?),
276 )
277 .await?;
278
279 tracing::info!(
280 "Registered {} slash commands (guild={:?})",
281 commands.len(),
282 guild_id
283 );
284 Ok(())
285 }
286
287 pub fn parse_approval_reaction(emoji: &str) -> ApprovalReaction {
289 match emoji {
290 "\u{1f44d}" | "thumbsup" => ApprovalReaction::Approved,
291 "\u{1f44e}" | "thumbsdown" => ApprovalReaction::Denied,
292 "white_check_mark" | "\u{2705}" => ApprovalReaction::Approved,
293 "x" | "\u{274c}" => ApprovalReaction::Denied,
294 _ => ApprovalReaction::Unknown,
295 }
296 }
297
298 fn approval_embed(request: &ApprovalRequest) -> serde_json::Value {
300 serde_json::json!({
301 "embeds": [{
302 "title": "⚠️ Approval Required",
303 "color": 16753920, "fields": [
305 {
306 "name": "Step",
307 "value": format!("`{}`", request.step_name),
308 "inline": true
309 },
310 {
311 "name": "Execution",
312 "value": format!("`{}`", request.execution_id),
313 "inline": true
314 },
315 {
316 "name": "Description",
317 "value": &request.description,
318 "inline": false
319 },
320 {
321 "name": "Command",
322 "value": format!("`{}`", request.action),
323 "inline": false
324 }
325 ],
326 "footer": {
327 "text": "React with 👍 to approve or 👎 to deny"
328 }
329 }]
330 })
331 }
332}
333
334impl ChatPlatform for DiscordBot {
335 async fn send_message(&self, msg: &OutgoingMessage) -> Result<String> {
336 let mut body = serde_json::json!({
337 "content": msg.text,
338 });
339
340 if let Some(ref blocks) = msg.blocks {
341 if let Some(embeds) = blocks.get("embeds") {
342 body["embeds"] = embeds.clone();
343 }
344 }
345
346 let channel_id = msg.thread_id.as_deref().unwrap_or(&msg.channel_id);
348 let path = format!("/channels/{}/messages", channel_id);
349 let response = self
350 .api_request(reqwest::Method::POST, &path, Some(body))
351 .await?;
352
353 let msg_response: DiscordMessage =
354 serde_json::from_value(response).context("Failed to parse message response")?;
355
356 Ok(msg_response.id)
357 }
358
359 async fn send_approval(&self, channel_id: &str, request: &ApprovalRequest) -> Result<String> {
360 let embed = Self::approval_embed(request);
361
362 let msg = OutgoingMessage {
363 channel_id: channel_id.to_string(),
364 text: format!(
365 "**Approval Required** for step `{}` — react 👍 to approve, 👎 to deny",
366 request.step_name
367 ),
368 thread_id: None,
369 blocks: Some(embed),
370 };
371
372 let message_id = self.send_message(&msg).await?;
373
374 let _ = self
376 .add_reaction(channel_id, &message_id, "👍")
377 .await;
378 let _ = self
379 .add_reaction(channel_id, &message_id, "👎")
380 .await;
381
382 Ok(message_id)
383 }
384
385 async fn update_message(&self, channel_id: &str, message_id: &str, text: &str) -> Result<()> {
386 let path = format!("/channels/{}/messages/{}", channel_id, message_id);
387 self.api_request(
388 reqwest::Method::PATCH,
389 &path,
390 Some(serde_json::json!({ "content": text })),
391 )
392 .await?;
393 Ok(())
394 }
395
396 async fn add_reaction(&self, channel_id: &str, message_id: &str, emoji: &str) -> Result<()> {
397 let encoded_emoji = urlencoding::encode(emoji);
399 let path = format!(
400 "/channels/{}/messages/{}/reactions/{}/@me",
401 channel_id, message_id, encoded_emoji
402 );
403 self.api_request(reqwest::Method::PUT, &path, None).await?;
404 Ok(())
405 }
406
407 async fn send_progress(
408 &self,
409 channel_id: &str,
410 thread_id: &str,
411 progress: &ProgressUpdate,
412 ) -> Result<String> {
413 let icon = match progress.status {
414 ProgressStatus::Started => "🚀",
415 ProgressStatus::StepRunning => "⏳",
416 ProgressStatus::StepDone => "✅",
417 ProgressStatus::StepFailed => "❌",
418 ProgressStatus::Completed => "🎉",
419 ProgressStatus::Failed => "💥",
420 };
421
422 let duration = progress
423 .duration_ms
424 .map(|ms| format!(" ({}ms)", ms))
425 .unwrap_or_default();
426
427 let text = format!(
428 "{} [{}/{}] `{}`{}",
429 icon,
430 progress.step_index + 1,
431 progress.total_steps,
432 progress.step_name,
433 duration
434 );
435
436 let msg = OutgoingMessage {
437 channel_id: channel_id.to_string(),
438 text,
439 thread_id: Some(thread_id.to_string()),
440 blocks: None,
441 };
442
443 self.send_message(&msg).await
444 }
445
446 async fn send_notification(
447 &self,
448 channel_id: &str,
449 thread_id: Option<&str>,
450 notification: &WorkflowNotification,
451 ) -> Result<String> {
452 let (emoji, title) = if notification.success {
453 ("✅", "Workflow Completed")
454 } else {
455 ("❌", "Workflow Failed")
456 };
457
458 let error_line = notification
459 .error
460 .as_ref()
461 .map(|e| format!("\nError: `{}`", e))
462 .unwrap_or_default();
463
464 let text = format!(
465 "{} **{}**\nWorkflow: `{}`\nSteps: {}/{}\nDuration: {}ms{}",
466 emoji,
467 title,
468 notification.workflow_id,
469 notification.steps_completed,
470 notification.total_steps,
471 notification.duration_ms,
472 error_line,
473 );
474
475 let embed = serde_json::json!({
476 "embeds": [{
477 "title": format!("{} {}", emoji, title),
478 "color": if notification.success { 5763719 } else { 15548997 },
479 "description": format!(
480 "Workflow: `{}`\nSteps: {}/{}\nDuration: {}ms{}",
481 notification.workflow_id,
482 notification.steps_completed,
483 notification.total_steps,
484 notification.duration_ms,
485 error_line,
486 )
487 }]
488 });
489
490 let msg = OutgoingMessage {
491 channel_id: channel_id.to_string(),
492 text,
493 thread_id: thread_id.map(String::from),
494 blocks: Some(embed),
495 };
496
497 self.send_message(&msg).await
498 }
499
500 async fn start_thread(
501 &self,
502 channel_id: &str,
503 execution_id: &str,
504 workflow_id: &str,
505 total_steps: usize,
506 shadow: bool,
507 ) -> Result<String> {
508 let mode = if shadow { "Shadow" } else { "Live" };
509
510 let msg = OutgoingMessage {
512 channel_id: channel_id.to_string(),
513 text: format!(
514 "{} Running: `{}` ({} steps)\nExecution: `{}`",
515 if shadow { "\u{1f47b}" } else { "\u{25b6}\u{fe0f}" },
516 workflow_id,
517 total_steps,
518 execution_id
519 ),
520 thread_id: None,
521 blocks: None,
522 };
523 let message_id = self.send_message(&msg).await?;
524
525 let path = format!("/channels/{}/messages/{}/threads", channel_id, message_id);
527 let thread_body = serde_json::json!({
528 "name": format!("{} {} ({})", mode, workflow_id, &execution_id[..8.min(execution_id.len())]),
529 "auto_archive_duration": 1440,
530 });
531
532 let response = self
533 .api_request(reqwest::Method::POST, &path, Some(thread_body))
534 .await?;
535 let thread_id = response
536 .get("id")
537 .and_then(|v| v.as_str())
538 .unwrap_or(&message_id)
539 .to_string();
540
541 Ok(thread_id)
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_discord_config_fields() {
551 let config = DiscordConfig {
552 bot_token: "test-token".into(),
553 application_id: "123456".into(),
554 default_channel_id: Some("789".into()),
555 command_prefix: "!".into(),
556 };
557 assert_eq!(config.bot_token, "test-token");
558 assert_eq!(config.application_id, "123456");
559 }
560
561 #[test]
562 fn test_parse_run_command() {
563 let cmd = DiscordBot::parse_command_with_prefix("!run deploy --shadow", "!");
564 assert_eq!(
565 cmd,
566 DiscordCommand::Run {
567 workflow: "deploy".into(),
568 shadow: true,
569 variables: HashMap::new(),
570 }
571 );
572 }
573
574 #[test]
575 fn test_parse_run_with_vars() {
576 let cmd = DiscordBot::parse_command_with_prefix("!run deploy --var=env=prod", "!");
577 match cmd {
578 DiscordCommand::Run {
579 workflow,
580 shadow,
581 variables,
582 } => {
583 assert_eq!(workflow, "deploy");
584 assert!(!shadow);
585 assert_eq!(variables.get("env").unwrap(), "prod");
586 }
587 _ => panic!("Expected Run command"),
588 }
589 }
590
591 #[test]
592 fn test_parse_workflows_command() {
593 assert_eq!(
594 DiscordBot::parse_command_with_prefix("!workflows", "!"),
595 DiscordCommand::Workflows
596 );
597 assert_eq!(
598 DiscordBot::parse_command_with_prefix("!wf", "!"),
599 DiscordCommand::Workflows
600 );
601 }
602
603 #[test]
604 fn test_parse_audit_command() {
605 assert_eq!(
606 DiscordBot::parse_command_with_prefix("!audit", "!"),
607 DiscordCommand::Audit { workflow_id: None }
608 );
609 assert_eq!(
610 DiscordBot::parse_command_with_prefix("!audit my-workflow", "!"),
611 DiscordCommand::Audit {
612 workflow_id: Some("my-workflow".into())
613 }
614 );
615 }
616
617 #[test]
618 fn test_parse_status_command() {
619 assert_eq!(
620 DiscordBot::parse_command_with_prefix("!status", "!"),
621 DiscordCommand::Status
622 );
623 }
624
625 #[test]
626 fn test_parse_help_command() {
627 assert_eq!(
628 DiscordBot::parse_command_with_prefix("!help", "!"),
629 DiscordCommand::Help
630 );
631 }
632
633 #[test]
634 fn test_parse_unknown_command() {
635 let cmd = DiscordBot::parse_command_with_prefix("!foobar", "!");
636 assert!(matches!(cmd, DiscordCommand::Unknown { .. }));
637 }
638
639 #[test]
640 fn test_parse_no_prefix() {
641 let cmd = DiscordBot::parse_command_with_prefix("hello world", "!");
642 assert!(matches!(cmd, DiscordCommand::Unknown { .. }));
643 }
644
645 #[test]
646 fn test_parse_custom_prefix() {
647 let cmd = DiscordBot::parse_command_with_prefix("$run deploy", "$");
648 assert_eq!(
649 cmd,
650 DiscordCommand::Run {
651 workflow: "deploy".into(),
652 shadow: false,
653 variables: HashMap::new(),
654 }
655 );
656 }
657
658 #[test]
659 fn test_approval_reaction_parsing() {
660 assert_eq!(
661 DiscordBot::parse_approval_reaction("👍"),
662 ApprovalReaction::Approved
663 );
664 assert_eq!(
665 DiscordBot::parse_approval_reaction("thumbsup"),
666 ApprovalReaction::Approved
667 );
668 assert_eq!(
669 DiscordBot::parse_approval_reaction("👎"),
670 ApprovalReaction::Denied
671 );
672 assert_eq!(
673 DiscordBot::parse_approval_reaction("thumbsdown"),
674 ApprovalReaction::Denied
675 );
676 assert_eq!(
677 DiscordBot::parse_approval_reaction("✅"),
678 ApprovalReaction::Approved
679 );
680 assert_eq!(
681 DiscordBot::parse_approval_reaction("❌"),
682 ApprovalReaction::Denied
683 );
684 assert_eq!(
685 DiscordBot::parse_approval_reaction("fire"),
686 ApprovalReaction::Unknown
687 );
688 }
689
690 #[test]
691 fn test_slash_commands() {
692 let commands = DiscordBot::slash_commands();
693 assert_eq!(commands.len(), 2);
694 assert_eq!(commands[0].name, "run");
695 assert_eq!(commands[1].name, "workflows");
696 assert_eq!(commands[0].options.len(), 2);
697 assert_eq!(commands[0].options[0].name, "workflow");
698 }
699
700 #[test]
701 fn test_approval_embed() {
702 let request = ApprovalRequest {
703 execution_id: "exec-123".into(),
704 step_name: "deploy".into(),
705 description: "Deploy to production".into(),
706 action: "docker compose up -d".into(),
707 allowed_approvers: Vec::new(),
708 };
709 let embed = DiscordBot::approval_embed(&request);
710 let embeds = embed["embeds"].as_array().unwrap();
711 assert_eq!(embeds.len(), 1);
712 assert_eq!(embeds[0]["title"], "⚠️ Approval Required");
713 assert_eq!(embeds[0]["fields"].as_array().unwrap().len(), 4);
714 }
715}