//! Slack Events API channel for IronClaw. //! //! This WASM component implements the channel interface for handling Slack //! webhooks and sending messages back to Slack. //! //! # Features //! //! - URL verification for Slack Events API //! - Message event parsing (@mentions, DMs) //! - Thread support for conversations //! - Response posting via Slack Web API //! //! # Security //! //! - Signature validation is handled by the host (webhook secrets) //! - Bot token is injected by host during HTTP requests //! - WASM never sees raw credentials // Generate bindings from the WIT file wit_bindgen::generate!({ world: "sandboxed-channel", path: "../../wit/channel.wit", }); use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage, InboundAttachment}; /// Slack event wrapper. #[derive(Debug, Deserialize)] struct SlackEventWrapper { /// Event type (url_verification, event_callback, etc.) #[serde(rename = "type")] event_type: String, /// Challenge token for URL verification. challenge: Option, /// The actual event payload (for event_callback). event: Option, /// Team ID that sent this event. team_id: Option, /// Event ID for deduplication. event_id: Option, } /// Slack event payload. #[derive(Debug, Deserialize)] struct SlackEvent { /// Event type (message, app_mention, etc.) #[serde(rename = "type")] event_type: String, /// User who triggered the event. user: Option, /// Channel where the event occurred. channel: Option, /// Message text. text: Option, /// Thread timestamp (for threaded messages). thread_ts: Option, /// Message timestamp. ts: Option, /// Bot ID (if message is from a bot). bot_id: Option, /// Subtype (bot_message, etc.) subtype: Option, /// File attachments shared in the message. #[serde(default)] files: Option>, } /// Slack file attachment. #[derive(Debug, Deserialize)] struct SlackFile { /// File ID. id: String, /// MIME type. mimetype: Option, /// Original filename. name: Option, /// File size in bytes. size: Option, /// URL to download the file (requires auth). url_private: Option, } /// Metadata stored with emitted messages for response routing. #[derive(Debug, Serialize, Deserialize)] struct SlackMessageMetadata { /// Slack channel ID. channel: String, /// Thread timestamp for threaded replies. thread_ts: Option, /// Original message timestamp. message_ts: String, /// Team ID. team_id: Option, } /// Slack API response for chat.postMessage. #[derive(Debug, Deserialize)] struct SlackPostMessageResponse { ok: bool, error: Option, ts: Option, } /// Workspace path for persisting owner_id across WASM callbacks. const OWNER_ID_PATH: &str = "state/owner_id"; /// Workspace path for persisting dm_policy across WASM callbacks. const DM_POLICY_PATH: &str = "state/dm_policy"; /// Workspace path for persisting allow_from (JSON array) across WASM callbacks. const ALLOW_FROM_PATH: &str = "state/allow_from"; /// Channel name for pairing store (used by pairing host APIs). const CHANNEL_NAME: &str = "slack"; /// Channel configuration from capabilities file. #[derive(Debug, Deserialize)] struct SlackConfig { /// Name of secret containing signing secret (for verification by host). #[serde(default = "default_signing_secret_name")] #[allow(dead_code)] signing_secret_name: String, #[serde(default)] owner_id: Option, #[serde(default)] dm_policy: Option, #[serde(default)] allow_from: Option>, } fn default_signing_secret_name() -> String { "slack_signing_secret".to_string() } struct SlackChannel; impl Guest for SlackChannel { fn on_start(config_json: String) -> Result { let config: SlackConfig = serde_json::from_str(&config_json) .map_err(|e| format!("Failed to parse config: {}", e))?; channel_host::log(channel_host::LogLevel::Info, "Slack channel starting"); // Persist owner_id so subsequent callbacks can read it if let Some(ref owner_id) = config.owner_id { let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id); channel_host::log( channel_host::LogLevel::Info, &format!("Owner restriction enabled: user {}", owner_id), ); } else { let _ = channel_host::workspace_write(OWNER_ID_PATH, ""); } // Persist dm_policy and allow_from for DM pairing let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing"); let _ = channel_host::workspace_write(DM_POLICY_PATH, dm_policy); let allow_from_json = serde_json::to_string(&config.allow_from.unwrap_or_default()) .unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); Ok(ChannelConfig { display_name: "Slack".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/slack".to_string(), methods: vec!["POST".to_string()], require_secret: true, }], poll: None, }) } fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { // Parse the request body let body_str = match std::str::from_utf8(&req.body) { Ok(s) => s, Err(_) => { return json_response(400, serde_json::json!({"error": "Invalid UTF-8 body"})); } }; // Parse as Slack event let event_wrapper: SlackEventWrapper = match serde_json::from_str(body_str) { Ok(e) => e, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to parse Slack event: {}", e), ); return json_response(400, serde_json::json!({"error": "Invalid event payload"})); } }; match event_wrapper.event_type.as_str() { // URL verification challenge (Slack setup) "url_verification" => { if let Some(challenge) = event_wrapper.challenge { channel_host::log( channel_host::LogLevel::Info, "Responding to Slack URL verification", ); json_response(200, serde_json::json!({"challenge": challenge})) } else { json_response(400, serde_json::json!({"error": "Missing challenge"})) } } // Actual event callback "event_callback" => { if let Some(event) = event_wrapper.event { handle_slack_event(event, event_wrapper.team_id, event_wrapper.event_id); } // Always respond 200 quickly to Slack (they have a 3s timeout) json_response(200, serde_json::json!({"ok": true})) } // Unknown event type _ => { channel_host::log( channel_host::LogLevel::Warn, &format!("Unknown Slack event type: {}", event_wrapper.event_type), ); json_response(200, serde_json::json!({"ok": true})) } } } fn on_poll() { // Slack uses webhooks, no polling needed } fn on_respond(response: AgentResponse) -> Result<(), String> { // Parse metadata to get channel info let metadata: SlackMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; // Build Slack API request let mut payload = serde_json::json!({ "channel": metadata.channel, "text": response.content, }); // Add thread_ts for threaded replies if let Some(thread_ts) = response.thread_id.or(metadata.thread_ts) { payload["thread_ts"] = serde_json::Value::String(thread_ts); } let payload_bytes = serde_json::to_vec(&payload) .map_err(|e| format!("Failed to serialize payload: {}", e))?; // Make HTTP request to Slack API // The bot token is injected by the host based on credential configuration let headers = serde_json::json!({ "Content-Type": "application/json" }); let result = channel_host::http_request( "POST", "https://slack.com/api/chat.postMessage", &headers.to_string(), Some(&payload_bytes), None, ); match result { Ok(http_response) => { if http_response.status != 200 { return Err(format!( "Slack API returned status {}", http_response.status )); } // Parse Slack response let slack_response: SlackPostMessageResponse = serde_json::from_slice(&http_response.body) .map_err(|e| format!("Failed to parse Slack response: {}", e))?; if !slack_response.ok { return Err(format!( "Slack API error: {}", slack_response .error .unwrap_or_else(|| "unknown".to_string()) )); } channel_host::log( channel_host::LogLevel::Debug, &format!( "Posted message to Slack channel {}: ts={}", metadata.channel, slack_response.ts.unwrap_or_default() ), ); Ok(()) } Err(e) => Err(format!("HTTP request failed: {}", e)), } } fn on_status(_update: StatusUpdate) {} fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { Err("broadcast not yet implemented for Slack channel".to_string()) } fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } } /// Extract attachments from Slack file objects. fn extract_slack_attachments(files: &Option>) -> Vec { let Some(files) = files else { return Vec::new(); }; files .iter() .map(|f| InboundAttachment { id: f.id.clone(), mime_type: f .mimetype .clone() .unwrap_or_else(|| "application/octet-stream".to_string()), filename: f.name.clone(), size_bytes: f.size, source_url: f.url_private.clone(), storage_key: None, extracted_text: None, extras_json: String::new(), }) .collect() } /// Download a file from Slack using the url_private endpoint. /// /// Slack file downloads require Bearer auth with the bot token, which is /// injected by the host credential system via `channel_host::http_request`. fn download_slack_file(url: &str) -> Result, String> { let headers = serde_json::json!({}); let result = channel_host::http_request("GET", url, &headers.to_string(), None, None); let response = result.map_err(|e| format!("Slack file download failed: {}", e))?; if response.status != 200 { let body_str = String::from_utf8_lossy(&response.body); return Err(format!( "Slack file download returned {}: {}", response.status, body_str )); } Ok(response.body) } /// Download file bytes and store them via the host for processing. /// /// Downloads all file types (images, documents, etc.) so the host-side /// middleware can process them (vision pipeline for images, text extraction /// for documents, transcription for audio, etc.). /// Maximum file size to download (20 MB). Files larger than this are skipped /// to avoid excessive memory use and slow downloads in the WASM runtime. const MAX_DOWNLOAD_SIZE_BYTES: u64 = 20 * 1024 * 1024; fn download_and_store_slack_files(attachments: &[InboundAttachment]) { for att in attachments { let Some(ref url) = att.source_url else { continue; }; // Skip files that exceed the size limit if let Some(size) = att.size_bytes { if size > MAX_DOWNLOAD_SIZE_BYTES { channel_host::log( channel_host::LogLevel::Warn, &format!( "Skipping Slack file download: {} bytes exceeds {} MB limit (id={})", size, MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024), att.id ), ); continue; } } match download_slack_file(url) { Ok(bytes) => { // Post-download size guard: metadata size_bytes is optional, // so a file with no size info could bypass the pre-download check. if bytes.len() as u64 > MAX_DOWNLOAD_SIZE_BYTES { channel_host::log( channel_host::LogLevel::Warn, &format!( "Discarding Slack file after download: {} bytes exceeds {} MB limit (id={})", bytes.len(), MAX_DOWNLOAD_SIZE_BYTES / (1024 * 1024), att.id ), ); continue; } channel_host::log( channel_host::LogLevel::Info, &format!( "Downloaded Slack file: {} bytes, mime={}", bytes.len(), att.mime_type ), ); if let Err(e) = channel_host::store_attachment_data(&att.id, &bytes) { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to store Slack file data: {}", e), ); } } Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to download Slack file: {}", e), ); } } } } /// Handle a Slack event and emit message if applicable. fn handle_slack_event(event: SlackEvent, team_id: Option, _event_id: Option) { let attachments = extract_slack_attachments(&event.files); // Download and store file attachments for host-side processing download_and_store_slack_files(&attachments); match event.event_type.as_str() { // Direct mention of the bot (always in a channel, not a DM) "app_mention" => { if let (Some(user), Some(channel), Some(text), Some(ts)) = ( event.user, event.channel.clone(), event.text, event.ts.clone(), ) { // app_mention is always in a channel (not DM) if !check_sender_permission(&user, &channel, false) { return; } emit_message( user, text, channel, event.thread_ts.or(Some(ts)), team_id, attachments, ); } } // Direct message to the bot "message" => { // Skip messages from bots (including ourselves) if event.bot_id.is_some() || event.subtype.is_some() { return; } if let (Some(user), Some(channel), Some(text), Some(ts)) = ( event.user, event.channel.clone(), event.text, event.ts.clone(), ) { // Only process DMs (channel IDs starting with D) if channel.starts_with('D') { if !check_sender_permission(&user, &channel, true) { return; } emit_message( user, text, channel, event.thread_ts.or(Some(ts)), team_id, attachments, ); } } } _ => { channel_host::log( channel_host::LogLevel::Debug, &format!("Ignoring Slack event type: {}", event.event_type), ); } } } /// Emit a message to the agent. fn emit_message( user_id: String, text: String, channel: String, thread_ts: Option, team_id: Option, attachments: Vec, ) { let message_ts = thread_ts.clone().unwrap_or_default(); let metadata = SlackMessageMetadata { channel: channel.clone(), thread_ts: thread_ts.clone(), message_ts: message_ts.clone(), team_id, }; let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|e| { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize Slack metadata: {}", e), ); "{}".to_string() }); // Strip @ mentions of the bot from the text for cleaner messages let cleaned_text = strip_bot_mention(&text); channel_host::emit_message(&EmittedMessage { user_id, user_name: None, // Could fetch from Slack API if needed content: cleaned_text, thread_id: thread_ts, metadata_json, attachments, }); } // ============================================================================ // Permission & Pairing // ============================================================================ /// Check if a sender is permitted. Returns true if allowed. /// For pairing mode, sends a pairing code DM if denied. fn check_sender_permission(user_id: &str, channel_id: &str, is_dm: bool) -> bool { // 1. Owner check (highest priority, applies to all contexts) let owner_id = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); if let Some(ref owner) = owner_id { if user_id != owner { channel_host::log( channel_host::LogLevel::Debug, &format!( "Dropping message from non-owner user {} (owner: {})", user_id, owner ), ); return false; } return true; } // 2. DM policy (only for DMs when no owner_id) if !is_dm { return true; // Channel messages bypass DM policy } let dm_policy = channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); if dm_policy == "open" { return true; } // 3. Build merged allow list: config allow_from + pairing store let mut allowed: Vec = channel_host::workspace_read(ALLOW_FROM_PATH) .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); if let Ok(store_allowed) = channel_host::pairing_read_allow_from(CHANNEL_NAME) { allowed.extend(store_allowed); } // 4. Check sender (Slack events only have user ID, not username) let is_allowed = allowed.contains(&"*".to_string()) || allowed.contains(&user_id.to_string()); if is_allowed { return true; } // 5. Not allowed — handle by policy if dm_policy == "pairing" { let meta = serde_json::json!({ "user_id": user_id, "channel_id": channel_id, }) .to_string(); match channel_host::pairing_upsert_request(CHANNEL_NAME, user_id, &meta) { Ok(result) => { channel_host::log( channel_host::LogLevel::Info, &format!( "Pairing request for user {}: code {}", user_id, result.code ), ); if result.created { let _ = send_pairing_reply(channel_id, &result.code); } } Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Pairing upsert failed: {}", e), ); } } } false } /// Send a pairing code message via Slack chat.postMessage. fn send_pairing_reply(channel_id: &str, code: &str) -> Result<(), String> { let payload = serde_json::json!({ "channel": channel_id, "text": format!( "To pair with this bot, run: `ironclaw pairing approve slack {}`", code ), }); let payload_bytes = serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; let headers = serde_json::json!({"Content-Type": "application/json"}); let result = channel_host::http_request( "POST", "https://slack.com/api/chat.postMessage", &headers.to_string(), Some(&payload_bytes), None, ); match result { Ok(response) if response.status == 200 => Ok(()), Ok(response) => { let body_str = String::from_utf8_lossy(&response.body); Err(format!( "Slack API error: {} - {}", response.status, body_str )) } Err(e) => Err(format!("HTTP request failed: {}", e)), } } /// Strip leading bot mention from text. fn strip_bot_mention(text: &str) -> String { // Slack mentions look like <@U12345678> let trimmed = text.trim(); if trimmed.starts_with("<@") { if let Some(end) = trimmed.find('>') { return trimmed[end + 1..].trim_start().to_string(); } } trimmed.to_string() } /// Create a JSON HTTP response. fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_else(|e| { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to serialize JSON response: {}", e), ); Vec::new() }); let headers = serde_json::json!({"Content-Type": "application/json"}); OutgoingHttpResponse { status, headers_json: headers.to_string(), body, } } // Export the component export!(SlackChannel); #[cfg(test)] mod tests { use super::*; #[test] fn test_extract_slack_attachments_with_files() { let files = Some(vec![ SlackFile { id: "F123".to_string(), mimetype: Some("image/png".to_string()), name: Some("screenshot.png".to_string()), size: Some(50000), url_private: Some("https://files.slack.com/F123".to_string()), }, SlackFile { id: "F456".to_string(), mimetype: Some("application/pdf".to_string()), name: Some("doc.pdf".to_string()), size: Some(120000), url_private: None, }, ]); let attachments = extract_slack_attachments(&files); assert_eq!(attachments.len(), 2); assert_eq!(attachments[0].id, "F123"); assert_eq!(attachments[0].mime_type, "image/png"); assert_eq!(attachments[0].filename, Some("screenshot.png".to_string())); assert_eq!(attachments[0].size_bytes, Some(50000)); assert_eq!( attachments[0].source_url, Some("https://files.slack.com/F123".to_string()) ); assert_eq!(attachments[1].id, "F456"); assert_eq!(attachments[1].mime_type, "application/pdf"); assert!(attachments[1].source_url.is_none()); } #[test] fn test_extract_slack_attachments_none() { let attachments = extract_slack_attachments(&None); assert!(attachments.is_empty()); } #[test] fn test_extract_slack_attachments_empty() { let attachments = extract_slack_attachments(&Some(vec![])); assert!(attachments.is_empty()); } #[test] fn test_extract_slack_attachments_missing_mime() { let files = Some(vec![SlackFile { id: "F789".to_string(), mimetype: None, name: Some("unknown".to_string()), size: None, url_private: None, }]); let attachments = extract_slack_attachments(&files); assert_eq!(attachments.len(), 1); assert_eq!(attachments[0].mime_type, "application/octet-stream"); } #[test] fn test_parse_slack_event_with_files() { let json = r#"{ "type": "message", "user": "U123", "channel": "D456", "text": "Check this file", "ts": "1234567890.000001", "files": [ { "id": "F001", "mimetype": "image/jpeg", "name": "photo.jpg", "size": 30000, "url_private": "https://files.slack.com/F001" } ] }"#; let event: SlackEvent = serde_json::from_str(json).unwrap(); assert!(event.files.is_some()); let files = event.files.unwrap(); assert_eq!(files.len(), 1); assert_eq!(files[0].id, "F001"); } #[test] fn test_parse_slack_event_without_files() { let json = r#"{ "type": "message", "user": "U123", "channel": "D456", "text": "Just text", "ts": "1234567890.000001" }"#; let event: SlackEvent = serde_json::from_str(json).unwrap(); assert!(event.files.is_none()); } #[test] fn test_max_download_size_constant() { // Verify the constant is 20 MB assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024); } }