@yanhaidao/wecom 1.0.1 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -26
- package/assets/01.image.jpg +0 -0
- package/assets/link-me.jpg +0 -0
- package/index.ts +4 -4
- package/{clawdbot.plugin.json → openclaw.plugin.json} +1 -0
- package/package.json +5 -5
- package/src/accounts.ts +9 -10
- package/src/channel.ts +12 -12
- package/src/config-schema.ts +3 -0
- package/src/crypto.ts +3 -3
- package/src/media.test.ts +49 -0
- package/src/media.ts +39 -0
- package/src/monitor.active.test.ts +137 -0
- package/src/monitor.integration.test.ts +190 -0
- package/src/monitor.ts +452 -261
- package/src/monitor.webhook.test.ts +162 -94
- package/src/runtime.ts +1 -2
- package/src/types.ts +84 -2
- package/tsconfig.json +1 -1
- package/vitest.config.ts +15 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenClaw 企业微信(WeCom)Channel 插件
|
|
2
2
|
|
|
3
3
|
维护者:YanHaidao(VX:YanHaidao)
|
|
4
4
|
|
|
@@ -10,28 +10,44 @@
|
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
+
## 文件与图片入模(说明)
|
|
14
|
+
|
|
15
|
+
图片/文件 URL 下载内容为加密数据,需使用 `EncodingAESKey` 解密后再解析并入模。
|
|
16
|
+
|
|
17
|
+
## 测试页截图(文件上传 / 解析)
|
|
18
|
+
|
|
19
|
+
> 图片过大可替换为压缩版(保持文件名不变即可)。
|
|
20
|
+
|
|
21
|
+

|
|
22
|
+
|
|
23
|
+
## A2UI 交互卡片(template_card)
|
|
24
|
+
|
|
25
|
+
- Agent 输出 `{"template_card": ...}`(JSON)时:单聊且有 `response_url` 会发送交互卡片;群聊或无 `response_url` 自动降级为文本说明(不透出原始 JSON)。
|
|
26
|
+
- 收到 `template_card_event` 时:会转换为伪文本消息触发 Agent,并基于 `msgid` 去重避免重复处理。
|
|
27
|
+
- 卡片相关的示例/skill:加群获取(见上方交流群二维码)。
|
|
28
|
+
|
|
13
29
|
## 安装
|
|
14
30
|
|
|
15
31
|
### 从 npm 安装
|
|
16
32
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
33
|
+
openclaw plugins install @yanhaidao/wecom
|
|
34
|
+
openclaw plugins enable wecom
|
|
35
|
+
openclaw gateway restart
|
|
20
36
|
```
|
|
21
37
|
|
|
22
|
-
|
|
23
38
|
## 配置结构参考
|
|
24
39
|
|
|
25
|
-
```
|
|
40
|
+
```json
|
|
26
41
|
{
|
|
27
|
-
channels: {
|
|
28
|
-
wecom: {
|
|
29
|
-
enabled: true,
|
|
30
|
-
webhookPath: "/wecom",
|
|
31
|
-
token: "YOUR_TOKEN",
|
|
32
|
-
encodingAESKey: "YOUR_ENCODING_AES_KEY",
|
|
33
|
-
receiveId: "
|
|
34
|
-
|
|
42
|
+
"channels": {
|
|
43
|
+
"wecom": {
|
|
44
|
+
"enabled": true,
|
|
45
|
+
"webhookPath": "/wecom",
|
|
46
|
+
"token": "YOUR_TOKEN",
|
|
47
|
+
"encodingAESKey": "YOUR_ENCODING_AES_KEY",
|
|
48
|
+
"receiveId": "",
|
|
49
|
+
"streamPlaceholderContent": "正在思考...",
|
|
50
|
+
"dm": { "policy": "pairing" }
|
|
35
51
|
}
|
|
36
52
|
}
|
|
37
53
|
}
|
|
@@ -48,7 +64,7 @@ clawdbot gateway restart
|
|
|
48
64
|
创建机器人时需要填写回调 URL(公网可访问的 HTTPS 地址),例如:`https://example.com/wecom`
|
|
49
65
|
|
|
50
66
|
3. 记录机器人配置
|
|
51
|
-
在机器人详情里找到并保存以下信息,后续会写入
|
|
67
|
+
在机器人详情里找到并保存以下信息,后续会写入 OpenClaw 配置:
|
|
52
68
|
- Token
|
|
53
69
|
- EncodingAESKey
|
|
54
70
|
- ReceiveId(如果你的机器人/回调配置需要校验的话)
|
|
@@ -57,33 +73,33 @@ clawdbot gateway restart
|
|
|
57
73
|
|
|
58
74
|
1. 启用插件
|
|
59
75
|
```bash
|
|
60
|
-
|
|
76
|
+
openclaw plugins enable wecom
|
|
61
77
|
```
|
|
62
78
|
|
|
63
79
|
2. 配置企业微信机器人(必需)
|
|
64
80
|
```bash
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
81
|
+
openclaw config set channels.wecom.enabled true
|
|
82
|
+
openclaw config set channels.wecom.webhookPath "/wecom"
|
|
83
|
+
openclaw config set channels.wecom.token "YOUR_TOKEN"
|
|
84
|
+
openclaw config set channels.wecom.encodingAESKey "YOUR_ENCODING_AES_KEY"
|
|
85
|
+
openclaw config set channels.wecom.receiveId ""
|
|
70
86
|
```
|
|
71
87
|
|
|
72
88
|
3. 配置 Gateway(示例)
|
|
73
89
|
```bash
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
90
|
+
openclaw config set gateway.mode "local"
|
|
91
|
+
openclaw config set gateway.bind "0.0.0.0"
|
|
92
|
+
openclaw config set gateway.port 18789
|
|
77
93
|
```
|
|
78
94
|
|
|
79
95
|
4. 重启 Gateway
|
|
80
96
|
```bash
|
|
81
|
-
|
|
97
|
+
openclaw gateway restart
|
|
82
98
|
```
|
|
83
99
|
|
|
84
100
|
5. 验证
|
|
85
101
|
```bash
|
|
86
|
-
|
|
102
|
+
openclaw channels status
|
|
87
103
|
```
|
|
88
104
|
|
|
89
105
|
## 说明
|
|
@@ -91,3 +107,15 @@ clawdbot channels status
|
|
|
91
107
|
- webhook 必须是公网 HTTPS。出于安全考虑,建议只对外暴露 `/wecom` 路径。
|
|
92
108
|
- stream 模式:第一次回包可能是占位符;随后 WeCom 会以 `msgtype=stream` 回调刷新拉取完整内容。
|
|
93
109
|
- 限制:仅支持被动回复,不支持脱离回调的主动发送。
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# 更新日志
|
|
114
|
+
|
|
115
|
+
## 2026.1.31
|
|
116
|
+
|
|
117
|
+
- 文档:补充入模与测试截图说明。
|
|
118
|
+
|
|
119
|
+
## 2026.1.30
|
|
120
|
+
|
|
121
|
+
- 项目更名:Clawdbot → OpenClaw(CLI:`clawdbot` → `openclaw`)。
|
|
Binary file
|
package/assets/link-me.jpg
CHANGED
|
Binary file
|
package/index.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Author: YanHaidao
|
|
3
3
|
*/
|
|
4
|
-
import type {
|
|
5
|
-
import { emptyPluginConfigSchema } from "
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
5
|
+
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
|
6
6
|
|
|
7
7
|
import { handleWecomWebhookRequest } from "./src/monitor.js";
|
|
8
8
|
import { setWecomRuntime } from "./src/runtime.js";
|
|
@@ -11,9 +11,9 @@ import { wecomPlugin } from "./src/channel.js";
|
|
|
11
11
|
const plugin = {
|
|
12
12
|
id: "wecom",
|
|
13
13
|
name: "WeCom",
|
|
14
|
-
description: "
|
|
14
|
+
description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
15
15
|
configSchema: emptyPluginConfigSchema(),
|
|
16
|
-
register(api:
|
|
16
|
+
register(api: OpenClawPluginApi) {
|
|
17
17
|
setWecomRuntime(api.runtime);
|
|
18
18
|
api.registerChannel({ plugin: wecomPlugin });
|
|
19
19
|
api.registerHttpHandler(handleWecomWebhookRequest);
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yanhaidao/wecom",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
|
|
6
6
|
"author": "YanHaidao (VX: YanHaidao)",
|
|
7
|
-
"
|
|
7
|
+
"openclaw": {
|
|
8
8
|
"extensions": [
|
|
9
9
|
"./index.ts"
|
|
10
10
|
],
|
|
@@ -32,10 +32,10 @@
|
|
|
32
32
|
}
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"axios": "^1.13.4",
|
|
35
36
|
"zod": "^4.3.6"
|
|
36
37
|
},
|
|
37
|
-
"devDependencies": {},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"
|
|
39
|
+
"openclaw": ">=2026.1.26"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/accounts.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
|
3
3
|
|
|
4
4
|
import type { ResolvedWecomAccount, WecomAccountConfig, WecomConfig } from "./types.js";
|
|
5
5
|
|
|
6
|
-
function listConfiguredAccountIds(cfg:
|
|
6
|
+
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
|
|
7
7
|
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
|
|
8
8
|
if (!accounts || typeof accounts !== "object") return [];
|
|
9
9
|
return Object.keys(accounts).filter(Boolean);
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function listWecomAccountIds(cfg:
|
|
12
|
+
export function listWecomAccountIds(cfg: OpenClawConfig): string[] {
|
|
13
13
|
const ids = listConfiguredAccountIds(cfg);
|
|
14
14
|
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
|
|
15
15
|
return ids.sort((a, b) => a.localeCompare(b));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
export function resolveDefaultWecomAccountId(cfg:
|
|
18
|
+
export function resolveDefaultWecomAccountId(cfg: OpenClawConfig): string {
|
|
19
19
|
const wecomConfig = cfg.channels?.wecom as WecomConfig | undefined;
|
|
20
20
|
if (wecomConfig?.defaultAccount?.trim()) return wecomConfig.defaultAccount.trim();
|
|
21
21
|
const ids = listWecomAccountIds(cfg);
|
|
@@ -24,7 +24,7 @@ export function resolveDefaultWecomAccountId(cfg: ClawdbotConfig): string {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
function resolveAccountConfig(
|
|
27
|
-
cfg:
|
|
27
|
+
cfg: OpenClawConfig,
|
|
28
28
|
accountId: string,
|
|
29
29
|
): WecomAccountConfig | undefined {
|
|
30
30
|
const accounts = (cfg.channels?.wecom as WecomConfig | undefined)?.accounts;
|
|
@@ -32,7 +32,7 @@ function resolveAccountConfig(
|
|
|
32
32
|
return accounts[accountId] as WecomAccountConfig | undefined;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function mergeWecomAccountConfig(cfg:
|
|
35
|
+
function mergeWecomAccountConfig(cfg: OpenClawConfig, accountId: string): WecomAccountConfig {
|
|
36
36
|
const raw = (cfg.channels?.wecom ?? {}) as WecomConfig;
|
|
37
37
|
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
|
|
38
38
|
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
|
@@ -40,7 +40,7 @@ function mergeWecomAccountConfig(cfg: ClawdbotConfig, accountId: string): WecomA
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
export function resolveWecomAccount(params: {
|
|
43
|
-
cfg:
|
|
43
|
+
cfg: OpenClawConfig;
|
|
44
44
|
accountId?: string | null;
|
|
45
45
|
}): ResolvedWecomAccount {
|
|
46
46
|
const accountId = normalizeAccountId(params.accountId);
|
|
@@ -65,9 +65,8 @@ export function resolveWecomAccount(params: {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
export function listEnabledWecomAccounts(cfg:
|
|
68
|
+
export function listEnabledWecomAccounts(cfg: OpenClawConfig): ResolvedWecomAccount[] {
|
|
69
69
|
return listWecomAccountIds(cfg)
|
|
70
70
|
.map((accountId) => resolveWecomAccount({ cfg, accountId }))
|
|
71
71
|
.filter((account) => account.enabled);
|
|
72
72
|
}
|
|
73
|
-
|
package/src/channel.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
ChannelAccountSnapshot,
|
|
3
3
|
ChannelPlugin,
|
|
4
|
-
|
|
5
|
-
} from "
|
|
4
|
+
OpenClawConfig,
|
|
5
|
+
} from "openclaw/plugin-sdk";
|
|
6
6
|
import {
|
|
7
7
|
buildChannelConfigSchema,
|
|
8
8
|
DEFAULT_ACCOUNT_ID,
|
|
9
9
|
deleteAccountFromConfigSection,
|
|
10
10
|
formatPairingApproveHint,
|
|
11
11
|
setAccountEnabledInConfigSection,
|
|
12
|
-
} from "
|
|
12
|
+
} from "openclaw/plugin-sdk";
|
|
13
13
|
|
|
14
14
|
import { listWecomAccountIds, resolveDefaultWecomAccountId, resolveWecomAccount } from "./accounts.js";
|
|
15
15
|
import { WecomConfigSchema } from "./config-schema.js";
|
|
@@ -49,12 +49,12 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
49
49
|
reload: { configPrefixes: ["channels.wecom"] },
|
|
50
50
|
configSchema: buildChannelConfigSchema(WecomConfigSchema),
|
|
51
51
|
config: {
|
|
52
|
-
listAccountIds: (cfg) => listWecomAccountIds(cfg as
|
|
53
|
-
resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as
|
|
54
|
-
defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as
|
|
52
|
+
listAccountIds: (cfg) => listWecomAccountIds(cfg as OpenClawConfig),
|
|
53
|
+
resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as OpenClawConfig, accountId }),
|
|
54
|
+
defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as OpenClawConfig),
|
|
55
55
|
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
|
56
56
|
setAccountEnabledInConfigSection({
|
|
57
|
-
cfg: cfg as
|
|
57
|
+
cfg: cfg as OpenClawConfig,
|
|
58
58
|
sectionKey: "wecom",
|
|
59
59
|
accountId,
|
|
60
60
|
enabled,
|
|
@@ -62,9 +62,9 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
62
62
|
}),
|
|
63
63
|
deleteAccount: ({ cfg, accountId }) =>
|
|
64
64
|
deleteAccountFromConfigSection({
|
|
65
|
-
cfg: cfg as
|
|
65
|
+
cfg: cfg as OpenClawConfig,
|
|
66
66
|
sectionKey: "wecom",
|
|
67
|
-
clearBaseFields: ["name", "webhookPath", "token", "encodingAESKey", "receiveId", "welcomeText"],
|
|
67
|
+
clearBaseFields: ["name", "webhookPath", "token", "encodingAESKey", "receiveId", "streamPlaceholderContent", "welcomeText"],
|
|
68
68
|
accountId,
|
|
69
69
|
}),
|
|
70
70
|
isConfigured: (account) => account.configured,
|
|
@@ -76,7 +76,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
76
76
|
webhookPath: account.config.webhookPath ?? "/wecom",
|
|
77
77
|
}),
|
|
78
78
|
resolveAllowFrom: ({ cfg, accountId }) => {
|
|
79
|
-
const account = resolveWecomAccount({ cfg: cfg as
|
|
79
|
+
const account = resolveWecomAccount({ cfg: cfg as OpenClawConfig, accountId });
|
|
80
80
|
return (account.config.dm?.allowFrom ?? []).map((entry) => String(entry));
|
|
81
81
|
},
|
|
82
82
|
formatAllowFrom: ({ allowFrom }) =>
|
|
@@ -88,7 +88,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
88
88
|
security: {
|
|
89
89
|
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
|
90
90
|
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
91
|
-
const useAccountPath = Boolean((cfg as
|
|
91
|
+
const useAccountPath = Boolean((cfg as OpenClawConfig).channels?.wecom?.accounts?.[resolvedAccountId]);
|
|
92
92
|
const basePath = useAccountPath ? `channels.wecom.accounts.${resolvedAccountId}.` : "channels.wecom.";
|
|
93
93
|
return {
|
|
94
94
|
policy: account.config.dm?.policy ?? "pairing",
|
|
@@ -174,7 +174,7 @@ export const wecomPlugin: ChannelPlugin<ResolvedWecomAccount> = {
|
|
|
174
174
|
const path = (account.config.webhookPath ?? "/wecom").trim();
|
|
175
175
|
const unregister = registerWecomWebhookTarget({
|
|
176
176
|
account,
|
|
177
|
-
config: ctx.cfg as
|
|
177
|
+
config: ctx.cfg as OpenClawConfig,
|
|
178
178
|
runtime: ctx.runtime,
|
|
179
179
|
// The HTTP handler resolves the active PluginRuntime via getWecomRuntime().
|
|
180
180
|
// The stored target only needs to be decrypt/verify-capable.
|
package/src/config-schema.ts
CHANGED
|
@@ -19,6 +19,8 @@ export const WecomConfigSchema = z.object({
|
|
|
19
19
|
encodingAESKey: z.string().optional(),
|
|
20
20
|
receiveId: z.string().optional(),
|
|
21
21
|
|
|
22
|
+
streamPlaceholderContent: z.string().optional(),
|
|
23
|
+
|
|
22
24
|
welcomeText: z.string().optional(),
|
|
23
25
|
dm: dmSchema,
|
|
24
26
|
|
|
@@ -30,6 +32,7 @@ export const WecomConfigSchema = z.object({
|
|
|
30
32
|
token: z.string().optional(),
|
|
31
33
|
encodingAESKey: z.string().optional(),
|
|
32
34
|
receiveId: z.string().optional(),
|
|
35
|
+
streamPlaceholderContent: z.string().optional(),
|
|
33
36
|
welcomeText: z.string().optional(),
|
|
34
37
|
dm: dmSchema,
|
|
35
38
|
})).optional(),
|
package/src/crypto.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
|
|
3
|
-
function decodeEncodingAESKey(encodingAESKey: string): Buffer {
|
|
3
|
+
export function decodeEncodingAESKey(encodingAESKey: string): Buffer {
|
|
4
4
|
const trimmed = encodingAESKey.trim();
|
|
5
5
|
if (!trimmed) throw new Error("encodingAESKey missing");
|
|
6
6
|
const withPadding = trimmed.endsWith("=") ? trimmed : `${trimmed}=`;
|
|
@@ -13,7 +13,7 @@ function decodeEncodingAESKey(encodingAESKey: string): Buffer {
|
|
|
13
13
|
|
|
14
14
|
// WeCom uses PKCS#7 padding with a block size of 32 bytes (not AES's 16-byte block).
|
|
15
15
|
// This is compatible with AES-CBC as 32 is a multiple of 16, but it requires manual padding/unpadding.
|
|
16
|
-
const WECOM_PKCS7_BLOCK_SIZE = 32;
|
|
16
|
+
export const WECOM_PKCS7_BLOCK_SIZE = 32;
|
|
17
17
|
|
|
18
18
|
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
19
19
|
const mod = buf.length % blockSize;
|
|
@@ -22,7 +22,7 @@ function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
|
22
22
|
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
|
|
25
|
+
export function pkcs7Unpad(buf: Buffer, blockSize: number): Buffer {
|
|
26
26
|
if (buf.length === 0) throw new Error("invalid pkcs7 payload");
|
|
27
27
|
const pad = buf[buf.length - 1]!;
|
|
28
28
|
if (pad < 1 || pad > blockSize) {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { decryptWecomMedia } from "./media.js";
|
|
3
|
+
import { WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
|
+
import axios from "axios";
|
|
5
|
+
import crypto from "node:crypto";
|
|
6
|
+
|
|
7
|
+
vi.mock("axios");
|
|
8
|
+
|
|
9
|
+
function pkcs7Pad(buf: Buffer, blockSize: number): Buffer {
|
|
10
|
+
const mod = buf.length % blockSize;
|
|
11
|
+
const pad = mod === 0 ? blockSize : blockSize - mod;
|
|
12
|
+
const padByte = Buffer.from([pad]);
|
|
13
|
+
return Buffer.concat([buf, Buffer.alloc(pad, padByte[0]!)]);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("decryptWecomMedia", () => {
|
|
17
|
+
it("should download and decrypt media successfully", async () => {
|
|
18
|
+
// 1. Setup Key and Data
|
|
19
|
+
const aesKeyBase64 = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa="; // 32 bytes when decoded + padding
|
|
20
|
+
const aesKey = Buffer.from(aesKeyBase64 + "=", "base64");
|
|
21
|
+
const iv = aesKey.subarray(0, 16);
|
|
22
|
+
|
|
23
|
+
const originalData = Buffer.from("Hello WeCom Image Data", "utf8");
|
|
24
|
+
|
|
25
|
+
// 2. Encrypt manually (AES-256-CBC + PKCS7)
|
|
26
|
+
const padded = pkcs7Pad(originalData, WECOM_PKCS7_BLOCK_SIZE);
|
|
27
|
+
const cipher = crypto.createCipheriv("aes-256-cbc", aesKey, iv);
|
|
28
|
+
cipher.setAutoPadding(false);
|
|
29
|
+
const encrypted = Buffer.concat([cipher.update(padded), cipher.final()]);
|
|
30
|
+
|
|
31
|
+
// 3. Mock Axios
|
|
32
|
+
(axios.get as any).mockResolvedValue({
|
|
33
|
+
data: encrypted,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 4. Test
|
|
37
|
+
const decrypted = await decryptWecomMedia("http://mock.url/image", aesKeyBase64);
|
|
38
|
+
|
|
39
|
+
// 5. Assert
|
|
40
|
+
expect(decrypted.toString("utf8")).toBe("Hello WeCom Image Data");
|
|
41
|
+
expect(axios.get).toHaveBeenCalledWith("http://mock.url/image", expect.objectContaining({
|
|
42
|
+
responseType: "arraybuffer"
|
|
43
|
+
}));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should fail if key is invalid", async () => {
|
|
47
|
+
await expect(decryptWecomMedia("http://url", "invalid-key")).rejects.toThrow();
|
|
48
|
+
});
|
|
49
|
+
});
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import { decodeEncodingAESKey, pkcs7Unpad, WECOM_PKCS7_BLOCK_SIZE } from "./crypto.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Download and decrypt WeCom media file (e.g. image).
|
|
7
|
+
*
|
|
8
|
+
* WeCom media files are AES-256-CBC encrypted with the same EncodingAESKey.
|
|
9
|
+
* The IV is the first 16 bytes of the AES Key.
|
|
10
|
+
* The content is PKCS#7 padded.
|
|
11
|
+
*/
|
|
12
|
+
export async function decryptWecomMedia(url: string, encodingAESKey: string, maxBytes?: number): Promise<Buffer> {
|
|
13
|
+
// 1. Download encrypted content
|
|
14
|
+
const response = await axios.get(url, {
|
|
15
|
+
responseType: "arraybuffer", // Important: get raw buffer
|
|
16
|
+
timeout: 15000,
|
|
17
|
+
maxContentLength: maxBytes || undefined, // Limit download size
|
|
18
|
+
maxBodyLength: maxBytes || undefined,
|
|
19
|
+
});
|
|
20
|
+
const encryptedData = Buffer.from(response.data);
|
|
21
|
+
|
|
22
|
+
// 2. Prepare Key and IV
|
|
23
|
+
const aesKey = decodeEncodingAESKey(encodingAESKey);
|
|
24
|
+
const iv = aesKey.subarray(0, 16);
|
|
25
|
+
|
|
26
|
+
// 3. Decrypt
|
|
27
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
28
|
+
decipher.setAutoPadding(false); // We handle padding manually
|
|
29
|
+
const decryptedPadded = Buffer.concat([
|
|
30
|
+
decipher.update(encryptedData),
|
|
31
|
+
decipher.final(),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
// 4. Unpad
|
|
35
|
+
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
|
|
36
|
+
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
|
|
37
|
+
// Our pkcs7Unpad function does exactly this + validation.
|
|
38
|
+
return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
|
|
39
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { sendActiveMessage, handleWecomWebhookRequest, registerWecomWebhookTarget } from "./monitor.js";
|
|
3
|
+
import * as cryptoHelpers from "./crypto.js";
|
|
4
|
+
import * as runtime from "./runtime.js";
|
|
5
|
+
import axios from "axios";
|
|
6
|
+
import { IncomingMessage, ServerResponse } from "node:http";
|
|
7
|
+
import { Socket } from "node:net";
|
|
8
|
+
import * as crypto from "node:crypto";
|
|
9
|
+
|
|
10
|
+
vi.mock("axios");
|
|
11
|
+
|
|
12
|
+
// Helpers
|
|
13
|
+
function createMockRequest(bodyObj: any): IncomingMessage {
|
|
14
|
+
const socket = new Socket();
|
|
15
|
+
const req = new IncomingMessage(socket);
|
|
16
|
+
req.method = "POST";
|
|
17
|
+
req.url = "/wecom?timestamp=123&nonce=456&signature=789";
|
|
18
|
+
req.push(JSON.stringify(bodyObj));
|
|
19
|
+
req.push(null);
|
|
20
|
+
return req;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createMockResponse(): ServerResponse {
|
|
24
|
+
const req = new IncomingMessage(new Socket());
|
|
25
|
+
const res = new ServerResponse(req);
|
|
26
|
+
res.end = vi.fn() as any;
|
|
27
|
+
res.setHeader = vi.fn();
|
|
28
|
+
(res as any).statusCode = 200;
|
|
29
|
+
return res;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Monitor Active Features", () => {
|
|
33
|
+
let capturedDeliver: ((payload: { text: string }) => Promise<void>) | undefined;
|
|
34
|
+
let mockCore: any;
|
|
35
|
+
// Valid 32-byte AES Key (Base64 encoded)
|
|
36
|
+
const validKey = "jWmYm7qr5nMoCAstdRmNjt3p7vsH8HkK+qiJqQ0aaaa=";
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
capturedDeliver = undefined;
|
|
40
|
+
vi.restoreAllMocks();
|
|
41
|
+
|
|
42
|
+
// Spy on crypto.randomBytes (default export in monitor.ts usage)
|
|
43
|
+
vi.spyOn(crypto.default, "randomBytes").mockImplementation((size) => {
|
|
44
|
+
return Buffer.alloc(size, 0x11);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Mock Crypto Helpers
|
|
48
|
+
// Wespy on verifyWecomSignature to always pass
|
|
49
|
+
vi.spyOn(cryptoHelpers, "verifyWecomSignature").mockReturnValue(true);
|
|
50
|
+
|
|
51
|
+
// We spy on decryptWecomEncrypted to return our mock plaintext
|
|
52
|
+
// Note: For this to work despite direct import in monitor.ts, we rely on Vitest's
|
|
53
|
+
// module mocking capabilities or the fact that * exports might be live bindings.
|
|
54
|
+
// If this fails, we will know.
|
|
55
|
+
vi.spyOn(cryptoHelpers, "decryptWecomEncrypted").mockImplementation((opts) => {
|
|
56
|
+
return JSON.stringify({
|
|
57
|
+
msgid: "test-msg-id",
|
|
58
|
+
msgtype: "text",
|
|
59
|
+
text: { content: "hello" },
|
|
60
|
+
response_url: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key"
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
mockCore = {
|
|
65
|
+
channel: {
|
|
66
|
+
text: {
|
|
67
|
+
resolveMarkdownTableMode: () => "off",
|
|
68
|
+
convertMarkdownTables: (t: string) => t.replace(/\|/g, "-")
|
|
69
|
+
},
|
|
70
|
+
reply: {
|
|
71
|
+
finalizeInboundContext: (c: any) => c,
|
|
72
|
+
resolveEnvelopeFormatOptions: () => ({}),
|
|
73
|
+
formatAgentEnvelope: () => "",
|
|
74
|
+
dispatchReplyWithBufferedBlockDispatcher: async (opts: any) => {
|
|
75
|
+
capturedDeliver = opts.dispatcherOptions.deliver;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
routing: { resolveAgentRoute: () => ({ agentId: "1", sessionKey: "1", accountId: "1" }) },
|
|
80
|
+
session: {
|
|
81
|
+
resolveStorePath: () => "",
|
|
82
|
+
readSessionUpdatedAt: () => 0,
|
|
83
|
+
recordInboundSession: vi.fn()
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
logging: { shouldLogVerbose: () => false }
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
vi.spyOn(runtime, "getWecomRuntime").mockReturnValue(mockCore);
|
|
90
|
+
|
|
91
|
+
registerWecomWebhookTarget({
|
|
92
|
+
account: { accountId: "1", enabled: true, configured: true, token: "T", encodingAESKey: validKey, receiveId: "R", config: {} as any },
|
|
93
|
+
config: {} as any,
|
|
94
|
+
runtime: { log: () => { } },
|
|
95
|
+
core: mockCore,
|
|
96
|
+
path: "/wecom"
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should protect <think> tags from table conversion", async () => {
|
|
101
|
+
const req = createMockRequest({ encrypt: "mock-encrypt" });
|
|
102
|
+
const res = createMockResponse();
|
|
103
|
+
await handleWecomWebhookRequest(req, res);
|
|
104
|
+
|
|
105
|
+
expect(capturedDeliver).toBeDefined();
|
|
106
|
+
|
|
107
|
+
const payload = { text: "Out | side\n<think>Inside | Think</think>" };
|
|
108
|
+
const convertSpy = vi.spyOn(mockCore.channel.text, "convertMarkdownTables");
|
|
109
|
+
|
|
110
|
+
await capturedDeliver!(payload);
|
|
111
|
+
|
|
112
|
+
const calledArg = convertSpy.mock.calls[0][0];
|
|
113
|
+
expect(calledArg).toContain("__THINK_PLACEHOLDER_0__");
|
|
114
|
+
expect(calledArg).not.toContain("<think>");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("should store response_url and allow active message sending", async () => {
|
|
118
|
+
const req = createMockRequest({ encrypt: "mock-encrypt" });
|
|
119
|
+
const res = createMockResponse();
|
|
120
|
+
|
|
121
|
+
// We use a real key but mocked randomBytes.
|
|
122
|
+
// However, `handleWecomWebhookRequest` calls `buildEncryptedJsonReply` -> `encryptWecomPlaintext`.
|
|
123
|
+
// `encryptWecomPlaintext` uses the key. Since it's valid, it should work fine.
|
|
124
|
+
// We don't verify the OUTPUT of handleWecomWebhookRequest, just that it runs and sets up state.
|
|
125
|
+
|
|
126
|
+
await handleWecomWebhookRequest(req, res);
|
|
127
|
+
|
|
128
|
+
const streamId = Buffer.alloc(16, 0x11).toString("hex");
|
|
129
|
+
|
|
130
|
+
await sendActiveMessage(streamId, "Active Hello");
|
|
131
|
+
|
|
132
|
+
expect(axios.post).toHaveBeenCalledWith(
|
|
133
|
+
"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test-key",
|
|
134
|
+
{ msgtype: "text", text: { content: "Active Hello" } }
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|