// Feishu API types have fields reserved for future use. #![allow(dead_code)] //! Feishu/Lark Bot channel for IronClaw. //! //! This WASM component implements the channel interface for handling Feishu //! webhooks (Event Subscription v2.0) and sending messages back via the //! Feishu/Lark Bot API. //! //! # Features //! //! - Webhook-based message receiving (Event Subscription v2.0) //! - URL verification challenge handling //! - Private chat (DM) support //! - Group chat support with @mention triggering //! - Tenant access token management (app_id + app_secret exchange) //! - Supports both Feishu (open.feishu.cn) and Lark (open.larksuite.com) //! //! # Security //! //! - App credentials (app_id, app_secret) are injected by the host into //! the config JSON during startup for token exchange //! - Bearer token for API calls is obtained via token exchange and cached //! - Verification token validated by host for webhook requests // 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}; // ============================================================================ // Workspace paths for cross-callback state // ============================================================================ const OWNER_ID_PATH: &str = "owner_id"; const DM_POLICY_PATH: &str = "dm_policy"; const ALLOW_FROM_PATH: &str = "allow_from"; const API_BASE_PATH: &str = "api_base"; const APP_ID_PATH: &str = "app_id"; const APP_SECRET_PATH: &str = "app_secret"; const TOKEN_PATH: &str = "tenant_access_token"; const TOKEN_EXPIRY_PATH: &str = "token_expiry"; // ============================================================================ // Feishu API Types // ============================================================================ /// Feishu Event Subscription v2.0 envelope. /// https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case #[derive(Debug, Deserialize)] struct FeishuEvent { /// Schema version (always "2.0" for v2 events). #[serde(default)] schema: Option, /// Event header with metadata. header: Option, /// Event payload (varies by event type). event: Option, /// URL verification challenge (only for initial setup). challenge: Option, /// Token for URL verification (only for initial setup). token: Option, /// Type field for URL verification ("url_verification"). #[serde(rename = "type")] event_type: Option, } /// Event header containing metadata. #[derive(Debug, Deserialize)] struct FeishuEventHeader { /// Unique event ID. event_id: String, /// Event type (e.g., "im.message.receive_v1"). event_type: String, /// Timestamp. #[serde(default)] create_time: Option, /// App ID. #[serde(default)] app_id: Option, /// Tenant key. #[serde(default)] tenant_key: Option, } /// Message receive event payload (im.message.receive_v1). #[derive(Debug, Deserialize)] struct MessageReceiveEvent { sender: FeishuSender, message: FeishuMessage, } /// Sender information. #[derive(Debug, Deserialize)] struct FeishuSender { sender_id: FeishuSenderId, #[serde(default)] sender_type: Option, #[serde(default)] tenant_key: Option, } /// Sender ID with multiple ID types. #[derive(Debug, Deserialize)] struct FeishuSenderId { #[serde(default)] open_id: Option, #[serde(default)] user_id: Option, #[serde(default)] union_id: Option, } /// Message content. #[derive(Debug, Deserialize)] struct FeishuMessage { /// Unique message ID. message_id: String, /// Parent message ID (for thread replies). #[serde(default)] parent_id: Option, /// Root message ID (for thread root). #[serde(default)] root_id: Option, /// Chat ID the message belongs to. chat_id: String, /// Chat type: "p2p" (DM) or "group". #[serde(default)] chat_type: Option, /// Message type: "text", "image", "post", etc. message_type: String, /// JSON-encoded content. content: String, /// Mentions in the message. #[serde(default)] mentions: Option>, } /// Mention in a message. #[derive(Debug, Deserialize)] struct FeishuMention { key: String, id: FeishuMentionId, name: String, #[serde(default)] tenant_key: Option, } /// Mention ID. #[derive(Debug, Deserialize)] struct FeishuMentionId { #[serde(default)] open_id: Option, #[serde(default)] user_id: Option, #[serde(default)] union_id: Option, } /// Text message content (when message_type == "text"). #[derive(Debug, Deserialize)] struct TextContent { text: String, } /// Metadata stored for responding to messages. #[derive(Debug, Serialize, Deserialize)] struct FeishuMessageMetadata { chat_id: String, message_id: String, chat_type: String, } /// Feishu API response wrapper. #[derive(Debug, Deserialize)] struct FeishuApiResponse { code: i32, msg: String, #[serde(default)] data: Option, } /// Tenant access token response (flat format). /// /// Unlike most Feishu APIs that nest results under `data`, the /// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`, /// `tenant_access_token`, and `expire` at the top level. #[derive(Debug, Deserialize)] struct TenantAccessTokenResponse { #[serde(default)] code: i32, #[serde(default)] msg: String, tenant_access_token: String, expire: i64, } /// Send message request body. #[derive(Debug, Serialize)] struct SendMessageBody { receive_id: String, msg_type: String, content: String, } /// Reply message request body. #[derive(Debug, Serialize)] struct ReplyMessageBody { msg_type: String, content: String, } // ============================================================================ // Configuration // ============================================================================ /// Channel configuration parsed from capabilities.json `config` section. #[derive(Debug, Deserialize)] struct FeishuConfig { /// Feishu App ID (for token exchange). app_id: Option, /// Feishu App Secret (for token exchange). app_secret: Option, /// API base URL. Defaults to "https://open.feishu.cn" (use /// "https://open.larksuite.com" for Lark international). #[serde(default = "default_api_base")] api_base: String, /// Restrict to a single owner (open_id). If set, messages from other /// users are silently ignored. owner_id: Option, /// DM pairing policy: "open" or "pairing" (default). dm_policy: Option, /// Allowed user IDs (open_id) for DM pairing. #[serde(default)] allow_from: Option>, } fn default_api_base() -> String { "https://open.feishu.cn".to_string() } // ============================================================================ // Channel Implementation // ============================================================================ struct FeishuChannel; export!(FeishuChannel); impl Guest for FeishuChannel { fn on_start(config_json: String) -> Result { let config: FeishuConfig = serde_json::from_str(&config_json) .map_err(|e| format!("Failed to parse config: {}", e))?; channel_host::log(channel_host::LogLevel::Info, "Feishu channel starting"); // Persist config for cross-callback access. let api_base = config.api_base.trim_end_matches('/').to_string(); let _ = channel_host::workspace_write(API_BASE_PATH, &api_base); // Persist app credentials for token exchange in later callbacks. // These are injected by the host from the secrets store into the // config JSON (see setup.rs inject_channel_secrets_into_config). if let Some(ref app_id) = config.app_id { let _ = channel_host::workspace_write(APP_ID_PATH, app_id); } if let Some(ref app_secret) = config.app_secret { let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret); } if let Some(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, ""); } let dm_policy = config.dm_policy.as_deref().unwrap_or("pairing").to_string(); 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); // Obtain initial tenant access token if credentials are available. let has_credentials = config.app_id.is_some() && config.app_secret.is_some(); if has_credentials { match obtain_tenant_token(&api_base) { Ok(_) => { channel_host::log( channel_host::LogLevel::Info, "Tenant access token obtained successfully", ); } Err(e) => { // Non-fatal: token will be obtained on first message send. channel_host::log( channel_host::LogLevel::Warn, &format!("Failed to obtain initial token (will retry): {}", e), ); } } } else { channel_host::log( channel_host::LogLevel::Warn, "No app credentials in config; outbound messaging will fail \ unless feishu_app_id and feishu_app_secret are injected by the host", ); } Ok(ChannelConfig { display_name: "Feishu".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/feishu".to_string(), methods: vec!["POST".to_string()], require_secret: false, }], poll: None, }) } fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { // Parse the request body as UTF-8. 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 Feishu event envelope. let event: FeishuEvent = match serde_json::from_str(body_str) { Ok(e) => e, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to parse Feishu event: {}", e), ); return json_response(200, serde_json::json!({})); } }; // Handle URL verification challenge (initial webhook setup). if event.event_type.as_deref() == Some("url_verification") { if let Some(challenge) = &event.challenge { channel_host::log( channel_host::LogLevel::Info, "Handling URL verification challenge", ); return json_response(200, serde_json::json!({ "challenge": challenge })); } } // Handle v2.0 events. if let Some(header) = &event.header { match header.event_type.as_str() { "im.message.receive_v1" => { if let Some(event_data) = &event.event { handle_message_event(event_data); } } other => { channel_host::log( channel_host::LogLevel::Debug, &format!("Ignoring event type: {}", other), ); } } } // Always respond 200 quickly (Feishu expects fast responses). json_response(200, serde_json::json!({})) } fn on_poll() { // Feishu uses webhooks, not polling. } fn on_respond(response: AgentResponse) -> Result<(), String> { let metadata: FeishuMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; send_reply(&metadata.message_id, &response.content) } fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { send_message(&user_id, "open_id", &response.content) } fn on_status(_update: StatusUpdate) { // Status updates (thinking, tool execution, etc.) are not forwarded // to Feishu in this initial implementation. } fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Feishu channel shutting down"); } } // ============================================================================ // Message Handling // ============================================================================ /// Handle an im.message.receive_v1 event. fn handle_message_event(event_data: &serde_json::Value) { let msg_event: MessageReceiveEvent = match serde_json::from_value(event_data.clone()) { Ok(e) => e, Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Failed to parse message event: {}", e), ); return; } }; let sender_id = msg_event .sender .sender_id .open_id .as_deref() .unwrap_or("unknown"); // Owner restriction check. if let Some(owner_id) = channel_host::workspace_read(OWNER_ID_PATH) { if !owner_id.is_empty() && sender_id != owner_id { channel_host::log( channel_host::LogLevel::Debug, &format!("Ignoring message from non-owner: {}", sender_id), ); return; } } // allow_from restriction: if configured, only listed user IDs may interact. if let Some(allow_from_json) = channel_host::workspace_read(ALLOW_FROM_PATH) { if let Ok(allow_list) = serde_json::from_str::>(&allow_from_json) { if !allow_list.is_empty() && !allow_list.iter().any(|id| id == sender_id) { channel_host::log( channel_host::LogLevel::Debug, &format!( "Ignoring message from user not in allow_from: {}", sender_id ), ); return; } } } // DM pairing check for p2p chats. let chat_type = msg_event.message.chat_type.as_deref().unwrap_or("unknown"); if chat_type == "p2p" { let dm_policy = channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); if dm_policy == "pairing" { let sender_name = sender_id.to_string(); match channel_host::pairing_is_allowed("feishu", sender_id, Some(&sender_name)) { Ok(true) => {} Ok(false) => { // Upsert a pairing request. let meta = serde_json::json!({ "sender_id": sender_id, "chat_id": msg_event.message.chat_id, "chat_type": chat_type, }); let _ = channel_host::pairing_upsert_request( "feishu", sender_id, &meta.to_string(), ); channel_host::log( channel_host::LogLevel::Info, &format!("Pairing request created for {}", sender_id), ); return; } Err(e) => { channel_host::log( channel_host::LogLevel::Error, &format!("Pairing check failed: {}", e), ); return; } } } } // Extract text content. let text = extract_text_content(&msg_event.message); if text.is_empty() { channel_host::log( channel_host::LogLevel::Debug, &format!( "Ignoring non-text message type: {}", msg_event.message.message_type ), ); return; } // Build metadata for responding. let metadata = FeishuMessageMetadata { chat_id: msg_event.message.chat_id.clone(), message_id: msg_event.message.message_id.clone(), chat_type: chat_type.to_string(), }; let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Determine thread ID from reply chain. let thread_id = msg_event .message .root_id .as_deref() .or(msg_event.message.parent_id.as_deref()) .map(|s| s.to_string()); // Emit message to the agent. channel_host::emit_message(&EmittedMessage { user_id: sender_id.to_string(), user_name: None, content: text, thread_id, metadata_json, attachments: vec![], }); } /// Extract text content from a Feishu message. /// /// Currently handles "text" message type. Other types (image, post, file, /// etc.) are logged and skipped. fn extract_text_content(message: &FeishuMessage) -> String { match message.message_type.as_str() { "text" => { // Content is JSON: {"text": "hello"} match serde_json::from_str::(&message.content) { Ok(tc) => { let mut text = tc.text; // Strip @mention placeholders like @_user_1. if let Some(mentions) = &message.mentions { for mention in mentions { text = text.replace(&mention.key, &mention.name); } } text.trim().to_string() } Err(_) => String::new(), } } _ => String::new(), } } // ============================================================================ // Outbound Messaging // ============================================================================ /// Reply to a specific message. fn send_reply(message_id: &str, content: &str) -> Result<(), String> { let api_base = channel_host::workspace_read(API_BASE_PATH) .unwrap_or_else(|| "https://open.feishu.cn".to_string()); let token = get_valid_token(&api_base)?; let url = format!("{}/open-apis/im/v1/messages/{}/reply", api_base, message_id); let body = ReplyMessageBody { msg_type: "text".to_string(), content: serde_json::json!({"text": content}).to_string(), }; let body_json = serde_json::to_string(&body).map_err(|e| format!("Failed to serialize body: {}", e))?; let headers = serde_json::json!({ "Content-Type": "application/json; charset=utf-8", "Authorization": format!("Bearer {}", token), }); let result = channel_host::http_request( "POST", &url, &headers.to_string(), Some(body_json.as_bytes()), Some(10_000), ); match result { Ok(response) => { if response.status != 200 { let body_str = String::from_utf8_lossy(&response.body); return Err(format!( "Feishu API returned {}: {}", response.status, body_str )); } // Check API-level error code. if let Ok(api_resp) = serde_json::from_slice::>(&response.body) { if api_resp.code != 0 { return Err(format!( "Feishu API error {}: {}", api_resp.code, api_resp.msg )); } } Ok(()) } Err(e) => Err(format!("HTTP request failed: {}", e)), } } /// Send a new message to a user/chat (for broadcast). fn send_message(receive_id: &str, receive_id_type: &str, content: &str) -> Result<(), String> { let api_base = channel_host::workspace_read(API_BASE_PATH) .unwrap_or_else(|| "https://open.feishu.cn".to_string()); let token = get_valid_token(&api_base)?; let url = format!( "{}/open-apis/im/v1/messages?receive_id_type={}", api_base, receive_id_type ); let body = SendMessageBody { receive_id: receive_id.to_string(), msg_type: "text".to_string(), content: serde_json::json!({"text": content}).to_string(), }; let body_json = serde_json::to_string(&body).map_err(|e| format!("Failed to serialize body: {}", e))?; let headers = serde_json::json!({ "Content-Type": "application/json; charset=utf-8", "Authorization": format!("Bearer {}", token), }); let result = channel_host::http_request( "POST", &url, &headers.to_string(), Some(body_json.as_bytes()), Some(10_000), ); match result { Ok(response) => { if response.status != 200 { let body_str = String::from_utf8_lossy(&response.body); return Err(format!( "Feishu API returned {}: {}", response.status, body_str )); } if let Ok(api_resp) = serde_json::from_slice::>(&response.body) { if api_resp.code != 0 { return Err(format!( "Feishu API error {}: {}", api_resp.code, api_resp.msg )); } } Ok(()) } Err(e) => Err(format!("HTTP request failed: {}", e)), } } // ============================================================================ // Token Management // ============================================================================ /// Get a valid tenant access token, refreshing if needed. fn get_valid_token(api_base: &str) -> Result { // Check cached token. if let Some(token) = channel_host::workspace_read(TOKEN_PATH) { if !token.is_empty() { if let Some(expiry_str) = channel_host::workspace_read(TOKEN_EXPIRY_PATH) { if let Ok(expiry) = expiry_str.parse::() { let now = channel_host::now_millis(); // Refresh 5 minutes before expiry. if now < expiry.saturating_sub(300_000) { return Ok(token); } } } } } // Token expired or missing — obtain new one. obtain_tenant_token(api_base) } /// Exchange app_id + app_secret for a tenant access token. /// /// Reads credentials from workspace storage (persisted during `on_start` /// from config JSON injected by the host). fn obtain_tenant_token(api_base: &str) -> Result { let app_id = channel_host::workspace_read(APP_ID_PATH) .filter(|s| !s.is_empty()) .ok_or_else(|| "app_id not configured (missing from workspace)".to_string())?; let app_secret = channel_host::workspace_read(APP_SECRET_PATH) .filter(|s| !s.is_empty()) .ok_or_else(|| "app_secret not configured (missing from workspace)".to_string())?; let url = format!( "{}/open-apis/auth/v3/tenant_access_token/internal", api_base ); let body = serde_json::json!({ "app_id": &app_id, "app_secret": &app_secret, }); let headers = serde_json::json!({ "Content-Type": "application/json; charset=utf-8", }); let body_bytes = body.to_string(); let result = channel_host::http_request( "POST", &url, &headers.to_string(), Some(body_bytes.as_bytes()), Some(10_000), ); match result { Ok(response) => { if response.status != 200 { let body_str = String::from_utf8_lossy(&response.body); return Err(format!( "Token exchange returned {}: {}", response.status, body_str )); } let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body) .map_err(|e| format!("Failed to parse token response: {}", e))?; if token_resp.code != 0 { return Err(format!( "Token exchange error {}: {}", token_resp.code, token_resp.msg )); } if token_resp.tenant_access_token.is_empty() { return Err("Token response missing tenant_access_token".to_string()); } if token_resp.expire <= 0 { return Err(format!( "Token response has invalid expire value: {}", token_resp.expire )); } // Cache the token with expiry. let now = channel_host::now_millis(); let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000)); let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token); let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string()); channel_host::log( channel_host::LogLevel::Debug, &format!( "Tenant access token refreshed, expires in {}s", token_resp.expire ), ); Ok(token_resp.tenant_access_token) } Err(e) => Err(format!("Token exchange request failed: {}", e)), } } // ============================================================================ // Helpers // ============================================================================ /// Build a JSON HTTP response. fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse { let body_bytes = serde_json::to_vec(&body).unwrap_or_default(); OutgoingHttpResponse { status, headers_json: serde_json::json!({ "Content-Type": "application/json", }) .to_string(), body: body_bytes, } } #[cfg(test)] mod tests { use super::*; #[test] fn parse_flat_token_response() { let json = r#"{ "code": 0, "msg": "ok", "tenant_access_token": "t-abc123", "expire": 7200 }"#; let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.code, 0); assert_eq!(resp.msg, "ok"); assert_eq!(resp.tenant_access_token, "t-abc123"); assert_eq!(resp.expire, 7200); } #[test] fn parse_token_response_rejects_missing_token() { let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#; let result: Result = serde_json::from_str(json); assert!(result.is_err(), "should fail when tenant_access_token is missing"); } #[test] fn parse_token_response_rejects_missing_expire() { let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#; let result: Result = serde_json::from_str(json); assert!(result.is_err(), "should fail when expire is missing"); } #[test] fn parse_token_response_defaults_code_and_msg() { let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#; let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.code, 0); assert_eq!(resp.msg, ""); assert_eq!(resp.tenant_access_token, "t-abc"); assert_eq!(resp.expire, 3600); } #[test] fn parse_token_error_response() { let json = r#"{ "code": 10003, "msg": "invalid app_id", "tenant_access_token": "", "expire": 0 }"#; let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap(); assert_eq!(resp.code, 10003); assert!(resp.tenant_access_token.is_empty()); } }