opendev_tools_impl/
message.rs1use std::collections::HashMap;
8use std::path::PathBuf;
9
10use opendev_tools_core::{BaseTool, ToolContext, ToolDisplayMeta, ToolResult};
11
12#[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 let channel_config = load_channel_config();
75 let config_for_channel = channel_config.get(channel).and_then(|v| v.as_object());
76
77 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 let payload = build_payload(channel, message, format);
102
103 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
146fn 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 serde_json::json!({
170 "text": message,
171 "format": format
172 })
173 }
174 }
175}
176
177fn 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;