mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
Add fabro-static::EnvVars as the shared registry for fixed environment variable names and migrate env reads, clap env bindings, and subprocess/test allowlists to use it. Add clippy bans for raw std::env lookup APIs so future dynamic env facades must be documented explicitly.
241 lines
6.9 KiB
Rust
241 lines
6.9 KiB
Rust
use fabro_static::EnvVars;
|
|
use serde_json::{Value, json};
|
|
use tracing::debug;
|
|
|
|
const SLACK_API_BASE: &str = "https://slack.com/api";
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct PostedMessage {
|
|
pub channel_id: String,
|
|
pub ts: String,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SlackClient {
|
|
bot_token: String,
|
|
api_base: String,
|
|
http: fabro_http::HttpClient,
|
|
}
|
|
|
|
impl SlackClient {
|
|
#[expect(
|
|
clippy::disallowed_methods,
|
|
reason = "Slack client supports a documented process-env API base URL override."
|
|
)]
|
|
pub fn new(bot_token: String) -> Self {
|
|
let api_base =
|
|
std::env::var(EnvVars::SLACK_BASE_URL).unwrap_or_else(|_| SLACK_API_BASE.to_string());
|
|
Self {
|
|
bot_token,
|
|
api_base,
|
|
http: fabro_http::http_client().expect("Slack HTTP client should build"),
|
|
}
|
|
}
|
|
|
|
pub fn http(&self) -> &fabro_http::HttpClient {
|
|
&self.http
|
|
}
|
|
|
|
pub async fn post_message(
|
|
&self,
|
|
channel: &str,
|
|
blocks: &[Value],
|
|
thread_ts: Option<&str>,
|
|
) -> Result<PostedMessage, SlackApiError> {
|
|
let body = build_post_message_body(channel, blocks, thread_ts);
|
|
let resp = self
|
|
.http
|
|
.post(format!("{}/chat.postMessage", self.api_base))
|
|
.bearer_auth(&self.bot_token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SlackApiError::Http(e.to_string()))?;
|
|
|
|
let json: Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| SlackApiError::Http(e.to_string()))?;
|
|
|
|
let posted = parse_post_message_response(&json)?;
|
|
debug!(channel, ts = %posted.ts, "Posted Slack message");
|
|
Ok(posted)
|
|
}
|
|
|
|
pub async fn update_message(
|
|
&self,
|
|
channel: &str,
|
|
ts: &str,
|
|
blocks: &[Value],
|
|
) -> Result<(), SlackApiError> {
|
|
let body = build_update_message_body(channel, ts, blocks);
|
|
let resp = self
|
|
.http
|
|
.post(format!("{}/chat.update", self.api_base))
|
|
.bearer_auth(&self.bot_token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.map_err(|e| SlackApiError::Http(e.to_string()))?;
|
|
|
|
let json: Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| SlackApiError::Http(e.to_string()))?;
|
|
|
|
check_ok(&json)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum SlackApiError {
|
|
#[error("Slack HTTP error: {0}")]
|
|
Http(String),
|
|
#[error("Slack API error: {0}")]
|
|
Api(String),
|
|
}
|
|
|
|
fn check_ok(response: &Value) -> Result<(), SlackApiError> {
|
|
if response["ok"].as_bool() != Some(true) {
|
|
let error = response["error"].as_str().unwrap_or("unknown_error");
|
|
return Err(SlackApiError::Api(error.to_string()));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn parse_post_message_response(response: &Value) -> Result<PostedMessage, SlackApiError> {
|
|
check_ok(response)?;
|
|
let channel_id = response["channel"]
|
|
.as_str()
|
|
.ok_or_else(|| SlackApiError::Api("missing channel in response".to_string()))?;
|
|
let ts = response["ts"]
|
|
.as_str()
|
|
.ok_or_else(|| SlackApiError::Api("missing ts in response".to_string()))?;
|
|
Ok(PostedMessage {
|
|
channel_id: channel_id.to_string(),
|
|
ts: ts.to_string(),
|
|
})
|
|
}
|
|
|
|
pub fn parse_wss_url(response: &Value) -> Result<String, SlackApiError> {
|
|
check_ok(response)?;
|
|
response["url"]
|
|
.as_str()
|
|
.map(std::string::ToString::to_string)
|
|
.ok_or_else(|| SlackApiError::Api("missing url in response".to_string()))
|
|
}
|
|
|
|
fn build_post_message_body(channel: &str, blocks: &[Value], thread_ts: Option<&str>) -> Value {
|
|
let mut body = json!({
|
|
"channel": channel,
|
|
"blocks": blocks
|
|
});
|
|
if let Some(ts) = thread_ts {
|
|
body["thread_ts"] = json!(ts);
|
|
}
|
|
body
|
|
}
|
|
|
|
fn build_update_message_body(channel: &str, ts: &str, blocks: &[Value]) -> Value {
|
|
json!({
|
|
"channel": channel,
|
|
"ts": ts,
|
|
"blocks": blocks
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn post_message_request_body_format() {
|
|
let body =
|
|
build_post_message_body("#general", &[serde_json::json!({"type": "section"})], None);
|
|
assert_eq!(body["channel"], "#general");
|
|
assert_eq!(body["blocks"][0]["type"], "section");
|
|
assert!(body["thread_ts"].is_null());
|
|
}
|
|
|
|
#[test]
|
|
fn post_message_request_body_with_thread() {
|
|
let body = build_post_message_body(
|
|
"#general",
|
|
&[serde_json::json!({"type": "section"})],
|
|
Some("1234.5678"),
|
|
);
|
|
assert_eq!(body["thread_ts"], "1234.5678");
|
|
}
|
|
|
|
#[test]
|
|
fn update_message_request_body_format() {
|
|
let body = build_update_message_body("#general", "1234.5678", &[
|
|
serde_json::json!({"type": "section"}),
|
|
]);
|
|
assert_eq!(body["channel"], "#general");
|
|
assert_eq!(body["ts"], "1234.5678");
|
|
assert_eq!(body["blocks"][0]["type"], "section");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_post_message_response_extracts_channel_and_ts() {
|
|
let response = serde_json::json!({
|
|
"ok": true,
|
|
"channel": "C1234567890",
|
|
"ts": "1234.5678"
|
|
});
|
|
let posted = parse_post_message_response(&response).unwrap();
|
|
assert_eq!(posted.channel_id, "C1234567890");
|
|
assert_eq!(posted.ts, "1234.5678");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_post_message_response_api_error() {
|
|
let response = serde_json::json!({
|
|
"ok": false,
|
|
"error": "not_in_channel"
|
|
});
|
|
let err = parse_post_message_response(&response).unwrap_err();
|
|
assert!(err.to_string().contains("not_in_channel"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wss_url_success() {
|
|
let response = serde_json::json!({
|
|
"ok": true,
|
|
"url": "wss://wss-primary.slack.com/link/?ticket=abc123"
|
|
});
|
|
let url = parse_wss_url(&response).unwrap();
|
|
assert!(url.starts_with("wss://"));
|
|
assert!(url.contains("ticket=abc123"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wss_url_api_error() {
|
|
let response = serde_json::json!({
|
|
"ok": false,
|
|
"error": "invalid_auth"
|
|
});
|
|
let err = parse_wss_url(&response).unwrap_err();
|
|
assert!(err.to_string().contains("invalid_auth"));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_wss_url_missing_url() {
|
|
let response = serde_json::json!({
|
|
"ok": true
|
|
});
|
|
let err = parse_wss_url(&response).unwrap_err();
|
|
assert!(err.to_string().contains("missing url"));
|
|
}
|
|
|
|
#[test]
|
|
fn slack_api_error_display() {
|
|
let http_err = SlackApiError::Http("timeout".to_string());
|
|
assert_eq!(http_err.to_string(), "Slack HTTP error: timeout");
|
|
|
|
let api_err = SlackApiError::Api("channel_not_found".to_string());
|
|
assert_eq!(api_err.to_string(), "Slack API error: channel_not_found");
|
|
}
|
|
}
|