293 lines
9.2 KiB
Rust
293 lines
9.2 KiB
Rust
//! 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<String>,
|
|
/// Message timestamp (acts as message ID in Slack).
|
|
#[serde(default)]
|
|
ts: Option<String>,
|
|
}
|
|
|
|
// ── 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<MessageId, String> {
|
|
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(|_| "<no body>".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(|_| "<no body>".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", "<p>hello</p>")
|
|
.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<T: ChatTransport>() {}
|
|
assert_transport::<SlackTransport>();
|
|
|
|
let _: Arc<dyn ChatTransport> = Arc::new(SlackTransport::new("xoxb-test".to_string()));
|
|
}
|
|
}
|