story-kit: merge 326_story_slack_slash_commands_for_pipeline_management
This commit is contained in:
44
.story_kit/specs/functional/SLACK_SETUP.md
Normal file
44
.story_kit/specs/functional/SLACK_SETUP.md
Normal 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
1
Cargo.lock
generated
@@ -4021,6 +4021,7 @@ dependencies = [
|
||||
"rust-embed",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"serde_yaml",
|
||||
"strip-ansi-escapes",
|
||||
"tempfile",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, ×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 "<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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user