story-kit: merge 326_story_slack_slash_commands_for_pipeline_management

This commit is contained in:
Dave
2026-03-20 01:23:54 +00:00
parent ff1705f26c
commit 39707ce026
6 changed files with 344 additions and 4 deletions

View File

@@ -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://<your-host>/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://<your-host>/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

1
Cargo.lock generated
View File

@@ -4021,6 +4021,7 @@ dependencies = [
"rust-embed",
"serde",
"serde_json",
"serde_urlencoded",
"serde_yaml",
"strip-ansi-escapes",
"tempfile",

View File

@@ -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"

View File

@@ -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"] }

View File

@@ -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)

View File

@@ -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<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_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: 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 "<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::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<dyn ChatTransport> = 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<crate::agents::AgentPool> {
Arc::new(crate::agents::AgentPool::new_test(3000))
}
fn test_ambient_rooms() -> Arc<Mutex<HashSet<String>>> {
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}");
}
}