//! 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::Mutex as TokioMutex; use crate::agents::AgentPool; use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation}; use crate::slog; use crate::transport::{ChatTransport, MessageId}; // ── 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, } /// 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::matrix::commands::{CommandDispatch, try_handle_command}; let dispatch = CommandDispatch { bot_name: &ctx.bot_name, bot_user_id: &ctx.bot_user_id, project_root: &ctx.project_root, agents: &ctx.agents, ambient_rooms: &ctx.ambient_rooms, room_id: &payload.channel_id, }; let response_text = try_handle_command(&dispatch, &synthetic_message) .unwrap_or_else(|| format!("Command `{keyword}` did not produce a response.")); let resp = SlashCommandResponse { response_type: "ephemeral", text: response_text, }; Response::builder() .status(StatusCode::OK) .content_type("application/json") .body(serde_json::to_string(&resp).unwrap_or_default()) } /// Dispatch an incoming Slack message to bot commands or LLM. async fn handle_incoming_message( ctx: &SlackWebhookContext, channel: &str, user: &str, message: &str, ) { use crate::matrix::commands::{CommandDispatch, try_handle_command}; let dispatch = CommandDispatch { bot_name: &ctx.bot_name, bot_user_id: &ctx.bot_user_id, project_root: &ctx.project_root, agents: &ctx.agents, ambient_rooms: &ctx.ambient_rooms, room_id: channel, }; if let Some(response) = try_handle_command(&dispatch, message) { slog!("[slack] Sending command response to {channel}"); 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::matrix::htop::extract_htop_command( message, &ctx.bot_name, &ctx.bot_user_id, ) { use crate::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::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs); 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::matrix::htop::build_htop_message( &agents, (tick * 2) as u32, duration_secs, ); 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::matrix::delete::extract_delete_command( message, &ctx.bot_name, &ctx.bot_user_id, ) { let response = match del_cmd { crate::matrix::delete::DeleteCommand::Delete { story_number } => { slog!("[slack] Handling delete command from {user}: story {story_number}"); crate::matrix::delete::handle_delete( &ctx.bot_name, &story_number, &ctx.project_root, &ctx.agents, ) .await } crate::matrix::delete::DeleteCommand::BadArgs => { format!("Usage: `{} delete `", ctx.bot_name) } }; 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::matrix::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 _ = post_transport.send_message(&post_channel, &chunk, "").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 result = 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| {}, ) .await; // 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::*; // ── 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::matrix::commands::{CommandDispatch, try_handle_command}; let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "C01ABCDEF".to_string(); // Simulate what slash_command_receive does: build a synthetic message. let bot_name = "Storkit"; let keyword = slash_command_to_bot_keyword("/storkit-status").unwrap(); let synthetic = format!("{bot_name} {keyword}"); let dispatch = CommandDispatch { bot_name, bot_user_id: "slack-bot", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; let result = try_handle_command(&dispatch, &synthetic); assert!(result.is_some(), "status slash command should produce output via registry"); assert!(result.unwrap().contains("Pipeline Status")); } #[test] fn slash_command_show_passes_args_through_registry() { use crate::matrix::commands::{CommandDispatch, try_handle_command}; let agents = test_agents(); let ambient_rooms = test_ambient_rooms(); let room_id = "C01ABCDEF".to_string(); let bot_name = "Storkit"; let keyword = slash_command_to_bot_keyword("/storkit-show").unwrap(); // Simulate /storkit-show with text "999" let synthetic = format!("{bot_name} {keyword} 999"); let dispatch = CommandDispatch { bot_name, bot_user_id: "slack-bot", project_root: std::path::Path::new("/tmp"), agents: &agents, ambient_rooms: &ambient_rooms, room_id: &room_id, }; 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}"); } }