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
36#[derive(Deserialize)]
37struct SlackMessage {
38 #[serde(rename = "type")]
39 msg_type: Option<String>,
40 ts: String,
41 user: Option<String>,
43 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 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(¶ms)
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 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 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 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 let mut messages = messages;
254 messages.reverse();
255
256 for msg in messages {
257 if msg.msg_type.as_deref() != Some("message") {
259 continue;
260 }
261
262 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 {
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 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 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 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 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 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 assert!(!resp.has_more.unwrap_or(false));
425 }
426}