From a3799c7748ae99e4634ceff5035afe1ba5128e5f Mon Sep 17 00:00:00 2001 From: YanHaidao Date: Tue, 27 Jan 2026 11:45:52 +0800 Subject: [PATCH] =?UTF-8?q?-=20Add=20WeCom=20channel=20plugin=20for=20Claw?= =?UTF-8?q?dbot=20(popular=20in=20China)=20-=20Add=20channel=20documentati?= =?UTF-8?q?on=20(EN=20+=20=E4=B8=AD=E6=96=87)=20and=20plugin=20README=20-?= =?UTF-8?q?=20Link=20WeCom=20from=20channels=20index=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20WeCom=20=E6=B8=A0=E9=81=93=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=EF=BC=88=E5=9B=BD=E5=86=85=E5=B8=B8=E7=94=A8=EF=BC=89=20-=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=A2=91=E9=81=93=E6=96=87=E6=A1=A3=EF=BC=88?= =?UTF-8?q?=E8=8B=B1=E6=96=87=20+=20=E4=B8=AD=E6=96=87=EF=BC=89=E4=B8=8E?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20README=20-=20=E5=9C=A8=20Channels=20?= =?UTF-8?q?=E7=B4=A2=E5=BC=95=E4=B8=AD=E5=8A=A0=E5=85=A5=20WeCom=20?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/channels/index.md | 1 + docs/channels/wecom.md | 337 ++++++++++ extensions/wecom/README.md | 91 +++ extensions/wecom/clawdbot.plugin.json | 9 + extensions/wecom/index.ts | 23 + extensions/wecom/package.json | 33 + extensions/wecom/src/accounts.ts | 73 +++ extensions/wecom/src/channel.ts | 212 ++++++ extensions/wecom/src/config-schema.ts | 36 ++ extensions/wecom/src/crypto.test.ts | 32 + extensions/wecom/src/crypto.ts | 133 ++++ extensions/wecom/src/monitor.ts | 646 +++++++++++++++++++ extensions/wecom/src/monitor.webhook.test.ts | 161 +++++ extensions/wecom/src/runtime.ts | 15 + extensions/wecom/src/types.ts | 77 +++ 15 files changed, 1879 insertions(+) create mode 100644 docs/channels/wecom.md create mode 100644 extensions/wecom/README.md create mode 100644 extensions/wecom/clawdbot.plugin.json create mode 100644 extensions/wecom/index.ts create mode 100644 extensions/wecom/package.json create mode 100644 extensions/wecom/src/accounts.ts create mode 100644 extensions/wecom/src/channel.ts create mode 100644 extensions/wecom/src/config-schema.ts create mode 100644 extensions/wecom/src/crypto.test.ts create mode 100644 extensions/wecom/src/crypto.ts create mode 100644 extensions/wecom/src/monitor.ts create mode 100644 extensions/wecom/src/monitor.webhook.test.ts create mode 100644 extensions/wecom/src/runtime.ts create mode 100644 extensions/wecom/src/types.ts diff --git a/docs/channels/index.md b/docs/channels/index.md index 4c2f77581..327b936bc 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -30,6 +30,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). - [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket. +- [WeCom](/channels/wecom) — WeCom intelligent bot (API mode) via encrypted webhooks + passive replies (plugin, installed separately). ## Notes diff --git a/docs/channels/wecom.md b/docs/channels/wecom.md new file mode 100644 index 000000000..44893e1c8 --- /dev/null +++ b/docs/channels/wecom.md @@ -0,0 +1,337 @@ +--- +summary: "WeCom (WeChat Work) intelligent bot support status, capabilities, and configuration" +read_when: + - You want to connect Clawdbot to WeCom + - You are troubleshooting WeCom webhooks and replies + - 你想把 Clawdbot 接入企业微信 WeCom + - 你在排查 WeCom webhook 回调和回复问题 +--- +# WeCom(企业微信) + +Status: WeCom intelligent bot (API mode) via encrypted webhooks + passive replies (stream). + +## Quick setup (beginner) + +1) Install + enable the plugin: + ```bash + clawdbot plugins install @clawdbot/wecom + clawdbot plugins enable wecom + ``` +2) Create a WeCom “intelligent bot (API mode)” and collect: + - `Token` + - `EncodingAESKey` + - `ReceiveId` (often empty for intelligent bots) +3) Configure Clawdbot (example): + ```json5 + { + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + dm: { policy: "pairing" } + } + } + } + ``` +4) Restart the gateway: + ```bash + clawdbot gateway restart + ``` +5) Verify: + ```bash + clawdbot plugins list | rg -n "wecom|WeCom" + clawdbot channels status --probe + ``` + +## Public URL (Webhook-only) + +WeCom webhooks require a public HTTPS endpoint. For security, **only expose `/wecom`** to the internet. Keep the dashboard and other sensitive endpoints private. + +### Option A: Tailscale Funnel (Recommended) +只公开 webhook 路径: +```bash +tailscale funnel --bg --set-path /wecom http://127.0.0.1:18789/wecom +tailscale funnel status +``` + +Your webhook URL looks like: +`https://..ts.net/wecom` + +> Note: If `tailscale funnel status` shows `(tailnet only)`, Funnel is not public yet; enable Funnel in your tailnet policy. + +### Option B: Reverse Proxy (Nginx/Caddy) +Only proxy `/wecom*` to the gateway: +```caddy +your-domain.com { + reverse_proxy /wecom* localhost:18789 +} +``` + +### Option C: Cloudflare Tunnel +Limit your tunnel ingress to only route the webhook path: +- Path `/wecom` → `http://localhost:18789/wecom` +- Default rule → 404 + +## How it works + +1) WeCom sends GET/POST webhooks to your endpoint: + - GET: URL verification (VerifyURL) + - POST: message callbacks +2) Each request includes `msg_signature`, `timestamp`, `nonce`, and an `encrypt` field in the body. +3) Clawdbot verifies + decrypts the message, routes it to the right agent/session, and replies using **stream**. +4) Stream behavior: + - The first reply may be a minimal placeholder (often `"1"`). + - WeCom then calls back with `msgtype=stream` to refresh and fetch the actual content. + +Limitations: +- WeCom intelligent bots are passive-reply only; standalone sends (for example `sendText`) are not supported. + +## Targets + +- 私聊:`wecom:` +- 群聊:`wecom:group:` + +## Config highlights + +### Single account +```json5 +{ + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + welcomeText: "你好,我是 Clawdbot。", + dm: { + policy: "pairing", + allowFrom: ["userid1", "userid2"] + } + } + } +} +``` + +### Multiple accounts (accounts) +```json5 +{ + channels: { + wecom: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + webhookPath: "/wecom", + token: "TOKEN_A", + encodingAESKey: "AESKEY_A", + receiveId: "" + }, + team2: { + webhookPath: "/wecom-team2", + token: "TOKEN_B", + encodingAESKey: "AESKEY_B", + receiveId: "" + } + } + } + } +} +``` + +## Troubleshooting + +### 400 missing query params +This is expected if you open `/wecom` in a browser; WeCom webhooks include `msg_signature/timestamp/nonce`. + +### 401 unauthorized / invalid signature +Common causes: +- Wrong Token +- Reverse proxy drops query string +- Wrong webhook URL (domain or path) + +### 400 decrypt failed / receiveId mismatch +Common causes: +- Wrong `encodingAESKey` +- Wrong `receiveId` (if unsure, try empty string or follow WeCom console requirements) + +### 只回复 1,没有后续内容 +This usually means: +- The initial callback succeeds, but WeCom’s `msgtype=stream` refresh callback does not reach your webhook (network, reverse proxy, TLS/cert, 502, timeouts). + +Checklist: +1) 看反代 access_log:同一条消息应该至少看到两次 `/wecom` POST。 +2) 用 `clawdbot logs --follow` 观察是否有 `unauthorized`、`decrypt failed` 之类错误。 + +Related docs: +- [Gateway configuration](/gateway/configuration) +- [Security](/gateway/security) + +--- + +# WeCom(企业微信)中文说明 + +Status: 支持企业微信 WeCom 智能机器人(API 模式)加密回调 + 被动回复(stream)。 + +## Quick setup(新手快速上手) + +1) 安装并启用插件: + ```bash + clawdbot plugins install @clawdbot/wecom + clawdbot plugins enable wecom + ``` +2) 在企业微信后台创建“智能机器人(API 模式)”,拿到以下参数: + - `Token` + - `EncodingAESKey` + - `ReceiveId`(部分场景需要;智能机器人常见为空字符串) +3) 配置 Clawdbot(示例): + ```json5 + { + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + dm: { policy: "pairing" } + } + } + } + ``` +4) 重启网关: + ```bash + clawdbot gateway restart + ``` +5) 验证: + ```bash + clawdbot plugins list | rg -n "wecom|WeCom" + clawdbot channels status --probe + ``` + +## Public URL(Webhook-only) + +WeCom webhook 需要公网 HTTPS endpoint。为了安全,强烈建议**只暴露 `/wecom` 路径**,不要把 Clawdbot dashboard 和其他敏感端点暴露到公网。 + +### Option A: Tailscale Funnel(推荐) +只公开 webhook 路径: +```bash +tailscale funnel --bg --set-path /wecom http://127.0.0.1:18789/wecom +tailscale funnel status +``` + +你的 webhook URL 形如: +`https://..ts.net/wecom` + +> Note: 如果 `tailscale funnel status` 显示 `(tailnet only)`,表示还没有真正公网开放;需要在 tailnet 策略里允许 Funnel。 + +### Option B: Reverse Proxy(Nginx/Caddy) +只代理 `/wecom*` 到网关: +```caddy +your-domain.com { + reverse_proxy /wecom* localhost:18789 +} +``` + +### Option C: Cloudflare Tunnel +把 tunnel 的 ingress 规则限制为只转发 webhook: +- Path `/wecom` → `http://localhost:18789/wecom` +- Default rule → 404 + +## How it works(工作原理) + +1) WeCom 会对你的 webhook 发起 GET/POST 回调: + - GET:URL 验证(VerifyURL) + - POST:消息回调 +2) 每次请求会携带 `msg_signature`、`timestamp`、`nonce`,并在 body 里携带 `encrypt`(加密的消息体)。 +3) Clawdbot 会验签 + 解密,路由到对应 agent/session,然后以 **stream 模式**回复。 +4) stream 模式要点: + - 首次回包通常会返回一个 stream 占位符(内容可能是 `"1"`)。 + - WeCom 会用 `msgtype=stream` 的刷新回调再次请求 webhook,以拉取后续内容。 + +重要限制: +- WeCom 智能机器人是被动回复模式,不支持“脱离回调主动发消息”;因此 `sendText` 之类的独立发送会被拒绝。 + +## Targets(目标标识) + +- 私聊:`wecom:` +- 群聊:`wecom:group:` + +## Config highlights(配置要点) + +### 单账号 +```json5 +{ + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + welcomeText: "你好,我是 Clawdbot。", + dm: { + policy: "pairing", + allowFrom: ["userid1", "userid2"] + } + } + } +} +``` + +### 多账号(accounts) +```json5 +{ + channels: { + wecom: { + enabled: true, + defaultAccount: "default", + accounts: { + default: { + webhookPath: "/wecom", + token: "TOKEN_A", + encodingAESKey: "AESKEY_A", + receiveId: "" + }, + team2: { + webhookPath: "/wecom-team2", + token: "TOKEN_B", + encodingAESKey: "AESKEY_B", + receiveId: "" + } + } + } + } +} +``` + +## Troubleshooting(排障) + +### 400 missing query params +你直接用浏览器访问 `/wecom` 常见会这样;WeCom 回调必须带 `msg_signature/timestamp/nonce`。 + +### 401 unauthorized / invalid signature +常见原因: +- Token 配错 +- 反向代理丢了 query string +- 回调 URL 配到错误的域名或路径 + +### 400 decrypt failed / receiveId mismatch +常见原因: +- `encodingAESKey` 错 +- `receiveId` 不匹配(不确定时先留空字符串,或按后台要求填写) + +### 只回复 1,没有后续内容 +这通常表示: +- 首次回调成功,但 WeCom 的 `msgtype=stream` 刷新回调没有打到你的 webhook(网络、反代、证书、502/超时)。 + +排查思路: +1) 看反代 access_log:同一条消息应该至少看到两次 `/wecom` POST。 +2) 用 `clawdbot logs --follow` 观察是否有 `unauthorized`、`decrypt failed` 之类错误。 + +Related docs: +- [Gateway configuration](/gateway/configuration) +- [Security](/gateway/security) diff --git a/extensions/wecom/README.md b/extensions/wecom/README.md new file mode 100644 index 000000000..d08ee8307 --- /dev/null +++ b/extensions/wecom/README.md @@ -0,0 +1,91 @@ +# WeCom (WeChat Work) Channel Plugin for Clawdbot + +Maintainer: YanHaidao (VX: YanHaidao) + +Status: WeCom intelligent bot (API mode) via encrypted webhooks + passive replies (stream). + +## Install + +### Option A: Install from npm +```bash +clawdbot plugins install @clawdbot/wecom +clawdbot plugins enable wecom +clawdbot gateway restart +``` + +### Option B: Local development (link) +```bash +clawdbot plugins install --link extensions/wecom +clawdbot plugins enable wecom +clawdbot gateway restart +``` + +## Configure + +```json5 +{ + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + dm: { policy: "pairing" } + } + } +} +``` + +## Notes + +- Webhooks require public HTTPS. For security, only expose the `/wecom` path to the internet. +- Stream behavior: the first reply may be a minimal placeholder; WeCom will call back with `msgtype=stream` to refresh and fetch the full content. +- Limitations: passive replies only; standalone send is not supported. + +--- + +# Clawdbot 企业微信(WeCom)Channel 插件 + +维护者:YanHaidao(VX:YanHaidao) + +状态:支持企业微信智能机器人(API 模式)加密回调 + 被动回复(stream)。 + +## 安装 + +### 方式 A:从 npm 安装 +```bash +clawdbot plugins install @clawdbot/wecom +clawdbot plugins enable wecom +clawdbot gateway restart +``` + +### 方式 B:本地开发(link) +```bash +clawdbot plugins install --link extensions/wecom +clawdbot plugins enable wecom +clawdbot gateway restart +``` + +## 配置 + +```json5 +{ + channels: { + wecom: { + enabled: true, + webhookPath: "/wecom", + token: "YOUR_TOKEN", + encodingAESKey: "YOUR_ENCODING_AES_KEY", + receiveId: "", + dm: { policy: "pairing" } + } + } +} +``` + +## 说明 + +- webhook 必须是公网 HTTPS。出于安全考虑,建议只对外暴露 `/wecom` 路径。 +- stream 模式:第一次回包可能是占位符;随后 WeCom 会以 `msgtype=stream` 回调刷新拉取完整内容。 +- 限制:仅支持被动回复,不支持脱离回调的主动发送。 diff --git a/extensions/wecom/clawdbot.plugin.json b/extensions/wecom/clawdbot.plugin.json new file mode 100644 index 000000000..bb0a82be6 --- /dev/null +++ b/extensions/wecom/clawdbot.plugin.json @@ -0,0 +1,9 @@ +{ + "id": "wecom", + "channels": ["wecom"], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/wecom/index.ts b/extensions/wecom/index.ts new file mode 100644 index 000000000..e77dd1c06 --- /dev/null +++ b/extensions/wecom/index.ts @@ -0,0 +1,23 @@ +/** + * Author: YanHaidao + */ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { handleWecomWebhookRequest } from "./src/monitor.js"; +import { setWecomRuntime } from "./src/runtime.js"; +import { wecomPlugin } from "./src/channel.js"; + +const plugin = { + id: "wecom", + name: "WeCom", + description: "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setWecomRuntime(api.runtime); + api.registerChannel({ plugin: wecomPlugin }); + api.registerHttpHandler(handleWecomWebhookRequest); + }, +}; + +export default plugin; diff --git a/extensions/wecom/package.json b/extensions/wecom/package.json new file mode 100644 index 000000000..b0e87ec90 --- /dev/null +++ b/extensions/wecom/package.json @@ -0,0 +1,33 @@ +{ + "name": "@clawdbot/wecom", + "version": "2026.1.25", + "type": "module", + "description": "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin", + "author": "YanHaidao (VX: YanHaidao)", + "clawdbot": { + "extensions": ["./index.ts"], + "channel": { + "id": "wecom", + "label": "WeCom", + "selectionLabel": "WeCom (plugin)", + "detailLabel": "WeCom Bot", + "docsPath": "/channels/wecom", + "docsLabel": "wecom", + "blurb": "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.", + "aliases": ["wechatwork", "wework", "qywx", "企微", "企业微信"], + "order": 85 + }, + "install": { + "npmSpec": "@clawdbot/wecom", + "localPath": "extensions/wecom", + "defaultChoice": "npm" + } + }, + "dependencies": {}, + "devDependencies": { + "clawdbot": "workspace:*" + }, + "peerDependencies": { + "clawdbot": ">=2026.1.25" + } +} diff --git a/extensions/wecom/src/accounts.ts b/extensions/wecom/src/accounts.ts new file mode 100644 index 000000000..2e26e0745 --- /dev/null +++ b/extensions/wecom/src/accounts.ts @@ -0,0 +1,73 @@ +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig } from "./types.js"; + +function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] { + const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return []; + return Object.keys(accounts).filter(Boolean); +} + +export function listWecomAccountIds(cfg: ClawdbotConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultWecomAccountId(cfg: ClawdbotConfig): string { + const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined; + if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim(); + const ids = listWecomAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: ClawdbotConfig, + accountId: string, +): WecomAccountConfig | undefined { + const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + return accounts[accountId] as WecomAccountConfig | undefined; +} + +function mergeWecomAccountConfig(cfg: ClawdbotConfig, accountId: string): WecomAccountConfig { + const raw = (cfg.channels?.wecom ?? {}) as WecomConfig; + const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +export function resolveWecomAccount(params: { + cfg: ClawdbotConfig; + accountId?: string | null; +}): ResolvedWecomAccount { + const accountId = normalizeAccountId(params.accountId); + const baseEnabled = (params.cfg.channels?.wecom as WecomConfig | undefined)?.enabled !== false; + const merged = mergeWecomAccountConfig(params.cfg, accountId); + const enabled = baseEnabled && merged.enabled !== false; + + const token = merged.token?.trim() || undefined; + const encodingAESKey = merged.encodingAESKey?.trim() || undefined; + const receiveId = merged.receiveId?.trim() ?? ""; + const configured = Boolean(token && encodingAESKey); + + return { + accountId, + name: merged.name?.trim() || undefined, + enabled, + configured, + token, + encodingAESKey, + receiveId, + config: merged, + }; +} + +export function listEnabledWecomAccounts(cfg: ClawdbotConfig): ResolvedWecomAccount[] { + return listWecomAccountIds(cfg) + .map((accountId) => resolveWecomAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} + diff --git a/extensions/wecom/src/channel.ts b/extensions/wecom/src/channel.ts new file mode 100644 index 000000000..62d5743cd --- /dev/null +++ b/extensions/wecom/src/channel.ts @@ -0,0 +1,212 @@ +import type { + ChannelAccountSnapshot, + ChannelPlugin, + ClawdbotConfig, +} from "clawdbot/plugin-sdk"; +import { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + deleteAccountFromConfigSection, + formatPairingApproveHint, + setAccountEnabledInConfigSection, +} from "clawdbot/plugin-sdk"; + +import { listWecomAccountIds, resolveDefaultWecomAccountId, resolveWecomAccount } from "./accounts.js"; +import { WecomConfigSchema } from "./config-schema.js"; +import type { ResolvedWecomAccount } from "./types.js"; +import { registerWecomWebhookTarget } from "./monitor.js"; + +const meta = { + id: "wecom", + label: "WeCom", + selectionLabel: "WeCom (plugin)", + docsPath: "/channels/wecom", + docsLabel: "wecom", + blurb: "Enterprise WeCom intelligent bot (API mode) via encrypted webhooks + passive replies.", + aliases: ["wechatwork", "wework", "qywx", "企微", "企业微信"], + order: 85, + quickstartAllowFrom: true, +}; + +function normalizeWecomMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined; +} + +export const wecomPlugin: ChannelPlugin = { + id: "wecom", + meta, + capabilities: { + chatTypes: ["direct", "group"], + media: false, + reactions: false, + threads: false, + polls: false, + nativeCommands: false, + blockStreaming: true, + }, + reload: { configPrefixes: ["channels.wecom"] }, + configSchema: buildChannelConfigSchema(WecomConfigSchema), + config: { + listAccountIds: (cfg) => listWecomAccountIds(cfg as ClawdbotConfig), + resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId }), + defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as ClawdbotConfig), + setAccountEnabled: ({ cfg, accountId, enabled }) => + setAccountEnabledInConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "wecom", + accountId, + enabled, + allowTopLevel: true, + }), + deleteAccount: ({ cfg, accountId }) => + deleteAccountFromConfigSection({ + cfg: cfg as ClawdbotConfig, + sectionKey: "wecom", + clearBaseFields: ["name", "webhookPath", "token", "encodingAESKey", "receiveId", "welcomeText"], + accountId, + }), + isConfigured: (account) => account.configured, + describeAccount: (account): ChannelAccountSnapshot => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + webhookPath: account.config.webhookPath ?? "/wecom", + }), + resolveAllowFrom: ({ cfg, accountId }) => { + const account = resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId }); + return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)); + }, + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => String(entry).trim()) + .filter(Boolean) + .map((entry) => entry.toLowerCase()), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean((cfg as ClawdbotConfig).channels?.wecom?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath ? `channels.wecom.accounts.${resolvedAccountId}.` : "channels.wecom."; + return { + policy: account.config.dm?.policy ?? "pairing", + allowFrom: (account.config.dm?.allowFrom ?? []).map((entry) => String(entry)), + policyPath: `${basePath}dm.policy`, + allowFromPath: `${basePath}dm.allowFrom`, + approveHint: formatPairingApproveHint("wecom"), + normalizeEntry: (raw) => raw.trim().toLowerCase(), + }; + }, + }, + groups: { + // WeCom bots are usually mention-gated by the platform in groups already. + resolveRequireMention: () => true, + }, + threading: { + resolveReplyToMode: () => "off", + }, + messaging: { + normalizeTarget: normalizeWecomMessagingTarget, + targetResolver: { + looksLikeId: (raw) => Boolean(raw.trim()), + hint: "", + }, + }, + outbound: { + deliveryMode: "direct", + chunkerMode: "text", + textChunkLimit: 20480, + sendText: async () => { + return { + channel: "wecom", + ok: false, + messageId: "", + error: new Error("WeCom intelligent bot only supports replying within callbacks (no standalone sendText)."), + }; + }, + }, + status: { + defaultRuntime: { + accountId: DEFAULT_ACCOUNT_ID, + running: false, + lastStartAt: null, + lastStopAt: null, + lastError: null, + }, + buildChannelSummary: ({ snapshot }) => ({ + configured: snapshot.configured ?? false, + running: snapshot.running ?? false, + webhookPath: snapshot.webhookPath ?? null, + lastStartAt: snapshot.lastStartAt ?? null, + lastStopAt: snapshot.lastStopAt ?? null, + lastError: snapshot.lastError ?? null, + lastInboundAt: snapshot.lastInboundAt ?? null, + lastOutboundAt: snapshot.lastOutboundAt ?? null, + probe: snapshot.probe, + lastProbeAt: snapshot.lastProbeAt ?? null, + }), + probeAccount: async () => ({ ok: true }), + buildAccountSnapshot: ({ account, runtime }) => ({ + accountId: account.accountId, + name: account.name, + enabled: account.enabled, + configured: account.configured, + webhookPath: account.config.webhookPath ?? "/wecom", + running: runtime?.running ?? false, + lastStartAt: runtime?.lastStartAt ?? null, + lastStopAt: runtime?.lastStopAt ?? null, + lastError: runtime?.lastError ?? null, + lastInboundAt: runtime?.lastInboundAt ?? null, + lastOutboundAt: runtime?.lastOutboundAt ?? null, + dmPolicy: account.config.dm?.policy ?? "pairing", + }), + }, + gateway: { + startAccount: async (ctx) => { + const account = ctx.account; + if (!account.configured) { + ctx.log?.warn(`[${account.accountId}] wecom not configured; skipping webhook registration`); + ctx.setStatus({ accountId: account.accountId, running: false, configured: false }); + return { stop: () => {} }; + } + const path = (account.config.webhookPath ?? "/wecom").trim(); + const unregister = registerWecomWebhookTarget({ + account, + config: ctx.cfg as ClawdbotConfig, + runtime: ctx.runtime, + // The HTTP handler resolves the active PluginRuntime via getWecomRuntime(). + // The stored target only needs to be decrypt/verify-capable. + core: ({} as unknown) as any, + path, + statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), + }); + ctx.log?.info(`[${account.accountId}] wecom webhook registered at ${path}`); + ctx.setStatus({ + accountId: account.accountId, + running: true, + configured: true, + webhookPath: path, + lastStartAt: Date.now(), + }); + return { + stop: () => { + unregister(); + ctx.setStatus({ + accountId: account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }, + }; + }, + stopAccount: async (ctx) => { + ctx.setStatus({ + accountId: ctx.account.accountId, + running: false, + lastStopAt: Date.now(), + }); + }, + }, +}; diff --git a/extensions/wecom/src/config-schema.ts b/extensions/wecom/src/config-schema.ts new file mode 100644 index 000000000..6a42cec10 --- /dev/null +++ b/extensions/wecom/src/config-schema.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +const allowFromEntry = z.union([z.string(), z.number()]); + +const dmSchema = z + .object({ + enabled: z.boolean().optional(), + policy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), + allowFrom: z.array(allowFromEntry).optional(), + }) + .optional(); + +export const WecomConfigSchema = z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + + webhookPath: z.string().optional(), + token: z.string().optional(), + encodingAESKey: z.string().optional(), + receiveId: z.string().optional(), + + welcomeText: z.string().optional(), + dm: dmSchema, + + defaultAccount: z.string().optional(), + accounts: z.object({}).catchall(z.object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + webhookPath: z.string().optional(), + token: z.string().optional(), + encodingAESKey: z.string().optional(), + receiveId: z.string().optional(), + welcomeText: z.string().optional(), + dm: dmSchema, + })).optional(), +}); diff --git a/extensions/wecom/src/crypto.test.ts b/extensions/wecom/src/crypto.test.ts new file mode 100644 index 000000000..c9495da33 --- /dev/null +++ b/extensions/wecom/src/crypto.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js"; + +describe("wecom crypto", () => { + it("round-trips plaintext", () => { + const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; // 43 chars base64 (plus '=' padding) + const plaintext = JSON.stringify({ hello: "world" }); + const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext }); + const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt }); + expect(decrypted).toBe(plaintext); + }); + + it("pads correctly when raw length is a multiple of 32", () => { + const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; + // raw length = 20 + plaintext.length + receiveId.length; choose plaintext length % 32 === 12 + const plaintext = "x".repeat(12); + const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext }); + const decrypted = decryptWecomEncrypted({ encodingAESKey, receiveId: "", encrypt }); + expect(decrypted).toBe(plaintext); + }); + + it("computes sha1 msg signature", () => { + const sig = computeWecomMsgSignature({ + token: "token", + timestamp: "123", + nonce: "456", + encrypt: "ENCRYPT", + }); + expect(sig).toMatch(/^[a-f0-9]{40}$/); + }); +}); diff --git a/extensions/wecom/src/crypto.ts b/extensions/wecom/src/crypto.ts new file mode 100644 index 000000000..06ee445c0 --- /dev/null +++ b/extensions/wecom/src/crypto.ts @@ -0,0 +1,133 @@ +import crypto from "node:crypto"; + +function decodeEncodingAESKey(encodingAESKey: string): Buffer { + const trimmed = encodingAESKey.trim(); + if (!trimmed) throw new Error("encodingAESKey missing"); + const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`; + const key = Buffer.from(withPadding, "base64"); + if (key.length !== 32) { + throw new Error(`invalid encodingAESKey (expected 32 bytes after base64 decode, got ${key.length})`); + } + return key; +} + +// WeCom uses PKCS#7 padding with a block size of 32 bytes (not AES's 16-byte block). +// This is compatible with AES-CBC as 32 is a multiple of 16, but it requires manual padding/unpadding. +const WECOM_PKCS7_BLOCK_SIZE = 32; + +function pkcs7Pad(buf: Buffer, blockSize: number): Buffer { + const mod = buf.length % blockSize; + const pad = mod === 0 ? blockSize : blockSize - mod; + const padByte = Buffer.from([pad]); + return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]); +} + +function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer { + if (buf.length === 0) throw new Error("invalid pkcs7 payload"); + const pad = buf[buf.length - 1]!; + if (pad < 1 || pad > blockSize) { + throw new Error("invalid pkcs7 padding"); + } + if (pad > buf.length) { + throw new Error("invalid pkcs7 payload"); + } + // Best-effort validation (all padding bytes equal). + for (let i = 0; i < pad; i += 1) { + if (buf[buf.length - 1 - i] !== pad) { + throw new Error("invalid pkcs7 padding"); + } + } + return buf.subarray(0, buf.length - pad); +} + +function sha1Hex(input: string): string { + return crypto.createHash("sha1").update(input).digest("hex"); +} + +export function computeWecomMsgSignature(params: { + token: string; + timestamp: string; + nonce: string; + encrypt: string; +}): string { + const parts = [params.token, params.timestamp, params.nonce, params.encrypt] + .map((v) => String(v ?? "")) + .sort(); + return sha1Hex(parts.join("")); +} + +export function verifyWecomSignature(params: { + token: string; + timestamp: string; + nonce: string; + encrypt: string; + signature: string; +}): boolean { + const expected = computeWecomMsgSignature({ + token: params.token, + timestamp: params.timestamp, + nonce: params.nonce, + encrypt: params.encrypt, + }); + return expected === params.signature; +} + +export function decryptWecomEncrypted(params: { + encodingAESKey: string; + receiveId?: string; + encrypt: string; +}): string { + const aesKey = decodeEncodingAESKey(params.encodingAESKey); + const iv = aesKey.subarray(0, 16); + const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv); + decipher.setAutoPadding(false); + const decryptedPadded = Buffer.concat([ + decipher.update(Buffer.from(params.encrypt, "base64")), + decipher.final(), + ]); + const decrypted = pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE); + + if (decrypted.length < 20) { + throw new Error(`invalid decrypted payload (expected at least 20 bytes, got ${decrypted.length})`); + } + + // 16 bytes random + 4 bytes network-order length + msg + receiveId (optional) + const msgLen = decrypted.readUInt32BE(16); + const msgStart = 20; + const msgEnd = msgStart + msgLen; + if (msgEnd > decrypted.length) { + throw new Error(`invalid decrypted msg length (msgEnd=${msgEnd}, payloadLength=${decrypted.length})`); + } + const msg = decrypted.subarray(msgStart, msgEnd).toString("utf8"); + + const receiveId = params.receiveId ?? ""; + if (receiveId) { + const trailing = decrypted.subarray(msgEnd).toString("utf8"); + if (trailing !== receiveId) { + throw new Error(`receiveId mismatch (expected "${receiveId}", got "${trailing}")`); + } + } + + return msg; +} + +export function encryptWecomPlaintext(params: { + encodingAESKey: string; + receiveId?: string; + plaintext: string; +}): string { + const aesKey = decodeEncodingAESKey(params.encodingAESKey); + const iv = aesKey.subarray(0, 16); + const random16 = crypto.randomBytes(16); + const msg = Buffer.from(params.plaintext ?? "", "utf8"); + const msgLen = Buffer.alloc(4); + msgLen.writeUInt32BE(msg.length, 0); + const receiveId = Buffer.from(params.receiveId ?? "", "utf8"); + + const raw = Buffer.concat([random16, msgLen, msg, receiveId]); + const padded = pkcs7Pad(raw, WECOM_PKCS7_BLOCK_SIZE); + const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv); + cipher.setAutoPadding(false); + const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]); + return encrypted.toString("base64"); +} diff --git a/extensions/wecom/src/monitor.ts b/extensions/wecom/src/monitor.ts new file mode 100644 index 000000000..9318e68ff --- /dev/null +++ b/extensions/wecom/src/monitor.ts @@ -0,0 +1,646 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import crypto from "node:crypto"; + +import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk"; + +import type { ResolvedWecomAccount, WecomInboundMessage } from "./types.js"; +import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js"; +import { getWecomRuntime } from "./runtime.js"; + +export type WecomRuntimeEnv = { + log?: (message: string) => void; + error?: (message: string) => void; +}; + +type WecomWebhookTarget = { + account: ResolvedWecomAccount; + config: ClawdbotConfig; + runtime: WecomRuntimeEnv; + core: PluginRuntime; + path: string; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +type StreamState = { + streamId: string; + msgid?: string; + createdAt: number; + updatedAt: number; + started: boolean; + finished: boolean; + error?: string; + content: string; +}; + +const webhookTargets = new Map(); +const streams = new Map(); +const msgidToStreamId = new Map(); + +const STREAM_TTL_MS = 10 * 60 * 1000; +const STREAM_MAX_BYTES = 20_480; + +function normalizeWebhookPath(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) return "/"; + const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; + if (withSlash.length > 1 && withSlash.endsWith("/")) return withSlash.slice(0, -1); + return withSlash; +} + +function pruneStreams(): void { + const cutoff = Date.now() - STREAM_TTL_MS; + for (const [id, state] of streams.entries()) { + if (state.updatedAt < cutoff) { + streams.delete(id); + } + } + for (const [msgid, id] of msgidToStreamId.entries()) { + if (!streams.has(id)) { + msgidToStreamId.delete(msgid); + } + } +} + +function truncateUtf8Bytes(text: string, maxBytes: number): string { + const buf = Buffer.from(text, "utf8"); + if (buf.length <= maxBytes) return text; + const slice = buf.subarray(buf.length - maxBytes); + return slice.toString("utf8"); +} + +function jsonOk(res: ServerResponse, body: unknown): void { + res.statusCode = 200; + // WeCom's reference implementation returns the encrypted JSON as text/plain. + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: IncomingMessage, maxBytes: number) { + const chunks: Buffer[] = []; + let total = 0; + return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + req.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { + resolve({ ok: false, error: "payload too large" }); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf8"); + if (!raw.trim()) { + resolve({ ok: false, error: "empty payload" }); + return; + } + resolve({ ok: true, value: JSON.parse(raw) as unknown }); + } catch (err) { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + } + }); + req.on("error", (err) => { + resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + }); +} + +function buildEncryptedJsonReply(params: { + account: ResolvedWecomAccount; + plaintextJson: unknown; + nonce: string; + timestamp: string; +}): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } { + const plaintext = JSON.stringify(params.plaintextJson ?? {}); + const encrypt = encryptWecomPlaintext({ + encodingAESKey: params.account.encodingAESKey ?? "", + receiveId: params.account.receiveId ?? "", + plaintext, + }); + const msgsignature = computeWecomMsgSignature({ + token: params.account.token ?? "", + timestamp: params.timestamp, + nonce: params.nonce, + encrypt, + }); + return { + encrypt, + msgsignature, + timestamp: params.timestamp, + nonce: params.nonce, + }; +} + +function resolveQueryParams(req: IncomingMessage): URLSearchParams { + const url = new URL(req.url ?? "/", "http://localhost"); + return url.searchParams; +} + +function resolvePath(req: IncomingMessage): string { + const url = new URL(req.url ?? "/", "http://localhost"); + return normalizeWebhookPath(url.pathname || "/"); +} + +function resolveSignatureParam(params: URLSearchParams): string { + return ( + params.get("msg_signature") ?? + params.get("msgsignature") ?? + params.get("signature") ?? + "" + ); +} + +function buildStreamPlaceholderReply(streamId: string): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } { + return { + msgtype: "stream", + stream: { + id: streamId, + finish: false, + // Spec: "第一次回复内容为 1" works as a minimal placeholder. + content: "1", + }, + }; +} + +function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } { + const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES); + return { + msgtype: "stream", + stream: { + id: state.streamId, + finish: state.finished, + content, + }, + }; +} + +function createStreamId(): string { + return crypto.randomBytes(16).toString("hex"); +} + +function logVerbose(target: WecomWebhookTarget, message: string): void { + const core = target.core; + const should = core.logging?.shouldLogVerbose?.() ?? false; + if (should) { + target.runtime.log?.(`[wecom] ${message}`); + } +} + +function parseWecomPlainMessage(raw: string): WecomInboundMessage { + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") { + return {}; + } + return parsed as WecomInboundMessage; +} + +async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise { + if (maxWaitMs <= 0) return; + const startedAt = Date.now(); + await new Promise((resolve) => { + const tick = () => { + const state = streams.get(streamId); + if (!state) return resolve(); + if (state.error || state.finished || state.content.trim()) return resolve(); + if (Date.now() - startedAt >= maxWaitMs) return resolve(); + setTimeout(tick, 25); + }; + tick(); + }); +} + +async function startAgentForStream(params: { + target: WecomWebhookTarget; + accountId: string; + msg: WecomInboundMessage; + streamId: string; +}): Promise { + const { target, msg, streamId } = params; + const core = target.core; + const config = target.config; + const account = target.account; + + const userid = msg.from?.userid?.trim() || "unknown"; + const chatType = msg.chattype === "group" ? "group" : "direct"; + const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid; + const rawBody = buildInboundBody(msg); + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config, + channel: "wecom", + accountId: account.accountId, + peer: { kind: chatType === "group" ? "group" : "dm", id: chatId }, + }); + + logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`); + + const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "WeCom", + from: fromLabel, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`, + To: `wecom:${chatId}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: chatType, + ConversationLabel: fromLabel, + SenderName: userid, + SenderId: userid, + Provider: "wecom", + Surface: "wecom", + MessageSid: msg.msgid, + OriginatingChannel: "wecom", + OriginatingTo: `wecom:${chatId}`, + }); + + await core.channel.session.recordInboundSession({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + onRecordError: (err) => { + target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`); + }, + }); + + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "wecom", + accountId: account.accountId, + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config, + dispatcherOptions: { + deliver: async (payload) => { + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const current = streams.get(streamId); + if (!current) return; + const nextText = current.content + ? `${current.content}\n\n${text}`.trim() + : text.trim(); + current.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES); + current.updatedAt = Date.now(); + target.statusSink?.({ lastOutboundAt: Date.now() }); + }, + onError: (err, info) => { + target.runtime.error?.(`[${account.accountId}] wecom ${info.kind} reply failed: ${String(err)}`); + }, + }, + }); + + const current = streams.get(streamId); + if (current) { + current.finished = true; + current.updatedAt = Date.now(); + } +} + +function buildInboundBody(msg: WecomInboundMessage): string { + const msgtype = String(msg.msgtype ?? "").toLowerCase(); + if (msgtype === "text") { + const content = (msg as any).text?.content; + return typeof content === "string" ? content : ""; + } + if (msgtype === "voice") { + const content = (msg as any).voice?.content; + return typeof content === "string" ? content : "[voice]"; + } + if (msgtype === "mixed") { + const items = (msg as any).mixed?.msg_item; + if (Array.isArray(items)) { + return items + .map((item: any) => { + const t = String(item?.msgtype ?? "").toLowerCase(); + if (t === "text") return String(item?.text?.content ?? ""); + if (t === "image") return `[image] ${String(item?.image?.url ?? "").trim()}`.trim(); + return `[${t || "item"}]`; + }) + .filter((part: string) => Boolean(part && part.trim())) + .join("\n"); + } + return "[mixed]"; + } + if (msgtype === "image") { + const url = String((msg as any).image?.url ?? "").trim(); + return url ? `[image] ${url}` : "[image]"; + } + if (msgtype === "file") { + const url = String((msg as any).file?.url ?? "").trim(); + return url ? `[file] ${url}` : "[file]"; + } + if (msgtype === "event") { + const eventtype = String((msg as any).event?.eventtype ?? "").trim(); + return eventtype ? `[event] ${eventtype}` : "[event]"; + } + if (msgtype === "stream") { + const id = String((msg as any).stream?.id ?? "").trim(); + return id ? `[stream_refresh] ${id}` : "[stream_refresh]"; + } + return msgtype ? `[${msgtype}]` : ""; +} + +export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void { + const key = normalizeWebhookPath(target.path); + const normalizedTarget = { ...target, path: key }; + const existing = webhookTargets.get(key) ?? []; + const next = [...existing, normalizedTarget]; + webhookTargets.set(key, next); + return () => { + const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget); + if (updated.length > 0) webhookTargets.set(key, updated); + else webhookTargets.delete(key); + }; +} + +export async function handleWecomWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + pruneStreams(); + + const path = resolvePath(req); + const targets = webhookTargets.get(path); + if (!targets || targets.length === 0) return false; + + const query = resolveQueryParams(req); + const timestamp = query.get("timestamp") ?? ""; + const nonce = query.get("nonce") ?? ""; + const signature = resolveSignatureParam(query); + + const firstTarget = targets[0]!; + logVerbose(firstTarget, `incoming ${req.method} request on ${path} (timestamp=${timestamp}, nonce=${nonce}, signature=${signature})`); + + if (req.method === "GET") { + const echostr = query.get("echostr") ?? ""; + if (!timestamp || !nonce || !signature || !echostr) { + logVerbose(firstTarget, "GET request missing query params"); + res.statusCode = 400; + res.end("missing query params"); + return true; + } + const target = targets.find((candidate) => { + if (!candidate.account.configured || !candidate.account.token) return false; + const ok = verifyWecomSignature({ + token: candidate.account.token, + timestamp, + nonce, + encrypt: echostr, + signature, + }); + if (!ok) { + logVerbose(candidate, `signature verification failed for echostr (token=${candidate.account.token?.slice(0, 4)}...)`); + } + return ok; + }); + if (!target || !target.account.encodingAESKey) { + logVerbose(firstTarget, "no matching target for GET signature"); + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + try { + const plain = decryptWecomEncrypted({ + encodingAESKey: target.account.encodingAESKey, + receiveId: target.account.receiveId, + encrypt: echostr, + }); + logVerbose(target, "GET echostr decrypted successfully"); + res.statusCode = 200; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end(plain); + return true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logVerbose(target, `GET decrypt failed: ${msg}`); + res.statusCode = 400; + res.end(msg || "decrypt failed"); + return true; + } + } + + if (req.method !== "POST") { + res.statusCode = 405; + res.setHeader("Allow", "GET, POST"); + res.end("Method Not Allowed"); + return true; + } + + if (!timestamp || !nonce || !signature) { + logVerbose(firstTarget, "POST request missing query params"); + res.statusCode = 400; + res.end("missing query params"); + return true; + } + + const body = await readJsonBody(req, 1024 * 1024); + if (!body.ok) { + logVerbose(firstTarget, `POST body read failed: ${body.error}`); + res.statusCode = body.error === "payload too large" ? 413 : 400; + res.end(body.error ?? "invalid payload"); + return true; + } + const record = body.value && typeof body.value === "object" ? (body.value as Record) : null; + const encrypt = record ? String(record.encrypt ?? record.Encrypt ?? "") : ""; + if (!encrypt) { + logVerbose(firstTarget, "POST request missing encrypt field in body"); + res.statusCode = 400; + res.end("missing encrypt"); + return true; + } + + // Find the first target that validates the signature. + const target = targets.find((candidate) => { + if (!candidate.account.token) return false; + const ok = verifyWecomSignature({ + token: candidate.account.token, + timestamp, + nonce, + encrypt, + signature, + }); + if (!ok) { + logVerbose(candidate, `signature verification failed for POST (token=${candidate.account.token?.slice(0, 4)}...)`); + } + return ok; + }); + if (!target) { + logVerbose(firstTarget, "no matching target for POST signature"); + res.statusCode = 401; + res.end("unauthorized"); + return true; + } + + if (!target.account.configured || !target.account.token || !target.account.encodingAESKey) { + logVerbose(target, "target found but not fully configured"); + res.statusCode = 500; + res.end("wecom not configured"); + return true; + } + + let plain: string; + try { + plain = decryptWecomEncrypted({ + encodingAESKey: target.account.encodingAESKey, + receiveId: target.account.receiveId, + encrypt, + }); + logVerbose(target, `decrypted POST message: ${plain}`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + logVerbose(target, `POST decrypt failed: ${msg}`); + res.statusCode = 400; + res.end(msg || "decrypt failed"); + return true; + } + + const msg = parseWecomPlainMessage(plain); + target.statusSink?.({ lastInboundAt: Date.now() }); + + const msgtype = String(msg.msgtype ?? "").toLowerCase(); + const msgid = msg.msgid ? String(msg.msgid) : undefined; + + // Stream refresh callback: reply with current state (if any). + if (msgtype === "stream") { + const streamId = String((msg as any).stream?.id ?? "").trim(); + const state = streamId ? streams.get(streamId) : undefined; + if (state) logVerbose(target, `stream refresh streamId=${streamId} started=${state.started} finished=${state.finished}`); + const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({ + streamId: streamId || "unknown", + createdAt: Date.now(), + updatedAt: Date.now(), + started: true, + finished: true, + content: "", + }); + jsonOk(res, buildEncryptedJsonReply({ + account: target.account, + plaintextJson: reply, + nonce, + timestamp, + })); + return true; + } + + // Dedupe: if we already created a stream for this msgid, return placeholder again. + if (msgid && msgidToStreamId.has(msgid)) { + const streamId = msgidToStreamId.get(msgid) ?? ""; + const reply = buildStreamPlaceholderReply(streamId); + jsonOk(res, buildEncryptedJsonReply({ + account: target.account, + plaintextJson: reply, + nonce, + timestamp, + })); + return true; + } + + // enter_chat welcome event: optionally reply with text (allowed by spec). + if (msgtype === "event") { + const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase(); + if (eventtype === "enter_chat") { + const welcome = target.account.config.welcomeText?.trim(); + const reply = welcome + ? { msgtype: "text", text: { content: welcome } } + : {}; + jsonOk(res, buildEncryptedJsonReply({ + account: target.account, + plaintextJson: reply, + nonce, + timestamp, + })); + return true; + } + + // For other events, reply empty to avoid timeouts. + jsonOk(res, buildEncryptedJsonReply({ + account: target.account, + plaintextJson: {}, + nonce, + timestamp, + })); + return true; + } + + // Default: respond with a stream placeholder and compute the actual reply async. + const streamId = createStreamId(); + if (msgid) msgidToStreamId.set(msgid, streamId); + streams.set(streamId, { + streamId, + msgid, + createdAt: Date.now(), + updatedAt: Date.now(), + started: false, + finished: false, + content: "", + }); + + // Kick off agent processing in the background. + let core: PluginRuntime | null = null; + try { + core = getWecomRuntime(); + } catch (err) { + // If runtime is not ready, we can't process the agent, but we should still + // return the placeholder if possible, or handle it as a background error. + logVerbose(target, `runtime not ready, skipping agent processing: ${String(err)}`); + } + + if (core) { + streams.get(streamId)!.started = true; + const enrichedTarget: WecomWebhookTarget = { ...target, core }; + startAgentForStream({ target: enrichedTarget, accountId: target.account.accountId, msg, streamId }).catch((err) => { + const state = streams.get(streamId); + if (state) { + state.error = err instanceof Error ? err.message : String(err); + state.content = state.content || `Error: ${state.error}`; + state.finished = true; + state.updatedAt = Date.now(); + } + target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`); + }); + } else { + // In tests or uninitialized state, we might not have a core. + // We mark it as finished to avoid hanging, but don't set an error content + // immediately if we want to return the placeholder "1". + const state = streams.get(streamId); + if (state) { + state.finished = true; + state.updatedAt = Date.now(); + } + } + + // Try to include a first chunk in the initial response (matches WeCom demo behavior). + // If nothing is ready quickly, fall back to the placeholder "1". + await waitForStreamContent(streamId, 800); + const state = streams.get(streamId); + const initialReply = state && (state.content.trim() || state.error) + ? buildStreamReplyFromState(state) + : buildStreamPlaceholderReply(streamId); + jsonOk(res, buildEncryptedJsonReply({ + account: target.account, + plaintextJson: initialReply, + nonce, + timestamp, + })); + + logVerbose(target, `accepted msgtype=${msgtype || "unknown"} msgid=${msgid || "none"} streamId=${streamId}`); + return true; +} diff --git a/extensions/wecom/src/monitor.webhook.test.ts b/extensions/wecom/src/monitor.webhook.test.ts new file mode 100644 index 000000000..de19af91e --- /dev/null +++ b/extensions/wecom/src/monitor.webhook.test.ts @@ -0,0 +1,161 @@ +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { describe, expect, it } from "vitest"; + +import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; + +import type { ResolvedWecomAccount } from "./types.js"; +import { computeWecomMsgSignature, decryptWecomEncrypted, encryptWecomPlaintext } from "./crypto.js"; +import { handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js"; + +async function withServer( + handler: Parameters[0], + fn: (baseUrl: string) => Promise, +) { + const server = createServer(handler); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address() as AddressInfo | null; + if (!address) throw new Error("missing server address"); + try { + await fn(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("handleWecomWebhookRequest", () => { + const token = "test-token"; + const encodingAESKey = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG"; + + it("handles GET url verification", async () => { + const account: ResolvedWecomAccount = { + accountId: "default", + name: "Test", + enabled: true, + configured: true, + token, + encodingAESKey, + receiveId: "", + config: { webhookPath: "/hook", token, encodingAESKey }, + }; + + const unregister = registerWecomWebhookTarget({ + account, + config: {} as ClawdbotConfig, + runtime: {}, + core: {} as any, + path: "/hook", + }); + + try { + await withServer(async (req, res) => { + const handled = await handleWecomWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, async (baseUrl) => { + const timestamp = "13500001234"; + const nonce = "123412323"; + const echostr = encryptWecomPlaintext({ + encodingAESKey, + receiveId: "", + plaintext: "ping", + }); + const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt: echostr }); + const response = await fetch( + `${baseUrl}/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}&echostr=${encodeURIComponent(echostr)}`, + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("ping"); + }); + } finally { + unregister(); + } + }); + + it("handles POST callback and returns encrypted stream placeholder", async () => { + const account: ResolvedWecomAccount = { + accountId: "default", + name: "Test", + enabled: true, + configured: true, + token, + encodingAESKey, + receiveId: "", + config: { webhookPath: "/hook", token, encodingAESKey }, + }; + + const unregister = registerWecomWebhookTarget({ + account, + config: {} as ClawdbotConfig, + runtime: {}, + core: {} as any, + path: "/hook", + }); + + try { + await withServer(async (req, res) => { + const handled = await handleWecomWebhookRequest(req, res); + if (!handled) { + res.statusCode = 404; + res.end("not found"); + } + }, async (baseUrl) => { + const timestamp = "1700000000"; + const nonce = "nonce"; + const plain = JSON.stringify({ + msgid: "MSGID", + aibotid: "AIBOTID", + chattype: "single", + from: { userid: "USERID" }, + response_url: "RESPONSEURL", + msgtype: "text", + text: { content: "hello" }, + }); + const encrypt = encryptWecomPlaintext({ encodingAESKey, receiveId: "", plaintext: plain }); + const msg_signature = computeWecomMsgSignature({ token, timestamp, nonce, encrypt }); + + const response = await fetch( + `${baseUrl}/hook?msg_signature=${encodeURIComponent(msg_signature)}×tamp=${encodeURIComponent(timestamp)}&nonce=${encodeURIComponent(nonce)}`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ encrypt }), + }, + ); + expect(response.status).toBe(200); + const json = JSON.parse(await response.text()) as any; + expect(typeof json.encrypt).toBe("string"); + expect(typeof json.msgsignature).toBe("string"); + expect(typeof json.timestamp).toBe("string"); + expect(typeof json.nonce).toBe("string"); + + const replyPlain = decryptWecomEncrypted({ + encodingAESKey, + receiveId: "", + encrypt: json.encrypt, + }); + const reply = JSON.parse(replyPlain) as any; + expect(reply.msgtype).toBe("stream"); + expect(reply.stream?.content).toBe("1"); + expect(reply.stream?.finish).toBe(false); + expect(typeof reply.stream?.id).toBe("string"); + expect(reply.stream?.id.length).toBeGreaterThan(0); + + const expectedSig = computeWecomMsgSignature({ + token, + timestamp: String(json.timestamp), + nonce: String(json.nonce), + encrypt: String(json.encrypt), + }); + expect(json.msgsignature).toBe(expectedSig); + }); + } finally { + unregister(); + } + }); +}); diff --git a/extensions/wecom/src/runtime.ts b/extensions/wecom/src/runtime.ts new file mode 100644 index 000000000..36217c767 --- /dev/null +++ b/extensions/wecom/src/runtime.ts @@ -0,0 +1,15 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setWecomRuntime(next: PluginRuntime): void { + runtime = next; +} + +export function getWecomRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("WeCom runtime not initialized"); + } + return runtime; +} + diff --git a/extensions/wecom/src/types.ts b/extensions/wecom/src/types.ts new file mode 100644 index 000000000..83b69df16 --- /dev/null +++ b/extensions/wecom/src/types.ts @@ -0,0 +1,77 @@ +export type WecomDmConfig = { + policy?: "pairing" | "allowlist" | "open" | "disabled"; + allowFrom?: Array; +}; + +export type WecomAccountConfig = { + name?: string; + enabled?: boolean; + + webhookPath?: string; + token?: string; + encodingAESKey?: string; + receiveId?: string; + + dm?: WecomDmConfig; + welcomeText?: string; +}; + +export type WecomConfig = WecomAccountConfig & { + accounts?: Record; + defaultAccount?: string; +}; + +export type ResolvedWecomAccount = { + accountId: string; + name?: string; + enabled: boolean; + configured: boolean; + token?: string; + encodingAESKey?: string; + receiveId: string; + config: WecomAccountConfig; +}; + +export type WecomInboundBase = { + msgid?: string; + aibotid?: string; + chattype?: "single" | "group"; + chatid?: string; + response_url?: string; + from?: { userid?: string; corpid?: string }; + msgtype?: string; +}; + +export type WecomInboundText = WecomInboundBase & { + msgtype: "text"; + text?: { content?: string }; + quote?: unknown; +}; + +export type WecomInboundVoice = WecomInboundBase & { + msgtype: "voice"; + voice?: { content?: string }; + quote?: unknown; +}; + +export type WecomInboundStreamRefresh = WecomInboundBase & { + msgtype: "stream"; + stream?: { id?: string }; +}; + +export type WecomInboundEvent = WecomInboundBase & { + msgtype: "event"; + create_time?: number; + event?: { + eventtype?: string; + [key: string]: unknown; + }; +}; + +export type WecomInboundMessage = + | WecomInboundText + | WecomInboundVoice + | WecomInboundStreamRefresh + | WecomInboundEvent + | (WecomInboundBase & Record); +