diff --git a/.story_kit/bot.toml.example b/.story_kit/bot.toml.example index 3219ba2..7e104a2 100644 --- a/.story_kit/bot.toml.example +++ b/.story_kit/bot.toml.example @@ -46,3 +46,16 @@ enabled = false # # Once approved, set the name below (default: "pipeline_notification"): # whatsapp_notification_template = "pipeline_notification" + +# ── Slack Bot API ───────────────────────────────────────────────────── +# Set transport = "slack" to use Slack instead of Matrix. +# The webhook endpoint will be available at /webhook/slack. +# Configure this URL in the Slack App → Event Subscriptions → Request URL. +# +# Required Slack App scopes: chat:write, chat:update +# Subscribe to bot events: message.channels, message.groups, message.im +# +# transport = "slack" +# slack_bot_token = "xoxb-..." +# slack_signing_secret = "your-signing-secret" +# slack_channel_ids = ["C01ABCDEF"] diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs index 19c3c15..b8d9fb6 100644 --- a/server/src/http/mod.rs +++ b/server/src/http/mod.rs @@ -29,6 +29,7 @@ use settings::SettingsApi; use std::path::{Path, PathBuf}; use std::sync::Arc; +use crate::slack::SlackWebhookContext; use crate::whatsapp::WhatsAppWebhookContext; const DEFAULT_PORT: u16 = 3001; @@ -56,6 +57,7 @@ pub fn remove_port_file(path: &Path) { pub fn build_routes( ctx: AppContext, whatsapp_ctx: Option>, + slack_ctx: Option>, ) -> impl poem::Endpoint { let ctx_arc = std::sync::Arc::new(ctx); @@ -87,6 +89,13 @@ pub fn build_routes( ); } + if let Some(sl_ctx) = slack_ctx { + route = route.at( + "/webhook/slack", + post(crate::slack::webhook_receive).data(sl_ctx), + ); + } + route.data(ctx_arc) } @@ -196,6 +205,6 @@ mod tests { fn build_routes_constructs_without_panic() { let tmp = tempfile::tempdir().unwrap(); let ctx = context::AppContext::new_test(tmp.path().to_path_buf()); - let _endpoint = build_routes(ctx, None); + let _endpoint = build_routes(ctx, None, None); } } diff --git a/server/src/main.rs b/server/src/main.rs index 7427750..6fbcbdb 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,7 @@ mod state; mod store; pub mod transport; mod workflow; +pub mod slack; pub mod whatsapp; mod worktree; @@ -228,7 +229,38 @@ async fn main() -> Result<(), std::io::Error> { }) }); - let app = build_routes(ctx, whatsapp_ctx); + // Build Slack webhook context if bot.toml configures transport = "slack". + let slack_ctx: Option> = startup_root + .as_ref() + .and_then(|root| matrix::BotConfig::load(root)) + .filter(|cfg| cfg.transport == "slack") + .map(|cfg| { + let transport = Arc::new(slack::SlackTransport::new( + cfg.slack_bot_token.clone().unwrap_or_default(), + )); + let bot_name = cfg + .display_name + .clone() + .unwrap_or_else(|| "Assistant".to_string()); + let root = startup_root.clone().unwrap(); + let history = slack::load_slack_history(&root); + let channel_ids: std::collections::HashSet = + cfg.slack_channel_ids.iter().cloned().collect(); + Arc::new(slack::SlackWebhookContext { + signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(), + transport, + project_root: root, + agents: Arc::clone(&startup_agents), + bot_name, + bot_user_id: "slack-bot".to_string(), + ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + history: std::sync::Arc::new(tokio::sync::Mutex::new(history)), + history_size: cfg.history_size, + channel_ids, + }) + }); + + let app = build_routes(ctx, whatsapp_ctx, slack_ctx); // Optional Matrix bot: connect to the homeserver and start listening for diff --git a/server/src/matrix/config.rs b/server/src/matrix/config.rs index 9c5e585..35861d8 100644 --- a/server/src/matrix/config.rs +++ b/server/src/matrix/config.rs @@ -87,6 +87,19 @@ pub struct BotConfig { /// use. Defaults to `"pipeline_notification"`. #[serde(default)] pub whatsapp_notification_template: Option, + + // ── Slack Bot API fields ───────────────────────────────────────── + // These are only required when `transport = "slack"`. + + /// Slack Bot User OAuth Token (starts with `xoxb-`). + #[serde(default)] + pub slack_bot_token: Option, + /// Slack Signing Secret used to verify incoming webhook requests. + #[serde(default)] + pub slack_signing_secret: Option, + /// Slack channel IDs the bot should listen in. + #[serde(default)] + pub slack_channel_ids: Vec, } fn default_transport() -> String { @@ -142,6 +155,29 @@ impl BotConfig { ); return None; } + } else if config.transport == "slack" { + // Validate Slack-specific fields. + if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + slack_bot_token" + ); + return None; + } + if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + slack_signing_secret" + ); + return None; + } + if config.slack_channel_ids.is_empty() { + eprintln!( + "[bot] bot.toml: transport=\"slack\" requires \ + at least one slack_channel_ids entry" + ); + return None; + } } else if config.room_ids.is_empty() { eprintln!( "[matrix-bot] bot.toml has no room_ids configured — \ @@ -680,6 +716,97 @@ enabled = true transport = "whatsapp" whatsapp_phone_number_id = "123456" whatsapp_access_token = "EAAtoken" +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + // ── Slack config tests ───────────────────────────────────────────── + + #[test] + fn load_slack_transport_reads_config() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "slack" +slack_bot_token = "xoxb-123" +slack_signing_secret = "secret123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + let config = BotConfig::load(tmp.path()).unwrap(); + assert_eq!(config.transport, "slack"); + assert_eq!(config.slack_bot_token.as_deref(), Some("xoxb-123")); + assert_eq!(config.slack_signing_secret.as_deref(), Some("secret123")); + assert_eq!(config.slack_channel_ids, vec!["C01ABCDEF"]); + } + + #[test] + fn load_slack_returns_none_when_missing_bot_token() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "slack" +slack_signing_secret = "secret123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_slack_returns_none_when_missing_signing_secret() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "slack" +slack_bot_token = "xoxb-123" +slack_channel_ids = ["C01ABCDEF"] +"#, + ) + .unwrap(); + assert!(BotConfig::load(tmp.path()).is_none()); + } + + #[test] + fn load_slack_returns_none_when_missing_channel_ids() { + let tmp = tempfile::tempdir().unwrap(); + let sk = tmp.path().join(".story_kit"); + fs::create_dir_all(&sk).unwrap(); + fs::write( + sk.join("bot.toml"), + r#" +homeserver = "https://matrix.example.com" +username = "@bot:example.com" +password = "secret" +enabled = true +transport = "slack" +slack_bot_token = "xoxb-123" +slack_signing_secret = "secret123" "#, ) .unwrap(); diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index c6537fe..7231ce7 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -62,9 +62,12 @@ pub fn spawn_bot( } }; - // WhatsApp transport is handled via HTTP webhooks, not the Matrix sync loop. - if config.transport == "whatsapp" { - crate::slog!("[bot] transport=whatsapp — skipping Matrix bot; webhooks handle WhatsApp"); + // WhatsApp and Slack transports are handled via HTTP webhooks, not the Matrix sync loop. + if config.transport == "whatsapp" || config.transport == "slack" { + crate::slog!( + "[bot] transport={} — skipping Matrix bot; webhooks handle this transport", + config.transport + ); return; } diff --git a/server/src/slack.rs b/server/src/slack.rs new file mode 100644 index 0000000..b6c011d --- /dev/null +++ b/server/src/slack.rs @@ -0,0 +1,1180 @@ +//! 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 = ".story_kit/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}"), + } +} + +// ── 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") +} + +/// 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, + // Slack messages in configured channels are always "addressed" to the bot. + is_addressed: true, + }; + + 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(".story_kit"); + 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(".story_kit"); + 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())); + } +} diff --git a/server/src/transport.rs b/server/src/transport.rs index 95e7fb6..6f38776 100644 --- a/server/src/transport.rs +++ b/server/src/transport.rs @@ -83,4 +83,15 @@ mod tests { fn assert_send_sync() {} assert_send_sync::(); } + + /// Verify that SlackTransport satisfies the ChatTransport trait and + /// can be used as `Arc` (compile-time check). + #[test] + fn slack_transport_satisfies_trait() { + fn assert_transport() {} + assert_transport::(); + + let _: Arc = + Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string())); + } }