storkit: merge 413_refactor_split_slack_rs_into_focused_modules
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
//! 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 history::load_slack_history;
|
||||
pub use meta::SlackTransport;
|
||||
pub use format::markdown_to_slack;
|
||||
pub use commands::SlackWebhookContext;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use poem::{Request, Response, handler, http::StatusCode};
|
||||
use crate::slog;
|
||||
|
||||
// ── 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.bot_name)
|
||||
} else {
|
||||
format!("{} {keyword} {}", ctx.bot_name, payload.text)
|
||||
};
|
||||
|
||||
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
||||
|
||||
let dispatch = CommandDispatch {
|
||||
bot_name: &ctx.bot_name,
|
||||
bot_user_id: &ctx.bot_user_id,
|
||||
project_root: &ctx.project_root,
|
||||
agents: &ctx.agents,
|
||||
ambient_rooms: &ctx.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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user