@yanhaidao/wecom 2.3.4 → 2.3.9

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.
Files changed (108) hide show
  1. package/README.md +213 -339
  2. package/assets/03.bot.page.png +0 -0
  3. package/changelog/v2.3.9.md +22 -0
  4. package/compat-single-account.md +32 -2
  5. package/index.ts +5 -5
  6. package/package.json +8 -7
  7. package/src/agent/api-client.upload.test.ts +1 -2
  8. package/src/agent/handler.ts +82 -9
  9. package/src/agent/index.ts +1 -1
  10. package/src/app/account-runtime.ts +245 -0
  11. package/src/app/bootstrap.ts +29 -0
  12. package/src/app/index.ts +31 -0
  13. package/src/capability/agent/delivery-service.ts +79 -0
  14. package/src/capability/agent/fallback-policy.ts +13 -0
  15. package/src/capability/agent/index.ts +3 -0
  16. package/src/capability/agent/ingress-service.ts +38 -0
  17. package/src/capability/bot/dispatch-config.ts +47 -0
  18. package/src/capability/bot/fallback-delivery.ts +178 -0
  19. package/src/capability/bot/index.ts +1 -0
  20. package/src/capability/bot/local-path-delivery.ts +215 -0
  21. package/src/capability/bot/service.ts +56 -0
  22. package/src/capability/bot/stream-delivery.ts +379 -0
  23. package/src/capability/bot/stream-finalizer.ts +120 -0
  24. package/src/capability/bot/stream-orchestrator.ts +352 -0
  25. package/src/capability/bot/types.ts +8 -0
  26. package/src/capability/index.ts +2 -0
  27. package/src/channel.lifecycle.test.ts +9 -6
  28. package/src/channel.meta.test.ts +12 -0
  29. package/src/channel.ts +48 -21
  30. package/src/config/accounts.ts +223 -283
  31. package/src/config/derived-paths.test.ts +111 -0
  32. package/src/config/derived-paths.ts +41 -0
  33. package/src/config/index.ts +10 -12
  34. package/src/config/runtime-config.ts +46 -0
  35. package/src/config/schema.ts +59 -102
  36. package/src/domain/models.ts +7 -0
  37. package/src/domain/policies.ts +36 -0
  38. package/src/dynamic-agent.ts +6 -0
  39. package/src/gateway-monitor.ts +43 -93
  40. package/src/http.ts +23 -2
  41. package/src/monitor/limits.ts +7 -0
  42. package/src/monitor/state.ts +28 -508
  43. package/src/monitor.active.test.ts +3 -3
  44. package/src/monitor.integration.test.ts +0 -1
  45. package/src/monitor.ts +64 -2603
  46. package/src/monitor.webhook.test.ts +127 -42
  47. package/src/observability/audit-log.ts +48 -0
  48. package/src/observability/legacy-operational-event-store.ts +36 -0
  49. package/src/observability/raw-envelope-log.ts +28 -0
  50. package/src/observability/status-registry.ts +13 -0
  51. package/src/observability/transport-session-view.ts +14 -0
  52. package/src/onboarding.test.ts +219 -0
  53. package/src/onboarding.ts +88 -71
  54. package/src/outbound.test.ts +5 -5
  55. package/src/outbound.ts +18 -66
  56. package/src/runtime/dispatcher.ts +52 -0
  57. package/src/runtime/index.ts +4 -0
  58. package/src/runtime/outbound-intent.ts +4 -0
  59. package/src/runtime/reply-orchestrator.test.ts +38 -0
  60. package/src/runtime/reply-orchestrator.ts +55 -0
  61. package/src/runtime/routing-bridge.ts +19 -0
  62. package/src/runtime/session-manager.ts +76 -0
  63. package/src/runtime.ts +7 -14
  64. package/src/shared/command-auth.ts +1 -17
  65. package/src/shared/media-service.ts +36 -0
  66. package/src/shared/media-types.ts +5 -0
  67. package/src/store/active-reply-store.ts +42 -0
  68. package/src/store/interfaces.ts +11 -0
  69. package/src/store/memory-store.ts +43 -0
  70. package/src/store/stream-batch-store.ts +350 -0
  71. package/src/target.ts +28 -0
  72. package/src/transport/agent-api/client.ts +44 -0
  73. package/src/transport/agent-api/core.ts +367 -0
  74. package/src/transport/agent-api/delivery.ts +41 -0
  75. package/src/transport/agent-api/media-upload.ts +11 -0
  76. package/src/transport/agent-api/reply.ts +39 -0
  77. package/src/transport/agent-callback/http-handler.ts +47 -0
  78. package/src/transport/agent-callback/inbound.ts +5 -0
  79. package/src/transport/agent-callback/reply.ts +13 -0
  80. package/src/transport/agent-callback/request-handler.ts +244 -0
  81. package/src/transport/agent-callback/session.ts +23 -0
  82. package/src/transport/bot-webhook/active-reply.ts +36 -0
  83. package/src/transport/bot-webhook/http-handler.ts +48 -0
  84. package/src/transport/bot-webhook/inbound-normalizer.ts +371 -0
  85. package/src/transport/bot-webhook/inbound.ts +5 -0
  86. package/src/transport/bot-webhook/message-shape.ts +89 -0
  87. package/src/transport/bot-webhook/protocol.ts +148 -0
  88. package/src/transport/bot-webhook/reply.ts +15 -0
  89. package/src/transport/bot-webhook/request-handler.ts +394 -0
  90. package/src/transport/bot-webhook/session.ts +23 -0
  91. package/src/transport/bot-ws/inbound.ts +109 -0
  92. package/src/transport/bot-ws/reply.ts +48 -0
  93. package/src/transport/bot-ws/sdk-adapter.ts +180 -0
  94. package/src/transport/bot-ws/session.ts +28 -0
  95. package/src/transport/http/common.ts +109 -0
  96. package/src/transport/http/registry.ts +92 -0
  97. package/src/transport/http/request-handler.ts +84 -0
  98. package/src/transport/index.ts +14 -0
  99. package/src/types/account.ts +56 -91
  100. package/src/types/config.ts +59 -112
  101. package/src/types/constants.ts +20 -35
  102. package/src/types/events.ts +21 -0
  103. package/src/types/index.ts +14 -38
  104. package/src/types/legacy-stream.ts +50 -0
  105. package/src/types/runtime-context.ts +28 -0
  106. package/src/types/runtime.ts +161 -0
  107. package/src/agent/api-client.ts +0 -383
  108. package/src/monitor/types.ts +0 -136
package/src/monitor.ts CHANGED
@@ -1,869 +1,58 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
- import { pathToFileURL } from "node:url";
3
2
 
4
- import crypto from "node:crypto";
5
-
6
- import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk";
7
-
8
- import type { ResolvedAgentAccount } from "./types/index.js";
9
- import type { ResolvedBotAccount } from "./types/index.js";
10
- import type { WecomBotInboundMessage as WecomInboundMessage, WecomInboundQuote } from "./types/index.js";
11
- import { decryptWecomEncrypted, encryptWecomPlaintext, verifyWecomSignature, computeWecomMsgSignature } from "./crypto.js";
12
- import { extractEncryptFromXml } from "./crypto/xml.js";
13
3
  import { getWecomRuntime } from "./runtime.js";
14
- import { decryptWecomMediaWithMeta } from "./media.js";
15
- import { WEBHOOK_PATHS, LIMITS as WECOM_LIMITS } from "./types/constants.js";
16
- import { handleAgentWebhook } from "./agent/index.js";
17
- import { resolveWecomAccount, resolveWecomEgressProxyUrl, resolveWecomMediaMaxBytes, shouldRejectWecomDefaultRoute } from "./config/index.js";
18
- import { wecomFetch } from "./http.js";
19
- import { sendText as sendAgentText, sendMedia as sendAgentMedia, uploadMedia } from "./agent/api-client.js";
20
- import { extractAgentId, parseXml } from "./shared/xml-parser.js";
4
+ import { handleWecomHttpRequest } from "./transport/http/request-handler.js";
5
+ import { registerAgentWebhookTarget, registerWecomWebhookTarget } from "./transport/http/registry.js";
6
+ import { sendActiveMessage } from "./transport/bot-webhook/active-reply.js";
7
+ import { createBotWebhookRequestHandler } from "./transport/bot-webhook/request-handler.js";
8
+ import {
9
+ shouldProcessBotInboundMessage,
10
+ type BotInboundProcessDecision,
11
+ } from "./transport/bot-webhook/message-shape.js";
21
12
 
22
13
  /**
23
- * **核心监控模块 (Monitor Loop)**
24
- *
25
- * 负责接收企业微信 Webhook 回调,处理消息流、媒体解密、消息去重防抖,并分发给 Agent 处理。
26
- * 它是插件与企业微信交互的“心脏”,管理着所有会话的生命周期。
14
+ * Legacy compatibility bridge for the old monitor entrypoints.
15
+ *
16
+ * Bot webhook parsing and stream orchestration now live in
17
+ * `transport/bot-webhook/*` and `capability/bot/*`.
27
18
  */
28
19
 
29
- import type { WecomRuntimeEnv, WecomWebhookTarget, StreamState, PendingInbound, ActiveReplyState } from "./monitor/types.js";
30
- import { monitorState, LIMITS } from "./monitor/state.js";
31
- import { buildWecomUnauthorizedCommandPrompt, resolveWecomCommandAuthorization } from "./shared/command-auth.js";
32
- import { generateAgentId, shouldUseDynamicAgent, ensureDynamicAgentListed } from "./dynamic-agent.js";
20
+ import type {
21
+ WecomRuntimeAuditEvent,
22
+ WecomWebhookTarget,
23
+ } from "./types/runtime-context.js";
24
+ import { monitorState } from "./monitor/state.js";
25
+ import { createBotStreamOrchestrator } from "./capability/bot/stream-orchestrator.js";
33
26
 
34
- // Global State
35
- monitorState.streamStore.setFlushHandler((pending) => void flushPending(pending));
36
-
37
- // Stores (convenience aliases)
38
27
  const streamStore = monitorState.streamStore;
39
- const activeReplyStore = monitorState.activeReplyStore;
40
-
41
- // Target Registry
42
- const webhookTargets = new Map<string, WecomWebhookTarget[]>();
43
-
44
- // Agent 模式 target 存储
45
- type AgentWebhookTarget = {
46
- agent: ResolvedAgentAccount;
47
- config: OpenClawConfig;
48
- runtime: WecomRuntimeEnv;
49
- path: string;
50
- // ...
51
- };
52
- const agentTargets = new Map<string, AgentWebhookTarget[]>();
53
-
54
- const STREAM_MAX_BYTES = LIMITS.STREAM_MAX_BYTES;
55
- const STREAM_MAX_DM_BYTES = 200_000;
56
- const BOT_WINDOW_MS = 6 * 60 * 1000;
57
- const BOT_SWITCH_MARGIN_MS = 30 * 1000;
58
- // REQUEST_TIMEOUT_MS is available in LIMITS but defined locally in other functions, we can leave it or use LIMITS.REQUEST_TIMEOUT_MS
59
- // Keeping local variables for now if they are used, or we can replace usages.
60
- // The constants STREAM_TTL_MS and ACTIVE_REPLY_TTL_MS are internalized in state.ts, so we can remove them here.
61
-
62
- /** 错误提示信息 */
63
- const ERROR_HELP = "\n\n遇到问题?联系作者: YanHaidao (微信: YanHaidao)";
64
-
65
- /**
66
- * **normalizeWebhookPath (标准化 Webhook 路径)**
67
- *
68
- * 将用户配置的路径统一格式化为以 `/` 开头且不以 `/` 结尾的字符串。
69
- * 例如: `wecom` -> `/wecom`
70
- */
71
- function normalizeWebhookPath(raw: string): string {
72
- const trimmed = raw.trim();
73
- if (!trimmed) return "/";
74
- const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
75
- if (withSlash.length > 1 && withSlash.endsWith("/")) return withSlash.slice(0, -1);
76
- return withSlash;
77
- }
78
-
79
-
80
- /**
81
- * **ensurePruneTimer (启动清理定时器)**
82
- *
83
- * 当有活跃的 Webhook Target 注册时,调用 MonitorState 启动自动清理任务。
84
- * 清理任务包括:删除过期 Stream、移除无效 Active Reply URL 等。
85
- */
86
- function ensurePruneTimer() {
87
- monitorState.startPruning();
88
- }
89
-
90
- /**
91
- * **checkPruneTimer (检查并停止清理定时器)**
92
- *
93
- * 当没有活跃的 Webhook Target 时(Bot 和 Agent 均移除),停止清理任务以节省资源。
94
- */
95
- function checkPruneTimer() {
96
- const hasBot = webhookTargets.size > 0;
97
- const hasAgent = agentTargets.size > 0;
98
- if (!hasBot && !hasAgent) {
99
- monitorState.stopPruning();
100
- }
101
- }
102
-
103
-
104
-
105
-
106
- function truncateUtf8Bytes(text: string, maxBytes: number): string {
107
- const buf = Buffer.from(text, "utf8");
108
- if (buf.length <= maxBytes) return text;
109
- const slice = buf.subarray(buf.length - maxBytes);
110
- return slice.toString("utf8");
111
- }
112
-
113
- /**
114
- * **jsonOk (返回 JSON 响应)**
115
- *
116
- * 辅助函数:向企业微信服务器返回 HTTP 200 及 JSON 内容。
117
- * 注意企业微信要求加密内容以 Content-Type: text/plain 返回,但这里为了通用性使用了标准 JSON 响应,
118
- * 并通过 Content-Type 修正适配。
119
- */
120
- function jsonOk(res: ServerResponse, body: unknown): void {
121
- res.statusCode = 200;
122
- // WeCom's reference implementation returns the encrypted JSON as text/plain.
123
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
124
- res.end(JSON.stringify(body));
125
- }
126
28
 
127
- /**
128
- * **readJsonBody (读取 JSON 请求体)**
129
- *
130
- * 异步读取 HTTP 请求体并解析为 JSON。包含大小限制检查,防止大包攻击。
131
- *
132
- * @param req HTTP 请求对象
133
- * @param maxBytes 最大允许字节数
134
- */
135
- async function readJsonBody(req: IncomingMessage, maxBytes: number) {
136
- const chunks: Buffer[] = [];
137
- let total = 0;
138
- return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
139
- req.on("data", (chunk: Buffer) => {
140
- total += chunk.length;
141
- if (total > maxBytes) {
142
- resolve({ ok: false, error: "payload too large" });
143
- req.destroy();
144
- return;
145
- }
146
- chunks.push(chunk);
147
- });
148
- req.on("end", () => {
149
- try {
150
- const raw = Buffer.concat(chunks).toString("utf8");
151
- if (!raw.trim()) {
152
- resolve({ ok: false, error: "empty payload" });
153
- return;
154
- }
155
- resolve({ ok: true, value: JSON.parse(raw) as unknown });
156
- } catch (err) {
157
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
158
- }
159
- });
160
- req.on("error", (err) => {
161
- resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
162
- });
163
- });
164
- }
165
-
166
- /**
167
- * **buildEncryptedJsonReply (构建加密回复)**
168
- *
169
- * 将明文 JSON 包装成企业微信要求的加密 XML/JSON 格式(此处实际返回 JSON 结构)。
170
- * 包含签名计算逻辑。
171
- */
172
- function buildEncryptedJsonReply(params: {
173
- account: ResolvedBotAccount;
174
- plaintextJson: unknown;
175
- nonce: string;
176
- timestamp: string;
177
- }): { encrypt: string; msgsignature: string; timestamp: string; nonce: string } {
178
- const plaintext = JSON.stringify(params.plaintextJson ?? {});
179
- const encrypt = encryptWecomPlaintext({
180
- encodingAESKey: params.account.encodingAESKey ?? "",
181
- receiveId: params.account.receiveId ?? "",
182
- plaintext,
183
- });
184
- const msgsignature = computeWecomMsgSignature({
185
- token: params.account.token ?? "",
186
- timestamp: params.timestamp,
187
- nonce: params.nonce,
188
- encrypt,
189
- });
190
- return {
191
- encrypt,
192
- msgsignature,
193
- timestamp: params.timestamp,
194
- nonce: params.nonce,
195
- };
196
- }
197
-
198
- function resolveQueryParams(req: IncomingMessage): URLSearchParams {
199
- const url = new URL(req.url ?? "/", "http://localhost");
200
- return url.searchParams;
201
- }
202
-
203
- function resolvePath(req: IncomingMessage): string {
204
- const url = new URL(req.url ?? "/", "http://localhost");
205
- return normalizeWebhookPath(url.pathname || "/");
206
- }
207
-
208
- function resolveSignatureParam(params: URLSearchParams): string {
209
- return (
210
- params.get("msg_signature") ??
211
- params.get("msgsignature") ??
212
- params.get("signature") ??
213
- ""
214
- );
215
- }
216
-
217
- type RouteFailureReason =
218
- | "wecom_account_not_found"
219
- | "wecom_account_conflict"
220
- | "wecom_identity_mismatch"
221
- | "wecom_matrix_path_required";
222
-
223
- function isNonMatrixWecomBasePath(path: string): boolean {
224
- return (
225
- path === WEBHOOK_PATHS.BOT ||
226
- path === WEBHOOK_PATHS.BOT_ALT ||
227
- path === WEBHOOK_PATHS.AGENT ||
228
- path === WEBHOOK_PATHS.BOT_PLUGIN ||
229
- path === WEBHOOK_PATHS.AGENT_PLUGIN
230
- );
231
- }
232
-
233
- function hasMatrixExplicitRoutesRegistered(): boolean {
234
- for (const key of webhookTargets.keys()) {
235
- if (key.startsWith(`${WEBHOOK_PATHS.BOT_ALT}/`)) return true;
236
- if (key.startsWith(`${WEBHOOK_PATHS.BOT_PLUGIN}/`)) return true;
237
- }
238
- for (const key of agentTargets.keys()) {
239
- if (key.startsWith(`${WEBHOOK_PATHS.AGENT}/`)) return true;
240
- if (key.startsWith(`${WEBHOOK_PATHS.AGENT_PLUGIN}/`)) return true;
241
- }
242
- return false;
243
- }
244
-
245
- function maskAccountId(accountId: string): string {
246
- const normalized = accountId.trim();
247
- if (!normalized) return "***";
248
- if (normalized.length <= 4) return `${normalized[0] ?? "*"}***`;
249
- return `${normalized.slice(0, 2)}***${normalized.slice(-2)}`;
250
- }
251
-
252
- function logRouteFailure(params: {
253
- reqId: string;
254
- path: string;
255
- method: string;
256
- reason: RouteFailureReason;
257
- candidateAccountIds: string[];
258
- }): void {
259
- const payload = {
260
- reqId: params.reqId,
261
- path: params.path,
262
- method: params.method,
263
- reason: params.reason,
264
- candidateAccountIds: params.candidateAccountIds.map(maskAccountId),
265
- };
266
- console.error(`[wecom] route-error ${JSON.stringify(payload)}`);
267
- }
268
-
269
- function writeRouteFailure(
270
- res: ServerResponse,
271
- reason: RouteFailureReason,
272
- message: string,
29
+ function recordWebhookOperationalEvent(
30
+ target:
31
+ | Pick<WecomWebhookTarget, "account" | "auditSink">
32
+ | { agent: { accountId: string }; auditSink?: (event: WecomRuntimeAuditEvent) => void },
33
+ event: WecomRuntimeAuditEvent,
273
34
  ): void {
274
- res.statusCode = 401;
275
- res.setHeader("Content-Type", "application/json; charset=utf-8");
276
- res.end(JSON.stringify({ error: reason, message }));
277
- }
278
-
279
- async function readTextBody(req: IncomingMessage, maxBytes: number): Promise<{ ok: true; value: string } | { ok: false; error: string }> {
280
- const chunks: Buffer[] = [];
281
- let total = 0;
282
- return await new Promise((resolve) => {
283
- req.on("data", (chunk: Buffer) => {
284
- total += chunk.length;
285
- if (total > maxBytes) {
286
- resolve({ ok: false as const, error: "payload too large" });
287
- req.destroy();
288
- return;
289
- }
290
- chunks.push(chunk);
291
- });
292
- req.on("end", () => {
293
- resolve({ ok: true as const, value: Buffer.concat(chunks).toString("utf8") });
294
- });
295
- req.on("error", (err) => {
296
- resolve({ ok: false as const, error: err instanceof Error ? err.message : String(err) });
297
- });
35
+ const accountId = "account" in target ? target.account.accountId : target.agent.accountId;
36
+ monitorState.operationalEvents.append({
37
+ accountId,
38
+ transport: event.transport,
39
+ category: event.category,
40
+ summary: event.summary,
41
+ messageId: event.messageId,
298
42
  });
43
+ target.auditSink?.(event);
299
44
  }
300
45
 
301
- function normalizeAgentIdValue(value: unknown): number | undefined {
302
- if (typeof value === "number" && Number.isFinite(value)) return value;
303
- const raw = String(value ?? "").trim();
304
- if (!raw) return undefined;
305
- const parsed = Number(raw);
306
- return Number.isFinite(parsed) ? parsed : undefined;
307
- }
308
-
309
- function resolveBotIdentitySet(target: WecomWebhookTarget): Set<string> {
310
- const ids = new Set<string>();
311
- const single = target.account.config.aibotid?.trim();
312
- if (single) ids.add(single);
313
- for (const botId of target.account.config.botIds ?? []) {
314
- const normalized = String(botId ?? "").trim();
315
- if (normalized) ids.add(normalized);
316
- }
317
- return ids;
318
- }
319
-
320
- function buildStreamPlaceholderReply(params: {
321
- streamId: string;
322
- placeholderContent?: string;
323
- }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
324
- const content = params.placeholderContent?.trim() || "1";
325
- return {
326
- msgtype: "stream",
327
- stream: {
328
- id: params.streamId,
329
- finish: false,
330
- // Spec: "第一次回复内容为 1" works as a minimal placeholder.
331
- content,
332
- },
333
- };
334
- }
335
-
336
- function buildStreamImmediateTextReply(params: { streamId: string; content: string }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
337
- return {
338
- msgtype: "stream",
339
- stream: {
340
- id: params.streamId,
341
- finish: true,
342
- content: params.content.trim() || "1",
343
- },
344
- };
345
- }
346
-
347
- function buildStreamTextPlaceholderReply(params: { streamId: string; content: string }): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
348
- return {
349
- msgtype: "stream",
350
- stream: {
351
- id: params.streamId,
352
- finish: false,
353
- content: params.content.trim() || "1",
354
- },
355
- };
356
- }
357
-
358
- function buildStreamReplyFromState(state: StreamState): { msgtype: "stream"; stream: { id: string; finish: boolean; content: string } } {
359
- const content = truncateUtf8Bytes(state.content, STREAM_MAX_BYTES);
360
- // Images handled? The original code had image logic.
361
- // Ensure we return message item if images exist
362
- return {
363
- msgtype: "stream",
364
- stream: {
365
- id: state.streamId,
366
- finish: state.finished,
367
- content,
368
- ...(state.finished && state.images?.length ? {
369
- msg_item: state.images.map(img => ({
370
- msgtype: "image",
371
- image: { base64: img.base64, md5: img.md5 }
372
- }))
373
- } : {})
374
- },
375
- };
376
- }
377
-
378
- function appendDmContent(state: StreamState, text: string): void {
379
- const next = state.dmContent ? `${state.dmContent}\n\n${text}`.trim() : text.trim();
380
- state.dmContent = truncateUtf8Bytes(next, STREAM_MAX_DM_BYTES);
381
- }
382
-
383
- function computeTaskKey(target: WecomWebhookTarget, msg: WecomInboundMessage): string | undefined {
384
- const msgid = msg.msgid ? String(msg.msgid) : "";
385
- if (!msgid) return undefined;
386
- const aibotid = String((msg as any).aibotid ?? "unknown").trim() || "unknown";
387
- return `bot:${target.account.accountId}:${aibotid}:${msgid}`;
388
- }
389
-
390
- function resolveAgentAccountOrUndefined(cfg: OpenClawConfig, accountId: string): ResolvedAgentAccount | undefined {
391
- const agent = resolveWecomAccount({ cfg, accountId }).agent;
392
- return agent?.configured ? agent : undefined;
393
- }
394
-
395
- function buildFallbackPrompt(params: {
396
- kind: "media" | "timeout" | "error";
397
- agentConfigured: boolean;
398
- userId?: string;
399
- filename?: string;
400
- chatType?: "group" | "direct";
401
- }): string {
402
- const who = params.userId ? `(${params.userId})` : "";
403
- const scope = params.chatType === "group" ? "群聊" : params.chatType === "direct" ? "私聊" : "会话";
404
- if (!params.agentConfigured) {
405
- return `${scope}中需要通过应用私信发送${params.filename ? `(${params.filename})` : ""},但管理员尚未配置企业微信自建应用(Agent)通道。请联系管理员配置后再试。${who}`.trim();
406
- }
407
- if (!params.userId) {
408
- return `${scope}中需要通过应用私信兜底发送${params.filename ? `(${params.filename})` : ""},但本次回调未能识别触发者 userid(请检查企微回调字段 from.userid / fromuserid)。请联系管理员排查配置。`.trim();
409
- }
410
- if (params.kind === "media") {
411
- return `已生成文件${params.filename ? `(${params.filename})` : ""},将通过应用私信发送给你。${who}`.trim();
412
- }
413
- if (params.kind === "timeout") {
414
- return `内容较长,为避免超时,后续内容将通过应用私信发送给你。${who}`.trim();
415
- }
416
- return `交付出现异常,已尝试通过应用私信发送给你。${who}`.trim();
417
- }
418
-
419
- async function sendBotFallbackPromptNow(params: { streamId: string; text: string }): Promise<void> {
420
- const responseUrl = getActiveReplyUrl(params.streamId);
421
- if (!responseUrl) {
422
- throw new Error("no response_url(无法主动推送群内提示)");
423
- }
424
- await useActiveReplyOnce(params.streamId, async ({ responseUrl, proxyUrl }) => {
425
- const payload = {
426
- msgtype: "stream",
427
- stream: {
428
- id: params.streamId,
429
- finish: true,
430
- content: truncateUtf8Bytes(params.text, STREAM_MAX_BYTES) || "1",
431
- },
432
- };
433
- const res = await wecomFetch(
434
- responseUrl,
435
- {
436
- method: "POST",
437
- headers: { "Content-Type": "application/json" },
438
- body: JSON.stringify(payload),
439
- },
440
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
441
- );
442
- if (!res.ok) {
443
- throw new Error(`fallback prompt push failed: ${res.status}`);
444
- }
445
- });
446
- }
447
-
448
- async function pushFinalStreamReplyNow(streamId: string): Promise<void> {
449
- const state = streamStore.getStream(streamId);
450
- const responseUrl = getActiveReplyUrl(streamId);
451
- if (!state || !responseUrl) return;
452
- const finalReply = buildStreamReplyFromState(state) as unknown as Record<string, unknown>;
453
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
454
- const res = await wecomFetch(
455
- responseUrl,
456
- {
457
- method: "POST",
458
- headers: { "Content-Type": "application/json" },
459
- body: JSON.stringify(finalReply),
460
- },
461
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
462
- );
463
- if (!res.ok) {
464
- throw new Error(`final stream push failed: ${res.status}`);
465
- }
466
- });
467
- }
468
-
469
- async function sendAgentDmText(params: {
470
- agent: ResolvedAgentAccount;
471
- userId: string;
472
- text: string;
473
- core: PluginRuntime;
474
- }): Promise<void> {
475
- const chunks = params.core.channel.text.chunkText(params.text, 20480);
476
- for (const chunk of chunks) {
477
- const trimmed = chunk.trim();
478
- if (!trimmed) continue;
479
- await sendAgentText({ agent: params.agent, toUser: params.userId, text: trimmed });
480
- }
481
- }
482
-
483
- async function sendAgentDmMedia(params: {
484
- agent: ResolvedAgentAccount;
485
- userId: string;
486
- mediaUrlOrPath: string;
487
- contentType?: string;
488
- filename: string;
489
- }): Promise<void> {
490
- let buffer: Buffer;
491
- let inferredContentType = params.contentType;
492
-
493
- const looksLikeUrl = /^https?:\/\//i.test(params.mediaUrlOrPath);
494
- if (looksLikeUrl) {
495
- const res = await fetch(params.mediaUrlOrPath, { signal: AbortSignal.timeout(30_000) });
496
- if (!res.ok) throw new Error(`media download failed: ${res.status}`);
497
- buffer = Buffer.from(await res.arrayBuffer());
498
- inferredContentType = inferredContentType || res.headers.get("content-type") || "application/octet-stream";
499
- } else {
500
- const fs = await import("node:fs/promises");
501
- buffer = await fs.readFile(params.mediaUrlOrPath);
502
- }
503
-
504
- let mediaType: "image" | "voice" | "video" | "file" = "file";
505
- const ct = (inferredContentType || "").toLowerCase();
506
- if (ct.startsWith("image/")) mediaType = "image";
507
- else if (ct.startsWith("audio/")) mediaType = "voice";
508
- else if (ct.startsWith("video/")) mediaType = "video";
509
-
510
- const mediaId = await uploadMedia({
511
- agent: params.agent,
512
- type: mediaType,
513
- buffer,
514
- filename: params.filename,
515
- });
516
- await sendAgentMedia({
517
- agent: params.agent,
518
- toUser: params.userId,
519
- mediaId,
520
- mediaType,
46
+ function recordBotOperationalEvent(
47
+ target: Pick<WecomWebhookTarget, "account" | "auditSink">,
48
+ event: Omit<WecomRuntimeAuditEvent, "transport">,
49
+ ): void {
50
+ recordWebhookOperationalEvent(target, {
51
+ transport: "bot-webhook",
52
+ ...event,
521
53
  });
522
54
  }
523
55
 
524
- function extractLocalImagePathsFromText(params: {
525
- text: string;
526
- mustAlsoAppearIn: string;
527
- }): string[] {
528
- const text = params.text;
529
- const mustAlsoAppearIn = params.mustAlsoAppearIn;
530
- if (!text.trim()) return [];
531
-
532
- // Conservative: only accept common absolute paths for macOS/Linux hosts.
533
- // Also require that the exact path appeared in the user's original message to prevent exfil.
534
- const exts = "(png|jpg|jpeg|gif|webp|bmp)";
535
- const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+?\.${exts})`, "gi");
536
- const found = new Set<string>();
537
- let m: RegExpExecArray | null;
538
- while ((m = re.exec(text))) {
539
- const p = m[1];
540
- if (!p) continue;
541
- if (!mustAlsoAppearIn.includes(p)) continue;
542
- found.add(p);
543
- }
544
- return Array.from(found);
545
- }
546
-
547
- function extractLocalFilePathsFromText(text: string): string[] {
548
- if (!text.trim()) return [];
549
-
550
- // Conservative: only accept common absolute paths for macOS/Linux hosts.
551
- // This is primarily for “send local file” style requests (operator/debug usage).
552
- const re = new RegExp(String.raw`(\/(?:Users|tmp|root|home)\/[^\s"'<>]+)`, "g");
553
- const found = new Set<string>();
554
- let m: RegExpExecArray | null;
555
- while ((m = re.exec(text))) {
556
- const p = m[1];
557
- if (!p) continue;
558
- found.add(p);
559
- }
560
- return Array.from(found);
561
- }
562
-
563
- const MIME_BY_EXT: Record<string, string> = {
564
- png: "image/png",
565
- jpg: "image/jpeg",
566
- jpeg: "image/jpeg",
567
- gif: "image/gif",
568
- webp: "image/webp",
569
- bmp: "image/bmp",
570
- pdf: "application/pdf",
571
- txt: "text/plain",
572
- csv: "text/csv",
573
- tsv: "text/tab-separated-values",
574
- md: "text/markdown",
575
- json: "application/json",
576
- xml: "application/xml",
577
- yaml: "application/yaml",
578
- yml: "application/yaml",
579
- zip: "application/zip",
580
- rar: "application/vnd.rar",
581
- "7z": "application/x-7z-compressed",
582
- tar: "application/x-tar",
583
- gz: "application/gzip",
584
- tgz: "application/gzip",
585
- doc: "application/msword",
586
- docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
587
- xls: "application/vnd.ms-excel",
588
- xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
589
- ppt: "application/vnd.ms-powerpoint",
590
- pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
591
- rtf: "application/rtf",
592
- odt: "application/vnd.oasis.opendocument.text",
593
- mp3: "audio/mpeg",
594
- wav: "audio/wav",
595
- ogg: "audio/ogg",
596
- amr: "voice/amr",
597
- m4a: "audio/mp4",
598
- mp4: "video/mp4",
599
- mov: "video/quicktime",
600
- };
601
-
602
- const EXT_BY_MIME: Record<string, string> = {
603
- ...Object.fromEntries(Object.entries(MIME_BY_EXT).map(([ext, mime]) => [mime, ext])),
604
- "application/octet-stream": "bin",
605
- };
606
-
607
- const GENERIC_CONTENT_TYPES = new Set([
608
- "application/octet-stream",
609
- "binary/octet-stream",
610
- "application/download",
611
- ]);
612
-
613
- function normalizeContentType(raw?: string | null): string | undefined {
614
- const normalized = String(raw ?? "").trim().split(";")[0]?.trim().toLowerCase();
615
- return normalized || undefined;
616
- }
617
-
618
- function isGenericContentType(raw?: string | null): boolean {
619
- const normalized = normalizeContentType(raw);
620
- if (!normalized) return true;
621
- return GENERIC_CONTENT_TYPES.has(normalized);
622
- }
623
-
624
- function guessContentTypeFromPath(filePath: string): string | undefined {
625
- const ext = filePath.split(".").pop()?.toLowerCase();
626
- if (!ext) return undefined;
627
- return MIME_BY_EXT[ext];
628
- }
629
-
630
- function guessExtensionFromContentType(contentType?: string): string | undefined {
631
- const normalized = normalizeContentType(contentType);
632
- if (!normalized) return undefined;
633
- if (normalized === "image/jpeg") return "jpg";
634
- return EXT_BY_MIME[normalized];
635
- }
636
-
637
- function extractFileNameFromUrl(rawUrl?: string): string | undefined {
638
- const s = String(rawUrl ?? "").trim();
639
- if (!s) return undefined;
640
- try {
641
- const u = new URL(s);
642
- const name = decodeURIComponent(u.pathname.split("/").pop() ?? "").trim();
643
- return name || undefined;
644
- } catch {
645
- return undefined;
646
- }
647
- }
648
-
649
- function sanitizeInboundFilename(raw?: string): string | undefined {
650
- const s = String(raw ?? "").trim();
651
- if (!s) return undefined;
652
- const base = s.split(/[\\/]/).pop()?.trim() ?? "";
653
- if (!base) return undefined;
654
- const sanitized = base.replace(/[\u0000-\u001f<>:"|?*]/g, "_").trim();
655
- return sanitized || undefined;
656
- }
657
-
658
- function hasLikelyExtension(name?: string): boolean {
659
- if (!name) return false;
660
- return /\.[a-z0-9]{1,16}$/i.test(name);
661
- }
662
-
663
- function detectMimeFromBuffer(buffer: Buffer): string | undefined {
664
- if (!buffer || buffer.length < 4) return undefined;
665
-
666
- // PNG
667
- if (
668
- buffer.length >= 8 &&
669
- buffer[0] === 0x89 &&
670
- buffer[1] === 0x50 &&
671
- buffer[2] === 0x4e &&
672
- buffer[3] === 0x47 &&
673
- buffer[4] === 0x0d &&
674
- buffer[5] === 0x0a &&
675
- buffer[6] === 0x1a &&
676
- buffer[7] === 0x0a
677
- ) {
678
- return "image/png";
679
- }
680
-
681
- // JPEG
682
- if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
683
- return "image/jpeg";
684
- }
685
-
686
- // GIF
687
- if (buffer.subarray(0, 6).toString("ascii") === "GIF87a" || buffer.subarray(0, 6).toString("ascii") === "GIF89a") {
688
- return "image/gif";
689
- }
690
-
691
- // WEBP
692
- if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
693
- return "image/webp";
694
- }
695
-
696
- // BMP
697
- if (buffer[0] === 0x42 && buffer[1] === 0x4d) {
698
- return "image/bmp";
699
- }
700
-
701
- // PDF
702
- if (buffer.subarray(0, 5).toString("ascii") === "%PDF-") {
703
- return "application/pdf";
704
- }
705
-
706
- // OGG
707
- if (buffer.subarray(0, 4).toString("ascii") === "OggS") {
708
- return "audio/ogg";
709
- }
710
-
711
- // WAV
712
- if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
713
- return "audio/wav";
714
- }
715
-
716
- // MP3
717
- if (buffer.subarray(0, 3).toString("ascii") === "ID3" || (buffer[0] === 0xff && (buffer[1] & 0xe0) === 0xe0)) {
718
- return "audio/mpeg";
719
- }
720
-
721
- // MP4/MOV family
722
- if (buffer.length >= 12 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
723
- return "video/mp4";
724
- }
725
-
726
- // Legacy Office (OLE Compound File)
727
- if (
728
- buffer.length >= 8 &&
729
- buffer[0] === 0xd0 &&
730
- buffer[1] === 0xcf &&
731
- buffer[2] === 0x11 &&
732
- buffer[3] === 0xe0 &&
733
- buffer[4] === 0xa1 &&
734
- buffer[5] === 0xb1 &&
735
- buffer[6] === 0x1a &&
736
- buffer[7] === 0xe1
737
- ) {
738
- return "application/msword";
739
- }
740
-
741
- // ZIP / OOXML
742
- const zipMagic =
743
- (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x03 && buffer[3] === 0x04) ||
744
- (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x05 && buffer[3] === 0x06) ||
745
- (buffer[0] === 0x50 && buffer[1] === 0x4b && buffer[2] === 0x07 && buffer[3] === 0x08);
746
- if (zipMagic) {
747
- const probe = buffer.subarray(0, Math.min(buffer.length, 512 * 1024));
748
- if (probe.includes(Buffer.from("word/"))) {
749
- return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
750
- }
751
- if (probe.includes(Buffer.from("xl/"))) {
752
- return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
753
- }
754
- if (probe.includes(Buffer.from("ppt/"))) {
755
- return "application/vnd.openxmlformats-officedocument.presentationml.presentation";
756
- }
757
- return "application/zip";
758
- }
759
-
760
- // Plain text heuristic
761
- const sample = buffer.subarray(0, Math.min(buffer.length, 4096));
762
- let printable = 0;
763
- for (const b of sample) {
764
- if (b === 0x00) return undefined;
765
- if (b === 0x09 || b === 0x0a || b === 0x0d || (b >= 0x20 && b <= 0x7e)) {
766
- printable += 1;
767
- }
768
- }
769
- if (sample.length > 0 && printable / sample.length > 0.95) {
770
- return "text/plain";
771
- }
772
-
773
- return undefined;
774
- }
775
-
776
- function resolveInlineFileName(input: unknown): string | undefined {
777
- const raw = String(input ?? "").trim();
778
- return sanitizeInboundFilename(raw);
779
- }
780
-
781
- function pickBotFileName(msg: WecomInboundMessage, item?: Record<string, any>): string | undefined {
782
- const fromItem = item
783
- ? resolveInlineFileName(
784
- item?.filename ??
785
- item?.file_name ??
786
- item?.fileName ??
787
- item?.name ??
788
- item?.title,
789
- )
790
- : undefined;
791
- if (fromItem) return fromItem;
792
-
793
- const fromFile = resolveInlineFileName(
794
- (msg as any)?.file?.filename ??
795
- (msg as any)?.file?.file_name ??
796
- (msg as any)?.file?.fileName ??
797
- (msg as any)?.file?.name ??
798
- (msg as any)?.file?.title ??
799
- (msg as any)?.filename ??
800
- (msg as any)?.fileName ??
801
- (msg as any)?.FileName,
802
- );
803
- return fromFile;
804
- }
805
-
806
- function inferInboundMediaMeta(params: {
807
- kind: "image" | "file";
808
- buffer: Buffer;
809
- sourceUrl?: string;
810
- sourceContentType?: string;
811
- sourceFilename?: string;
812
- explicitFilename?: string;
813
- }): { contentType: string; filename: string } {
814
- const headerType = normalizeContentType(params.sourceContentType);
815
- const magicType = detectMimeFromBuffer(params.buffer);
816
- const rawUrlName = sanitizeInboundFilename(extractFileNameFromUrl(params.sourceUrl));
817
- const guessedByUrl = hasLikelyExtension(rawUrlName) ? rawUrlName : undefined;
818
- const explicitName = sanitizeInboundFilename(params.explicitFilename);
819
- const sourceName = sanitizeInboundFilename(params.sourceFilename);
820
- const chosenName = explicitName || sourceName || guessedByUrl;
821
- const typeByName = chosenName ? guessContentTypeFromPath(chosenName) : undefined;
822
-
823
- let contentType: string;
824
- if (params.kind === "image") {
825
- if (magicType?.startsWith("image/")) contentType = magicType;
826
- else if (headerType?.startsWith("image/")) contentType = headerType;
827
- else if (typeByName?.startsWith("image/")) contentType = typeByName;
828
- else contentType = "image/jpeg";
829
- } else {
830
- contentType =
831
- magicType ||
832
- (!isGenericContentType(headerType) ? headerType! : undefined) ||
833
- typeByName ||
834
- "application/octet-stream";
835
- }
836
-
837
- const hasExt = Boolean(chosenName && /\.[a-z0-9]{1,16}$/i.test(chosenName));
838
- const ext = guessExtensionFromContentType(contentType) || (params.kind === "image" ? "jpg" : "bin");
839
- const filename = chosenName
840
- ? (hasExt ? chosenName : `${chosenName}.${ext}`)
841
- : `${params.kind}.${ext}`;
842
-
843
- return { contentType, filename };
844
- }
845
-
846
- function looksLikeSendLocalFileIntent(rawBody: string): boolean {
847
- const t = rawBody.trim();
848
- if (!t) return false;
849
- // Heuristic: treat as “send file” intent only when there is an explicit local path AND a send-ish verb.
850
- // This avoids accidentally sending a file when the user is merely referencing a path.
851
- return /(发送|发给|发到|转发|把.*发|把.*发送|帮我发|给我发)/.test(t);
852
- }
853
-
854
- function storeActiveReply(streamId: string, responseUrl?: string, proxyUrl?: string): void {
855
- activeReplyStore.store(streamId, responseUrl, proxyUrl);
856
- }
857
-
858
- function getActiveReplyUrl(streamId: string): string | undefined {
859
- return activeReplyStore.getUrl(streamId);
860
- }
861
-
862
- async function useActiveReplyOnce(streamId: string, fn: (params: { responseUrl: string; proxyUrl?: string }) => Promise<void>): Promise<void> {
863
- return activeReplyStore.use(streamId, fn);
864
- }
865
-
866
-
867
56
  function logVerbose(target: WecomWebhookTarget, message: string): void {
868
57
  const should =
869
58
  target.core.logging?.shouldLogVerbose?.() ??
@@ -882,1240 +71,26 @@ function logInfo(target: WecomWebhookTarget, message: string): void {
882
71
  target.runtime.log?.(`[wecom] ${message}`);
883
72
  }
884
73
 
885
- function resolveWecomSenderUserId(msg: WecomInboundMessage): string | undefined {
886
- const direct = msg.from?.userid?.trim();
887
- if (direct) return direct;
888
- const legacy = String((msg as any).fromuserid ?? (msg as any).from_userid ?? (msg as any).fromUserId ?? "").trim();
889
- return legacy || undefined;
890
- }
891
-
892
- export type BotInboundProcessDecision = {
893
- shouldProcess: boolean;
894
- reason: string;
895
- senderUserId?: string;
896
- chatId?: string;
897
- };
898
-
899
- /**
900
- * 仅允许“真实用户消息”进入 Bot 会话:
901
- * - 发送者缺失 -> 丢弃,避免落到 unknown 会话导致串会话
902
- * - 发送者是 sys -> 丢弃,避免系统回调触发 AI 自动回复
903
- * - 群消息缺失 chatid -> 丢弃,避免 group:unknown 串群
904
- */
905
- export function shouldProcessBotInboundMessage(msg: WecomInboundMessage): BotInboundProcessDecision {
906
- const senderUserId = resolveWecomSenderUserId(msg)?.trim();
907
- if (!senderUserId) {
908
- return { shouldProcess: false, reason: "missing_sender" };
909
- }
910
- if (senderUserId.toLowerCase() === "sys") {
911
- return { shouldProcess: false, reason: "system_sender" };
912
- }
913
-
914
- const chatType = String(msg.chattype ?? "").trim().toLowerCase();
915
- if (chatType === "group") {
916
- const chatId = msg.chatid?.trim();
917
- if (!chatId) {
918
- return { shouldProcess: false, reason: "missing_chatid", senderUserId };
919
- }
920
- return { shouldProcess: true, reason: "user_message", senderUserId, chatId };
921
- }
922
-
923
- return { shouldProcess: true, reason: "user_message", senderUserId, chatId: senderUserId };
924
- }
925
-
926
- function parseWecomPlainMessage(raw: string): WecomInboundMessage {
927
- const parsed = JSON.parse(raw) as unknown;
928
- if (!parsed || typeof parsed !== "object") {
929
- return {};
930
- }
931
- return parsed as WecomInboundMessage;
932
- }
933
-
934
- type InboundResult = {
935
- body: string;
936
- media?: {
937
- buffer: Buffer;
938
- contentType: string;
939
- filename: string;
940
- };
941
- };
74
+ const botStreamOrchestrator = createBotStreamOrchestrator({
75
+ streamStore,
76
+ recordBotOperationalEvent,
77
+ });
942
78
 
943
- /**
944
- * **processInboundMessage (处理接收消息)**
945
- *
946
- * 解析企业微信传入的消息体。
947
- * 主要职责:
948
- * 1. 识别媒体消息(Image/File/Mixed)。
949
- * 2. 如果存在媒体文件,调用 `media.ts` 进行解密和下载。
950
- * 3. 构造统一的 `InboundResult` 供后续 Agent 处理。
951
- *
952
- * @param target Webhook 目标配置
953
- * @param msg 企业微信原始消息对象
954
- */
955
- async function processInboundMessage(target: WecomWebhookTarget, msg: WecomInboundMessage): Promise<InboundResult> {
956
- const msgtype = String(msg.msgtype ?? "").toLowerCase();
957
- const aesKey = target.account.encodingAESKey;
958
- const maxBytes = resolveWecomMediaMaxBytes(target.config);
959
- const proxyUrl = resolveWecomEgressProxyUrl(target.config);
79
+ const { flushPending, startAgentForStream } = botStreamOrchestrator;
960
80
 
961
- // 图片消息处理:如果存在 url 且配置了 aesKey,则尝试解密下载
962
- if (msgtype === "image") {
963
- const url = String((msg as any).image?.url ?? "").trim();
964
- if (url && aesKey) {
965
- try {
966
- const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
967
- const inferred = inferInboundMediaMeta({
968
- kind: "image",
969
- buffer: decrypted.buffer,
970
- sourceUrl: decrypted.sourceUrl || url,
971
- sourceContentType: decrypted.sourceContentType,
972
- sourceFilename: decrypted.sourceFilename,
973
- explicitFilename: pickBotFileName(msg),
974
- });
975
- return {
976
- body: "[image]",
977
- media: {
978
- buffer: decrypted.buffer,
979
- contentType: inferred.contentType,
980
- filename: inferred.filename,
981
- }
982
- };
983
- } catch (err) {
984
- target.runtime.error?.(`Failed to decrypt inbound image: ${String(err)}`);
985
- target.runtime.error?.(
986
- `图片解密失败: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
987
- );
988
- const errorMessage = typeof err === 'object' && err
989
- ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
990
- : String(err);
991
- return { body: `[image] (decryption failed: ${errorMessage})` };
992
- }
993
- }
994
- }
995
-
996
- if (msgtype === "file") {
997
- const url = String((msg as any).file?.url ?? "").trim();
998
- if (url && aesKey) {
999
- try {
1000
- const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1001
- const inferred = inferInboundMediaMeta({
1002
- kind: "file",
1003
- buffer: decrypted.buffer,
1004
- sourceUrl: decrypted.sourceUrl || url,
1005
- sourceContentType: decrypted.sourceContentType,
1006
- sourceFilename: decrypted.sourceFilename,
1007
- explicitFilename: pickBotFileName(msg),
1008
- });
1009
- return {
1010
- body: "[file]",
1011
- media: {
1012
- buffer: decrypted.buffer,
1013
- contentType: inferred.contentType,
1014
- filename: inferred.filename,
1015
- }
1016
- };
1017
- } catch (err) {
1018
- target.runtime.error?.(
1019
- `Failed to decrypt inbound file: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
1020
- );
1021
- const errorMessage = typeof err === 'object' && err
1022
- ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
1023
- : String(err);
1024
- return { body: `[file] (decryption failed: ${errorMessage})` };
1025
- }
1026
- }
1027
- }
1028
-
1029
- // Mixed message handling: extract first media if available
1030
- if (msgtype === "mixed") {
1031
- const items = (msg as any).mixed?.msg_item;
1032
- if (Array.isArray(items)) {
1033
- let foundMedia: InboundResult["media"] | undefined = undefined;
1034
- let bodyParts: string[] = [];
1035
-
1036
- for (const item of items) {
1037
- const t = String(item.msgtype ?? "").toLowerCase();
1038
- if (t === "text") {
1039
- const content = String(item.text?.content ?? "").trim();
1040
- if (content) bodyParts.push(content);
1041
- } else if ((t === "image" || t === "file") && !foundMedia && aesKey) {
1042
- // Found first media, try to download
1043
- const url = String(item[t]?.url ?? "").trim();
1044
- if (url) {
1045
- try {
1046
- const decrypted = await decryptWecomMediaWithMeta(url, aesKey, { maxBytes, http: { proxyUrl } });
1047
- const inferred = inferInboundMediaMeta({
1048
- kind: t,
1049
- buffer: decrypted.buffer,
1050
- sourceUrl: decrypted.sourceUrl || url,
1051
- sourceContentType: decrypted.sourceContentType,
1052
- sourceFilename: decrypted.sourceFilename,
1053
- explicitFilename: pickBotFileName(msg, item?.[t]),
1054
- });
1055
- foundMedia = {
1056
- buffer: decrypted.buffer,
1057
- contentType: inferred.contentType,
1058
- filename: inferred.filename,
1059
- };
1060
- bodyParts.push(`[${t}]`);
1061
- } catch (err) {
1062
- target.runtime.error?.(
1063
- `Failed to decrypt mixed ${t}: ${String(err)}; 可调大 channels.wecom.media.maxBytes(当前=${maxBytes})例如:openclaw config set channels.wecom.media.maxBytes ${50 * 1024 * 1024}`,
1064
- );
1065
- const errorMessage = typeof err === 'object' && err
1066
- ? `${(err as any).message}${((err as any).cause) ? ` (cause: ${String((err as any).cause)})` : ''}`
1067
- : String(err);
1068
- bodyParts.push(`[${t}] (decryption failed: ${errorMessage})`);
1069
- }
1070
- } else {
1071
- bodyParts.push(`[${t}]`);
1072
- }
1073
- } else {
1074
- // Other items or already found media -> just placeholder
1075
- bodyParts.push(`[${t}]`);
1076
- }
1077
- }
1078
- return {
1079
- body: bodyParts.join("\n"),
1080
- media: foundMedia
1081
- };
1082
- }
1083
- }
1084
-
1085
- return { body: buildInboundBody(msg) };
1086
- }
1087
-
1088
-
1089
- /**
1090
- * Flush pending inbound messages after debounce timeout.
1091
- * Merges all buffered message contents and starts agent processing.
1092
- */
1093
- /**
1094
- * **flushPending (刷新待处理消息 / 核心 Agent 触发点)**
1095
- *
1096
- * 当防抖计时器结束时被调用。
1097
- * 核心逻辑:
1098
- * 1. 聚合所有 pending 的消息内容(用于上下文)。
1099
- * 2. 获取 PluginRuntime。
1100
- * 3. 标记 Stream 为 Started。
1101
- * 4. 调用 `startAgentForStream` 启动 Agent 流程。
1102
- * 5. 处理异常并更新 Stream 状态为 Error。
1103
- */
1104
- async function flushPending(pending: PendingInbound): Promise<void> {
1105
- const { streamId, target, msg, contents, msgids, conversationKey, batchKey } = pending;
1106
-
1107
- // Merge all message contents (each is already formatted by buildInboundBody)
1108
- const mergedContents = contents.filter(c => c.trim()).join("\n").trim();
1109
-
1110
- let core: PluginRuntime | null = null;
1111
- try {
1112
- core = getWecomRuntime();
1113
- } catch (err) {
1114
- logVerbose(target, `flush pending: runtime not ready: ${String(err)}`);
1115
- streamStore.markFinished(streamId);
1116
- logInfo(target, `queue: runtime not ready,结束批次并推进 streamId=${streamId}`);
1117
- streamStore.onStreamFinished(streamId);
1118
- return;
1119
- }
1120
-
1121
- if (core) {
1122
- streamStore.markStarted(streamId);
1123
- const enrichedTarget: WecomWebhookTarget = { ...target, core };
1124
- logInfo(target, `flush pending: start batch streamId=${streamId} batchKey=${batchKey} conversationKey=${conversationKey} mergedCount=${contents.length}`);
1125
- logVerbose(target, `防抖结束: 开始处理聚合消息 数量=${contents.length} streamId=${streamId}`);
1126
-
1127
- // Pass the first msg (with its media structure), and mergedContents for multi-message context
1128
- startAgentForStream({
1129
- target: enrichedTarget,
1130
- accountId: target.account.accountId,
1131
- msg,
1132
- streamId,
1133
- mergedContents: contents.length > 1 ? mergedContents : undefined,
1134
- mergedMsgids: msgids.length > 1 ? msgids : undefined,
1135
- }).catch((err) => {
1136
- streamStore.updateStream(streamId, (state) => {
1137
- state.error = err instanceof Error ? err.message : String(err);
1138
- state.content = state.content || `Error: ${state.error}`;
1139
- state.finished = true;
1140
- });
1141
- target.runtime.error?.(`[${target.account.accountId}] wecom agent failed (处理失败): ${String(err)}`);
1142
- streamStore.onStreamFinished(streamId);
1143
- });
1144
- }
1145
- }
1146
-
1147
-
1148
- /**
1149
- * **waitForStreamContent (等待流内容)**
1150
- *
1151
- * 用于长轮询 (Long Polling) 场景:阻塞等待流输出内容,直到超时或流结束。
1152
- * 这保证了用户能尽快收到第一批响应,而不是空转。
1153
- */
1154
- async function waitForStreamContent(streamId: string, maxWaitMs: number): Promise<void> {
1155
- if (maxWaitMs <= 0) return;
1156
- const startedAt = Date.now();
1157
- await new Promise<void>((resolve) => {
1158
- const tick = () => {
1159
- const state = streamStore.getStream(streamId);
1160
- if (!state) return resolve();
1161
- if (state.error || state.finished) return resolve();
1162
- if (state.content.trim()) return resolve();
1163
- if (Date.now() - startedAt >= maxWaitMs) return resolve();
1164
- setTimeout(tick, 25);
1165
- };
1166
- tick();
1167
- });
1168
- }
1169
-
1170
- /**
1171
- * **startAgentForStream (启动 Agent 处理流程)**
1172
- *
1173
- * 将接收到的(或聚合的)消息转换为 OpenClaw 内部格式,并分发给对应的 Agent。
1174
- * 包含:
1175
- * 1. 消息解密与媒体保存。
1176
- * 2. 路由解析 (Agent Route)。
1177
- * 3. 鉴权 (Command Authorization)。
1178
- * 4. 会话记录 (Session Recording)。
1179
- * 5. 触发 Agent 响应 (Dispatch Reply)。
1180
- * 6. 处理 Agent 输出(包括文本、Markdown 表格转换、<think> 标签保护、模板卡片识别)。
1181
- */
1182
- async function startAgentForStream(params: {
1183
- target: WecomWebhookTarget;
1184
- accountId: string;
1185
- msg: WecomInboundMessage;
1186
- streamId: string;
1187
- mergedContents?: string; // Combined content from debounced messages
1188
- mergedMsgids?: string[];
1189
- }): Promise<void> {
1190
- const { target, msg, streamId } = params;
1191
- const core = target.core;
1192
- const config = target.config;
1193
- const account = target.account;
1194
-
1195
- const userid = resolveWecomSenderUserId(msg) || "unknown";
1196
- const chatType = msg.chattype === "group" ? "group" : "direct";
1197
- const chatId = msg.chattype === "group" ? (msg.chatid?.trim() || "unknown") : userid;
1198
- const taskKey = computeTaskKey(target, msg);
1199
- const aibotid = String((msg as any).aibotid ?? "").trim() || undefined;
1200
-
1201
- // 更新 Stream 状态:记录上下文信息(用户ID、ChatType等)
1202
- streamStore.updateStream(streamId, (s) => {
1203
- s.userId = userid;
1204
- s.chatType = chatType === "group" ? "group" : "direct";
1205
- s.chatId = chatId;
1206
- s.taskKey = taskKey;
1207
- s.aibotid = aibotid;
1208
- });
1209
-
1210
- // 1. 处理入站消息 (Decrypt media if any)
1211
- // 解析消息体,若是图片/文件则自动解密
1212
- let { body: rawBody, media } = await processInboundMessage(target, msg);
1213
-
1214
- // 若存在从防抖逻辑聚合来的多条消息内容,则覆盖 rawBody
1215
- if (params.mergedContents) {
1216
- rawBody = params.mergedContents;
1217
- }
1218
-
1219
- // P0: 群聊/私聊里“让 Bot 发送本机图片/文件路径”的场景,优先走 Bot 原会话交付(图片),
1220
- // 非图片文件则走 Agent 私信兜底,并确保 Bot 会话里有中文提示。
1221
- //
1222
- // 典型背景:Agent 主动发群 chatId(wr/wc...)在很多情况下会 86008,无论怎么“修复”都发不出去;
1223
- // 这种请求如果能被动回复图片,就必须由 Bot 在群内交付。
1224
- const directLocalPaths = extractLocalFilePathsFromText(rawBody);
1225
- if (directLocalPaths.length) {
1226
- logVerbose(
1227
- target,
1228
- `local-path: 检测到用户消息包含本机路径 count=${directLocalPaths.length} intent=${looksLikeSendLocalFileIntent(rawBody)}`,
1229
- );
1230
- }
1231
- if (directLocalPaths.length && looksLikeSendLocalFileIntent(rawBody)) {
1232
- const fs = await import("node:fs/promises");
1233
- const pathModule = await import("node:path");
1234
- const imageExts = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
1235
-
1236
- const imagePaths: string[] = [];
1237
- const otherPaths: string[] = [];
1238
- for (const p of directLocalPaths) {
1239
- const ext = pathModule.extname(p).slice(1).toLowerCase();
1240
- if (imageExts.has(ext)) imagePaths.push(p);
1241
- else otherPaths.push(p);
1242
- }
1243
-
1244
- // 1) 图片:优先 Bot 群内/原会话交付(被动/流式 msg_item)
1245
- if (imagePaths.length > 0 && otherPaths.length === 0) {
1246
- const loaded: Array<{ base64: string; md5: string; path: string }> = [];
1247
- for (const p of imagePaths) {
1248
- try {
1249
- const buf = await fs.readFile(p);
1250
- const base64 = buf.toString("base64");
1251
- const md5 = crypto.createHash("md5").update(buf).digest("hex");
1252
- loaded.push({ base64, md5, path: p });
1253
- } catch (err) {
1254
- target.runtime.error?.(`local-path: 读取图片失败 path=${p}: ${String(err)}`);
1255
- }
1256
- }
1257
-
1258
- if (loaded.length > 0) {
1259
- streamStore.updateStream(streamId, (s) => {
1260
- s.images = loaded.map(({ base64, md5 }) => ({ base64, md5 }));
1261
- s.content = loaded.length === 1
1262
- ? `已发送图片(${pathModule.basename(loaded[0]!.path)})`
1263
- : `已发送 ${loaded.length} 张图片`;
1264
- s.finished = true;
1265
- });
1266
-
1267
- const responseUrl = getActiveReplyUrl(streamId);
1268
- if (responseUrl) {
1269
- try {
1270
- const finalReply = buildStreamReplyFromState(streamStore.getStream(streamId)!) as unknown as Record<string, unknown>;
1271
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1272
- const res = await wecomFetch(
1273
- responseUrl,
1274
- {
1275
- method: "POST",
1276
- headers: { "Content-Type": "application/json" },
1277
- body: JSON.stringify(finalReply),
1278
- },
1279
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1280
- );
1281
- if (!res.ok) throw new Error(`local-path image push failed: ${res.status}`);
1282
- });
1283
- logVerbose(target, `local-path: 已通过 Bot response_url 推送图片 frames=final images=${loaded.length}`);
1284
- } catch (err) {
1285
- target.runtime.error?.(`local-path: Bot 主动推送图片失败(将依赖 stream_refresh 拉取): ${String(err)}`);
1286
- }
1287
- } else {
1288
- logVerbose(target, `local-path: 无 response_url,等待 stream_refresh 拉取最终图片`);
1289
- }
1290
- // 该消息已完成,推进队列处理下一批
1291
- streamStore.onStreamFinished(streamId);
1292
- return;
1293
- }
1294
-
1295
- // 图片路径都读取失败时,切换到 Agent 私信兜底,并主动结束 Bot 流。
1296
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1297
- const agentOk = Boolean(agentCfg);
1298
- const fallbackName = imagePaths.length === 1
1299
- ? (imagePaths[0]!.split("/").pop() || "image")
1300
- : `${imagePaths.length} 张图片`;
1301
- const prompt = buildFallbackPrompt({
1302
- kind: "media",
1303
- agentConfigured: agentOk,
1304
- userId: userid,
1305
- filename: fallbackName,
1306
- chatType,
1307
- });
1308
-
1309
- streamStore.updateStream(streamId, (s) => {
1310
- s.fallbackMode = "error";
1311
- s.finished = true;
1312
- s.content = prompt;
1313
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1314
- });
1315
-
1316
- try {
1317
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1318
- logVerbose(target, `local-path: 图片读取失败后已推送兜底提示`);
1319
- } catch (err) {
1320
- target.runtime.error?.(`local-path: 图片读取失败后的兜底提示推送失败: ${String(err)}`);
1321
- }
1322
-
1323
- if (agentCfg && userid && userid !== "unknown") {
1324
- for (const p of imagePaths) {
1325
- const guessedType = guessContentTypeFromPath(p);
1326
- try {
1327
- await sendAgentDmMedia({
1328
- agent: agentCfg,
1329
- userId: userid,
1330
- mediaUrlOrPath: p,
1331
- contentType: guessedType,
1332
- filename: p.split("/").pop() || "image",
1333
- });
1334
- streamStore.updateStream(streamId, (s) => {
1335
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
1336
- });
1337
- logVerbose(
1338
- target,
1339
- `local-path: 图片已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
1340
- );
1341
- } catch (err) {
1342
- target.runtime.error?.(`local-path: 图片 Agent 私信兜底失败 path=${p}: ${String(err)}`);
1343
- }
1344
- }
1345
- }
1346
- streamStore.onStreamFinished(streamId);
1347
- return;
1348
- }
1349
-
1350
- // 2) 非图片文件:Bot 会话里提示 + Agent 私信兜底(目标锁定 userId)
1351
- if (otherPaths.length > 0) {
1352
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1353
- const agentOk = Boolean(agentCfg);
1354
-
1355
- const filename = otherPaths.length === 1 ? otherPaths[0]!.split("/").pop()! : `${otherPaths.length} 个文件`;
1356
- const prompt = buildFallbackPrompt({
1357
- kind: "media",
1358
- agentConfigured: agentOk,
1359
- userId: userid,
1360
- filename,
1361
- chatType,
1362
- });
1363
-
1364
- streamStore.updateStream(streamId, (s) => {
1365
- s.fallbackMode = "media";
1366
- s.finished = true;
1367
- s.content = prompt;
1368
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1369
- });
1370
-
1371
- try {
1372
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1373
- logVerbose(target, `local-path: 文件兜底提示已推送`);
1374
- } catch (err) {
1375
- target.runtime.error?.(`local-path: 文件兜底提示推送失败: ${String(err)}`);
1376
- }
1377
-
1378
- if (!agentCfg) {
1379
- streamStore.onStreamFinished(streamId);
1380
- return;
1381
- }
1382
- if (!userid || userid === "unknown") {
1383
- target.runtime.error?.(`local-path: 无法识别触发者 userId,无法 Agent 私信发送文件`);
1384
- streamStore.onStreamFinished(streamId);
1385
- return;
1386
- }
1387
-
1388
- for (const p of otherPaths) {
1389
- const alreadySent = streamStore.getStream(streamId)?.agentMediaKeys?.includes(p);
1390
- if (alreadySent) continue;
1391
- const guessedType = guessContentTypeFromPath(p);
1392
- try {
1393
- await sendAgentDmMedia({
1394
- agent: agentCfg,
1395
- userId: userid,
1396
- mediaUrlOrPath: p,
1397
- contentType: guessedType,
1398
- filename: p.split("/").pop() || "file",
1399
- });
1400
- streamStore.updateStream(streamId, (s) => {
1401
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), p]));
1402
- });
1403
- logVerbose(
1404
- target,
1405
- `local-path: 文件已通过 Agent 私信发送 user=${userid} path=${p} contentType=${guessedType ?? "unknown"}`,
1406
- );
1407
- } catch (err) {
1408
- target.runtime.error?.(`local-path: Agent 私信发送文件失败 path=${p}: ${String(err)}`);
1409
- }
1410
- }
1411
- streamStore.onStreamFinished(streamId);
1412
- return;
1413
- }
1414
- }
1415
-
1416
- // 2. Save media if present
1417
- let mediaPath: string | undefined;
1418
- let mediaType: string | undefined;
1419
- if (media) {
1420
- try {
1421
- const maxBytes = resolveWecomMediaMaxBytes(target.config);
1422
- const saved = await core.channel.media.saveMediaBuffer(
1423
- media.buffer,
1424
- media.contentType,
1425
- "inbound",
1426
- maxBytes,
1427
- media.filename
1428
- );
1429
- mediaPath = saved.path;
1430
- mediaType = saved.contentType;
1431
- logVerbose(target, `saved inbound media to ${mediaPath} (${mediaType})`);
1432
- } catch (err) {
1433
- target.runtime.error?.(`Failed to save inbound media: ${String(err)}`);
1434
- }
1435
- }
1436
-
1437
- const route = core.channel.routing.resolveAgentRoute({
1438
- cfg: config,
1439
- channel: "wecom",
1440
- accountId: account.accountId,
1441
- peer: { kind: chatType === "group" ? "group" : "direct", id: chatId },
1442
- });
1443
-
1444
- const useDynamicAgent = shouldUseDynamicAgent({
1445
- chatType: chatType === "group" ? "group" : "dm",
1446
- senderId: userid,
1447
- config,
1448
- });
1449
-
1450
- if (shouldRejectWecomDefaultRoute({ cfg: config, matchedBy: route.matchedBy, useDynamicAgent })) {
1451
- const prompt =
1452
- `当前账号(${account.accountId})未绑定 OpenClaw Agent,已拒绝回退到默认主智能体。` +
1453
- `请在 bindings 中添加:{"agentId":"你的Agent","match":{"channel":"wecom","accountId":"${account.accountId}"}}`;
1454
- target.runtime.error?.(
1455
- `[wecom] routing guard: blocked default fallback accountId=${account.accountId} matchedBy=${route.matchedBy} streamId=${streamId}`,
1456
- );
1457
- streamStore.updateStream(streamId, (s) => {
1458
- s.finished = true;
1459
- s.content = prompt;
1460
- });
1461
- try {
1462
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1463
- } catch (err) {
1464
- target.runtime.error?.(`routing guard prompt push failed streamId=${streamId}: ${String(err)}`);
1465
- }
1466
- streamStore.onStreamFinished(streamId);
1467
- return;
1468
- }
1469
-
1470
- // ===== 动态 Agent 路由注入 =====
1471
-
1472
- if (useDynamicAgent) {
1473
- const targetAgentId = generateAgentId(
1474
- chatType === "group" ? "group" : "dm",
1475
- chatId,
1476
- account.accountId,
1477
- );
1478
- route.agentId = targetAgentId;
1479
- route.sessionKey = `agent:${targetAgentId}:wecom:${account.accountId}:${chatType === "group" ? "group" : "dm"}:${chatId}`;
1480
- // 异步添加到 agents.list(不阻塞)
1481
- ensureDynamicAgentListed(targetAgentId, core).catch(() => {});
1482
- logVerbose(target, `dynamic agent routing: ${targetAgentId}, sessionKey=${route.sessionKey}`);
1483
- }
1484
- // ===== 动态 Agent 路由注入结束 =====
1485
-
1486
- logVerbose(target, `starting agent processing (streamId=${streamId}, agentId=${route.agentId}, peerKind=${chatType}, peerId=${chatId})`);
1487
- logVerbose(target, `启动 Agent 处理: streamId=${streamId} 路由=${route.agentId} 类型=${chatType} ID=${chatId}`);
1488
-
1489
- const fromLabel = chatType === "group" ? `group:${chatId}` : `user:${userid}`;
1490
- const storePath = core.channel.session.resolveStorePath(config.session?.store, {
1491
- agentId: route.agentId,
1492
- });
1493
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
1494
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
1495
- storePath,
1496
- sessionKey: route.sessionKey,
1497
- });
1498
- const body = core.channel.reply.formatAgentEnvelope({
1499
- channel: "WeCom",
1500
- from: fromLabel,
1501
- previousTimestamp,
1502
- envelope: envelopeOptions,
1503
- body: rawBody,
1504
- });
1505
-
1506
- const authz = await resolveWecomCommandAuthorization({
1507
- core,
1508
- cfg: config,
1509
- accountConfig: account.config,
1510
- rawBody,
1511
- senderUserId: userid,
1512
- });
1513
- const commandAuthorized = authz.commandAuthorized;
1514
- logVerbose(
1515
- target,
1516
- `authz: dmPolicy=${authz.dmPolicy} shouldCompute=${authz.shouldComputeAuth} sender=${userid.toLowerCase()} senderAllowed=${authz.senderAllowed} authorizerConfigured=${authz.authorizerConfigured} commandAuthorized=${String(authz.commandAuthorized)}`,
1517
- );
1518
-
1519
- // 命令门禁:如果这是命令且未授权,必须给用户一个明确的中文回复(不能静默忽略)
1520
- if (authz.shouldComputeAuth && authz.commandAuthorized !== true) {
1521
- const prompt = buildWecomUnauthorizedCommandPrompt({ senderUserId: userid, dmPolicy: authz.dmPolicy, scope: "bot" });
1522
- streamStore.updateStream(streamId, (s) => {
1523
- s.finished = true;
1524
- s.content = prompt;
1525
- });
1526
- try {
1527
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1528
- logInfo(target, `authz: 未授权命令已提示用户 streamId=${streamId}`);
1529
- } catch (err) {
1530
- target.runtime.error?.(`authz: 未授权命令提示推送失败 streamId=${streamId}: ${String(err)}`);
1531
- }
1532
- streamStore.onStreamFinished(streamId);
1533
- return;
1534
- }
1535
-
1536
- const rawBodyNormalized = rawBody.trim();
1537
- const isResetCommand = /^\/(new|reset)(?:\s|$)/i.test(rawBodyNormalized);
1538
- const resetCommandKind = isResetCommand ? (rawBodyNormalized.match(/^\/(new|reset)/i)?.[1]?.toLowerCase() ?? "new") : null;
1539
-
1540
- const attachments = mediaPath ? [{
1541
- name: media?.filename || "file",
1542
- mimeType: mediaType,
1543
- url: pathToFileURL(mediaPath).href
1544
- }] : undefined;
1545
-
1546
- const ctxPayload = core.channel.reply.finalizeInboundContext({
1547
- Body: body,
1548
- RawBody: rawBody,
1549
- CommandBody: rawBody,
1550
- Attachments: attachments,
1551
- From: chatType === "group" ? `wecom:group:${chatId}` : `wecom:${userid}`,
1552
- To: `wecom:${chatId}`,
1553
- SessionKey: route.sessionKey,
1554
- AccountId: route.accountId,
1555
- ChatType: chatType,
1556
- ConversationLabel: fromLabel,
1557
- SenderName: userid,
1558
- SenderId: userid,
1559
- Provider: "wecom",
1560
- // Keep Surface aligned with OriginatingChannel for Bot-mode delivery.
1561
- // If Surface is "webchat", core dispatch treats this as cross-channel
1562
- // and routes replies via routeReply -> wecom outbound (Agent API),
1563
- // bypassing the Bot stream deliver path.
1564
- Surface: "wecom",
1565
- MessageSid: msg.msgid,
1566
- CommandAuthorized: commandAuthorized,
1567
- OriginatingChannel: "wecom",
1568
- OriginatingTo: `wecom:${chatId}`,
1569
- MediaPath: mediaPath,
1570
- MediaType: mediaType,
1571
- MediaUrl: mediaPath, // Local path for now
1572
- });
1573
-
1574
- await core.channel.session.recordInboundSession({
1575
- storePath,
1576
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1577
- ctx: ctxPayload,
1578
- onRecordError: (err) => {
1579
- target.runtime.error?.(`wecom: failed updating session meta: ${String(err)}`);
1580
- },
1581
- });
1582
-
1583
- const tableMode = core.channel.text.resolveMarkdownTableMode({
1584
- cfg: config,
1585
- channel: "wecom",
1586
- accountId: account.accountId,
1587
- });
1588
-
1589
- // WeCom Bot 会话交付约束:
1590
- // - 图片应尽量由 Bot 在原会话交付(流式最终帧 msg_item)。
1591
- // - 非图片文件走 Agent 私信兜底(本文件中实现),并由 Bot 给出提示。
1592
- //
1593
- // 重要:message 工具不是 sandbox 工具,必须通过 cfg.tools.deny 禁用。
1594
- // 否则 Agent 可能直接通过 message 工具私信/发群,绕过 Bot 交付链路,导致群里“没有任何提示”。
1595
- const cfgForDispatch = (() => {
1596
- const baseAgents = (config as any)?.agents ?? {};
1597
- const baseAgentDefaults = (baseAgents as any)?.defaults ?? {};
1598
- const baseBlockChunk = (baseAgentDefaults as any)?.blockStreamingChunk ?? {};
1599
- const baseBlockCoalesce = (baseAgentDefaults as any)?.blockStreamingCoalesce ?? {};
1600
- const baseTools = (config as any)?.tools ?? {};
1601
- const baseSandbox = (baseTools as any)?.sandbox ?? {};
1602
- const baseSandboxTools = (baseSandbox as any)?.tools ?? {};
1603
- const existingTopLevelDeny = Array.isArray((baseTools as any).deny) ? ((baseTools as any).deny as string[]) : [];
1604
- const existingSandboxDeny = Array.isArray((baseSandboxTools as any).deny) ? ((baseSandboxTools as any).deny as string[]) : [];
1605
- const topLevelDeny = Array.from(new Set([...existingTopLevelDeny, "message"]));
1606
- const sandboxDeny = Array.from(new Set([...existingSandboxDeny, "message"]));
1607
- return {
1608
- ...(config as any),
1609
- agents: {
1610
- ...baseAgents,
1611
- defaults: {
1612
- ...baseAgentDefaults,
1613
- // Bot 通道使用企业微信被动流式刷新,需要更小的块阈值,避免只在结束时一次性输出。
1614
- blockStreamingChunk: {
1615
- ...baseBlockChunk,
1616
- minChars: baseBlockChunk.minChars ?? 120,
1617
- maxChars: baseBlockChunk.maxChars ?? 360,
1618
- breakPreference: baseBlockChunk.breakPreference ?? "sentence",
1619
- },
1620
- blockStreamingCoalesce: {
1621
- ...baseBlockCoalesce,
1622
- minChars: baseBlockCoalesce.minChars ?? 120,
1623
- maxChars: baseBlockCoalesce.maxChars ?? 360,
1624
- idleMs: baseBlockCoalesce.idleMs ?? 250,
1625
- },
1626
- },
1627
- },
1628
- tools: {
1629
- ...baseTools,
1630
- deny: topLevelDeny,
1631
- sandbox: {
1632
- ...baseSandbox,
1633
- tools: {
1634
- ...baseSandboxTools,
1635
- deny: sandboxDeny,
1636
- },
1637
- },
1638
- },
1639
- } as OpenClawConfig;
1640
- })();
1641
- logVerbose(target, `tool-policy: WeCom Bot 会话已禁用 message 工具(tools.deny += message;并同步到 tools.sandbox.tools.deny,防止绕过 Bot 交付)`);
1642
-
1643
- // 调度 Agent 回复
1644
- // 使用 dispatchReplyWithBufferedBlockDispatcher 可以处理流式输出 buffer
1645
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
1646
- ctx: ctxPayload,
1647
- cfg: cfgForDispatch,
1648
- // WeCom Bot relies on passive stream-refresh callbacks; force block streaming on
1649
- // so the dispatcher emits incremental blocks instead of only a final message.
1650
- replyOptions: {
1651
- disableBlockStreaming: false,
1652
- },
1653
- dispatcherOptions: {
1654
- deliver: async (payload, info) => {
1655
- let text = payload.text ?? "";
1656
-
1657
- // 保护 <think> 标签不被 markdown 表格转换破坏
1658
- const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
1659
- const thinks: string[] = [];
1660
- text = text.replace(thinkRegex, (match: string) => {
1661
- thinks.push(match);
1662
- return `__THINK_PLACEHOLDER_${thinks.length - 1}__`;
1663
- });
1664
-
1665
- // [A2UI] Detect template_card JSON output from Agent
1666
- const trimmedText = text.trim();
1667
- if (trimmedText.startsWith("{") && trimmedText.includes('"template_card"')) {
1668
- try {
1669
- const parsed = JSON.parse(trimmedText);
1670
- if (parsed.template_card) {
1671
- const isSingleChat = msg.chattype !== "group";
1672
- const responseUrl = getActiveReplyUrl(streamId);
1673
-
1674
- if (responseUrl && isSingleChat) {
1675
- // 单聊且有 response_url:发送卡片
1676
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
1677
- const res = await wecomFetch(
1678
- responseUrl,
1679
- {
1680
- method: "POST",
1681
- headers: { "Content-Type": "application/json" },
1682
- body: JSON.stringify({
1683
- msgtype: "template_card",
1684
- template_card: parsed.template_card,
1685
- }),
1686
- },
1687
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
1688
- );
1689
- if (!res.ok) {
1690
- throw new Error(`template_card send failed: ${res.status}`);
1691
- }
1692
- });
1693
- logVerbose(target, `sent template_card: task_id=${parsed.template_card.task_id}`);
1694
- streamStore.updateStream(streamId, (s) => {
1695
- s.finished = true;
1696
- s.content = "[已发送交互卡片]";
1697
- });
1698
- target.statusSink?.({ lastOutboundAt: Date.now() });
1699
- return;
1700
- } else {
1701
- // 群聊 或 无 response_url:降级为文本描述
1702
- logVerbose(target, `template_card fallback to text (group=${!isSingleChat}, hasUrl=${!!responseUrl})`);
1703
- const cardTitle = parsed.template_card.main_title?.title || "交互卡片";
1704
- const cardDesc = parsed.template_card.main_title?.desc || "";
1705
- const buttons = parsed.template_card.button_list?.map((b: any) => b.text).join(" / ") || "";
1706
- text = `📋 **${cardTitle}**${cardDesc ? `\n${cardDesc}` : ""}${buttons ? `\n\n选项: ${buttons}` : ""}`;
1707
- }
1708
- }
1709
- } catch { /* parse fail, use normal text */ }
1710
- }
1711
-
1712
- text = core.channel.text.convertMarkdownTables(text, tableMode);
1713
-
1714
- // Restore <think> tags
1715
- thinks.forEach((think, i) => {
1716
- text = text.replace(`__THINK_PLACEHOLDER_${i}__`, think);
1717
- });
1718
-
1719
- const current = streamStore.getStream(streamId);
1720
- if (!current) return;
1721
-
1722
- if (!current.images) current.images = [];
1723
- if (!current.agentMediaKeys) current.agentMediaKeys = [];
1724
-
1725
- const deliverKind = info?.kind ?? "block";
1726
- logVerbose(
1727
- target,
1728
- `deliver: kind=${deliverKind} chatType=${current.chatType ?? chatType} user=${current.userId ?? userid} textLen=${text.length} mediaCount=${(payload.mediaUrls?.length ?? 0) + (payload.mediaUrl ? 1 : 0)}`,
1729
- );
1730
-
1731
- // If the model referenced a local image path in its reply but did not emit mediaUrl(s),
1732
- // we can still deliver it via Bot *only* when that exact path appeared in the user's
1733
- // original message (rawBody). This prevents the model from exfiltrating arbitrary files.
1734
- if (!payload.mediaUrl && !(payload.mediaUrls?.length ?? 0) && text.includes("/")) {
1735
- const candidates = extractLocalImagePathsFromText({ text, mustAlsoAppearIn: rawBody });
1736
- if (candidates.length > 0) {
1737
- logVerbose(target, `media: 从输出文本推断到本机图片路径(来自用户原消息)count=${candidates.length}`);
1738
- for (const p of candidates) {
1739
- try {
1740
- const fs = await import("node:fs/promises");
1741
- const pathModule = await import("node:path");
1742
- const buf = await fs.readFile(p);
1743
- const ext = pathModule.extname(p).slice(1).toLowerCase();
1744
- const imageExts: Record<string, string> = {
1745
- jpg: "image/jpeg",
1746
- jpeg: "image/jpeg",
1747
- png: "image/png",
1748
- gif: "image/gif",
1749
- webp: "image/webp",
1750
- bmp: "image/bmp",
1751
- };
1752
- const contentType = imageExts[ext] ?? "application/octet-stream";
1753
- if (!contentType.startsWith("image/")) {
1754
- continue;
1755
- }
1756
- const base64 = buf.toString("base64");
1757
- const md5 = crypto.createHash("md5").update(buf).digest("hex");
1758
- current.images.push({ base64, md5 });
1759
- logVerbose(target, `media: 已加载本机图片用于 Bot 交付 path=${p}`);
1760
- } catch (err) {
1761
- target.runtime.error?.(`media: 读取本机图片失败 path=${p}: ${String(err)}`);
1762
- }
1763
- }
1764
- }
1765
- }
1766
-
1767
- // Always accumulate content for potential Agent DM fallback (not limited by STREAM_MAX_BYTES).
1768
- if (text.trim()) {
1769
- streamStore.updateStream(streamId, (s) => {
1770
- appendDmContent(s, text);
1771
- });
1772
- }
1773
-
1774
- // Timeout fallback: near 6min window, stop bot stream and switch to Agent DM.
1775
- const now = Date.now();
1776
- const deadline = current.createdAt + BOT_WINDOW_MS;
1777
- const switchAt = deadline - BOT_SWITCH_MARGIN_MS;
1778
- const nearTimeout = !current.fallbackMode && !current.finished && now >= switchAt;
1779
- if (nearTimeout) {
1780
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1781
- const agentOk = Boolean(agentCfg);
1782
- const prompt = buildFallbackPrompt({
1783
- kind: "timeout",
1784
- agentConfigured: agentOk,
1785
- userId: current.userId,
1786
- chatType: current.chatType,
1787
- });
1788
- logVerbose(
1789
- target,
1790
- `fallback(timeout): 触发切换(接近 6 分钟)chatType=${current.chatType} agentConfigured=${agentOk} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`,
1791
- );
1792
- streamStore.updateStream(streamId, (s) => {
1793
- s.fallbackMode = "timeout";
1794
- s.finished = true;
1795
- s.content = prompt;
1796
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1797
- });
1798
- try {
1799
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1800
- logVerbose(target, `fallback(timeout): 群内提示已推送`);
1801
- } catch (err) {
1802
- target.runtime.error?.(`wecom bot fallback prompt push failed (timeout) streamId=${streamId}: ${String(err)}`);
1803
- }
1804
- return;
1805
- }
1806
-
1807
- const mediaUrls = payload.mediaUrls || (payload.mediaUrl ? [payload.mediaUrl] : []);
1808
- for (const mediaPath of mediaUrls) {
1809
- let contentType: string | undefined;
1810
- let filename = mediaPath.split("/").pop() || "attachment";
1811
- try {
1812
- let buf: Buffer;
1813
-
1814
- const looksLikeUrl = /^https?:\/\//i.test(mediaPath);
1815
-
1816
- if (looksLikeUrl) {
1817
- const loaded = await core.channel.media.fetchRemoteMedia({ url: mediaPath });
1818
- buf = loaded.buffer;
1819
- contentType = loaded.contentType;
1820
- filename = loaded.fileName ?? "attachment";
1821
- } else {
1822
- const fs = await import("node:fs/promises");
1823
- const pathModule = await import("node:path");
1824
- buf = await fs.readFile(mediaPath);
1825
- filename = pathModule.basename(mediaPath);
1826
- const ext = pathModule.extname(mediaPath).slice(1).toLowerCase();
1827
- const imageExts: Record<string, string> = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp" };
1828
- contentType = imageExts[ext] ?? "application/octet-stream";
1829
- }
1830
-
1831
- if (contentType?.startsWith("image/")) {
1832
- const base64 = buf.toString("base64");
1833
- const md5 = crypto.createHash("md5").update(buf).digest("hex");
1834
- current.images.push({ base64, md5 });
1835
- logVerbose(target, `media: 识别为图片 contentType=${contentType} filename=${filename}`);
1836
- } else {
1837
- // Non-image media: Bot 不支持原样发送(尤其群聊),统一切换到 Agent 私信兜底,并在 Bot 会话里提示用户。
1838
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1839
- const agentOk = Boolean(agentCfg);
1840
- const alreadySent = current.agentMediaKeys.includes(mediaPath);
1841
- logVerbose(
1842
- target,
1843
- `fallback(media): 检测到非图片文件 chatType=${current.chatType} contentType=${contentType ?? "unknown"} filename=${filename} agentConfigured=${agentOk} alreadySent=${alreadySent} hasResponseUrl=${Boolean(getActiveReplyUrl(streamId))}`,
1844
- );
1845
-
1846
- if (agentCfg && !alreadySent && current.userId) {
1847
- try {
1848
- await sendAgentDmMedia({
1849
- agent: agentCfg,
1850
- userId: current.userId,
1851
- mediaUrlOrPath: mediaPath,
1852
- contentType,
1853
- filename,
1854
- });
1855
- logVerbose(target, `fallback(media): 文件已通过 Agent 私信发送 user=${current.userId}`);
1856
- streamStore.updateStream(streamId, (s) => {
1857
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1858
- });
1859
- } catch (err) {
1860
- target.runtime.error?.(`wecom agent dm media failed: ${String(err)}`);
1861
- }
1862
- }
1863
-
1864
- if (!current.fallbackMode) {
1865
- const prompt = buildFallbackPrompt({
1866
- kind: "media",
1867
- agentConfigured: agentOk,
1868
- userId: current.userId,
1869
- filename,
1870
- chatType: current.chatType,
1871
- });
1872
- streamStore.updateStream(streamId, (s) => {
1873
- s.fallbackMode = "media";
1874
- s.finished = true;
1875
- s.content = prompt;
1876
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1877
- });
1878
- try {
1879
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1880
- logVerbose(target, `fallback(media): 群内提示已推送`);
1881
- } catch (err) {
1882
- target.runtime.error?.(`wecom bot fallback prompt push failed (media) streamId=${streamId}: ${String(err)}`);
1883
- }
1884
- }
1885
- return;
1886
- }
1887
- } catch (err) {
1888
- target.runtime.error?.(`Failed to process outbound media: ${mediaPath}: ${String(err)}`);
1889
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1890
- const agentOk = Boolean(agentCfg);
1891
- const fallbackFilename = filename || mediaPath.split("/").pop() || "attachment";
1892
- if (agentCfg && current.userId && !current.agentMediaKeys.includes(mediaPath)) {
1893
- try {
1894
- await sendAgentDmMedia({
1895
- agent: agentCfg,
1896
- userId: current.userId,
1897
- mediaUrlOrPath: mediaPath,
1898
- contentType,
1899
- filename: fallbackFilename,
1900
- });
1901
- streamStore.updateStream(streamId, (s) => {
1902
- s.agentMediaKeys = Array.from(new Set([...(s.agentMediaKeys ?? []), mediaPath]));
1903
- });
1904
- logVerbose(target, `fallback(error): 媒体处理失败后已通过 Agent 私信发送 user=${current.userId}`);
1905
- } catch (sendErr) {
1906
- target.runtime.error?.(`fallback(error): 媒体处理失败后的 Agent 私信发送也失败: ${String(sendErr)}`);
1907
- }
1908
- }
1909
- if (!current.fallbackMode) {
1910
- const prompt = buildFallbackPrompt({
1911
- kind: "error",
1912
- agentConfigured: agentOk,
1913
- userId: current.userId,
1914
- filename: fallbackFilename,
1915
- chatType: current.chatType,
1916
- });
1917
- streamStore.updateStream(streamId, (s) => {
1918
- s.fallbackMode = "error";
1919
- s.finished = true;
1920
- s.content = prompt;
1921
- s.fallbackPromptSentAt = s.fallbackPromptSentAt ?? Date.now();
1922
- });
1923
- try {
1924
- await sendBotFallbackPromptNow({ streamId, text: prompt });
1925
- logVerbose(target, `fallback(error): 群内提示已推送`);
1926
- } catch (pushErr) {
1927
- target.runtime.error?.(`wecom bot fallback prompt push failed (error) streamId=${streamId}: ${String(pushErr)}`);
1928
- }
1929
- }
1930
- return;
1931
- }
1932
- }
1933
-
1934
- // If we are in fallback mode, do not continue updating the bot stream content.
1935
- const mode = streamStore.getStream(streamId)?.fallbackMode;
1936
- if (mode) return;
1937
-
1938
- const nextText = current.content
1939
- ? `${current.content}\n\n${text}`.trim()
1940
- : text.trim();
1941
-
1942
- streamStore.updateStream(streamId, (s) => {
1943
- s.content = truncateUtf8Bytes(nextText, STREAM_MAX_BYTES);
1944
- if (current.images?.length) s.images = current.images; // ensure images are saved
1945
- });
1946
- target.statusSink?.({ lastOutboundAt: Date.now() });
1947
- },
1948
- onError: (err, info) => {
1949
- target.runtime.error?.(`[${account.accountId}] wecom ${info.kind} reply failed: ${String(err)}`);
1950
- },
1951
- },
1952
- });
1953
-
1954
- // /new /reset:OpenClaw 核心会通过 routeReply 发送英文回执(✅ New session started...),
1955
- // 但 WeCom 双模式下这条回执可能会走 Agent 私信,导致“从 Bot 发,却在 Agent 再回一条”。
1956
- // 该英文回执已在 wecom outbound 层做抑制/改写;这里补一个“同会话中文回执”,保证用户可理解。
1957
- if (isResetCommand) {
1958
- const current = streamStore.getStream(streamId);
1959
- const hasAnyContent = Boolean(current?.content?.trim());
1960
- if (current && !hasAnyContent) {
1961
- const ackText = resetCommandKind === "reset" ? "✅ 已重置会话。" : "✅ 已开启新会话。";
1962
- streamStore.updateStream(streamId, (s) => {
1963
- s.content = ackText;
1964
- s.finished = true;
1965
- });
1966
- }
1967
- }
1968
-
1969
- streamStore.updateStream(streamId, (s) => {
1970
- if (!s.content.trim() && !(s.images?.length ?? 0)) {
1971
- s.content = "✅ 已处理完成。";
1972
- }
1973
- });
1974
-
1975
- streamStore.markFinished(streamId);
1976
-
1977
- // Timeout fallback final delivery (Agent DM): send once after the agent run completes.
1978
- const finishedState = streamStore.getStream(streamId);
1979
- if (finishedState?.fallbackMode === "timeout" && !finishedState.finalDeliveredAt) {
1980
- const agentCfg = resolveAgentAccountOrUndefined(config, account.accountId);
1981
- if (!agentCfg) {
1982
- // Agent not configured - group prompt already explains the situation.
1983
- streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
1984
- } else if (finishedState.userId) {
1985
- const dmText = (finishedState.dmContent ?? "").trim();
1986
- if (dmText) {
1987
- try {
1988
- logVerbose(target, `fallback(timeout): 开始通过 Agent 私信发送剩余内容 user=${finishedState.userId} len=${dmText.length}`);
1989
- await sendAgentDmText({ agent: agentCfg, userId: finishedState.userId, text: dmText, core });
1990
- logVerbose(target, `fallback(timeout): Agent 私信发送完成 user=${finishedState.userId}`);
1991
- } catch (err) {
1992
- target.runtime.error?.(`wecom agent dm text failed (timeout): ${String(err)}`);
1993
- }
1994
- }
1995
- streamStore.updateStream(streamId, (s) => { s.finalDeliveredAt = Date.now(); });
1996
- }
1997
- }
1998
-
1999
- // 统一终结:只要 response_url 可用,尽量主动推一次最终流帧,确保“思考中”能及时收口。
2000
- // 失败仅记录日志,不影响 stream_refresh 被动拉取链路。
2001
- const stateAfterFinish = streamStore.getStream(streamId);
2002
- const responseUrl = getActiveReplyUrl(streamId);
2003
- if (stateAfterFinish && responseUrl) {
2004
- try {
2005
- await pushFinalStreamReplyNow(streamId);
2006
- logVerbose(
2007
- target,
2008
- `final stream pushed via response_url streamId=${streamId}, chatType=${chatType}, images=${stateAfterFinish.images?.length ?? 0}`,
2009
- );
2010
- } catch (err) {
2011
- target.runtime.error?.(`final stream push via response_url failed streamId=${streamId}: ${String(err)}`);
2012
- }
2013
- }
2014
-
2015
- // 推进会话队列:如果 2/3 已排队,当前批次结束后自动开始下一批次
2016
- logInfo(target, `queue: 当前批次结束,尝试推进下一批 streamId=${streamId}`);
2017
-
2018
- // 体验优化:如果本批次中有“回执流”(ack stream)(例如 3 被合并到 2),则在批次结束时更新这些回执流,
2019
- // 避免它们永久停留在“已合并排队处理中…”。
2020
- const ackStreamIds = streamStore.drainAckStreamsForBatch(streamId);
2021
- if (ackStreamIds.length > 0) {
2022
- const mergedDoneHint = "✅ 已合并处理完成,请查看上一条回复。";
2023
- for (const ackId of ackStreamIds) {
2024
- streamStore.updateStream(ackId, (s) => {
2025
- s.content = mergedDoneHint;
2026
- s.finished = true;
2027
- });
2028
- }
2029
- logInfo(target, `queue: 已更新回执流 count=${ackStreamIds.length} batchStreamId=${streamId}`);
2030
- }
2031
-
2032
- streamStore.onStreamFinished(streamId);
2033
- }
2034
-
2035
- function formatQuote(quote: WecomInboundQuote): string {
2036
- const type = quote.msgtype ?? "";
2037
- if (type === "text") return quote.text?.content || "";
2038
- if (type === "image") return `[引用: 图片] ${quote.image?.url || ""}`;
2039
- if (type === "mixed" && quote.mixed?.msg_item) {
2040
- const items = quote.mixed.msg_item.map((item) => {
2041
- if (item.msgtype === "text") return item.text?.content;
2042
- if (item.msgtype === "image") return `[图片] ${item.image?.url || ""}`;
2043
- return "";
2044
- }).filter(Boolean).join(" ");
2045
- return `[引用: 图文] ${items}`;
2046
- }
2047
- if (type === "voice") return `[引用: 语音] ${quote.voice?.content || ""}`;
2048
- if (type === "file") return `[引用: 文件] ${quote.file?.url || ""}`;
2049
- return "";
2050
- }
2051
-
2052
- function buildInboundBody(msg: WecomInboundMessage): string {
2053
- let body = "";
2054
- const msgtype = String(msg.msgtype ?? "").toLowerCase();
2055
-
2056
- if (msgtype === "text") body = (msg as any).text?.content || "";
2057
- else if (msgtype === "voice") body = (msg as any).voice?.content || "[voice]";
2058
- else if (msgtype === "mixed") {
2059
- const items = (msg as any).mixed?.msg_item;
2060
- if (Array.isArray(items)) {
2061
- body = items.map((item: any) => {
2062
- const t = String(item?.msgtype ?? "").toLowerCase();
2063
- if (t === "text") return item?.text?.content || "";
2064
- if (t === "image") return `[image] ${item?.image?.url || ""}`;
2065
- return `[${t || "item"}]`;
2066
- }).filter(Boolean).join("\n");
2067
- } else body = "[mixed]";
2068
- } else if (msgtype === "image") body = `[image] ${(msg as any).image?.url || ""}`;
2069
- else if (msgtype === "file") body = `[file] ${(msg as any).file?.url || ""}`;
2070
- else if (msgtype === "event") body = `[event] ${(msg as any).event?.eventtype || ""}`;
2071
- else if (msgtype === "stream") body = `[stream_refresh] ${(msg as any).stream?.id || ""}`;
2072
- else body = msgtype ? `[${msgtype}]` : "";
2073
-
2074
- const quote = (msg as any).quote;
2075
- if (quote) {
2076
- const quoteText = formatQuote(quote).trim();
2077
- if (quoteText) body += `\n\n> ${quoteText}`;
2078
- }
2079
- return body;
2080
- }
81
+ monitorState.streamStore.setFlushHandler((pending) => void flushPending(pending));
2081
82
 
2082
- /**
2083
- * **registerWecomWebhookTarget (注册 Webhook 目标)**
2084
- *
2085
- * 注册一个 Bot 模式的接收端点。
2086
- * 同时会触发清理定时器的检查(如果有新注册,确保定时器运行)。
2087
- * 返回一个注销函数。
2088
- */
2089
- export function registerWecomWebhookTarget(target: WecomWebhookTarget): () => void {
2090
- const key = normalizeWebhookPath(target.path);
2091
- const normalizedTarget = { ...target, path: key };
2092
- const existing = webhookTargets.get(key) ?? [];
2093
- webhookTargets.set(key, [...existing, normalizedTarget]);
2094
- ensurePruneTimer();
2095
- return () => {
2096
- const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
2097
- if (updated.length > 0) webhookTargets.set(key, updated);
2098
- else webhookTargets.delete(key);
2099
- checkPruneTimer();
2100
- };
2101
- }
83
+ const handleBotWebhookRequest = createBotWebhookRequestHandler({
84
+ streamStore,
85
+ logInfo,
86
+ logVerbose,
87
+ recordBotOperationalEvent,
88
+ startAgentForStream,
89
+ });
2102
90
 
2103
- /**
2104
- * 注册 Agent 模式 Webhook Target
2105
- */
2106
- export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => void {
2107
- const key = normalizeWebhookPath(target.path);
2108
- const normalizedTarget = { ...target, path: key };
2109
- const existing = agentTargets.get(key) ?? [];
2110
- agentTargets.set(key, [...existing, normalizedTarget]);
2111
- ensurePruneTimer();
2112
- return () => {
2113
- const updated = (agentTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
2114
- if (updated.length > 0) agentTargets.set(key, updated);
2115
- else agentTargets.delete(key);
2116
- checkPruneTimer();
2117
- };
2118
- }
91
+ export { registerAgentWebhookTarget, registerWecomWebhookTarget };
92
+ export { sendActiveMessage, shouldProcessBotInboundMessage };
93
+ export type { BotInboundProcessDecision };
2119
94
 
2120
95
  /**
2121
96
  * **handleWecomWebhookRequest (HTTP 请求入口)**
@@ -2130,531 +105,17 @@ export function registerAgentWebhookTarget(target: AgentWebhookTarget): () => vo
2130
105
  * - POST 请求:接收消息,放入 StreamStore,返回流式 First Chunk。
2131
106
  */
2132
107
  export async function handleWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
2133
- const path = resolvePath(req);
2134
- const reqId = crypto.randomUUID().slice(0, 8);
2135
- const remote = req.socket?.remoteAddress ?? "unknown";
2136
- const ua = String(req.headers["user-agent"] ?? "");
2137
- const cl = String(req.headers["content-length"] ?? "");
2138
- // 不输出敏感参数内容,仅输出是否存在(排查“有没有打到网关/有没有带签名参数”)
2139
- const q = resolveQueryParams(req);
2140
- const hasTimestamp = Boolean(q.get("timestamp"));
2141
- const hasNonce = Boolean(q.get("nonce"));
2142
- const hasEchostr = Boolean(q.get("echostr"));
2143
- const hasMsgSig = Boolean(q.get("msg_signature"));
2144
- const hasSignature = Boolean(q.get("signature"));
2145
- console.log(
2146
- `[wecom] inbound(http): reqId=${reqId} path=${path} method=${req.method ?? "UNKNOWN"} remote=${remote} ua=${ua ? `"${ua}"` : "N/A"} contentLength=${cl || "N/A"} query={timestamp:${hasTimestamp},nonce:${hasNonce},echostr:${hasEchostr},msg_signature:${hasMsgSig},signature:${hasSignature}}`,
2147
- );
2148
-
2149
- if (hasMatrixExplicitRoutesRegistered() && isNonMatrixWecomBasePath(path)) {
2150
- logRouteFailure({
2151
- reqId,
2152
- path,
2153
- method: req.method ?? "UNKNOWN",
2154
- reason: "wecom_matrix_path_required",
2155
- candidateAccountIds: [],
2156
- });
2157
- writeRouteFailure(
2158
- res,
2159
- "wecom_matrix_path_required",
2160
- "Matrix mode requires explicit account path. Use /plugins/wecom/bot/{accountId} or /plugins/wecom/agent/{accountId}.",
2161
- );
2162
- return true;
2163
- }
2164
-
2165
- const isAgentPathCandidate =
2166
- path === WEBHOOK_PATHS.AGENT ||
2167
- path === WEBHOOK_PATHS.AGENT_PLUGIN ||
2168
- path.startsWith(`${WEBHOOK_PATHS.AGENT}/`) ||
2169
- path.startsWith(`${WEBHOOK_PATHS.AGENT_PLUGIN}/`);
2170
- const matchedAgentTargets = agentTargets.get(path) ?? [];
2171
- if (matchedAgentTargets.length > 0 || isAgentPathCandidate) {
2172
- const targets = matchedAgentTargets;
2173
- if (targets.length > 0) {
2174
- const query = resolveQueryParams(req);
2175
- const timestamp = query.get("timestamp") ?? "";
2176
- const nonce = query.get("nonce") ?? "";
2177
- const signature = resolveSignatureParam(query);
2178
- const hasSig = Boolean(signature);
2179
- const remote = req.socket?.remoteAddress ?? "unknown";
2180
-
2181
- if (req.method === "GET") {
2182
- const echostr = query.get("echostr") ?? "";
2183
- const signatureMatches = targets.filter((target) =>
2184
- verifyWecomSignature({
2185
- token: target.agent.token,
2186
- timestamp,
2187
- nonce,
2188
- encrypt: echostr,
2189
- signature,
2190
- }),
2191
- );
2192
- if (signatureMatches.length !== 1) {
2193
- const reason: RouteFailureReason =
2194
- signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
2195
- const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
2196
- (target) => target.agent.accountId,
2197
- );
2198
- logRouteFailure({
2199
- reqId,
2200
- path,
2201
- method: "GET",
2202
- reason,
2203
- candidateAccountIds: candidateIds,
2204
- });
2205
- writeRouteFailure(
2206
- res,
2207
- reason,
2208
- reason === "wecom_account_conflict"
2209
- ? "Agent callback account conflict: multiple accounts matched signature."
2210
- : "Agent callback account not found: signature verification failed.",
2211
- );
2212
- return true;
2213
- }
2214
- const selected = signatureMatches[0]!;
2215
- try {
2216
- const plain = decryptWecomEncrypted({
2217
- encodingAESKey: selected.agent.encodingAESKey,
2218
- receiveId: selected.agent.corpId,
2219
- encrypt: echostr,
2220
- });
2221
- res.statusCode = 200;
2222
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2223
- res.end(plain);
2224
- return true;
2225
- } catch {
2226
- res.statusCode = 400;
2227
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2228
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
2229
- return true;
2230
- }
2231
- }
2232
-
2233
- if (req.method !== "POST") return false;
2234
-
2235
- const rawBody = await readTextBody(req, WECOM_LIMITS.MAX_REQUEST_BODY_SIZE);
2236
- if (!rawBody.ok) {
2237
- res.statusCode = 400;
2238
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2239
- res.end(rawBody.error || "invalid payload");
2240
- return true;
2241
- }
2242
-
2243
- let encrypted = "";
2244
- try {
2245
- encrypted = extractEncryptFromXml(rawBody.value);
2246
- } catch (err) {
2247
- res.statusCode = 400;
2248
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2249
- res.end(`invalid xml - 缺少 Encrypt 字段${ERROR_HELP}`);
2250
- return true;
2251
- }
2252
-
2253
- const signatureMatches = targets.filter((target) =>
2254
- verifyWecomSignature({
2255
- token: target.agent.token,
2256
- timestamp,
2257
- nonce,
2258
- encrypt: encrypted,
2259
- signature,
2260
- }),
2261
- );
2262
- if (signatureMatches.length !== 1) {
2263
- const reason: RouteFailureReason =
2264
- signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
2265
- const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
2266
- (target) => target.agent.accountId,
2267
- );
2268
- logRouteFailure({
2269
- reqId,
2270
- path,
2271
- method: "POST",
2272
- reason,
2273
- candidateAccountIds: candidateIds,
2274
- });
2275
- writeRouteFailure(
2276
- res,
2277
- reason,
2278
- reason === "wecom_account_conflict"
2279
- ? "Agent callback account conflict: multiple accounts matched signature."
2280
- : "Agent callback account not found: signature verification failed.",
2281
- );
2282
- return true;
2283
- }
2284
-
2285
- const selected = signatureMatches[0]!;
2286
- let decrypted = "";
2287
- let parsed: ReturnType<typeof parseXml> | null = null;
2288
- try {
2289
- decrypted = decryptWecomEncrypted({
2290
- encodingAESKey: selected.agent.encodingAESKey,
2291
- receiveId: selected.agent.corpId,
2292
- encrypt: encrypted,
2293
- });
2294
- parsed = parseXml(decrypted);
2295
- } catch {
2296
- res.statusCode = 400;
2297
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2298
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
2299
- return true;
2300
- }
2301
- if (!parsed) {
2302
- res.statusCode = 400;
2303
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2304
- res.end(`invalid xml - XML 解析失败${ERROR_HELP}`);
2305
- return true;
2306
- }
2307
-
2308
- const inboundAgentId = normalizeAgentIdValue(extractAgentId(parsed));
2309
- if (
2310
- inboundAgentId !== undefined &&
2311
- selected.agent.agentId !== undefined &&
2312
- inboundAgentId !== selected.agent.agentId
2313
- ) {
2314
- selected.runtime.error?.(
2315
- `[wecom] inbound(agent): reqId=${reqId} accountId=${selected.agent.accountId} agentId_mismatch expected=${selected.agent.agentId} actual=${inboundAgentId}`,
2316
- );
2317
- }
2318
-
2319
- const core = getWecomRuntime();
2320
- selected.runtime.log?.(
2321
- `[wecom] inbound(agent): reqId=${reqId} method=${req.method ?? "UNKNOWN"} remote=${remote} timestamp=${timestamp ? "yes" : "no"} nonce=${nonce ? "yes" : "no"} msg_signature=${hasSig ? "yes" : "no"} accountId=${selected.agent.accountId}`,
2322
- );
2323
- return handleAgentWebhook({
2324
- req,
2325
- res,
2326
- verifiedPost: {
2327
- timestamp,
2328
- nonce,
2329
- signature,
2330
- encrypted,
2331
- decrypted,
2332
- parsed,
2333
- },
2334
- agent: selected.agent,
2335
- config: selected.config,
2336
- core,
2337
- log: selected.runtime.log,
2338
- error: selected.runtime.error,
2339
- });
2340
- }
2341
- // 未注册 Agent,返回 404
2342
- res.statusCode = 404;
2343
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2344
- res.end(`agent not configured - Agent 模式未配置,请运行 openclaw onboarding${ERROR_HELP}`);
2345
- return true;
2346
- }
2347
-
2348
- // Bot 模式路由: /plugins/wecom/bot(推荐)以及 /wecom、/wecom/bot(兼容)
2349
- const targets = webhookTargets.get(path);
2350
- if (!targets || targets.length === 0) return false;
2351
-
2352
- const query = resolveQueryParams(req);
2353
- const timestamp = query.get("timestamp") ?? "";
2354
- const nonce = query.get("nonce") ?? "";
2355
- const signature = resolveSignatureParam(query);
2356
-
2357
- if (req.method === "GET") {
2358
- const echostr = query.get("echostr") ?? "";
2359
- const signatureMatches = targets.filter((target) =>
2360
- target.account.token &&
2361
- verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt: echostr, signature }),
2362
- );
2363
- if (signatureMatches.length !== 1) {
2364
- const reason: RouteFailureReason =
2365
- signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
2366
- const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
2367
- (target) => target.account.accountId,
2368
- );
2369
- logRouteFailure({
2370
- reqId,
2371
- path,
2372
- method: "GET",
2373
- reason,
2374
- candidateAccountIds: candidateIds,
2375
- });
2376
- writeRouteFailure(
2377
- res,
2378
- reason,
2379
- reason === "wecom_account_conflict"
2380
- ? "Bot callback account conflict: multiple accounts matched signature."
2381
- : "Bot callback account not found: signature verification failed.",
2382
- );
2383
- return true;
2384
- }
2385
- const target = signatureMatches[0]!;
2386
- try {
2387
- const plain = decryptWecomEncrypted({ encodingAESKey: target.account.encodingAESKey, receiveId: target.account.receiveId, encrypt: echostr });
2388
- res.statusCode = 200;
2389
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2390
- res.end(plain);
2391
- return true;
2392
- } catch (err) {
2393
- res.statusCode = 400;
2394
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2395
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
2396
- return true;
2397
- }
2398
- }
2399
-
2400
- if (req.method !== "POST") return false;
2401
-
2402
- const body = await readJsonBody(req, 1024 * 1024);
2403
- if (!body.ok) {
2404
- res.statusCode = 400;
2405
- res.end(body.error || "invalid payload");
2406
- return true;
2407
- }
2408
- const record = body.value as any;
2409
- const encrypt = String(record?.encrypt ?? record?.Encrypt ?? "");
2410
- // Bot POST 回调体积/字段诊断(不输出 encrypt 内容)
2411
- console.log(
2412
- `[wecom] inbound(bot): reqId=${reqId} rawJsonBytes=${Buffer.byteLength(JSON.stringify(record), "utf8")} hasEncrypt=${Boolean(encrypt)} encryptLen=${encrypt.length}`,
2413
- );
2414
- const signatureMatches = targets.filter((target) =>
2415
- target.account.token &&
2416
- verifyWecomSignature({ token: target.account.token, timestamp, nonce, encrypt, signature }),
2417
- );
2418
- if (signatureMatches.length !== 1) {
2419
- const reason: RouteFailureReason =
2420
- signatureMatches.length === 0 ? "wecom_account_not_found" : "wecom_account_conflict";
2421
- const candidateIds = (signatureMatches.length > 0 ? signatureMatches : targets).map(
2422
- (target) => target.account.accountId,
2423
- );
2424
- logRouteFailure({
2425
- reqId,
2426
- path,
2427
- method: "POST",
2428
- reason,
2429
- candidateAccountIds: candidateIds,
2430
- });
2431
- writeRouteFailure(
2432
- res,
2433
- reason,
2434
- reason === "wecom_account_conflict"
2435
- ? "Bot callback account conflict: multiple accounts matched signature."
2436
- : "Bot callback account not found: signature verification failed.",
2437
- );
2438
- return true;
2439
- }
2440
-
2441
- const target = signatureMatches[0]!;
2442
- let msg: WecomInboundMessage;
2443
- try {
2444
- const plain = decryptWecomEncrypted({
2445
- encodingAESKey: target.account.encodingAESKey,
2446
- receiveId: target.account.receiveId,
2447
- encrypt,
2448
- });
2449
- msg = parseWecomPlainMessage(plain);
2450
- } catch {
2451
- res.statusCode = 400;
2452
- res.setHeader("Content-Type", "text/plain; charset=utf-8");
2453
- res.end(`decrypt failed - 解密失败,请检查 EncodingAESKey${ERROR_HELP}`);
2454
- return true;
2455
- }
2456
- const expected = resolveBotIdentitySet(target);
2457
- if (expected.size > 0) {
2458
- const inboundAibotId = String((msg as any).aibotid ?? "").trim();
2459
- if (!inboundAibotId || !expected.has(inboundAibotId)) {
2460
- target.runtime.error?.(
2461
- `[wecom] inbound(bot): reqId=${reqId} accountId=${target.account.accountId} aibotid_mismatch expected=${Array.from(expected).join(",")} actual=${inboundAibotId || "N/A"}`,
2462
- );
2463
- }
2464
- }
2465
-
2466
- logInfo(target, `inbound(bot): reqId=${reqId} selectedAccount=${target.account.accountId} path=${path}`);
2467
- const msgtype = String(msg.msgtype ?? "").toLowerCase();
2468
- const proxyUrl = resolveWecomEgressProxyUrl(target.config);
2469
-
2470
- // Handle Event
2471
- if (msgtype === "event") {
2472
- const eventtype = String((msg as any).event?.eventtype ?? "").toLowerCase();
2473
-
2474
- if (eventtype === "template_card_event") {
2475
- const msgid = msg.msgid ? String(msg.msgid) : undefined;
2476
-
2477
- // Dedupe: skip if already processed this event
2478
- if (msgid && streamStore.getStreamByMsgId(msgid)) {
2479
- logVerbose(target, `template_card_event: already processed msgid=${msgid}, skipping`);
2480
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
2481
- return true;
2482
- }
2483
-
2484
- const cardEvent = (msg as any).event?.template_card_event;
2485
- let interactionDesc = `[卡片交互] 按钮: ${cardEvent?.event_key || "unknown"}`;
2486
- if (cardEvent?.selected_items?.selected_item?.length) {
2487
- const selects = cardEvent.selected_items.selected_item.map((i: any) => `${i.question_key}=${i.option_ids?.option_id?.join(",")}`);
2488
- interactionDesc += ` 选择: ${selects.join("; ")}`;
2489
- }
2490
- if (cardEvent?.task_id) interactionDesc += ` (任务ID: ${cardEvent.task_id})`;
2491
-
2492
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
2493
-
2494
- const streamId = streamStore.createStream({ msgid });
2495
- streamStore.markStarted(streamId);
2496
- storeActiveReply(streamId, msg.response_url);
2497
- const core = getWecomRuntime();
2498
- startAgentForStream({
2499
- target: { ...target, core },
2500
- accountId: target.account.accountId,
2501
- msg: { ...msg, msgtype: "text", text: { content: interactionDesc } } as any,
2502
- streamId,
2503
- }).catch(err => target.runtime.error?.(`interaction failed: ${String(err)}`));
2504
- return true;
2505
- }
2506
-
2507
- if (eventtype === "enter_chat") {
2508
- const welcome = target.account.config.welcomeText?.trim();
2509
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: welcome ? { msgtype: "text", text: { content: welcome } } : {}, nonce, timestamp }));
2510
- return true;
2511
- }
2512
-
2513
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
2514
- return true;
2515
- }
2516
-
2517
- // Handle Stream Refresh
2518
- if (msgtype === "stream") {
2519
- const streamId = String((msg as any).stream?.id ?? "").trim();
2520
- const state = streamStore.getStream(streamId);
2521
- const reply = state ? buildStreamReplyFromState(state) : buildStreamReplyFromState({ streamId: streamId || "unknown", createdAt: Date.now(), updatedAt: Date.now(), started: true, finished: true, content: "" });
2522
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: reply, nonce, timestamp }));
2523
- return true;
2524
- }
2525
-
2526
- // Handle Message (with Debounce)
2527
- try {
2528
- const decision = shouldProcessBotInboundMessage(msg);
2529
- if (!decision.shouldProcess) {
2530
- logInfo(
2531
- target,
2532
- `inbound: skipped msgtype=${msgtype} reason=${decision.reason} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${resolveWecomSenderUserId(msg) || "N/A"}`,
2533
- );
2534
- jsonOk(res, buildEncryptedJsonReply({ account: target.account, plaintextJson: {}, nonce, timestamp }));
2535
- return true;
2536
- }
2537
-
2538
- const userid = decision.senderUserId!;
2539
- const chatId = decision.chatId ?? userid;
2540
- const conversationKey = `wecom:${target.account.accountId}:${userid}:${chatId}`;
2541
- const msgContent = buildInboundBody(msg);
2542
-
2543
- logInfo(
2544
- target,
2545
- `inbound: msgtype=${msgtype} chattype=${String(msg.chattype ?? "")} chatid=${String(msg.chatid ?? "")} from=${userid} msgid=${String(msg.msgid ?? "")} hasResponseUrl=${Boolean((msg as any).response_url)}`,
2546
- );
2547
-
2548
- // 去重: 若 msgid 已存在于 StreamStore,说明是重试请求,直接返回占位符
2549
- if (msg.msgid) {
2550
- const existingStreamId = streamStore.getStreamByMsgId(String(msg.msgid));
2551
- if (existingStreamId) {
2552
- logInfo(target, `message: 重复的 msgid=${msg.msgid},跳过处理并返回占位符 streamId=${existingStreamId}`);
2553
- jsonOk(res, buildEncryptedJsonReply({
2554
- account: target.account,
2555
- plaintextJson: buildStreamPlaceholderReply({
2556
- streamId: existingStreamId,
2557
- placeholderContent: target.account.config.streamPlaceholderContent
2558
- }),
2559
- nonce,
2560
- timestamp
2561
- }));
2562
- return true;
2563
- }
2564
- }
2565
-
2566
- // 加入 Pending 队列 (防抖/聚合)
2567
- // 消息不会立即处理,而是等待防抖计时器结束(flushPending)后统一触发
2568
- const { streamId, status } = streamStore.addPendingMessage({
2569
- conversationKey,
2570
- target,
2571
- msg,
2572
- msgContent,
2573
- nonce,
2574
- timestamp,
2575
- debounceMs: (target.account.config as any).debounceMs
2576
- });
2577
-
2578
- // 无论是否新建,都尽量保存 response_url(用于兜底提示/最终帧推送)
2579
- if (msg.response_url) {
2580
- storeActiveReply(streamId, msg.response_url, proxyUrl);
2581
- }
2582
-
2583
- const defaultPlaceholder = target.account.config.streamPlaceholderContent;
2584
- const queuedPlaceholder = "已收到,已排队处理中...";
2585
- const mergedQueuedPlaceholder = "已收到,已合并排队处理中...";
2586
-
2587
- if (status === "active_new") {
2588
- jsonOk(res, buildEncryptedJsonReply({
2589
- account: target.account,
2590
- plaintextJson: buildStreamPlaceholderReply({
2591
- streamId,
2592
- placeholderContent: defaultPlaceholder
2593
- }),
2594
- nonce,
2595
- timestamp
2596
- }));
2597
- return true;
2598
- }
2599
-
2600
- if (status === "queued_new") {
2601
- logInfo(target, `queue: 已进入下一批次 streamId=${streamId} msgid=${String(msg.msgid ?? "")}`);
2602
- jsonOk(res, buildEncryptedJsonReply({
2603
- account: target.account,
2604
- plaintextJson: buildStreamPlaceholderReply({
2605
- streamId,
2606
- placeholderContent: queuedPlaceholder
2607
- }),
2608
- nonce,
2609
- timestamp
2610
- }));
2611
- return true;
2612
- }
2613
-
2614
- // active_merged / queued_merged:合并进某个批次,但本条消息不应该刷出“完整答案”,否则用户会看到重复内容。
2615
- // 做法:为本条 msgid 创建一个“回执 stream”,先显示“已合并排队”,并在批次结束时自动更新为“已合并处理完成”。
2616
- const ackStreamId = streamStore.createStream({ msgid: String(msg.msgid ?? "") || undefined });
2617
- streamStore.updateStream(ackStreamId, (s) => {
2618
- s.finished = false;
2619
- s.started = true;
2620
- s.content = mergedQueuedPlaceholder;
2621
- });
2622
- if (msg.msgid) streamStore.setStreamIdForMsgId(String(msg.msgid), ackStreamId);
2623
- streamStore.addAckStreamForBatch({ batchStreamId: streamId, ackStreamId });
2624
- logInfo(target, `queue: 已合并排队(回执流) ackStreamId=${ackStreamId} mergedIntoStreamId=${streamId} msgid=${String(msg.msgid ?? "")}`);
2625
- jsonOk(res, buildEncryptedJsonReply({
2626
- account: target.account,
2627
- plaintextJson: buildStreamTextPlaceholderReply({ streamId: ackStreamId, content: mergedQueuedPlaceholder }),
2628
- nonce,
2629
- timestamp
2630
- }));
2631
- return true;
2632
- } catch (err) {
2633
- target.runtime.error?.(`[wecom] Bot message handler crashed: ${String(err)}`);
2634
- // 尽量返回 200,避免企微重试风暴;同时给一个可见的错误文本
2635
- jsonOk(res, buildEncryptedJsonReply({
2636
- account: target.account,
2637
- plaintextJson: { msgtype: "text", text: { content: "服务内部错误:Bot 处理异常,请稍后重试。" } },
2638
- nonce,
2639
- timestamp
2640
- }));
2641
- return true;
2642
- }
108
+ return handleWecomHttpRequest({
109
+ req,
110
+ res,
111
+ handleBotWebhookRequest,
112
+ });
2643
113
  }
2644
114
 
2645
- export async function sendActiveMessage(streamId: string, content: string): Promise<void> {
2646
- await useActiveReplyOnce(streamId, async ({ responseUrl, proxyUrl }) => {
2647
- const res = await wecomFetch(
2648
- responseUrl,
2649
- {
2650
- method: "POST",
2651
- headers: { "Content-Type": "application/json" },
2652
- body: JSON.stringify({ msgtype: "text", text: { content } }),
2653
- },
2654
- { proxyUrl, timeoutMs: LIMITS.REQUEST_TIMEOUT_MS },
2655
- );
2656
- if (!res.ok) {
2657
- throw new Error(`active send failed: ${res.status}`);
2658
- }
115
+ export async function handleLegacyWecomWebhookRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
116
+ return handleWecomHttpRequest({
117
+ req,
118
+ res,
119
+ handleBotWebhookRequest,
2659
120
  });
2660
121
  }