diff --git a/docs/channels-reference.md b/docs/channels-reference.md index 2238e23..2ab904e 100644 --- a/docs/channels-reference.md +++ b/docs/channels-reference.md @@ -150,10 +150,6 @@ allowed_users = ["*"] See [Matrix E2EE Guide](./matrix-e2ee-guide.md) for encrypted-room troubleshooting. -Notes: -- Outbound Matrix replies are emitted as markdown-capable `m.room.message` text content so common clients can render lists, emphasis, and code blocks. -- If you still see `matrix_sdk_crypto::backups` warnings, follow the backup/recovery section in the Matrix E2EE guide. - ### 4.6 Signal ```toml @@ -236,6 +232,19 @@ receive_mode = "websocket" # or "webhook" port = 8081 # required for webhook mode ``` +Interactive onboarding support: + +```bash +zeroclaw onboard --interactive +``` + +The wizard now includes a dedicated **Lark/Feishu** step with: + +- region selection (`Feishu (CN)` vs `Lark (International)`) +- credential verification against official Open Platform auth endpoint +- receive mode selection (`websocket` or `webhook`) +- optional webhook verification token prompt (recommended for stronger callback authenticity checks) + ### 4.12 DingTalk ```toml @@ -320,7 +329,7 @@ rg -n "Matrix|Telegram|Discord|Slack|Mattermost|Signal|WhatsApp|Email|IRC|Lark|D | Discord | `Discord: connected and identified` | `Discord: ignoring message from unauthorized user:` | `Discord: received Reconnect (op 7)` / `Discord: received Invalid Session (op 9)` | | Slack | `Slack channel listening on #` | `Slack: ignoring message from unauthorized user:` | `Slack poll error:` / `Slack parse error:` | | Mattermost | `Mattermost channel listening on` | `Mattermost: ignoring message from unauthorized user:` | `Mattermost poll error:` / `Mattermost parse error:` | -| Matrix | `Matrix channel listening on room` / `Matrix room ... is encrypted; E2EE decryption is enabled via matrix-sdk.` / `Matrix room-key backup is enabled for this device.` / `Matrix device '...' is verified for E2EE.` | `Matrix whoami failed; falling back to configured session hints for E2EE session restore:` / `Matrix whoami failed while resolving listener user_id; using configured user_id hint:` / `Matrix room-key backup is not enabled for this device...` / `Matrix device '...' is not verified...` | `Matrix sync error: ... retrying...` | +| Matrix | `Matrix channel listening on room` / `Matrix room ... is encrypted; E2EE decryption is enabled via matrix-sdk.` | `Matrix whoami failed; falling back to configured session hints for E2EE session restore:` / `Matrix whoami failed while resolving listener user_id; using configured user_id hint:` | `Matrix sync error: ... retrying...` | | Signal | `Signal channel listening via SSE on` | (allowlist checks are enforced by `allowed_from`) | `Signal SSE returned ...` / `Signal SSE connect error:` | | WhatsApp (channel) | `WhatsApp channel active (webhook mode).` | `WhatsApp: ignoring message from unauthorized number:` | `WhatsApp send failed:` | | Webhook / WhatsApp (gateway) | `WhatsApp webhook verified successfully` | `Webhook: rejected — not paired / invalid bearer token` / `Webhook: rejected request — invalid or missing X-Webhook-Secret` / `WhatsApp webhook verification failed — token mismatch` | `Webhook JSON parse error:` | @@ -340,3 +349,4 @@ If a specific channel task crashes or exits, the channel supervisor in `channels - `Channel message worker crashed:` These messages indicate automatic restart behavior is active, and you should inspect preceding logs for root cause. + diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index abc2c31..d7aed97 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -1,7 +1,9 @@ -use crate::config::schema::{DingTalkConfig, IrcConfig, QQConfig, StreamMode, WhatsAppConfig}; +use crate::config::schema::{ + DingTalkConfig, IrcConfig, LarkReceiveMode, QQConfig, StreamMode, WhatsAppConfig, +}; use crate::config::{ AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, - HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, + HeartbeatConfig, IMessageConfig, LarkConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebhookConfig, }; use crate::hardware::{self, HardwareConfig}; @@ -168,7 +170,8 @@ pub fn run_wizard() -> Result { || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some() - || config.channels_config.qq.is_some(); + || config.channels_config.qq.is_some() + || config.channels_config.lark.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() @@ -227,7 +230,8 @@ pub fn run_channels_repair_wizard() -> Result { || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some() - || config.channels_config.qq.is_some(); + || config.channels_config.qq.is_some() + || config.channels_config.lark.is_some(); if has_channels && config.api_key.is_some() { let launch: bool = Confirm::new() @@ -2544,13 +2548,21 @@ fn setup_channels() -> Result { "— Tencent QQ Bot" } ), + format!( + "Lark/Feishu {}", + if config.lark.is_some() { + "✅ connected" + } else { + "— Lark/Feishu Bot" + } + ), "Done — finish setup".to_string(), ]; let choice = Select::new() .with_prompt(" Connect a channel (or Done to continue)") .items(&options) - .default(10) + .default(11) .interact()?; match choice { @@ -3443,6 +3455,193 @@ fn setup_channels() -> Result { allowed_users, }); } + 10 => { + // ── Lark/Feishu ── + println!(); + println!( + " {} {}", + style("Lark/Feishu Setup").white().bold(), + style("— talk to ZeroClaw from Lark or Feishu").dim() + ); + print_bullet( + "1. Go to Lark/Feishu Open Platform (open.larksuite.com / open.feishu.cn)", + ); + print_bullet("2. Create an app and enable 'Bot' capability"); + print_bullet("3. Copy the App ID and App Secret"); + println!(); + + let app_id: String = Input::new().with_prompt(" App ID").interact_text()?; + let app_id = app_id.trim().to_string(); + + if app_id.trim().is_empty() { + println!(" {} Skipped", style("→").dim()); + continue; + } + + let app_secret: String = + Input::new().with_prompt(" App Secret").interact_text()?; + let app_secret = app_secret.trim().to_string(); + + if app_secret.is_empty() { + println!(" {} App Secret is required", style("❌").red().bold()); + continue; + } + + let use_feishu = Select::new() + .with_prompt(" Region") + .items(["Feishu (CN)", "Lark (International)"]) + .default(0) + .interact()? + == 0; + + // Test connection (run entirely in separate thread — Response must be used/dropped there) + print!(" {} Testing connection... ", style("⏳").dim()); + let base_url = if use_feishu { + "https://open.feishu.cn/open-apis" + } else { + "https://open.larksuite.com/open-apis" + }; + let app_id_clone = app_id.clone(); + let app_secret_clone = app_secret.clone(); + let endpoint = format!("{base_url}/auth/v3/tenant_access_token/internal"); + + let thread_result = std::thread::spawn(move || { + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(8)) + .connect_timeout(Duration::from_secs(4)) + .build() + .map_err(|err| format!("failed to build HTTP client: {err}"))?; + let body = serde_json::json!({ + "app_id": app_id_clone, + "app_secret": app_secret_clone, + }); + + let response = client + .post(endpoint) + .json(&body) + .send() + .map_err(|err| format!("request error: {err}"))?; + + let status = response.status(); + let payload: Value = response.json().unwrap_or_default(); + let has_token = payload + .get("tenant_access_token") + .and_then(Value::as_str) + .is_some_and(|token| !token.trim().is_empty()); + + if status.is_success() && has_token { + return Ok::<(), String>(()); + } + + let detail = payload + .get("msg") + .or_else(|| payload.get("message")) + .and_then(Value::as_str) + .unwrap_or("unknown error"); + + Err(format!("auth rejected ({status}): {detail}")) + }) + .join(); + + match thread_result { + Ok(Ok(())) => { + println!( + "\r {} Lark/Feishu credentials verified ", + style("✅").green().bold() + ); + } + Ok(Err(reason)) => { + println!( + "\r {} Connection failed — check your credentials", + style("❌").red().bold() + ); + println!(" {}", style(reason).dim()); + continue; + } + Err(_) => { + println!( + "\r {} Connection failed — check your credentials", + style("❌").red().bold() + ); + continue; + } + } + + let receive_mode_choice = Select::new() + .with_prompt(" Receive Mode") + .items([ + "WebSocket (recommended, no public IP needed)", + "Webhook (requires public HTTPS endpoint)", + ]) + .default(0) + .interact()?; + + let receive_mode = if receive_mode_choice == 0 { + LarkReceiveMode::Websocket + } else { + LarkReceiveMode::Webhook + }; + + let verification_token = if receive_mode == LarkReceiveMode::Webhook { + let token: String = Input::new() + .with_prompt(" Verification Token (optional, for Webhook mode)") + .allow_empty(true) + .interact_text()?; + if token.is_empty() { + None + } else { + Some(token) + } + } else { + None + }; + + if receive_mode == LarkReceiveMode::Webhook && verification_token.is_none() { + println!( + " {} Verification Token is empty — webhook authenticity checks are reduced.", + style("⚠").yellow().bold() + ); + } + + let port = if receive_mode == LarkReceiveMode::Webhook { + let p: String = Input::new() + .with_prompt(" Webhook Port") + .default("8080".into()) + .interact_text()?; + Some(p.parse().unwrap_or(8080)) + } else { + None + }; + + let users_str: String = Input::new() + .with_prompt(" Allowed user Open IDs (comma-separated, '*' for all)") + .allow_empty(true) + .interact_text()?; + + let allowed_users: Vec = users_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + + if allowed_users.is_empty() { + println!( + " {} No users allowlisted — Lark/Feishu inbound messages will be denied until you add Open IDs or '*'.", + style("⚠").yellow().bold() + ); + } + + config.lark = Some(LarkConfig { + app_id, + app_secret, + verification_token, + encrypt_key: None, + allowed_users, + use_feishu, + receive_mode, + port, + }); + } _ => break, // Done } println!(); @@ -3483,6 +3682,9 @@ fn setup_channels() -> Result { if config.qq.is_some() { active.push("QQ"); } + if config.lark.is_some() { + active.push("Lark"); + } println!( " {} Channels: {}", @@ -3935,7 +4137,8 @@ fn print_summary(config: &Config) { || config.channels_config.matrix.is_some() || config.channels_config.email.is_some() || config.channels_config.dingtalk.is_some() - || config.channels_config.qq.is_some(); + || config.channels_config.qq.is_some() + || config.channels_config.lark.is_some(); println!(); println!( @@ -4003,6 +4206,9 @@ fn print_summary(config: &Config) { if config.channels_config.webhook.is_some() { channels.push("Webhook"); } + if config.channels_config.lark.is_some() { + channels.push("Lark"); + } println!( " {} Channels: {}", style("📡").cyan(),