1use crate::registry::Tool;
8use async_trait::async_trait;
9use rustant_core::error::ToolError;
10use rustant_core::types::{RiskLevel, ToolOutput};
11use serde_json::json;
12use std::path::{Path, PathBuf};
13use std::time::Duration;
14use tracing::debug;
15
16pub struct SlackTool {
18 workspace: PathBuf,
19}
20
21impl SlackTool {
22 pub fn new(workspace: PathBuf) -> Self {
23 Self { workspace }
24 }
25}
26
27#[async_trait]
28impl Tool for SlackTool {
29 fn name(&self) -> &str {
30 "slack"
31 }
32
33 fn description(&self) -> &str {
34 "Interact with Slack workspaces via Bot Token API. Actions: \
35 send_message (post to a channel or DM), read_messages (fetch recent messages), \
36 list_channels (list workspace channels), reply_thread (reply in a thread), \
37 list_users (list workspace members), add_reaction (react to a message). \
38 Requires SLACK_BOT_TOKEN env var or channels.slack.bot_token in config."
39 }
40
41 fn parameters_schema(&self) -> serde_json::Value {
42 json!({
43 "type": "object",
44 "properties": {
45 "action": {
46 "type": "string",
47 "description": "The action to perform",
48 "enum": ["send_message", "read_messages", "list_channels", "reply_thread", "list_users", "add_reaction"]
49 },
50 "channel": {
51 "type": "string",
52 "description": "Channel name (e.g. '#general') or ID (e.g. 'C01234'). Required for send_message, read_messages, reply_thread, add_reaction."
53 },
54 "message": {
55 "type": "string",
56 "description": "Message text to send. Required for send_message and reply_thread."
57 },
58 "thread_ts": {
59 "type": "string",
60 "description": "Thread timestamp to reply to. Required for reply_thread and add_reaction."
61 },
62 "emoji": {
63 "type": "string",
64 "description": "Emoji name without colons (e.g. 'thumbsup'). Required for add_reaction."
65 },
66 "limit": {
67 "type": "integer",
68 "description": "Max messages to return for read_messages (default: 10, max: 100).",
69 "default": 10
70 }
71 },
72 "required": ["action"]
73 })
74 }
75
76 async fn execute(&self, args: serde_json::Value) -> Result<ToolOutput, ToolError> {
77 let action = args["action"]
78 .as_str()
79 .ok_or_else(|| ToolError::InvalidArguments {
80 name: "slack".to_string(),
81 reason: "missing required 'action' parameter".to_string(),
82 })?;
83
84 let token = get_bot_token(&self.workspace)?;
85 let client = reqwest::Client::new();
86
87 match action {
88 "send_message" => {
89 let channel =
90 args["channel"]
91 .as_str()
92 .ok_or_else(|| ToolError::InvalidArguments {
93 name: "slack".to_string(),
94 reason: "send_message requires 'channel' parameter".to_string(),
95 })?;
96 let message =
97 args["message"]
98 .as_str()
99 .ok_or_else(|| ToolError::InvalidArguments {
100 name: "slack".to_string(),
101 reason: "send_message requires 'message' parameter".to_string(),
102 })?;
103
104 debug!(channel = channel, "Sending Slack message");
105
106 let body = json!({
107 "channel": channel,
108 "text": message,
109 });
110 let resp = slack_api_post(
111 &client,
112 &token,
113 "https://slack.com/api/chat.postMessage",
114 &body,
115 )
116 .await?;
117
118 let ts = resp["ts"].as_str().unwrap_or("unknown");
119 let ch = resp["channel"].as_str().unwrap_or(channel);
120 Ok(ToolOutput::text(format!(
121 "Message sent to {} (ts: {})",
122 ch, ts
123 )))
124 }
125
126 "read_messages" => {
127 let channel =
128 args["channel"]
129 .as_str()
130 .ok_or_else(|| ToolError::InvalidArguments {
131 name: "slack".to_string(),
132 reason: "read_messages requires 'channel' parameter".to_string(),
133 })?;
134 let limit = args["limit"].as_u64().unwrap_or(10).min(100);
135
136 debug!(channel = channel, limit = limit, "Reading Slack messages");
137
138 let url = format!(
139 "https://slack.com/api/conversations.history?channel={}&limit={}",
140 urlencoding::encode(channel),
141 limit
142 );
143 let resp = slack_api_get(&client, &token, &url).await?;
144
145 let messages = resp["messages"].as_array();
146 match messages {
147 Some(msgs) if !msgs.is_empty() => {
148 let mut output = format!(
149 "Recent messages in {} ({} message(s)):\n\n",
150 channel,
151 msgs.len()
152 );
153 for msg in msgs {
154 let user = msg["user"].as_str().unwrap_or("unknown");
155 let text = msg["text"].as_str().unwrap_or("");
156 let ts = msg["ts"].as_str().unwrap_or("");
157 output.push_str(&format!("[{}] {}: {}\n", ts, user, text));
158 }
159 Ok(ToolOutput::text(output))
160 }
161 _ => Ok(ToolOutput::text(format!(
162 "No recent messages in {}.",
163 channel
164 ))),
165 }
166 }
167
168 "list_channels" => {
169 debug!("Listing Slack channels");
170
171 let url = "https://slack.com/api/conversations.list?types=public_channel,private_channel&limit=200";
172 let resp = slack_api_get(&client, &token, url).await?;
173
174 let channels = resp["channels"].as_array();
175 match channels {
176 Some(chs) if !chs.is_empty() => {
177 let mut output = format!("Slack channels ({} found):\n\n", chs.len());
178 for ch in chs {
179 let name = ch["name"].as_str().unwrap_or("?");
180 let id = ch["id"].as_str().unwrap_or("?");
181 let members = ch["num_members"].as_u64().unwrap_or(0);
182 let purpose = ch["purpose"]["value"].as_str().unwrap_or("");
183 let private = if ch["is_private"].as_bool().unwrap_or(false) {
184 " (private)"
185 } else {
186 ""
187 };
188 output.push_str(&format!(
189 "#{}{} [{}] — {} members",
190 name, private, id, members
191 ));
192 if !purpose.is_empty() {
193 output.push_str(&format!(" — {}", purpose));
194 }
195 output.push('\n');
196 }
197 Ok(ToolOutput::text(output))
198 }
199 _ => Ok(ToolOutput::text("No channels found.".to_string())),
200 }
201 }
202
203 "reply_thread" => {
204 let channel =
205 args["channel"]
206 .as_str()
207 .ok_or_else(|| ToolError::InvalidArguments {
208 name: "slack".to_string(),
209 reason: "reply_thread requires 'channel' parameter".to_string(),
210 })?;
211 let thread_ts =
212 args["thread_ts"]
213 .as_str()
214 .ok_or_else(|| ToolError::InvalidArguments {
215 name: "slack".to_string(),
216 reason: "reply_thread requires 'thread_ts' parameter".to_string(),
217 })?;
218 let message =
219 args["message"]
220 .as_str()
221 .ok_or_else(|| ToolError::InvalidArguments {
222 name: "slack".to_string(),
223 reason: "reply_thread requires 'message' parameter".to_string(),
224 })?;
225
226 debug!(
227 channel = channel,
228 thread_ts = thread_ts,
229 "Replying to Slack thread"
230 );
231
232 let body = json!({
233 "channel": channel,
234 "text": message,
235 "thread_ts": thread_ts,
236 });
237 let resp = slack_api_post(
238 &client,
239 &token,
240 "https://slack.com/api/chat.postMessage",
241 &body,
242 )
243 .await?;
244
245 let ts = resp["ts"].as_str().unwrap_or("unknown");
246 Ok(ToolOutput::text(format!(
247 "Thread reply sent to {} (ts: {})",
248 channel, ts
249 )))
250 }
251
252 "list_users" => {
253 debug!("Listing Slack users");
254
255 let url = "https://slack.com/api/users.list?limit=200";
256 let resp = slack_api_get(&client, &token, url).await?;
257
258 let members = resp["members"].as_array();
259 match members {
260 Some(users) if !users.is_empty() => {
261 let mut output =
262 format!("Slack workspace members ({} found):\n\n", users.len());
263 for user in users {
264 let name = user["name"].as_str().unwrap_or("?");
265 let real_name = user["real_name"].as_str().unwrap_or("");
266 let id = user["id"].as_str().unwrap_or("?");
267 let is_bot = user["is_bot"].as_bool().unwrap_or(false);
268 let bot_tag = if is_bot { " [bot]" } else { "" };
269 output.push_str(&format!(
270 "@{}{} ({}) [{}]\n",
271 name, bot_tag, real_name, id
272 ));
273 }
274 Ok(ToolOutput::text(output))
275 }
276 _ => Ok(ToolOutput::text("No users found.".to_string())),
277 }
278 }
279
280 "add_reaction" => {
281 let channel =
282 args["channel"]
283 .as_str()
284 .ok_or_else(|| ToolError::InvalidArguments {
285 name: "slack".to_string(),
286 reason: "add_reaction requires 'channel' parameter".to_string(),
287 })?;
288 let timestamp =
289 args["thread_ts"]
290 .as_str()
291 .ok_or_else(|| ToolError::InvalidArguments {
292 name: "slack".to_string(),
293 reason:
294 "add_reaction requires 'thread_ts' parameter (message timestamp)"
295 .to_string(),
296 })?;
297 let emoji = args["emoji"]
298 .as_str()
299 .ok_or_else(|| ToolError::InvalidArguments {
300 name: "slack".to_string(),
301 reason: "add_reaction requires 'emoji' parameter".to_string(),
302 })?;
303
304 debug!(
305 channel = channel,
306 timestamp = timestamp,
307 emoji = emoji,
308 "Adding Slack reaction"
309 );
310
311 let body = json!({
312 "channel": channel,
313 "timestamp": timestamp,
314 "name": emoji,
315 });
316 slack_api_post(
317 &client,
318 &token,
319 "https://slack.com/api/reactions.add",
320 &body,
321 )
322 .await?;
323
324 Ok(ToolOutput::text(format!(
325 "Reaction :{}: added to message in {}.",
326 emoji, channel
327 )))
328 }
329
330 other => Err(ToolError::InvalidArguments {
331 name: "slack".to_string(),
332 reason: format!(
333 "unknown action '{}'. Valid: send_message, read_messages, list_channels, reply_thread, list_users, add_reaction",
334 other
335 ),
336 }),
337 }
338 }
339
340 fn risk_level(&self) -> RiskLevel {
341 RiskLevel::Write
344 }
345
346 fn timeout(&self) -> Duration {
347 Duration::from_secs(30)
348 }
349}
350
351fn get_bot_token(workspace: &Path) -> Result<String, ToolError> {
357 if let Ok(token) = std::env::var("SLACK_BOT_TOKEN")
359 && !token.is_empty()
360 {
361 return Ok(token);
362 }
363
364 if let Ok(config) = rustant_core::config::load_config(Some(workspace), None)
366 && let Some(channels) = &config.channels
367 && let Some(slack) = &channels.slack
368 && !slack.bot_token.is_empty()
369 {
370 match slack.resolve_bot_token() {
371 Ok(token) if !token.is_empty() => return Ok(token),
372 Ok(_) => {}
373 Err(e) => tracing::warn!("Failed to resolve Slack bot token: {}", e),
374 }
375 }
376
377 Err(ToolError::ExecutionFailed {
378 name: "slack".to_string(),
379 message: "No Slack bot token found. Set SLACK_BOT_TOKEN env var or configure \
380 channels.slack.bot_token in .rustant/config.toml"
381 .to_string(),
382 })
383}
384
385async fn slack_api_get(
387 client: &reqwest::Client,
388 token: &str,
389 url: &str,
390) -> Result<serde_json::Value, ToolError> {
391 let resp = client
392 .get(url)
393 .header("Authorization", format!("Bearer {}", token))
394 .timeout(Duration::from_secs(15))
395 .send()
396 .await
397 .map_err(|e| ToolError::ExecutionFailed {
398 name: "slack".to_string(),
399 message: format!("HTTP request failed: {}", e),
400 })?;
401
402 let status = resp.status();
403 let body: serde_json::Value = resp.json().await.map_err(|e| ToolError::ExecutionFailed {
404 name: "slack".to_string(),
405 message: format!("Failed to parse response: {}", e),
406 })?;
407
408 if !status.is_success() {
409 return Err(ToolError::ExecutionFailed {
410 name: "slack".to_string(),
411 message: format!("Slack API returned HTTP {}: {:?}", status, body),
412 });
413 }
414
415 if body["ok"].as_bool() != Some(true) {
416 let error = body["error"].as_str().unwrap_or("unknown_error");
417 return Err(ToolError::ExecutionFailed {
418 name: "slack".to_string(),
419 message: format!("Slack API error: {}", error),
420 });
421 }
422
423 Ok(body)
424}
425
426async fn slack_api_post(
428 client: &reqwest::Client,
429 token: &str,
430 url: &str,
431 body: &serde_json::Value,
432) -> Result<serde_json::Value, ToolError> {
433 let resp = client
434 .post(url)
435 .header("Authorization", format!("Bearer {}", token))
436 .header("Content-Type", "application/json; charset=utf-8")
437 .timeout(Duration::from_secs(15))
438 .json(body)
439 .send()
440 .await
441 .map_err(|e| ToolError::ExecutionFailed {
442 name: "slack".to_string(),
443 message: format!("HTTP request failed: {}", e),
444 })?;
445
446 let status = resp.status();
447 let resp_body: serde_json::Value =
448 resp.json().await.map_err(|e| ToolError::ExecutionFailed {
449 name: "slack".to_string(),
450 message: format!("Failed to parse response: {}", e),
451 })?;
452
453 if !status.is_success() {
454 return Err(ToolError::ExecutionFailed {
455 name: "slack".to_string(),
456 message: format!("Slack API returned HTTP {}: {:?}", status, resp_body),
457 });
458 }
459
460 if resp_body["ok"].as_bool() != Some(true) {
461 let error = resp_body["error"].as_str().unwrap_or("unknown_error");
462 return Err(ToolError::ExecutionFailed {
463 name: "slack".to_string(),
464 message: format!("Slack API error: {}", error),
465 });
466 }
467
468 Ok(resp_body)
469}
470
471#[cfg(test)]
472#[allow(clippy::await_holding_lock)]
473mod tests {
474 use super::*;
475 use std::sync::Mutex;
476
477 static ENV_MUTEX: Mutex<()> = Mutex::new(());
479
480 #[test]
481 fn test_slack_tool_definition() {
482 let tool = SlackTool::new(PathBuf::from("/tmp"));
483 assert_eq!(tool.name(), "slack");
484 assert_eq!(tool.risk_level(), RiskLevel::Write);
485 let schema = tool.parameters_schema();
486 assert!(schema["properties"]["action"].is_object());
487 assert!(schema["properties"]["channel"].is_object());
488 assert!(schema["properties"]["message"].is_object());
489 assert!(schema["properties"]["thread_ts"].is_object());
490 assert!(schema["properties"]["emoji"].is_object());
491 assert!(schema["properties"]["limit"].is_object());
492 }
493
494 #[test]
495 fn test_slack_tool_schema_required_fields() {
496 let tool = SlackTool::new(PathBuf::from("/tmp"));
497 let schema = tool.parameters_schema();
498 let required = schema["required"].as_array().unwrap();
499 assert!(required.contains(&json!("action")));
500 assert_eq!(required.len(), 1);
501 }
502
503 #[test]
504 fn test_slack_tool_timeout() {
505 let tool = SlackTool::new(PathBuf::from("/tmp"));
506 assert_eq!(tool.timeout(), Duration::from_secs(30));
507 }
508
509 #[tokio::test]
510 async fn test_slack_missing_action() {
511 let tool = SlackTool::new(PathBuf::from("/tmp"));
512 let result = tool.execute(json!({})).await;
513 assert!(result.is_err());
514 match result.unwrap_err() {
515 ToolError::InvalidArguments { name, reason } => {
516 assert_eq!(name, "slack");
517 assert!(reason.contains("action"));
518 }
519 _ => panic!("Expected InvalidArguments error"),
520 }
521 }
522
523 #[tokio::test]
524 async fn test_slack_send_message_missing_channel() {
525 let _guard = ENV_MUTEX.lock().unwrap();
526 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-test-token") };
528 let tool = SlackTool::new(PathBuf::from("/tmp"));
529 let result = tool
530 .execute(json!({"action": "send_message", "message": "hello"}))
531 .await;
532 assert!(result.is_err());
533 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
535 }
536
537 #[tokio::test]
538 async fn test_slack_send_message_missing_message() {
539 let _guard = ENV_MUTEX.lock().unwrap();
540 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-test-token") };
542 let tool = SlackTool::new(PathBuf::from("/tmp"));
543 let result = tool
544 .execute(json!({"action": "send_message", "channel": "#general"}))
545 .await;
546 assert!(result.is_err());
547 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
549 }
550
551 #[tokio::test]
552 async fn test_slack_unknown_action() {
553 let _guard = ENV_MUTEX.lock().unwrap();
554 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-test-token") };
556 let tool = SlackTool::new(PathBuf::from("/tmp"));
557 let result = tool.execute(json!({"action": "invalid_action"})).await;
558 assert!(result.is_err());
559 match result.unwrap_err() {
560 ToolError::InvalidArguments { name, reason } => {
561 assert_eq!(name, "slack");
562 assert!(reason.contains("unknown action"));
563 }
564 _ => panic!("Expected InvalidArguments error"),
565 }
566 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
568 }
569
570 #[test]
571 fn test_bot_token_env_resolution() {
572 let _guard = ENV_MUTEX.lock().unwrap();
573 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-from-env") };
576 let result = get_bot_token(Path::new("/tmp"));
577 assert!(result.is_ok());
578 assert_eq!(result.unwrap(), "xoxb-from-env");
579
580 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "") };
583 let result = get_bot_token(Path::new("/tmp"));
584 if let Ok(ref token) = result {
586 assert!(!token.is_empty());
587 }
588
589 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
591 }
592
593 #[tokio::test]
594 async fn test_slack_reply_thread_missing_thread_ts() {
595 let _guard = ENV_MUTEX.lock().unwrap();
596 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-test-token") };
598 let tool = SlackTool::new(PathBuf::from("/tmp"));
599 let result = tool
600 .execute(json!({"action": "reply_thread", "channel": "#general", "message": "hi"}))
601 .await;
602 assert!(result.is_err());
603 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
605 }
606
607 #[tokio::test]
608 async fn test_slack_add_reaction_missing_emoji() {
609 let _guard = ENV_MUTEX.lock().unwrap();
610 unsafe { std::env::set_var("SLACK_BOT_TOKEN", "xoxb-test-token") };
612 let tool = SlackTool::new(PathBuf::from("/tmp"));
613 let result = tool
614 .execute(
615 json!({"action": "add_reaction", "channel": "#general", "thread_ts": "123.456"}),
616 )
617 .await;
618 assert!(result.is_err());
619 unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
621 }
622}