- Add WeCom channel plugin for Clawdbot (popular in China)

- Add channel documentation (EN + 中文) and plugin README
- Link WeCom from channels index
- 新增 WeCom 渠道插件(国内常用)
- 新增频道文档(英文 + 中文)与插件 README
- 在 Channels 索引中加入 WeCom 入口
This commit is contained in:
YanHaidao 2026-01-27 11:45:52 +08:00
parent 83de980d6c
commit a3799c7748
15 changed files with 1879 additions and 0 deletions

View File

@ -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

337
docs/channels/wecom.md Normal file
View File

@ -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://<node-name>.<tailnet>.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:<userid>`
- 群聊:`wecom:group:<chatid>`
## 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 WeComs `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 URLWebhook-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://<node-name>.<tailnet>.ts.net/wecom`
> Note: 如果 `tailscale funnel status` 显示 `(tailnet only)`,表示还没有真正公网开放;需要在 tailnet 策略里允许 Funnel。
### Option B: Reverse ProxyNginx/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 回调:
- GETURL 验证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:<userid>`
- 群聊:`wecom:group:<chatid>`
## 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)

View File

@ -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 企业微信WeComChannel 插件
维护者YanHaidaoVXYanHaidao
状态支持企业微信智能机器人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` 回调刷新拉取完整内容。
- 限制:仅支持被动回复,不支持脱离回调的主动发送。

View File

@ -0,0 +1,9 @@
{
"id": "wecom",
"channels": ["wecom"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

23
extensions/wecom/index.ts Normal file
View File

@ -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;

View File

@ -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"
}
}

View File

@ -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);
}

View File

@ -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<ResolvedWecomAccount> = {
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: "<userid|chatid>",
},
},
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(),
});
},
},
};

View File

@ -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(),
});

View File

@ -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}$/);
});
});

View File

@ -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");
}

View File

@ -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<string, WecomWebhookTarget[]>();
const streams = new Map<string, StreamState>();
const msgidToStreamId = new Map<string, string>();
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<void> {
if (maxWaitMs <= 0) return;
const startedAt = Date.now();
await new Promise<void>((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<void> {
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<boolean> {
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<string, unknown>) : 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;
}

View File

@ -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<typeof createServer>[0],
fn: (baseUrl: string) => Promise<void>,
) {
const server = createServer(handler);
await new Promise<void>((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<void>((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)}&timestamp=${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)}&timestamp=${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();
}
});
});

View File

@ -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;
}

View File

@ -0,0 +1,77 @@
export type WecomDmConfig = {
policy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
};
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<string, WecomAccountConfig>;
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<string, unknown>);