Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions config.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
186 changes: 181 additions & 5 deletions crates/openab-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ pub struct Config {
pub gateway: Option<GatewayConfig>,
pub telegram: Option<TelegramConfig>,
pub line: Option<LineConfig>,
pub wecom: Option<PlatformTrustConfig>,
pub wecom: Option<WecomConfig>,
pub googlechat: Option<PlatformTrustConfig>,
pub teams: Option<PlatformTrustConfig>,
pub agentcore: Option<AgentCoreConfig>,
Expand Down Expand Up @@ -865,15 +865,125 @@ 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<String>,
/// App secret (access-token exchange). Env fallback: `WECOM_SECRET`.
pub secret: Option<String>,
/// Callback token (signature verification, L1). Env fallback: `WECOM_TOKEN`.
pub token: Option<String>,
/// Callback AES key (43 chars, message decryption, L1). Env fallback:
/// `WECOM_ENCODING_AES_KEY`.
pub encoding_aes_key: Option<String>,
/// Agent ID (numeric). Env fallback: `WECOM_AGENT_ID`.
pub agent_id: Option<String>,
/// Webhook mount path. Env fallback: `WECOM_WEBHOOK_PATH`
/// (default `/webhook/wecom`).
pub webhook_path: Option<String>,
/// Streaming (recall + resend) opt-in. Env fallback:
/// `WECOM_STREAMING_ENABLED` (default false).
pub streaming_enabled: Option<bool>,
/// Debounce window in seconds. Env fallback: `WECOM_DEBOUNCE_SECS`
/// (default 3).
pub debounce_secs: Option<u64>,
/// 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<bool>,
/// 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<Vec<String>>,
}

/// Fully resolved WeCom settings (config → env → default applied).
#[derive(Debug, Clone)]
pub struct ResolvedWecom {
pub corp_id: Option<String>,
pub secret: Option<String>,
pub token: Option<String>,
pub encoding_aes_key: Option<String>,
pub agent_id: Option<String>,
pub webhook_path: String,
pub streaming_enabled: bool,
pub debounce_secs: u64,
pub allow_all_users: bool,
pub allowed_users: Vec<String>,
}

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<String>, env: &str| -> Option<String> {
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
/// 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)]
Expand Down Expand Up @@ -2132,13 +2242,77 @@ 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#"
[discord]
bot_token = "x"

[wecom]
corp_id = "corp1"
token = "tok"
allowed_users = ["zhangsan", "lisi"]

[googlechat]
Expand All @@ -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()][..])
Expand Down
2 changes: 1 addition & 1 deletion crates/openab-gateway/src/adapters/wecom.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F: Fn(&str) -> Option<String>>(read: F) -> Option<Self> {
pub(crate) fn from_reader<F: Fn(&str) -> Option<String>>(read: F) -> Option<Self> {
let corp_id = read("WECOM_CORP_ID")?;
let secret = read("WECOM_SECRET")?;
let token = read("WECOM_TOKEN")?;
Expand Down
38 changes: 38 additions & 0 deletions crates/openab-gateway/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String>,
pub secret: Option<String>,
pub token: Option<String>,
pub encoding_aes_key: Option<String>,
pub agent_id: Option<String>,
pub webhook_path: String,
pub streaming_enabled: bool,
pub debounce_secs: u64,
}

// --- Public serve() entry point ---

/// Configuration for the standalone gateway server.
Expand Down
29 changes: 22 additions & 7 deletions docs/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-----|------|---------|-------------|
Expand All @@ -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"]

Expand Down
14 changes: 14 additions & 0 deletions docs/wecom.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading