Skip to main content

rustyclaw_core/messengers/
slack.rs

1//! Slack messenger using Bot Token and Web API.
2//!
3//! Uses the Slack Web API (chat.postMessage, conversations.history) with a bot token.
4//! Supports channels, DMs, and threaded replies.
5
6use super::{Message, Messenger, SendOptions};
7use anyhow::{Context, Result};
8use async_trait::async_trait;
9use std::sync::Mutex;
10
11/// Slack messenger using bot token and Web API.
12pub struct SlackMessenger {
13    name: String,
14    bot_token: String,
15    /// App-level token for Socket Mode (optional, for real-time events).
16    app_token: Option<String>,
17    /// Default channel to listen on (if not specified per-message).
18    default_channel: Option<String>,
19    connected: bool,
20    http: reqwest::Client,
21    /// Bot user ID (resolved on initialize).
22    bot_user_id: Option<String>,
23    /// Track last read timestamp per channel for polling.
24    /// Wrapped in a Mutex so receive_messages(&self) can update it.
25    last_ts: Mutex<std::collections::HashMap<String, String>>,
26}
27
28impl SlackMessenger {
29    pub fn new(name: String, bot_token: String) -> Self {
30        Self {
31            name,
32            bot_token,
33            app_token: None,
34            default_channel: None,
35            connected: false,
36            http: reqwest::Client::new(),
37            bot_user_id: None,
38            last_ts: Mutex::new(std::collections::HashMap::new()),
39        }
40    }
41
42    /// Set the app-level token for Socket Mode.
43    pub fn with_app_token(mut self, token: String) -> Self {
44        self.app_token = Some(token);
45        self
46    }
47
48    /// Set the default channel to listen on.
49    pub fn with_default_channel(mut self, channel: String) -> Self {
50        self.default_channel = Some(channel);
51        self
52    }
53
54    fn api_url(method: &str) -> String {
55        format!("https://slack.com/api/{}", method)
56    }
57
58    /// Make an authenticated API call.
59    async fn api_call(&self, method: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
60        let resp = self
61            .http
62            .post(Self::api_url(method))
63            .header("Authorization", format!("Bearer {}", self.bot_token))
64            .header("Content-Type", "application/json; charset=utf-8")
65            .json(body)
66            .send()
67            .await
68            .with_context(|| format!("Slack API call to {} failed", method))?;
69
70        let status = resp.status();
71        let data: serde_json::Value = resp
72            .json()
73            .await
74            .with_context(|| format!("Failed to parse Slack {} response", method))?;
75
76        if !status.is_success() {
77            anyhow::bail!("Slack {} returned HTTP {}", method, status);
78        }
79
80        if data["ok"].as_bool() != Some(true) {
81            let error = data["error"].as_str().unwrap_or("unknown_error");
82            anyhow::bail!("Slack {} error: {}", method, error);
83        }
84
85        Ok(data)
86    }
87
88    /// Fetch conversation history for a channel since last known timestamp.
89    async fn fetch_history(&self, channel: &str) -> Result<Vec<Message>> {
90        let mut params = serde_json::json!({
91            "channel": channel,
92            "limit": 20
93        });
94
95        {
96            let last_ts = self.last_ts.lock().unwrap();
97            if let Some(ts) = last_ts.get(channel) {
98                params["oldest"] = serde_json::json!(ts);
99            }
100        }
101
102        let data = self.api_call("conversations.history", &params).await?;
103
104        let messages_arr = data["messages"]
105            .as_array()
106            .cloned()
107            .unwrap_or_default();
108
109        let mut result = Vec::new();
110
111        for msg in &messages_arr {
112            // Skip bot's own messages
113            if let Some(user) = msg["user"].as_str() {
114                if Some(user) == self.bot_user_id.as_deref() {
115                    continue;
116                }
117            }
118
119            // Skip messages without text
120            let text = match msg["text"].as_str() {
121                Some(t) if !t.is_empty() => t,
122                _ => continue,
123            };
124
125            let ts = msg["ts"].as_str().unwrap_or("0").to_string();
126            let sender = msg["user"].as_str().unwrap_or("unknown").to_string();
127
128            result.push(Message {
129                id: ts.clone(),
130                sender,
131                content: text.to_string(),
132                timestamp: parse_slack_ts(&ts),
133                channel: Some(channel.to_string()),
134                reply_to: msg["thread_ts"].as_str().map(|s| s.to_string()),
135                media: None,
136            });
137        }
138
139        // Update last seen timestamp
140        if let Some(newest) = messages_arr
141            .first()
142            .and_then(|m| m["ts"].as_str())
143        {
144            let mut last_ts = self.last_ts.lock().unwrap();
145            last_ts.insert(channel.to_string(), newest.to_string());
146        }
147
148        // Slack returns newest first, reverse for chronological order
149        result.reverse();
150        Ok(result)
151    }
152}
153
154/// Parse a Slack timestamp (e.g. "1234567890.123456") into epoch seconds.
155fn parse_slack_ts(ts: &str) -> i64 {
156    ts.split('.')
157        .next()
158        .and_then(|s| s.parse::<i64>().ok())
159        .unwrap_or(0)
160}
161
162#[async_trait]
163impl Messenger for SlackMessenger {
164    fn name(&self) -> &str {
165        &self.name
166    }
167
168    fn messenger_type(&self) -> &str {
169        "slack"
170    }
171
172    async fn initialize(&mut self) -> Result<()> {
173        // Verify bot token with auth.test
174        let data = self
175            .api_call("auth.test", &serde_json::json!({}))
176            .await
177            .context("Slack auth.test failed — check your bot token")?;
178
179        self.bot_user_id = data["user_id"].as_str().map(|s| s.to_string());
180        self.connected = true;
181
182        tracing::info!(
183            bot_user = ?self.bot_user_id,
184            team = ?data["team"].as_str(),
185            "Slack connected"
186        );
187
188        Ok(())
189    }
190
191    async fn send_message(&self, channel: &str, content: &str) -> Result<String> {
192        let data = self
193            .api_call(
194                "chat.postMessage",
195                &serde_json::json!({
196                    "channel": channel,
197                    "text": content,
198                    "unfurl_links": false,
199                }),
200            )
201            .await?;
202
203        Ok(data["ts"].as_str().unwrap_or("unknown").to_string())
204    }
205
206    async fn send_message_with_options(&self, opts: SendOptions<'_>) -> Result<String> {
207        let mut payload = serde_json::json!({
208            "channel": opts.recipient,
209            "text": opts.content,
210            "unfurl_links": false,
211        });
212
213        // Thread reply
214        if let Some(thread_ts) = opts.reply_to {
215            payload["thread_ts"] = serde_json::json!(thread_ts);
216        }
217
218        let data = self.api_call("chat.postMessage", &payload).await?;
219        Ok(data["ts"].as_str().unwrap_or("unknown").to_string())
220    }
221
222    async fn receive_messages(&self) -> Result<Vec<Message>> {
223        let channel = match &self.default_channel {
224            Some(ch) => ch.clone(),
225            None => return Ok(Vec::new()),
226        };
227        self.fetch_history(&channel).await
228    }
229
230    fn is_connected(&self) -> bool {
231        self.connected
232    }
233
234    async fn disconnect(&mut self) -> Result<()> {
235        self.connected = false;
236        Ok(())
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_slack_messenger_creation() {
246        let messenger = SlackMessenger::new("test".to_string(), "xoxb-fake".to_string());
247        assert_eq!(messenger.name(), "test");
248        assert_eq!(messenger.messenger_type(), "slack");
249        assert!(!messenger.is_connected());
250    }
251
252    #[test]
253    fn test_parse_slack_ts() {
254        assert_eq!(parse_slack_ts("1234567890.123456"), 1234567890);
255        assert_eq!(parse_slack_ts("0"), 0);
256        assert_eq!(parse_slack_ts(""), 0);
257    }
258
259    #[test]
260    fn test_with_options() {
261        let messenger = SlackMessenger::new("test".to_string(), "xoxb-fake".to_string())
262            .with_app_token("xapp-fake".to_string())
263            .with_default_channel("C12345".to_string());
264        assert_eq!(messenger.app_token, Some("xapp-fake".to_string()));
265        assert_eq!(messenger.default_channel, Some("C12345".to_string()));
266    }
267}