diff --git a/.story_kit/specs/functional/SLACK_SETUP.md b/.story_kit/specs/functional/SLACK_SETUP.md new file mode 100644 index 0000000..062709e --- /dev/null +++ b/.story_kit/specs/functional/SLACK_SETUP.md @@ -0,0 +1,44 @@ +# Slack Integration Setup + +## Bot Configuration + +Slack integration is configured via `bot.toml` in the project's `.story_kit/` directory: + +```toml +transport = "slack" +display_name = "Storkit" +slack_bot_token = "xoxb-..." +slack_signing_secret = "..." +slack_channel_ids = ["C01ABCDEF"] +``` + +## Slack App Configuration + +### Event Subscriptions + +1. In your Slack app settings, enable **Event Subscriptions**. +2. Set the **Request URL** to: `https:///webhook/slack` +3. Subscribe to the `message.channels` and `message.im` bot events. + +### Slash Commands + +Slash commands provide quick access to pipeline commands without mentioning the bot. + +1. In your Slack app settings, go to **Slash Commands**. +2. Create the following commands, all pointing to the same **Request URL**: `https:///webhook/slack/command` + +| Command | Description | +|---------|-------------| +| `/storkit-status` | Show pipeline status and agent availability | +| `/storkit-cost` | Show token spend: 24h total, top stories, and breakdown | +| `/storkit-show` | Display the full text of a work item (e.g. `/storkit-show 42`) | +| `/storkit-git` | Show git status: branch, changes, ahead/behind | +| `/storkit-htop` | Show system and agent process dashboard | + +All slash command responses are **ephemeral** — only the user who invoked the command sees the response. + +### OAuth & Permissions + +Required bot token scopes: +- `chat:write` — send messages +- `commands` — handle slash commands diff --git a/Cargo.lock b/Cargo.lock index de6c4ec..009c830 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4021,6 +4021,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "serde_urlencoded", "serde_yaml", "strip-ansi-escapes", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 749cb13..f9b6dc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ reqwest = { version = "0.13.2", features = ["json", "stream"] } rust-embed = "8" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_urlencoded = "0.7" serde_yaml = "0.9" strip-ansi-escapes = "0.2" tempfile = "3" diff --git a/server/Cargo.toml b/server/Cargo.toml index d0c0467..5f8edfc 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -22,6 +22,7 @@ reqwest = { workspace = true, features = ["json", "stream"] } rust-embed = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_urlencoded = { workspace = true } serde_yaml = { workspace = true } strip-ansi-escapes = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync"] } diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index b8d9fb6..4123819 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -90,10 +90,15 @@ pub fn build_routes( } if let Some(sl_ctx) = slack_ctx { - route = route.at( - "/webhook/slack", - post(crate::slack::webhook_receive).data(sl_ctx), - ); + route = route + .at( + "/webhook/slack", + post(crate::slack::webhook_receive).data(sl_ctx.clone()), + ) + .at( + "/webhook/slack/command", + post(crate::slack::slash_command_receive).data(sl_ctx), + ); } route.data(ctx_arc) diff --git a/server/src/slack.rs b/server/src/slack.rs index b6c011d..955ce9f 100644 --- a/server/src/slack.rs +++ b/server/src/slack.rs @@ -440,6 +440,49 @@ fn save_slack_history( } } +// ── Slash command types ───────────────────────────────────────────────── + +/// Payload sent by Slack for slash commands (application/x-www-form-urlencoded). +#[derive(Deserialize, Debug)] +pub struct SlackSlashCommandPayload { + /// The slash command that was invoked (e.g. "/storkit-status"). + pub command: String, + /// Any text typed after the command (e.g. "42" for "/storkit-show 42"). + #[serde(default)] + pub text: String, + /// The user who invoked the command. + #[serde(default)] + pub user_id: String, + /// The channel where the command was invoked. + #[serde(default)] + pub channel_id: String, +} + +/// JSON response for Slack slash commands. +#[derive(Serialize)] +struct SlashCommandResponse { + response_type: &'static str, + text: String, +} + +/// Map a Slack slash command name to the corresponding bot command keyword. +/// +/// Supported: `/storkit-status`, `/storkit-cost`, `/storkit-show`, +/// `/storkit-git`, `/storkit-htop`. +fn slash_command_to_bot_keyword(command: &str) -> Option<&'static str> { + // Strip leading "/" and the "storkit-" prefix. + let name = command.strip_prefix('/').unwrap_or(command); + let keyword = name.strip_prefix("storkit-")?; + match keyword { + "status" => Some("status"), + "cost" => Some("cost"), + "show" => Some("show"), + "git" => Some("git"), + "htop" => Some("htop"), + _ => None, + } +} + // ── Webhook handler (Poem) ────────────────────────────────────────────── use poem::{Request, Response, handler, http::StatusCode}; @@ -548,6 +591,110 @@ pub async fn webhook_receive( .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<&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_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: 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 slash_command_to_bot_keyword(&payload.command) { + Some(k) => k, + None => { + let resp = 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.bot_name) + } else { + format!("{} {keyword} {}", ctx.bot_name, payload.text) + }; + + use crate::matrix::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, + is_addressed: true, + }; + + let response_text = try_handle_command(&dispatch, &synthetic_message) + .unwrap_or_else(|| format!("Command `{keyword}` did not produce a response.")); + + let resp = 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()) +} + /// Dispatch an incoming Slack message to bot commands or LLM. async fn handle_incoming_message( ctx: &SlackWebhookContext, @@ -1177,4 +1324,145 @@ mod tests { let _: Arc = Arc::new(SlackTransport::new("xoxb-test".to_string())); } + + // ── Slash command types ──────────────────────────────────────────── + + #[test] + fn parse_slash_command_payload() { + let body = "command=%2Fstorkit-status&text=&user_id=U123&channel_id=C456"; + let payload: SlackSlashCommandPayload = serde_urlencoded::from_str(body).unwrap(); + assert_eq!(payload.command, "/storkit-status"); + assert_eq!(payload.text, ""); + assert_eq!(payload.user_id, "U123"); + assert_eq!(payload.channel_id, "C456"); + } + + #[test] + fn parse_slash_command_payload_with_text() { + let body = "command=%2Fstorkit-show&text=42&user_id=U123&channel_id=C456"; + let payload: SlackSlashCommandPayload = serde_urlencoded::from_str(body).unwrap(); + assert_eq!(payload.command, "/storkit-show"); + assert_eq!(payload.text, "42"); + } + + // ── slash_command_to_bot_keyword ─────────────────────────────────── + + #[test] + fn slash_command_maps_status() { + assert_eq!(slash_command_to_bot_keyword("/storkit-status"), Some("status")); + } + + #[test] + fn slash_command_maps_cost() { + assert_eq!(slash_command_to_bot_keyword("/storkit-cost"), Some("cost")); + } + + #[test] + fn slash_command_maps_show() { + assert_eq!(slash_command_to_bot_keyword("/storkit-show"), Some("show")); + } + + #[test] + fn slash_command_maps_git() { + assert_eq!(slash_command_to_bot_keyword("/storkit-git"), Some("git")); + } + + #[test] + fn slash_command_maps_htop() { + assert_eq!(slash_command_to_bot_keyword("/storkit-htop"), Some("htop")); + } + + #[test] + fn slash_command_unknown_returns_none() { + assert_eq!(slash_command_to_bot_keyword("/storkit-unknown"), None); + } + + #[test] + fn slash_command_non_storkit_returns_none() { + assert_eq!(slash_command_to_bot_keyword("/other-command"), None); + } + + // ── SlashCommandResponse serialization ──────────────────────────── + + #[test] + fn slash_response_is_ephemeral() { + let resp = SlashCommandResponse { + response_type: "ephemeral", + text: "hello".to_string(), + }; + let json: serde_json::Value = serde_json::from_str( + &serde_json::to_string(&resp).unwrap() + ).unwrap(); + assert_eq!(json["response_type"], "ephemeral"); + assert_eq!(json["text"], "hello"); + } + + // ── Slash command shares handlers with mention-based commands ────── + + fn test_agents() -> Arc { + Arc::new(crate::agents::AgentPool::new_test(3000)) + } + + fn test_ambient_rooms() -> Arc>> { + Arc::new(Mutex::new(HashSet::new())) + } + + #[test] + fn slash_command_dispatches_through_command_registry() { + // Verify that the synthetic message built by the slash handler + // correctly dispatches through try_handle_command. + use crate::matrix::commands::{CommandDispatch, try_handle_command}; + + let agents = test_agents(); + let ambient_rooms = test_ambient_rooms(); + let room_id = "C01ABCDEF".to_string(); + + // Simulate what slash_command_receive does: build a synthetic message. + let bot_name = "Storkit"; + let keyword = slash_command_to_bot_keyword("/storkit-status").unwrap(); + let synthetic = format!("{bot_name} {keyword}"); + + let dispatch = CommandDispatch { + bot_name, + bot_user_id: "slack-bot", + project_root: std::path::Path::new("/tmp"), + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + is_addressed: true, + }; + + let result = try_handle_command(&dispatch, &synthetic); + assert!(result.is_some(), "status slash command should produce output via registry"); + assert!(result.unwrap().contains("Pipeline Status")); + } + + #[test] + fn slash_command_show_passes_args_through_registry() { + use crate::matrix::commands::{CommandDispatch, try_handle_command}; + + let agents = test_agents(); + let ambient_rooms = test_ambient_rooms(); + let room_id = "C01ABCDEF".to_string(); + + let bot_name = "Storkit"; + let keyword = slash_command_to_bot_keyword("/storkit-show").unwrap(); + // Simulate /storkit-show with text "999" + let synthetic = format!("{bot_name} {keyword} 999"); + + let dispatch = CommandDispatch { + bot_name, + bot_user_id: "slack-bot", + project_root: std::path::Path::new("/tmp"), + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + is_addressed: true, + }; + + let result = try_handle_command(&dispatch, &synthetic); + assert!(result.is_some(), "show slash command should produce output"); + let output = result.unwrap(); + assert!(output.contains("999"), "show output should reference the story number: {output}"); + } }