use super::traits::{Channel, ChannelMessage}; use async_trait::async_trait; /// Slack channel — polls conversations.history via Web API pub struct SlackChannel { bot_token: String, channel_id: Option, allowed_users: Vec, client: reqwest::Client, } impl SlackChannel { pub fn new(bot_token: String, channel_id: Option, allowed_users: Vec) -> Self { Self { bot_token, channel_id, allowed_users, client: reqwest::Client::new(), } } /// Check if a Slack user ID is in the allowlist. /// Empty list means deny everyone until explicitly configured. /// `"*"` means allow everyone. fn is_user_allowed(&self, user_id: &str) -> bool { self.allowed_users.iter().any(|u| u == "*" || u == user_id) } /// Get the bot's own user ID so we can ignore our own messages async fn get_bot_user_id(&self) -> Option { let resp: serde_json::Value = self .client .get("https://slack.com/api/auth.test") .bearer_auth(&self.bot_token) .send() .await .ok()? .json() .await .ok()?; resp.get("user_id") .and_then(|u| u.as_str()) .map(String::from) } } #[async_trait] impl Channel for SlackChannel { fn name(&self) -> &str { "slack" } async fn send(&self, message: &str, channel: &str) -> anyhow::Result<()> { let body = serde_json::json!({ "channel": channel, "text": message }); let resp = self .client .post("https://slack.com/api/chat.postMessage") .bearer_auth(&self.bot_token) .json(&body) .send() .await?; let status = resp.status(); let body = resp .text() .await .unwrap_or_else(|e| format!("")); if !status.is_success() { anyhow::bail!("Slack chat.postMessage failed ({status}): {body}"); } // Slack returns 200 for most app-level errors; check JSON "ok" field let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); if parsed.get("ok") == Some(&serde_json::Value::Bool(false)) { let err = parsed .get("error") .and_then(|e| e.as_str()) .unwrap_or("unknown"); anyhow::bail!("Slack chat.postMessage failed: {err}"); } Ok(()) } async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { let channel_id = self .channel_id .clone() .ok_or_else(|| anyhow::anyhow!("Slack channel_id required for listening"))?; let bot_user_id = self.get_bot_user_id().await.unwrap_or_default(); let mut last_ts = String::new(); tracing::info!("Slack channel listening on #{channel_id}..."); loop { tokio::time::sleep(std::time::Duration::from_secs(3)).await; let mut params = vec![("channel", channel_id.clone()), ("limit", "10".to_string())]; if !last_ts.is_empty() { params.push(("oldest", last_ts.clone())); } let resp = match self .client .get("https://slack.com/api/conversations.history") .bearer_auth(&self.bot_token) .query(¶ms) .send() .await { Ok(r) => r, Err(e) => { tracing::warn!("Slack poll error: {e}"); continue; } }; let data: serde_json::Value = match resp.json().await { Ok(d) => d, Err(e) => { tracing::warn!("Slack parse error: {e}"); continue; } }; if let Some(messages) = data.get("messages").and_then(|m| m.as_array()) { // Messages come newest-first, reverse to process oldest first for msg in messages.iter().rev() { let ts = msg.get("ts").and_then(|t| t.as_str()).unwrap_or(""); let user = msg .get("user") .and_then(|u| u.as_str()) .unwrap_or("unknown"); let text = msg.get("text").and_then(|t| t.as_str()).unwrap_or(""); // Skip bot's own messages if user == bot_user_id { continue; } // Sender validation if !self.is_user_allowed(user) { tracing::warn!("Slack: ignoring message from unauthorized user: {user}"); continue; } // Skip empty or already-seen if text.is_empty() || ts <= last_ts.as_str() { continue; } last_ts = ts.to_string(); let channel_msg = ChannelMessage { id: format!("slack_{channel_id}_{ts}"), sender: user.to_string(), reply_target: channel_id.clone(), content: text.to_string(), channel: "slack".to_string(), timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(), }; if tx.send(channel_msg).await.is_err() { return Ok(()); } } } } } async fn health_check(&self) -> bool { self.client .get("https://slack.com/api/auth.test") .bearer_auth(&self.bot_token) .send() .await .map(|r| r.status().is_success()) .unwrap_or(false) } } #[cfg(test)] mod tests { use super::*; #[test] fn slack_channel_name() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]); assert_eq!(ch.name(), "slack"); } #[test] fn slack_channel_with_channel_id() { let ch = SlackChannel::new("xoxb-fake".into(), Some("C12345".into()), vec![]); assert_eq!(ch.channel_id, Some("C12345".to_string())); } #[test] fn empty_allowlist_denies_everyone() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec![]); assert!(!ch.is_user_allowed("U12345")); assert!(!ch.is_user_allowed("anyone")); } #[test] fn wildcard_allows_everyone() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["*".into()]); assert!(ch.is_user_allowed("U12345")); } #[test] fn specific_allowlist_filters() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "U222".into()]); assert!(ch.is_user_allowed("U111")); assert!(ch.is_user_allowed("U222")); assert!(!ch.is_user_allowed("U333")); } #[test] fn allowlist_exact_match_not_substring() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); assert!(!ch.is_user_allowed("U1111")); assert!(!ch.is_user_allowed("U11")); } #[test] fn allowlist_empty_user_id() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); assert!(!ch.is_user_allowed("")); } #[test] fn allowlist_case_sensitive() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into()]); assert!(ch.is_user_allowed("U111")); assert!(!ch.is_user_allowed("u111")); } #[test] fn allowlist_wildcard_and_specific() { let ch = SlackChannel::new("xoxb-fake".into(), None, vec!["U111".into(), "*".into()]); assert!(ch.is_user_allowed("U111")); assert!(ch.is_user_allowed("anyone")); } // ── Message ID edge cases ───────────────────────────────────── #[test] fn slack_message_id_format_includes_channel_and_ts() { // Verify that message IDs follow the format: slack_{channel_id}_{ts} let ts = "1234567890.123456"; let channel_id = "C12345"; let expected_id = format!("slack_{channel_id}_{ts}"); assert_eq!(expected_id, "slack_C12345_1234567890.123456"); } #[test] fn slack_message_id_is_deterministic() { // Same channel_id + same ts = same ID (prevents duplicates after restart) let ts = "1234567890.123456"; let channel_id = "C12345"; let id1 = format!("slack_{channel_id}_{ts}"); let id2 = format!("slack_{channel_id}_{ts}"); assert_eq!(id1, id2); } #[test] fn slack_message_id_different_ts_different_id() { // Different timestamps produce different IDs let channel_id = "C12345"; let id1 = format!("slack_{channel_id}_1234567890.123456"); let id2 = format!("slack_{channel_id}_1234567890.123457"); assert_ne!(id1, id2); } #[test] fn slack_message_id_different_channel_different_id() { // Different channels produce different IDs even with same ts let ts = "1234567890.123456"; let id1 = format!("slack_C12345_{ts}"); let id2 = format!("slack_C67890_{ts}"); assert_ne!(id1, id2); } #[test] fn slack_message_id_no_uuid_randomness() { // Verify format doesn't contain random UUID components let ts = "1234567890.123456"; let channel_id = "C12345"; let id = format!("slack_{channel_id}_{ts}"); assert!(!id.contains('-')); // No UUID dashes assert!(id.starts_with("slack_")); } }