@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 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
- | **主动推送** | ❌ 仅回调 | ✅ 随时推送 | **✅ 完整 API** |
61
- | **Cronjob 定时** | 仅回调 | 支持 | **✅ 完美集成** |
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
- openclaw config set channels.wecom.bot.dm.allowFrom '[]'
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: Cronjob 定时任务怎么发给群?**
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
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yanhaidao/wecom",
3
- "version": "2.2.4",
3
+ "version": "2.2.5",
4
4
  "type": "module",
5
5
  "description": "OpenClaw WeCom (WeChat Work) intelligent bot channel plugin",
6
6
  "author": "YanHaidao (VX: YanHaidao)",
@@ -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();
@@ -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 { errcode?: number; errmsg?: string };
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 { errcode?: number; errmsg?: string };
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
- }): Promise<{ buffer: Buffer; contentType: string }> {
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
  }
@@ -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 content = extractContent(msg);
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
- log?.(`[wecom-agent] ${msgType} from=${fromUser} chatId=${chatId ?? "N/A"} content=${content.slice(0, 100)}`);
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 ext = extMap[contentType] || "bin";
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
- contentType,
378
+ normalizedContentType,
266
379
  "inbound", // context/scope
267
- LIMITS.MAX_REQUEST_BODY_SIZE, // limit
268
- filename
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 = contentType;
386
+ mediaType = normalizedContentType;
274
387
 
275
388
  // 构建附件
276
389
  attachments.push({
277
- name: filename,
278
- mimeType: contentType,
390
+ name: originalFileName,
391
+ mimeType: normalizedContentType,
279
392
  url: pathToFileURL(saved.path).href, // 使用跨平台安全的文件 URL
280
393
  });
281
394
 
282
395
  // 更新文本提示
283
- finalContent = `${content} (已下载 ${buffer.length} 字节)`;
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 download failed: ${String(err)}`);
286
- finalContent = `${content} (媒体下载失败)`;
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
- error?.(`[wecom-agent] mediaId not found for ${msgType}`);
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
- OriginatingTo: `wecom:${peerId}`,
336
- CommandAuthorized: true, // 已通过 WeCom 签名验证
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
- await sendText({
363
- agent,
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 = {
@@ -9,3 +9,4 @@ export {
9
9
  isWecomEnabled,
10
10
  } from "./accounts.js";
11
11
  export { resolveWecomEgressProxyUrl, resolveWecomEgressProxyUrlFromNetwork } from "./network.js";
12
+ export { DEFAULT_WECOM_MEDIA_MAX_BYTES, resolveWecomMediaMaxBytes } from "./media.js";
@@ -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
+ }