Skip to main content

opendev_tools_impl/
message.rs

1//! Message tool — send messages to external channels via webhooks.
2//!
3//! Supports Slack, Discord, and generic webhook channels.
4//! Channel configuration is loaded from `~/.opendev/settings.json`
5//! or `.opendev/settings.json`.
6
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
11
12/// Tool for sending messages to configured channels.
13#[derive(Debug)]
14pub struct MessageTool;
15
16#[async_trait::async_trait]
17impl BaseTool for MessageTool {
18    fn name(&self) -> &str {
19        "message"
20    }
21
22    fn description(&self) -> &str {
23        "Send a message to a configured channel (Slack, Discord, or generic webhook)."
24    }
25
26    fn parameter_schema(&self) -> serde_json::Value {
27        serde_json::json!({
28            "type": "object",
29            "properties": {
30                "channel": {
31                    "type": "string",
32                    "description": "Channel type: 'slack', 'discord', or 'webhook'"
33                },
34                "target": {
35                    "type": "string",
36                    "description": "Webhook URL (overrides configured default)"
37                },
38                "message": {
39                    "type": "string",
40                    "description": "Message content to send"
41                },
42                "format": {
43                    "type": "string",
44                    "description": "Message format: 'text' (default) or 'markdown'",
45                    "enum": ["text", "markdown"]
46                }
47            },
48            "required": ["channel", "message"]
49        })
50    }
51
52    async fn execute(
53        &self,
54        args: HashMap<String, serde_json::Value>,
55        _ctx: &ToolContext,
56    ) -> ToolResult {
57        let channel = match args.get("channel").and_then(|v| v.as_str()) {
58            Some(c) if !c.is_empty() => c,
59            _ => return ToolResult::fail("channel is required"),
60        };
61
62        let message = match args.get("message").and_then(|v| v.as_str()) {
63            Some(m) if !m.is_empty() => m,
64            _ => return ToolResult::fail("message is required"),
65        };
66
67        let target = args.get("target").and_then(|v| v.as_str());
68        let format = args
69            .get("format")
70            .and_then(|v| v.as_str())
71            .unwrap_or("text");
72
73        // Load channel config
74        let channel_config = load_channel_config();
75        let config_for_channel = channel_config.get(channel).and_then(|v| v.as_object());
76
77        // Determine webhook URL
78        let webhook_url = target.map(|t| t.to_string()).or_else(|| {
79            config_for_channel
80                .and_then(|c| c.get("webhook_url"))
81                .and_then(|v| v.as_str())
82                .map(|s| s.to_string())
83        });
84
85        let webhook_url = match webhook_url {
86            Some(url) if !url.is_empty() => url,
87            _ => {
88                return ToolResult::fail(format!(
89                    "No webhook URL configured for channel '{channel}'. \
90                     Set it in ~/.opendev/settings.json under channels.{channel}.webhook_url \
91                     or pass it as the 'target' parameter."
92                ));
93            }
94        };
95
96        if !webhook_url.starts_with("http://") && !webhook_url.starts_with("https://") {
97            return ToolResult::fail("Webhook URL must start with http:// or https://");
98        }
99
100        // Build payload based on channel type
101        let payload = build_payload(channel, message, format);
102
103        // Send the webhook
104        let client = match reqwest::Client::builder()
105            .connect_timeout(std::time::Duration::from_secs(5))
106            .timeout(std::time::Duration::from_secs(10))
107            .build()
108        {
109            Ok(c) => c,
110            Err(e) => return ToolResult::fail(format!("Failed to create HTTP client: {e}")),
111        };
112
113        match client
114            .post(&webhook_url)
115            .header("Content-Type", "application/json")
116            .json(&payload)
117            .send()
118            .await
119        {
120            Ok(response) => {
121                let status = response.status().as_u16();
122                if (200..300).contains(&status) {
123                    ToolResult::ok(format!("Message sent to {channel} (status {status})"))
124                } else {
125                    let body = response
126                        .text()
127                        .await
128                        .unwrap_or_else(|_| "unknown error".to_string());
129                    ToolResult::fail(format!("Webhook returned status {status}: {body}"))
130                }
131            }
132            Err(e) => ToolResult::fail(format!("Failed to send message: {e}")),
133        }
134    }
135
136    fn display_meta(&self) -> Option<ToolDisplayMeta> {
137        Some(ToolDisplayMeta {
138            verb: "Message",
139            label: "channel",
140            category: "Other",
141            primary_arg_keys: &["channel", "message"],
142        })
143    }
144}
145
146/// Build the webhook payload for a specific channel type.
147fn build_payload(channel: &str, message: &str, format: &str) -> serde_json::Value {
148    match channel {
149        "slack" => {
150            if format == "markdown" {
151                serde_json::json!({
152                    "blocks": [{
153                        "type": "section",
154                        "text": {
155                            "type": "mrkdwn",
156                            "text": message
157                        }
158                    }]
159                })
160            } else {
161                serde_json::json!({ "text": message })
162            }
163        }
164        "discord" => {
165            serde_json::json!({ "content": message })
166        }
167        _ => {
168            // Generic webhook
169            serde_json::json!({
170                "text": message,
171                "format": format
172            })
173        }
174    }
175}
176
177/// Load channel configuration from settings files.
178fn load_channel_config() -> HashMap<String, serde_json::Value> {
179    let mut channels = HashMap::new();
180
181    let config_paths = [
182        dirs::home_dir().map(|h| h.join(".opendev").join("settings.json")),
183        Some(PathBuf::from(".opendev").join("settings.json")),
184    ];
185
186    for path in config_paths.iter().flatten() {
187        if path.exists()
188            && let Ok(content) = std::fs::read_to_string(path)
189            && let Ok(data) = serde_json::from_str::<serde_json::Value>(&content)
190            && let Some(ch) = data.get("channels").and_then(|v| v.as_object())
191        {
192            for (key, value) in ch {
193                channels.insert(key.clone(), value.clone());
194            }
195        }
196    }
197
198    channels
199}
200
201#[cfg(test)]
202#[path = "message_tests.rs"]
203mod tests;