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.
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.

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::{
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> {
|| 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> {
|| 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<ChannelsConfig> {
"— 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<ChannelsConfig> {
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
}
println!();
@ -3483,6 +3682,9 @@ fn setup_channels() -> Result<ChannelsConfig> {
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(),