Skip to main content

mur_chat/
discord.rs

1//! Discord bot integration — Bot API, prefix commands, slash commands, reaction-based approval.
2
3use crate::platform::{
4    ApprovalRequest, ChatPlatform, OutgoingMessage, ProgressStatus, ProgressUpdate,
5    WorkflowNotification,
6};
7use anyhow::{Context, Result};
8use serde::Deserialize;
9use std::collections::HashMap;
10
11/// Discord bot configuration.
12#[derive(Debug, Clone)]
13pub struct DiscordConfig {
14    /// Bot token from Discord Developer Portal.
15    pub bot_token: String,
16    /// Application ID for slash command registration.
17    pub application_id: String,
18    /// Default channel ID for notifications.
19    pub default_channel_id: Option<String>,
20    /// Command prefix for text commands (default: "!").
21    pub command_prefix: String,
22}
23
24impl DiscordConfig {
25    /// Create from environment variables.
26    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
43/// Discord bot client.
44pub struct DiscordBot {
45    config: DiscordConfig,
46    client: reqwest::Client,
47}
48
49/// Discord API response for message operations.
50#[derive(Deserialize)]
51struct DiscordMessage {
52    id: String,
53}
54
55/// Parsed Discord command from user message.
56///
57/// NOTE: This enum is nearly identical to `TelegramCommand` and `ChatCommand`.
58/// A shared command enum should be considered to reduce duplication.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum DiscordCommand {
61    /// !run <workflow> [--shadow] [--var key=value]
62    Run {
63        workflow: String,
64        shadow: bool,
65        variables: HashMap<String, String>,
66    },
67    /// !workflows — list available workflows
68    Workflows,
69    /// !audit [workflow_id] — show audit log
70    Audit { workflow_id: Option<String> },
71    /// !status — show daemon status
72    Status,
73    /// !help — show help text
74    Help,
75    /// Unknown command
76    Unknown { text: String },
77}
78
79/// Slash command definition for registration.
80#[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/// Slash command option.
89#[derive(Debug, Clone, serde::Serialize)]
90pub struct SlashCommandOption {
91    pub name: String,
92    pub description: String,
93    /// 3 = string, 5 = boolean
94    #[serde(rename = "type")]
95    pub option_type: u8,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub required: Option<bool>,
98}
99
100/// Reaction type for approval tracking.
101#[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    /// Base URL for Discord API.
117    ///
118    /// NOTE: The API version "v10" is hardcoded. Consider making it configurable
119    /// or defining it as a constant for easier upgrades.
120    fn api_url(path: &str) -> String {
121        format!("https://discord.com/api/v10{}", path)
122    }
123
124    /// Make an authenticated request to the Discord API.
125    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        // Some endpoints (like add_reaction) return 204 No Content
155        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    /// Parse a prefix-based command (!run, !workflows, etc.).
163    pub fn parse_command(&self, text: &str) -> DiscordCommand {
164        Self::parse_command_with_prefix(text, &self.config.command_prefix)
165    }
166
167    /// Parse a command with a given prefix.
168    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                // NOTE: Discord uses `--var=key=value` syntax (single token),
189                // while command.rs uses `--var key=value` (two tokens). This
190                // inconsistency should be unified in a future refactor.
191                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    /// Build the list of slash commands for registration.
232    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, // STRING
242                        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, // BOOLEAN
248                        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    /// Register slash commands with Discord for a guild (or globally).
261    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    /// Interpret a reaction emoji as an approval decision.
288    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    /// Build a Discord embed for an approval request.
299    fn approval_embed(request: &ApprovalRequest) -> serde_json::Value {
300        serde_json::json!({
301            "embeds": [{
302                "title": "⚠️ Approval Required",
303                "color": 16753920,  // Orange
304                "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        // If thread_id is set, post to the thread channel instead
347        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        // Pre-add reaction emojis to make it easy for users
375        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        // URL-encode the emoji for the API path
398        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        // First send a message to get a message ID
511        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        // Create a thread from the message
526        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}