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::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 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 user: Option<String>,
60 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 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(¶ms)
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 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 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 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 let mut messages = messages;
271 messages.reverse();
272
273 for msg in messages {
274 if msg.msg_type.as_deref() != Some("message") {
276 continue;
277 }
278
279 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 {
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 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 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 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 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 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 assert!(!resp.has_more.unwrap_or(false));
444 }
445}