@yanhaidao/wecom 2.2.4 → 2.2.5
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 +29 -19
- package/assets/link-me.jpg +0 -0
- package/package.json +1 -1
- package/scripts/test-proxy.ts +70 -0
- package/src/agent/api-client.ts +54 -5
- package/src/agent/handler.ts +188 -23
- package/src/channel.ts +1 -1
- package/src/config/index.ts +1 -0
- package/src/config/media.ts +14 -0
- package/src/monitor/state.queue.test.ts +185 -0
- package/src/monitor/state.ts +179 -19
- package/src/monitor/types.ts +8 -0
- package/src/monitor.ts +206 -58
- package/src/monitor.webhook.test.ts +83 -1
- package/src/outbound.test.ts +43 -0
- package/src/outbound.ts +31 -2
- package/src/shared/command-auth.ts +101 -0
- package/src/shared/xml-parser.test.ts +30 -0
- package/src/shared/xml-parser.ts +105 -7
- package/src/target.ts +1 -1
- package/src/types/message.ts +2 -0
- package/GEMINI.md +0 -76
package/README.md
CHANGED
|
@@ -44,23 +44,16 @@
|
|
|
44
44
|
|
|
45
45
|
---
|
|
46
46
|
|
|
47
|
-
<div align="center">
|
|
48
|
-
<img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/01.image.jpg" width="45%" />
|
|
49
|
-
<img src="https://cdn.jsdelivr.net/npm/@yanhaidao/wecom@latest/assets/02.image.jpg" width="45%" />
|
|
50
|
-
</div>
|
|
51
|
-
|
|
52
|
-
|
|
53
47
|
|
|
54
48
|
## 📊 模式能力对比
|
|
55
49
|
|
|
56
50
|
| 能力维度 | 🤖 Bot 模式 | 🧩 Agent 模式 | ✨ **本插件 (双模)** |
|
|
57
|
-
|
|
58
|
-
|
|
|
59
|
-
|
|
|
60
|
-
|
|
|
61
|
-
|
|
|
62
|
-
|
|
|
63
|
-
| **群聊支持** | ✅ @即回 | ⚠️ 仅自建群 | **✅ 混合支持** |
|
|
51
|
+
|:---|:---|:---|:---|
|
|
52
|
+
| **接收消息 (单聊)** | ✅ 文本/图片/语音/文件 | ✅ 文本/图片/语音/视频/位置/链接 | **✅ 全能互补** (覆盖所有类型) |
|
|
53
|
+
| **接收消息 (群聊)** | ✅ 文本/引用 | ❌ 不支持 (无回调) | **✅ 文本/引用** |
|
|
54
|
+
| **发送消息** | ❌ 仅支持文本/图片/Markdown | ✅ **全格式支持** (文本/图片/视频/文件等) | **✅ 智能路由** (自动切换) |
|
|
55
|
+
| **流式响应** | ✅ **支持** (打字机效果) | ❌ 不支持 | **✅ 完美支持** |
|
|
56
|
+
| **主动推送** | ❌ 仅被动回复 | ✅ **支持** (指定用户/部门/标签) | **✅ 完整 API** |
|
|
64
57
|
|
|
65
58
|
---
|
|
66
59
|
|
|
@@ -89,8 +82,13 @@ openclaw config set channels.wecom.bot.receiveId ""
|
|
|
89
82
|
openclaw config set channels.wecom.bot.streamPlaceholderContent "正在思考..."
|
|
90
83
|
openclaw config set channels.wecom.bot.welcomeText "你好!我是 AI 助手"
|
|
91
84
|
|
|
92
|
-
#
|
|
93
|
-
|
|
85
|
+
# DM 门禁(推荐显式设置 policy)
|
|
86
|
+
# - open: 默认放开(所有人可用)
|
|
87
|
+
# - disabled: 全部禁用
|
|
88
|
+
# - allowlist: 仅 allowFrom 允许的人可用
|
|
89
|
+
openclaw config set channels.wecom.bot.dm.policy "open"
|
|
90
|
+
# policy=allowlist 时生效(例如只允许某些 userid;"*" 表示允许所有人)
|
|
91
|
+
openclaw config set channels.wecom.bot.dm.allowFrom '["*"]'
|
|
94
92
|
```
|
|
95
93
|
|
|
96
94
|
### 3. 配置 Agent 模式(自建应用,可选)
|
|
@@ -103,8 +101,8 @@ openclaw config set channels.wecom.agent.agentId 1000001
|
|
|
103
101
|
openclaw config set channels.wecom.agent.token "YOUR_CALLBACK_TOKEN"
|
|
104
102
|
openclaw config set channels.wecom.agent.encodingAESKey "YOUR_CALLBACK_AES_KEY"
|
|
105
103
|
openclaw config set channels.wecom.agent.welcomeText "欢迎使用智能助手"
|
|
106
|
-
|
|
107
|
-
openclaw config set channels.wecom.agent.dm.allowFrom '[]'
|
|
104
|
+
openclaw config set channels.wecom.agent.dm.policy "open"
|
|
105
|
+
openclaw config set channels.wecom.agent.dm.allowFrom '["*"]'
|
|
108
106
|
```
|
|
109
107
|
|
|
110
108
|
### 4. 高级网络配置 (公网出口代理)
|
|
@@ -317,9 +315,15 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
317
315
|
**Q4: 群里 @机器人 发送文件失败?**
|
|
318
316
|
> **A:** 因为企业微信 Bot 接口本身不支持发送非图片文件。我们的解决方案是:自动检测到文件发送需求后,改为通过 Agent 私信该用户发送文件,并在群里给出 "文件已私信发给您" 的提示。
|
|
319
317
|
|
|
320
|
-
**Q5:
|
|
318
|
+
**Q5: 为什么在 Agent 模式下发送文件(如 PDF、Word)给机器人没有反应?**
|
|
319
|
+
> **A:** 这是由于企业微信官方接口限制。自建应用(Agent)的消息回调接口仅支持:文本、图片、语音、视频、位置和链接信息。**不支持**通用文件(File)类型的回调,因此插件无法感知您发送的文件。
|
|
320
|
+
|
|
321
|
+
**Q6: Cronjob 定时任务怎么发给群?**
|
|
321
322
|
> **A:** Cronjob 必须走 Agent 通道(Bot 无法主动发消息)。您只需在配置中指定 `to: "party:1"` (部门) 或 `to: "group:wr123..."` (外部群),即可实现定时推送到群。
|
|
322
323
|
|
|
324
|
+
**Q7: 为什么发视频给 Bot 没反应?**
|
|
325
|
+
> **A:** 官方 Bot 接口**不支持接收视频**。如果您需要处理视频内容,请配置并使用 Agent 模式(Agent 支持接收视频)。
|
|
326
|
+
|
|
323
327
|
---
|
|
324
328
|
|
|
325
329
|
## 联系我
|
|
@@ -334,12 +338,18 @@ Agent 输出 `{"template_card": ...}` 时自动渲染为交互卡片:
|
|
|
334
338
|
|
|
335
339
|
## 更新日志
|
|
336
340
|
|
|
341
|
+
### 2026.2.5
|
|
342
|
+
|
|
343
|
+
- 🛠 **体验优化**:WeCom 媒体(图片/语音/视频/文件)处理的默认大小上限提升到 25MB,减少大文件因超限导致的“下载/保存失败”。
|
|
344
|
+
- 📌 **可配置提示**:若仍遇到 Media exceeds ... limit,日志/回复会提示通过 channels.wecom.media.maxBytes 调整上限,并给出可直接执行的 openclaw config set 示例命令。
|
|
345
|
+
|
|
337
346
|
### 2026.2.4
|
|
338
347
|
|
|
339
348
|
- 🚀 **架构升级**:实施 "Bot 优先 + Agent 兜底" 策略,兼顾流式体验与长任务稳定性(6分钟切换)。
|
|
340
|
-
- ✨ **全模态支持**:Agent
|
|
349
|
+
- ✨ **全模态支持**:Agent 模式完整支持接收图片/语音/视频(文件仅支持发送)。
|
|
341
350
|
- ✨ **Cronjob 增强**:支持向部门 (`party:ID`) 和标签 (`tag:ID`) 广播消息。
|
|
342
351
|
- 🛠 **Monitor 重构**:统一的消息防抖与流状态管理,提升并发稳定性。
|
|
352
|
+
- 🛠 **体验优化**:修复企微重试导致的重复回复(Bot/Agent 均做 `msgid` 去重);优化 Bot 连续多条消息的排队/合并回执,避免“重复同一答案”或“消息失败提示”。
|
|
343
353
|
- 🐞 **修复**:Outbound ID 解析逻辑及 API 客户端参数缺失问题。
|
|
344
354
|
|
|
345
355
|
### 2026.2.3
|
package/assets/link-me.jpg
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { wecomFetch } from "../src/http.js";
|
|
2
|
+
|
|
3
|
+
const args = process.argv.slice(2);
|
|
4
|
+
let proxyUrl = "";
|
|
5
|
+
|
|
6
|
+
if (args.includes("--host")) {
|
|
7
|
+
const hostIndex = args.indexOf("--host");
|
|
8
|
+
const portIndex = args.indexOf("--port");
|
|
9
|
+
const userIndex = args.indexOf("--user");
|
|
10
|
+
const passIndex = args.indexOf("--pass");
|
|
11
|
+
|
|
12
|
+
if (hostIndex === -1 || portIndex === -1) {
|
|
13
|
+
console.error("Error: --host and --port are required when using specific params.");
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const host = args[hostIndex + 1];
|
|
18
|
+
const port = args[portIndex + 1];
|
|
19
|
+
const user = userIndex !== -1 ? args[userIndex + 1] : "";
|
|
20
|
+
const pass = passIndex !== -1 ? args[passIndex + 1] : "";
|
|
21
|
+
|
|
22
|
+
if (user && pass) {
|
|
23
|
+
// Safe encoding
|
|
24
|
+
proxyUrl = `http://${encodeURIComponent(user)}:${encodeURIComponent(pass)}@${host}:${port}`;
|
|
25
|
+
} else {
|
|
26
|
+
proxyUrl = `http://${host}:${port}`;
|
|
27
|
+
}
|
|
28
|
+
} else {
|
|
29
|
+
proxyUrl = args[0] || process.env.PROXY_URL || "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!proxyUrl) {
|
|
33
|
+
console.error("Usage: npx tsx extensions/wecom/scripts/test-proxy.ts <proxy_url>");
|
|
34
|
+
console.error(" OR: npx tsx extensions/wecom/scripts/test-proxy.ts --host <ip> --port <port> --user <u?> --pass <p?>");
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(`Testing proxy: ${proxyUrl.replace(/:([^:@]+)@/, ":***@")}`); // Mask password in log
|
|
39
|
+
|
|
40
|
+
async function run() {
|
|
41
|
+
try {
|
|
42
|
+
// 1. Test IP echo to verify traffic goes through proxy
|
|
43
|
+
console.log("1. Checking IP via httpbin.org...");
|
|
44
|
+
const ipRes = await wecomFetch("https://httpbin.org/ip", {}, { proxyUrl, timeoutMs: 10000 });
|
|
45
|
+
if (!ipRes.ok) {
|
|
46
|
+
throw new Error(`IP check failed: ${ipRes.status} ${ipRes.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
const ipJson = await ipRes.json();
|
|
49
|
+
console.log(" Result:", ipJson);
|
|
50
|
+
|
|
51
|
+
// 2. Test WeCom API connectivity
|
|
52
|
+
console.log("2. Checking WeCom connectivity...");
|
|
53
|
+
const wecomRes = await wecomFetch("https://qyapi.weixin.qq.com/cgi-bin/gettoken", {}, { proxyUrl, timeoutMs: 10000 });
|
|
54
|
+
const wecomJson = await wecomRes.json();
|
|
55
|
+
console.log(" Result:", wecomJson);
|
|
56
|
+
console.log("✅ Proxy works!");
|
|
57
|
+
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Extract cause for better debugging
|
|
60
|
+
const cause = (err as any).cause;
|
|
61
|
+
if (cause) {
|
|
62
|
+
console.error("❌ Proxy test failed (Cause):", cause);
|
|
63
|
+
} else {
|
|
64
|
+
console.error("❌ Proxy test failed:", err);
|
|
65
|
+
}
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
run();
|
package/src/agent/api-client.ts
CHANGED
|
@@ -118,11 +118,26 @@ export async function sendText(params: {
|
|
|
118
118
|
headers: { "Content-Type": "application/json" },
|
|
119
119
|
body: JSON.stringify(body),
|
|
120
120
|
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
121
|
-
const json = await res.json() as {
|
|
121
|
+
const json = await res.json() as {
|
|
122
|
+
errcode?: number;
|
|
123
|
+
errmsg?: string;
|
|
124
|
+
invaliduser?: string;
|
|
125
|
+
invalidparty?: string;
|
|
126
|
+
invalidtag?: string;
|
|
127
|
+
};
|
|
122
128
|
|
|
123
129
|
if (json?.errcode !== 0) {
|
|
124
130
|
throw new Error(`send failed: ${json?.errcode} ${json?.errmsg}`);
|
|
125
131
|
}
|
|
132
|
+
|
|
133
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
134
|
+
const details = [
|
|
135
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
136
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
137
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
|
|
138
|
+
].filter(Boolean).join(", ");
|
|
139
|
+
throw new Error(`send partial failure: ${details}`);
|
|
140
|
+
}
|
|
126
141
|
}
|
|
127
142
|
|
|
128
143
|
/**
|
|
@@ -246,11 +261,26 @@ export async function sendMedia(params: {
|
|
|
246
261
|
headers: { "Content-Type": "application/json" },
|
|
247
262
|
body: JSON.stringify(body),
|
|
248
263
|
}, { proxyUrl: resolveWecomEgressProxyUrlFromNetwork(agent.network), timeoutMs: LIMITS.REQUEST_TIMEOUT_MS });
|
|
249
|
-
const json = await res.json() as {
|
|
264
|
+
const json = await res.json() as {
|
|
265
|
+
errcode?: number;
|
|
266
|
+
errmsg?: string;
|
|
267
|
+
invaliduser?: string;
|
|
268
|
+
invalidparty?: string;
|
|
269
|
+
invalidtag?: string;
|
|
270
|
+
};
|
|
250
271
|
|
|
251
272
|
if (json?.errcode !== 0) {
|
|
252
273
|
throw new Error(`send ${mediaType} failed: ${json?.errcode} ${json?.errmsg}`);
|
|
253
274
|
}
|
|
275
|
+
|
|
276
|
+
if (json?.invaliduser || json?.invalidparty || json?.invalidtag) {
|
|
277
|
+
const details = [
|
|
278
|
+
json.invaliduser ? `invaliduser=${json.invaliduser}` : "",
|
|
279
|
+
json.invalidparty ? `invalidparty=${json.invalidparty}` : "",
|
|
280
|
+
json.invalidtag ? `invalidtag=${json.invalidtag}` : ""
|
|
281
|
+
].filter(Boolean).join(", ");
|
|
282
|
+
throw new Error(`send ${mediaType} partial failure: ${details}`);
|
|
283
|
+
}
|
|
254
284
|
}
|
|
255
285
|
|
|
256
286
|
/**
|
|
@@ -263,7 +293,8 @@ export async function sendMedia(params: {
|
|
|
263
293
|
export async function downloadMedia(params: {
|
|
264
294
|
agent: ResolvedAgentAccount;
|
|
265
295
|
mediaId: string;
|
|
266
|
-
|
|
296
|
+
maxBytes?: number;
|
|
297
|
+
}): Promise<{ buffer: Buffer; contentType: string; filename?: string }> {
|
|
267
298
|
const { agent, mediaId } = params;
|
|
268
299
|
const token = await getAccessToken(agent);
|
|
269
300
|
const url = `${API_ENDPOINTS.DOWNLOAD_MEDIA}?access_token=${encodeURIComponent(token)}&media_id=${encodeURIComponent(mediaId)}`;
|
|
@@ -275,6 +306,24 @@ export async function downloadMedia(params: {
|
|
|
275
306
|
}
|
|
276
307
|
|
|
277
308
|
const contentType = res.headers.get("content-type") || "application/octet-stream";
|
|
309
|
+
const disposition = res.headers.get("content-disposition") || "";
|
|
310
|
+
const filename = (() => {
|
|
311
|
+
// 兼容:filename="a.md" / filename=a.md / filename*=UTF-8''a%2Eb.md
|
|
312
|
+
const mStar = disposition.match(/filename\*\s*=\s*([^;]+)/i);
|
|
313
|
+
if (mStar) {
|
|
314
|
+
const raw = mStar[1]!.trim().replace(/^"(.*)"$/, "$1");
|
|
315
|
+
const parts = raw.split("''");
|
|
316
|
+
const encoded = parts.length === 2 ? parts[1]! : raw;
|
|
317
|
+
try {
|
|
318
|
+
return decodeURIComponent(encoded);
|
|
319
|
+
} catch {
|
|
320
|
+
return encoded;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const m = disposition.match(/filename\s*=\s*([^;]+)/i);
|
|
324
|
+
if (!m) return undefined;
|
|
325
|
+
return m[1]!.trim().replace(/^"(.*)"$/, "$1") || undefined;
|
|
326
|
+
})();
|
|
278
327
|
|
|
279
328
|
// 检查是否返回了错误 JSON
|
|
280
329
|
if (contentType.includes("application/json")) {
|
|
@@ -282,6 +331,6 @@ export async function downloadMedia(params: {
|
|
|
282
331
|
throw new Error(`download failed: ${json?.errcode} ${json?.errmsg}`);
|
|
283
332
|
}
|
|
284
333
|
|
|
285
|
-
const buffer = await readResponseBodyAsBuffer(res);
|
|
286
|
-
return { buffer, contentType };
|
|
334
|
+
const buffer = await readResponseBodyAsBuffer(res, params.maxBytes);
|
|
335
|
+
return { buffer, contentType, filename };
|
|
287
336
|
}
|
package/src/agent/handler.ts
CHANGED
|
@@ -4,20 +4,85 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { pathToFileURL } from "node:url";
|
|
7
|
+
import path from "node:path";
|
|
7
8
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
8
9
|
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
|
|
9
10
|
import type { ResolvedAgentAccount } from "../types/index.js";
|
|
10
11
|
import { LIMITS } from "../types/constants.js";
|
|
11
12
|
import { decryptWecomEncrypted, verifyWecomSignature, computeWecomMsgSignature, encryptWecomPlaintext } from "../crypto/index.js";
|
|
12
13
|
import { extractEncryptFromXml, buildEncryptedXmlResponse } from "../crypto/xml.js";
|
|
13
|
-
import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId } from "../shared/xml-parser.js";
|
|
14
|
+
import { parseXml, extractMsgType, extractFromUser, extractContent, extractChatId, extractMediaId, extractMsgId, extractFileName } from "../shared/xml-parser.js";
|
|
14
15
|
import { sendText, downloadMedia } from "./api-client.js";
|
|
15
16
|
import { getWecomRuntime } from "../runtime.js";
|
|
16
17
|
import type { WecomAgentInboundMessage } from "../types/index.js";
|
|
18
|
+
import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "../shared/command-auth.js";
|
|
19
|
+
import { resolveWecomMediaMaxBytes } from "../config/index.js";
|
|
17
20
|
|
|
18
21
|
/** 错误提示信息 */
|
|
19
22
|
const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
|
|
20
23
|
|
|
24
|
+
// Agent webhook 幂等去重池(防止企微回调重试导致重复回复)
|
|
25
|
+
// 注意:这是进程内内存去重,重启会清空;但足以覆盖企微的短周期重试。
|
|
26
|
+
const RECENT_MSGID_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
const recentAgentMsgIds = new Map<string, number>();
|
|
28
|
+
|
|
29
|
+
function rememberAgentMsgId(msgId: string): boolean {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
const existing = recentAgentMsgIds.get(msgId);
|
|
32
|
+
if (existing && now - existing < RECENT_MSGID_TTL_MS) return false;
|
|
33
|
+
recentAgentMsgIds.set(msgId, now);
|
|
34
|
+
// 简单清理:只在写入时做一次线性 prune,避免无界增长
|
|
35
|
+
for (const [k, ts] of recentAgentMsgIds) {
|
|
36
|
+
if (now - ts >= RECENT_MSGID_TTL_MS) recentAgentMsgIds.delete(k);
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function looksLikeTextFile(buffer: Buffer): boolean {
|
|
42
|
+
const sampleSize = Math.min(buffer.length, 4096);
|
|
43
|
+
if (sampleSize === 0) return true;
|
|
44
|
+
let bad = 0;
|
|
45
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
46
|
+
const b = buffer[i]!;
|
|
47
|
+
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d; // \t \n \r
|
|
48
|
+
const isPrintable = b >= 0x20 && b !== 0x7f;
|
|
49
|
+
if (!isWhitespace && !isPrintable) bad++;
|
|
50
|
+
}
|
|
51
|
+
// 非可打印字符占比太高,基本可判断为二进制
|
|
52
|
+
return bad / sampleSize <= 0.02;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function analyzeTextHeuristic(buffer: Buffer): { sampleSize: number; badCount: number; badRatio: number } {
|
|
56
|
+
const sampleSize = Math.min(buffer.length, 4096);
|
|
57
|
+
if (sampleSize === 0) return { sampleSize: 0, badCount: 0, badRatio: 0 };
|
|
58
|
+
let badCount = 0;
|
|
59
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
60
|
+
const b = buffer[i]!;
|
|
61
|
+
const isWhitespace = b === 0x09 || b === 0x0a || b === 0x0d;
|
|
62
|
+
const isPrintable = b >= 0x20 && b !== 0x7f;
|
|
63
|
+
if (!isWhitespace && !isPrintable) badCount++;
|
|
64
|
+
}
|
|
65
|
+
return { sampleSize, badCount, badRatio: badCount / sampleSize };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function previewHex(buffer: Buffer, maxBytes = 32): string {
|
|
69
|
+
const n = Math.min(buffer.length, maxBytes);
|
|
70
|
+
if (n <= 0) return "";
|
|
71
|
+
return buffer
|
|
72
|
+
.subarray(0, n)
|
|
73
|
+
.toString("hex")
|
|
74
|
+
.replace(/(..)/g, "$1 ")
|
|
75
|
+
.trim();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildTextFilePreview(buffer: Buffer, maxChars: number): string | undefined {
|
|
79
|
+
if (!looksLikeTextFile(buffer)) return undefined;
|
|
80
|
+
const text = buffer.toString("utf8");
|
|
81
|
+
if (!text.trim()) return undefined;
|
|
82
|
+
const truncated = text.length > maxChars ? `${text.slice(0, maxChars)}\n…(已截断)` : text;
|
|
83
|
+
return truncated;
|
|
84
|
+
}
|
|
85
|
+
|
|
21
86
|
/**
|
|
22
87
|
* **AgentWebhookParams (Webhook 处理器参数)**
|
|
23
88
|
*
|
|
@@ -98,6 +163,13 @@ async function handleUrlVerification(
|
|
|
98
163
|
const nonce = query.get("nonce") ?? "";
|
|
99
164
|
const echostr = query.get("echostr") ?? "";
|
|
100
165
|
const signature = query.get("msg_signature") ?? "";
|
|
166
|
+
const remote = req.socket?.remoteAddress ?? "unknown";
|
|
167
|
+
|
|
168
|
+
// 不输出敏感参数内容,仅输出存在性
|
|
169
|
+
// 用于排查:是否有请求打到 /wecom/agent
|
|
170
|
+
// 以及是否带齐 timestamp/nonce/msg_signature/echostr
|
|
171
|
+
// eslint-disable-next-line no-unused-vars
|
|
172
|
+
const _debug = { remote, hasTimestamp: Boolean(timestamp), hasNonce: Boolean(nonce), hasSig: Boolean(signature), hasEchostr: Boolean(echostr) };
|
|
101
173
|
|
|
102
174
|
const valid = verifyWecomSignature({
|
|
103
175
|
token: agent.token,
|
|
@@ -139,13 +211,19 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
139
211
|
const { req, res, agent, config, core, log, error } = params;
|
|
140
212
|
|
|
141
213
|
try {
|
|
214
|
+
log?.(`[wecom-agent] inbound: method=${req.method ?? "UNKNOWN"} remote=${req.socket?.remoteAddress ?? "unknown"}`);
|
|
142
215
|
const rawXml = await readRawBody(req);
|
|
216
|
+
log?.(`[wecom-agent] inbound: rawXmlBytes=${Buffer.byteLength(rawXml, "utf8")}`);
|
|
143
217
|
const encrypted = extractEncryptFromXml(rawXml);
|
|
218
|
+
log?.(`[wecom-agent] inbound: hasEncrypt=${Boolean(encrypted)} encryptLen=${encrypted ? String(encrypted).length : 0}`);
|
|
144
219
|
|
|
145
220
|
const query = resolveQueryParams(req);
|
|
146
221
|
const timestamp = query.get("timestamp") ?? "";
|
|
147
222
|
const nonce = query.get("nonce") ?? "";
|
|
148
223
|
const signature = query.get("msg_signature") ?? "";
|
|
224
|
+
log?.(
|
|
225
|
+
`[wecom-agent] inbound: query timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${signature ? "yes" : "no"}`,
|
|
226
|
+
);
|
|
149
227
|
|
|
150
228
|
// 验证签名
|
|
151
229
|
const valid = verifyWecomSignature({
|
|
@@ -157,6 +235,7 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
157
235
|
});
|
|
158
236
|
|
|
159
237
|
if (!valid) {
|
|
238
|
+
error?.(`[wecom-agent] inbound: signature invalid`);
|
|
160
239
|
res.statusCode = 401;
|
|
161
240
|
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
162
241
|
res.end(`unauthorized - 签名验证失败${ERROR_HELP}`);
|
|
@@ -169,15 +248,28 @@ async function handleMessageCallback(params: AgentWebhookParams): Promise<boolea
|
|
|
169
248
|
receiveId: agent.corpId,
|
|
170
249
|
encrypt: encrypted,
|
|
171
250
|
});
|
|
251
|
+
log?.(`[wecom-agent] inbound: decryptedBytes=${Buffer.byteLength(decrypted, "utf8")}`);
|
|
172
252
|
|
|
173
253
|
// 解析 XML
|
|
174
254
|
const msg = parseXml(decrypted);
|
|
175
255
|
const msgType = extractMsgType(msg);
|
|
176
256
|
const fromUser = extractFromUser(msg);
|
|
177
257
|
const chatId = extractChatId(msg);
|
|
178
|
-
const
|
|
258
|
+
const msgId = extractMsgId(msg);
|
|
259
|
+
if (msgId) {
|
|
260
|
+
const ok = rememberAgentMsgId(msgId);
|
|
261
|
+
if (!ok) {
|
|
262
|
+
log?.(`[wecom-agent] duplicate msgId=${msgId} from=${fromUser} chatId=${chatId ?? "N/A"} type=${msgType}; skipped`);
|
|
263
|
+
res.statusCode = 200;
|
|
264
|
+
res.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
265
|
+
res.end("success");
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const content = String(extractContent(msg) ?? "");
|
|
179
270
|
|
|
180
|
-
|
|
271
|
+
const preview = content.length > 100 ? `${content.slice(0, 100)}…` : content;
|
|
272
|
+
log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} msgId=${msgId ?? "N/A"} content=${preview}`);
|
|
181
273
|
|
|
182
274
|
// 先返回 success (Agent 模式使用 API 发送回复,不用被动回复)
|
|
183
275
|
res.statusCode = 200;
|
|
@@ -237,6 +329,7 @@ async function processAgentMessage(params: {
|
|
|
237
329
|
|
|
238
330
|
const isGroup = Boolean(chatId);
|
|
239
331
|
const peerId = isGroup ? chatId! : fromUser;
|
|
332
|
+
const mediaMaxBytes = resolveWecomMediaMaxBytes(config);
|
|
240
333
|
|
|
241
334
|
// 处理媒体文件
|
|
242
335
|
const attachments: any[] = []; // TODO: define specific type
|
|
@@ -249,44 +342,95 @@ async function processAgentMessage(params: {
|
|
|
249
342
|
if (mediaId) {
|
|
250
343
|
try {
|
|
251
344
|
log?.(`[wecom-agent] downloading media: ${mediaId} (${msgType})`);
|
|
252
|
-
const { buffer, contentType } = await downloadMedia({ agent, mediaId });
|
|
345
|
+
const { buffer, contentType, filename: headerFileName } = await downloadMedia({ agent, mediaId, maxBytes: mediaMaxBytes });
|
|
346
|
+
const xmlFileName = extractFileName(msg);
|
|
347
|
+
const originalFileName = (xmlFileName || headerFileName || `${mediaId}.bin`).trim();
|
|
348
|
+
const heuristic = analyzeTextHeuristic(buffer);
|
|
253
349
|
|
|
254
350
|
// 推断文件名后缀
|
|
255
351
|
const extMap: Record<string, string> = {
|
|
256
352
|
"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif",
|
|
257
353
|
"audio/amr": "amr", "audio/speex": "speex", "video/mp4": "mp4",
|
|
258
354
|
};
|
|
259
|
-
const
|
|
355
|
+
const textPreview = msgType === "file" ? buildTextFilePreview(buffer, 12_000) : undefined;
|
|
356
|
+
const looksText = Boolean(textPreview);
|
|
357
|
+
const originalExt = path.extname(originalFileName).toLowerCase();
|
|
358
|
+
const normalizedContentType =
|
|
359
|
+
looksText && originalExt === ".md" ? "text/markdown" :
|
|
360
|
+
looksText && (!contentType || contentType === "application/octet-stream")
|
|
361
|
+
? "text/plain; charset=utf-8"
|
|
362
|
+
: contentType;
|
|
363
|
+
|
|
364
|
+
const ext = extMap[normalizedContentType] || (looksText ? "txt" : "bin");
|
|
260
365
|
const filename = `${mediaId}.${ext}`;
|
|
261
366
|
|
|
367
|
+
log?.(
|
|
368
|
+
`[wecom-agent] file meta: msgType=${msgType} mediaId=${mediaId} size=${buffer.length} maxBytes=${mediaMaxBytes} ` +
|
|
369
|
+
`contentType=${contentType} normalizedContentType=${normalizedContentType} originalFileName=${originalFileName} ` +
|
|
370
|
+
`xmlFileName=${xmlFileName ?? "N/A"} headerFileName=${headerFileName ?? "N/A"} ` +
|
|
371
|
+
`textHeuristic(sample=${heuristic.sampleSize}, bad=${heuristic.badCount}, ratio=${heuristic.badRatio.toFixed(4)}) ` +
|
|
372
|
+
`headHex="${previewHex(buffer)}"`,
|
|
373
|
+
);
|
|
374
|
+
|
|
262
375
|
// 使用 Core SDK 保存媒体文件
|
|
263
376
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
264
377
|
buffer,
|
|
265
|
-
|
|
378
|
+
normalizedContentType,
|
|
266
379
|
"inbound", // context/scope
|
|
267
|
-
|
|
268
|
-
|
|
380
|
+
mediaMaxBytes, // limit
|
|
381
|
+
originalFileName
|
|
269
382
|
);
|
|
270
383
|
|
|
271
384
|
log?.(`[wecom-agent] media saved to: ${saved.path}`);
|
|
272
385
|
mediaPath = saved.path;
|
|
273
|
-
mediaType =
|
|
386
|
+
mediaType = normalizedContentType;
|
|
274
387
|
|
|
275
388
|
// 构建附件
|
|
276
389
|
attachments.push({
|
|
277
|
-
name:
|
|
278
|
-
mimeType:
|
|
390
|
+
name: originalFileName,
|
|
391
|
+
mimeType: normalizedContentType,
|
|
279
392
|
url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
|
|
280
393
|
});
|
|
281
394
|
|
|
282
395
|
// 更新文本提示
|
|
283
|
-
|
|
396
|
+
if (textPreview) {
|
|
397
|
+
finalContent = [
|
|
398
|
+
content,
|
|
399
|
+
"",
|
|
400
|
+
"文件内容预览:",
|
|
401
|
+
"```",
|
|
402
|
+
textPreview,
|
|
403
|
+
"```",
|
|
404
|
+
`(已下载 ${buffer.length} 字节)`,
|
|
405
|
+
].join("\n");
|
|
406
|
+
} else {
|
|
407
|
+
if (msgType === "file") {
|
|
408
|
+
finalContent = [
|
|
409
|
+
content,
|
|
410
|
+
"",
|
|
411
|
+
`已收到文件:${originalFileName}`,
|
|
412
|
+
`文件类型:${normalizedContentType || contentType || "未知"}`,
|
|
413
|
+
"提示:当前仅对文本/Markdown/JSON/CSV/HTML/PDF(可选)做内容抽取;其他二进制格式请转为 PDF 或复制文本内容。",
|
|
414
|
+
`(已下载 ${buffer.length} 字节)`,
|
|
415
|
+
].join("\n");
|
|
416
|
+
} else {
|
|
417
|
+
finalContent = `${content} (已下载 ${buffer.length} 字节)`;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
log?.(`[wecom-agent] file preview: enabled=${looksText} finalContentLen=${finalContent.length} attachments=${attachments.length}`);
|
|
284
421
|
} catch (err) {
|
|
285
|
-
error?.(`[wecom-agent] media
|
|
286
|
-
finalContent =
|
|
422
|
+
error?.(`[wecom-agent] media processing failed: ${String(err)}`);
|
|
423
|
+
finalContent = [
|
|
424
|
+
content,
|
|
425
|
+
"",
|
|
426
|
+
`媒体处理失败:${String(err)}`,
|
|
427
|
+
`提示:可在 OpenClaw 配置中提高 channels.wecom.media.maxBytes(当前=${mediaMaxBytes})`,
|
|
428
|
+
`例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
|
|
429
|
+
].join("\n");
|
|
287
430
|
}
|
|
288
431
|
} else {
|
|
289
|
-
|
|
432
|
+
const keys = Object.keys((msg as unknown as Record<string, unknown>) ?? {}).slice(0, 50).join(",");
|
|
433
|
+
error?.(`[wecom-agent] mediaId not found for ${msgType}; keys=${keys}`);
|
|
290
434
|
}
|
|
291
435
|
}
|
|
292
436
|
|
|
@@ -316,6 +460,28 @@ async function processAgentMessage(params: {
|
|
|
316
460
|
body: finalContent,
|
|
317
461
|
});
|
|
318
462
|
|
|
463
|
+
const authz = await resolveWecomCommandAuthorization({
|
|
464
|
+
core,
|
|
465
|
+
cfg: config,
|
|
466
|
+
// Agent 门禁应读取 channels.wecom.agent.dm(即 agent.config.dm),而不是 channels.wecom.dm(不存在)
|
|
467
|
+
accountConfig: agent.config as any,
|
|
468
|
+
rawBody: finalContent,
|
|
469
|
+
senderUserId: fromUser,
|
|
470
|
+
});
|
|
471
|
+
log?.(`[wecom-agent] authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${fromUser.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`);
|
|
472
|
+
|
|
473
|
+
// 命令门禁:未授权时必须明确回复(Agent 侧用私信提示)
|
|
474
|
+
if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
|
|
475
|
+
const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: fromUser, dmPolicy: authz.dmPolicy, scope: "agent" });
|
|
476
|
+
try {
|
|
477
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text: prompt });
|
|
478
|
+
log?.(`[wecom-agent] unauthorized command: replied via DM to ${fromUser}`);
|
|
479
|
+
} catch (err: unknown) {
|
|
480
|
+
error?.(`[wecom-agent] unauthorized command reply failed: ${String(err)}`);
|
|
481
|
+
}
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
319
485
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
320
486
|
Body: body,
|
|
321
487
|
RawBody: finalContent,
|
|
@@ -332,8 +498,11 @@ async function processAgentMessage(params: {
|
|
|
332
498
|
Provider: "wecom",
|
|
333
499
|
Surface: "wecom",
|
|
334
500
|
OriginatingChannel: "wecom",
|
|
335
|
-
|
|
336
|
-
|
|
501
|
+
// 标记为 Agent 会话的回复路由目标,避免与 Bot 会话混淆:
|
|
502
|
+
// - 用于让 /new /reset 这类命令回执不被 Bot 侧策略拦截
|
|
503
|
+
// - 群聊场景也统一路由为私信触发者(与 deliver 策略一致)
|
|
504
|
+
OriginatingTo: `wecom-agent:${fromUser}`,
|
|
505
|
+
CommandAuthorized: authz.commandAuthorized ?? true,
|
|
337
506
|
MediaPath: mediaPath,
|
|
338
507
|
MediaType: mediaType,
|
|
339
508
|
MediaUrl: mediaPath,
|
|
@@ -359,12 +528,8 @@ async function processAgentMessage(params: {
|
|
|
359
528
|
if (!text) return;
|
|
360
529
|
|
|
361
530
|
try {
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
toUser: fromUser,
|
|
365
|
-
chatId: isGroup ? chatId : undefined,
|
|
366
|
-
text,
|
|
367
|
-
});
|
|
531
|
+
// 统一策略:Agent 模式在群聊场景默认只私信触发者(避免 wr/wc chatId 86008)
|
|
532
|
+
await sendText({ agent, toUser: fromUser, chatId: undefined, text });
|
|
368
533
|
log?.(`[wecom-agent] reply delivered (${info.kind}) to ${fromUser}`);
|
|
369
534
|
} catch (err: unknown) {
|
|
370
535
|
error?.(`[wecom-agent] reply failed: ${String(err)}`);
|
package/src/channel.ts
CHANGED
|
@@ -31,7 +31,7 @@ const meta = {
|
|
|
31
31
|
function normalizeWecomMessagingTarget(raw: string): string | undefined {
|
|
32
32
|
const trimmed = raw.trim();
|
|
33
33
|
if (!trimmed) return undefined;
|
|
34
|
-
return trimmed.replace(/^(wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
|
|
34
|
+
return trimmed.replace(/^(wecom-agent|wecom|wechatwork|wework|qywx):/i, "").trim() || undefined;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
type ResolvedWecomAccount = {
|
package/src/config/index.ts
CHANGED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
// 默认给一个相对“够用”的上限(80MB),避免视频/较大文件频繁触发失败。
|
|
4
|
+
// 仍保留上限以防止恶意大文件把进程内存打爆(下载实现会读入内存再保存)。
|
|
5
|
+
export const DEFAULT_WECOM_MEDIA_MAX_BYTES = 80 * 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
export function resolveWecomMediaMaxBytes(cfg: OpenClawConfig): number {
|
|
8
|
+
const raw = (cfg.channels?.wecom as any)?.media?.maxBytes;
|
|
9
|
+
const n = typeof raw === "number" ? raw : Number(raw);
|
|
10
|
+
if (Number.isFinite(n) && n > 0) {
|
|
11
|
+
return Math.floor(n);
|
|
12
|
+
}
|
|
13
|
+
return DEFAULT_WECOM_MEDIA_MAX_BYTES;
|
|
14
|
+
}
|