//! SlackTransport — ChatTransport implementation for the Slack Bot API. use async_trait::async_trait; use serde::{Deserialize, Serialize}; use crate::chat::{ChatTransport, MessageId}; use crate::slog; // ── Slack API base URL (overridable for tests) ────────────────────────── const SLACK_API_BASE: &str = "https://slack.com/api"; // ── SlackTransport ────────────────────────────────────────────────────── /// Slack Bot API transport. /// /// Sends messages via `POST {SLACK_API_BASE}/chat.postMessage` and edits /// via `POST {SLACK_API_BASE}/chat.update`. pub struct SlackTransport { bot_token: String, client: reqwest::Client, /// Optional base URL override for tests. api_base: String, } impl SlackTransport { /// Creates a new `SlackTransport` authenticated with the given bot token. pub fn new(bot_token: String) -> Self { Self { bot_token, client: reqwest::Client::new(), api_base: SLACK_API_BASE.to_string(), } } #[cfg(test)] fn with_api_base(bot_token: String, api_base: String) -> Self { Self { bot_token, client: reqwest::Client::new(), api_base, } } } // ── Slack API response types ──────────────────────────────────────────── #[derive(Deserialize, Debug)] struct SlackApiResponse { ok: bool, #[serde(default)] error: Option, /// Message timestamp (acts as message ID in Slack). #[serde(default)] ts: Option, } // ── Slack API request types ───────────────────────────────────────────── #[derive(Serialize)] struct PostMessageRequest<'a> { channel: &'a str, text: &'a str, } #[derive(Serialize)] struct UpdateMessageRequest<'a> { channel: &'a str, ts: &'a str, text: &'a str, } #[async_trait] impl ChatTransport for SlackTransport { async fn send_message( &self, channel: &str, plain: &str, _html: &str, ) -> Result { slog!("[slack] send_message to {channel}: {plain:.80}"); let url = format!("{}/chat.postMessage", self.api_base); let payload = PostMessageRequest { channel, text: plain, }; let resp = self .client .post(&url) .bearer_auth(&self.bot_token) .json(&payload) .send() .await .map_err(|e| format!("Slack API request failed: {e}"))?; let status = resp.status(); let resp_text = resp .text() .await .unwrap_or_else(|_| "".to_string()); if !status.is_success() { return Err(format!("Slack API returned {status}: {resp_text}")); } let parsed: SlackApiResponse = serde_json::from_str(&resp_text) .map_err(|e| format!("Failed to parse Slack API response: {e} — body: {resp_text}"))?; if !parsed.ok { return Err(format!( "Slack API error: {}", parsed.error.unwrap_or_else(|| "unknown".to_string()) )); } Ok(parsed.ts.unwrap_or_default()) } async fn edit_message( &self, channel: &str, original_message_id: &str, plain: &str, _html: &str, ) -> Result<(), String> { slog!("[slack] edit_message in {channel}: ts={original_message_id}"); let url = format!("{}/chat.update", self.api_base); let payload = UpdateMessageRequest { channel, ts: original_message_id, text: plain, }; let resp = self .client .post(&url) .bearer_auth(&self.bot_token) .json(&payload) .send() .await .map_err(|e| format!("Slack chat.update request failed: {e}"))?; let status = resp.status(); let resp_text = resp .text() .await .unwrap_or_else(|_| "".to_string()); if !status.is_success() { return Err(format!("Slack chat.update returned {status}: {resp_text}")); } let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| { format!("Failed to parse Slack chat.update response: {e} — body: {resp_text}") })?; if !parsed.ok { return Err(format!( "Slack chat.update error: {}", parsed.error.unwrap_or_else(|| "unknown".to_string()) )); } Ok(()) } async fn send_typing(&self, _channel: &str, _typing: bool) -> Result<(), String> { // Slack Bot API does not expose typing indicators for bots. Ok(()) } } // ── Tests ─────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; use std::sync::Arc; #[tokio::test] async fn transport_send_message_calls_slack_api() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/chat.postMessage") .match_header("authorization", "Bearer xoxb-test-token") .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) .create_async() .await; let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url()); let result = transport .send_message("C01ABCDEF", "hello", "

hello

") .await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "1234567890.123456"); mock.assert_async().await; } #[tokio::test] async fn transport_send_message_handles_api_error() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/chat.postMessage") .with_body(r#"{"ok": false, "error": "channel_not_found"}"#) .create_async() .await; let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url()); let result = transport.send_message("C_INVALID", "hello", "").await; assert!(result.is_err()); assert!( result.unwrap_err().contains("channel_not_found"), "error should contain the Slack error code" ); } #[tokio::test] async fn transport_edit_message_calls_chat_update() { let mut server = mockito::Server::new_async().await; let mock = server .mock("POST", "/chat.update") .match_header("authorization", "Bearer xoxb-test-token") .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) .create_async() .await; let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url()); let result = transport .edit_message("C01ABCDEF", "1234567890.123456", "updated", "") .await; assert!(result.is_ok()); mock.assert_async().await; } #[tokio::test] async fn transport_edit_message_handles_error() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/chat.update") .with_body(r#"{"ok": false, "error": "message_not_found"}"#) .create_async() .await; let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url()); let result = transport .edit_message("C01ABCDEF", "bad-ts", "updated", "") .await; assert!(result.is_err()); assert!(result.unwrap_err().contains("message_not_found")); } #[tokio::test] async fn transport_send_typing_succeeds() { let transport = SlackTransport::new("xoxb-test".to_string()); assert!(transport.send_typing("C01ABCDEF", true).await.is_ok()); assert!(transport.send_typing("C01ABCDEF", false).await.is_ok()); } #[tokio::test] async fn transport_handles_http_error() { let mut server = mockito::Server::new_async().await; server .mock("POST", "/chat.postMessage") .with_status(500) .with_body("Internal Server Error") .create_async() .await; let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url()); let result = transport.send_message("C01ABCDEF", "hello", "").await; assert!(result.is_err()); assert!(result.unwrap_err().contains("500")); } // ── ChatTransport trait satisfaction ───────────────────────────────── #[test] fn slack_transport_satisfies_trait() { fn assert_transport() {} assert_transport::(); let _: Arc = Arc::new(SlackTransport::new("xoxb-test".to_string())); } }