@sunnoy/wecom 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -9
- package/index.js +16 -0
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-doc/SKILL.md +363 -0
- package/skills/wecom-doc/references/doc-api.md +224 -0
- package/wecom/accounts.js +19 -0
- package/wecom/agent-api.js +7 -6
- package/wecom/callback-crypto.js +80 -0
- package/wecom/callback-inbound.js +718 -0
- package/wecom/callback-media.js +76 -0
- package/wecom/channel-plugin.js +129 -126
- package/wecom/constants.js +84 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +687 -326
- package/image-processor.js +0 -175
package/wecom/ws-monitor.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { fileURLToPath, URL } from "node:url";
|
|
4
6
|
import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
|
|
7
|
+
import { uploadAndSendMedia, buildMediaErrorSummary } from "./media-uploader.js";
|
|
8
|
+
import { createPersistentReqIdStore } from "./reqid-store.js";
|
|
5
9
|
import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
|
|
6
|
-
import { prepareImageBufferForMsgItem } from "../image-processor.js";
|
|
7
10
|
import { logger } from "../logger.js";
|
|
8
11
|
import { normalizeThinkingTags } from "../think-parser.js";
|
|
9
12
|
import { MessageDeduplicator } from "../utils.js";
|
|
@@ -20,6 +23,7 @@ import {
|
|
|
20
23
|
CHANNEL_ID,
|
|
21
24
|
DEFAULT_MEDIA_MAX_MB,
|
|
22
25
|
DEFAULT_WELCOME_MESSAGE,
|
|
26
|
+
DEFAULT_WELCOME_MESSAGES,
|
|
23
27
|
FILE_DOWNLOAD_TIMEOUT_MS,
|
|
24
28
|
IMAGE_DOWNLOAD_TIMEOUT_MS,
|
|
25
29
|
MEDIA_DOCUMENT_PLACEHOLDER,
|
|
@@ -34,6 +38,7 @@ import {
|
|
|
34
38
|
import { setConfigProxyUrl } from "./http.js";
|
|
35
39
|
import { checkDmPolicy } from "./dm-policy.js";
|
|
36
40
|
import { checkGroupPolicy } from "./group-policy.js";
|
|
41
|
+
import { fetchAndSaveMcpConfig } from "./mcp-config.js";
|
|
37
42
|
import {
|
|
38
43
|
clearAccountDisplaced,
|
|
39
44
|
forecastActiveSendQuota,
|
|
@@ -61,17 +66,20 @@ import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
|
61
66
|
const DEFAULT_AGENT_ID = "main";
|
|
62
67
|
const DEFAULT_STATE_DIRNAME = ".openclaw";
|
|
63
68
|
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
|
|
64
|
-
const
|
|
65
|
-
const MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
69
|
+
const WAITING_MODEL_TICK_MS = 1_000;
|
|
66
70
|
const REASONING_STREAM_THROTTLE_MS = 800;
|
|
67
71
|
const VISIBLE_STREAM_THROTTLE_MS = 800;
|
|
68
72
|
// Reserve headroom below the SDK's per-reqId queue limit (100) so the final
|
|
69
73
|
// reply always has room.
|
|
70
74
|
const MAX_INTERMEDIATE_STREAM_MESSAGES = 85;
|
|
75
|
+
// WeCom stream messages expire if not updated within 6 minutes. Send a
|
|
76
|
+
// keepalive update every 4 minutes to keep the stream alive during long runs.
|
|
77
|
+
const STREAM_KEEPALIVE_INTERVAL_MS = 4 * 60 * 1000;
|
|
71
78
|
// Match MEDIA:/FILE: directives at line start, optionally preceded by markdown list markers.
|
|
72
79
|
const REPLY_MEDIA_DIRECTIVE_PATTERN = /^\s*(?:[-*•]\s+|\d+\.\s+)?(?:MEDIA|FILE)\s*:/im;
|
|
73
80
|
const WECOM_REPLY_MEDIA_GUIDANCE_HEADER = "[WeCom reply media rule]";
|
|
74
81
|
const inboundMessageDeduplicator = new MessageDeduplicator();
|
|
82
|
+
const sessionReasoningInitLocks = new Map();
|
|
75
83
|
|
|
76
84
|
function withTimeout(promise, timeoutMs, message) {
|
|
77
85
|
if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
@@ -119,6 +127,15 @@ function normalizeReasoningStreamText(text) {
|
|
|
119
127
|
return lines.join("\n").trim();
|
|
120
128
|
}
|
|
121
129
|
|
|
130
|
+
function buildWaitingModelContent(seconds) {
|
|
131
|
+
const normalizedSeconds = Math.max(1, Number.parseInt(String(seconds ?? 1), 10) || 1);
|
|
132
|
+
const lines = [];
|
|
133
|
+
for (let current = 1; current <= normalizedSeconds; current += 1) {
|
|
134
|
+
lines.push(`等待模型响应 ${current}s`);
|
|
135
|
+
}
|
|
136
|
+
return `<think>${lines.join("\n")}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
122
139
|
function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = false }) {
|
|
123
140
|
const normalizedReasoning = String(reasoningText ?? "").trim();
|
|
124
141
|
const normalizedVisible = String(visibleText ?? "").trim();
|
|
@@ -135,6 +152,112 @@ function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = f
|
|
|
135
152
|
return normalizedVisible ? `${thinkBlock}\n${normalizedVisible}` : thinkBlock;
|
|
136
153
|
}
|
|
137
154
|
|
|
155
|
+
function normalizeWecomCreateTimeMs(value) {
|
|
156
|
+
const seconds = Number(value);
|
|
157
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
return Math.trunc(seconds * 1000);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function getWecomSourceTiming(createTime, now = Date.now()) {
|
|
164
|
+
const sourceCreateTimeMs = normalizeWecomCreateTimeMs(createTime);
|
|
165
|
+
if (!sourceCreateTimeMs) {
|
|
166
|
+
return {
|
|
167
|
+
sourceCreateTime: undefined,
|
|
168
|
+
sourceCreateTimeIso: undefined,
|
|
169
|
+
sourceToIngressMs: undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
sourceCreateTime: createTime,
|
|
175
|
+
sourceCreateTimeIso: new Date(sourceCreateTimeMs).toISOString(),
|
|
176
|
+
sourceToIngressMs: Math.max(0, now - sourceCreateTimeMs),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function resolveWsKeepaliveContent({ reasoningText = "", visibleText = "", lastStreamText = "" }) {
|
|
181
|
+
const currentContent = buildWsStreamContent({
|
|
182
|
+
reasoningText,
|
|
183
|
+
visibleText,
|
|
184
|
+
finish: false,
|
|
185
|
+
});
|
|
186
|
+
return currentContent || String(lastStreamText ?? "").trim() || THINKING_MESSAGE;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeSessionStoreKey(sessionKey) {
|
|
190
|
+
return String(sessionKey ?? "").trim().toLowerCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function withSessionReasoningInitLock(storePath, task) {
|
|
194
|
+
const lockKey = path.resolve(String(storePath ?? ""));
|
|
195
|
+
const previous = sessionReasoningInitLocks.get(lockKey) ?? Promise.resolve();
|
|
196
|
+
const current = previous.then(task, task);
|
|
197
|
+
sessionReasoningInitLocks.set(lockKey, current);
|
|
198
|
+
return await current.finally(() => {
|
|
199
|
+
if (sessionReasoningInitLocks.get(lockKey) === current) {
|
|
200
|
+
sessionReasoningInitLocks.delete(lockKey);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function ensureDefaultSessionReasoningLevel({
|
|
206
|
+
core,
|
|
207
|
+
storePath,
|
|
208
|
+
sessionKey,
|
|
209
|
+
ctx,
|
|
210
|
+
reasoningLevel = "stream",
|
|
211
|
+
channelTag = "WS",
|
|
212
|
+
}) {
|
|
213
|
+
const normalizedSessionKey = normalizeSessionStoreKey(sessionKey);
|
|
214
|
+
if (!storePath || !normalizedSessionKey || !ctx || !core?.session?.recordSessionMetaFromInbound) {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const recorded = await core.session.recordSessionMetaFromInbound({
|
|
220
|
+
storePath,
|
|
221
|
+
sessionKey: normalizedSessionKey,
|
|
222
|
+
ctx,
|
|
223
|
+
});
|
|
224
|
+
if (!recorded || recorded.reasoningLevel != null) {
|
|
225
|
+
return recorded;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return await withSessionReasoningInitLock(storePath, async () => {
|
|
229
|
+
let store;
|
|
230
|
+
try {
|
|
231
|
+
store = JSON.parse(await readFile(storePath, "utf8"));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
logger.warn(`[${channelTag}] Failed to read session store for reasoning default: ${error.message}`);
|
|
234
|
+
return recorded;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const resolvedKey = Object.keys(store).find((key) => normalizeSessionStoreKey(key) === normalizedSessionKey);
|
|
238
|
+
if (!resolvedKey) {
|
|
239
|
+
return recorded;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const existing = store[resolvedKey];
|
|
243
|
+
if (!existing || typeof existing !== "object" || existing.reasoningLevel != null) {
|
|
244
|
+
return existing ?? recorded;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
store[resolvedKey] = { ...existing, reasoningLevel };
|
|
248
|
+
await writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`);
|
|
249
|
+
logger.info(`[${channelTag}] Initialized session reasoningLevel default`, {
|
|
250
|
+
sessionKey: resolvedKey,
|
|
251
|
+
reasoningLevel,
|
|
252
|
+
});
|
|
253
|
+
return store[resolvedKey];
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
logger.warn(`[${channelTag}] Failed to initialize session reasoning default: ${error.message}`);
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
138
261
|
function createSdkLogger(accountId) {
|
|
139
262
|
return {
|
|
140
263
|
debug: (message, ...args) => logger.debug(`[WS:${accountId}] ${message}`, ...args),
|
|
@@ -165,19 +288,6 @@ function resolveChannelCore(runtime) {
|
|
|
165
288
|
throw new Error("OpenClaw channel runtime is unavailable");
|
|
166
289
|
}
|
|
167
290
|
|
|
168
|
-
function resolveMediaRuntime(runtime) {
|
|
169
|
-
const registeredRuntime = getRegisteredRuntimeOrNull();
|
|
170
|
-
const candidates = [runtime, registeredRuntime];
|
|
171
|
-
|
|
172
|
-
for (const candidate of candidates) {
|
|
173
|
-
if (typeof candidate?.media?.loadWebMedia === "function") {
|
|
174
|
-
return candidate;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
throw new Error("OpenClaw media runtime is unavailable");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
291
|
function resolveUserPath(value) {
|
|
182
292
|
const trimmed = String(value ?? "").trim();
|
|
183
293
|
if (!trimmed) {
|
|
@@ -279,17 +389,61 @@ function buildReplyMediaGuidance(config, agentId) {
|
|
|
279
389
|
const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
|
|
280
390
|
return [
|
|
281
391
|
WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
|
|
282
|
-
`
|
|
392
|
+
`Local reply files are allowed only under the current workspace: ${workspaceDir}`,
|
|
393
|
+
"Inside the agent sandbox, that same workspace is visible as /workspace.",
|
|
283
394
|
`Browser-generated files are also allowed only under: ${browserMediaDir}`,
|
|
284
395
|
"Never reference any other host path.",
|
|
285
|
-
"Do NOT call message.send
|
|
286
|
-
"
|
|
287
|
-
"
|
|
396
|
+
"Do NOT call message.send or message.sendAttachment to deliver files back to the current WeCom chat/user; use MEDIA: or FILE: directives instead.",
|
|
397
|
+
"For images: put each image path on its own line as MEDIA:/abs/path.",
|
|
398
|
+
"If a local file is in the current sandbox workspace, use its /workspace/... path directly.",
|
|
399
|
+
"For every non-image file (PDF, MD, DOC, DOCX, XLS, XLSX, CSV, ZIP, MP4, TXT, etc.): put it on its own line as FILE:/abs/path.",
|
|
400
|
+
"Example: FILE:/workspace/skills/deep-research/SKILL.md",
|
|
401
|
+
"CRITICAL: Never use MEDIA: for non-image files. PDF must always use FILE:, never MEDIA:.",
|
|
402
|
+
"CRITICAL: If a tool already returned a path prefixed with FILE: (e.g. FILE:/abs/path.pdf), keep the FILE: prefix exactly as-is. Do NOT change it to MEDIA:.",
|
|
288
403
|
"Each directive MUST be on its own line with no other text on that line.",
|
|
289
404
|
"The plugin will automatically send the media to the user.",
|
|
290
405
|
].join("\n");
|
|
291
406
|
}
|
|
292
407
|
|
|
408
|
+
function normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId) {
|
|
409
|
+
let normalized = String(mediaUrl ?? "").trim();
|
|
410
|
+
if (!normalized) {
|
|
411
|
+
return "";
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (/^file:\/\//i.test(normalized)) {
|
|
415
|
+
try {
|
|
416
|
+
const parsed = new URL(normalized);
|
|
417
|
+
if (parsed.protocol === "file:") {
|
|
418
|
+
normalized = fileURLToPath(parsed);
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
return normalized;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (/^sandbox:\/{0,2}/i.test(normalized)) {
|
|
426
|
+
normalized = normalized.replace(/^sandbox:\/{0,2}/i, "/");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (normalized === "/workspace" || normalized.startsWith("/workspace/")) {
|
|
430
|
+
const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
|
|
431
|
+
const rel = normalized === "/workspace" ? "" : normalized.slice("/workspace/".length);
|
|
432
|
+
const resolved = rel
|
|
433
|
+
? path.resolve(workspaceDir, ...rel.split("/").filter(Boolean))
|
|
434
|
+
: path.resolve(workspaceDir);
|
|
435
|
+
// Prevent path traversal outside workspace directory
|
|
436
|
+
const normalizedWorkspace = path.resolve(workspaceDir) + path.sep;
|
|
437
|
+
if (resolved !== path.resolve(workspaceDir) && !resolved.startsWith(normalizedWorkspace)) {
|
|
438
|
+
logger.warn(`[WS] Blocked path traversal attempt: ${mediaUrl} resolved to ${resolved}`);
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
return resolved;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return normalized;
|
|
445
|
+
}
|
|
446
|
+
|
|
293
447
|
function buildBodyForAgent(body, config, agentId) {
|
|
294
448
|
// Guidance is now injected via before_prompt_build hook into system prompt.
|
|
295
449
|
// Keep buildBodyForAgent as a plain passthrough for the user message body.
|
|
@@ -356,205 +510,97 @@ function applyAccountNetworkConfig(account) {
|
|
|
356
510
|
setApiBaseUrl(network.apiBaseUrl ?? "");
|
|
357
511
|
}
|
|
358
512
|
|
|
359
|
-
function
|
|
360
|
-
|
|
361
|
-
loaded?.fileName,
|
|
362
|
-
loaded?.filename,
|
|
363
|
-
loaded?.name,
|
|
364
|
-
typeof loaded?.path === "string" ? path.basename(loaded.path) : "",
|
|
365
|
-
];
|
|
366
|
-
|
|
367
|
-
for (const candidate of candidateNames) {
|
|
368
|
-
const normalized = String(candidate ?? "").trim();
|
|
369
|
-
if (normalized) {
|
|
370
|
-
return normalized;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
const normalizedUrl = String(mediaUrl ?? "").trim();
|
|
375
|
-
if (!normalizedUrl) {
|
|
376
|
-
return "attachment";
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (normalizedUrl.startsWith("/")) {
|
|
380
|
-
return path.basename(normalizedUrl) || "attachment";
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
try {
|
|
384
|
-
return path.basename(new URL(normalizedUrl).pathname) || "attachment";
|
|
385
|
-
} catch {
|
|
386
|
-
return path.basename(normalizedUrl) || "attachment";
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
function resolveReplyMediaAgentType(mediaUrl, loaded) {
|
|
391
|
-
const contentType = String(loaded?.contentType ?? "").toLowerCase();
|
|
392
|
-
if (loaded?.kind === "image" || contentType.startsWith("image/")) {
|
|
393
|
-
return "image";
|
|
394
|
-
}
|
|
395
|
-
const filename = resolveReplyMediaFilename(mediaUrl, loaded).toLowerCase();
|
|
396
|
-
if (/\.(jpg|jpeg|png|gif|bmp|webp)$/.test(filename)) {
|
|
397
|
-
return "image";
|
|
398
|
-
}
|
|
399
|
-
return "file";
|
|
513
|
+
function stripThinkTags(text) {
|
|
514
|
+
return String(text ?? "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
400
515
|
}
|
|
401
516
|
|
|
402
|
-
async function
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
const localRoots = resolveReplyMediaLocalRoots(config, agentId);
|
|
417
|
-
const msgItems = [];
|
|
418
|
-
const agentMedia = [];
|
|
419
|
-
const mirroredAgentMedia = [];
|
|
420
|
-
|
|
421
|
-
for (const mediaUrl of mediaUrls) {
|
|
422
|
-
try {
|
|
423
|
-
const loaded = await mediaRuntime.media.loadWebMedia(mediaUrl, {
|
|
424
|
-
maxBytes: MAX_REPLY_IMAGE_BYTES,
|
|
425
|
-
localRoots,
|
|
517
|
+
async function sendMediaBatch({ wsClient, frame, state, account, runtime, config, agentId }) {
|
|
518
|
+
const body = frame?.body ?? {};
|
|
519
|
+
const chatId = body.chatid || body.from?.userid;
|
|
520
|
+
const mediaLocalRoots = resolveReplyMediaLocalRoots(config, agentId);
|
|
521
|
+
|
|
522
|
+
for (const mediaUrl of state.pendingMediaUrls) {
|
|
523
|
+
const normalizedUrl = normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId);
|
|
524
|
+
if (!normalizedUrl) {
|
|
525
|
+
state.hasMediaFailed = true;
|
|
526
|
+
logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=invalid_local_path`);
|
|
527
|
+
const summary = buildMediaErrorSummary(mediaUrl, {
|
|
528
|
+
ok: false,
|
|
529
|
+
rejectReason: "invalid_local_path",
|
|
530
|
+
error: "reply media path resolved outside allowed roots",
|
|
426
531
|
});
|
|
427
|
-
|
|
532
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
533
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
534
|
+
: summary;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
const result = await uploadAndSendMedia({
|
|
538
|
+
wsClient,
|
|
539
|
+
mediaUrl: normalizedUrl,
|
|
540
|
+
chatId,
|
|
541
|
+
mediaLocalRoots,
|
|
542
|
+
includeDefaultMediaLocalRoots: false,
|
|
543
|
+
log: (...args) => logger.info(...args),
|
|
544
|
+
errorLog: (...args) => logger.error(...args),
|
|
545
|
+
});
|
|
428
546
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
}
|
|
434
|
-
const image = prepareImageBufferForMsgItem(loaded.buffer);
|
|
435
|
-
msgItems.push({
|
|
436
|
-
msgtype: "image",
|
|
437
|
-
image: {
|
|
438
|
-
base64: image.base64,
|
|
439
|
-
md5: image.md5,
|
|
440
|
-
},
|
|
441
|
-
});
|
|
442
|
-
if (mirrorImagesToAgent) {
|
|
443
|
-
mirroredAgentMedia.push({
|
|
444
|
-
mediaUrl,
|
|
445
|
-
mediaType,
|
|
446
|
-
buffer: loaded.buffer,
|
|
447
|
-
filename: resolveReplyMediaFilename(mediaUrl, loaded),
|
|
448
|
-
});
|
|
449
|
-
}
|
|
450
|
-
continue;
|
|
547
|
+
if (result.ok) {
|
|
548
|
+
state.hasMedia = true;
|
|
549
|
+
if (result.downgraded) {
|
|
550
|
+
logger.info(`[WS] Media downgraded: ${result.downgradeNote}`);
|
|
451
551
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
} catch (error) {
|
|
460
|
-
logger.error(`[WS] Failed to prepare reply media ${mediaUrl}: ${error.message}`);
|
|
552
|
+
} else {
|
|
553
|
+
state.hasMediaFailed = true;
|
|
554
|
+
logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
|
|
555
|
+
const summary = buildMediaErrorSummary(mediaUrl, result);
|
|
556
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
557
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
558
|
+
: summary;
|
|
461
559
|
}
|
|
462
560
|
}
|
|
463
|
-
|
|
464
|
-
return { msgItems, agentMedia, mirroredAgentMedia };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function buildPassiveMediaAgentTarget({ senderId, chatId, isGroupChat }) {
|
|
468
|
-
return isGroupChat ? { chatId } : { toUser: senderId };
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
function buildPassiveMediaNotice(mediaType, { deliveredViaAgent = true } = {}) {
|
|
472
|
-
if (mediaType === "file") {
|
|
473
|
-
return deliveredViaAgent
|
|
474
|
-
? "由于当前企业微信bot不支持给用户发送文件,文件通过自建应用发送。"
|
|
475
|
-
: "由于当前企业微信bot不支持给用户发送文件,且当前未配置自建应用发送渠道。";
|
|
476
|
-
}
|
|
477
|
-
if (mediaType === "image") {
|
|
478
|
-
return deliveredViaAgent
|
|
479
|
-
? "由于当前企业微信bot不支持直接发送图片,图片通过自建应用发送。"
|
|
480
|
-
: "由于当前企业微信bot不支持直接发送图片,且当前未配置自建应用发送渠道。";
|
|
481
|
-
}
|
|
482
|
-
return deliveredViaAgent
|
|
483
|
-
? "由于当前企业微信bot不支持直接发送媒体,媒体通过自建应用发送。"
|
|
484
|
-
: "由于当前企业微信bot不支持直接发送媒体,且当前未配置自建应用发送渠道。";
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function buildPassiveMediaNoticeBlock(mediaEntries, { deliveredViaAgent = true } = {}) {
|
|
488
|
-
if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
|
|
489
|
-
return "";
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
const mediaTypes = [...new Set(mediaEntries.map((entry) => entry.mediaType).filter(Boolean))];
|
|
493
|
-
return mediaTypes
|
|
494
|
-
.map((mediaType) => buildPassiveMediaNotice(mediaType, { deliveredViaAgent }))
|
|
495
|
-
.filter(Boolean)
|
|
496
|
-
.join("\n\n");
|
|
561
|
+
state.pendingMediaUrls = [];
|
|
497
562
|
}
|
|
498
563
|
|
|
499
|
-
async function
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
chatId,
|
|
503
|
-
isGroupChat,
|
|
504
|
-
text,
|
|
505
|
-
includeText = false,
|
|
506
|
-
includeNotice = true,
|
|
507
|
-
mediaEntries,
|
|
508
|
-
}) {
|
|
509
|
-
if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
|
|
510
|
-
return;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
if (!account?.agentCredentials) {
|
|
514
|
-
logger.warn("[WS] Agent API is not configured; skipped passive non-image media delivery");
|
|
515
|
-
return;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
applyAccountNetworkConfig(account);
|
|
564
|
+
async function finishThinkingStream({ wsClient, frame, state, accountId }) {
|
|
565
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
566
|
+
let finishText;
|
|
519
567
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
if (includeNotice) {
|
|
526
|
-
const noticeText = buildPassiveMediaNoticeBlock(mediaEntries);
|
|
527
|
-
if (noticeText) {
|
|
528
|
-
noticeParts.push(noticeText);
|
|
568
|
+
if (visibleText) {
|
|
569
|
+
let finalVisibleText = state.accumulatedText;
|
|
570
|
+
if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
571
|
+
finalVisibleText += `\n\n${state.mediaErrorSummary}`;
|
|
529
572
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
...target,
|
|
535
|
-
text: noticeParts.join("\n\n"),
|
|
573
|
+
finishText = buildWsStreamContent({
|
|
574
|
+
reasoningText: state.reasoningText,
|
|
575
|
+
visibleText: finalVisibleText,
|
|
576
|
+
finish: true,
|
|
536
577
|
});
|
|
578
|
+
} else if (state.hasMedia) {
|
|
579
|
+
finishText = "文件已发送,请查收。";
|
|
580
|
+
} else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
581
|
+
finishText = state.mediaErrorSummary;
|
|
582
|
+
} else {
|
|
583
|
+
finishText = "处理完成。";
|
|
537
584
|
}
|
|
538
585
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
agent: account.agentCredentials,
|
|
548
|
-
...target,
|
|
549
|
-
mediaId,
|
|
550
|
-
mediaType: entry.mediaType,
|
|
551
|
-
});
|
|
552
|
-
}
|
|
586
|
+
await sendWsReply({
|
|
587
|
+
wsClient,
|
|
588
|
+
frame,
|
|
589
|
+
streamId: state.streamId,
|
|
590
|
+
text: finishText,
|
|
591
|
+
finish: true,
|
|
592
|
+
accountId,
|
|
593
|
+
});
|
|
553
594
|
}
|
|
554
595
|
|
|
555
596
|
function resolveWelcomeMessage(account) {
|
|
556
597
|
const configured = String(account?.config?.welcomeMessage ?? "").trim();
|
|
557
|
-
|
|
598
|
+
if (configured) {
|
|
599
|
+
return configured;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const index = Math.floor(Math.random() * DEFAULT_WELCOME_MESSAGES.length);
|
|
603
|
+
return DEFAULT_WELCOME_MESSAGES[index] || DEFAULT_WELCOME_MESSAGE;
|
|
558
604
|
}
|
|
559
605
|
|
|
560
606
|
function collectMixedMessageItems({ mixed, textParts, imageUrls, imageAesKeys }) {
|
|
@@ -808,13 +854,13 @@ async function flushPendingRepliesViaAgentApi(account) {
|
|
|
808
854
|
}
|
|
809
855
|
}
|
|
810
856
|
|
|
811
|
-
async function sendThinkingReply({ wsClient, frame, streamId }) {
|
|
857
|
+
async function sendThinkingReply({ wsClient, frame, streamId, text = THINKING_MESSAGE }) {
|
|
812
858
|
try {
|
|
813
859
|
await sendWsReply({
|
|
814
860
|
wsClient,
|
|
815
861
|
frame,
|
|
816
862
|
streamId,
|
|
817
|
-
text
|
|
863
|
+
text,
|
|
818
864
|
finish: false,
|
|
819
865
|
});
|
|
820
866
|
} catch (error) {
|
|
@@ -872,13 +918,15 @@ function buildInboundContext({
|
|
|
872
918
|
SenderName: senderId,
|
|
873
919
|
GroupId: isGroupChat ? chatId : undefined,
|
|
874
920
|
Timestamp: Date.now(),
|
|
921
|
+
SourceTimestamp: normalizeWecomCreateTimeMs(body?.create_time) || undefined,
|
|
875
922
|
Provider: CHANNEL_ID,
|
|
876
923
|
Surface: CHANNEL_ID,
|
|
877
924
|
OriginatingChannel: CHANNEL_ID,
|
|
878
925
|
OriginatingTo: isGroupChat ? `${CHANNEL_ID}:group:${chatId}` : `${CHANNEL_ID}:${senderId}`,
|
|
879
926
|
CommandAuthorized: true,
|
|
880
|
-
|
|
881
|
-
|
|
927
|
+
// frame is null for callback-inbound path; use body.msgid as fallback
|
|
928
|
+
ReqId: frame?.headers?.req_id ?? body?.msgid ?? "",
|
|
929
|
+
WeComFrame: frame ?? null,
|
|
882
930
|
};
|
|
883
931
|
|
|
884
932
|
if (mediaList.length > 0) {
|
|
@@ -897,7 +945,7 @@ function buildInboundContext({
|
|
|
897
945
|
return { ctxPayload: core.reply.finalizeInboundContext(context), storePath };
|
|
898
946
|
}
|
|
899
947
|
|
|
900
|
-
async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
948
|
+
async function processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }) {
|
|
901
949
|
const core = resolveChannelCore(runtime);
|
|
902
950
|
const body = frame?.body ?? {};
|
|
903
951
|
const senderId = body?.from?.userid;
|
|
@@ -926,12 +974,46 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
926
974
|
return;
|
|
927
975
|
}
|
|
928
976
|
|
|
977
|
+
const perfStartedAt = Date.now();
|
|
978
|
+
const sourceTiming = getWecomSourceTiming(body?.create_time, perfStartedAt);
|
|
979
|
+
const perfState = {
|
|
980
|
+
firstReasoningReceivedAt: 0,
|
|
981
|
+
firstReasoningForwardedAt: 0,
|
|
982
|
+
firstVisibleReceivedAt: 0,
|
|
983
|
+
firstVisibleForwardedAt: 0,
|
|
984
|
+
thinkingSentAt: 0,
|
|
985
|
+
finalReplySentAt: 0,
|
|
986
|
+
};
|
|
987
|
+
const logPerf = (stage, extra = {}) => {
|
|
988
|
+
logger.info(`[WSPERF:${account.accountId}] ${stage}`, {
|
|
989
|
+
messageId,
|
|
990
|
+
senderId,
|
|
991
|
+
chatId,
|
|
992
|
+
isGroupChat,
|
|
993
|
+
elapsedMs: Date.now() - perfStartedAt,
|
|
994
|
+
...extra,
|
|
995
|
+
});
|
|
996
|
+
};
|
|
997
|
+
|
|
929
998
|
recordInboundMessage({ accountId: account.accountId, chatId });
|
|
930
999
|
|
|
931
1000
|
const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
|
|
932
1001
|
const originalText = textParts.join("\n").trim();
|
|
933
1002
|
let text = originalText;
|
|
934
1003
|
|
|
1004
|
+
logger.info(`[WS:${account.accountId}] ← inbound`, {
|
|
1005
|
+
senderId,
|
|
1006
|
+
chatId,
|
|
1007
|
+
isGroupChat,
|
|
1008
|
+
messageId,
|
|
1009
|
+
...sourceTiming,
|
|
1010
|
+
textLength: originalText.length,
|
|
1011
|
+
imageCount: imageUrls.length,
|
|
1012
|
+
fileCount: fileUrls.length,
|
|
1013
|
+
preview: originalText.slice(0, 80) || (imageUrls.length ? "[image]" : fileUrls.length ? "[file]" : ""),
|
|
1014
|
+
});
|
|
1015
|
+
logPerf("inbound", sourceTiming);
|
|
1016
|
+
|
|
935
1017
|
if (!text && quoteContent) {
|
|
936
1018
|
text = quoteContent;
|
|
937
1019
|
}
|
|
@@ -1030,9 +1112,24 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1030
1112
|
}),
|
|
1031
1113
|
]);
|
|
1032
1114
|
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
1115
|
+
logPerf("media_ready", {
|
|
1116
|
+
imageCount: imageMediaList.length,
|
|
1117
|
+
fileCount: fileMediaList.length,
|
|
1118
|
+
});
|
|
1033
1119
|
|
|
1034
|
-
const streamId = generateReqId("stream");
|
|
1035
|
-
|
|
1120
|
+
const streamId = reqIdStore?.getSync(chatId) ?? generateReqId("stream");
|
|
1121
|
+
if (reqIdStore) reqIdStore.set(chatId, streamId);
|
|
1122
|
+
const state = {
|
|
1123
|
+
accumulatedText: "",
|
|
1124
|
+
reasoningText: "",
|
|
1125
|
+
streamId,
|
|
1126
|
+
replyMediaUrls: [],
|
|
1127
|
+
pendingMediaUrls: [],
|
|
1128
|
+
hasMedia: false,
|
|
1129
|
+
hasMediaFailed: false,
|
|
1130
|
+
mediaErrorSummary: "",
|
|
1131
|
+
deliverCalled: false,
|
|
1132
|
+
};
|
|
1036
1133
|
setMessageState(messageId, state);
|
|
1037
1134
|
|
|
1038
1135
|
// Throttle reasoning and visible text stream updates to avoid exceeding
|
|
@@ -1042,26 +1139,95 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1042
1139
|
let pendingReasoningTimer = null;
|
|
1043
1140
|
let lastVisibleSendAt = 0;
|
|
1044
1141
|
let pendingVisibleTimer = null;
|
|
1142
|
+
let lastStreamSentAt = 0;
|
|
1143
|
+
let lastNonEmptyStreamText = "";
|
|
1144
|
+
let lastForwardedVisibleText = "";
|
|
1145
|
+
let keepaliveTimer = null;
|
|
1146
|
+
let waitingModelTimer = null;
|
|
1147
|
+
let waitingModelSeconds = 0;
|
|
1148
|
+
let waitingModelActive = false;
|
|
1045
1149
|
|
|
1046
1150
|
const canSendIntermediate = () => streamMessagesSent < MAX_INTERMEDIATE_STREAM_MESSAGES;
|
|
1047
1151
|
|
|
1152
|
+
const stopWaitingModelUpdates = () => {
|
|
1153
|
+
waitingModelActive = false;
|
|
1154
|
+
if (waitingModelTimer) {
|
|
1155
|
+
clearTimeout(waitingModelTimer);
|
|
1156
|
+
waitingModelTimer = null;
|
|
1157
|
+
}
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
const sendWaitingModelUpdate = async (seconds) => {
|
|
1161
|
+
const waitingText = buildWaitingModelContent(seconds);
|
|
1162
|
+
lastStreamSentAt = Date.now();
|
|
1163
|
+
lastNonEmptyStreamText = waitingText;
|
|
1164
|
+
try {
|
|
1165
|
+
streamMessagesSent++;
|
|
1166
|
+
await sendWsReply({
|
|
1167
|
+
wsClient,
|
|
1168
|
+
frame,
|
|
1169
|
+
streamId: state.streamId,
|
|
1170
|
+
text: waitingText,
|
|
1171
|
+
finish: false,
|
|
1172
|
+
accountId: account.accountId,
|
|
1173
|
+
});
|
|
1174
|
+
logPerf("waiting_model_forwarded", {
|
|
1175
|
+
seconds,
|
|
1176
|
+
streamMessagesSent,
|
|
1177
|
+
chars: waitingText.length,
|
|
1178
|
+
});
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
logger.warn(`[WS] Waiting-model stream send failed (non-fatal): ${error.message}`);
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const scheduleWaitingModelUpdate = () => {
|
|
1185
|
+
if (!waitingModelActive) {
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (waitingModelTimer) {
|
|
1189
|
+
clearTimeout(waitingModelTimer);
|
|
1190
|
+
}
|
|
1191
|
+
waitingModelTimer = setTimeout(async () => {
|
|
1192
|
+
waitingModelTimer = null;
|
|
1193
|
+
if (!waitingModelActive || !canSendIntermediate()) {
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
waitingModelSeconds += 1;
|
|
1197
|
+
await sendWaitingModelUpdate(waitingModelSeconds);
|
|
1198
|
+
scheduleWaitingModelUpdate();
|
|
1199
|
+
}, WAITING_MODEL_TICK_MS);
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1048
1202
|
const sendReasoningUpdate = async () => {
|
|
1049
1203
|
if (!canSendIntermediate()) return;
|
|
1050
1204
|
lastReasoningSendAt = Date.now();
|
|
1205
|
+
lastStreamSentAt = lastReasoningSendAt;
|
|
1206
|
+
const streamText = buildWsStreamContent({
|
|
1207
|
+
reasoningText: state.reasoningText,
|
|
1208
|
+
visibleText: state.accumulatedText,
|
|
1209
|
+
finish: false,
|
|
1210
|
+
});
|
|
1211
|
+
if (streamText) {
|
|
1212
|
+
lastNonEmptyStreamText = streamText;
|
|
1213
|
+
}
|
|
1051
1214
|
try {
|
|
1052
1215
|
streamMessagesSent++;
|
|
1053
1216
|
await sendWsReply({
|
|
1054
1217
|
wsClient,
|
|
1055
1218
|
frame,
|
|
1056
1219
|
streamId: state.streamId,
|
|
1057
|
-
text:
|
|
1058
|
-
reasoningText: state.reasoningText,
|
|
1059
|
-
visibleText: state.accumulatedText,
|
|
1060
|
-
finish: false,
|
|
1061
|
-
}),
|
|
1220
|
+
text: streamText,
|
|
1062
1221
|
finish: false,
|
|
1063
1222
|
accountId: account.accountId,
|
|
1064
1223
|
});
|
|
1224
|
+
if (!perfState.firstReasoningForwardedAt) {
|
|
1225
|
+
perfState.firstReasoningForwardedAt = Date.now();
|
|
1226
|
+
logPerf("first_reasoning_forwarded", {
|
|
1227
|
+
streamMessagesSent,
|
|
1228
|
+
chars: streamText.length,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1065
1231
|
} catch (error) {
|
|
1066
1232
|
logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
|
|
1067
1233
|
}
|
|
@@ -1070,25 +1236,161 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1070
1236
|
const sendVisibleUpdate = async () => {
|
|
1071
1237
|
if (!canSendIntermediate()) return;
|
|
1072
1238
|
lastVisibleSendAt = Date.now();
|
|
1239
|
+
lastStreamSentAt = lastVisibleSendAt;
|
|
1240
|
+
const visibleText = state.accumulatedText;
|
|
1241
|
+
const streamText = buildWsStreamContent({
|
|
1242
|
+
reasoningText: state.reasoningText,
|
|
1243
|
+
visibleText,
|
|
1244
|
+
finish: false,
|
|
1245
|
+
});
|
|
1246
|
+
if (streamText) {
|
|
1247
|
+
lastNonEmptyStreamText = streamText;
|
|
1248
|
+
}
|
|
1249
|
+
lastForwardedVisibleText = visibleText;
|
|
1073
1250
|
try {
|
|
1074
1251
|
streamMessagesSent++;
|
|
1075
1252
|
await sendWsReply({
|
|
1076
1253
|
wsClient,
|
|
1077
1254
|
frame,
|
|
1078
1255
|
streamId: state.streamId,
|
|
1079
|
-
text:
|
|
1080
|
-
reasoningText: state.reasoningText,
|
|
1081
|
-
visibleText: state.accumulatedText,
|
|
1082
|
-
finish: false,
|
|
1083
|
-
}),
|
|
1256
|
+
text: streamText,
|
|
1084
1257
|
finish: false,
|
|
1085
1258
|
accountId: account.accountId,
|
|
1086
1259
|
});
|
|
1260
|
+
if (!perfState.firstVisibleForwardedAt) {
|
|
1261
|
+
perfState.firstVisibleForwardedAt = Date.now();
|
|
1262
|
+
logPerf("first_visible_forwarded", {
|
|
1263
|
+
streamMessagesSent,
|
|
1264
|
+
chars: streamText.length,
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1087
1267
|
} catch (error) {
|
|
1088
1268
|
logger.warn(`[WS] Visible stream send failed (non-fatal): ${error.message}`);
|
|
1089
1269
|
}
|
|
1090
1270
|
};
|
|
1091
1271
|
|
|
1272
|
+
const flushPendingStreamUpdates = async () => {
|
|
1273
|
+
const hadPendingReasoning = Boolean(pendingReasoningTimer);
|
|
1274
|
+
const hadPendingVisible = Boolean(pendingVisibleTimer);
|
|
1275
|
+
|
|
1276
|
+
if (pendingReasoningTimer) {
|
|
1277
|
+
clearTimeout(pendingReasoningTimer);
|
|
1278
|
+
pendingReasoningTimer = null;
|
|
1279
|
+
}
|
|
1280
|
+
if (pendingVisibleTimer) {
|
|
1281
|
+
clearTimeout(pendingVisibleTimer);
|
|
1282
|
+
pendingVisibleTimer = null;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
if (hadPendingReasoning) {
|
|
1286
|
+
const visibleText = hadPendingVisible ? state.accumulatedText : lastForwardedVisibleText;
|
|
1287
|
+
const streamText = buildWsStreamContent({
|
|
1288
|
+
reasoningText: state.reasoningText,
|
|
1289
|
+
visibleText,
|
|
1290
|
+
finish: false,
|
|
1291
|
+
});
|
|
1292
|
+
if (!streamText || streamText === lastNonEmptyStreamText) {
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
lastReasoningSendAt = Date.now();
|
|
1296
|
+
lastStreamSentAt = lastReasoningSendAt;
|
|
1297
|
+
lastNonEmptyStreamText = streamText;
|
|
1298
|
+
if (hadPendingVisible) {
|
|
1299
|
+
lastForwardedVisibleText = visibleText;
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
streamMessagesSent++;
|
|
1303
|
+
await sendWsReply({
|
|
1304
|
+
wsClient,
|
|
1305
|
+
frame,
|
|
1306
|
+
streamId: state.streamId,
|
|
1307
|
+
text: streamText,
|
|
1308
|
+
finish: false,
|
|
1309
|
+
accountId: account.accountId,
|
|
1310
|
+
});
|
|
1311
|
+
if (!perfState.firstReasoningForwardedAt) {
|
|
1312
|
+
perfState.firstReasoningForwardedAt = Date.now();
|
|
1313
|
+
logPerf("first_reasoning_forwarded", {
|
|
1314
|
+
streamMessagesSent,
|
|
1315
|
+
chars: streamText.length,
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
} catch (error) {
|
|
1319
|
+
logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
|
|
1320
|
+
}
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (hadPendingVisible) {
|
|
1324
|
+
await sendVisibleUpdate();
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
const scheduleKeepalive = () => {
|
|
1329
|
+
if (keepaliveTimer) clearTimeout(keepaliveTimer);
|
|
1330
|
+
keepaliveTimer = setTimeout(async () => {
|
|
1331
|
+
keepaliveTimer = null;
|
|
1332
|
+
if (!canSendIntermediate()) return;
|
|
1333
|
+
const idle = Date.now() - lastStreamSentAt;
|
|
1334
|
+
if (idle < STREAM_KEEPALIVE_INTERVAL_MS) {
|
|
1335
|
+
// A real update was sent recently; wait remaining time then send immediately.
|
|
1336
|
+
const remaining = STREAM_KEEPALIVE_INTERVAL_MS - idle;
|
|
1337
|
+
keepaliveTimer = setTimeout(async () => {
|
|
1338
|
+
keepaliveTimer = null;
|
|
1339
|
+
if (!canSendIntermediate()) return;
|
|
1340
|
+
logger.debug(`[WS] Sending stream keepalive after deferred wait (idle ${Math.round((Date.now() - lastStreamSentAt) / 1000)}s)`);
|
|
1341
|
+
lastStreamSentAt = Date.now();
|
|
1342
|
+
const keepaliveText = resolveWsKeepaliveContent({
|
|
1343
|
+
reasoningText: state.reasoningText,
|
|
1344
|
+
visibleText: state.accumulatedText,
|
|
1345
|
+
lastStreamText: lastNonEmptyStreamText,
|
|
1346
|
+
});
|
|
1347
|
+
if (keepaliveText) {
|
|
1348
|
+
lastNonEmptyStreamText = keepaliveText;
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
streamMessagesSent++;
|
|
1352
|
+
await sendWsReply({
|
|
1353
|
+
wsClient,
|
|
1354
|
+
frame,
|
|
1355
|
+
streamId: state.streamId,
|
|
1356
|
+
text: keepaliveText,
|
|
1357
|
+
finish: false,
|
|
1358
|
+
accountId: account.accountId,
|
|
1359
|
+
});
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
logger.warn(`[WS] Keepalive send failed (non-fatal): ${err.message}`);
|
|
1362
|
+
}
|
|
1363
|
+
scheduleKeepalive();
|
|
1364
|
+
}, remaining);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
logger.debug(`[WS] Sending stream keepalive (idle ${Math.round(idle / 1000)}s)`);
|
|
1368
|
+
lastStreamSentAt = Date.now();
|
|
1369
|
+
const keepaliveText = resolveWsKeepaliveContent({
|
|
1370
|
+
reasoningText: state.reasoningText,
|
|
1371
|
+
visibleText: state.accumulatedText,
|
|
1372
|
+
lastStreamText: lastNonEmptyStreamText,
|
|
1373
|
+
});
|
|
1374
|
+
if (keepaliveText) {
|
|
1375
|
+
lastNonEmptyStreamText = keepaliveText;
|
|
1376
|
+
}
|
|
1377
|
+
try {
|
|
1378
|
+
streamMessagesSent++;
|
|
1379
|
+
await sendWsReply({
|
|
1380
|
+
wsClient,
|
|
1381
|
+
frame,
|
|
1382
|
+
streamId: state.streamId,
|
|
1383
|
+
text: keepaliveText,
|
|
1384
|
+
finish: false,
|
|
1385
|
+
accountId: account.accountId,
|
|
1386
|
+
});
|
|
1387
|
+
} catch (error) {
|
|
1388
|
+
logger.warn(`[WS] Stream keepalive send failed (non-fatal): ${error.message}`);
|
|
1389
|
+
}
|
|
1390
|
+
scheduleKeepalive();
|
|
1391
|
+
}, STREAM_KEEPALIVE_INTERVAL_MS);
|
|
1392
|
+
};
|
|
1393
|
+
|
|
1092
1394
|
const cancelPendingTimers = () => {
|
|
1093
1395
|
if (pendingReasoningTimer) {
|
|
1094
1396
|
clearTimeout(pendingReasoningTimer);
|
|
@@ -1098,6 +1400,11 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1098
1400
|
clearTimeout(pendingVisibleTimer);
|
|
1099
1401
|
pendingVisibleTimer = null;
|
|
1100
1402
|
}
|
|
1403
|
+
if (keepaliveTimer) {
|
|
1404
|
+
clearTimeout(keepaliveTimer);
|
|
1405
|
+
keepaliveTimer = null;
|
|
1406
|
+
}
|
|
1407
|
+
stopWaitingModelUpdates();
|
|
1101
1408
|
};
|
|
1102
1409
|
|
|
1103
1410
|
const cleanupState = () => {
|
|
@@ -1106,8 +1413,21 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1106
1413
|
};
|
|
1107
1414
|
|
|
1108
1415
|
if (account.sendThinkingMessage !== false) {
|
|
1109
|
-
|
|
1416
|
+
waitingModelActive = true;
|
|
1417
|
+
waitingModelSeconds = 1;
|
|
1418
|
+
await sendThinkingReply({
|
|
1419
|
+
wsClient,
|
|
1420
|
+
frame,
|
|
1421
|
+
streamId,
|
|
1422
|
+
text: buildWaitingModelContent(waitingModelSeconds),
|
|
1423
|
+
});
|
|
1424
|
+
lastNonEmptyStreamText = buildWaitingModelContent(waitingModelSeconds);
|
|
1425
|
+
perfState.thinkingSentAt = Date.now();
|
|
1426
|
+
logPerf("thinking_sent", { streamId });
|
|
1427
|
+
scheduleWaitingModelUpdate();
|
|
1110
1428
|
}
|
|
1429
|
+
lastStreamSentAt = Date.now();
|
|
1430
|
+
scheduleKeepalive();
|
|
1111
1431
|
|
|
1112
1432
|
const peerKind = isGroupChat ? "group" : "dm";
|
|
1113
1433
|
const peerId = isGroupChat ? chatId : senderId;
|
|
@@ -1155,13 +1475,13 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1155
1475
|
});
|
|
1156
1476
|
ctxPayload.CommandAuthorized = commandAuthorized;
|
|
1157
1477
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1478
|
+
await ensureDefaultSessionReasoningLevel({
|
|
1479
|
+
core,
|
|
1480
|
+
storePath,
|
|
1481
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1482
|
+
ctx: ctxPayload,
|
|
1483
|
+
channelTag: "WS",
|
|
1484
|
+
});
|
|
1165
1485
|
|
|
1166
1486
|
const runDispatch = async () => {
|
|
1167
1487
|
let cleanedUp = false;
|
|
@@ -1174,6 +1494,12 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1174
1494
|
};
|
|
1175
1495
|
|
|
1176
1496
|
try {
|
|
1497
|
+
logPerf("dispatch_start", {
|
|
1498
|
+
routeAgentId: route.agentId,
|
|
1499
|
+
sessionKey: route.sessionKey,
|
|
1500
|
+
mediaCount: mediaList.length,
|
|
1501
|
+
...sourceTiming,
|
|
1502
|
+
});
|
|
1177
1503
|
await streamContext.run(
|
|
1178
1504
|
{ streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
|
|
1179
1505
|
async () => {
|
|
@@ -1187,7 +1513,14 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1187
1513
|
if (!nextReasoning) {
|
|
1188
1514
|
return;
|
|
1189
1515
|
}
|
|
1516
|
+
stopWaitingModelUpdates();
|
|
1190
1517
|
state.reasoningText = nextReasoning;
|
|
1518
|
+
if (!perfState.firstReasoningReceivedAt) {
|
|
1519
|
+
perfState.firstReasoningReceivedAt = Date.now();
|
|
1520
|
+
logPerf("first_reasoning_received", {
|
|
1521
|
+
chars: nextReasoning.length,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1191
1524
|
|
|
1192
1525
|
// Throttle: skip if sent recently, schedule a trailing update instead.
|
|
1193
1526
|
const elapsed = Date.now() - lastReasoningSendAt;
|
|
@@ -1205,30 +1538,62 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1205
1538
|
},
|
|
1206
1539
|
dispatcherOptions: {
|
|
1207
1540
|
deliver: async (payload, info) => {
|
|
1541
|
+
state.deliverCalled = true;
|
|
1208
1542
|
const normalized = normalizeReplyPayload(payload);
|
|
1209
1543
|
const chunk = normalized.text;
|
|
1210
1544
|
const mediaUrls = normalized.mediaUrls;
|
|
1545
|
+
|
|
1546
|
+
if (chunk) {
|
|
1547
|
+
stopWaitingModelUpdates();
|
|
1548
|
+
state.accumulatedText += chunk;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1211
1551
|
for (const mediaUrl of mediaUrls) {
|
|
1212
1552
|
if (!state.replyMediaUrls.includes(mediaUrl)) {
|
|
1213
1553
|
state.replyMediaUrls.push(mediaUrl);
|
|
1554
|
+
state.pendingMediaUrls.push(mediaUrl);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
if (state.pendingMediaUrls.length > 0) {
|
|
1559
|
+
try {
|
|
1560
|
+
await sendMediaBatch({
|
|
1561
|
+
wsClient, frame, state, account, runtime, config,
|
|
1562
|
+
agentId: route.agentId,
|
|
1563
|
+
});
|
|
1564
|
+
} catch (mediaErr) {
|
|
1565
|
+
state.hasMediaFailed = true;
|
|
1566
|
+
const errMsg = String(mediaErr);
|
|
1567
|
+
const summary = `文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
|
|
1568
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1569
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1570
|
+
: summary;
|
|
1571
|
+
logger.error(`[WS] sendMediaBatch threw: ${errMsg}`);
|
|
1214
1572
|
}
|
|
1215
1573
|
}
|
|
1216
1574
|
|
|
1217
|
-
|
|
1575
|
+
if (!perfState.firstVisibleReceivedAt && chunk?.trim()) {
|
|
1576
|
+
perfState.firstVisibleReceivedAt = Date.now();
|
|
1577
|
+
logPerf("first_visible_received", {
|
|
1578
|
+
chars: chunk.length,
|
|
1579
|
+
});
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1218
1582
|
if (info.kind !== "final") {
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1583
|
+
const hasText = stripThinkTags(state.accumulatedText);
|
|
1584
|
+
if (hasText) {
|
|
1585
|
+
const elapsed = Date.now() - lastVisibleSendAt;
|
|
1586
|
+
if (elapsed < VISIBLE_STREAM_THROTTLE_MS) {
|
|
1587
|
+
if (!pendingVisibleTimer) {
|
|
1588
|
+
pendingVisibleTimer = setTimeout(async () => {
|
|
1589
|
+
pendingVisibleTimer = null;
|
|
1590
|
+
await sendVisibleUpdate();
|
|
1591
|
+
}, VISIBLE_STREAM_THROTTLE_MS - elapsed);
|
|
1592
|
+
}
|
|
1593
|
+
return;
|
|
1228
1594
|
}
|
|
1229
|
-
|
|
1595
|
+
await sendVisibleUpdate();
|
|
1230
1596
|
}
|
|
1231
|
-
await sendVisibleUpdate();
|
|
1232
1597
|
}
|
|
1233
1598
|
},
|
|
1234
1599
|
onError: (error, info) => {
|
|
@@ -1239,112 +1604,67 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1239
1604
|
},
|
|
1240
1605
|
);
|
|
1241
1606
|
|
|
1607
|
+
// Flush the latest throttled snapshot before finish=true so reasoning
|
|
1608
|
+
// and visible deltas are not collapsed away by the final frame.
|
|
1609
|
+
await flushPendingStreamUpdates();
|
|
1610
|
+
|
|
1242
1611
|
// Cancel pending throttled timers before the final reply to prevent
|
|
1243
1612
|
// non-final updates from being sent after finish=true.
|
|
1244
1613
|
cancelPendingTimers();
|
|
1245
1614
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
mirrorImagesToAgent: Boolean(account?.agentCredentials),
|
|
1252
|
-
});
|
|
1253
|
-
const msgItem = preparedReplyMedia.msgItems;
|
|
1254
|
-
const deferredMediaDeliveredViaAgent = Boolean(account?.agentCredentials);
|
|
1255
|
-
const finalReplyText = buildWsStreamContent({
|
|
1256
|
-
reasoningText: state.reasoningText,
|
|
1257
|
-
visibleText: state.accumulatedText,
|
|
1258
|
-
finish: true,
|
|
1259
|
-
});
|
|
1260
|
-
const passiveMediaNotice = buildPassiveMediaNoticeBlock(preparedReplyMedia.agentMedia, {
|
|
1261
|
-
deliveredViaAgent: deferredMediaDeliveredViaAgent,
|
|
1262
|
-
});
|
|
1263
|
-
const finalWsText = [finalReplyText, passiveMediaNotice].filter(Boolean).join("\n\n");
|
|
1264
|
-
|
|
1265
|
-
if (preparedReplyMedia.agentMedia.length > 0 && !account?.agentCredentials) {
|
|
1266
|
-
logger.warn("[WS] Agent API is not configured; passive non-image media delivery was skipped");
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
// If dispatch returned no content at all (e.g. upstream empty_stream),
|
|
1270
|
-
// send a fallback so the user isn't left waiting in silence.
|
|
1271
|
-
const effectiveFinalText = finalWsText || (msgItem.length === 0 ? "模型暂时无法响应,请稍后重试。" : "");
|
|
1272
|
-
if (effectiveFinalText || msgItem.length > 0) {
|
|
1273
|
-
logger.info("[WS] Sending passive final reply", {
|
|
1615
|
+
try {
|
|
1616
|
+
await finishThinkingStream({
|
|
1617
|
+
wsClient,
|
|
1618
|
+
frame,
|
|
1619
|
+
state,
|
|
1274
1620
|
accountId: account.accountId,
|
|
1275
|
-
agentId: route.agentId,
|
|
1276
|
-
streamId: state.streamId,
|
|
1277
|
-
textLength: effectiveFinalText.length,
|
|
1278
|
-
imageItemCount: msgItem.length,
|
|
1279
|
-
deferredAgentMediaCount: preparedReplyMedia.agentMedia.length,
|
|
1280
|
-
mirroredAgentImageCount: preparedReplyMedia.mirroredAgentMedia.length,
|
|
1281
|
-
emptyStreamFallback: !finalWsText,
|
|
1282
1621
|
});
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
accountId: account.accountId,
|
|
1297
|
-
chatId,
|
|
1298
|
-
senderId,
|
|
1299
|
-
});
|
|
1622
|
+
perfState.finalReplySentAt = Date.now();
|
|
1623
|
+
logPerf("final_reply_sent", {
|
|
1624
|
+
textLength: state.accumulatedText.length,
|
|
1625
|
+
hasMedia: state.hasMedia,
|
|
1626
|
+
hasMediaFailed: state.hasMediaFailed,
|
|
1627
|
+
});
|
|
1628
|
+
} catch (sendError) {
|
|
1629
|
+
logger.warn(`[WS] Final reply send failed, enqueuing for retry: ${sendError.message}`, {
|
|
1630
|
+
accountId: account.accountId,
|
|
1631
|
+
chatId,
|
|
1632
|
+
senderId,
|
|
1633
|
+
});
|
|
1634
|
+
if (state.accumulatedText) {
|
|
1300
1635
|
enqueuePendingReply(account.accountId, {
|
|
1301
|
-
text:
|
|
1636
|
+
text: state.accumulatedText,
|
|
1302
1637
|
senderId,
|
|
1303
1638
|
chatId,
|
|
1304
1639
|
isGroupChat,
|
|
1305
1640
|
});
|
|
1306
1641
|
}
|
|
1307
1642
|
}
|
|
1308
|
-
|
|
1309
|
-
if (account?.agentCredentials && preparedReplyMedia.mirroredAgentMedia.length > 0) {
|
|
1310
|
-
await deliverPassiveAgentMedia({
|
|
1311
|
-
account,
|
|
1312
|
-
senderId,
|
|
1313
|
-
chatId,
|
|
1314
|
-
isGroupChat,
|
|
1315
|
-
text: state.accumulatedText,
|
|
1316
|
-
includeText: false,
|
|
1317
|
-
includeNotice: false,
|
|
1318
|
-
mediaEntries: preparedReplyMedia.mirroredAgentMedia,
|
|
1319
|
-
});
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
if (account?.agentCredentials && preparedReplyMedia.agentMedia.length > 0) {
|
|
1323
|
-
await deliverPassiveAgentMedia({
|
|
1324
|
-
account,
|
|
1325
|
-
senderId,
|
|
1326
|
-
chatId,
|
|
1327
|
-
isGroupChat,
|
|
1328
|
-
text: finalWsText,
|
|
1329
|
-
includeText: false,
|
|
1330
|
-
includeNotice: false,
|
|
1331
|
-
mediaEntries: preparedReplyMedia.agentMedia,
|
|
1332
|
-
});
|
|
1333
|
-
}
|
|
1334
1643
|
safeCleanup();
|
|
1644
|
+
logPerf("dispatch_complete", {
|
|
1645
|
+
hadReasoning: Boolean(perfState.firstReasoningReceivedAt),
|
|
1646
|
+
hadVisibleText: Boolean(perfState.firstVisibleReceivedAt),
|
|
1647
|
+
totalOutputChars: state.accumulatedText.length,
|
|
1648
|
+
replyMediaCount: state.replyMediaUrls.length,
|
|
1649
|
+
});
|
|
1335
1650
|
} catch (error) {
|
|
1336
1651
|
logger.error(`[WS] Failed to dispatch reply: ${error.message}`);
|
|
1652
|
+
logPerf("dispatch_failed", {
|
|
1653
|
+
error: error.message,
|
|
1654
|
+
});
|
|
1337
1655
|
try {
|
|
1338
|
-
|
|
1656
|
+
// Ensure the user sees an error message, not "处理完成。"
|
|
1657
|
+
if (!stripThinkTags(state.accumulatedText) && !state.hasMedia) {
|
|
1658
|
+
state.accumulatedText = `⚠️ 处理出错:${error.message}`;
|
|
1659
|
+
}
|
|
1660
|
+
await finishThinkingStream({
|
|
1339
1661
|
wsClient,
|
|
1340
1662
|
frame,
|
|
1341
|
-
|
|
1342
|
-
text: "处理消息时出错,请稍后再试。",
|
|
1343
|
-
finish: true,
|
|
1663
|
+
state,
|
|
1344
1664
|
accountId: account.accountId,
|
|
1345
1665
|
});
|
|
1346
|
-
} catch (
|
|
1347
|
-
|
|
1666
|
+
} catch (finishErr) {
|
|
1667
|
+
logger.error(`[WS] Failed to finish thinking stream after dispatch error: ${finishErr.message}`);
|
|
1348
1668
|
if (state.accumulatedText) {
|
|
1349
1669
|
enqueuePendingReply(account.accountId, {
|
|
1350
1670
|
text: state.accumulatedText,
|
|
@@ -1359,8 +1679,24 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1359
1679
|
};
|
|
1360
1680
|
|
|
1361
1681
|
const lockKey = `${account.accountId}:${peerId}`;
|
|
1682
|
+
const queuedAt = Date.now();
|
|
1362
1683
|
const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
|
|
1363
|
-
const current = previous.then(
|
|
1684
|
+
const current = previous.then(
|
|
1685
|
+
async () => {
|
|
1686
|
+
const queueWaitMs = Date.now() - queuedAt;
|
|
1687
|
+
if (queueWaitMs >= 50) {
|
|
1688
|
+
logPerf("dispatch_lock_acquired", { queueWaitMs });
|
|
1689
|
+
}
|
|
1690
|
+
return await runDispatch();
|
|
1691
|
+
},
|
|
1692
|
+
async () => {
|
|
1693
|
+
const queueWaitMs = Date.now() - queuedAt;
|
|
1694
|
+
if (queueWaitMs >= 50) {
|
|
1695
|
+
logPerf("dispatch_lock_acquired", { queueWaitMs, previousFailed: true });
|
|
1696
|
+
}
|
|
1697
|
+
return await runDispatch();
|
|
1698
|
+
},
|
|
1699
|
+
);
|
|
1364
1700
|
dispatchLocks.set(lockKey, current);
|
|
1365
1701
|
current.finally(() => {
|
|
1366
1702
|
if (dispatchLocks.get(lockKey) === current) {
|
|
@@ -1394,10 +1730,19 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1394
1730
|
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
|
|
1395
1731
|
});
|
|
1396
1732
|
|
|
1733
|
+
const reqIdStore = createPersistentReqIdStore(account.accountId);
|
|
1734
|
+
await reqIdStore.warmup();
|
|
1735
|
+
|
|
1397
1736
|
return new Promise((resolve, reject) => {
|
|
1398
1737
|
let settled = false;
|
|
1399
1738
|
|
|
1400
1739
|
const cleanup = async () => {
|
|
1740
|
+
try {
|
|
1741
|
+
await reqIdStore.flush();
|
|
1742
|
+
} catch (flushErr) {
|
|
1743
|
+
logger.warn(`[WS:${account.accountId}] Failed to flush reqId store on cleanup: ${flushErr.message}`);
|
|
1744
|
+
}
|
|
1745
|
+
reqIdStore.destroy();
|
|
1401
1746
|
await cleanupWsAccount(account.accountId);
|
|
1402
1747
|
};
|
|
1403
1748
|
|
|
@@ -1439,6 +1784,8 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1439
1784
|
clearAccountDisplaced(account.accountId);
|
|
1440
1785
|
setWsClient(account.accountId, wsClient);
|
|
1441
1786
|
|
|
1787
|
+
void fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
|
|
1788
|
+
|
|
1442
1789
|
// Drain pending replies that failed due to prior WS disconnection.
|
|
1443
1790
|
if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
|
|
1444
1791
|
void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
|
|
@@ -1465,7 +1812,7 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1465
1812
|
wsClient.on("message", async (frame) => {
|
|
1466
1813
|
try {
|
|
1467
1814
|
await withTimeout(
|
|
1468
|
-
processWsMessage({ frame, account, config, runtime, wsClient }),
|
|
1815
|
+
processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }),
|
|
1469
1816
|
MESSAGE_PROCESS_TIMEOUT_MS,
|
|
1470
1817
|
`Message processing timed out (msgId=${frame?.body?.msgid ?? "unknown"})`,
|
|
1471
1818
|
);
|
|
@@ -1534,11 +1881,25 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1534
1881
|
}
|
|
1535
1882
|
|
|
1536
1883
|
export const wsMonitorTesting = {
|
|
1884
|
+
buildWsStreamContent,
|
|
1885
|
+
ensureDefaultSessionReasoningLevel,
|
|
1886
|
+
resolveWsKeepaliveContent,
|
|
1537
1887
|
processWsMessage,
|
|
1538
1888
|
parseMessageContent,
|
|
1539
1889
|
splitReplyMediaFromText,
|
|
1540
1890
|
buildBodyForAgent,
|
|
1891
|
+
normalizeReplyMediaUrlForLoad,
|
|
1541
1892
|
flushPendingRepliesViaAgentApi,
|
|
1893
|
+
stripThinkTags,
|
|
1894
|
+
finishThinkingStream,
|
|
1542
1895
|
};
|
|
1543
1896
|
|
|
1544
|
-
export { buildReplyMediaGuidance };
|
|
1897
|
+
export { buildReplyMediaGuidance, ensureDefaultSessionReasoningLevel, normalizeReplyMediaUrlForLoad };
|
|
1898
|
+
|
|
1899
|
+
// Shared internals used by callback-inbound.js
|
|
1900
|
+
export {
|
|
1901
|
+
buildInboundContext,
|
|
1902
|
+
resolveChannelCore,
|
|
1903
|
+
normalizeReplyPayload,
|
|
1904
|
+
resolveReplyMediaLocalRoots,
|
|
1905
|
+
};
|