Files
huskies/server/src/chat/transport/slack/mod.rs
T

308 lines
10 KiB
Rust

//! Slack Bot API integration.
//!
//! Provides:
//! - [`SlackTransport`] — a [`ChatTransport`] that sends messages via the
//! Slack Web API (`api.slack.com/api/chat.postMessage` / `chat.update`).
//! - [`webhook_receive`] — Poem handler for the Slack Events API webhook
//! (POST incoming events including URL verification challenge).
pub mod commands;
pub mod format;
pub mod history;
pub mod meta;
pub mod verify;
pub use commands::SlackWebhookContext;
pub use format::markdown_to_slack;
pub use history::load_slack_history;
pub use meta::SlackTransport;
use serde::Deserialize;
use crate::slog;
use poem::{Request, Response, handler, http::StatusCode};
// ── Slack Events API types ──────────────────────────────────────────────
/// Outer envelope for Slack Events API callbacks.
///
/// Slack sends three types of payloads:
/// - `url_verification`: challenge-response handshake during app setup
/// - `event_callback`: actual events (messages, reactions, etc.)
#[derive(Deserialize, Debug)]
pub struct SlackEventEnvelope {
pub r#type: String,
/// Present only for `url_verification` events.
pub challenge: Option<String>,
/// Present only for `event_callback` events.
pub event: Option<SlackEvent>,
}
#[derive(Deserialize, Debug)]
pub struct SlackEvent {
pub r#type: Option<String>,
/// Channel or DM where the message was sent.
pub channel: Option<String>,
/// User who sent the message.
pub user: Option<String>,
/// Message text.
pub text: Option<String>,
/// Bot ID — present if the message was sent by a bot.
pub bot_id: Option<String>,
/// Subtype (e.g. "bot_message", "message_changed") — absent for plain user messages.
pub subtype: Option<String>,
}
// ── Webhook handlers ────────────────────────────────────────────────────
/// POST /webhook/slack — receive incoming events from Slack Events API.
///
/// Handles both `url_verification` (challenge-response handshake) and
/// `event_callback` (incoming messages) event types.
#[handler]
pub async fn webhook_receive(
req: &Request,
body: poem::Body,
ctx: poem::web::Data<&std::sync::Arc<SlackWebhookContext>>,
) -> Response {
use std::sync::Arc;
let timestamp = req
.header("X-Slack-Request-Timestamp")
.unwrap_or("")
.to_string();
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
let bytes = match body.into_bytes().await {
Ok(b) => b,
Err(e) => {
slog!("[slack] Failed to read webhook body: {e}");
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Bad request");
}
};
// Verify request signature.
if !verify::verify_slack_signature(&ctx.signing_secret, &timestamp, &bytes, &signature) {
slog!("[slack] Webhook signature verification failed");
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Invalid signature");
}
let envelope: SlackEventEnvelope = match serde_json::from_slice(&bytes) {
Ok(e) => e,
Err(e) => {
slog!("[slack] Failed to parse webhook payload: {e}");
return Response::builder().status(StatusCode::OK).body("ok");
}
};
// Handle URL verification challenge.
if envelope.r#type == "url_verification" {
if let Some(challenge) = envelope.challenge {
slog!("[slack] URL verification succeeded");
return Response::builder()
.status(StatusCode::OK)
.content_type("text/plain")
.body(challenge);
}
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Missing challenge");
}
// Handle event callbacks.
if envelope.r#type == "event_callback"
&& let Some(event) = envelope.event
&& event.r#type.as_deref() == Some("message")
&& event.subtype.is_none()
&& event.bot_id.is_none()
&& let (Some(channel), Some(user), Some(text)) = (event.channel, event.user, event.text)
&& ctx.channel_ids.contains(&channel)
{
let ctx = Arc::clone(*ctx);
tokio::spawn(async move {
slog!("[slack] Message from {user} in {channel}: {text}");
commands::handle_incoming_message(&ctx, &channel, &user, &text).await;
});
}
Response::builder().status(StatusCode::OK).body("ok")
}
/// POST /webhook/slack/command — receive incoming Slack slash commands.
///
/// Slash commands arrive as `application/x-www-form-urlencoded` POST requests.
/// The response is JSON with `response_type: "ephemeral"` so only the invoking
/// user sees the reply.
#[handler]
pub async fn slash_command_receive(
req: &Request,
body: poem::Body,
ctx: poem::web::Data<&std::sync::Arc<SlackWebhookContext>>,
) -> Response {
let timestamp = req
.header("X-Slack-Request-Timestamp")
.unwrap_or("")
.to_string();
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
let bytes = match body.into_bytes().await {
Ok(b) => b,
Err(e) => {
slog!("[slack] Failed to read slash command body: {e}");
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Bad request");
}
};
// Verify request signature.
if !verify::verify_slack_signature(&ctx.signing_secret, &timestamp, &bytes, &signature) {
slog!("[slack] Slash command signature verification failed");
return Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body("Invalid signature");
}
let payload: commands::SlackSlashCommandPayload = match serde_urlencoded::from_bytes(&bytes) {
Ok(p) => p,
Err(e) => {
slog!("[slack] Failed to parse slash command payload: {e}");
return Response::builder()
.status(StatusCode::BAD_REQUEST)
.body("Bad request");
}
};
slog!(
"[slack] Slash command from {}: {} {}",
payload.user_id,
payload.command,
payload.text
);
let keyword = match commands::slash_command_to_bot_keyword(&payload.command) {
Some(k) => k,
None => {
let resp = commands::SlashCommandResponse {
response_type: "ephemeral",
text: format!("Unknown command: {}", payload.command),
};
return Response::builder()
.status(StatusCode::OK)
.content_type("application/json")
.body(serde_json::to_string(&resp).unwrap_or_default());
}
};
// Build a synthetic message that the command registry can parse.
// The format is "<bot_name> <keyword> <args>" so strip_bot_mention + dispatch works.
let synthetic_message = if payload.text.is_empty() {
format!("{} {keyword}", ctx.services.bot_name)
} else {
format!("{} {keyword} {}", ctx.services.bot_name, payload.text)
};
use crate::chat::commands::{CommandDispatch, try_handle_command};
let dispatch = CommandDispatch {
bot_name: &ctx.services.bot_name,
bot_user_id: &ctx.services.bot_user_id,
project_root: &ctx.services.project_root,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: &payload.channel_id,
};
let response_text = try_handle_command(&dispatch, &synthetic_message)
.unwrap_or_else(|| format!("Command `{keyword}` did not produce a response."));
let response_text = markdown_to_slack(&response_text);
let resp = commands::SlashCommandResponse {
response_type: "ephemeral",
text: response_text,
};
Response::builder()
.status(StatusCode::OK)
.content_type("application/json")
.body(serde_json::to_string(&resp).unwrap_or_default())
}
// ── Tests ───────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_url_verification_event() {
let json = r#"{
"type": "url_verification",
"challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}"#;
let envelope: SlackEventEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(envelope.r#type, "url_verification");
assert_eq!(
envelope.challenge.as_deref(),
Some("3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P")
);
}
#[test]
fn parse_message_event() {
let json = r#"{
"type": "event_callback",
"event": {
"type": "message",
"channel": "C01ABCDEF",
"user": "U01GHIJKL",
"text": "help"
}
}"#;
let envelope: SlackEventEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(envelope.r#type, "event_callback");
let event = envelope.event.unwrap();
assert_eq!(event.r#type.as_deref(), Some("message"));
assert_eq!(event.channel.as_deref(), Some("C01ABCDEF"));
assert_eq!(event.user.as_deref(), Some("U01GHIJKL"));
assert_eq!(event.text.as_deref(), Some("help"));
assert!(event.bot_id.is_none());
assert!(event.subtype.is_none());
}
#[test]
fn parse_bot_message_has_bot_id() {
let json = r#"{
"type": "event_callback",
"event": {
"type": "message",
"channel": "C01ABCDEF",
"bot_id": "B01234",
"text": "I am a bot"
}
}"#;
let envelope: SlackEventEnvelope = serde_json::from_str(json).unwrap();
let event = envelope.event.unwrap();
assert!(event.bot_id.is_some());
}
#[test]
fn parse_message_with_subtype() {
let json = r#"{
"type": "event_callback",
"event": {
"type": "message",
"subtype": "message_changed",
"channel": "C01ABCDEF"
}
}"#;
let envelope: SlackEventEnvelope = serde_json::from_str(json).unwrap();
let event = envelope.event.unwrap();
assert_eq!(event.subtype.as_deref(), Some("message_changed"));
}
}