@yanhaidao/wecom 1.0.1 → 2.0.0
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 +37 -17
- package/index.ts +4 -4
- package/{clawdbot.plugin.json → openclaw.plugin.json} +1 -0
- package/package.json +6 -6
- 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 +37 -0
- package/src/monitor.active.test.ts +137 -0
- package/src/monitor.integration.test.ts +171 -0
- package/src/monitor.ts +153 -32
- package/src/monitor.webhook.test.ts +162 -94
- package/src/runtime.ts +1 -2
- package/src/types.ts +19 -3
- 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
|
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
### 从 npm 安装
|
|
16
16
|
```bash
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
openclaw plugins install @yanhaidao/wecom
|
|
18
|
+
openclaw plugins enable wecom
|
|
19
|
+
openclaw gateway restart
|
|
20
20
|
```
|
|
21
21
|
|
|
22
22
|
|
|
@@ -30,7 +30,9 @@ clawdbot gateway restart
|
|
|
30
30
|
webhookPath: "/wecom",
|
|
31
31
|
token: "YOUR_TOKEN",
|
|
32
32
|
encodingAESKey: "YOUR_ENCODING_AES_KEY",
|
|
33
|
-
receiveId: "
|
|
33
|
+
receiveId: "",
|
|
34
|
+
// stream 模式第一次回包占位符(默认 "正在思考...")
|
|
35
|
+
streamPlaceholderContent: "正在思考...",
|
|
34
36
|
dm: { policy: "pairing" }
|
|
35
37
|
}
|
|
36
38
|
}
|
|
@@ -48,7 +50,7 @@ clawdbot gateway restart
|
|
|
48
50
|
创建机器人时需要填写回调 URL(公网可访问的 HTTPS 地址),例如:`https://example.com/wecom`
|
|
49
51
|
|
|
50
52
|
3. 记录机器人配置
|
|
51
|
-
在机器人详情里找到并保存以下信息,后续会写入
|
|
53
|
+
在机器人详情里找到并保存以下信息,后续会写入 OpenClaw 配置:
|
|
52
54
|
- Token
|
|
53
55
|
- EncodingAESKey
|
|
54
56
|
- ReceiveId(如果你的机器人/回调配置需要校验的话)
|
|
@@ -57,33 +59,33 @@ clawdbot gateway restart
|
|
|
57
59
|
|
|
58
60
|
1. 启用插件
|
|
59
61
|
```bash
|
|
60
|
-
|
|
62
|
+
openclaw plugins enable wecom
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
2. 配置企业微信机器人(必需)
|
|
64
66
|
```bash
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
67
|
+
openclaw config set channels.wecom.enabled true
|
|
68
|
+
openclaw config set channels.wecom.webhookPath "/wecom"
|
|
69
|
+
openclaw config set channels.wecom.token "YOUR_TOKEN"
|
|
70
|
+
openclaw config set channels.wecom.encodingAESKey "YOUR_ENCODING_AES_KEY"
|
|
71
|
+
openclaw config set channels.wecom.receiveId ""
|
|
70
72
|
```
|
|
71
73
|
|
|
72
74
|
3. 配置 Gateway(示例)
|
|
73
75
|
```bash
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
openclaw config set gateway.mode "local"
|
|
77
|
+
openclaw config set gateway.bind "0.0.0.0"
|
|
78
|
+
openclaw config set gateway.port 18789
|
|
77
79
|
```
|
|
78
80
|
|
|
79
81
|
4. 重启 Gateway
|
|
80
82
|
```bash
|
|
81
|
-
|
|
83
|
+
openclaw gateway restart
|
|
82
84
|
```
|
|
83
85
|
|
|
84
86
|
5. 验证
|
|
85
87
|
```bash
|
|
86
|
-
|
|
88
|
+
openclaw channels status
|
|
87
89
|
```
|
|
88
90
|
|
|
89
91
|
## 说明
|
|
@@ -91,3 +93,21 @@ clawdbot channels status
|
|
|
91
93
|
- webhook 必须是公网 HTTPS。出于安全考虑,建议只对外暴露 `/wecom` 路径。
|
|
92
94
|
- stream 模式:第一次回包可能是占位符;随后 WeCom 会以 `msgtype=stream` 回调刷新拉取完整内容。
|
|
93
95
|
- 限制:仅支持被动回复,不支持脱离回调的主动发送。
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# 更新日志
|
|
100
|
+
|
|
101
|
+
## 2026.1.30
|
|
102
|
+
|
|
103
|
+
### 重大变更
|
|
104
|
+
|
|
105
|
+
1. **项目更名**:Clawdbot 正式更名为 **OpenClaw**。CLI 命令由 `clawdbot` 变更为 `openclaw`。请更新您的安装脚本和文档引用。
|
|
106
|
+
|
|
107
|
+
### 企业微信插件改进计划
|
|
108
|
+
|
|
109
|
+
1. 引用回复纳入上下文:AI 将同时理解你引用的那条消息;文本原文直传,图片/文件/语音等以 `[引用: 类型] URL` 形式提供上下文线索。
|
|
110
|
+
2. `<think>...</think>` 原样透传:不做过滤、转义或重排,确保支持该特性的企业微信客户端可稳定展示思考态 UI。
|
|
111
|
+
3. 流式回复稳定性加固:减少空刷新、超时与“卡住不回”;异常时返回可见错误摘要而非无期限等待。
|
|
112
|
+
4. 交付可验收:围绕入站解析、stream 状态与回包链路增强可观测性,方便客户侧定位问题并验证效果。
|
|
113
|
+
5. 下一阶段(可选):补齐图片闭环(加密图片解密入模 + 原生 stream `msg_item` 图片回传),实现图文对话体验。
|
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.0",
|
|
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,37 @@
|
|
|
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): 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
|
+
});
|
|
18
|
+
const encryptedData = Buffer.from(response.data);
|
|
19
|
+
|
|
20
|
+
// 2. Prepare Key and IV
|
|
21
|
+
const aesKey = decodeEncodingAESKey(encodingAESKey);
|
|
22
|
+
const iv = aesKey.subarray(0, 16);
|
|
23
|
+
|
|
24
|
+
// 3. Decrypt
|
|
25
|
+
const decipher = crypto.createDecipheriv("aes-256-cbc", aesKey, iv);
|
|
26
|
+
decipher.setAutoPadding(false); // We handle padding manually
|
|
27
|
+
const decryptedPadded = Buffer.concat([
|
|
28
|
+
decipher.update(encryptedData),
|
|
29
|
+
decipher.final(),
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
// 4. Unpad
|
|
33
|
+
// Note: Unlike msg bodies, usually removing PKCS#7 padding is enough for media files.
|
|
34
|
+
// The Python SDK logic: pad_len = decrypted_data[-1]; decrypted_data = decrypted_data[:-pad_len]
|
|
35
|
+
// Our pkcs7Unpad function does exactly this + validation.
|
|
36
|
+
return pkcs7Unpad(decryptedPadded, WECOM_PKCS7_BLOCK_SIZE);
|
|
37
|
+
}
|
|
@@ -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
|
+
});
|