storkit: merge 413_refactor_split_slack_rs_into_focused_modules

This commit is contained in:
dave
2026-03-27 15:26:38 +00:00
parent 8ac8cdba88
commit 16853328fa
7 changed files with 2052 additions and 1985 deletions
+309
View File
@@ -0,0 +1,309 @@
//! 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 {
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()));
}
}