1use 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 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 user: Option<String>,
51 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 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(¶ms)
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 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 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 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 let mut messages = messages;
264 messages.reverse();
265
266 for msg in messages {
267 if msg.msg_type.as_deref() != Some("message") {
269 continue;
270 }
271
272 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 {
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 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 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 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 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 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 assert!(!resp.has_more.unwrap_or(false));
437 }
438}