@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 CHANGED
@@ -1,4 +1,4 @@
1
- # Clawdbot 企业微信(WeCom)Channel 插件
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
- clawdbot plugins install @yanhaidao/wecom
18
- clawdbot plugins enable wecom
19
- clawdbot gateway restart
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: "YOUR_RECEIVE_ID",
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
- 在机器人详情里找到并保存以下信息,后续会写入 Clawdbot 配置:
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
- clawdbot plugins enable wecom
62
+ openclaw plugins enable wecom
61
63
  ```
62
64
 
63
65
  2. 配置企业微信机器人(必需)
64
66
  ```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 ""
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
- clawdbot config set gateway.mode "local"
75
- clawdbot config set gateway.bind "0.0.0.0"
76
- clawdbot config set gateway.port 18789
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
- clawdbot gateway restart
83
+ openclaw gateway restart
82
84
  ```
83
85
 
84
86
  5. 验证
85
87
  ```bash
86
- clawdbot channels status
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 { 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.0",
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,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
+ });