fix(feishu): map legacy config keys and improve feature guidance

This commit is contained in:
argenis de la rosa 2026-03-01 00:51:56 -05:00 committed by Argenis
parent fe3556da58
commit d800b1caf5
2 changed files with 222 additions and 3 deletions

View File

@ -5117,8 +5117,14 @@ fn collect_configured_channels(
#[cfg(not(feature = "channel-lark"))]
if config.channels_config.lark.is_some() || config.channels_config.feishu.is_some() {
let executable = std::env::current_exe()
.map(|path| path.display().to_string())
.unwrap_or_else(|_| "<unknown>".to_string());
tracing::warn!(
"Lark/Feishu channel is configured but this build was compiled without `channel-lark`; skipping Lark/Feishu health check."
"Lark/Feishu channel is configured but this binary was compiled without `channel-lark`; skipping Lark/Feishu startup. \
binary={executable}. \
If you built from source, run the built artifact directly (for example `./target/release/zeroclaw daemon`) \
or run `cargo run --features channel-lark -- daemon`."
);
}

View File

@ -7032,6 +7032,81 @@ fn validate_mcp_config(config: &McpConfig) -> Result<()> {
Ok(())
}
fn legacy_feishu_table(raw_toml: &toml::Value) -> Option<&toml::map::Map<String, toml::Value>> {
raw_toml
.get("channels_config")?
.as_table()?
.get("feishu")?
.as_table()
}
fn extract_legacy_feishu_mention_only(raw_toml: &toml::Value) -> Option<bool> {
legacy_feishu_table(raw_toml)?
.get("mention_only")
.and_then(toml::Value::as_bool)
}
fn has_legacy_feishu_use_feishu(raw_toml: &toml::Value) -> bool {
legacy_feishu_table(raw_toml)
.and_then(|table| table.get("use_feishu"))
.is_some()
}
fn is_legacy_feishu_unknown_path(path: &str, key: &str) -> bool {
if !path.ends_with(key) {
return false;
}
let mut segments = path.split('.');
matches!(
(segments.next(), segments.next(), segments.next()),
(Some("channels_config"), Some("feishu"), Some(_))
)
}
fn apply_feishu_legacy_compat(
config: &mut Config,
legacy_feishu_mention_only: Option<bool>,
legacy_feishu_use_feishu_present: bool,
saw_legacy_feishu_mention_only_path: bool,
saw_legacy_feishu_use_feishu_path: bool,
) {
// Backward compatibility: users sometimes migrate config snippets from
// [channels_config.lark] to [channels_config.feishu] and keep old keys.
if let Some(feishu_cfg) = config.channels_config.feishu.as_mut() {
if let Some(legacy_mention_only) = legacy_feishu_mention_only {
if feishu_cfg.group_reply.is_none() {
let mapped_mode = if legacy_mention_only {
GroupReplyMode::MentionOnly
} else {
GroupReplyMode::AllMessages
};
feishu_cfg.group_reply = Some(GroupReplyConfig {
mode: Some(mapped_mode),
allowed_sender_ids: Vec::new(),
});
tracing::warn!(
"Legacy key [channels_config.feishu].mention_only is deprecated; mapped to [channels_config.feishu.group_reply].mode."
);
} else if saw_legacy_feishu_mention_only_path {
tracing::warn!(
"Legacy key [channels_config.feishu].mention_only is ignored because [channels_config.feishu.group_reply] is already set."
);
}
} else if saw_legacy_feishu_mention_only_path {
tracing::warn!(
"Legacy key [channels_config.feishu].mention_only is invalid; expected boolean."
);
}
if legacy_feishu_use_feishu_present || saw_legacy_feishu_use_feishu_path {
tracing::warn!(
"Legacy key [channels_config.feishu].use_feishu is redundant and ignored; [channels_config.feishu] always uses Feishu endpoints."
);
}
}
}
impl Config {
pub async fn load_or_init() -> Result<Self> {
let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
@ -7070,8 +7145,49 @@ impl Config {
.await
.context("Failed to read config file")?;
let mut config: Config =
toml::from_str(&contents).context("Failed to deserialize config file")?;
// Parse raw TOML first so legacy compatibility rewrites can be applied after
// deserialization while still preserving unknown-key detection.
let raw_toml: toml::Value =
toml::from_str(&contents).context("Failed to parse config file")?;
let legacy_feishu_mention_only = extract_legacy_feishu_mention_only(&raw_toml);
let legacy_feishu_use_feishu_present = has_legacy_feishu_use_feishu(&raw_toml);
// Track ignored/unknown config keys to warn users about silent misconfigurations
// (e.g., using [providers.ollama] which doesn't exist instead of top-level api_url)
let mut ignored_paths: Vec<String> = Vec::new();
let mut config: Config = serde_ignored::deserialize(
toml::de::Deserializer::parse(&contents).context("Failed to parse config file")?,
|path| {
ignored_paths.push(path.to_string());
},
)
.context("Failed to deserialize config file")?;
let mut saw_legacy_feishu_mention_only_path = false;
let mut saw_legacy_feishu_use_feishu_path = false;
// Warn about each unknown config key
for path in ignored_paths {
if is_legacy_feishu_unknown_path(&path, ".mention_only") {
saw_legacy_feishu_mention_only_path = true;
continue;
}
if is_legacy_feishu_unknown_path(&path, ".use_feishu") {
saw_legacy_feishu_use_feishu_path = true;
continue;
}
tracing::warn!(
"Unknown config key ignored: \"{}\". Check config.toml for typos or deprecated options.",
path
);
}
apply_feishu_legacy_compat(
&mut config,
legacy_feishu_mention_only,
legacy_feishu_use_feishu_present,
saw_legacy_feishu_mention_only_path,
saw_legacy_feishu_use_feishu_path,
);
// Set computed paths that are skipped during serialization
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
@ -13103,6 +13219,103 @@ default_model = "legacy-model"
);
}
#[test]
async fn feishu_legacy_key_extractors_detect_compat_fields() {
let raw: toml::Value = toml::from_str(
r#"
[channels_config.feishu]
app_id = "cli_123"
app_secret = "secret"
mention_only = true
use_feishu = true
"#,
)
.unwrap();
assert_eq!(extract_legacy_feishu_mention_only(&raw), Some(true));
assert!(has_legacy_feishu_use_feishu(&raw));
}
#[test]
async fn feishu_legacy_unknown_path_matcher_is_strict() {
assert!(is_legacy_feishu_unknown_path(
"channels_config.feishu.?.mention_only",
".mention_only"
));
assert!(is_legacy_feishu_unknown_path(
"channels_config.feishu.?.use_feishu",
".use_feishu"
));
assert!(!is_legacy_feishu_unknown_path(
"channels_config.feishu_extra.?.mention_only",
".mention_only"
));
assert!(!is_legacy_feishu_unknown_path(
"channels_config.feishu_legacy.?.use_feishu",
".use_feishu"
));
}
#[test]
async fn feishu_legacy_mention_only_maps_to_group_reply_mode() {
let mut parsed = Config::default();
parsed.channels_config.feishu = Some(FeishuConfig {
app_id: "cli_123".into(),
app_secret: "secret".into(),
encrypt_key: None,
verification_token: None,
allowed_users: vec![],
group_reply: None,
receive_mode: LarkReceiveMode::Websocket,
port: None,
draft_update_interval_ms: default_lark_draft_update_interval_ms(),
max_draft_edits: default_lark_max_draft_edits(),
});
apply_feishu_legacy_compat(&mut parsed, Some(true), true, true, true);
let feishu = parsed
.channels_config
.feishu
.expect("feishu config should exist");
assert_eq!(
feishu.effective_group_reply_mode(),
GroupReplyMode::MentionOnly
);
}
#[test]
async fn feishu_legacy_mention_only_does_not_override_group_reply() {
let mut parsed = Config::default();
parsed.channels_config.feishu = Some(FeishuConfig {
app_id: "cli_123".into(),
app_secret: "secret".into(),
encrypt_key: None,
verification_token: None,
allowed_users: vec![],
group_reply: Some(GroupReplyConfig {
mode: Some(GroupReplyMode::AllMessages),
allowed_sender_ids: vec![],
}),
receive_mode: LarkReceiveMode::Websocket,
port: None,
draft_update_interval_ms: default_lark_draft_update_interval_ms(),
max_draft_edits: default_lark_max_draft_edits(),
});
apply_feishu_legacy_compat(&mut parsed, Some(true), false, true, false);
let feishu = parsed
.channels_config
.feishu
.expect("feishu config should exist");
assert_eq!(
feishu.effective_group_reply_mode(),
GroupReplyMode::AllMessages
);
}
#[test]
async fn qq_config_defaults_to_webhook_receive_mode() {
let json = r#"{"app_id":"123","app_secret":"secret"}"#;