diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 323a5a3874..6e308a10a7 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -74,7 +74,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | Slack | ✅ | ✅ | - | WASM tool | | iMessage | ✅ | ❌ | P3 | BlueBubbles or Linq recommended | | Linq | ✅ | ❌ | P3 | Real iMessage via API, no Mac required | -| Feishu/Lark | ✅ | ❌ | P3 | Bitable create app/field tools, Docx table/image/file actions, rich-text media extraction | +| Feishu/Lark | ✅ | 🚧 | P3 | WASM channel with Event Subscription v2.0; Bitable/Docx tools planned | | LINE | ✅ | ❌ | P3 | | | WebChat | ✅ | ✅ | - | Web gateway chat | | Matrix | ✅ | ❌ | P3 | E2EE support | diff --git a/channels-src/feishu/Cargo.toml b/channels-src/feishu/Cargo.toml new file mode 100644 index 0000000000..53b9357df3 --- /dev/null +++ b/channels-src/feishu/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "feishu-channel" +version = "0.1.0" +edition = "2021" +description = "Feishu/Lark Bot channel for IronClaw" +license = "MIT OR Apache-2.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# WIT bindgen for WASM component model +wit-bindgen = "0.36" + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Exclude from parent workspace (this is a standalone WASM component) + +[profile.release] +# Optimize for size +opt-level = "s" +lto = true +strip = true +codegen-units = 1 + +[workspace] diff --git a/channels-src/feishu/build.sh b/channels-src/feishu/build.sh new file mode 100755 index 0000000000..006e612010 --- /dev/null +++ b/channels-src/feishu/build.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Build the Feishu/Lark channel WASM component +# +# Prerequisites: +# - Rust with wasm32-wasip2 target: rustup target add wasm32-wasip2 +# - wasm-tools for component creation: cargo install wasm-tools +# +# Output: +# - feishu.wasm - WASM component ready for deployment +# - feishu.capabilities.json - Capabilities file (copy alongside .wasm) + +set -euo pipefail + +cd "$(dirname "$0")" + +echo "Building Feishu/Lark channel WASM component..." + +# Build the WASM module +cargo build --release --target wasm32-wasip2 + +# Convert to component model (if not already a component) +# wasm-tools component new is idempotent on components +WASM_PATH="target/wasm32-wasip2/release/feishu_channel.wasm" + +if [ -f "$WASM_PATH" ]; then + # Create component if needed + wasm-tools component new "$WASM_PATH" -o feishu.wasm 2>/dev/null || cp "$WASM_PATH" feishu.wasm + + # Optimize the component + wasm-tools strip feishu.wasm -o feishu.wasm + + echo "Built: feishu.wasm ($(du -h feishu.wasm | cut -f1))" + echo "" + echo "To install:" + echo " mkdir -p ~/.ironclaw/channels" + echo " cp feishu.wasm feishu.capabilities.json ~/.ironclaw/channels/" + echo "" + echo "Then add your Feishu App credentials to secrets:" + echo " # Set FEISHU_APP_ID and FEISHU_APP_SECRET in your environment or secrets store" +else + echo "Error: WASM output not found at $WASM_PATH" + exit 1 +fi diff --git a/channels-src/feishu/feishu.capabilities.json b/channels-src/feishu/feishu.capabilities.json new file mode 100644 index 0000000000..82b1be4e44 --- /dev/null +++ b/channels-src/feishu/feishu.capabilities.json @@ -0,0 +1,78 @@ +{ + "version": "0.1.0", + "wit_version": "0.3.0", + "type": "channel", + "name": "feishu", + "description": "Feishu/Lark Bot channel for receiving and responding to Feishu messages", + "auth": { + "secret_name": "feishu_app_id", + "display_name": "Feishu / Lark", + "instructions": "Create a bot at https://open.feishu.cn/app (Feishu) or https://open.larksuite.com/app (Lark). You need the App ID and App Secret.", + "setup_url": "https://open.feishu.cn/app", + "token_hint": "App ID looks like cli_XXXX, App Secret is a long alphanumeric string", + "env_var": "FEISHU_APP_ID" + }, + "setup": { + "required_secrets": [ + { + "name": "feishu_app_id", + "prompt": "Enter your Feishu/Lark App ID (from https://open.feishu.cn/app)", + "optional": false + }, + { + "name": "feishu_app_secret", + "prompt": "Enter your Feishu/Lark App Secret", + "optional": false + }, + { + "name": "feishu_verification_token", + "prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription settings)", + "optional": true + } + ], + "setup_url": "https://open.feishu.cn/app" + }, + "capabilities": { + "http": { + "allowlist": [ + { "host": "open.feishu.cn", "path_prefix": "/open-apis/" }, + { "host": "open.larksuite.com", "path_prefix": "/open-apis/" } + ], + "credentials": { + "feishu_bearer": { + "secret_name": "feishu_tenant_access_token", + "location": { "type": "bearer" }, + "host_patterns": ["open.feishu.cn", "open.larksuite.com"] + } + }, + "rate_limit": { + "requests_per_minute": 60, + "requests_per_hour": 2000 + } + }, + "secrets": { + "allowed_names": ["feishu_*"] + }, + "channel": { + "allowed_paths": ["/webhook/feishu"], + "allow_polling": false, + "workspace_prefix": "channels/feishu/", + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + }, + "webhook": { + "secret_header": "X-Feishu-Verification-Token", + "secret_name": "feishu_verification_token" + } + } + }, + "config": { + "app_id": null, + "app_secret": null, + "api_base": "https://open.feishu.cn", + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] + } +} diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs new file mode 100644 index 0000000000..921c02d2dc --- /dev/null +++ b/channels-src/feishu/src/lib.rs @@ -0,0 +1,831 @@ +// 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, Attachment, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, + OutgoingHttpResponse, PollConfig, 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. +#[derive(Debug, Deserialize)] +struct TenantAccessTokenData { + 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_sandboxed_channel!(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, &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), + 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), + 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 result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&body.to_string()), + 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: FeishuApiResponse = + 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 + )); + } + + let data = token_resp + .data + .ok_or_else(|| "Token response missing data".to_string())?; + + // Cache the token with expiry. + let now = channel_host::now_millis(); + let expiry = now + (data.expire as u64) * 1000; + + let _ = channel_host::workspace_write(TOKEN_PATH, &data.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", + data.expire + ), + ); + + Ok(data.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, + } +} diff --git a/registry/_bundles.json b/registry/_bundles.json index c7adf1cde3..cea9155113 100644 --- a/registry/_bundles.json +++ b/registry/_bundles.json @@ -20,7 +20,8 @@ "channels/discord", "channels/telegram", "channels/slack", - "channels/whatsapp" + "channels/whatsapp", + "channels/feishu" ], "shared_auth": null }, diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json new file mode 100644 index 0000000000..cbdf7da228 --- /dev/null +++ b/registry/channels/feishu.json @@ -0,0 +1,34 @@ +{ + "name": "feishu", + "display_name": "Feishu / Lark Channel", + "kind": "channel", + "version": "0.1.0", + "wit_version": "0.3.0", + "description": "Talk to your agent through a Feishu or Lark bot", + "keywords": [ + "messaging", + "bot", + "chat", + "feishu", + "lark" + ], + "source": { + "dir": "channels-src/feishu", + "capabilities": "feishu.capabilities.json", + "crate_name": "feishu-channel" + }, + "artifacts": {}, + "auth_summary": { + "method": "manual", + "provider": "Feishu / Lark", + "secrets": [ + "feishu_app_id", + "feishu_app_secret" + ], + "shared_auth": null, + "setup_url": "https://open.feishu.cn/app" + }, + "tags": [ + "messaging" + ] +} diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index eb3675b744..60fe8f4d1f 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -22,6 +22,7 @@ const KNOWN_CHANNELS: &[(&str, &str)] = &[ ("slack", "slack_channel"), ("discord", "discord_channel"), ("whatsapp", "whatsapp_channel"), + ("feishu", "feishu_channel"), ]; /// Names of known channels that can be installed. diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index cf448750bc..b9deb5261e 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -161,6 +161,13 @@ async fn register_channel( config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); } + // Inject channel-specific secrets into config for channels that need + // credentials in API request bodies (e.g., Feishu token exchange). + // The credential injection system only replaces placeholders in URLs + // and headers, so channels like Feishu that exchange app_id + app_secret + // for a tenant token need the raw values in their config. + inject_channel_secrets_into_config(&channel_name, secrets_store, &mut config_updates).await; + if !config_updates.is_empty() { channel_arc.update_config(config_updates).await; tracing::info!( @@ -348,3 +355,62 @@ pub async fn inject_channel_credentials( Ok(count) } + +/// Inject channel-specific secrets into the config JSON. +/// +/// Some channels (e.g., Feishu) need raw credential values in their config +/// because they perform token exchanges that require secrets in the HTTP +/// request body. The standard credential injection system only replaces +/// placeholders in URLs and headers, so this function fills config fields +/// that map to secret names. +/// +/// Mapping: for a channel named "feishu", secrets `feishu_app_id` and +/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`. +async fn inject_channel_secrets_into_config( + channel_name: &str, + secrets_store: &Option>, + config_updates: &mut std::collections::HashMap, +) { + // Map of (config_key, secret_name) pairs per channel. + let secret_config_mappings: &[(&str, &str)] = match channel_name { + "feishu" => &[ + ("app_id", "feishu_app_id"), + ("app_secret", "feishu_app_secret"), + ], + _ => return, + }; + + let Some(secrets) = secrets_store else { + return; + }; + + for &(config_key, secret_name) in secret_config_mappings { + match secrets.get_decrypted("default", secret_name).await { + Ok(decrypted) => { + config_updates.insert( + config_key.to_string(), + serde_json::Value::String(decrypted.expose().to_string()), + ); + tracing::debug!( + channel = %channel_name, + config_key = %config_key, + "Injected secret into channel config" + ); + } + Err(_) => { + // Also try environment variable fallback. + let env_name = secret_name.to_uppercase(); + if let Ok(val) = std::env::var(&env_name) + && !val.is_empty() + { + config_updates.insert(config_key.to_string(), serde_json::Value::String(val)); + tracing::debug!( + channel = %channel_name, + config_key = %config_key, + "Injected secret from env into channel config" + ); + } + } + } + } +}