Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
29f232b
feat(gateway): add WeCom (企业微信) channel adapter
canyugs May 5, 2026
9adbbf4
docs(wecom): add setup guide and update READMEs
canyugs May 5, 2026
7770d7f
feat(wecom): re-enable streaming with recall+resend and msg_id tracking
canyugs May 6, 2026
8e28683
feat(wecom): add image receiving support
canyugs May 6, 2026
7a1ad67
feat(wecom): replace recall+resend streaming with thinking placeholde…
canyugs May 7, 2026
81433b0
feat(wecom): add text file receiving support
canyugs May 7, 2026
fb69583
fix(wecom): address PR review feedback
canyugs May 7, 2026
a367fa4
fix(wecom): split long lines at char boundaries and add flush debug l…
canyugs May 7, 2026
22b44a5
fix(wecom): address Copilot review feedback (round 2)
canyugs May 7, 2026
6a4a7a3
docs(wecom): simplify deployment section and add group verification note
canyugs May 7, 2026
4f877be
fix(wecom): address reviewer feedback on group support and config val…
canyugs May 9, 2026
f34a442
ci(gateway): drop clippy from gateway CI job
canyugs May 9, 2026
cf980a0
feat(wecom): make streaming opt-in and debounce configurable
canyugs May 9, 2026
7ed6c92
docs(wecom): explain decode_aes_key base64 config
canyugs May 9, 2026
ddfe6f4
fix(wecom): unify token-expiry retry across send paths
canyugs May 9, 2026
26a7b3f
fix(wecom): close chaodu's defense-in-depth gaps (F1-F4)
canyugs May 9, 2026
e836a6c
fix(wecom): close TOCTOU and add token retry to file download
canyugs May 9, 2026
355eca9
fix(wecom): address chaodu round-5 NITs (F2, F3, F4)
canyugs May 9, 2026
a58e1ba
ci(gateway): enable strict clippy and clear pre-existing warnings
canyugs May 9, 2026
e6b24c4
Merge remote-tracking branch 'upstream/main' into feat/wecom-adapter
canyugs May 9, 2026
8b55672
fix(gateway): clear remaining clippy warnings exposed by strict CI
canyugs May 9, 2026
7d83332
docs(wecom): address chaodu round-6 NITs (F1, F2 comments + F3/F4 kno…
canyugs May 9, 2026
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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
pull_request:
paths:
- "src/**"
- "gateway/**"
- "Cargo.toml"
- "Cargo.lock"
- "Dockerfile*"
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ See [docs/google-chat.md](docs/google-chat.md) for the full setup guide. Require

</details>

<details>
<summary><strong>WeCom (企业微信)</strong> (via Custom Gateway)</summary>

See [docs/wecom.md](docs/wecom.md) for the full setup guide. Requires the standalone [Custom Gateway](gateway/) service.

</details>

### 2. Install with Helm (Kiro CLI — default)

```bash
Expand Down
8 changes: 7 additions & 1 deletion charts/openab/templates/gateway-secret.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand Down
34 changes: 34 additions & 0 deletions charts/openab/templates/gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
11 changes: 11 additions & 0 deletions charts/openab/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
187 changes: 187 additions & 0 deletions docs/wecom.md
Original file line number Diff line number Diff line change
@@ -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.<name>.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 |
13 changes: 12 additions & 1 deletion gateway/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading