diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4239edd9..b2c13804 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "src/**"
+ - "gateway/**"
- "Cargo.toml"
- "Cargo.lock"
- "Dockerfile*"
@@ -31,3 +32,28 @@ jobs:
- name: cargo test
run: cargo test
+
+ gateway:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: gateway
+ steps:
+ - uses: actions/checkout@v6
+
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: clippy
+
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: gateway
+
+ - name: cargo check
+ run: cargo check
+
+ - name: cargo clippy
+ run: cargo clippy -- -D warnings
+
+ - name: cargo test
+ run: cargo test
diff --git a/README.md b/README.md
index 4dd3d4f2..2d630404 100644
--- a/README.md
+++ b/README.md
@@ -111,6 +111,13 @@ See [docs/google-chat.md](docs/google-chat.md) for the full setup guide. Require
+
+WeCom (企业微信) (via Custom Gateway)
+
+See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service.
+
+
+
### 2. Install with Helm (Kiro CLI — default)
```bash
diff --git a/charts/openab/templates/gateway-secret.yaml b/charts/openab/templates/gateway-secret.yaml
index 7d4869bd..3c2c05d0 100644
--- a/charts/openab/templates/gateway-secret.yaml
+++ b/charts/openab/templates/gateway-secret.yaml
@@ -8,7 +8,8 @@
{{- $hasTelegram := (($cfg.gateway).telegram).botToken }}
{{- $hasLine := (($cfg.gateway).line).channelSecret }}
{{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken }}
-{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat }}
+{{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }}
+{{- if or $hasTeams $hasFeishu $hasTelegram $hasLine $hasGoogleChat $hasWecom }}
---
apiVersion: v1
kind: Secret
@@ -52,6 +53,11 @@ data:
google-chat-access-token: {{ ($cfg.gateway).googleChat.accessToken | b64enc | quote }}
{{- end }}
{{- end }}
+ {{- if $hasWecom }}
+ wecom-secret: {{ ($cfg.gateway).wecom.secret | b64enc | quote }}
+ wecom-token: {{ ($cfg.gateway).wecom.token | b64enc | quote }}
+ wecom-encoding-aes-key: {{ ($cfg.gateway).wecom.encodingAesKey | b64enc | quote }}
+ {{- end }}
{{- end }}
{{- end }}
{{- end }}
diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml
index 057937dc..2a89dc79 100644
--- a/charts/openab/templates/gateway.yaml
+++ b/charts/openab/templates/gateway.yaml
@@ -184,6 +184,40 @@ spec:
value: {{ ($cfg.gateway).googleChat.webhookPath | quote }}
{{- end }}
{{- end }}
+ {{- $hasWecom := and (($cfg.gateway).wecom).corpId (($cfg.gateway).wecom).agentId (($cfg.gateway).wecom).secret (($cfg.gateway).wecom).token (($cfg.gateway).wecom).encodingAesKey }}
+ {{- if $hasWecom }}
+ - name: WECOM_CORP_ID
+ value: {{ ($cfg.gateway).wecom.corpId | quote }}
+ - name: WECOM_AGENT_ID
+ value: {{ ($cfg.gateway).wecom.agentId | quote }}
+ - name: WECOM_SECRET
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-secret
+ - name: WECOM_TOKEN
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-token
+ - name: WECOM_ENCODING_AES_KEY
+ valueFrom:
+ secretKeyRef:
+ name: {{ include "openab.agentFullname" $d }}
+ key: wecom-encoding-aes-key
+ {{- if (($cfg.gateway).wecom).webhookPath }}
+ - name: WECOM_WEBHOOK_PATH
+ value: {{ ($cfg.gateway).wecom.webhookPath | quote }}
+ {{- end }}
+ {{- if (($cfg.gateway).wecom).streamingEnabled }}
+ - name: WECOM_STREAMING_ENABLED
+ value: {{ ($cfg.gateway).wecom.streamingEnabled | quote }}
+ {{- end }}
+ {{- if (($cfg.gateway).wecom).debounceSecs }}
+ - name: WECOM_DEBOUNCE_SECS
+ value: {{ ($cfg.gateway).wecom.debounceSecs | quote }}
+ {{- end }}
+ {{- end }}
- name: RUST_LOG
value: {{ ($cfg.gateway).rustLog | default "info" | quote }}
livenessProbe:
diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml
index 8a83e963..50b65915 100644
--- a/charts/openab/values.yaml
+++ b/charts/openab/values.yaml
@@ -309,6 +309,17 @@ agents:
saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh)
accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL)
webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH
+ # WeCom (企业微信) adapter config (gateway-side env vars)
+ # See docs/wecom.md for full setup guide
+ wecom:
+ corpId: "" # Enterprise Corp ID → WECOM_CORP_ID
+ secret: "" # App Secret → WECOM_SECRET (use --set-literal or external secret mgmt)
+ token: "" # Callback verification token → WECOM_TOKEN
+ encodingAesKey: "" # 43-char AES key → WECOM_ENCODING_AES_KEY
+ agentId: "" # Agent ID → WECOM_AGENT_ID (required)
+ webhookPath: "" # Gateway default: /webhook/wecom → WECOM_WEBHOOK_PATH
+ streamingEnabled: "" # Enable thinking-placeholder + recall streaming (causes brief client flicker) → WECOM_STREAMING_ENABLED. Default off.
+ debounceSecs: "" # Debounce quiet-period seconds before flushing streamed text → WECOM_DEBOUNCE_SECS. Default 3, minimum 1 (0 is treated as unset by Helm).
# Scheduled messages — config-driven cron (ADR: basic-cronjob)
# Each entry sends a message to the agent at the specified schedule.
# Example:
diff --git a/docs/wecom.md b/docs/wecom.md
new file mode 100644
index 00000000..ad8efec8
--- /dev/null
+++ b/docs/wecom.md
@@ -0,0 +1,187 @@
+# WeCom (企业微信) Setup
+
+Connect a WeCom (Enterprise WeChat) bot to OpenAB via the Custom Gateway.
+
+```
+WeCom ──POST──▶ Gateway (:8080) ◀──WebSocket── OAB Pod
+ (OAB connects out)
+```
+
+## Prerequisites
+
+- A running OAB instance (with any ACP agent authenticated)
+- The Custom Gateway deployed ([gateway/README.md](../gateway/README.md))
+- A WeCom enterprise account with admin access
+
+## 1. Create a WeCom App
+
+1. Log in to [WeCom Admin Console](https://work.weixin.qq.com/wework_admin/frame)
+2. Go to **应用管理** (App Management) → **自建** (Self-built) → **创建应用** (Create App)
+3. Fill in the app name and description, select visible scope
+4. After creation, note down:
+ - **AgentId** — on the app detail page
+ - **Secret** — click to view/copy on the app detail page
+5. Go to **我的企业** (My Enterprise) → copy the **企业ID** (Corp ID)
+
+## 2. Configure the Callback URL
+
+1. In the app detail page, scroll to **接收消息** (Receive Messages)
+2. Click **设置API接收** (Set API Receive)
+3. Fill in:
+ - **URL**: `https://your-gateway-host/webhook/wecom` (must be HTTPS)
+ - **Token**: click "随机获取" (Random Generate) or set your own
+ - **EncodingAESKey**: click "随机获取" (Random Generate) or set your own
+4. **Do NOT click Save yet** — you need the gateway running first to verify the URL
+
+## 3. Configure the Gateway
+
+Set the following environment variables:
+
+| Variable | Required | Description |
+|---|---|---|
+| `WECOM_CORP_ID` | Yes | Enterprise Corp ID (from My Enterprise page) |
+| `WECOM_AGENT_ID` | Yes | App Agent ID |
+| `WECOM_SECRET` | Yes | App Secret |
+| `WECOM_TOKEN` | Yes | Callback Token (from step 2) |
+| `WECOM_ENCODING_AES_KEY` | Yes | Callback EncodingAESKey (43 characters) |
+| `WECOM_WEBHOOK_PATH` | No | Webhook path (default: `/webhook/wecom`) |
+| `WECOM_STREAMING_ENABLED` | No | Stream replies via "thinking" placeholder + recall + resend (default: `false`). WeCom has no edit-message API; enabling this causes a brief client flicker during streaming. |
+| `WECOM_DEBOUNCE_SECS` | No | Quiet-period seconds before flushing buffered streamed text (default: `3`, minimum: `1` — `0` is silently ignored by Helm's truthy check and disables the buffer purpose) |
+
+```bash
+docker run -d --name openab-gateway \
+ -e WECOM_CORP_ID="ww1234567890abcdef" \
+ -e WECOM_AGENT_ID="1000002" \
+ -e WECOM_SECRET="your-app-secret" \
+ -e WECOM_TOKEN="your-callback-token" \
+ -e WECOM_ENCODING_AES_KEY="your-43-char-encoding-aes-key" \
+ -p 8080:8080 \
+ ghcr.io/openabdev/openab-gateway:latest
+```
+
+For Kubernetes with Helm, see [`charts/openab/values.yaml`](../charts/openab/values.yaml) — set values under `agents..gateway.wecom`.
+
+## 4. Verify the Callback URL
+
+Once the gateway is running with the correct env vars:
+
+1. Go back to the WeCom Admin Console → App → 接收消息 → 设置API接收
+2. Click **保存** (Save)
+3. WeCom will send a verification request to your URL — if the gateway decrypts and responds correctly, you'll see "保存成功" (Save Successful)
+
+If verification fails:
+- Check that the gateway is reachable over HTTPS
+- Verify `WECOM_TOKEN` and `WECOM_ENCODING_AES_KEY` match exactly what's shown in the WeCom console
+- Check gateway logs for errors
+
+## 5. Configure OAB
+
+```toml
+[gateway]
+url = "ws://openab-gateway:8080/ws"
+platform = "wecom"
+allow_all_channels = true
+allow_all_users = true
+
+[agent]
+command = "claude-agent-acp"
+args = []
+working_dir = "/home/node"
+env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }
+
+[pool]
+max_sessions = 10
+```
+
+| Key | Required | Description |
+|---|---|---|
+| `url` | Yes | WebSocket URL of the gateway |
+| `platform` | No | Session key namespace (default: `wecom`) |
+| `allow_all_channels` | No | Allow messages from all channels (default: `false`) |
+| `allow_all_users` | No | Allow messages from all users (default: `false`) |
+
+## 6. Expose the Gateway (HTTPS)
+
+WeCom requires a publicly accessible HTTPS URL for callbacks.
+
+### Option A: Zeabur (one-click HTTPS for quick testing)
+
+Deploy the gateway to [Zeabur](https://zeabur.com) — HTTPS is automatically provisioned.
+
+### Option B: Cloudflare Tunnel
+
+```bash
+cloudflared tunnel --url http://localhost:8080
+```
+
+### Option C: Reverse proxy (production)
+
+Use nginx, Caddy, or a cloud load balancer with TLS termination pointing to the gateway's `:8080`.
+
+## 7. Set Trusted IP (Optional)
+
+For production, restrict the callback to WeCom's IP ranges:
+
+1. In the WeCom Admin Console → App → **企业可信IP** (Trusted IP)
+2. Add your gateway's public IP
+
+## Usage
+
+Send a direct message to the bot in the WeCom mobile or desktop app:
+
+```
+你好,帮我解释一下这段代码
+```
+
+The bot will reply directly in the same conversation.
+
+> **Note on group chats:** WeCom self-built enterprise apps only deliver **1:1 direct messages** to the callback URL. Group chat messages are not forwarded by this API path; group chat support would require the `appchat` API (not yet implemented). For group chat use cases, see the WeCom AI Bot WebSocket API as a future adapter.
+
+## Features
+
+| Feature | Status |
+|---|---|
+| Direct message (1:1) | ✅ |
+| Text message receive/reply | ✅ |
+| AES-256-CBC message decryption | ✅ |
+| Message deduplication | ✅ |
+| Auto-split long replies (2048 bytes) | ✅ |
+| Access token auto-refresh | ✅ |
+| Image receive | ✅ |
+| Text file receive | ✅ |
+| Streaming replies (thinking placeholder + debounce flush) | ✅ |
+| Group chat | ❌ Not supported (callback API limitation) |
+| Voice/video messages | Planned |
+| Markdown card replies | Planned |
+
+## Production Hardening
+
+The gateway does no application-level rate limiting on `/webhook/wecom`. Each request triggers an XML envelope parse, a SHA1 signature computation, and (if signature passes) AES-256-CBC decryption. A 5-minute timestamp freshness check rejects stale callbacks before any crypto runs, so old replays are cheap to drop, but fresh-but-invalid requests still consume CPU.
+
+Run the gateway behind a reverse proxy or load balancer that enforces rate limits at the IP / connection level:
+
+| Layer | Example |
+|---|---|
+| Edge / CDN | Cloudflare WAF rate limiting rules on `/webhook/wecom` |
+| Cloud LB | AWS ALB rate-based rules, GCP Cloud Armor |
+| Reverse proxy | nginx `limit_req_zone`, Caddy `rate_limit` directive |
+
+In addition, restrict the callback URL to WeCom's published IP ranges via the **企业可信IP** (Trusted IP) list in the WeCom Admin Console. This is the most effective control because all legitimate callbacks originate from those ranges.
+
+### Redact `corpsecret` from access logs
+
+WeCom's `gettoken` API mandates `corpsecret` as a query parameter (the protocol does not support a header alternative). The gateway itself does not log this URL, but if the gateway sits behind a reverse proxy with default access logging enabled, the secret will appear in access logs. Configure the proxy to redact query strings on `/cgi-bin/gettoken` outbound calls (or sanitize at log-shipping time).
+
+### Known limitations
+
+- **Streaming task lifetime on shutdown** — the optional streaming mode (`WECOM_STREAMING_ENABLED=true`) spawns one debounce task per in-flight reply. On SIGTERM these tasks are dropped by the tokio runtime; any text buffered but not yet flushed is lost. The agent will typically re-emit on the next interaction. If you need flush-on-shutdown semantics, keep streaming off (default) so each reply is sent synchronously.
+- **DedupeCache eviction is lazy** — entries are TTL-checked on lookup and bulk-evicted only when the cache reaches `DEDUPE_MAX_SIZE` (10K). For low-traffic deployments the HashMap can sit just below the cap with stale entries; max memory is bounded (~500 KB) and the dedup window itself is honored, so this does not affect correctness.
+
+## Troubleshooting
+
+| Symptom | Cause | Fix |
+|---|---|---|
+| Callback verification fails | Token/EncodingAESKey mismatch | Double-check values match WeCom console exactly |
+| Bot receives but doesn't reply | Agent auth token not configured | Set `env = { CLAUDE_CODE_OAUTH_TOKEN = "${OPENAB_AUTH_TOKEN}" }` in OAB config |
+| Intermittent "no response" | WeCom disabled callback after errors | Re-save callback config in WeCom console to re-verify |
+| "IP not in whitelist" on reply | Trusted IP not set | Add gateway IP to app's trusted IP list, or leave it empty for dev |
diff --git a/gateway/Cargo.lock b/gateway/Cargo.lock
index b0fa728b..b0e24b92 100644
--- a/gateway/Cargo.lock
+++ b/gateway/Cargo.lock
@@ -1112,7 +1112,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openab-gateway"
-version = "0.1.0"
+version = "0.4.0"
dependencies = [
"aes",
"anyhow",
@@ -1125,9 +1125,11 @@ dependencies = [
"image",
"jsonwebtoken",
"prost",
+ "quick-xml",
"reqwest",
"serde",
"serde_json",
+ "sha1",
"sha2",
"subtle",
"tokio",
@@ -1274,6 +1276,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+[[package]]
+name = "quick-xml"
+version = "0.37.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
+dependencies = [
+ "memchr",
+]
+
[[package]]
name = "quinn"
version = "0.11.9"
diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml
index 76746e0b..eed46efb 100644
--- a/gateway/Cargo.toml
+++ b/gateway/Cargo.toml
@@ -24,6 +24,8 @@ aes = "0.8"
cbc = "0.1"
prost = "0.13"
subtle = "2"
+sha1 = "0.10"
+quick-xml = "0.37"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
[dev-dependencies]
diff --git a/gateway/README.md b/gateway/README.md
index aa36cbf6..79c492a2 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -67,6 +67,14 @@ url = "ws://gateway:8080/ws"
| `GOOGLE_CHAT_SA_KEY_FILE` | (optional) | Path to service account key JSON file (alternative to `SA_KEY_JSON`) |
| `GOOGLE_CHAT_ACCESS_TOKEN` | (optional) | Static OAuth2 access token (fallback, expires in 1 hour) |
| `GOOGLE_CHAT_WEBHOOK_PATH` | `/webhook/googlechat` | Webhook endpoint path |
+| `WECOM_CORP_ID` | (required*) | WeCom Corp ID — enables wecom adapter |
+| `WECOM_AGENT_ID` | (required*) | WeCom App Agent ID |
+| `WECOM_SECRET` | (required*) | WeCom App Secret |
+| `WECOM_TOKEN` | (required*) | Callback verification Token |
+| `WECOM_ENCODING_AES_KEY` | (required*) | Callback EncodingAESKey (43 chars) |
+| `WECOM_WEBHOOK_PATH` | `/webhook/wecom` | Webhook endpoint path |
+| `WECOM_STREAMING_ENABLED` | `false` | Enable thinking-placeholder + recall streaming (causes brief client flicker) |
+| `WECOM_DEBOUNCE_SECS` | `3` | Debounce quiet-period seconds before flushing buffered streamed text |
### Endpoints
@@ -76,6 +84,8 @@ url = "ws://gateway:8080/ws"
| `POST /webhook/line` | LINE webhook receiver |
| `POST /webhook/feishu` | Feishu webhook receiver (when `FEISHU_CONNECTION_MODE=webhook`) |
| `POST /webhook/googlechat` | Google Chat webhook receiver |
+| `GET /webhook/wecom` | WeCom callback URL verification |
+| `POST /webhook/wecom` | WeCom message callback receiver |
| `GET /ws` | WebSocket server (OAB connects here) |
| `GET /health` | Health check |
@@ -117,6 +127,10 @@ See [docs/feishu.md](../docs/feishu.md) for the full setup guide.
See [docs/google-chat.md](../docs/google-chat.md) for the full setup guide.
+### WeCom (企业微信)
+
+See [docs/wecom.md](../docs/wecom.md) for the full setup guide.
+
### Other Platforms
GitHub webhooks, CI/CD events, monitoring alerts — any HTTP event source can be added as a gateway adapter. See the ADR for the adapter interface.
diff --git a/gateway/src/adapters/feishu.rs b/gateway/src/adapters/feishu.rs
index 09e97fe0..98e9608f 100644
--- a/gateway/src/adapters/feishu.rs
+++ b/gateway/src/adapters/feishu.rs
@@ -226,6 +226,8 @@ mod event_types {
pub header: Option,
pub event: Option,
pub challenge: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
#[serde(rename = "type")]
pub event_type_field: Option,
}
@@ -233,6 +235,8 @@ mod event_types {
#[derive(Debug, Deserialize)]
pub struct FeishuEventHeader {
pub event_id: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub event_type: Option,
}
@@ -269,6 +273,8 @@ mod event_types {
pub struct FeishuMention {
pub key: Option,
pub id: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub name: Option,
}
@@ -459,13 +465,15 @@ mod event_types {
// Bypass: if bot has previously replied in this thread (participated),
// no @mention needed (like Discord's "involved" mode).
let in_thread = thread_id.is_some();
- if channel_type == "group" && !is_bot_sender && config.require_mention {
- if !(in_thread && bypass_mention_gating) {
- if let Some(bot_id) = bot_open_id {
- let bot_mentioned = mention_ids.iter().any(|id| id == bot_id);
- if !bot_mentioned {
- return None;
- }
+ if channel_type == "group"
+ && !is_bot_sender
+ && config.require_mention
+ && !(in_thread && bypass_mention_gating)
+ {
+ if let Some(bot_id) = bot_open_id {
+ let bot_mentioned = mention_ids.iter().any(|id| id == bot_id);
+ if !bot_mentioned {
+ return None;
}
}
}
@@ -843,6 +851,7 @@ pub async fn start_websocket(
}
/// Single WebSocket connection lifecycle.
+#[allow(clippy::too_many_arguments)]
async fn ws_connect_loop(
token_cache: &Arc,
bot_open_id_store: &Arc>>,
@@ -912,7 +921,7 @@ async fn ws_connect_loop(
ack.payload = Some(b"{\"code\":200}".to_vec());
let ack_bytes = ack.encode_to_vec();
let _ = ws_tx.send(
- tokio_tungstenite::tungstenite::Message::Binary(ack_bytes.into())
+ tokio_tungstenite::tungstenite::Message::Binary(ack_bytes)
).await;
}
}
@@ -933,6 +942,7 @@ async fn ws_connect_loop(
}
/// Process a single WebSocket text message.
+#[allow(clippy::too_many_arguments)]
async fn handle_ws_message(
text: &str,
bot_open_id_store: &Arc>>,
@@ -1157,8 +1167,8 @@ fn markdown_to_post(md: &str) -> serde_json::Value {
let line = raw_lines[li];
// Detect fenced code block
let trimmed = line.trim_start();
- if trimmed.starts_with("```") {
- let lang = trimmed[3..].trim().to_string();
+ if let Some(after_fence) = trimmed.strip_prefix("```") {
+ let lang = after_fence.trim().to_string();
let mut code = String::new();
li += 1;
while li < raw_lines.len() {
@@ -1231,9 +1241,7 @@ fn parse_inline(line: &str) -> Vec {
}
if close_ticks == ticks {
// Found matching close — content between is literal
- for j in i..end {
- buf.push(chars[j]);
- }
+ buf.extend(chars[i..end].iter().copied());
i = end + close_ticks;
break 'outer;
}
@@ -1244,9 +1252,7 @@ fn parse_inline(line: &str) -> Vec {
}
if end >= len {
// No matching close — treat backticks as literal
- for j in i..len {
- buf.push(chars[j]);
- }
+ buf.extend(chars[i..len].iter().copied());
i = len;
}
continue;
@@ -1271,9 +1277,7 @@ fn parse_inline(line: &str) -> Vec {
}
if close_run == run {
// Found matching close — strip both, keep inner text
- for j in after..scan {
- buf.push(chars[j]);
- }
+ buf.extend(chars[after..scan].iter().copied());
i = scan + close_run;
found_close = true;
break;
@@ -1816,7 +1820,9 @@ fn detect_and_mark_multibot(
thread_id_for_check
.map(|tid| {
let cache = multibot_threads.lock().unwrap_or_else(|e| e.into_inner());
- !cache.get(tid).is_some_and(|ts| ts.elapsed().as_secs() < config.session_ttl_secs)
+ cache
+ .get(tid)
+ .is_none_or(|ts| ts.elapsed().as_secs() >= config.session_ttl_secs)
})
.unwrap_or(true)
}
diff --git a/gateway/src/adapters/googlechat.rs b/gateway/src/adapters/googlechat.rs
index 73787089..c4c5ebc3 100644
--- a/gateway/src/adapters/googlechat.rs
+++ b/gateway/src/adapters/googlechat.rs
@@ -64,6 +64,8 @@ pub struct GoogleChatSpace {
pub name: String,
#[serde(rename = "type")]
pub space_type: Option,
+ // Parsed by serde, not consumed in current code paths.
+ #[allow(dead_code)]
pub space_type_renamed: Option,
}
diff --git a/gateway/src/adapters/mod.rs b/gateway/src/adapters/mod.rs
index f261efe6..94a2a8a7 100644
--- a/gateway/src/adapters/mod.rs
+++ b/gateway/src/adapters/mod.rs
@@ -3,3 +3,4 @@ pub mod googlechat;
pub mod line;
pub mod teams;
pub mod telegram;
+pub mod wecom;
diff --git a/gateway/src/adapters/wecom.rs b/gateway/src/adapters/wecom.rs
new file mode 100644
index 00000000..a33a71e5
--- /dev/null
+++ b/gateway/src/adapters/wecom.rs
@@ -0,0 +1,1654 @@
+use anyhow::Result;
+use axum::extract::State;
+use std::sync::Arc;
+use tokio::sync::RwLock;
+use tracing::{info, warn};
+
+pub struct WecomConfig {
+ pub corp_id: String,
+ pub agent_id: String,
+ pub secret: String,
+ pub token: String,
+ pub encoding_aes_key: String,
+ pub webhook_path: String,
+ pub streaming_enabled: bool,
+ pub debounce_secs: u64,
+}
+
+impl WecomConfig {
+ pub fn from_env() -> Option {
+ Self::from_reader(|k| std::env::var(k).ok())
+ }
+
+ /// 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 {
+ let corp_id = read("WECOM_CORP_ID")?;
+ let secret = read("WECOM_SECRET")?;
+ let token = read("WECOM_TOKEN")?;
+ let encoding_aes_key = read("WECOM_ENCODING_AES_KEY")?;
+ let agent_id = read("WECOM_AGENT_ID")?;
+ if agent_id.parse::().is_err() {
+ warn!("WECOM_AGENT_ID must be a numeric value, got '{}'", agent_id);
+ return None;
+ }
+ let webhook_path = read("WECOM_WEBHOOK_PATH").unwrap_or_else(|| "/webhook/wecom".into());
+ // Streaming opts-in: WeCom callback mode has no edit-message API, so
+ // streaming is implemented via thinking-placeholder + recall + resend,
+ // which causes a brief client flicker. Default off; set to true only if
+ // the UX tradeoff is acceptable.
+ let streaming_enabled = read("WECOM_STREAMING_ENABLED")
+ .map(|v| v == "true" || v == "1")
+ .unwrap_or(false);
+ let debounce_secs = read("WECOM_DEBOUNCE_SECS")
+ .and_then(|v| v.parse::().ok())
+ .unwrap_or(3);
+
+ if encoding_aes_key.len() != 43 {
+ warn!("WECOM_ENCODING_AES_KEY must be 43 characters, got {}", encoding_aes_key.len());
+ return None;
+ }
+
+ info!(
+ corp_id = %corp_id,
+ agent_id = %agent_id,
+ streaming_enabled,
+ debounce_secs,
+ "wecom adapter configured"
+ );
+ Some(Self {
+ corp_id,
+ agent_id,
+ secret,
+ token,
+ encoding_aes_key,
+ webhook_path,
+ streaming_enabled,
+ debounce_secs,
+ })
+ }
+}
+
+fn decode_aes_key(encoding_aes_key: &str) -> anyhow::Result> {
+ use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
+ use base64::Engine;
+ // WeCom's EncodingAESKey is 43 base64 chars without trailing padding.
+ // Append "=" to make it a 44-char standard base64 string before decoding.
+ // Indifferent + allow_trailing_bits accommodate WeCom's non-standard
+ // encoding: the 43rd char's last 2 bits are not part of the output and
+ // must be ignored rather than rejected.
+ let padded = format!("{}=", encoding_aes_key);
+ let config = GeneralPurposeConfig::new()
+ .with_decode_padding_mode(DecodePaddingMode::Indifferent)
+ .with_decode_allow_trailing_bits(true);
+ let engine = GeneralPurpose::new(&base64::alphabet::STANDARD, config);
+ let key = engine
+ .decode(&padded)
+ .map_err(|e| anyhow::anyhow!("encoding_aes_key base64 decode failed: {e}"))?;
+ anyhow::ensure!(
+ key.len() == 32,
+ "encoding_aes_key must decode to 32 bytes, got {}",
+ key.len()
+ );
+ Ok(key)
+}
+
+fn compute_signature(token: &str, timestamp: &str, nonce: &str, encrypt: &str) -> String {
+ use sha1::Digest;
+ let mut parts = [token, timestamp, nonce, encrypt];
+ parts.sort_unstable();
+ let joined: String = parts.concat();
+ let hash = sha1::Sha1::digest(joined.as_bytes());
+ format!("{:x}", hash)
+}
+
+fn verify_signature(
+ token: &str,
+ timestamp: &str,
+ nonce: &str,
+ encrypt: &str,
+ expected: &str,
+) -> bool {
+ let computed = compute_signature(token, timestamp, nonce, encrypt);
+ tracing::debug!(
+ computed = %computed,
+ expected = %expected,
+ token_len = token.len(),
+ encrypt_len = encrypt.len(),
+ "signature comparison"
+ );
+ subtle::ConstantTimeEq::ct_eq(computed.as_bytes(), expected.as_bytes()).into()
+}
+
+fn decrypt_message(
+ encoding_aes_key: &str,
+ encrypted: &str,
+ expected_corp_id: &str,
+) -> anyhow::Result {
+ use aes::cipher::{BlockDecryptMut, KeyIvInit};
+ use base64::Engine;
+
+ let key = decode_aes_key(encoding_aes_key)?;
+ let iv = &key[..16];
+
+ let cipher_bytes = base64::engine::general_purpose::STANDARD
+ .decode(encrypted)
+ .map_err(|e| anyhow::anyhow!("base64 decode failed: {e}"))?;
+
+ if cipher_bytes.is_empty() || cipher_bytes.len() % 16 != 0 {
+ anyhow::bail!("ciphertext length {} not a multiple of 16", cipher_bytes.len());
+ }
+
+ type Aes256CbcDec = cbc::Decryptor;
+ let decryptor = Aes256CbcDec::new_from_slices(&key, iv)
+ .map_err(|e| anyhow::anyhow!("aes init failed: {e}"))?;
+
+ let mut buf = cipher_bytes.to_vec();
+ // WeCom uses PKCS7 with block_size=32, not 16. Decrypt without padding validation
+ // and strip padding manually.
+ let plaintext = decryptor
+ .decrypt_padded_mut::(&mut buf)
+ .map_err(|e| anyhow::anyhow!("aes decrypt failed: {e}"))?;
+
+ // Strip WeCom PKCS7 padding (block_size=32): last byte indicates pad length (1-32)
+ let pad_byte = *plaintext.last().ok_or_else(|| anyhow::anyhow!("empty plaintext"))? as usize;
+ if pad_byte == 0 || pad_byte > 32 || pad_byte > plaintext.len() {
+ anyhow::bail!("invalid wecom padding value: {pad_byte}");
+ }
+ let pad_start = plaintext.len() - pad_byte;
+ if !plaintext[pad_start..].iter().all(|&b| b as usize == pad_byte) {
+ anyhow::bail!("invalid PKCS#7 padding: not all padding bytes match");
+ }
+ let plaintext = &plaintext[..pad_start];
+
+ // Plaintext structure: random(16) + msg_len(4, big-endian) + msg + corp_id
+ if plaintext.len() < 20 {
+ anyhow::bail!("decrypted payload too short");
+ }
+ let msg_len =
+ u32::from_be_bytes([plaintext[16], plaintext[17], plaintext[18], plaintext[19]]) as usize;
+ if plaintext.len() < 20 + msg_len {
+ anyhow::bail!("msg_len exceeds payload size");
+ }
+ let msg = &plaintext[20..20 + msg_len];
+ let corp_id = &plaintext[20 + msg_len..];
+
+ let corp_id_str =
+ std::str::from_utf8(corp_id).map_err(|e| anyhow::anyhow!("corp_id not utf8: {e}"))?;
+ if corp_id_str != expected_corp_id {
+ anyhow::bail!("corp_id mismatch: expected {expected_corp_id}, got {corp_id_str}");
+ }
+
+ String::from_utf8(msg.to_vec()).map_err(|e| anyhow::anyhow!("message not utf8: {e}"))
+}
+
+// --- Deduplication ---
+
+const DEDUPE_TTL_SECS: u64 = 30;
+const DEDUPE_MAX_SIZE: usize = 10_000;
+
+struct DedupeCache {
+ entries: std::sync::Mutex>,
+}
+
+impl DedupeCache {
+ fn new() -> Self {
+ Self {
+ entries: std::sync::Mutex::new(std::collections::HashMap::new()),
+ }
+ }
+
+ fn check_and_insert(&self, msg_id: &str) -> bool {
+ let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
+ let now = std::time::Instant::now();
+
+ if entries.len() >= DEDUPE_MAX_SIZE {
+ entries.retain(|_, t| now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS);
+ }
+
+ if let Some(t) = entries.get(msg_id) {
+ if now.duration_since(*t).as_secs() < DEDUPE_TTL_SECS {
+ return false;
+ }
+ }
+
+ entries.insert(msg_id.to_string(), now);
+ true
+ }
+}
+
+// --- Token cache ---
+
+pub const WECOM_API_BASE: &str = "https://qyapi.weixin.qq.com";
+const TOKEN_REFRESH_MARGIN_SECS: u64 = 300;
+
+pub struct WecomTokenCache {
+ inner: RwLock