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