Skip to main content

orch/channels/
slack.rs

1//! Slack channel — receives commands and streams agent output.
2//!
3//! Uses the Slack Web API with HTTP polling (conversations.history) to receive
4//! messages and the chat.postMessage API to send responses.
5//!
6//! Configuration in `~/.orch/config.yml`:
7//! ```yaml
8//! channels:
9//!   slack:
10//!     bot_token: "xoxb-..."       # Bot User OAuth Token
11//!     channel_id: "C1234567890"   # Channel to monitor and post to
12//! ```
13//!
14//! The bot token requires the following OAuth scopes:
15//! - `channels:history` / `groups:history` — read messages
16//! - `chat:write` — post messages
17//! - `channels:read` — health check
18
19use super::{Channel, IncomingMessage, OutgoingMessage};
20use anyhow::Context;
21use async_trait::async_trait;
22use chrono::{DateTime, Utc};
23use reqwest::Client;
24use serde::Deserialize;
25use sha2::{Digest, Sha256};
26use std::time::Duration;
27
28pub struct SlackChannel {
29    pub bot_token: String,
30    pub client: Client,
31    pub channel_id: Option<String>,
32    /// Cursor tracking the latest message timestamp seen (Slack uses `ts` as cursor).
33    pub last_ts: std::sync::Arc<tokio::sync::Mutex<Option<String>>>,
34}
35
36#[derive(Deserialize)]
37struct SlackMessage {
38    #[serde(rename = "type")]
39    msg_type: Option<String>,
40    ts: String,
41    /// Absent for bot-posted messages; present for human users.
42    user: Option<String>,
43    /// Present for bot messages.
44    bot_id: Option<String>,
45    text: Option<String>,
46}
47
48#[derive(Deserialize)]
49struct ResponseMetadata {
50    next_cursor: Option<String>,
51}
52
53#[derive(Deserialize)]
54struct ConversationsHistoryResponse {
55    ok: bool,
56    messages: Option<Vec<SlackMessage>>,
57    error: Option<String>,
58    has_more: Option<bool>,
59    response_metadata: Option<ResponseMetadata>,
60}
61
62#[derive(Deserialize)]
63struct PostMessageResponse {
64    ok: bool,
65    error: Option<String>,
66}
67
68#[derive(Deserialize)]
69struct AuthTestResponse {
70    ok: bool,
71    error: Option<String>,
72}
73
74impl SlackChannel {
75    pub fn new(bot_token: String, channel_id: Option<String>) -> anyhow::Result<Self> {
76        let client = Client::builder()
77            .timeout(Duration::from_secs(30))
78            .build()
79            .context("failed to build HTTP client")?;
80
81        Ok(Self {
82            bot_token,
83            client,
84            channel_id,
85            last_ts: std::sync::Arc::new(tokio::sync::Mutex::new(None)),
86        })
87    }
88
89    fn api_url(&self, method: &str) -> String {
90        format!("https://slack.com/api/{}", method)
91    }
92
93    /// Fetch all messages newer than `oldest` from a channel, following pagination.
94    ///
95    /// `oldest` is the Slack `ts` of the last processed message (exclusive lower bound).
96    /// Loops through pages until `has_more` is false or `next_cursor` is empty, so no
97    /// messages are dropped regardless of how many arrived since the last poll.
98    async fn get_messages(
99        &self,
100        channel_id: &str,
101        oldest: Option<String>,
102    ) -> anyhow::Result<Vec<SlackMessage>> {
103        let mut all_messages: Vec<SlackMessage> = Vec::new();
104        let mut cursor: Option<String> = None;
105
106        loop {
107            let mut params = vec![
108                ("channel", channel_id.to_string()),
109                ("limit", "200".to_string()),
110            ];
111
112            if let Some(ref ts) = oldest {
113                params.push(("oldest", ts.clone()));
114            }
115
116            if let Some(ref c) = cursor {
117                params.push(("cursor", c.clone()));
118            }
119
120            let response = self
121                .client
122                .get(self.api_url("conversations.history"))
123                .bearer_auth(&self.bot_token)
124                .query(&params)
125                .send()
126                .await?;
127
128            if !response.status().is_success() {
129                let body = response.text().await.unwrap_or_default();
130                anyhow::bail!("slack API HTTP error: {}", body);
131            }
132
133            let result: ConversationsHistoryResponse = response.json().await?;
134
135            if !result.ok {
136                let err = result.error.as_deref().unwrap_or("unknown");
137                anyhow::bail!("slack conversations.history error: {}", err);
138            }
139
140            all_messages.extend(result.messages.unwrap_or_default());
141
142            let has_more = result.has_more.unwrap_or(false);
143            let next_cursor = result
144                .response_metadata
145                .and_then(|m| m.next_cursor)
146                .filter(|c| !c.is_empty());
147
148            if !has_more || next_cursor.is_none() {
149                break;
150            }
151
152            cursor = next_cursor;
153        }
154
155        Ok(all_messages)
156    }
157
158    async fn send_message(&self, channel_id: &str, text: &str) -> anyhow::Result<()> {
159        let response = self
160            .client
161            .post(self.api_url("chat.postMessage"))
162            .bearer_auth(&self.bot_token)
163            .json(&serde_json::json!({
164                "channel": channel_id,
165                "text": text,
166                "mrkdwn": true
167            }))
168            .send()
169            .await?;
170
171        if !response.status().is_success() {
172            let body = response.text().await.unwrap_or_default();
173            anyhow::bail!("slack API HTTP error: {}", body);
174        }
175
176        let result: PostMessageResponse = response.json().await?;
177
178        if !result.ok {
179            let err = result.error.as_deref().unwrap_or("unknown");
180            anyhow::bail!("slack chat.postMessage error: {}", err);
181        }
182
183        Ok(())
184    }
185}
186
187#[async_trait]
188impl Channel for SlackChannel {
189    fn name(&self) -> &str {
190        "slack"
191    }
192
193    async fn start(&self) -> anyhow::Result<tokio::sync::mpsc::Receiver<IncomingMessage>> {
194        let (tx, rx) = tokio::sync::mpsc::channel(64);
195
196        // If no channel_id configured we can't poll
197        if self.channel_id.is_none() {
198            tracing::warn!("slack channel_id not configured, skipping message polling");
199            return Ok(rx);
200        }
201
202        let bot_token = self.bot_token.clone();
203        let client = self.client.clone();
204        let channel_id = match self.channel_id.clone() {
205            Some(id) => id,
206            None => {
207                // Defensive: is_none() early-return above should prevent reaching here
208                tracing::error!("slack channel_id unexpectedly None after guard check");
209                return Ok(rx);
210            }
211        };
212        let last_ts = self.last_ts.clone();
213
214        let token_fingerprint = {
215            let mut hasher = Sha256::new();
216            hasher.update(bot_token.as_bytes());
217            let hash = hasher.finalize();
218            let hex: String = hash[..8].iter().map(|b| format!("{:02x}", b)).collect();
219            format!("sha256:{hex}")
220        };
221
222        tracing::info!(
223            token_fingerprint = %token_fingerprint,
224            channel_id = %channel_id,
225            "slack channel started"
226        );
227
228        tokio::spawn(async move {
229            let polling_interval = std::time::Duration::from_secs(5);
230
231            loop {
232                tokio::time::sleep(polling_interval).await;
233
234                let channel = SlackChannel {
235                    bot_token: bot_token.clone(),
236                    client: client.clone(),
237                    channel_id: Some(channel_id.clone()),
238                    last_ts: last_ts.clone(),
239                };
240
241                // Snapshot the cursor once so all pagination pages use the same lower bound.
242                let oldest = last_ts.lock().await.clone();
243
244                let messages = match channel.get_messages(&channel_id, oldest).await {
245                    Ok(m) => m,
246                    Err(e) => {
247                        tracing::warn!(?e, "failed to get slack messages");
248                        continue;
249                    }
250                };
251
252                // Slack returns messages newest-first; reverse for chronological order
253                let mut messages = messages;
254                messages.reverse();
255
256                for msg in messages {
257                    // Skip non-message types (e.g. channel_join)
258                    if msg.msg_type.as_deref() != Some("message") {
259                        continue;
260                    }
261
262                    // Skip bot messages (including our own)
263                    if msg.bot_id.is_some() {
264                        continue;
265                    }
266
267                    let text = msg.text.clone().unwrap_or_default();
268                    if text.is_empty() {
269                        continue;
270                    }
271
272                    // Update last_ts to the maximum ts seen
273                    {
274                        let mut last = last_ts.lock().await;
275                        if last.as_ref().is_none_or(|prev: &String| msg.ts > *prev) {
276                            *last = Some(msg.ts.clone());
277                        }
278                    }
279
280                    let author = msg.user.clone().unwrap_or_else(|| "unknown".to_string());
281
282                    // Parse Slack ts (Unix seconds with decimal sub-second)
283                    let timestamp = msg
284                        .ts
285                        .split('.')
286                        .next()
287                        .and_then(|s| s.parse::<i64>().ok())
288                        .and_then(|secs| DateTime::from_timestamp(secs, 0))
289                        .unwrap_or_else(Utc::now);
290
291                    let incoming = IncomingMessage {
292                        channel: "slack".to_string(),
293                        id: msg.ts.clone(),
294                        thread_id: channel_id.clone(),
295                        author,
296                        body: text,
297                        timestamp,
298                        metadata: serde_json::json!({ "channel_id": channel_id }),
299                        topic_id: None,
300                    };
301
302                    if tx.send(incoming).await.is_err() {
303                        tracing::debug!("slack channel receiver dropped");
304                        return;
305                    }
306                }
307            }
308        });
309
310        Ok(rx)
311    }
312
313    async fn send(&self, msg: &OutgoingMessage) -> anyhow::Result<()> {
314        let channel_id = self
315            .channel_id
316            .as_ref()
317            .ok_or_else(|| anyhow::anyhow!("slack channel_id not configured"))?;
318
319        self.send_message(channel_id, &msg.body).await
320    }
321
322    async fn health_check(&self) -> anyhow::Result<()> {
323        let response = self
324            .client
325            .get(self.api_url("auth.test"))
326            .bearer_auth(&self.bot_token)
327            .send()
328            .await?;
329
330        if !response.status().is_success() {
331            let body = response.text().await.unwrap_or_default();
332            anyhow::bail!("slack health check HTTP error: {}", body);
333        }
334
335        let result: AuthTestResponse = response.json().await?;
336
337        if !result.ok {
338            let err = result.error.as_deref().unwrap_or("unknown");
339            anyhow::bail!("slack auth.test failed: {}", err);
340        }
341
342        tracing::info!("slack bot health check passed");
343        Ok(())
344    }
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn slack_channel_name() {
353        let ch = SlackChannel::new("xoxb-test".to_string(), None).unwrap();
354        assert_eq!(ch.name(), "slack");
355    }
356
357    #[test]
358    fn slack_api_url() {
359        let ch = SlackChannel::new("xoxb-test".to_string(), None).unwrap();
360        assert_eq!(ch.api_url("auth.test"), "https://slack.com/api/auth.test");
361        assert_eq!(
362            ch.api_url("chat.postMessage"),
363            "https://slack.com/api/chat.postMessage"
364        );
365    }
366
367    #[test]
368    fn slack_ts_comparison_is_lexicographic() {
369        // Slack ts values are strings like "1700000000.123456"
370        // Lexicographic comparison works because the integer part is the same width
371        let older = "1700000000.000100".to_string();
372        let newer = "1700000001.000000".to_string();
373        assert!(newer > older);
374    }
375
376    #[test]
377    fn conversations_history_response_deserializes_pagination_fields() {
378        // Response with more pages available
379        let json = r#"{
380            "ok": true,
381            "messages": [{"type": "message", "ts": "1700000001.000000", "text": "hello"}],
382            "has_more": true,
383            "response_metadata": {"next_cursor": "bmV4dF90czoxNzAwMDAwMDAxLjAwMDAwMA=="}
384        }"#;
385        let resp: ConversationsHistoryResponse = serde_json::from_str(json).unwrap();
386        assert!(resp.ok);
387        assert_eq!(resp.has_more, Some(true));
388        let meta = resp.response_metadata.unwrap();
389        assert_eq!(
390            meta.next_cursor.as_deref(),
391            Some("bmV4dF90czoxNzAwMDAwMDAxLjAwMDAwMA==")
392        );
393    }
394
395    #[test]
396    fn conversations_history_response_last_page_has_no_cursor() {
397        // Final page: has_more=false, empty next_cursor
398        let json = r#"{
399            "ok": true,
400            "messages": [{"type": "message", "ts": "1700000002.000000", "text": "last"}],
401            "has_more": false,
402            "response_metadata": {"next_cursor": ""}
403        }"#;
404        let resp: ConversationsHistoryResponse = serde_json::from_str(json).unwrap();
405        assert_eq!(resp.has_more, Some(false));
406        let next = resp
407            .response_metadata
408            .and_then(|m| m.next_cursor)
409            .filter(|c| !c.is_empty());
410        assert!(
411            next.is_none(),
412            "empty next_cursor should be treated as no more pages"
413        );
414    }
415
416    #[test]
417    fn conversations_history_response_missing_pagination_fields() {
418        // Older API responses may omit has_more and response_metadata entirely
419        let json = r#"{"ok": true, "messages": []}"#;
420        let resp: ConversationsHistoryResponse = serde_json::from_str(json).unwrap();
421        assert_eq!(resp.has_more, None);
422        assert!(resp.response_metadata.is_none());
423        // has_more defaults to false — no pagination loop
424        assert!(!resp.has_more.unwrap_or(false));
425    }
426}