From 094c8767fd6f1478705a7ead556f7b27ec6c2873 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sun, 12 Jul 2026 20:41:09 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(wecom):=20full=20[wecom]=20section=20?= =?UTF-8?q?=E2=80=94=20credentials=20+=20connection=20config-first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second platform slice of the config-first parity umbrella (#1375): [wecom] graduates from the shared PlatformTrustConfig to a dedicated WecomConfig carrying corp_id, secret, token, encoding_aes_key, agent_id, webhook_path, streaming_enabled, debounce_secs alongside the trust fields. Each field resolves config → WECOM_* env → default. - core: WecomConfig/ResolvedWecom + resolve(); trust_config() view keeps the registry override path unchanged - gateway: GatewayWecomConfig bridge + AppState::apply_wecom_config; the apply rebuilds the adapter through the same from_reader validation as env-only construction (five mandatory credentials, numeric agent_id, 43-char AES key) — incomplete section resolves to no adapter, matching env-only semantics - unified: applies [wecom] before warn_unenforceable_l1 - docs: config.toml.example, config-reference.md ([wecom] gets its own full section; combined section shrinks to googlechat/teams), wecom.md Backward compatible: env-only deployments construct identically; existing trust-only [wecom] sections keep parsing (all fields optional). Closes #1378. Refs #1375. Stacked on #1381. --- config.toml.example | 8 + crates/openab-core/src/config.rs | 178 +++++++++++++++++++- crates/openab-gateway/src/adapters/wecom.rs | 2 +- crates/openab-gateway/src/lib.rs | 38 +++++ docs/config-reference.md | 29 +++- docs/wecom.md | 14 ++ src/main.rs | 21 ++- 7 files changed, 280 insertions(+), 10 deletions(-) diff --git a/config.toml.example b/config.toml.example index 1661ae6e7..20b4527b8 100644 --- a/config.toml.example +++ b/config.toml.example @@ -95,6 +95,14 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # credentials stay on their gateway env vars (WECOM_CORP_ID/WECOM_SECRET, # GOOGLE_CHAT_*, TEAMS_APP_ID/TEAMS_APP_SECRET). # [wecom] +# corp_id = "${WECOM_CORP_ID}" # env fallback: WECOM_CORP_ID +# secret = "${WECOM_SECRET}" # env fallback: WECOM_SECRET +# token = "${WECOM_TOKEN}" # callback token (L1); env fallback: WECOM_TOKEN +# encoding_aes_key = "${WECOM_ENCODING_AES_KEY}" # 43 chars (L1); env fallback: WECOM_ENCODING_AES_KEY +# agent_id = "1000002" # numeric; env fallback: WECOM_AGENT_ID +# webhook_path = "/webhook/wecom" # env fallback: WECOM_WEBHOOK_PATH +# streaming_enabled = false # env fallback: WECOM_STREAMING_ENABLED +# debounce_secs = 3 # env fallback: WECOM_DEBOUNCE_SECS # allow_all_users = false # env fallback: WECOM_ALLOW_ALL_USERS # allowed_users = ["zhangsan", "lisi"] # WeCom UserIDs (tenant-assigned, freeform strings) # # env fallback: WECOM_ALLOWED_USERS (comma-separated) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 502a25739..795d10d8a 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -173,7 +173,7 @@ pub struct Config { pub gateway: Option, pub telegram: Option, pub line: Option, - pub wecom: Option, + pub wecom: Option, pub googlechat: Option, pub teams: Option, pub agentcore: Option, @@ -865,6 +865,116 @@ impl LineConfig { } } +/// First-class `[wecom]` section — credentials, connection, and L3 identity +/// trust for the WeCom adapter. Config-first invariant (#1375): each field +/// resolves `[wecom].field` (with `${}` expansion) → `WECOM_*` env var → +/// default. Graduates from the shared [`PlatformTrustConfig`] (#1378). +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct WecomConfig { + /// Corp ID. Env fallback: `WECOM_CORP_ID`. + pub corp_id: Option, + /// App secret (access-token exchange). Env fallback: `WECOM_SECRET`. + pub secret: Option, + /// Callback token (signature verification, L1). Env fallback: `WECOM_TOKEN`. + pub token: Option, + /// Callback AES key (43 chars, message decryption, L1). Env fallback: + /// `WECOM_ENCODING_AES_KEY`. + pub encoding_aes_key: Option, + /// Agent ID (numeric). Env fallback: `WECOM_AGENT_ID`. + pub agent_id: Option, + /// Webhook mount path. Env fallback: `WECOM_WEBHOOK_PATH` + /// (default `/webhook/wecom`). + pub webhook_path: Option, + /// Streaming (recall + resend) opt-in. Env fallback: + /// `WECOM_STREAMING_ENABLED` (default false). + pub streaming_enabled: Option, + /// Debounce window in seconds. Env fallback: `WECOM_DEBOUNCE_SECS` + /// (default 3). + pub debounce_secs: Option, + /// Explicit flag: true = allow all users, false = check `allowed_users`. + /// When not set, defaults to `false` (deny-all, per identity-trust-none + /// ADR). Env fallback: `WECOM_ALLOW_ALL_USERS` (empty string = unset). + pub allow_all_users: Option, + /// WeCom UserIDs (tenant-assigned, freeform strings) allowed to interact. + /// Only checked when `allow_all_users` resolves to `false`. Env fallback: + /// `WECOM_ALLOWED_USERS` (comma-separated). + pub allowed_users: Option>, +} + +/// Fully resolved WeCom settings (config → env → default applied). +#[derive(Debug, Clone)] +pub struct ResolvedWecom { + pub corp_id: Option, + pub secret: Option, + pub token: Option, + pub encoding_aes_key: Option, + pub agent_id: Option, + pub webhook_path: String, + pub streaming_enabled: bool, + pub debounce_secs: u64, + pub allow_all_users: bool, + pub allowed_users: Vec, +} + +impl WecomConfig { + /// Resolve every field: config value (if set) → `WECOM_*` env → default. + /// String fields filter empty strings from `${}` expansion of unset vars. + pub fn resolve(&self) -> ResolvedWecom { + let opt_str = |cfg: &Option, env: &str| -> Option { + cfg.as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .or_else(|| std::env::var(env).ok()) + }; + ResolvedWecom { + corp_id: opt_str(&self.corp_id, "WECOM_CORP_ID"), + secret: opt_str(&self.secret, "WECOM_SECRET"), + token: opt_str(&self.token, "WECOM_TOKEN"), + encoding_aes_key: opt_str(&self.encoding_aes_key, "WECOM_ENCODING_AES_KEY"), + agent_id: opt_str(&self.agent_id, "WECOM_AGENT_ID"), + webhook_path: opt_str(&self.webhook_path, "WECOM_WEBHOOK_PATH") + .unwrap_or_else(|| "/webhook/wecom".into()), + streaming_enabled: self.streaming_enabled.unwrap_or_else(|| { + std::env::var("WECOM_STREAMING_ENABLED") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) + }), + debounce_secs: self.debounce_secs.unwrap_or_else(|| { + std::env::var("WECOM_DEBOUNCE_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(3) + }), + allow_all_users: self.allow_all_users.unwrap_or_else(|| { + std::env::var("WECOM_ALLOW_ALL_USERS") + .ok() + .filter(|v| !v.is_empty()) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(false) + }), + allowed_users: match &self.allowed_users { + Some(list) => list.clone(), + None => std::env::var("WECOM_ALLOWED_USERS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }, + } + } + + /// Trust-fields view for the shared registry override path, preserving + /// the semantics the section had as a [`PlatformTrustConfig`]. + pub fn trust_config(&self) -> PlatformTrustConfig { + PlatformTrustConfig { + allow_all_users: self.allow_all_users, + allowed_users: self.allowed_users.clone(), + } + } +} + /// Shared first-class trust section for gateway platforms whose Phase 1 needs /// exactly the two L3 identity fields (identity-trust-none ADR): `[wecom]`, /// `[googlechat]`, `[teams]`. Same shape and resolution order as @@ -2132,6 +2242,68 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] std::env::remove_var("LINE_ALLOWED_USERS"); } + /// All `WECOM_*` env scenarios in ONE test — env is process-global (same + /// pattern as `line_resolve_all_scenarios`). Only credential/connection + /// fields here; the trust fields share `PlatformTrustConfig` semantics + /// via `trust_config()` and are covered by `platform_trust_resolve_all_scenarios`. + #[test] + fn wecom_resolve_all_scenarios() { + for k in [ + "WECOM_CORP_ID", + "WECOM_SECRET", + "WECOM_TOKEN", + "WECOM_ENCODING_AES_KEY", + "WECOM_AGENT_ID", + "WECOM_WEBHOOK_PATH", + "WECOM_STREAMING_ENABLED", + "WECOM_DEBOUNCE_SECS", + ] { + std::env::remove_var(k); + } + // --- defaults --- + let r = WecomConfig::default().resolve(); + assert!(r.corp_id.is_none()); + assert_eq!(r.webhook_path, "/webhook/wecom"); + assert!(!r.streaming_enabled); + assert_eq!(r.debounce_secs, 3); + + // --- config wins over env --- + std::env::set_var("WECOM_CORP_ID", "env-corp"); + std::env::set_var("WECOM_DEBOUNCE_SECS", "9"); + let cfg = WecomConfig { + corp_id: Some("cfg-corp".into()), + debounce_secs: Some(5), + ..Default::default() + }; + let r = cfg.resolve(); + assert_eq!(r.corp_id.as_deref(), Some("cfg-corp")); + assert_eq!(r.debounce_secs, 5); + + // --- empty-string ${} expansion falls through to env --- + let cfg = WecomConfig { + corp_id: Some("".into()), + ..Default::default() + }; + assert_eq!(cfg.resolve().corp_id.as_deref(), Some("env-corp")); + assert_eq!(cfg.resolve().debounce_secs, 9); // env fallback + + // --- trust_config() preserves trust semantics --- + let cfg = WecomConfig { + allow_all_users: Some(true), + allowed_users: Some(vec!["zhangsan".into()]), + ..Default::default() + }; + let t = cfg.trust_config(); + assert_eq!(t.allow_all_users, Some(true)); + assert_eq!( + t.allowed_users.as_deref(), + Some(&["zhangsan".to_string()][..]) + ); + + std::env::remove_var("WECOM_CORP_ID"); + std::env::remove_var("WECOM_DEBOUNCE_SECS"); + } + #[test] fn platform_trust_sections_parse_from_toml() { let toml_str = r#" @@ -2139,6 +2311,8 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] bot_token = "x" [wecom] +corp_id = "corp1" +token = "tok" allowed_users = ["zhangsan", "lisi"] [googlechat] @@ -2149,6 +2323,8 @@ allow_all_users = true "#; let cfg = parse_config_str(toml_str, "test").unwrap(); let wecom = cfg.wecom.expect("wecom section"); + assert_eq!(wecom.corp_id.as_deref(), Some("corp1")); + assert_eq!(wecom.token.as_deref(), Some("tok")); assert_eq!( wecom.allowed_users.as_deref(), Some(&["zhangsan".to_string(), "lisi".to_string()][..]) diff --git a/crates/openab-gateway/src/adapters/wecom.rs b/crates/openab-gateway/src/adapters/wecom.rs index b5c8ae5fb..97aa84551 100644 --- a/crates/openab-gateway/src/adapters/wecom.rs +++ b/crates/openab-gateway/src/adapters/wecom.rs @@ -24,7 +24,7 @@ impl WecomConfig { /// Build config from an arbitrary string reader. Tests use this with a /// HashMap so they don't mutate process-wide environment variables — /// `env::set_var` races other tests under cargo's parallel runner. - fn from_reader Option>(read: F) -> Option { + pub(crate) fn from_reader Option>(read: F) -> Option { let corp_id = read("WECOM_CORP_ID")?; let secret = read("WECOM_SECRET")?; let token = read("WECOM_TOKEN")?; diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 0cadf06f5..0d3c0450b 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -328,6 +328,29 @@ impl AppState { self.line_access_token = cfg.channel_access_token; self.line_webhook_path = cfg.webhook_path; } + + /// Apply resolved `[wecom]` config values (#1378), rebuilding the WeCom + /// adapter from them. Reuses the adapter's `from_reader` construction so + /// the exact same validation applies (all five credentials mandatory, + /// numeric agent_id, 43-char AES key) — an incomplete section resolves to + /// no adapter, matching env-only semantics. + #[cfg(feature = "wecom")] + pub fn apply_wecom_config(&mut self, cfg: GatewayWecomConfig) { + let streaming = if cfg.streaming_enabled { "true" } else { "false" }.to_string(); + let debounce = cfg.debounce_secs.to_string(); + self.wecom = adapters::wecom::WecomConfig::from_reader(|k| match k { + "WECOM_CORP_ID" => cfg.corp_id.clone(), + "WECOM_SECRET" => cfg.secret.clone(), + "WECOM_TOKEN" => cfg.token.clone(), + "WECOM_ENCODING_AES_KEY" => cfg.encoding_aes_key.clone(), + "WECOM_AGENT_ID" => cfg.agent_id.clone(), + "WECOM_WEBHOOK_PATH" => Some(cfg.webhook_path.clone()), + "WECOM_STREAMING_ENABLED" => Some(streaming.clone()), + "WECOM_DEBOUNCE_SECS" => Some(debounce.clone()), + _ => None, + }) + .map(adapters::wecom::WecomAdapter::new); + } } /// Parameter object for passing resolved Telegram config across the crate @@ -350,6 +373,21 @@ pub struct GatewayLineConfig { pub webhook_path: String, } +/// Parameter object for passing resolved WeCom config across the crate +/// boundary without introducing a dependency on `openab-core` (#1378). +/// Fields are the fully resolved (config → env → default) values. +#[derive(Debug, Clone)] +pub struct GatewayWecomConfig { + pub corp_id: Option, + pub secret: Option, + pub token: Option, + pub encoding_aes_key: Option, + pub agent_id: Option, + pub webhook_path: String, + pub streaming_enabled: bool, + pub debounce_secs: u64, +} + // --- Public serve() entry point --- /// Configuration for the standalone gateway server. diff --git a/docs/config-reference.md b/docs/config-reference.md index ce77b58c3..aedb7e94f 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -159,13 +159,32 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] --- -## `[wecom]` / `[googlechat]` / `[teams]` +## `[wecom]` -First-class L3 identity trust for the remaining gateway platforms — same shape and semantics as `[line]`. Each section replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for its platform (deprecated: warns at startup, becomes an error in Phase 2). Platform credentials remain on the gateway env vars (`WECOM_CORP_ID`/`WECOM_SECRET`, `GOOGLE_CHAT_*`, `TEAMS_APP_ID`/`TEAMS_APP_SECRET`). +Full first-class WeCom section (config-first parity, #1378) — credentials, connection, and L3 identity trust. Each field resolves: config → `WECOM_*` env → default. The adapter requires all five credentials (`corp_id`, `secret`, `token`, `encoding_aes_key`, `agent_id`); an incomplete section (after env fallback) disables the adapter, matching env-only semantics. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `corp_id` | string | — | Corp ID. Env: `WECOM_CORP_ID`. | +| `secret` | string | — | App secret. Env: `WECOM_SECRET`. | +| `token` | string | — | Callback token (L1 signature). Env: `WECOM_TOKEN`. | +| `encoding_aes_key` | string | — | 43-char callback AES key (L1). Env: `WECOM_ENCODING_AES_KEY`. | +| `agent_id` | string | — | Numeric agent id. Env: `WECOM_AGENT_ID`. | +| `webhook_path` | string | `/webhook/wecom` | Env: `WECOM_WEBHOOK_PATH`. | +| `streaming_enabled` | bool | `false` | Recall+resend streaming opt-in. Env: `WECOM_STREAMING_ENABLED`. | +| `debounce_secs` | u64 | `3` | Debounce window. Env: `WECOM_DEBOUNCE_SECS`. | +| `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `WECOM_ALLOW_ALL_USERS`. | +| `allowed_users` | string[] | `[]` | WeCom UserIDs. Env: `WECOM_ALLOWED_USERS` (comma-separated). | + +--- + +## `[googlechat]` / `[teams]` + +First-class L3 identity trust — same shape and semantics as `[line]`. Each section replaces the uniform `GATEWAY_ALLOW_ALL_USERS` / `GATEWAY_ALLOWED_USERS` env vars for its platform (deprecated: warns at startup, becomes an error in Phase 2). Platform credentials remain on the gateway env vars (`GOOGLE_CHAT_*`, `TEAMS_APP_ID`/`TEAMS_APP_SECRET`) until their config-first parity slices land (#1379, #1380). > **Mode scoping:** these sections (like `[line]`) take effect on the **embedded/unified adapter path**, where events pass the shared ingress trust gate. Deployments using the standalone `openab-gateway` companion over WebSocket enforce trust via `[gateway].allow_all_users` / `allowed_users` instead; Phase 1c consolidates the two paths. -Each field resolves: config value → `{PREFIX}_*` env var → default (deny-all). Env prefixes: `WECOM`, `GOOGLE_CHAT`, `TEAMS`. +Each field resolves: config value → `{PREFIX}_*` env var → default (deny-all). Env prefixes: `GOOGLE_CHAT`, `TEAMS`. | Key | Type | Default | Description | |-----|------|---------|-------------| @@ -176,14 +195,10 @@ Sender ID formats per platform: | Platform | Sender ID format | Example | |----------|-----------------|---------| -| WeCom | Tenant-assigned UserID (freeform string) | `"zhangsan"` | | Google Chat | User resource name | `"users/123456789"` | | MS Teams | Bot Framework `activity.from.id` | `"29:1abc..."` | ```toml -[wecom] -allowed_users = ["zhangsan", "lisi"] - [googlechat] allowed_users = ["users/123456789"] diff --git a/docs/wecom.md b/docs/wecom.md index 8c6737613..ae969fb86 100644 --- a/docs/wecom.md +++ b/docs/wecom.md @@ -129,6 +129,20 @@ max_sessions = 10 | `allow_all_channels` | No | Allow messages from all channels (default: `false`) | | `allow_all_users` | No | Allow messages from all users (default: `false`) | +### `[wecom]` Section (credentials + trust) + +Since #1378 the `[wecom]` section carries the full adapter configuration — credentials, connection, and trust — config-first with `WECOM_*` env fallback: + +```toml +[wecom] +corp_id = "${WECOM_CORP_ID}" +secret = "${WECOM_SECRET}" +token = "${WECOM_TOKEN}" +encoding_aes_key = "${WECOM_ENCODING_AES_KEY}" +agent_id = "1000002" +allowed_users = ["zhangsan", "lisi"] +``` + ### User Trust (`[wecom]` section) > **Mode scoping:** the `[wecom]` section applies when the WeCom adapter is **embedded in the OAB binary** (unified mode, `WECOM_CORP_ID` env set on the OAB container). In the standalone-gateway mode shown above, trust is enforced by `[gateway].allow_all_users` / `allowed_users` instead — the `[wecom]` section has no effect on that path yet (Phase 1c consolidates the two). diff --git a/src/main.rs b/src/main.rs index 1c564ee5a..884b138ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -513,7 +513,7 @@ async fn main() -> anyhow::Result<()> { platform_trust_override( &mut reg, "wecom", - &cfg.wecom, + &cfg.wecom.as_ref().map(|w| w.trust_config()), "WECOM", cfg!(feature = "wecom") && std::env::var("WECOM_CORP_ID").is_ok(), allow_all_channels, @@ -880,6 +880,25 @@ async fn main() -> anyhow::Result<()> { webhook_path: r.webhook_path, }); } + + // First-class `[wecom]` config overrides env-derived values + // (config-authoritative + ${} expansion + WECOM_* env fallback, + // #1378). The apply rebuilds the adapter through the same + // validation as env-only construction. + #[cfg(feature = "wecom")] + if let Some(ref w) = cfg.wecom { + let r = w.resolve(); + gw_state_inner.apply_wecom_config(openab_gateway::GatewayWecomConfig { + corp_id: r.corp_id, + secret: r.secret, + token: r.token, + encoding_aes_key: r.encoding_aes_key, + agent_id: r.agent_id, + webhook_path: r.webhook_path, + streaming_enabled: r.streaming_enabled, + debounce_secs: r.debounce_secs, + }); + } let gw_state = Arc::new(gw_state_inner); // Phase 1 L1 audit (#1356): warn if any active webhook platform has From e313d591298fc4729efb5e26892634e30eb80dd4 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Sun, 12 Jul 2026 21:05:04 -0400 Subject: [PATCH 2/2] docs(config): drop graduated [wecom] from PlatformTrustConfig doc (review F1) --- crates/openab-core/src/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 795d10d8a..4c3544fad 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -976,14 +976,14 @@ impl WecomConfig { } /// Shared first-class trust section for gateway platforms whose Phase 1 needs -/// exactly the two L3 identity fields (identity-trust-none ADR): `[wecom]`, -/// `[googlechat]`, `[teams]`. Same shape and resolution order as +/// exactly the two L3 identity fields (identity-trust-none ADR): `[googlechat]`, +/// `[teams]`. Same shape and resolution order as /// [`LineConfig`] / [`TelegramConfig`]: `[section].field` (with `${}` /// expansion) → `{PREFIX}_*` env var → deny-all default. /// /// Trust-only by design — platform credentials stay on the gateway env vars -/// the webhook adapters read (`WECOM_CORP_ID`/`WECOM_SECRET`, -/// `GOOGLE_CHAT_*`, `TEAMS_APP_ID`/`TEAMS_APP_SECRET`). Platforms that later +/// the webhook adapters read (`GOOGLE_CHAT_*`, +/// `TEAMS_APP_ID`/`TEAMS_APP_SECRET`). Platforms that later /// need extra trust fields (e.g. `trusted_bot_ids`) graduate to their own /// struct, as LINE will for group policy. #[derive(Debug, Clone, Default, Deserialize)]