@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 CHANGED
@@ -1,4 +1,4 @@
1
- # Clawdbot 企业微信(WeCom)Channel 插件
1
+ # OpenClaw 企业微信(WeCom)Channel 插件
2
2
 
3
3
  维护者:YanHaidao(VX:YanHaidao)
4
4
 
@@ -10,28 +10,44 @@
10
10
 
11
11
  ![企业微信交流群](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/link-me.jpg)
12
12
 
13
+ ## 文件与图片入模(说明)
14
+
15
+ 图片/文件 URL 下载内容为加密数据,需使用 `EncodingAESKey` 解密后再解析并入模。
16
+
17
+ ## 测试页截图(文件上传 / 解析)
18
+
19
+ > 图片过大可替换为压缩版(保持文件名不变即可)。
20
+
21
+ ![WeCom 测试页截图(文件上传 / 解析)](https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg)
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
- clawdbot plugins install @yanhaidao/wecom
18
- clawdbot plugins enable wecom
19
- clawdbot gateway restart
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
- ```json5
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: "YOUR_RECEIVE_ID",
34
- dm: { policy: "pairing" }
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
- 在机器人详情里找到并保存以下信息,后续会写入 Clawdbot 配置:
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
- clawdbot plugins enable wecom
76
+ openclaw plugins enable wecom
61
77
  ```
62
78
 
63
79
  2. 配置企业微信机器人(必需)
64
80
  ```bash
65
- clawdbot config set channels.wecom.enabled true
66
- clawdbot config set channels.wecom.webhookPath "/wecom"
67
- clawdbot config set channels.wecom.token "YOUR_TOKEN"
68
- clawdbot config set channels.wecom.encodingAESKey "YOUR_ENCODING_AES_KEY"
69
- clawdbot config set channels.wecom.receiveId ""
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
- clawdbot config set gateway.mode "local"
75
- clawdbot config set gateway.bind "0.0.0.0"
76
- clawdbot config set gateway.port 18789
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
- clawdbot gateway restart
97
+ openclaw gateway restart
82
98
  ```
83
99
 
84
100
  5. 验证
85
101
  ```bash
86
- clawdbot channels status
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
Binary file
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Author: YanHaidao
3
3
  */
4
- import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
5
- import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
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: "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin",
14
+ description: "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
15
15
  configSchema: emptyPluginConfigSchema(),
16
- register(api: ClawdbotPluginApi) {
16
+ register(api: OpenClawPluginApi) {
17
17
  setWecomRuntime(api.runtime);
18
18
  api.registerChannel({ plugin: wecomPlugin });
19
19
  api.registerHttpHandler(handleWecomWebhookRequest);
@@ -7,3 +7,4 @@
7
7
  "properties": {}
8
8
  }
9
9
  }
10
+
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "1.0.1",
3
+ "version": "2.0.1",
4
4
  "type": "module",
5
- "description": "Clawdbot WeCom (WeChat Work) intelligent bot channel plugin",
5
+ "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
7
- "clawdbot": {
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
- "clawdbot": ">=2026.1.24"
39
+ "openclaw": ">=2026.1.26"
40
40
  }
41
41
  }
package/src/accounts.ts CHANGED
@@ -1,21 +1,21 @@
1
- import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
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: ClawdbotConfig): string[] {
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: ClawdbotConfig): string[] {
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: ClawdbotConfig): string {
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: ClawdbotConfig,
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: ClawdbotConfig, accountId: string): WecomAccountConfig {
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: ClawdbotConfig;
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: ClawdbotConfig): ResolvedWecomAccount[] {
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
- ClawdbotConfig,
5
- } from "clawdbot/plugin-sdk";
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 "clawdbot/plugin-sdk";
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 ClawdbotConfig),
53
- resolveAccount: (cfg, accountId) => resolveWecomAccount({ cfg: cfg as ClawdbotConfig, accountId }),
54
- defaultAccountId: (cfg) => resolveDefaultWecomAccountId(cfg as ClawdbotConfig),
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 ClawdbotConfig,
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 ClawdbotConfig,
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 ClawdbotConfig, accountId });
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 ClawdbotConfig).channels?.wecom?.accounts?.[resolvedAccountId]);
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 ClawdbotConfig,
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.
@@ -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
+ });