storkit: merge 413_refactor_split_slack_rs_into_focused_modules
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user