@yanhaidao/wecom 2.2.3 → 2.2.4

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/src/monitor.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { pathToFileURL } from "node:url";
3
+
2
4
  import crypto from "node:crypto";
3
5
 
4
6
  import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
@@ -8,73 +10,62 @@ import type { ResolvedBotAccount } from "./types/index.js";
8
10
  import type { WecomInboundMessage, WecomInboundQuote } from "./types.js";
9
11
  import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
10
12
  import { getWecomRuntime } from "./runtime.js";
11
- import { decryptWecomMedia } from "./media.js";
13
+ import { decryptWecomMedia, decryptWecomMediaWithHttp } from "./media.js";
12
14
  import { WEBHOOK_PATHS } from "./types/constants.js";
13
15
  import { handleAgentWebhook } from "./agent/index.js";
16
+ import { resolveWecomAccounts, resolveWecomEgressProxyUrl } from "./config/index.js";
17
+ import { wecomFetch } from "./http.js";
18
+ import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
14
19
  import axios from "axios";
15
20
 
16
- export type WecomRuntimeEnv = {
17
- log?: (message: string) => void;
18
- error?: (message: string) => void;
19
- };
21
+ /**
22
+ * **核心监控模块 (Monitor Loop)**
23
+ *
24
+ * 负责接收企业微信 Webhook 回调,处理消息流、媒体解密、消息去重防抖,并分发给 Agent 处理。
25
+ * 它是插件与企业微信交互的“心脏”,管理着所有会话的生命周期。
26
+ */
20
27
 
21
- type WecomWebhookTarget = {
22
- account: ResolvedBotAccount;
23
- config: OpenClawConfig;
24
- runtime: WecomRuntimeEnv;
25
- core: PluginRuntime;
26
- path: string;
27
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
28
- };
28
+ import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState, PendingInbound, ActiveReplyState } from "./monitor/types.js";
29
+ import { monitorState, LIMITS } from "./monitor/state.js";
29
30
 
30
- type StreamState = {
31
- streamId: string;
32
- msgid?: string;
33
- createdAt: number;
34
- updatedAt: number;
35
- started: boolean;
36
- finished: boolean;
37
- error?: string;
38
- content: string;
39
- images?: { base64: string; md5: string }[];
40
- };
31
+ // Global State
32
+ monitorState.streamStore.setFlushHandler((pending) => void flushPending(pending));
33
+
34
+ // Stores (convenience aliases)
35
+ const streamStore = monitorState.streamStore;
36
+ const activeReplyStore = monitorState.activeReplyStore;
41
37
 
38
+ // Target Registry
42
39
  const webhookTargets = new Map<string, WecomWebhookTarget[]>();
43
- const streams = new Map<string, StreamState>();
44
- const msgidToStreamId = new Map<string, string>();
45
- const activeReplies = new Map<string, { response_url: string; createdAt: number; usedAt?: number; lastError?: string }>();
46
40
 
47
41
  // Agent 模式 target 存储
48
42
  type AgentWebhookTarget = {
49
43
  agent: ResolvedAgentAccount;
50
44
  config: OpenClawConfig;
51
45
  runtime: WecomRuntimeEnv;
46
+ // ...
52
47
  };
53
48
  const agentTargets = new Map<string, AgentWebhookTarget>();
54
49
 
55
- // Pending inbound messages for debouncing rapid consecutive messages
56
- type PendingInbound = {
57
- streamId: string;
58
- target: WecomWebhookTarget;
59
- msg: WecomInboundMessage;
60
- contents: string[];
61
- media?: { buffer: Buffer; contentType: string; filename: string };
62
- msgids: string[];
63
- nonce: string;
64
- timestamp: string;
65
- timeout: ReturnType<typeof setTimeout> | null;
66
- createdAt: number;
67
- };
68
50
  const pendingInbounds = new Map<string, PendingInbound>();
69
51
 
70
- const STREAM_TTL_MS = 10 * 60 * 1000;
71
- const ACTIVE_REPLY_TTL_MS = 60 * 60 * 1000;
72
- const STREAM_MAX_BYTES = 20_480;
73
- const DEFAULT_DEBOUNCE_MS = 500;
52
+ const STREAM_MAX_BYTES = LIMITS.STREAM_MAX_BYTES;
53
+ const STREAM_MAX_DM_BYTES = 200_000;
54
+ const BOT_WINDOW_MS = 6 * 60 * 1000;
55
+ const BOT_SWITCH_MARGIN_MS = 30 * 1000;
56
+ // REQUEST_TIMEOUT_MS is available in LIMITS but defined locally in other functions, we can leave it or use LIMITS.REQUEST_TIMEOUT_MS
57
+ // Keeping local variables for now if they are used, or we can replace usages.
58
+ // The constants STREAM_TTL_MS and ACTIVE_REPLY_TTL_MS are internalized in state.ts, so we can remove them here.
74
59
 
75
60
  /** 错误提示信息 */
76
61
  const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
77
62
 
63
+ /**
64
+ * **normalizeWebhookPath (标准化 Webhook 路径)**
65
+ *
66
+ * 将用户配置的路径统一格式化为以 `/` 开头且不以 `/` 结尾的字符串。
67
+ * 例如: `wecom` -> `/wecom`
68
+ */
78
69
  function normalizeWebhookPath(raw: string): string {
79
70
  const trimmed = raw.trim();
80
71
  if (!trimmed) return "/";
@@ -83,25 +74,33 @@ function normalizeWebhookPath(raw: string): string {
83
74
  return withSlash;
84
75
  }
85
76
 
86
- function pruneStreams(): void {
87
- const cutoff = Date.now() - STREAM_TTL_MS;
88
- for (const [id, state] of streams.entries()) {
89
- if (state.updatedAt < cutoff) {
90
- streams.delete(id);
91
- }
92
- }
93
- for (const [msgid, id] of msgidToStreamId.entries()) {
94
- if (!streams.has(id)) {
95
- msgidToStreamId.delete(msgid);
96
- }
97
- }
98
77
 
99
- const activeCutoff = Date.now() - ACTIVE_REPLY_TTL_MS;
100
- for (const [streamId, state] of activeReplies.entries()) {
101
- if (state.createdAt < activeCutoff) activeReplies.delete(streamId);
78
+ /**
79
+ * **ensurePruneTimer (启动清理定时器)**
80
+ *
81
+ * 当有活跃的 Webhook Target 注册时,调用 MonitorState 启动自动清理任务。
82
+ * 清理任务包括:删除过期 Stream、移除无效 Active Reply URL 等。
83
+ */
84
+ function ensurePruneTimer() {
85
+ monitorState.startPruning();
86
+ }
87
+
88
+ /**
89
+ * **checkPruneTimer (检查并停止清理定时器)**
90
+ *
91
+ * 当没有活跃的 Webhook Target 时(Bot 和 Agent 均移除),停止清理任务以节省资源。
92
+ */
93
+ function checkPruneTimer() {
94
+ const hasBot = webhookTargets.size > 0;
95
+ const hasAgent = agentTargets.size > 0;
96
+ if (!hasBot && !hasAgent) {
97
+ monitorState.stopPruning();
102
98
  }
103
99
  }
104
100
 
101
+
102
+
103
+
105
104
  function truncateUtf8Bytes(text: string, maxBytes: number): string {
106
105
  const buf = Buffer.from(text, "utf8");
107
106
  if (buf.length <= maxBytes) return text;
@@ -109,6 +108,13 @@ function truncateUtf8Bytes(text: string, maxBytes: number): string {
109
108
  return slice.toString("utf8");
110
109
  }
111
110
 
111
+ /**
112
+ * **jsonOk (返回 JSON 响应)**
113
+ *
114
+ * 辅助函数:向企业微信服务器返回 HTTP 200 及 JSON 内容。
115
+ * 注意企业微信要求加密内容以 Content-Type: text/plain 返回,但这里为了通用性使用了标准 JSON 响应,
116
+ * 并通过 Content-Type 修正适配。
117
+ */
112
118
  function jsonOk(res: ServerResponse, body: unknown): void {
113
119
  res.statusCode = 200;
114
120
  // WeCom's reference implementation returns the encrypted JSON as text/plain.
@@ -116,6 +122,14 @@ function jsonOk(res: ServerResponse, body: unknown): void {
116
122
  res.end(JSON.stringify(body));
117
123
  }
118
124
 
125
+ /**
126
+ * **readJsonBody (读取 JSON 请求体)**
127
+ *
128
+ * 异步读取 HTTP 请求体并解析为 JSON。包含大小限制检查,防止大包攻击。
129
+ *
130
+ * @param req HTTP 请求对象
131
+ * @param maxBytes 最大允许字节数
132
+ */
119
133
  async function readJsonBody(req: IncomingMessage, maxBytes: number) {
120
134
  const chunks: Buffer[] = [];
121
135
  let total = 0;
@@ -147,8 +161,14 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
147
161
  });
148
162
  }
149
163
 
164
+ /**
165
+ * **buildEncryptedJsonReply (构建加密回复)**
166
+ *
167
+ * 将明文 JSON 包装成企业微信要求的加密 XML/JSON 格式(此处实际返回 JSON 结构)。
168
+ * 包含签名计算逻辑。
169
+ */
150
170
  function buildEncryptedJsonReply(params: {
151
- account: ResolvedWecomAccount;
171
+ account: ResolvedBotAccount;
152
172
  plaintextJson: unknown;
153
173
  nonce: string;
154
174
  timestamp: string;
@@ -210,6 +230,8 @@ function buildStreamPlaceholderReply(params: {
210
230
 
211
231
  function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
212
232
  const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES);
233
+ // Images handled? The original code had image logic.
234
+ // Ensure we return message item if images exist
213
235
  return {
214
236
  msgtype: "stream",
215
237
  stream: {
@@ -226,33 +248,207 @@ function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; str
226
248
  };
227
249
  }
228
250
 
229
- function createStreamId(): string {
230
- return crypto.randomBytes(16).toString("hex");
251
+ function appendDmContent(state: StreamState, text: string): void {
252
+ const next = state.dmContent ? `${state.dmContent}\n\n${text}`.trim() : text.trim();
253
+ state.dmContent = truncateUtf8Bytes(next, STREAM_MAX_DM_BYTES);
231
254
  }
232
255
 
233
- function storeActiveReply(streamId: string, responseUrl?: string): void {
234
- const url = responseUrl?.trim();
235
- if (!url) return;
236
- activeReplies.set(streamId, { response_url: url, createdAt: Date.now() });
256
+ function computeTaskKey(target: WecomWebhookTarget, msg: WecomInboundMessage): string | undefined {
257
+ const msgid = msg.msgid ? String(msg.msgid) : "";
258
+ if (!msgid) return undefined;
259
+ const aibotid = String((msg as any).aibotid ?? "unknown").trim() || "unknown";
260
+ return `bot:${target.account.accountId}:${aibotid}:${msgid}`;
237
261
  }
238
262
 
239
- function getActiveReplyUrl(streamId: string): string | undefined {
240
- return activeReplies.get(streamId)?.response_url;
263
+ function resolveAgentAccountOrUndefined(cfg: OpenClawConfig): ResolvedAgentAccount | undefined {
264
+ const agent = resolveWecomAccounts(cfg).agent;
265
+ return agent?.configured ? agent : undefined;
241
266
  }
242
267
 
243
- async function useActiveReplyOnce(streamId: string, send: (responseUrl: string) => Promise<void>): Promise<void> {
244
- const state = activeReplies.get(streamId);
245
- if (!state?.response_url) throw new Error(`No response_url for stream ${streamId}`);
246
- if (state.usedAt) throw new Error(`response_url already used for stream ${streamId}`);
247
- try {
248
- await send(state.response_url);
249
- state.usedAt = Date.now();
250
- } catch (err) {
251
- state.lastError = err instanceof Error ? err.message : String(err);
252
- throw err;
268
+ function buildFallbackPrompt(params: {
269
+ kind: "media" | "timeout" | "error";
270
+ agentConfigured: boolean;
271
+ userId?: string;
272
+ filename?: string;
273
+ chatType?: "group" | "direct";
274
+ }): string {
275
+ const who = params.userId ? `(${params.userId})` : "";
276
+ const scope = params.chatType === "group" ? "群聊" : params.chatType === "direct" ? "私聊" : "会话";
277
+ if (!params.agentConfigured) {
278
+ return `${scope}中需要通过应用私信发送${params.filename ? `(${params.filename})` : ""},但管理员尚未配置企业微信自建应用(Agent)通道。请联系管理员配置后再试。${who}`.trim();
279
+ }
280
+ if (params.kind === "media") {
281
+ return `已生成文件${params.filename ? `(${params.filename})` : ""},将通过应用私信发送给你。${who}`.trim();
282
+ }
283
+ if (params.kind === "timeout") {
284
+ return `内容较长,为避免超时,后续内容将通过应用私信发送给你。${who}`.trim();
285
+ }
286
+ return `交付出现异常,已尝试通过应用私信发送给你。${who}`.trim();
287
+ }
288
+
289
+ async function sendBotFallbackPromptNow(params: { streamId: string; text: string }): Promise<void> {
290
+ const responseUrl = getActiveReplyUrl(params.streamId);
291
+ if (!responseUrl) {
292
+ throw new Error("no response_url(无法主动推送群内提示)");
293
+ }
294
+ await useActiveReplyOnce(params.streamId, async ({ responseUrl, proxyUrl }) => {
295
+ const payload = {
296
+ msgtype: "stream",
297
+ stream: {
298
+ id: params.streamId,
299
+ finish: true,
300
+ content: truncateUtf8Bytes(params.text, STREAM_MAX_BYTES) || "1",
301
+ },
302
+ };
303
+ const res = await wecomFetch(
304
+ responseUrl,
305
+ {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify(payload),
309
+ },
310
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
311
+ );
312
+ if (!res.ok) {
313
+ throw new Error(`fallback prompt push failed: ${res.status}`);
314
+ }
315
+ });
316
+ }
317
+
318
+ async function sendAgentDmText(params: {
319
+ agent: ResolvedAgentAccount;
320
+ userId: string;
321
+ text: string;
322
+ core: PluginRuntime;
323
+ }): Promise<void> {
324
+ const chunks = params.core.channel.text.chunkText(params.text, 20480);
325
+ for (const chunk of chunks) {
326
+ const trimmed = chunk.trim();
327
+ if (!trimmed) continue;
328
+ await sendAgentText({ agent: params.agent, toUser: params.userId, text: trimmed });
329
+ }
330
+ }
331
+
332
+ async function sendAgentDmMedia(params: {
333
+ agent: ResolvedAgentAccount;
334
+ userId: string;
335
+ mediaUrlOrPath: string;
336
+ contentType?: string;
337
+ filename: string;
338
+ }): Promise<void> {
339
+ let buffer: Buffer;
340
+ let inferredContentType = params.contentType;
341
+
342
+ const looksLikeUrl = /^https?:\/\//i.test(params.mediaUrlOrPath);
343
+ if (looksLikeUrl) {
344
+ const res = await fetch(params.mediaUrlOrPath, { signal: AbortSignal.timeout(30_000) });
345
+ if (!res.ok) throw new Error(`media download failed: ${res.status}`);
346
+ buffer = Buffer.from(await res.arrayBuffer());
347
+ inferredContentType = inferredContentType || res.headers.get("content-type") || "application/octet-stream";
348
+ } else {
349
+ const fs = await import("node:fs/promises");
350
+ buffer = await fs.readFile(params.mediaUrlOrPath);
253
351
  }
352
+
353
+ let mediaType: "image" | "voice" | "video" | "file" = "file";
354
+ const ct = (inferredContentType || "").toLowerCase();
355
+ if (ct.startsWith("image/")) mediaType = "image";
356
+ else if (ct.startsWith("audio/")) mediaType = "voice";
357
+ else if (ct.startsWith("video/")) mediaType = "video";
358
+
359
+ const mediaId = await uploadMedia({
360
+ agent: params.agent,
361
+ type: mediaType,
362
+ buffer,
363
+ filename: params.filename,
364
+ });
365
+ await sendAgentMedia({
366
+ agent: params.agent,
367
+ toUser: params.userId,
368
+ mediaId,
369
+ mediaType,
370
+ });
254
371
  }
255
372
 
373
+ function extractLocalImagePathsFromText(params: {
374
+ text: string;
375
+ mustAlsoAppearIn: string;
376
+ }): string[] {
377
+ const text = params.text;
378
+ const mustAlsoAppearIn = params.mustAlsoAppearIn;
379
+ if (!text.trim()) return [];
380
+
381
+ // Conservative: only accept common macOS absolute paths for images.
382
+ // Also require that the exact path appeared in the user's original message to prevent exfil.
383
+ const exts = "(png|jpg|jpeg|gif|webp|bmp)";
384
+ const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+?\.${exts})`, "gi");
385
+ const found = new Set<string>();
386
+ let m: RegExpExecArray | null;
387
+ while ((m = re.exec(text))) {
388
+ const p = m[1];
389
+ if (!p) continue;
390
+ if (!mustAlsoAppearIn.includes(p)) continue;
391
+ found.add(p);
392
+ }
393
+ return Array.from(found);
394
+ }
395
+
396
+ function extractLocalFilePathsFromText(text: string): string[] {
397
+ if (!text.trim()) return [];
398
+
399
+ // Conservative: only accept common macOS absolute paths.
400
+ // This is primarily for “send local file” style requests (operator/debug usage).
401
+ const re = new RegExp(String.raw`(\/(?:Users|tmp)\/[^\s"'<>]+)`, "g");
402
+ const found = new Set<string>();
403
+ let m: RegExpExecArray | null;
404
+ while ((m = re.exec(text))) {
405
+ const p = m[1];
406
+ if (!p) continue;
407
+ found.add(p);
408
+ }
409
+ return Array.from(found);
410
+ }
411
+
412
+ function guessContentTypeFromPath(filePath: string): string | undefined {
413
+ const ext = filePath.split(".").pop()?.toLowerCase();
414
+ if (!ext) return undefined;
415
+ const map: Record<string, string> = {
416
+ png: "image/png",
417
+ jpg: "image/jpeg",
418
+ jpeg: "image/jpeg",
419
+ gif: "image/gif",
420
+ webp: "image/webp",
421
+ bmp: "image/bmp",
422
+ pdf: "application/pdf",
423
+ txt: "text/plain",
424
+ md: "text/markdown",
425
+ json: "application/json",
426
+ zip: "application/zip",
427
+ };
428
+ return map[ext];
429
+ }
430
+
431
+ function looksLikeSendLocalFileIntent(rawBody: string): boolean {
432
+ const t = rawBody.trim();
433
+ if (!t) return false;
434
+ // Heuristic: treat as “send file” intent only when there is an explicit local path AND a send-ish verb.
435
+ // This avoids accidentally sending a file when the user is merely referencing a path.
436
+ return /(发送|发给|发到|转发|把.*发|把.*发送|帮我发|给我发)/.test(t);
437
+ }
438
+
439
+ function storeActiveReply(streamId: string, responseUrl?: string, proxyUrl?: string): void {
440
+ activeReplyStore.store(streamId, responseUrl, proxyUrl);
441
+ }
442
+
443
+ function getActiveReplyUrl(streamId: string): string | undefined {
444
+ return activeReplyStore.getUrl(streamId);
445
+ }
446
+
447
+ async function useActiveReplyOnce(streamId: string, fn: (params: { responseUrl: string; proxyUrl?: string }) => Promise<void>): Promise<void> {
448
+ return activeReplyStore.use(streamId, fn);
449
+ }
450
+
451
+
256
452
  function normalizeWecomAllowFromEntry(raw: string): string {
257
453
  return raw
258
454
  .trim()
@@ -284,6 +480,17 @@ function logVerbose(target: WecomWebhookTarget, message: string): void {
284
480
  target.runtime.log?.(`[wecom] ${message}`);
285
481
  }
286
482
 
483
+ function logInfo(target: WecomWebhookTarget, message: string): void {
484
+ target.runtime.log?.(`[wecom] ${message}`);
485
+ }
486
+
487
+ function resolveWecomSenderUserId(msg: WecomInboundMessage): string | undefined {
488
+ const direct = msg.from?.userid?.trim();
489
+ if (direct) return direct;
490
+ const legacy = String((msg as any).fromuserid ?? (msg as any).from_userid ?? (msg as any).fromUserId ?? "").trim();
491
+ return legacy || undefined;
492
+ }
493
+
287
494
  function parseWecomPlainMessage(raw: string): WecomInboundMessage {
288
495
  const parsed = JSON.parse(raw) as unknown;
289
496
  if (!parsed || typeof parsed !== "object") {
@@ -301,17 +508,31 @@ type InboundResult = {
301
508
  };
302
509
  };
303
510
 
511
+ /**
512
+ * **processInboundMessage (处理接收消息)**
513
+ *
514
+ * 解析企业微信传入的消息体。
515
+ * 主要职责:
516
+ * 1. 识别媒体消息(Image/File/Mixed)。
517
+ * 2. 如果存在媒体文件,调用 `media.ts` 进行解密和下载。
518
+ * 3. 构造统一的 `InboundResult` 供后续 Agent 处理。
519
+ *
520
+ * @param target Webhook 目标配置
521
+ * @param msg 企业微信原始消息对象
522
+ */
304
523
  async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
305
524
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
306
525
  const aesKey = target.account.encodingAESKey;
307
- const mediaMaxMb = target.config.mediaMaxMb ?? 5; // Default 5MB
526
+ const mediaMaxMb = 5; // Default 5MB
308
527
  const maxBytes = mediaMaxMb * 1024 * 1024;
528
+ const proxyUrl = resolveWecomEgressProxyUrl(target.config);
309
529
 
530
+ // 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
310
531
  if (msgtype === "image") {
311
532
  const url = String((msg as any).image?.url ?? "").trim();
312
533
  if (url && aesKey) {
313
534
  try {
314
- const buf = await decryptWecomMedia(url, aesKey, maxBytes);
535
+ const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
315
536
  return {
316
537
  body: "[image]",
317
538
  media: {
@@ -322,6 +543,7 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
322
543
  };
323
544
  } catch (err) {
324
545
  target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
546
+ target.runtime.error?.(`图片解密失败: ${String(err)}`);
325
547
  return { body: `[image] (decryption failed: ${typeof err === 'object' && err ? (err as any).message : String(err)})` };
326
548
  }
327
549
  }
@@ -331,7 +553,7 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
331
553
  const url = String((msg as any).file?.url ?? "").trim();
332
554
  if (url && aesKey) {
333
555
  try {
334
- const buf = await decryptWecomMedia(url, aesKey, maxBytes);
556
+ const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
335
557
  return {
336
558
  body: "[file]",
337
559
  media: {
@@ -364,7 +586,7 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
364
586
  const url = String(item[t]?.url ?? "").trim();
365
587
  if (url) {
366
588
  try {
367
- const buf = await decryptWecomMedia(url, aesKey, maxBytes);
589
+ const buf = await decryptWecomMediaWithHttp(url, aesKey, { maxBytes, http: { proxyUrl } });
368
590
  foundMedia = {
369
591
  buffer: buf,
370
592
  contentType: t === "image" ? "image/jpeg" : "application/octet-stream",
@@ -393,21 +615,24 @@ async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInbou
393
615
  return { body: buildInboundBody(msg) };
394
616
  }
395
617
 
618
+
396
619
  /**
397
620
  * Flush pending inbound messages after debounce timeout.
398
621
  * Merges all buffered message contents and starts agent processing.
399
622
  */
400
- async function flushPending(pendingKey: string): Promise<void> {
401
- const pending = pendingInbounds.get(pendingKey);
402
- if (!pending) return;
403
- pendingInbounds.delete(pendingKey);
404
-
405
- if (pending.timeout) {
406
- clearTimeout(pending.timeout);
407
- pending.timeout = null;
408
- }
409
-
410
- const { streamId, target, msg, contents, media, msgids } = pending;
623
+ /**
624
+ * **flushPending (刷新待处理消息 / 核心 Agent 触发点)**
625
+ *
626
+ * 当防抖计时器结束时被调用。
627
+ * 核心逻辑:
628
+ * 1. 聚合所有 pending 的消息内容(用于上下文)。
629
+ * 2. 获取 PluginRuntime。
630
+ * 3. 标记 Stream 为 Started。
631
+ * 4. 调用 `startAgentForStream` 启动 Agent 流程。
632
+ * 5. 处理异常并更新 Stream 状态为 Error。
633
+ */
634
+ async function flushPending(pending: PendingInbound): Promise<void> {
635
+ const { streamId, target, msg, contents, msgids } = pending;
411
636
 
412
637
  // Merge all message contents (each is already formatted by buildInboundBody)
413
638
  const mergedContents = contents.filter(c => c.trim()).join("\n").trim();
@@ -417,19 +642,15 @@ async function flushPending(pendingKey: string): Promise<void> {
417
642
  core = getWecomRuntime();
418
643
  } catch (err) {
419
644
  logVerbose(target, `flush pending: runtime not ready: ${String(err)}`);
420
- const state = streams.get(streamId);
421
- if (state) {
422
- state.finished = true;
423
- state.updatedAt = Date.now();
424
- }
645
+ streamStore.markFinished(streamId);
425
646
  return;
426
647
  }
427
648
 
428
649
  if (core) {
429
- const state = streams.get(streamId);
430
- if (state) state.started = true;
650
+ streamStore.markStarted(streamId);
431
651
  const enrichedTarget: WecomWebhookTarget = { ...target, core };
432
652
  logVerbose(target, `flush pending: starting agent for ${contents.length} merged messages`);
653
+ logVerbose(target, `防抖结束: 开始处理聚合消息 数量=${contents.length} streamId=${streamId}`);
433
654
 
434
655
  // Pass the first msg (with its media structure), and mergedContents for multi-message context
435
656
  startAgentForStream({
@@ -440,24 +661,29 @@ async function flushPending(pendingKey: string): Promise<void> {
440
661
  mergedContents: contents.length > 1 ? mergedContents : undefined,
441
662
  mergedMsgids: msgids.length > 1 ? msgids : undefined,
442
663
  }).catch((err) => {
443
- const state = streams.get(streamId);
444
- if (state) {
664
+ streamStore.updateStream(streamId, (state) => {
445
665
  state.error = err instanceof Error ? err.message : String(err);
446
666
  state.content = state.content || `Error: ${state.error}`;
447
667
  state.finished = true;
448
- state.updatedAt = Date.now();
449
- }
450
- target.runtime.error?.(`[${target.account.accountId}] wecom agent failed: ${String(err)}`);
668
+ });
669
+ target.runtime.error?.(`[${target.account.accountId}] wecom agent failed (处理失败): ${String(err)}`);
451
670
  });
452
671
  }
453
672
  }
454
673
 
674
+
675
+ /**
676
+ * **waitForStreamContent (等待流内容)**
677
+ *
678
+ * 用于长轮询 (Long Polling) 场景:阻塞等待流输出内容,直到超时或流结束。
679
+ * 这保证了用户能尽快收到第一批响应,而不是空转。
680
+ */
455
681
  async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
456
682
  if (maxWaitMs <= 0) return;
457
683
  const startedAt = Date.now();
458
684
  await new Promise<void>((resolve) => {
459
685
  const tick = () => {
460
- const state = streams.get(streamId);
686
+ const state = streamStore.getStream(streamId);
461
687
  if (!state) return resolve();
462
688
  if (state.error || state.finished) return resolve();
463
689
  if (state.content.trim()) return resolve();
@@ -468,6 +694,18 @@ async function waitForStreamContent(streamId: string, maxWaitMs: number): Promis
468
694
  });
469
695
  }
470
696
 
697
+ /**
698
+ * **startAgentForStream (启动 Agent 处理流程)**
699
+ *
700
+ * 将接收到的(或聚合的)消息转换为 OpenClaw 内部格式,并分发给对应的 Agent。
701
+ * 包含:
702
+ * 1. 消息解密与媒体保存。
703
+ * 2. 路由解析 (Agent Route)。
704
+ * 3. 鉴权 (Command Authorization)。
705
+ * 4. 会话记录 (Session Recording)。
706
+ * 5. 触发 Agent 响应 (Dispatch Reply)。
707
+ * 6. 处理 Agent 输出(包括文本、Markdown 表格转换、<think> 标签保护、模板卡片识别)。
708
+ */
471
709
  async function startAgentForStream(params: {
472
710
  target: WecomWebhookTarget;
473
711
  accountId: string;
@@ -481,18 +719,168 @@ async function startAgentForStream(params: {
481
719
  const config = target.config;
482
720
  const account = target.account;
483
721
 
484
- const userid = msg.from?.userid?.trim() || "unknown";
722
+ const userid = resolveWecomSenderUserId(msg) || "unknown";
485
723
  const chatType = msg.chattype === "group" ? "group" : "direct";
486
724
  const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
487
- // 1. Process inbound message (decrypt media if any)
488
- const { body: rawBody, media } = await processInboundMessage(target, msg);
725
+ const taskKey = computeTaskKey(target, msg);
726
+ const aibotid = String((msg as any).aibotid ?? "").trim() || undefined;
727
+
728
+ // 更新 Stream 状态:记录上下文信息(用户ID、ChatType等)
729
+ streamStore.updateStream(streamId, (s) => {
730
+ s.userId = userid;
731
+ s.chatType = chatType === "group" ? "group" : "direct";
732
+ s.chatId = chatId;
733
+ s.taskKey = taskKey;
734
+ s.aibotid = aibotid;
735
+ });
736
+
737
+ // 1. 处理入站消息 (Decrypt media if any)
738
+ // 解析消息体,若是图片/文件则自动解密
739
+ let { body: rawBody, media } = await processInboundMessage(target, msg);
740
+
741
+ // 若存在从防抖逻辑聚合来的多条消息内容,则覆盖 rawBody
742
+ if (params.mergedContents) {
743
+ rawBody = params.mergedContents;
744
+ }
745
+
746
+ // P0: 群聊/私聊里“让 Bot 发送本机图片/文件路径”的场景,优先走 Bot 原会话交付(图片),
747
+ // 非图片文件则走 Agent 私信兜底,并确保 Bot 会话里有中文提示。
748
+ //
749
+ // 典型背景:Agent 主动发群 chatId(wr/wc...)在很多情况下会 86008,无论怎么“修复”都发不出去;
750
+ // 这种请求如果能被动回复图片,就必须由 Bot 在群内交付。
751
+ const directLocalPaths = extractLocalFilePathsFromText(rawBody);
752
+ if (directLocalPaths.length) {
753
+ logVerbose(
754
+ target,
755
+ `local-path: 检测到用户消息包含本机路径 count=${directLocalPaths.length} intent=${looksLikeSendLocalFileIntent(rawBody)}`,
756
+ );
757
+ }
758
+ if (directLocalPaths.length && looksLikeSendLocalFileIntent(rawBody)) {
759
+ const fs = await import("node:fs/promises");
760
+ const pathModule = await import("node:path");
761
+ const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
762
+
763
+ const imagePaths: string[] = [];
764
+ const otherPaths: string[] = [];
765
+ for (const p of directLocalPaths) {
766
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
767
+ if (imageExts.has(ext)) imagePaths.push(p);
768
+ else otherPaths.push(p);
769
+ }
770
+
771
+ // 1) 图片:优先 Bot 群内/原会话交付(被动/流式 msg_item)
772
+ if (imagePaths.length > 0 && otherPaths.length === 0) {
773
+ const loaded: Array<{ base64: string; md5: string; path: string }> = [];
774
+ for (const p of imagePaths) {
775
+ try {
776
+ const buf = await fs.readFile(p);
777
+ const base64 = buf.toString("base64");
778
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
779
+ loaded.push({ base64, md5, path: p });
780
+ } catch (err) {
781
+ target.runtime.error?.(`local-path: 读取图片失败 path=${p}: ${String(err)}`);
782
+ }
783
+ }
784
+
785
+ if (loaded.length > 0) {
786
+ streamStore.updateStream(streamId, (s) => {
787
+ s.images = loaded.map(({ base64, md5 }) => ({ base64, md5 }));
788
+ s.content = loaded.length === 1
789
+ ? `已发送图片(${pathModule.basename(loaded[0]!.path)})`
790
+ : `已发送 ${loaded.length} 张图片`;
791
+ s.finished = true;
792
+ });
793
+
794
+ const responseUrl = getActiveReplyUrl(streamId);
795
+ if (responseUrl) {
796
+ try {
797
+ const finalReply = buildStreamReplyFromState(streamStore.getStream(streamId)!) as unknown as Record<string, unknown>;
798
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
799
+ const res = await wecomFetch(
800
+ responseUrl,
801
+ {
802
+ method: "POST",
803
+ headers: { "Content-Type": "application/json" },
804
+ body: JSON.stringify(finalReply),
805
+ },
806
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
807
+ );
808
+ if (!res.ok) throw new Error(`local-path image push failed: ${res.status}`);
809
+ });
810
+ logVerbose(target, `local-path: 已通过 Bot response_url 推送图片 frames=final images=${loaded.length}`);
811
+ } catch (err) {
812
+ target.runtime.error?.(`local-path: Bot 主动推送图片失败(将依赖 stream_refresh 拉取): ${String(err)}`);
813
+ }
814
+ } else {
815
+ logVerbose(target, `local-path: 无 response_url,等待 stream_refresh 拉取最终图片`);
816
+ }
817
+ return;
818
+ }
819
+ }
820
+
821
+ // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
822
+ if (otherPaths.length > 0) {
823
+ const agentCfg = resolveAgentAccountOrUndefined(config);
824
+ const agentOk = Boolean(agentCfg);
825
+
826
+ const filename = otherPaths.length === 1 ? otherPaths[0]!.split("/").pop()! : `${otherPaths.length} 个文件`;
827
+ const prompt = buildFallbackPrompt({
828
+ kind: "media",
829
+ agentConfigured: agentOk,
830
+ userId: userid,
831
+ filename,
832
+ chatType,
833
+ });
834
+
835
+ streamStore.updateStream(streamId, (s) => {
836
+ s.fallbackMode = "media";
837
+ s.finished = true;
838
+ s.content = prompt;
839
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
840
+ });
841
+
842
+ try {
843
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
844
+ logVerbose(target, `local-path: 文件兜底提示已推送`);
845
+ } catch (err) {
846
+ target.runtime.error?.(`local-path: 文件兜底提示推送失败: ${String(err)}`);
847
+ }
848
+
849
+ if (!agentCfg) return;
850
+ if (!userid || userid === "unknown") {
851
+ target.runtime.error?.(`local-path: 无法识别触发者 userId,无法 Agent 私信发送文件`);
852
+ return;
853
+ }
854
+
855
+ for (const p of otherPaths) {
856
+ const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
857
+ if (alreadySent) continue;
858
+ try {
859
+ await sendAgentDmMedia({
860
+ agent: agentCfg,
861
+ userId: userid,
862
+ mediaUrlOrPath: p,
863
+ contentType: guessContentTypeFromPath(p),
864
+ filename: p.split("/").pop() || "file",
865
+ });
866
+ streamStore.updateStream(streamId, (s) => {
867
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
868
+ });
869
+ logVerbose(target, `local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p}`);
870
+ } catch (err) {
871
+ target.runtime.error?.(`local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
872
+ }
873
+ }
874
+ return;
875
+ }
876
+ }
489
877
 
490
878
  // 2. Save media if present
491
879
  let mediaPath: string | undefined;
492
880
  let mediaType: string | undefined;
493
881
  if (media) {
494
882
  try {
495
- const maxBytes = (target.config.mediaMaxMb ?? 5) * 1024 * 1024;
883
+ const maxBytes = 5 * 1024 * 1024;
496
884
  const saved = await core.channel.media.saveMediaBuffer(
497
885
  media.buffer,
498
886
  media.contentType,
@@ -516,6 +904,7 @@ async function startAgentForStream(params: {
516
904
  });
517
905
 
518
906
  logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
907
+ logVerbose(target, `启动 Agent 处理: streamId=${streamId} 路由=${route.agentId} 类型=${chatType} ID=${chatId}`);
519
908
 
520
909
  const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
521
910
  const storePath = core.channel.session.resolveStorePath(config.session?.store, {
@@ -555,10 +944,17 @@ async function startAgentForStream(params: {
555
944
  })
556
945
  : undefined;
557
946
 
947
+ const attachments = mediaPath ? [{
948
+ name: media?.filename || "file",
949
+ mimeType: mediaType,
950
+ url: pathToFileURL(mediaPath).href
951
+ }] : undefined;
952
+
558
953
  const ctxPayload = core.channel.reply.finalizeInboundContext({
559
954
  Body: body,
560
955
  RawBody: rawBody,
561
956
  CommandBody: rawBody,
957
+ Attachments: attachments,
562
958
  From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
563
959
  To: `wecom:${chatId}`,
564
960
  SessionKey: route.sessionKey,
@@ -593,14 +989,36 @@ async function startAgentForStream(params: {
593
989
  accountId: account.accountId,
594
990
  });
595
991
 
992
+ // WeCom Bot 会话交付约束:
993
+ // - 图片应尽量由 Bot 在原会话交付(流式最终帧 msg_item)。
994
+ // - 非图片文件走 Agent 私信兜底(本文件中实现),并由 Bot 给出提示。
995
+ //
996
+ // 重要:message 工具不是 sandbox 工具,必须通过 cfg.tools.deny 禁用。
997
+ // 否则 Agent 可能直接通过 message 工具私信/发群,绕过 Bot 交付链路,导致群里“没有任何提示”。
998
+ const cfgForDispatch = (() => {
999
+ const baseTools = (config as any)?.tools ?? {};
1000
+ const existingDeny = Array.isArray(baseTools.deny) ? (baseTools.deny as string[]) : [];
1001
+ const deny = Array.from(new Set([...existingDeny, "message"]));
1002
+ return {
1003
+ ...(config as any),
1004
+ tools: {
1005
+ ...baseTools,
1006
+ deny,
1007
+ },
1008
+ } as OpenClawConfig;
1009
+ })();
1010
+ logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(防止绕过 Bot 交付)`);
1011
+
1012
+ // 调度 Agent 回复
1013
+ // 使用 dispatchReplyWithBufferedBlockDispatcher 可以处理流式输出 buffer
596
1014
  await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
597
1015
  ctx: ctxPayload,
598
- cfg: config,
1016
+ cfg: cfgForDispatch,
599
1017
  dispatcherOptions: {
600
1018
  deliver: async (payload) => {
601
1019
  let text = payload.text ?? "";
602
1020
 
603
- // Protect <think> tags from table conversion
1021
+ // 保护 <think> 标签不被 markdown 表格转换破坏
604
1022
  const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
605
1023
  const thinks: string[] = [];
606
1024
  text = text.replace(thinkRegex, (match: string) => {
@@ -619,18 +1037,28 @@ async function startAgentForStream(params: {
619
1037
 
620
1038
  if (responseUrl && isSingleChat) {
621
1039
  // 单聊且有 response_url:发送卡片
622
- await useActiveReplyOnce(streamId, async (url) => {
623
- await axios.post(url, {
624
- msgtype: "template_card",
625
- template_card: parsed.template_card,
626
- });
1040
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1041
+ const res = await wecomFetch(
1042
+ responseUrl,
1043
+ {
1044
+ method: "POST",
1045
+ headers: { "Content-Type": "application/json" },
1046
+ body: JSON.stringify({
1047
+ msgtype: "template_card",
1048
+ template_card: parsed.template_card,
1049
+ }),
1050
+ },
1051
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1052
+ );
1053
+ if (!res.ok) {
1054
+ throw new Error(`template_card send failed: ${res.status}`);
1055
+ }
627
1056
  });
628
1057
  logVerbose(target, `sent template_card: task_id=${parsed.template_card.task_id}`);
629
- const current = streams.get(streamId);
630
- if (!current) return;
631
- current.finished = true;
632
- current.content = "[已发送交互卡片]";
633
- current.updatedAt = Date.now();
1058
+ streamStore.updateStream(streamId, (s) => {
1059
+ s.finished = true;
1060
+ s.content = "[已发送交互卡片]";
1061
+ });
634
1062
  target.statusSink?.({ lastOutboundAt: Date.now() });
635
1063
  return;
636
1064
  } else {
@@ -652,10 +1080,92 @@ async function startAgentForStream(params: {
652
1080
  text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
653
1081
  });
654
1082
 
655
- const current = streams.get(streamId);
1083
+ const current = streamStore.getStream(streamId);
656
1084
  if (!current) return;
657
1085
 
658
1086
  if (!current.images) current.images = [];
1087
+ if (!current.agentMediaKeys) current.agentMediaKeys = [];
1088
+
1089
+ logVerbose(
1090
+ target,
1091
+ `deliver: chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
1092
+ );
1093
+
1094
+ // If the model referenced a local image path in its reply but did not emit mediaUrl(s),
1095
+ // we can still deliver it via Bot *only* when that exact path appeared in the user's
1096
+ // original message (rawBody). This prevents the model from exfiltrating arbitrary files.
1097
+ if (!payload.mediaUrl && !(payload.mediaUrls?.length ?? 0) && text.includes("/")) {
1098
+ const candidates = extractLocalImagePathsFromText({ text, mustAlsoAppearIn: rawBody });
1099
+ if (candidates.length > 0) {
1100
+ logVerbose(target, `media: 从输出文本推断到本机图片路径(来自用户原消息)count=${candidates.length}`);
1101
+ for (const p of candidates) {
1102
+ try {
1103
+ const fs = await import("node:fs/promises");
1104
+ const pathModule = await import("node:path");
1105
+ const buf = await fs.readFile(p);
1106
+ const ext = pathModule.extname(p).slice(1).toLowerCase();
1107
+ const imageExts: Record<string, string> = {
1108
+ jpg: "image/jpeg",
1109
+ jpeg: "image/jpeg",
1110
+ png: "image/png",
1111
+ gif: "image/gif",
1112
+ webp: "image/webp",
1113
+ bmp: "image/bmp",
1114
+ };
1115
+ const contentType = imageExts[ext] ?? "application/octet-stream";
1116
+ if (!contentType.startsWith("image/")) {
1117
+ continue;
1118
+ }
1119
+ const base64 = buf.toString("base64");
1120
+ const md5 = crypto.createHash("md5").update(buf).digest("hex");
1121
+ current.images.push({ base64, md5 });
1122
+ logVerbose(target, `media: 已加载本机图片用于 Bot 交付 path=${p}`);
1123
+ } catch (err) {
1124
+ target.runtime.error?.(`media: 读取本机图片失败 path=${p}: ${String(err)}`);
1125
+ }
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ // Always accumulate content for potential Agent DM fallback (not limited by STREAM_MAX_BYTES).
1131
+ if (text.trim()) {
1132
+ streamStore.updateStream(streamId, (s) => {
1133
+ appendDmContent(s, text);
1134
+ });
1135
+ }
1136
+
1137
+ // Timeout fallback (group only): near 6min window, stop bot stream and switch to Agent DM.
1138
+ const now = Date.now();
1139
+ const deadline = current.createdAt + BOT_WINDOW_MS;
1140
+ const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
1141
+ const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
1142
+ if (nearTimeout) {
1143
+ const agentCfg = resolveAgentAccountOrUndefined(config);
1144
+ const agentOk = Boolean(agentCfg);
1145
+ const prompt = buildFallbackPrompt({
1146
+ kind: "timeout",
1147
+ agentConfigured: agentOk,
1148
+ userId: current.userId,
1149
+ chatType: current.chatType,
1150
+ });
1151
+ logVerbose(
1152
+ target,
1153
+ `fallback(timeout): 触发切换(接近 6 分钟)chatType=${current.chatType} agentConfigured=${agentOk} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`,
1154
+ );
1155
+ streamStore.updateStream(streamId, (s) => {
1156
+ s.fallbackMode = "timeout";
1157
+ s.finished = true;
1158
+ s.content = prompt;
1159
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1160
+ });
1161
+ try {
1162
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
1163
+ logVerbose(target, `fallback(timeout): 群内提示已推送`);
1164
+ } catch (err) {
1165
+ target.runtime.error?.(`wecom bot fallback prompt push failed (timeout) streamId=${streamId}: ${String(err)}`);
1166
+ }
1167
+ return;
1168
+ }
659
1169
 
660
1170
  const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
661
1171
  for (const mediaPath of mediaUrls) {
@@ -667,12 +1177,10 @@ async function startAgentForStream(params: {
667
1177
  const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
668
1178
 
669
1179
  if (looksLikeUrl) {
670
- const loaded = await core.channel.media.fetchRemoteMedia(mediaPath, {
671
- maxBytes: 10 * 1024 * 1024,
672
- });
1180
+ const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaPath });
673
1181
  buf = loaded.buffer;
674
1182
  contentType = loaded.contentType;
675
- filename = loaded.filename ?? "attachment";
1183
+ filename = loaded.fileName ?? "attachment";
676
1184
  } else {
677
1185
  const fs = await import("node:fs/promises");
678
1186
  const pathModule = await import("node:path");
@@ -687,19 +1195,75 @@ async function startAgentForStream(params: {
687
1195
  const base64 = buf.toString("base64");
688
1196
  const md5 = crypto.createHash("md5").update(buf).digest("hex");
689
1197
  current.images.push({ base64, md5 });
1198
+ logVerbose(target, `media: 识别为图片 contentType=${contentType} filename=${filename}`);
690
1199
  } else {
691
- text += `\n\n[File: ${filename}]`;
1200
+ // Non-image media: Bot 不支持原样发送(尤其群聊),统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
1201
+ const agentCfg = resolveAgentAccountOrUndefined(config);
1202
+ const agentOk = Boolean(agentCfg);
1203
+ const alreadySent = current.agentMediaKeys.includes(mediaPath);
1204
+ logVerbose(
1205
+ target,
1206
+ `fallback(media): 检测到非图片文件 chatType=${current.chatType} contentType=${contentType ?? "unknown"} filename=${filename} agentConfigured=${agentOk} alreadySent=${alreadySent} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`,
1207
+ );
1208
+
1209
+ if (agentCfg && !alreadySent && current.userId) {
1210
+ try {
1211
+ await sendAgentDmMedia({
1212
+ agent: agentCfg,
1213
+ userId: current.userId,
1214
+ mediaUrlOrPath: mediaPath,
1215
+ contentType,
1216
+ filename,
1217
+ });
1218
+ logVerbose(target, `fallback(media): 文件已通过 Agent 私信发送 user=${current.userId}`);
1219
+ streamStore.updateStream(streamId, (s) => {
1220
+ s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1221
+ });
1222
+ } catch (err) {
1223
+ target.runtime.error?.(`wecom agent dm media failed: ${String(err)}`);
1224
+ }
1225
+ }
1226
+
1227
+ if (!current.fallbackMode) {
1228
+ const prompt = buildFallbackPrompt({
1229
+ kind: "media",
1230
+ agentConfigured: agentOk,
1231
+ userId: current.userId,
1232
+ filename,
1233
+ chatType: current.chatType,
1234
+ });
1235
+ streamStore.updateStream(streamId, (s) => {
1236
+ s.fallbackMode = "media";
1237
+ s.finished = true;
1238
+ s.content = prompt;
1239
+ s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1240
+ });
1241
+ try {
1242
+ await sendBotFallbackPromptNow({ streamId, text: prompt });
1243
+ logVerbose(target, `fallback(media): 群内提示已推送`);
1244
+ } catch (err) {
1245
+ target.runtime.error?.(`wecom bot fallback prompt push failed (media) streamId=${streamId}: ${String(err)}`);
1246
+ }
1247
+ }
1248
+ return;
692
1249
  }
693
1250
  } catch (err) {
694
1251
  target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
695
1252
  }
696
1253
  }
697
1254
 
1255
+ // If we are in fallback mode, do not continue updating the bot stream content.
1256
+ const mode = streamStore.getStream(streamId)?.fallbackMode;
1257
+ if (mode) return;
1258
+
698
1259
  const nextText = current.content
699
1260
  ? `${current.content}\n\n${text}`.trim()
700
1261
  : text.trim();
701
- current.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
702
- current.updatedAt = Date.now();
1262
+
1263
+ streamStore.updateStream(streamId, (s) => {
1264
+ s.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
1265
+ if (current.images?.length) s.images = current.images; // ensure images are saved
1266
+ });
703
1267
  target.statusSink?.({ lastOutboundAt: Date.now() });
704
1268
  },
705
1269
  onError: (err, info) => {
@@ -708,10 +1272,60 @@ async function startAgentForStream(params: {
708
1272
  },
709
1273
  });
710
1274
 
711
- const current = streams.get(streamId);
712
- if (current) {
713
- current.finished = true;
714
- current.updatedAt = Date.now();
1275
+ streamStore.markFinished(streamId);
1276
+
1277
+ // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
1278
+ const finishedState = streamStore.getStream(streamId);
1279
+ if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
1280
+ const agentCfg = resolveAgentAccountOrUndefined(config);
1281
+ if (!agentCfg) {
1282
+ // Agent not configured - group prompt already explains the situation.
1283
+ streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
1284
+ } else if (finishedState.userId) {
1285
+ const dmText = (finishedState.dmContent ?? "").trim();
1286
+ if (dmText) {
1287
+ try {
1288
+ logVerbose(target, `fallback(timeout): 开始通过 Agent 私信发送剩余内容 user=${finishedState.userId} len=${dmText.length}`);
1289
+ await sendAgentDmText({ agent: agentCfg, userId: finishedState.userId, text: dmText, core });
1290
+ logVerbose(target, `fallback(timeout): Agent 私信发送完成 user=${finishedState.userId}`);
1291
+ } catch (err) {
1292
+ target.runtime.error?.(`wecom agent dm text failed (timeout): ${String(err)}`);
1293
+ }
1294
+ }
1295
+ streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
1296
+ }
1297
+ }
1298
+
1299
+ // Bot 群聊图片兜底:
1300
+ // 依赖企业微信的“流式消息刷新”回调来拉取最终消息有时会出现客户端未能及时拉取到最后一帧的情况,
1301
+ // 导致最终的图片(msg_item)没有展示。若存在 response_url,则在流结束后主动推送一次最终 stream 回复。
1302
+ // 注:该行为以 response_url 是否可用为准;失败则仅记录日志,不影响原有刷新链路。
1303
+ if (chatType === "group") {
1304
+ const state = streamStore.getStream(streamId);
1305
+ const hasImages = Boolean(state?.images?.length);
1306
+ const responseUrl = getActiveReplyUrl(streamId);
1307
+ if (state && hasImages && responseUrl) {
1308
+ const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
1309
+ try {
1310
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1311
+ const res = await wecomFetch(
1312
+ responseUrl,
1313
+ {
1314
+ method: "POST",
1315
+ headers: { "Content-Type": "application/json" },
1316
+ body: JSON.stringify(finalReply),
1317
+ },
1318
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1319
+ );
1320
+ if (!res.ok) {
1321
+ throw new Error(`final stream push failed: ${res.status}`);
1322
+ }
1323
+ });
1324
+ logVerbose(target, `final stream pushed via response_url (group) streamId=${streamId}, images=${state.images?.length ?? 0}`);
1325
+ } catch (err) {
1326
+ target.runtime.error?.(`final stream push via response_url failed (group) streamId=${streamId}: ${String(err)}`);
1327
+ }
1328
+ }
715
1329
  }
716
1330
  }
717
1331
 
@@ -762,15 +1376,24 @@ function buildInboundBody(msg: WecomInboundMessage): string {
762
1376
  return body;
763
1377
  }
764
1378
 
1379
+ /**
1380
+ * **registerWecomWebhookTarget (注册 Webhook 目标)**
1381
+ *
1382
+ * 注册一个 Bot 模式的接收端点。
1383
+ * 同时会触发清理定时器的检查(如果有新注册,确保定时器运行)。
1384
+ * 返回一个注销函数。
1385
+ */
765
1386
  export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
766
1387
  const key = normalizeWebhookPath(target.path);
767
1388
  const normalizedTarget = { ...target, path: key };
768
1389
  const existing = webhookTargets.get(key) ?? [];
769
1390
  webhookTargets.set(key, [...existing, normalizedTarget]);
1391
+ ensurePruneTimer();
770
1392
  return () => {
771
1393
  const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
772
1394
  if (updated.length > 0) webhookTargets.set(key, updated);
773
1395
  else webhookTargets.delete(key);
1396
+ checkPruneTimer();
774
1397
  };
775
1398
  }
776
1399
 
@@ -780,13 +1403,26 @@ export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => vo
780
1403
  export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => void {
781
1404
  const key = WEBHOOK_PATHS.AGENT;
782
1405
  agentTargets.set(key, target);
1406
+ ensurePruneTimer();
783
1407
  return () => {
784
1408
  agentTargets.delete(key);
1409
+ checkPruneTimer();
785
1410
  };
786
1411
  }
787
1412
 
1413
+ /**
1414
+ * **handleWecomWebhookRequest (HTTP 请求入口)**
1415
+ *
1416
+ * 处理来自企业微信的所有 Webhook 请求。
1417
+ * 职责:
1418
+ * 1. 路由分发:区分 Agent 模式 (`/wecom/agent`) 和 Bot 模式 (其他路径)。
1419
+ * 2. 安全校验:验证企业微信签名 (Signature)。
1420
+ * 3. 消息解密:处理企业微信的加密包。
1421
+ * 4. 响应处理:
1422
+ * - GET 请求:处理 EchoStr 验证。
1423
+ * - POST 请求:接收消息,放入 StreamStore,返回流式 First Chunk。
1424
+ */
788
1425
  export async function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
789
- pruneStreams();
790
1426
  const path = resolvePath(req);
791
1427
 
792
1428
  // Agent 模式路由: /wecom/agent
@@ -873,6 +1509,7 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
873
1509
 
874
1510
  const msg = parseWecomPlainMessage(plain);
875
1511
  const msgtype = String(msg.msgtype ?? "").toLowerCase();
1512
+ const proxyUrl = resolveWecomEgressProxyUrl(target.config);
876
1513
 
877
1514
  // Handle Event
878
1515
  if (msgtype === "event") {
@@ -882,7 +1519,7 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
882
1519
  const msgid = msg.msgid ? String(msg.msgid) : undefined;
883
1520
 
884
1521
  // Dedupe: skip if already processed this event
885
- if (msgid && msgidToStreamId.has(msgid)) {
1522
+ if (msgid && streamStore.getStreamByMsgId(msgid)) {
886
1523
  logVerbose(target, `template_card_event: already processed msgid=${msgid}, skipping`);
887
1524
  jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
888
1525
  return true;
@@ -898,9 +1535,8 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
898
1535
 
899
1536
  jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
900
1537
 
901
- const streamId = createStreamId();
902
- if (msgid) msgidToStreamId.set(msgid, streamId); // Mark as processed
903
- streams.set(streamId, { streamId, createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: false, content: "" });
1538
+ const streamId = streamStore.createStream({ msgid });
1539
+ streamStore.markStarted(streamId);
904
1540
  storeActiveReply(streamId, msg.response_url);
905
1541
  const core = getWecomRuntime();
906
1542
  startAgentForStream({
@@ -925,40 +1561,94 @@ export async function handleWecomWebhookRequest(req: IncomingMessage, res: Serve
925
1561
  // Handle Stream Refresh
926
1562
  if (msgtype === "stream") {
927
1563
  const streamId = String((msg as any).stream?.id ?? "").trim();
928
- const state = streams.get(streamId);
1564
+ const state = streamStore.getStream(streamId);
929
1565
  const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({ streamId: streamId || "unknown", createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: true, content: "" });
930
1566
  jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: reply, nonce, timestamp }));
931
1567
  return true;
932
1568
  }
933
1569
 
934
1570
  // Handle Message (with Debounce)
935
- const userid = msg.from?.userid?.trim() || "unknown";
936
- const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
937
- const pendingKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
938
- const msgContent = buildInboundBody(msg);
939
-
940
- const existingPending = pendingInbounds.get(pendingKey);
941
- if (existingPending) {
942
- existingPending.contents.push(msgContent);
943
- if (msg.msgid) existingPending.msgids.push(msg.msgid);
944
- if (existingPending.timeout) clearTimeout(existingPending.timeout);
945
- existingPending.timeout = setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS);
946
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId: existingPending.streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
947
- return true;
948
- }
1571
+ try {
1572
+ const userid = resolveWecomSenderUserId(msg) || "unknown";
1573
+ const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
1574
+ const pendingKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
1575
+ const msgContent = buildInboundBody(msg);
1576
+
1577
+ logInfo(
1578
+ target,
1579
+ `inbound: msgtype=${msgtype} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${userid} msgid=${String(msg.msgid ?? "")} hasResponseUrl=${Boolean((msg as any).response_url)}`,
1580
+ );
1581
+
1582
+ // 去重: msgid 已存在于 StreamStore,说明是重试请求,直接返回占位符
1583
+ if (msg.msgid) {
1584
+ const existingStreamId = streamStore.getStreamByMsgId(String(msg.msgid));
1585
+ if (existingStreamId) {
1586
+ logInfo(target, `message: 重复的 msgid=${msg.msgid},跳过处理并返回占位符 streamId=${existingStreamId}`);
1587
+ jsonOk(res, buildEncryptedJsonReply({
1588
+ account: target.account,
1589
+ plaintextJson: buildStreamPlaceholderReply({
1590
+ streamId: existingStreamId,
1591
+ placeholderContent: target.account.config.streamPlaceholderContent
1592
+ }),
1593
+ nonce,
1594
+ timestamp
1595
+ }));
1596
+ return true;
1597
+ }
1598
+ }
1599
+
1600
+ // 加入 Pending 队列 (防抖/聚合)
1601
+ // 消息不会立即处理,而是等待防抖计时器结束(flushPending)后统一触发
1602
+ const { streamId, isNew } = streamStore.addPendingMessage({
1603
+ pendingKey,
1604
+ target,
1605
+ msg,
1606
+ msgContent,
1607
+ nonce,
1608
+ timestamp,
1609
+ debounceMs: (target.account.config as any).debounceMs
1610
+ });
949
1611
 
950
- const streamId = createStreamId();
951
- if (msg.msgid) msgidToStreamId.set(msg.msgid, streamId);
952
- streams.set(streamId, { streamId, msgid: msg.msgid, createdAt: Date.now(), updatedAt: Date.now(), started: false, finished: false, content: "" });
953
- storeActiveReply(streamId, msg.response_url);
954
- pendingInbounds.set(pendingKey, { streamId, target, msg, contents: [msgContent], msgids: msg.msgid ? [msg.msgid] : [], nonce, timestamp, createdAt: Date.now(), timeout: setTimeout(() => void flushPending(pendingKey), DEFAULT_DEBOUNCE_MS) });
1612
+ if (isNew) {
1613
+ storeActiveReply(streamId, msg.response_url, proxyUrl);
1614
+ }
955
1615
 
956
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: buildStreamPlaceholderReply({ streamId, placeholderContent: target.account.config.streamPlaceholderContent }), nonce, timestamp }));
957
- return true;
1616
+ jsonOk(res, buildEncryptedJsonReply({
1617
+ account: target.account,
1618
+ plaintextJson: buildStreamPlaceholderReply({
1619
+ streamId,
1620
+ placeholderContent: target.account.config.streamPlaceholderContent
1621
+ }),
1622
+ nonce,
1623
+ timestamp
1624
+ }));
1625
+ return true;
1626
+ } catch (err) {
1627
+ target.runtime.error?.(`[wecom] Bot message handler crashed: ${String(err)}`);
1628
+ // 尽量返回 200,避免企微重试风暴;同时给一个可见的错误文本
1629
+ jsonOk(res, buildEncryptedJsonReply({
1630
+ account: target.account,
1631
+ plaintextJson: { msgtype: "text", text: { content: "服务内部错误:Bot 处理异常,请稍后重试。" } },
1632
+ nonce,
1633
+ timestamp
1634
+ }));
1635
+ return true;
1636
+ }
958
1637
  }
959
1638
 
960
1639
  export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
961
- await useActiveReplyOnce(streamId, async (url) => {
962
- await axios.post(url, { msgtype: "text", text: { content } });
1640
+ await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1641
+ const res = await wecomFetch(
1642
+ responseUrl,
1643
+ {
1644
+ method: "POST",
1645
+ headers: { "Content-Type": "application/json" },
1646
+ body: JSON.stringify({ msgtype: "text", text: { content } }),
1647
+ },
1648
+ { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1649
+ );
1650
+ if (!res.ok) {
1651
+ throw new Error(`active send failed: ${res.status}`);
1652
+ }
963
1653
  });
964
1654
  }