- 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:
parent
83de980d6c
commit
a3799c7748
@ -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
337
docs/channels/wecom.md
Normal 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 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://<node-name>.<tailnet>.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:<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)
|
||||
91
extensions/wecom/README.md
Normal file
91
extensions/wecom/README.md
Normal 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 企业微信(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` 回调刷新拉取完整内容。
|
||||
- 限制:仅支持被动回复,不支持脱离回调的主动发送。
|
||||
9
extensions/wecom/clawdbot.plugin.json
Normal file
9
extensions/wecom/clawdbot.plugin.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "wecom",
|
||||
"channels": ["wecom"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
23
extensions/wecom/index.ts
Normal file
23
extensions/wecom/index.ts
Normal 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;
|
||||
33
extensions/wecom/package.json
Normal file
33
extensions/wecom/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
73
extensions/wecom/src/accounts.ts
Normal file
73
extensions/wecom/src/accounts.ts
Normal 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);
|
||||
}
|
||||
|
||||
212
extensions/wecom/src/channel.ts
Normal file
212
extensions/wecom/src/channel.ts
Normal 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(),
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
36
extensions/wecom/src/config-schema.ts
Normal file
36
extensions/wecom/src/config-schema.ts
Normal 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(),
|
||||
});
|
||||
32
extensions/wecom/src/crypto.test.ts
Normal file
32
extensions/wecom/src/crypto.test.ts
Normal 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}$/);
|
||||
});
|
||||
});
|
||||
133
extensions/wecom/src/crypto.ts
Normal file
133
extensions/wecom/src/crypto.ts
Normal 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");
|
||||
}
|
||||
646
extensions/wecom/src/monitor.ts
Normal file
646
extensions/wecom/src/monitor.ts
Normal 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;
|
||||
}
|
||||
161
extensions/wecom/src/monitor.webhook.test.ts
Normal file
161
extensions/wecom/src/monitor.webhook.test.ts
Normal 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)}×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();
|
||||
}
|
||||
});
|
||||
});
|
||||
15
extensions/wecom/src/runtime.ts
Normal file
15
extensions/wecom/src/runtime.ts
Normal 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;
|
||||
}
|
||||
|
||||
77
extensions/wecom/src/types.ts
Normal file
77
extensions/wecom/src/types.ts
Normal 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>);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user