Skip to main content

rustant_tools/
slack.rs

1//! Slack tool — send messages, read channels, list users via Slack Web API.
2//!
3//! Uses the Slack Bot Token API (same as `RealSlackHttp` in rustant-core channels)
4//! to provide direct Slack interaction from the agent. Cross-platform (not macOS only).
5//! Bot token resolved from `SLACK_BOT_TOKEN` env var or `channels.slack.bot_token` in config.
6
7use 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
16/// Slack tool providing 6 actions for Slack workspace interaction.
17pub 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        // The tool contains both read and write actions; the safety guardian
342        // will gate writes via parse_action_details in agent.rs.
343        RiskLevel::Write
344    }
345
346    fn timeout(&self) -> Duration {
347        Duration::from_secs(30)
348    }
349}
350
351// ── Helpers ──────────────────────────────────────────────────────────────────
352
353/// Resolve the Slack bot token.
354/// 1. `SLACK_BOT_TOKEN` env var (fast path)
355/// 2. Config file `channels.slack.bot_token`
356fn get_bot_token(workspace: &Path) -> Result<String, ToolError> {
357    // Fast path: env var
358    if let Ok(token) = std::env::var("SLACK_BOT_TOKEN")
359        && !token.is_empty()
360    {
361        return Ok(token);
362    }
363
364    // Fallback: load from config
365    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
385/// Make a GET request to the Slack API with Bearer auth.
386async 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
426/// Make a POST request to the Slack API with Bearer auth and JSON body.
427async 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    /// Mutex to serialize tests that modify the SLACK_BOT_TOKEN env var.
478    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        // SAFETY: test-only env var manipulation
527        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        // SAFETY: test-only env var manipulation
534        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        // SAFETY: test-only env var manipulation
541        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        // SAFETY: test-only env var manipulation
548        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        // SAFETY: test-only env var manipulation
555        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        // SAFETY: test-only env var manipulation
567        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        // Test 1: valid env var is used
574        // SAFETY: test-only env var manipulation
575        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        // Test 2: empty env var is rejected (falls through to config)
581        // SAFETY: test-only env var manipulation
582        unsafe { std::env::set_var("SLACK_BOT_TOKEN", "") };
583        let result = get_bot_token(Path::new("/tmp"));
584        // Result depends on config; just verify empty string is not returned.
585        if let Ok(ref token) = result {
586            assert!(!token.is_empty());
587        }
588
589        // SAFETY: test-only env var manipulation
590        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        // SAFETY: test-only env var manipulation
597        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        // SAFETY: test-only env var manipulation
604        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        // SAFETY: test-only env var manipulation
611        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        // SAFETY: test-only env var manipulation
620        unsafe { std::env::remove_var("SLACK_BOT_TOKEN") };
621    }
622}