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