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