feat(onboard): add and harden Lark/Feishu wizard support

- add interactive Lark/Feishu setup in onboarding
- validate credentials with timeouts and clearer diagnostics
- add webhook/allowlist safety warnings for insecure defaults
- document interactive onboarding workflow in channels reference

Co-authored-by: HalcyonAzure <53591299+HalcyonAzure@users.noreply.github.com>
This commit is contained in:
Chummy 2026-02-19 10:31:05 +08:00
parent 606f2860a0
commit 8f7d879fd5
2 changed files with 227 additions and 11 deletions

View file

@ -150,10 +150,6 @@ allowed_users = ["*"]
See [Matrix E2EE Guide](./matrix-e2ee-guide.md) for encrypted-room troubleshooting. 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 ### 4.6 Signal
```toml ```toml
@ -236,6 +232,19 @@ receive_mode = "websocket" # or "webhook"
port = 8081 # required for webhook mode 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 ### 4.12 DingTalk
```toml ```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)` | | 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:` | | 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:` | | 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:` | | 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:` | | 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:` | | 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:` - `Channel message worker crashed:`
These messages indicate automatic restart behavior is active, and you should inspect preceding logs for root cause. These messages indicate automatic restart behavior is active, and you should inspect preceding logs for root cause.

View file

@ -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::{ use crate::config::{
AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig, AutonomyConfig, BrowserConfig, ChannelsConfig, ComposioConfig, Config, DiscordConfig,
HeartbeatConfig, IMessageConfig, MatrixConfig, MemoryConfig, ObservabilityConfig, HeartbeatConfig, IMessageConfig, LarkConfig, MatrixConfig, MemoryConfig, ObservabilityConfig,
RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebhookConfig, RuntimeConfig, SecretsConfig, SlackConfig, StorageConfig, TelegramConfig, WebhookConfig,
}; };
use crate::hardware::{self, HardwareConfig}; use crate::hardware::{self, HardwareConfig};
@ -168,7 +170,8 @@ pub fn run_wizard() -> Result<Config> {
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some() || config.channels_config.email.is_some()
|| config.channels_config.dingtalk.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() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -227,7 +230,8 @@ pub fn run_channels_repair_wizard() -> Result<Config> {
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some() || config.channels_config.email.is_some()
|| config.channels_config.dingtalk.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() { if has_channels && config.api_key.is_some() {
let launch: bool = Confirm::new() let launch: bool = Confirm::new()
@ -2544,13 +2548,21 @@ fn setup_channels() -> Result<ChannelsConfig> {
"— Tencent QQ Bot" "— Tencent QQ Bot"
} }
), ),
format!(
"Lark/Feishu {}",
if config.lark.is_some() {
"✅ connected"
} else {
"— Lark/Feishu Bot"
}
),
"Done — finish setup".to_string(), "Done — finish setup".to_string(),
]; ];
let choice = Select::new() let choice = Select::new()
.with_prompt(" Connect a channel (or Done to continue)") .with_prompt(" Connect a channel (or Done to continue)")
.items(&options) .items(&options)
.default(10) .default(11)
.interact()?; .interact()?;
match choice { match choice {
@ -3443,6 +3455,193 @@ fn setup_channels() -> Result<ChannelsConfig> {
allowed_users, 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<String> = 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 _ => break, // Done
} }
println!(); println!();
@ -3483,6 +3682,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
if config.qq.is_some() { if config.qq.is_some() {
active.push("QQ"); active.push("QQ");
} }
if config.lark.is_some() {
active.push("Lark");
}
println!( println!(
" {} Channels: {}", " {} Channels: {}",
@ -3935,7 +4137,8 @@ fn print_summary(config: &Config) {
|| config.channels_config.matrix.is_some() || config.channels_config.matrix.is_some()
|| config.channels_config.email.is_some() || config.channels_config.email.is_some()
|| config.channels_config.dingtalk.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!();
println!( println!(
@ -4003,6 +4206,9 @@ fn print_summary(config: &Config) {
if config.channels_config.webhook.is_some() { if config.channels_config.webhook.is_some() {
channels.push("Webhook"); channels.push("Webhook");
} }
if config.channels_config.lark.is_some() {
channels.push("Lark");
}
println!( println!(
" {} Channels: {}", " {} Channels: {}",
style("📡").cyan(), style("📡").cyan(),