308 lines
10 KiB
Rust
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, ×tamp, &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, ×tamp, &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"));
|
|
}
|
|
}
|