From 16853328fa942ad217cca718735f706a10a0851b Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 27 Mar 2026 15:26:38 +0000 Subject: [PATCH] storkit: merge 413_refactor_split_slack_rs_into_focused_modules --- server/src/chat/transport/slack.rs | 1985 ------------------- server/src/chat/transport/slack/commands.rs | 877 ++++++++ server/src/chat/transport/slack/format.rs | 128 ++ server/src/chat/transport/slack/history.rs | 119 ++ server/src/chat/transport/slack/meta.rs | 309 +++ server/src/chat/transport/slack/mod.rs | 319 +++ server/src/chat/transport/slack/verify.rs | 300 +++ 7 files changed, 2052 insertions(+), 1985 deletions(-) delete mode 100644 server/src/chat/transport/slack.rs create mode 100644 server/src/chat/transport/slack/commands.rs create mode 100644 server/src/chat/transport/slack/format.rs create mode 100644 server/src/chat/transport/slack/history.rs create mode 100644 server/src/chat/transport/slack/meta.rs create mode 100644 server/src/chat/transport/slack/mod.rs create mode 100644 server/src/chat/transport/slack/verify.rs diff --git a/server/src/chat/transport/slack.rs b/server/src/chat/transport/slack.rs deleted file mode 100644 index bf8ff03c..00000000 --- a/server/src/chat/transport/slack.rs +++ /dev/null @@ -1,1985 +0,0 @@ -//! 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). - -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fmt::Write as FmtWrite; -use std::sync::Arc; -use tokio::sync::oneshot; -use tokio::sync::Mutex as TokioMutex; - -use crate::agents::AgentPool; -use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation}; -use crate::slog; -use crate::chat::{ChatTransport, MessageId}; -use crate::http::context::{PermissionDecision, PermissionForward}; - -// ── Slack API base URL (overridable for tests) ────────────────────────── - -const SLACK_API_BASE: &str = "https://slack.com/api"; - -// ── SlackTransport ────────────────────────────────────────────────────── - -/// Slack Bot API transport. -/// -/// Sends messages via `POST {SLACK_API_BASE}/chat.postMessage` and edits -/// via `POST {SLACK_API_BASE}/chat.update`. -pub struct SlackTransport { - bot_token: String, - client: reqwest::Client, - /// Optional base URL override for tests. - api_base: String, -} - -impl SlackTransport { - pub fn new(bot_token: String) -> Self { - Self { - bot_token, - client: reqwest::Client::new(), - api_base: SLACK_API_BASE.to_string(), - } - } - - #[cfg(test)] - fn with_api_base(bot_token: String, api_base: String) -> Self { - Self { - bot_token, - client: reqwest::Client::new(), - api_base, - } - } -} - -// ── Slack API response types ──────────────────────────────────────────── - -#[derive(Deserialize, Debug)] -struct SlackApiResponse { - ok: bool, - #[serde(default)] - error: Option, - /// Message timestamp (acts as message ID in Slack). - #[serde(default)] - ts: Option, -} - -// ── Slack API request types ───────────────────────────────────────────── - -#[derive(Serialize)] -struct PostMessageRequest<'a> { - channel: &'a str, - text: &'a str, -} - -#[derive(Serialize)] -struct UpdateMessageRequest<'a> { - channel: &'a str, - ts: &'a str, - text: &'a str, -} - -#[async_trait] -impl ChatTransport for SlackTransport { - async fn send_message( - &self, - channel: &str, - plain: &str, - _html: &str, - ) -> Result { - slog!("[slack] send_message to {channel}: {plain:.80}"); - let url = format!("{}/chat.postMessage", self.api_base); - - let payload = PostMessageRequest { - channel, - text: plain, - }; - - let resp = self - .client - .post(&url) - .bearer_auth(&self.bot_token) - .json(&payload) - .send() - .await - .map_err(|e| format!("Slack API request failed: {e}"))?; - - let status = resp.status(); - let resp_text = resp - .text() - .await - .unwrap_or_else(|_| "".to_string()); - - if !status.is_success() { - return Err(format!("Slack API returned {status}: {resp_text}")); - } - - let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| { - format!("Failed to parse Slack API response: {e} — body: {resp_text}") - })?; - - if !parsed.ok { - return Err(format!( - "Slack API error: {}", - parsed.error.unwrap_or_else(|| "unknown".to_string()) - )); - } - - Ok(parsed.ts.unwrap_or_default()) - } - - async fn edit_message( - &self, - channel: &str, - original_message_id: &str, - plain: &str, - _html: &str, - ) -> Result<(), String> { - slog!("[slack] edit_message in {channel}: ts={original_message_id}"); - let url = format!("{}/chat.update", self.api_base); - - let payload = UpdateMessageRequest { - channel, - ts: original_message_id, - text: plain, - }; - - let resp = self - .client - .post(&url) - .bearer_auth(&self.bot_token) - .json(&payload) - .send() - .await - .map_err(|e| format!("Slack chat.update request failed: {e}"))?; - - let status = resp.status(); - let resp_text = resp - .text() - .await - .unwrap_or_else(|_| "".to_string()); - - if !status.is_success() { - return Err(format!("Slack chat.update returned {status}: {resp_text}")); - } - - let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| { - format!("Failed to parse Slack chat.update response: {e} — body: {resp_text}") - })?; - - if !parsed.ok { - return Err(format!( - "Slack chat.update error: {}", - parsed.error.unwrap_or_else(|| "unknown".to_string()) - )); - } - - Ok(()) - } - - async fn send_typing(&self, _channel: &str, _typing: bool) -> Result<(), String> { - // Slack Bot API does not expose typing indicators for bots. - Ok(()) - } -} - -// ── 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, -} - -// ── Request signature verification ────────────────────────────────────── - -/// Verify the Slack request signature using HMAC-SHA256. -/// -/// Slack sends `X-Slack-Signature` and `X-Slack-Request-Timestamp` headers. -/// We compute `HMAC-SHA256(signing_secret, "v0:{timestamp}:{body}")` and -/// compare it to the provided signature. -/// -/// This uses a constant-time comparison to prevent timing attacks. -fn verify_slack_signature( - signing_secret: &str, - timestamp: &str, - body: &[u8], - signature: &str, -) -> bool { - // Compute HMAC-SHA256 manually using the signing secret. - // Slack signature format: v0={hex(HMAC-SHA256(secret, "v0:{ts}:{body}"))} - let base_string = format!("v0:{timestamp}:"); - - // Simple HMAC-SHA256 implementation using ring-style approach. - // We use the hmac crate pattern with SHA-256. - // Since we don't want to add a dependency, we'll use a manual approach: - // HMAC(K, m) = H((K' ^ opad) || H((K' ^ ipad) || m)) - // where K' is the key padded/hashed to block size. - - let key = signing_secret.as_bytes(); - let block_size = 64; // SHA-256 block size - - // If key is longer than block size, hash it first. - let key_block = if key.len() > block_size { - let digest = sha256(key); - let mut k = vec![0u8; block_size]; - k[..32].copy_from_slice(&digest); - k - } else { - let mut k = vec![0u8; block_size]; - k[..key.len()].copy_from_slice(key); - k - }; - - // Inner and outer padded keys. - let mut ipad = vec![0x36u8; block_size]; - let mut opad = vec![0x5cu8; block_size]; - for i in 0..block_size { - ipad[i] ^= key_block[i]; - opad[i] ^= key_block[i]; - } - - // Inner hash: H(ipad || message) - let mut inner_data = ipad; - inner_data.extend_from_slice(base_string.as_bytes()); - inner_data.extend_from_slice(body); - let inner_hash = sha256(&inner_data); - - // Outer hash: H(opad || inner_hash) - let mut outer_data = opad; - outer_data.extend_from_slice(&inner_hash); - let hmac_result = sha256(&outer_data); - - // Format as "v0={hex}" - let mut expected = String::from("v0="); - for byte in &hmac_result { - write!(expected, "{byte:02x}").unwrap(); - } - - // Constant-time comparison. - constant_time_eq(expected.as_bytes(), signature.as_bytes()) -} - -/// Minimal SHA-256 implementation (no external dependency). -/// -/// This follows FIPS 180-4. Only used for HMAC signature verification, -/// not for any security-critical path beyond webhook authentication. -fn sha256(data: &[u8]) -> [u8; 32] { - let mut h: [u32; 8] = [ - 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, - 0x5be0cd19, - ]; - - let k: [u32; 64] = [ - 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, - 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, - 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, - 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, - 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, - 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, - 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, - 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, - 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, - 0xc67178f2, - ]; - - // Pre-processing: padding - let bit_len = (data.len() as u64) * 8; - let mut padded = data.to_vec(); - padded.push(0x80); - while (padded.len() % 64) != 56 { - padded.push(0); - } - padded.extend_from_slice(&bit_len.to_be_bytes()); - - // Process each 512-bit block - for chunk in padded.chunks_exact(64) { - let mut w = [0u32; 64]; - for i in 0..16 { - w[i] = u32::from_be_bytes([ - chunk[4 * i], - chunk[4 * i + 1], - chunk[4 * i + 2], - chunk[4 * i + 3], - ]); - } - for i in 16..64 { - let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); - let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); - w[i] = w[i - 16] - .wrapping_add(s0) - .wrapping_add(w[i - 7]) - .wrapping_add(s1); - } - - let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; - - for i in 0..64 { - let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); - let ch = (e & f) ^ ((!e) & g); - let temp1 = hh - .wrapping_add(s1) - .wrapping_add(ch) - .wrapping_add(k[i]) - .wrapping_add(w[i]); - let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); - let maj = (a & b) ^ (a & c) ^ (b & c); - let temp2 = s0.wrapping_add(maj); - - hh = g; - g = f; - f = e; - e = d.wrapping_add(temp1); - d = c; - c = b; - b = a; - a = temp1.wrapping_add(temp2); - } - - h[0] = h[0].wrapping_add(a); - h[1] = h[1].wrapping_add(b); - h[2] = h[2].wrapping_add(c); - h[3] = h[3].wrapping_add(d); - h[4] = h[4].wrapping_add(e); - h[5] = h[5].wrapping_add(f); - h[6] = h[6].wrapping_add(g); - h[7] = h[7].wrapping_add(hh); - } - - let mut result = [0u8; 32]; - for (i, val) in h.iter().enumerate() { - result[4 * i..4 * i + 4].copy_from_slice(&val.to_be_bytes()); - } - result -} - -/// Constant-time byte comparison. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - let mut diff = 0u8; - for (x, y) in a.iter().zip(b.iter()) { - diff |= x ^ y; - } - diff == 0 -} - -// ── Conversation history persistence ───────────────────────────────── - -/// Per-channel conversation history, keyed by channel ID. -pub type SlackConversationHistory = Arc>>; - -/// On-disk format for persisted Slack conversation history. -#[derive(Serialize, Deserialize)] -struct PersistedSlackHistory { - channels: HashMap, -} - -/// Path to the persisted Slack conversation history file. -const SLACK_HISTORY_FILE: &str = ".storkit/slack_history.json"; - -/// Load Slack conversation history from disk. -pub fn load_slack_history(project_root: &std::path::Path) -> HashMap { - let path = project_root.join(SLACK_HISTORY_FILE); - let data = match std::fs::read_to_string(&path) { - Ok(d) => d, - Err(_) => return HashMap::new(), - }; - let persisted: PersistedSlackHistory = match serde_json::from_str(&data) { - Ok(p) => p, - Err(e) => { - slog!("[slack] Failed to parse history file: {e}"); - return HashMap::new(); - } - }; - persisted.channels -} - -/// Save Slack conversation history to disk. -fn save_slack_history( - project_root: &std::path::Path, - history: &HashMap, -) { - let persisted = PersistedSlackHistory { - channels: history.clone(), - }; - let path = project_root.join(SLACK_HISTORY_FILE); - match serde_json::to_string_pretty(&persisted) { - Ok(json) => { - if let Err(e) = std::fs::write(&path, json) { - slog!("[slack] Failed to write history file: {e}"); - } - } - Err(e) => slog!("[slack] Failed to serialise history: {e}"), - } -} - -// ── 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}; -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::Mutex; - -/// Shared context for the Slack webhook handler, injected via Poem's `Data` extractor. -pub struct SlackWebhookContext { - pub signing_secret: String, - pub transport: Arc, - pub project_root: PathBuf, - pub agents: Arc, - pub bot_name: String, - /// The bot's "user ID" for command dispatch. - pub bot_user_id: String, - pub ambient_rooms: Arc>>, - /// Per-channel conversation history for LLM passthrough. - pub history: SlackConversationHistory, - /// Maximum number of conversation entries to keep per channel. - pub history_size: usize, - /// Allowed channel IDs (messages from other channels are ignored). - pub channel_ids: HashSet, - /// Permission requests from the MCP `prompt_permission` tool arrive here. - pub perm_rx: Arc>>, - /// Pending permission replies keyed by channel ID. - pub pending_perm_replies: - Arc>>>, - /// Seconds before an unanswered permission prompt is auto-denied. - pub permission_timeout_secs: u64, -} - -/// 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<&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 webhook 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] 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}"); - 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<&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::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 = 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. -/// Returns `true` if the message body should be interpreted as permission approval. -fn is_permission_approval(body: &str) -> bool { - let trimmed = body.trim().to_ascii_lowercase(); - matches!( - trimmed.as_str(), - "yes" | "y" | "approve" | "allow" | "ok" - ) -} - -async fn handle_incoming_message( - ctx: &SlackWebhookContext, - channel: &str, - user: &str, - message: &str, -) { - use crate::chat::commands::{CommandDispatch, try_handle_command}; - - // If there is a pending permission prompt for this channel, interpret the - // message as a yes/no response instead of starting a new command/LLM flow. - { - let mut pending = ctx.pending_perm_replies.lock().await; - if let Some(tx) = pending.remove(channel) { - let decision = if is_permission_approval(message) { - PermissionDecision::Approve - } else { - PermissionDecision::Deny - }; - let _ = tx.send(decision); - let confirmation = if decision == PermissionDecision::Approve { - "Permission approved." - } else { - "Permission denied." - }; - let formatted = markdown_to_slack(confirmation); - let _ = ctx.transport.send_message(channel, &formatted, "").await; - return; - } - } - - 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: channel, - }; - - if let Some(response) = try_handle_command(&dispatch, message) { - slog!("[slack] Sending command response to {channel}"); - let response = markdown_to_slack(&response); - if let Err(e) = ctx.transport.send_message(channel, &response, "").await { - slog!("[slack] Failed to send reply to {channel}: {e}"); - } - return; - } - - // Check for async commands (htop, delete). - if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) { - use crate::chat::transport::matrix::htop::HtopCommand; - slog!("[slack] Handling htop command from {user} in {channel}"); - match htop_cmd { - HtopCommand::Stop => { - let _ = ctx - .transport - .send_message(channel, "htop stopped.", "") - .await; - } - HtopCommand::Start { duration_secs } => { - // On Slack, htop uses native message editing for live updates. - let snapshot = - crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); - let snapshot = markdown_to_slack(&snapshot); - let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await { - Ok(id) => id, - Err(e) => { - slog!("[slack] Failed to send htop message: {e}"); - return; - } - }; - // Spawn a background task that edits the message periodically. - let transport = Arc::clone(&ctx.transport); - let agents = Arc::clone(&ctx.agents); - let ch = channel.to_string(); - tokio::spawn(async move { - let interval = std::time::Duration::from_secs(2); - let total_ticks = (duration_secs as usize) / 2; - for tick in 1..=total_ticks { - tokio::time::sleep(interval).await; - let updated = crate::chat::transport::matrix::htop::build_htop_message( - &agents, - (tick * 2) as u32, - duration_secs, - ); - let updated = markdown_to_slack(&updated); - if let Err(e) = - transport.edit_message(&ch, &msg_id, &updated, "").await - { - slog!("[slack] Failed to edit htop message: {e}"); - break; - } - } - }); - } - } - return; - } - - if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) { - let response = match del_cmd { - crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => { - slog!("[slack] Handling delete command from {user}: story {story_number}"); - crate::chat::transport::matrix::delete::handle_delete( - &ctx.bot_name, - &story_number, - &ctx.project_root, - &ctx.agents, - ) - .await - } - crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => { - format!("Usage: `{} delete `", ctx.bot_name) - } - }; - let response = markdown_to_slack(&response); - let _ = ctx.transport.send_message(channel, &response, "").await; - return; - } - - if crate::chat::transport::matrix::rebuild::extract_rebuild_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) - .is_some() - { - slog!("[slack] Handling rebuild command from {user} in {channel}"); - let ack = "Rebuilding server… this may take a moment."; - let _ = ctx.transport.send_message(channel, ack, "").await; - let response = crate::chat::transport::matrix::rebuild::handle_rebuild( - &ctx.bot_name, - &ctx.project_root, - &ctx.agents, - ) - .await; - let response = markdown_to_slack(&response); - let _ = ctx.transport.send_message(channel, &response, "").await; - return; - } - - if let Some(rmtree_cmd) = crate::chat::transport::matrix::rmtree::extract_rmtree_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) { - let response = match rmtree_cmd { - crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => { - slog!("[slack] Handling rmtree command from {user} in {channel}: story {story_number}"); - crate::chat::transport::matrix::rmtree::handle_rmtree( - &ctx.bot_name, - &story_number, - &ctx.project_root, - &ctx.agents, - ) - .await - } - crate::chat::transport::matrix::rmtree::RmtreeCommand::BadArgs => { - format!("Usage: `{} rmtree `", ctx.bot_name) - } - }; - let response = markdown_to_slack(&response); - let _ = ctx.transport.send_message(channel, &response, "").await; - return; - } - - if crate::chat::transport::matrix::reset::extract_reset_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) - .is_some() - { - slog!("[slack] Handling reset command from {user} in {channel}"); - { - let mut guard = ctx.history.lock().await; - let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default); - conv.session_id = None; - conv.entries.clear(); - save_slack_history(&ctx.project_root, &guard); - } - let _ = ctx - .transport - .send_message(channel, "Session cleared.", "") - .await; - return; - } - - if let Some(start_cmd) = crate::chat::transport::matrix::start::extract_start_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) { - let response = match start_cmd { - crate::chat::transport::matrix::start::StartCommand::Start { - story_number, - agent_hint, - } => { - slog!("[slack] Handling start command from {user} in {channel}: story {story_number}"); - crate::chat::transport::matrix::start::handle_start( - &ctx.bot_name, - &story_number, - agent_hint.as_deref(), - &ctx.project_root, - &ctx.agents, - ) - .await - } - crate::chat::transport::matrix::start::StartCommand::BadArgs => { - format!("Usage: `{} start `", ctx.bot_name) - } - }; - let response = markdown_to_slack(&response); - let _ = ctx.transport.send_message(channel, &response, "").await; - return; - } - - if let Some(assign_cmd) = crate::chat::transport::matrix::assign::extract_assign_command( - message, - &ctx.bot_name, - &ctx.bot_user_id, - ) { - let response = match assign_cmd { - crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => { - slog!("[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}"); - crate::chat::transport::matrix::assign::handle_assign( - &ctx.bot_name, - &story_number, - &model, - &ctx.project_root, - &ctx.agents, - ) - .await - } - crate::chat::transport::matrix::assign::AssignCommand::BadArgs => { - format!("Usage: `{} assign `", ctx.bot_name) - } - }; - let response = markdown_to_slack(&response); - let _ = ctx.transport.send_message(channel, &response, "").await; - return; - } - - // No command matched — forward to LLM for conversational response. - slog!("[slack] No command matched, forwarding to LLM for {user} in {channel}"); - handle_llm_message(ctx, channel, user, message).await; -} - -/// Forward a message to Claude Code and send the response back via Slack. -async fn handle_llm_message( - ctx: &SlackWebhookContext, - channel: &str, - user: &str, - user_message: &str, -) { - use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; - use crate::chat::util::drain_complete_paragraphs; - use std::sync::atomic::{AtomicBool, Ordering}; - use tokio::sync::watch; - - // Look up existing session ID for this channel. - let resume_session_id: Option = { - let guard = ctx.history.lock().await; - guard - .get(channel) - .and_then(|conv| conv.session_id.clone()) - }; - - let bot_name = &ctx.bot_name; - let prompt = format!( - "[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}" - ); - - let provider = ClaudeCodeProvider::new(); - let (_cancel_tx, mut cancel_rx) = watch::channel(false); - - // Channel for sending complete chunks to the Slack posting task. - let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::(); - let msg_tx_for_callback = msg_tx.clone(); - - // Spawn a task to post messages as they arrive. - let post_transport = Arc::clone(&ctx.transport); - let post_channel = channel.to_string(); - let post_task = tokio::spawn(async move { - while let Some(chunk) = msg_rx.recv().await { - let formatted = markdown_to_slack(&chunk); - let _ = post_transport.send_message(&post_channel, &formatted, "").await; - } - }); - - // Shared buffer between the sync token callback and the async scope. - let buffer = Arc::new(std::sync::Mutex::new(String::new())); - let buffer_for_callback = Arc::clone(&buffer); - let sent_any_chunk = Arc::new(AtomicBool::new(false)); - let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk); - - let project_root_str = ctx.project_root.to_string_lossy().to_string(); - let chat_fut = provider.chat_stream( - &prompt, - &project_root_str, - resume_session_id.as_deref(), - None, - &mut cancel_rx, - move |token| { - let mut buf = buffer_for_callback.lock().unwrap(); - buf.push_str(token); - let paragraphs = drain_complete_paragraphs(&mut buf); - for chunk in paragraphs { - sent_any_chunk_for_callback.store(true, Ordering::Relaxed); - let _ = msg_tx_for_callback.send(chunk); - } - }, - |_thinking| {}, - |_activity| {}, - ); - tokio::pin!(chat_fut); - - // Lock the permission receiver for the duration of this chat session. - let mut perm_rx_guard = ctx.perm_rx.lock().await; - - let result = loop { - tokio::select! { - r = &mut chat_fut => break r, - - Some(perm_fwd) = perm_rx_guard.recv() => { - let prompt_msg = format!( - "*Permission Request*\n\nTool: `{}`\n```json\n{}\n```\n\nReply *yes* to approve or *no* to deny.", - perm_fwd.tool_name, - serde_json::to_string_pretty(&perm_fwd.tool_input) - .unwrap_or_else(|_| perm_fwd.tool_input.to_string()), - ); - let formatted = markdown_to_slack(&prompt_msg); - let _ = ctx.transport.send_message(channel, &formatted, "").await; - - // Store the response sender so the incoming message handler - // can resolve it when the user replies yes/no. - ctx.pending_perm_replies - .lock() - .await - .insert(channel.to_string(), perm_fwd.response_tx); - - // Spawn a timeout task: auto-deny if the user does not respond. - let pending = Arc::clone(&ctx.pending_perm_replies); - let timeout_channel = channel.to_string(); - let timeout_transport = Arc::clone(&ctx.transport); - let timeout_secs = ctx.permission_timeout_secs; - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await; - if let Some(tx) = pending.lock().await.remove(&timeout_channel) { - let _ = tx.send(PermissionDecision::Deny); - let msg = "Permission request timed out — denied (fail-closed)."; - let _ = timeout_transport.send_message(&timeout_channel, msg, "").await; - } - }); - } - } - }; - drop(perm_rx_guard); - - // Flush remaining text. - let remaining = buffer.lock().unwrap().trim().to_string(); - let did_send_any = sent_any_chunk.load(Ordering::Relaxed); - - let (assistant_reply, new_session_id) = match result { - Ok(ClaudeCodeResult { - messages, - session_id, - }) => { - let reply = if !remaining.is_empty() { - let _ = msg_tx.send(remaining.clone()); - remaining - } else if !did_send_any { - let last_text = messages - .iter() - .rev() - .find(|m| { - m.role == crate::llm::types::Role::Assistant && !m.content.is_empty() - }) - .map(|m| m.content.clone()) - .unwrap_or_default(); - if !last_text.is_empty() { - let _ = msg_tx.send(last_text.clone()); - } - last_text - } else { - remaining - }; - slog!("[slack] session_id from chat_stream: {:?}", session_id); - (reply, session_id) - } - Err(e) => { - slog!("[slack] LLM error: {e}"); - let err_msg = format!("Error processing your request: {e}"); - let _ = msg_tx.send(err_msg.clone()); - (err_msg, None) - } - }; - - // Signal the posting task to finish and wait for it. - drop(msg_tx); - let _ = post_task.await; - - // Record this exchange in conversation history. - if !assistant_reply.starts_with("Error processing") { - let mut guard = ctx.history.lock().await; - let conv = guard.entry(channel.to_string()).or_default(); - - if new_session_id.is_some() { - conv.session_id = new_session_id; - } - - conv.entries.push(ConversationEntry { - role: ConversationRole::User, - sender: user.to_string(), - content: user_message.to_string(), - }); - conv.entries.push(ConversationEntry { - role: ConversationRole::Assistant, - sender: String::new(), - content: assistant_reply, - }); - - // Trim to configured maximum. - if conv.entries.len() > ctx.history_size { - let excess = conv.entries.len() - ctx.history_size; - conv.entries.drain(..excess); - } - - save_slack_history(&ctx.project_root, &guard); - } -} - -// ── Markdown → mrkdwn conversion ──────────────────────────────────────── - -/// Convert Markdown text to Slack mrkdwn format. -/// -/// Slack uses its own "mrkdwn" syntax which differs from standard Markdown. -/// This function converts common Markdown constructs so messages render -/// nicely in Slack instead of showing raw Markdown syntax. -pub fn markdown_to_slack(text: &str) -> String { - use regex::Regex; - use std::sync::LazyLock; - - // Regexes are compiled once and reused across calls. - static RE_FENCED_BLOCK: LazyLock = - LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap()); - static RE_HEADER: LazyLock = - LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap()); - static RE_BOLD_ITALIC: LazyLock = - LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap()); - static RE_BOLD: LazyLock = - LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap()); - static RE_STRIKETHROUGH: LazyLock = - LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap()); - static RE_LINK: LazyLock = - LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); - - // 1. Protect fenced code blocks by replacing them with placeholders. - let mut code_blocks: Vec = Vec::new(); - let protected = RE_FENCED_BLOCK.replace_all(text, |caps: ®ex::Captures| { - let idx = code_blocks.len(); - code_blocks.push(caps[0].to_string()); - format!("\x00CODEBLOCK{idx}\x00") - }); - let mut out = protected.into_owned(); - - // 2. Headers → bold text. - out = RE_HEADER.replace_all(&out, "*$1*").into_owned(); - - // 3. Bold+italic (***text***) → bold italic (*_text_*). - out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned(); - - // 4. Bold (**text**) → Slack bold (*text*). - out = RE_BOLD.replace_all(&out, "*$1*").into_owned(); - - // 5. Strikethrough (~~text~~) → Slack strikethrough (~text~). - out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned(); - - // 6. Links [text](url) → Slack mrkdwn format . - out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned(); - - // 7. Restore code blocks. - for (idx, block) in code_blocks.iter().enumerate() { - out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block); - } - - out -} - -// ── Tests ─────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - // ── SlackTransport ────────────────────────────────────────────────── - - #[tokio::test] - async fn transport_send_message_calls_slack_api() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/chat.postMessage") - .match_header("authorization", "Bearer xoxb-test-token") - .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) - .create_async() - .await; - - let transport = SlackTransport::with_api_base( - "xoxb-test-token".to_string(), - server.url(), - ); - - let result = transport - .send_message("C01ABCDEF", "hello", "

hello

") - .await; - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "1234567890.123456"); - mock.assert_async().await; - } - - #[tokio::test] - async fn transport_send_message_handles_api_error() { - let mut server = mockito::Server::new_async().await; - server - .mock("POST", "/chat.postMessage") - .with_body(r#"{"ok": false, "error": "channel_not_found"}"#) - .create_async() - .await; - - let transport = SlackTransport::with_api_base( - "xoxb-test-token".to_string(), - server.url(), - ); - - let result = transport - .send_message("C_INVALID", "hello", "") - .await; - assert!(result.is_err()); - assert!( - result.unwrap_err().contains("channel_not_found"), - "error should contain the Slack error code" - ); - } - - #[tokio::test] - async fn transport_edit_message_calls_chat_update() { - let mut server = mockito::Server::new_async().await; - let mock = server - .mock("POST", "/chat.update") - .match_header("authorization", "Bearer xoxb-test-token") - .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) - .create_async() - .await; - - let transport = SlackTransport::with_api_base( - "xoxb-test-token".to_string(), - server.url(), - ); - - let result = transport - .edit_message("C01ABCDEF", "1234567890.123456", "updated", "") - .await; - assert!(result.is_ok()); - mock.assert_async().await; - } - - #[tokio::test] - async fn transport_edit_message_handles_error() { - let mut server = mockito::Server::new_async().await; - server - .mock("POST", "/chat.update") - .with_body(r#"{"ok": false, "error": "message_not_found"}"#) - .create_async() - .await; - - let transport = SlackTransport::with_api_base( - "xoxb-test-token".to_string(), - server.url(), - ); - - let result = transport - .edit_message("C01ABCDEF", "bad-ts", "updated", "") - .await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("message_not_found")); - } - - #[tokio::test] - async fn transport_send_typing_succeeds() { - let transport = SlackTransport::new("xoxb-test".to_string()); - assert!(transport.send_typing("C01ABCDEF", true).await.is_ok()); - assert!(transport.send_typing("C01ABCDEF", false).await.is_ok()); - } - - #[tokio::test] - async fn transport_handles_http_error() { - let mut server = mockito::Server::new_async().await; - server - .mock("POST", "/chat.postMessage") - .with_status(500) - .with_body("Internal Server Error") - .create_async() - .await; - - let transport = SlackTransport::with_api_base( - "xoxb-test-token".to_string(), - server.url(), - ); - - let result = transport.send_message("C01ABCDEF", "hello", "").await; - assert!(result.is_err()); - assert!(result.unwrap_err().contains("500")); - } - - // ── Signature verification ────────────────────────────────────────── - - #[test] - fn verify_signature_with_known_values() { - // Test with a known good signature. - let secret = "8f742231b10e8888abcd99yez67291"; - let timestamp = "1531420618"; - let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c659f"; - - // Compute expected signature for this test case. - let sig = compute_test_signature(secret, timestamp, body); - - assert!(verify_slack_signature(secret, timestamp, body, &sig)); - } - - #[test] - fn verify_signature_rejects_bad_signature() { - let secret = "test-secret"; - let timestamp = "1234567890"; - let body = b"test body"; - - assert!(!verify_slack_signature( - secret, - timestamp, - body, - "v0=bad_signature_here" - )); - } - - #[test] - fn verify_signature_rejects_wrong_secret() { - let timestamp = "1234567890"; - let body = b"test body"; - - let sig = compute_test_signature("correct-secret", timestamp, body); - assert!(!verify_slack_signature("wrong-secret", timestamp, body, &sig)); - } - - /// Helper to compute a test signature using our sha256 + HMAC implementation. - fn compute_test_signature(secret: &str, timestamp: &str, body: &[u8]) -> String { - use std::fmt::Write; - - let key = secret.as_bytes(); - let block_size = 64; - let key_block = if key.len() > block_size { - let digest = sha256(key); - let mut k = vec![0u8; block_size]; - k[..32].copy_from_slice(&digest); - k - } else { - let mut k = vec![0u8; block_size]; - k[..key.len()].copy_from_slice(key); - k - }; - - let mut ipad = vec![0x36u8; block_size]; - let mut opad = vec![0x5cu8; block_size]; - for i in 0..block_size { - ipad[i] ^= key_block[i]; - opad[i] ^= key_block[i]; - } - - let base_string = format!("v0:{timestamp}:"); - let mut inner_data = ipad; - inner_data.extend_from_slice(base_string.as_bytes()); - inner_data.extend_from_slice(body); - let inner_hash = sha256(&inner_data); - - let mut outer_data = opad; - outer_data.extend_from_slice(&inner_hash); - let hmac_result = sha256(&outer_data); - - let mut expected = String::from("v0="); - for byte in &hmac_result { - write!(expected, "{byte:02x}").unwrap(); - } - expected - } - - // ── SHA-256 implementation ────────────────────────────────────────── - - #[test] - fn sha256_empty_string() { - let result = sha256(b""); - let hex: String = result.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!( - hex, - "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - ); - } - - #[test] - fn sha256_hello_world() { - let result = sha256(b"hello world"); - let hex: String = result.iter().map(|b| format!("{b:02x}")).collect(); - assert_eq!( - hex, - "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" - ); - } - - // ── Event envelope parsing ────────────────────────────────────────── - - #[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")); - } - - // ── Conversation history persistence ──────────────────────────────── - - #[test] - fn save_and_load_slack_history_round_trips() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".storkit"); - std::fs::create_dir_all(&sk).unwrap(); - - let mut history = HashMap::new(); - history.insert( - "C01ABCDEF".to_string(), - RoomConversation { - session_id: Some("sess-abc".to_string()), - entries: vec![ - ConversationEntry { - role: ConversationRole::User, - sender: "U01GHIJKL".to_string(), - content: "hello".to_string(), - }, - ConversationEntry { - role: ConversationRole::Assistant, - sender: String::new(), - content: "hi there!".to_string(), - }, - ], - }, - ); - - save_slack_history(tmp.path(), &history); - let loaded = load_slack_history(tmp.path()); - - assert_eq!(loaded.len(), 1); - let conv = loaded.get("C01ABCDEF").unwrap(); - assert_eq!(conv.session_id.as_deref(), Some("sess-abc")); - assert_eq!(conv.entries.len(), 2); - assert_eq!(conv.entries[0].content, "hello"); - assert_eq!(conv.entries[1].content, "hi there!"); - } - - #[test] - fn load_slack_history_returns_empty_when_file_missing() { - let tmp = tempfile::tempdir().unwrap(); - let history = load_slack_history(tmp.path()); - assert!(history.is_empty()); - } - - #[test] - fn load_slack_history_returns_empty_on_invalid_json() { - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".storkit"); - std::fs::create_dir_all(&sk).unwrap(); - std::fs::write(sk.join("slack_history.json"), "not json {{{").unwrap(); - let history = load_slack_history(tmp.path()); - assert!(history.is_empty()); - } - - // ── Constant-time comparison ──────────────────────────────────────── - - #[test] - fn constant_time_eq_same_values() { - assert!(constant_time_eq(b"hello", b"hello")); - } - - #[test] - fn constant_time_eq_different_values() { - assert!(!constant_time_eq(b"hello", b"world")); - } - - #[test] - fn constant_time_eq_different_lengths() { - assert!(!constant_time_eq(b"hello", b"hi")); - } - - // ── ChatTransport trait satisfaction ───────────────────────────────── - - #[test] - fn slack_transport_satisfies_trait() { - fn assert_transport() {} - assert_transport::(); - - 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::chat::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, - }; - - 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::chat::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, - }; - - 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}"); - } - - // ── markdown_to_slack tests ────────────────────────────────────────── - - #[test] - fn slack_headers_become_bold() { - assert_eq!(markdown_to_slack("# Title"), "*Title*"); - assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*"); - assert_eq!(markdown_to_slack("### Section"), "*Section*"); - assert_eq!(markdown_to_slack("###### Deep"), "*Deep*"); - } - - #[test] - fn slack_bold_converted() { - assert_eq!(markdown_to_slack("**bold text**"), "*bold text*"); - } - - #[test] - fn slack_bold_italic_converted() { - assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*"); - } - - #[test] - fn slack_strikethrough_converted() { - assert_eq!(markdown_to_slack("~~removed~~"), "~removed~"); - } - - #[test] - fn slack_links_converted_to_mrkdwn() { - assert_eq!( - markdown_to_slack("[click here](https://example.com)"), - "" - ); - } - - #[test] - fn slack_inline_code_preserved() { - assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here"); - } - - #[test] - fn slack_fenced_code_block_preserved() { - let input = "```rust\nlet x = 1;\n```"; - let output = markdown_to_slack(input); - assert!(output.contains("let x = 1;"), "code block content must be preserved"); - assert!(output.contains("```"), "fenced code delimiters must be preserved"); - } - - #[test] - fn slack_code_block_content_not_transformed() { - let input = "```\n**not bold** # not header\n```"; - let output = markdown_to_slack(input); - assert!( - output.contains("**not bold**"), - "markdown inside code blocks must not be transformed" - ); - } - - #[test] - fn slack_plain_text_unchanged() { - let plain = "Hello, this is a plain message with no formatting."; - assert_eq!(markdown_to_slack(plain), plain); - } - - #[test] - fn slack_empty_string_unchanged() { - assert_eq!(markdown_to_slack(""), ""); - } - - // ── rebuild command extraction ───────────────────────────────────── - - #[test] - fn rebuild_command_extracted_from_slack_message() { - let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( - "Storkit rebuild", - "Storkit", - "slack-bot", - ); - assert!(result.is_some(), "'Storkit rebuild' should be recognised"); - } - - #[test] - fn rebuild_command_extracted_plain_no_mention() { - // Slack slash-command synthetic messages may not include a bot mention. - let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( - "rebuild", - "Storkit", - "slack-bot", - ); - assert!(result.is_some(), "plain 'rebuild' should be recognised"); - } - - #[test] - fn non_rebuild_slack_message_not_extracted() { - let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( - "Storkit status", - "Storkit", - "slack-bot", - ); - assert!(result.is_none(), "'status' should not be recognised as rebuild"); - } - - // ── reset command extraction ─────────────────────────────────────── - - #[test] - fn reset_command_extracted_from_slack_message() { - let result = crate::chat::transport::matrix::reset::extract_reset_command( - "Storkit reset", - "Storkit", - "slack-bot", - ); - assert!(result.is_some(), "'Storkit reset' should be recognised"); - } - - #[test] - fn reset_command_extracted_plain_no_mention() { - let result = crate::chat::transport::matrix::reset::extract_reset_command( - "reset", - "Storkit", - "slack-bot", - ); - assert!(result.is_some(), "plain 'reset' should be recognised"); - } - - #[tokio::test] - async fn reset_command_clears_slack_session() { - use std::sync::Arc; - use tokio::sync::Mutex as TokioMutex; - - let channel = "C01ABCDEF"; - let history: SlackConversationHistory = Arc::new(TokioMutex::new({ - let mut m = HashMap::new(); - m.insert(channel.to_string(), RoomConversation { - session_id: Some("old-session".to_string()), - entries: vec![ConversationEntry { - role: ConversationRole::User, - sender: "U01GHIJKL".to_string(), - content: "previous message".to_string(), - }], - }); - m - })); - - let tmp = tempfile::tempdir().unwrap(); - let sk = tmp.path().join(".storkit"); - std::fs::create_dir_all(&sk).unwrap(); - - { - let mut guard = history.lock().await; - let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default); - conv.session_id = None; - conv.entries.clear(); - save_slack_history(tmp.path(), &guard); - } - - let guard = history.lock().await; - let conv = guard.get(channel).unwrap(); - assert!(conv.session_id.is_none(), "session_id should be cleared"); - assert!(conv.entries.is_empty(), "entries should be cleared"); - } - - #[test] - fn start_command_extracted_from_plain_slack_message() { - // Slack messages may arrive without a bot mention prefix. - // extract_start_command must recognise "start 42" by itself. - let result = crate::chat::transport::matrix::start::extract_start_command( - "start 42", - "Timmy", - "@timmy:home.local", - ); - assert!(result.is_some(), "plain 'start 42' should be recognised"); - assert_eq!( - result, - Some(crate::chat::transport::matrix::start::StartCommand::Start { - story_number: "42".to_string(), - agent_hint: None, - }) - ); - } - - #[test] - fn start_command_extracted_with_bot_name_prefix_slack() { - let result = crate::chat::transport::matrix::start::extract_start_command( - "Timmy start 99", - "Timmy", - "@timmy:home.local", - ); - assert!(result.is_some(), "'Timmy start 99' should be recognised"); - } - - #[test] - fn non_start_slack_message_not_extracted() { - let result = crate::chat::transport::matrix::start::extract_start_command( - "help", - "Timmy", - "@timmy:home.local", - ); - assert!(result.is_none(), "'help' should not be recognised as start"); - } - - // ── assign command extraction ────────────────────────────────────── - - #[test] - fn assign_command_extracted_from_plain_message_slack() { - let result = crate::chat::transport::matrix::assign::extract_assign_command( - "assign 42 opus", - "Timmy", - "@timmy:home.local", - ); - assert!( - matches!( - result, - Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) - ), - "plain 'assign 42 opus' should be recognised on Slack" - ); - } - - #[test] - fn assign_command_extracted_with_bot_name_prefix_slack() { - let result = crate::chat::transport::matrix::assign::extract_assign_command( - "Timmy assign 42 sonnet", - "Timmy", - "@timmy:home.local", - ); - assert!( - matches!( - result, - Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) - ), - "'Timmy assign 42 sonnet' should be recognised on Slack" - ); - } - - #[test] - fn assign_command_returns_bad_args_without_model_slack() { - let result = crate::chat::transport::matrix::assign::extract_assign_command( - "assign 42", - "Timmy", - "@timmy:home.local", - ); - assert_eq!( - result, - Some(crate::chat::transport::matrix::assign::AssignCommand::BadArgs) - ); - } - - #[test] - fn non_assign_slack_message_not_extracted() { - let result = crate::chat::transport::matrix::assign::extract_assign_command( - "status", - "Timmy", - "@timmy:home.local", - ); - assert!(result.is_none(), "'status' should not be recognised as assign on Slack"); - } -} diff --git a/server/src/chat/transport/slack/commands.rs b/server/src/chat/transport/slack/commands.rs new file mode 100644 index 00000000..1bae6ebb --- /dev/null +++ b/server/src/chat/transport/slack/commands.rs @@ -0,0 +1,877 @@ +//! Slack incoming message dispatch and slash command handling. + +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tokio::sync::{Mutex as TokioMutex, oneshot}; +use serde::{Deserialize, Serialize}; + +use crate::agents::AgentPool; +use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation}; +use crate::slog; +use crate::chat::ChatTransport; +use crate::http::context::{PermissionDecision, PermissionForward}; +use super::meta::SlackTransport; +use super::history::{SlackConversationHistory, save_slack_history}; +use super::format::markdown_to_slack; + +// ── 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)] +pub(super) struct SlashCommandResponse { + pub(super) response_type: &'static str, + pub(super) 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`. +pub(super) 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, + } +} + +// ── Shared webhook context (used by mod.rs handlers) ─────────────────── + +/// Shared context for the Slack webhook handler, injected via Poem's `Data` extractor. +pub struct SlackWebhookContext { + pub signing_secret: String, + pub transport: Arc, + pub project_root: PathBuf, + pub agents: Arc, + pub bot_name: String, + /// The bot's "user ID" for command dispatch. + pub bot_user_id: String, + pub ambient_rooms: Arc>>, + /// Per-channel conversation history for LLM passthrough. + pub history: SlackConversationHistory, + /// Maximum number of conversation entries to keep per channel. + pub history_size: usize, + /// Allowed channel IDs (messages from other channels are ignored). + pub channel_ids: HashSet, + /// Permission requests from the MCP `prompt_permission` tool arrive here. + pub perm_rx: Arc>>, + /// Pending permission replies keyed by channel ID. + pub pending_perm_replies: + Arc>>>, + /// Seconds before an unanswered permission prompt is auto-denied. + pub permission_timeout_secs: u64, +} + +// ── Permission approval detection ────────────────────────────────────── + +/// Returns `true` if the message body should be interpreted as permission approval. +fn is_permission_approval(body: &str) -> bool { + let trimmed = body.trim().to_ascii_lowercase(); + matches!( + trimmed.as_str(), + "yes" | "y" | "approve" | "allow" | "ok" + ) +} + +// ── Incoming message dispatch ─────────────────────────────────────────── + +pub(super) async fn handle_incoming_message( + ctx: &SlackWebhookContext, + channel: &str, + user: &str, + message: &str, +) { + use crate::chat::commands::{CommandDispatch, try_handle_command}; + + // If there is a pending permission prompt for this channel, interpret the + // message as a yes/no response instead of starting a new command/LLM flow. + { + let mut pending = ctx.pending_perm_replies.lock().await; + if let Some(tx) = pending.remove(channel) { + let decision = if is_permission_approval(message) { + PermissionDecision::Approve + } else { + PermissionDecision::Deny + }; + let _ = tx.send(decision); + let confirmation = if decision == PermissionDecision::Approve { + "Permission approved." + } else { + "Permission denied." + }; + let formatted = markdown_to_slack(confirmation); + let _ = ctx.transport.send_message(channel, &formatted, "").await; + return; + } + } + + 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: channel, + }; + + if let Some(response) = try_handle_command(&dispatch, message) { + slog!("[slack] Sending command response to {channel}"); + let response = markdown_to_slack(&response); + if let Err(e) = ctx.transport.send_message(channel, &response, "").await { + slog!("[slack] Failed to send reply to {channel}: {e}"); + } + return; + } + + // Check for async commands (htop, delete). + if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + use crate::chat::transport::matrix::htop::HtopCommand; + slog!("[slack] Handling htop command from {user} in {channel}"); + match htop_cmd { + HtopCommand::Stop => { + let _ = ctx + .transport + .send_message(channel, "htop stopped.", "") + .await; + } + HtopCommand::Start { duration_secs } => { + // On Slack, htop uses native message editing for live updates. + let snapshot = + crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); + let snapshot = markdown_to_slack(&snapshot); + let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await { + Ok(id) => id, + Err(e) => { + slog!("[slack] Failed to send htop message: {e}"); + return; + } + }; + // Spawn a background task that edits the message periodically. + let transport = Arc::clone(&ctx.transport); + let agents = Arc::clone(&ctx.agents); + let ch = channel.to_string(); + tokio::spawn(async move { + let interval = std::time::Duration::from_secs(2); + let total_ticks = (duration_secs as usize) / 2; + for tick in 1..=total_ticks { + tokio::time::sleep(interval).await; + let updated = crate::chat::transport::matrix::htop::build_htop_message( + &agents, + (tick * 2) as u32, + duration_secs, + ); + let updated = markdown_to_slack(&updated); + if let Err(e) = + transport.edit_message(&ch, &msg_id, &updated, "").await + { + slog!("[slack] Failed to edit htop message: {e}"); + break; + } + } + }); + } + } + return; + } + + if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match del_cmd { + crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => { + slog!("[slack] Handling delete command from {user}: story {story_number}"); + crate::chat::transport::matrix::delete::handle_delete( + &ctx.bot_name, + &story_number, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => { + format!("Usage: `{} delete `", ctx.bot_name) + } + }; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + + if crate::chat::transport::matrix::rebuild::extract_rebuild_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) + .is_some() + { + slog!("[slack] Handling rebuild command from {user} in {channel}"); + let ack = "Rebuilding server… this may take a moment."; + let _ = ctx.transport.send_message(channel, ack, "").await; + let response = crate::chat::transport::matrix::rebuild::handle_rebuild( + &ctx.bot_name, + &ctx.project_root, + &ctx.agents, + ) + .await; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + + if let Some(rmtree_cmd) = crate::chat::transport::matrix::rmtree::extract_rmtree_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match rmtree_cmd { + crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => { + slog!("[slack] Handling rmtree command from {user} in {channel}: story {story_number}"); + crate::chat::transport::matrix::rmtree::handle_rmtree( + &ctx.bot_name, + &story_number, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::rmtree::RmtreeCommand::BadArgs => { + format!("Usage: `{} rmtree `", ctx.bot_name) + } + }; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + + if crate::chat::transport::matrix::reset::extract_reset_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) + .is_some() + { + slog!("[slack] Handling reset command from {user} in {channel}"); + { + let mut guard = ctx.history.lock().await; + let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default); + conv.session_id = None; + conv.entries.clear(); + save_slack_history(&ctx.project_root, &guard); + } + let _ = ctx + .transport + .send_message(channel, "Session cleared.", "") + .await; + return; + } + + if let Some(start_cmd) = crate::chat::transport::matrix::start::extract_start_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match start_cmd { + crate::chat::transport::matrix::start::StartCommand::Start { + story_number, + agent_hint, + } => { + slog!("[slack] Handling start command from {user} in {channel}: story {story_number}"); + crate::chat::transport::matrix::start::handle_start( + &ctx.bot_name, + &story_number, + agent_hint.as_deref(), + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::start::StartCommand::BadArgs => { + format!("Usage: `{} start `", ctx.bot_name) + } + }; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + + if let Some(assign_cmd) = crate::chat::transport::matrix::assign::extract_assign_command( + message, + &ctx.bot_name, + &ctx.bot_user_id, + ) { + let response = match assign_cmd { + crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => { + slog!("[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}"); + crate::chat::transport::matrix::assign::handle_assign( + &ctx.bot_name, + &story_number, + &model, + &ctx.project_root, + &ctx.agents, + ) + .await + } + crate::chat::transport::matrix::assign::AssignCommand::BadArgs => { + format!("Usage: `{} assign `", ctx.bot_name) + } + }; + let response = markdown_to_slack(&response); + let _ = ctx.transport.send_message(channel, &response, "").await; + return; + } + + // No command matched — forward to LLM for conversational response. + slog!("[slack] No command matched, forwarding to LLM for {user} in {channel}"); + handle_llm_message(ctx, channel, user, message).await; +} + +/// Forward a message to Claude Code and send the response back via Slack. +async fn handle_llm_message( + ctx: &SlackWebhookContext, + channel: &str, + user: &str, + user_message: &str, +) { + use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; + use crate::chat::util::drain_complete_paragraphs; + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::sync::watch; + + // Look up existing session ID for this channel. + let resume_session_id: Option = { + let guard = ctx.history.lock().await; + guard + .get(channel) + .and_then(|conv| conv.session_id.clone()) + }; + + let bot_name = &ctx.bot_name; + let prompt = format!( + "[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{user}: {user_message}" + ); + + let provider = ClaudeCodeProvider::new(); + let (_cancel_tx, mut cancel_rx) = watch::channel(false); + + // Channel for sending complete chunks to the Slack posting task. + let (msg_tx, mut msg_rx) = tokio::sync::mpsc::unbounded_channel::(); + let msg_tx_for_callback = msg_tx.clone(); + + // Spawn a task to post messages as they arrive. + let post_transport = Arc::clone(&ctx.transport); + let post_channel = channel.to_string(); + let post_task = tokio::spawn(async move { + while let Some(chunk) = msg_rx.recv().await { + let formatted = markdown_to_slack(&chunk); + let _ = post_transport.send_message(&post_channel, &formatted, "").await; + } + }); + + // Shared buffer between the sync token callback and the async scope. + let buffer = Arc::new(std::sync::Mutex::new(String::new())); + let buffer_for_callback = Arc::clone(&buffer); + let sent_any_chunk = Arc::new(AtomicBool::new(false)); + let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk); + + let project_root_str = ctx.project_root.to_string_lossy().to_string(); + let chat_fut = provider.chat_stream( + &prompt, + &project_root_str, + resume_session_id.as_deref(), + None, + &mut cancel_rx, + move |token| { + let mut buf = buffer_for_callback.lock().unwrap(); + buf.push_str(token); + let paragraphs = drain_complete_paragraphs(&mut buf); + for chunk in paragraphs { + sent_any_chunk_for_callback.store(true, Ordering::Relaxed); + let _ = msg_tx_for_callback.send(chunk); + } + }, + |_thinking| {}, + |_activity| {}, + ); + tokio::pin!(chat_fut); + + // Lock the permission receiver for the duration of this chat session. + let mut perm_rx_guard = ctx.perm_rx.lock().await; + + let result = loop { + tokio::select! { + r = &mut chat_fut => break r, + + Some(perm_fwd) = perm_rx_guard.recv() => { + let prompt_msg = format!( + "*Permission Request*\n\nTool: `{}`\n```json\n{}\n```\n\nReply *yes* to approve or *no* to deny.", + perm_fwd.tool_name, + serde_json::to_string_pretty(&perm_fwd.tool_input) + .unwrap_or_else(|_| perm_fwd.tool_input.to_string()), + ); + let formatted = markdown_to_slack(&prompt_msg); + let _ = ctx.transport.send_message(channel, &formatted, "").await; + + // Store the response sender so the incoming message handler + // can resolve it when the user replies yes/no. + ctx.pending_perm_replies + .lock() + .await + .insert(channel.to_string(), perm_fwd.response_tx); + + // Spawn a timeout task: auto-deny if the user does not respond. + let pending = Arc::clone(&ctx.pending_perm_replies); + let timeout_channel = channel.to_string(); + let timeout_transport = Arc::clone(&ctx.transport); + let timeout_secs = ctx.permission_timeout_secs; + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)).await; + if let Some(tx) = pending.lock().await.remove(&timeout_channel) { + let _ = tx.send(PermissionDecision::Deny); + let msg = "Permission request timed out — denied (fail-closed)."; + let _ = timeout_transport.send_message(&timeout_channel, msg, "").await; + } + }); + } + } + }; + drop(perm_rx_guard); + + // Flush remaining text. + let remaining = buffer.lock().unwrap().trim().to_string(); + let did_send_any = sent_any_chunk.load(Ordering::Relaxed); + + let (assistant_reply, new_session_id) = match result { + Ok(ClaudeCodeResult { + messages, + session_id, + }) => { + let reply = if !remaining.is_empty() { + let _ = msg_tx.send(remaining.clone()); + remaining + } else if !did_send_any { + let last_text = messages + .iter() + .rev() + .find(|m| { + m.role == crate::llm::types::Role::Assistant && !m.content.is_empty() + }) + .map(|m| m.content.clone()) + .unwrap_or_default(); + if !last_text.is_empty() { + let _ = msg_tx.send(last_text.clone()); + } + last_text + } else { + remaining + }; + slog!("[slack] session_id from chat_stream: {:?}", session_id); + (reply, session_id) + } + Err(e) => { + slog!("[slack] LLM error: {e}"); + let err_msg = format!("Error processing your request: {e}"); + let _ = msg_tx.send(err_msg.clone()); + (err_msg, None) + } + }; + + // Signal the posting task to finish and wait for it. + drop(msg_tx); + let _ = post_task.await; + + // Record this exchange in conversation history. + if !assistant_reply.starts_with("Error processing") { + let mut guard = ctx.history.lock().await; + let conv = guard.entry(channel.to_string()).or_default(); + + if new_session_id.is_some() { + conv.session_id = new_session_id; + } + + conv.entries.push(ConversationEntry { + role: ConversationRole::User, + sender: user.to_string(), + content: user_message.to_string(), + }); + conv.entries.push(ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: assistant_reply, + }); + + // Trim to configured maximum. + if conv.entries.len() > ctx.history_size { + let excess = conv.entries.len() - ctx.history_size; + conv.entries.drain(..excess); + } + + save_slack_history(&ctx.project_root, &guard); + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── 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::chat::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, + }; + + 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::chat::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, + }; + + 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}"); + } + + // ── rebuild command extraction ───────────────────────────────────── + + #[test] + fn rebuild_command_extracted_from_slack_message() { + let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( + "Storkit rebuild", + "Storkit", + "slack-bot", + ); + assert!(result.is_some(), "'Storkit rebuild' should be recognised"); + } + + #[test] + fn rebuild_command_extracted_plain_no_mention() { + // Slack slash-command synthetic messages may not include a bot mention. + let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( + "rebuild", + "Storkit", + "slack-bot", + ); + assert!(result.is_some(), "plain 'rebuild' should be recognised"); + } + + #[test] + fn non_rebuild_slack_message_not_extracted() { + let result = crate::chat::transport::matrix::rebuild::extract_rebuild_command( + "Storkit status", + "Storkit", + "slack-bot", + ); + assert!(result.is_none(), "'status' should not be recognised as rebuild"); + } + + // ── reset command extraction ─────────────────────────────────────── + + #[test] + fn reset_command_extracted_from_slack_message() { + let result = crate::chat::transport::matrix::reset::extract_reset_command( + "Storkit reset", + "Storkit", + "slack-bot", + ); + assert!(result.is_some(), "'Storkit reset' should be recognised"); + } + + #[test] + fn reset_command_extracted_plain_no_mention() { + let result = crate::chat::transport::matrix::reset::extract_reset_command( + "reset", + "Storkit", + "slack-bot", + ); + assert!(result.is_some(), "plain 'reset' should be recognised"); + } + + #[tokio::test] + async fn reset_command_clears_slack_session() { + use std::sync::Arc; + use tokio::sync::Mutex as TokioMutex; + use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation}; + + let channel = "C01ABCDEF"; + let history: SlackConversationHistory = Arc::new(TokioMutex::new({ + let mut m = HashMap::new(); + m.insert(channel.to_string(), RoomConversation { + session_id: Some("old-session".to_string()), + entries: vec![ConversationEntry { + role: ConversationRole::User, + sender: "U01GHIJKL".to_string(), + content: "previous message".to_string(), + }], + }); + m + })); + + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + std::fs::create_dir_all(&sk).unwrap(); + + { + let mut guard = history.lock().await; + let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default); + conv.session_id = None; + conv.entries.clear(); + save_slack_history(tmp.path(), &guard); + } + + let guard = history.lock().await; + let conv = guard.get(channel).unwrap(); + assert!(conv.session_id.is_none(), "session_id should be cleared"); + assert!(conv.entries.is_empty(), "entries should be cleared"); + } + + // ── start command extraction ─────────────────────────────────────── + + #[test] + fn start_command_extracted_from_plain_slack_message() { + // Slack messages may arrive without a bot mention prefix. + // extract_start_command must recognise "start 42" by itself. + let result = crate::chat::transport::matrix::start::extract_start_command( + "start 42", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_some(), "plain 'start 42' should be recognised"); + assert_eq!( + result, + Some(crate::chat::transport::matrix::start::StartCommand::Start { + story_number: "42".to_string(), + agent_hint: None, + }) + ); + } + + #[test] + fn start_command_extracted_with_bot_name_prefix_slack() { + let result = crate::chat::transport::matrix::start::extract_start_command( + "Timmy start 99", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_some(), "'Timmy start 99' should be recognised"); + } + + #[test] + fn non_start_slack_message_not_extracted() { + let result = crate::chat::transport::matrix::start::extract_start_command( + "help", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_none(), "'help' should not be recognised as start"); + } + + // ── assign command extraction ────────────────────────────────────── + + #[test] + fn assign_command_extracted_from_plain_message_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42 opus", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "plain 'assign 42 opus' should be recognised on Slack" + ); + } + + #[test] + fn assign_command_extracted_with_bot_name_prefix_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "Timmy assign 42 sonnet", + "Timmy", + "@timmy:home.local", + ); + assert!( + matches!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::Assign { .. }) + ), + "'Timmy assign 42 sonnet' should be recognised on Slack" + ); + } + + #[test] + fn assign_command_returns_bad_args_without_model_slack() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "assign 42", + "Timmy", + "@timmy:home.local", + ); + assert_eq!( + result, + Some(crate::chat::transport::matrix::assign::AssignCommand::BadArgs) + ); + } + + #[test] + fn non_assign_slack_message_not_extracted() { + let result = crate::chat::transport::matrix::assign::extract_assign_command( + "status", + "Timmy", + "@timmy:home.local", + ); + assert!(result.is_none(), "'status' should not be recognised as assign on Slack"); + } +} diff --git a/server/src/chat/transport/slack/format.rs b/server/src/chat/transport/slack/format.rs new file mode 100644 index 00000000..6153112d --- /dev/null +++ b/server/src/chat/transport/slack/format.rs @@ -0,0 +1,128 @@ +//! Markdown to Slack mrkdwn conversion. + +/// Convert Markdown text to Slack mrkdwn format. +/// +/// Slack uses its own "mrkdwn" syntax which differs from standard Markdown. +/// This function converts common Markdown constructs so messages render +/// nicely in Slack instead of showing raw Markdown syntax. +pub fn markdown_to_slack(text: &str) -> String { + use regex::Regex; + use std::sync::LazyLock; + + // Regexes are compiled once and reused across calls. + static RE_FENCED_BLOCK: LazyLock = + LazyLock::new(|| Regex::new(r"(?ms)^```.*?\n(.*?)^```").unwrap()); + static RE_HEADER: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap()); + static RE_BOLD_ITALIC: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap()); + static RE_BOLD: LazyLock = + LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap()); + static RE_STRIKETHROUGH: LazyLock = + LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap()); + static RE_LINK: LazyLock = + LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap()); + + // 1. Protect fenced code blocks by replacing them with placeholders. + let mut code_blocks: Vec = Vec::new(); + let protected = RE_FENCED_BLOCK.replace_all(text, |caps: ®ex::Captures| { + let idx = code_blocks.len(); + code_blocks.push(caps[0].to_string()); + format!("\x00CODEBLOCK{idx}\x00") + }); + let mut out = protected.into_owned(); + + // 2. Headers → bold text. + out = RE_HEADER.replace_all(&out, "*$1*").into_owned(); + + // 3. Bold+italic (***text***) → bold italic (*_text_*). + out = RE_BOLD_ITALIC.replace_all(&out, "*_${1}_*").into_owned(); + + // 4. Bold (**text**) → Slack bold (*text*). + out = RE_BOLD.replace_all(&out, "*$1*").into_owned(); + + // 5. Strikethrough (~~text~~) → Slack strikethrough (~text~). + out = RE_STRIKETHROUGH.replace_all(&out, "~$1~").into_owned(); + + // 6. Links [text](url) → Slack mrkdwn format . + out = RE_LINK.replace_all(&out, "<$2|$1>").into_owned(); + + // 7. Restore code blocks. + for (idx, block) in code_blocks.iter().enumerate() { + out = out.replace(&format!("\x00CODEBLOCK{idx}\x00"), block); + } + + out +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slack_headers_become_bold() { + assert_eq!(markdown_to_slack("# Title"), "*Title*"); + assert_eq!(markdown_to_slack("## Subtitle"), "*Subtitle*"); + assert_eq!(markdown_to_slack("### Section"), "*Section*"); + assert_eq!(markdown_to_slack("###### Deep"), "*Deep*"); + } + + #[test] + fn slack_bold_converted() { + assert_eq!(markdown_to_slack("**bold text**"), "*bold text*"); + } + + #[test] + fn slack_bold_italic_converted() { + assert_eq!(markdown_to_slack("***emphasis***"), "*_emphasis_*"); + } + + #[test] + fn slack_strikethrough_converted() { + assert_eq!(markdown_to_slack("~~removed~~"), "~removed~"); + } + + #[test] + fn slack_links_converted_to_mrkdwn() { + assert_eq!( + markdown_to_slack("[click here](https://example.com)"), + "" + ); + } + + #[test] + fn slack_inline_code_preserved() { + assert_eq!(markdown_to_slack("use `foo()` here"), "use `foo()` here"); + } + + #[test] + fn slack_fenced_code_block_preserved() { + let input = "```rust\nlet x = 1;\n```"; + let output = markdown_to_slack(input); + assert!(output.contains("let x = 1;"), "code block content must be preserved"); + assert!(output.contains("```"), "fenced code delimiters must be preserved"); + } + + #[test] + fn slack_code_block_content_not_transformed() { + let input = "```\n**not bold** # not header\n```"; + let output = markdown_to_slack(input); + assert!( + output.contains("**not bold**"), + "markdown inside code blocks must not be transformed" + ); + } + + #[test] + fn slack_plain_text_unchanged() { + let plain = "Hello, this is a plain message with no formatting."; + assert_eq!(markdown_to_slack(plain), plain); + } + + #[test] + fn slack_empty_string_unchanged() { + assert_eq!(markdown_to_slack(""), ""); + } +} diff --git a/server/src/chat/transport/slack/history.rs b/server/src/chat/transport/slack/history.rs new file mode 100644 index 00000000..f77f8eb7 --- /dev/null +++ b/server/src/chat/transport/slack/history.rs @@ -0,0 +1,119 @@ +//! Slack conversation history persistence. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::Mutex as TokioMutex; + +use crate::chat::transport::matrix::RoomConversation; +use crate::slog; + +/// Per-channel conversation history, keyed by channel ID. +pub type SlackConversationHistory = Arc>>; + +/// On-disk format for persisted Slack conversation history. +#[derive(Serialize, Deserialize)] +struct PersistedSlackHistory { + channels: HashMap, +} + +/// Path to the persisted Slack conversation history file. +const SLACK_HISTORY_FILE: &str = ".storkit/slack_history.json"; + +/// Load Slack conversation history from disk. +pub fn load_slack_history(project_root: &std::path::Path) -> HashMap { + let path = project_root.join(SLACK_HISTORY_FILE); + let data = match std::fs::read_to_string(&path) { + Ok(d) => d, + Err(_) => return HashMap::new(), + }; + let persisted: PersistedSlackHistory = match serde_json::from_str(&data) { + Ok(p) => p, + Err(e) => { + slog!("[slack] Failed to parse history file: {e}"); + return HashMap::new(); + } + }; + persisted.channels +} + +/// Save Slack conversation history to disk. +pub(super) fn save_slack_history( + project_root: &std::path::Path, + history: &HashMap, +) { + let persisted = PersistedSlackHistory { + channels: history.clone(), + }; + let path = project_root.join(SLACK_HISTORY_FILE); + match serde_json::to_string_pretty(&persisted) { + Ok(json) => { + if let Err(e) = std::fs::write(&path, json) { + slog!("[slack] Failed to write history file: {e}"); + } + } + Err(e) => slog!("[slack] Failed to serialise history: {e}"), + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::transport::matrix::{ConversationEntry, ConversationRole}; + + #[test] + fn save_and_load_slack_history_round_trips() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + std::fs::create_dir_all(&sk).unwrap(); + + let mut history = HashMap::new(); + history.insert( + "C01ABCDEF".to_string(), + RoomConversation { + session_id: Some("sess-abc".to_string()), + entries: vec![ + ConversationEntry { + role: ConversationRole::User, + sender: "U01GHIJKL".to_string(), + content: "hello".to_string(), + }, + ConversationEntry { + role: ConversationRole::Assistant, + sender: String::new(), + content: "hi there!".to_string(), + }, + ], + }, + ); + + save_slack_history(tmp.path(), &history); + let loaded = load_slack_history(tmp.path()); + + assert_eq!(loaded.len(), 1); + let conv = loaded.get("C01ABCDEF").unwrap(); + assert_eq!(conv.session_id.as_deref(), Some("sess-abc")); + assert_eq!(conv.entries.len(), 2); + assert_eq!(conv.entries[0].content, "hello"); + assert_eq!(conv.entries[1].content, "hi there!"); + } + + #[test] + fn load_slack_history_returns_empty_when_file_missing() { + let tmp = tempfile::tempdir().unwrap(); + let history = load_slack_history(tmp.path()); + assert!(history.is_empty()); + } + + #[test] + fn load_slack_history_returns_empty_on_invalid_json() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".storkit"); + std::fs::create_dir_all(&sk).unwrap(); + std::fs::write(sk.join("slack_history.json"), "not json {{{").unwrap(); + let history = load_slack_history(tmp.path()); + assert!(history.is_empty()); + } +} diff --git a/server/src/chat/transport/slack/meta.rs b/server/src/chat/transport/slack/meta.rs new file mode 100644 index 00000000..f6d73110 --- /dev/null +++ b/server/src/chat/transport/slack/meta.rs @@ -0,0 +1,309 @@ +//! SlackTransport — ChatTransport implementation for the Slack Bot API. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::chat::{ChatTransport, MessageId}; +use crate::slog; + +// ── Slack API base URL (overridable for tests) ────────────────────────── + +const SLACK_API_BASE: &str = "https://slack.com/api"; + +// ── SlackTransport ────────────────────────────────────────────────────── + +/// Slack Bot API transport. +/// +/// Sends messages via `POST {SLACK_API_BASE}/chat.postMessage` and edits +/// via `POST {SLACK_API_BASE}/chat.update`. +pub struct SlackTransport { + bot_token: String, + client: reqwest::Client, + /// Optional base URL override for tests. + api_base: String, +} + +impl SlackTransport { + pub fn new(bot_token: String) -> Self { + Self { + bot_token, + client: reqwest::Client::new(), + api_base: SLACK_API_BASE.to_string(), + } + } + + #[cfg(test)] + fn with_api_base(bot_token: String, api_base: String) -> Self { + Self { + bot_token, + client: reqwest::Client::new(), + api_base, + } + } +} + +// ── Slack API response types ──────────────────────────────────────────── + +#[derive(Deserialize, Debug)] +struct SlackApiResponse { + ok: bool, + #[serde(default)] + error: Option, + /// Message timestamp (acts as message ID in Slack). + #[serde(default)] + ts: Option, +} + +// ── Slack API request types ───────────────────────────────────────────── + +#[derive(Serialize)] +struct PostMessageRequest<'a> { + channel: &'a str, + text: &'a str, +} + +#[derive(Serialize)] +struct UpdateMessageRequest<'a> { + channel: &'a str, + ts: &'a str, + text: &'a str, +} + +#[async_trait] +impl ChatTransport for SlackTransport { + async fn send_message( + &self, + channel: &str, + plain: &str, + _html: &str, + ) -> Result { + slog!("[slack] send_message to {channel}: {plain:.80}"); + let url = format!("{}/chat.postMessage", self.api_base); + + let payload = PostMessageRequest { + channel, + text: plain, + }; + + let resp = self + .client + .post(&url) + .bearer_auth(&self.bot_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("Slack API request failed: {e}"))?; + + let status = resp.status(); + let resp_text = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + + if !status.is_success() { + return Err(format!("Slack API returned {status}: {resp_text}")); + } + + let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| { + format!("Failed to parse Slack API response: {e} — body: {resp_text}") + })?; + + if !parsed.ok { + return Err(format!( + "Slack API error: {}", + parsed.error.unwrap_or_else(|| "unknown".to_string()) + )); + } + + Ok(parsed.ts.unwrap_or_default()) + } + + async fn edit_message( + &self, + channel: &str, + original_message_id: &str, + plain: &str, + _html: &str, + ) -> Result<(), String> { + slog!("[slack] edit_message in {channel}: ts={original_message_id}"); + let url = format!("{}/chat.update", self.api_base); + + let payload = UpdateMessageRequest { + channel, + ts: original_message_id, + text: plain, + }; + + let resp = self + .client + .post(&url) + .bearer_auth(&self.bot_token) + .json(&payload) + .send() + .await + .map_err(|e| format!("Slack chat.update request failed: {e}"))?; + + let status = resp.status(); + let resp_text = resp + .text() + .await + .unwrap_or_else(|_| "".to_string()); + + if !status.is_success() { + return Err(format!("Slack chat.update returned {status}: {resp_text}")); + } + + let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| { + format!("Failed to parse Slack chat.update response: {e} — body: {resp_text}") + })?; + + if !parsed.ok { + return Err(format!( + "Slack chat.update error: {}", + parsed.error.unwrap_or_else(|| "unknown".to_string()) + )); + } + + Ok(()) + } + + async fn send_typing(&self, _channel: &str, _typing: bool) -> Result<(), String> { + // Slack Bot API does not expose typing indicators for bots. + Ok(()) + } +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[tokio::test] + async fn transport_send_message_calls_slack_api() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/chat.postMessage") + .match_header("authorization", "Bearer xoxb-test-token") + .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) + .create_async() + .await; + + let transport = SlackTransport::with_api_base( + "xoxb-test-token".to_string(), + server.url(), + ); + + let result = transport + .send_message("C01ABCDEF", "hello", "

hello

") + .await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), "1234567890.123456"); + mock.assert_async().await; + } + + #[tokio::test] + async fn transport_send_message_handles_api_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/chat.postMessage") + .with_body(r#"{"ok": false, "error": "channel_not_found"}"#) + .create_async() + .await; + + let transport = SlackTransport::with_api_base( + "xoxb-test-token".to_string(), + server.url(), + ); + + let result = transport + .send_message("C_INVALID", "hello", "") + .await; + assert!(result.is_err()); + assert!( + result.unwrap_err().contains("channel_not_found"), + "error should contain the Slack error code" + ); + } + + #[tokio::test] + async fn transport_edit_message_calls_chat_update() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("POST", "/chat.update") + .match_header("authorization", "Bearer xoxb-test-token") + .with_body(r#"{"ok": true, "ts": "1234567890.123456"}"#) + .create_async() + .await; + + let transport = SlackTransport::with_api_base( + "xoxb-test-token".to_string(), + server.url(), + ); + + let result = transport + .edit_message("C01ABCDEF", "1234567890.123456", "updated", "") + .await; + assert!(result.is_ok()); + mock.assert_async().await; + } + + #[tokio::test] + async fn transport_edit_message_handles_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/chat.update") + .with_body(r#"{"ok": false, "error": "message_not_found"}"#) + .create_async() + .await; + + let transport = SlackTransport::with_api_base( + "xoxb-test-token".to_string(), + server.url(), + ); + + let result = transport + .edit_message("C01ABCDEF", "bad-ts", "updated", "") + .await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("message_not_found")); + } + + #[tokio::test] + async fn transport_send_typing_succeeds() { + let transport = SlackTransport::new("xoxb-test".to_string()); + assert!(transport.send_typing("C01ABCDEF", true).await.is_ok()); + assert!(transport.send_typing("C01ABCDEF", false).await.is_ok()); + } + + #[tokio::test] + async fn transport_handles_http_error() { + let mut server = mockito::Server::new_async().await; + server + .mock("POST", "/chat.postMessage") + .with_status(500) + .with_body("Internal Server Error") + .create_async() + .await; + + let transport = SlackTransport::with_api_base( + "xoxb-test-token".to_string(), + server.url(), + ); + + let result = transport.send_message("C01ABCDEF", "hello", "").await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("500")); + } + + // ── ChatTransport trait satisfaction ───────────────────────────────── + + #[test] + fn slack_transport_satisfies_trait() { + fn assert_transport() {} + assert_transport::(); + + let _: Arc = Arc::new(SlackTransport::new("xoxb-test".to_string())); + } +} diff --git a/server/src/chat/transport/slack/mod.rs b/server/src/chat/transport/slack/mod.rs new file mode 100644 index 00000000..ed2196fd --- /dev/null +++ b/server/src/chat/transport/slack/mod.rs @@ -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, + /// 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.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")); + } +} diff --git a/server/src/chat/transport/slack/verify.rs b/server/src/chat/transport/slack/verify.rs new file mode 100644 index 00000000..436ba8a4 --- /dev/null +++ b/server/src/chat/transport/slack/verify.rs @@ -0,0 +1,300 @@ +//! Slack request signature verification. + +use std::fmt::Write as FmtWrite; + +/// Verify the Slack request signature using HMAC-SHA256. +/// +/// Slack sends `X-Slack-Signature` and `X-Slack-Request-Timestamp` headers. +/// We compute `HMAC-SHA256(signing_secret, "v0:{timestamp}:{body}")` and +/// compare it to the provided signature. +/// +/// This uses a constant-time comparison to prevent timing attacks. +pub(super) fn verify_slack_signature( + signing_secret: &str, + timestamp: &str, + body: &[u8], + signature: &str, +) -> bool { + // Compute HMAC-SHA256 manually using the signing secret. + // Slack signature format: v0={hex(HMAC-SHA256(secret, "v0:{ts}:{body}"))} + let base_string = format!("v0:{timestamp}:"); + + // Simple HMAC-SHA256 implementation using ring-style approach. + // We use the hmac crate pattern with SHA-256. + // Since we don't want to add a dependency, we'll use a manual approach: + // HMAC(K, m) = H((K' ^ opad) || H((K' ^ ipad) || m)) + // where K' is the key padded/hashed to block size. + + let key = signing_secret.as_bytes(); + let block_size = 64; // SHA-256 block size + + // If key is longer than block size, hash it first. + let key_block = if key.len() > block_size { + let digest = sha256(key); + let mut k = vec![0u8; block_size]; + k[..32].copy_from_slice(&digest); + k + } else { + let mut k = vec![0u8; block_size]; + k[..key.len()].copy_from_slice(key); + k + }; + + // Inner and outer padded keys. + let mut ipad = vec![0x36u8; block_size]; + let mut opad = vec![0x5cu8; block_size]; + for i in 0..block_size { + ipad[i] ^= key_block[i]; + opad[i] ^= key_block[i]; + } + + // Inner hash: H(ipad || message) + let mut inner_data = ipad; + inner_data.extend_from_slice(base_string.as_bytes()); + inner_data.extend_from_slice(body); + let inner_hash = sha256(&inner_data); + + // Outer hash: H(opad || inner_hash) + let mut outer_data = opad; + outer_data.extend_from_slice(&inner_hash); + let hmac_result = sha256(&outer_data); + + // Format as "v0={hex}" + let mut expected = String::from("v0="); + for byte in &hmac_result { + write!(expected, "{byte:02x}").unwrap(); + } + + // Constant-time comparison. + constant_time_eq(expected.as_bytes(), signature.as_bytes()) +} + +/// Minimal SHA-256 implementation (no external dependency). +/// +/// This follows FIPS 180-4. Only used for HMAC signature verification, +/// not for any security-critical path beyond webhook authentication. +fn sha256(data: &[u8]) -> [u8; 32] { + let mut h: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19, + ]; + + let k: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + + // Pre-processing: padding + let bit_len = (data.len() as u64) * 8; + let mut padded = data.to_vec(); + padded.push(0x80); + while (padded.len() % 64) != 56 { + padded.push(0); + } + padded.extend_from_slice(&bit_len.to_be_bytes()); + + // Process each 512-bit block + for chunk in padded.chunks_exact(64) { + let mut w = [0u32; 64]; + for i in 0..16 { + w[i] = u32::from_be_bytes([ + chunk[4 * i], + chunk[4 * i + 1], + chunk[4 * i + 2], + chunk[4 * i + 3], + ]); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; + + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ ((!e) & g); + let temp1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(k[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(maj); + + hh = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + h[5] = h[5].wrapping_add(f); + h[6] = h[6].wrapping_add(g); + h[7] = h[7].wrapping_add(hh); + } + + let mut result = [0u8; 32]; + for (i, val) in h.iter().enumerate() { + result[4 * i..4 * i + 4].copy_from_slice(&val.to_be_bytes()); + } + result +} + +/// Constant-time byte comparison. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b.iter()) { + diff |= x ^ y; + } + diff == 0 +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── Signature verification ────────────────────────────────────────── + + #[test] + fn verify_signature_with_known_values() { + // Test with a known good signature. + let secret = "8f742231b10e8888abcd99yez67291"; + let timestamp = "1531420618"; + let body = b"token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c659f"; + + // Compute expected signature for this test case. + let sig = compute_test_signature(secret, timestamp, body); + + assert!(verify_slack_signature(secret, timestamp, body, &sig)); + } + + #[test] + fn verify_signature_rejects_bad_signature() { + let secret = "test-secret"; + let timestamp = "1234567890"; + let body = b"test body"; + + assert!(!verify_slack_signature( + secret, + timestamp, + body, + "v0=bad_signature_here" + )); + } + + #[test] + fn verify_signature_rejects_wrong_secret() { + let timestamp = "1234567890"; + let body = b"test body"; + + let sig = compute_test_signature("correct-secret", timestamp, body); + assert!(!verify_slack_signature("wrong-secret", timestamp, body, &sig)); + } + + /// Helper to compute a test signature using our sha256 + HMAC implementation. + fn compute_test_signature(secret: &str, timestamp: &str, body: &[u8]) -> String { + use std::fmt::Write; + + let key = secret.as_bytes(); + let block_size = 64; + let key_block = if key.len() > block_size { + let digest = sha256(key); + let mut k = vec![0u8; block_size]; + k[..32].copy_from_slice(&digest); + k + } else { + let mut k = vec![0u8; block_size]; + k[..key.len()].copy_from_slice(key); + k + }; + + let mut ipad = vec![0x36u8; block_size]; + let mut opad = vec![0x5cu8; block_size]; + for i in 0..block_size { + ipad[i] ^= key_block[i]; + opad[i] ^= key_block[i]; + } + + let base_string = format!("v0:{timestamp}:"); + let mut inner_data = ipad; + inner_data.extend_from_slice(base_string.as_bytes()); + inner_data.extend_from_slice(body); + let inner_hash = sha256(&inner_data); + + let mut outer_data = opad; + outer_data.extend_from_slice(&inner_hash); + let hmac_result = sha256(&outer_data); + + let mut expected = String::from("v0="); + for byte in &hmac_result { + write!(expected, "{byte:02x}").unwrap(); + } + expected + } + + // ── SHA-256 implementation ────────────────────────────────────────── + + #[test] + fn sha256_empty_string() { + let result = sha256(b""); + let hex: String = result.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } + + #[test] + fn sha256_hello_world() { + let result = sha256(b"hello world"); + let hex: String = result.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" + ); + } + + // ── Constant-time comparison ──────────────────────────────────────── + + #[test] + fn constant_time_eq_same_values() { + assert!(constant_time_eq(b"hello", b"hello")); + } + + #[test] + fn constant_time_eq_different_values() { + assert!(!constant_time_eq(b"hello", b"world")); + } + + #[test] + fn constant_time_eq_different_lengths() { + assert!(!constant_time_eq(b"hello", b"hi")); + } +}