//! 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, /// Present only for `event_callback` events. pub event: Option, } #[derive(Deserialize, Debug)] pub struct SlackEvent { pub r#type: Option, /// Channel or DM where the message was sent. pub channel: Option, /// User who sent the message. pub user: Option, /// Message text. pub text: Option, /// Bot ID — present if the message was sent by a bot. pub bot_id: Option, /// Subtype (e.g. "bot_message", "message_changed") — absent for plain user messages. pub subtype: Option, } // ── 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>, ) -> 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>, ) -> 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 " " 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")); } }