@sunnoy/wecom 2.2.1 → 2.4.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 +37 -1
- package/index.js +11 -2
- package/openclaw.plugin.json +137 -1
- package/package.json +2 -3
- package/skills/wecom-msg/SKILL.md +115 -0
- package/skills/wecom-msg/references/api-get-messages.md +74 -0
- package/skills/wecom-msg/references/api-get-msg-chat-list.md +64 -0
- package/skills/wecom-msg/references/api-get-msg-media.md +44 -0
- package/skills/wecom-msg/references/api-send-message.md +49 -0
- package/skills/wecom-send-media/SKILL.md +68 -0
- package/wecom/accounts.js +2 -0
- package/wecom/callback-inbound.js +10 -6
- package/wecom/callback-media.js +17 -9
- package/wecom/channel-plugin.js +79 -8
- package/wecom/constants.js +14 -7
- package/wecom/image-studio-tool.js +764 -0
- package/wecom/mcp-tool.js +18 -2
- package/wecom/parent-resolver.js +26 -0
- package/wecom/plugin-config.js +484 -0
- package/wecom/welcome-messages-file.js +155 -0
- package/wecom/workspace-template.js +57 -14
- package/wecom/ws-monitor.js +76 -11
- package/wecom/mcp-config.js +0 -146
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { logger } from "../logger.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_STATE_DIRNAME = ".openclaw";
|
|
7
|
+
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
|
|
8
|
+
|
|
9
|
+
/** @type {Map<string, { mtimeMs: number; size: number; list: string[] }>} */
|
|
10
|
+
const welcomeMessagesFileCache = new Map();
|
|
11
|
+
|
|
12
|
+
function resolveUserPath(value) {
|
|
13
|
+
const trimmed = String(value ?? "").trim();
|
|
14
|
+
if (!trimmed) {
|
|
15
|
+
return "";
|
|
16
|
+
}
|
|
17
|
+
if (trimmed.startsWith("~")) {
|
|
18
|
+
const homeDir = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || os.homedir();
|
|
19
|
+
return path.resolve(homeDir, trimmed.slice(1).replace(/^\/+/, ""));
|
|
20
|
+
}
|
|
21
|
+
return path.resolve(trimmed);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveOpenclawStateDir() {
|
|
25
|
+
const override = process.env.OPENCLAW_STATE_DIR?.trim() || process.env.CLAWDBOT_STATE_DIR?.trim();
|
|
26
|
+
if (override) {
|
|
27
|
+
return resolveUserPath(override);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const homeDir = process.env.OPENCLAW_HOME?.trim() || process.env.HOME || os.homedir();
|
|
31
|
+
const preferred = path.join(homeDir, DEFAULT_STATE_DIRNAME);
|
|
32
|
+
if (existsSync(preferred)) {
|
|
33
|
+
return preferred;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const legacyName of LEGACY_STATE_DIRNAMES) {
|
|
37
|
+
const candidate = path.join(homeDir, legacyName);
|
|
38
|
+
if (existsSync(candidate)) {
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return preferred;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveWelcomeMessagesFilePath(config) {
|
|
47
|
+
const raw = String(config?.welcomeMessagesFile ?? "").trim();
|
|
48
|
+
if (!raw) {
|
|
49
|
+
return "";
|
|
50
|
+
}
|
|
51
|
+
if (raw.startsWith("~")) {
|
|
52
|
+
return resolveUserPath(raw);
|
|
53
|
+
}
|
|
54
|
+
if (path.isAbsolute(raw)) {
|
|
55
|
+
return path.normalize(raw);
|
|
56
|
+
}
|
|
57
|
+
return path.join(resolveOpenclawStateDir(), raw);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeWelcomeEntry(item) {
|
|
61
|
+
if (typeof item === "string") {
|
|
62
|
+
const t = item.trim();
|
|
63
|
+
return t || null;
|
|
64
|
+
}
|
|
65
|
+
if (Array.isArray(item)) {
|
|
66
|
+
if (!item.every((line) => line === null || ["string", "number", "boolean"].includes(typeof line))) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const joined = item.map((line) => String(line ?? "")).join("\n");
|
|
70
|
+
const t = joined.trim();
|
|
71
|
+
return t || null;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseWelcomeMessagesJson(text) {
|
|
77
|
+
let data;
|
|
78
|
+
try {
|
|
79
|
+
data = JSON.parse(text);
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let list = data;
|
|
85
|
+
if (!Array.isArray(data) && data && typeof data === "object" && Array.isArray(data.messages)) {
|
|
86
|
+
list = data.messages;
|
|
87
|
+
}
|
|
88
|
+
if (!Array.isArray(list)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const messages = [];
|
|
93
|
+
for (const item of list) {
|
|
94
|
+
const normalized = normalizeWelcomeEntry(item);
|
|
95
|
+
if (normalized) {
|
|
96
|
+
messages.push(normalized);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return messages.length > 0 ? messages : null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Load welcome message candidates from welcomeMessagesFile.
|
|
104
|
+
* Accepts: JSON array of strings; array of string arrays (lines joined with \\n); or { "messages": same }.
|
|
105
|
+
* Uses mtime cache so file edits apply without restarting OpenClaw or reloading channel config.
|
|
106
|
+
* @param {Record<string, unknown> | undefined} config
|
|
107
|
+
* @returns {string[] | null}
|
|
108
|
+
*/
|
|
109
|
+
export function loadWelcomeMessagesFromFile(config) {
|
|
110
|
+
const filePath = resolveWelcomeMessagesFilePath(config);
|
|
111
|
+
if (!filePath) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let st;
|
|
116
|
+
try {
|
|
117
|
+
st = statSync(filePath);
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
if (!st.isFile()) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const mtimeMs = st.mtimeMs;
|
|
126
|
+
const size = st.size;
|
|
127
|
+
const cached = welcomeMessagesFileCache.get(filePath);
|
|
128
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
129
|
+
return cached.list;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let text;
|
|
133
|
+
try {
|
|
134
|
+
text = readFileSync(filePath, "utf8");
|
|
135
|
+
} catch (err) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
137
|
+
logger.warn(`[wecom] welcomeMessagesFile read failed (${filePath}): ${message}`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const list = parseWelcomeMessagesJson(text);
|
|
142
|
+
if (!list) {
|
|
143
|
+
logger.warn(
|
|
144
|
+
`[wecom] welcomeMessagesFile invalid JSON (expect a non-empty array or { "messages": [...] }): ${filePath}`,
|
|
145
|
+
);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
welcomeMessagesFileCache.set(filePath, { mtimeMs, size, list });
|
|
150
|
+
return list;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function clearWelcomeMessagesFileCacheForTesting() {
|
|
154
|
+
welcomeMessagesFileCache.clear();
|
|
155
|
+
}
|
|
@@ -220,7 +220,7 @@ export function seedAgentWorkspace(agentId, config, overrideTemplateDir) {
|
|
|
220
220
|
}
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
223
|
+
export function upsertAgentIdOnlyEntry(cfg, agentId, baseAgentId) {
|
|
224
224
|
const normalizedId = String(agentId || "")
|
|
225
225
|
.trim()
|
|
226
226
|
.toLowerCase();
|
|
@@ -252,8 +252,44 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
|
252
252
|
}
|
|
253
253
|
|
|
254
254
|
if (!existingIds.has(normalizedId)) {
|
|
255
|
-
|
|
255
|
+
const entry = { id: normalizedId, heartbeat: {} };
|
|
256
|
+
|
|
257
|
+
// Inherit inheritable properties from the base agent so the dynamic
|
|
258
|
+
// agent retains model, subagents (spawn permissions), and tool config.
|
|
259
|
+
if (baseAgentId) {
|
|
260
|
+
const baseEntry = currentList.find(
|
|
261
|
+
(e) => e && typeof e.id === "string" && e.id === baseAgentId,
|
|
262
|
+
);
|
|
263
|
+
if (baseEntry) {
|
|
264
|
+
for (const key of ["model", "subagents", "tools"]) {
|
|
265
|
+
if (baseEntry[key] != null) {
|
|
266
|
+
entry[key] = JSON.parse(JSON.stringify(baseEntry[key]));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
nextList.push(entry);
|
|
256
273
|
changed = true;
|
|
274
|
+
} else if (baseAgentId) {
|
|
275
|
+
// Backfill missing inheritable properties on existing entries that were
|
|
276
|
+
// persisted before the inheritance logic was added.
|
|
277
|
+
const existingEntry = nextList.find(
|
|
278
|
+
(e) => e && typeof e.id === "string" && e.id.trim().toLowerCase() === normalizedId,
|
|
279
|
+
);
|
|
280
|
+
if (existingEntry) {
|
|
281
|
+
const baseEntry = currentList.find(
|
|
282
|
+
(e) => e && typeof e.id === "string" && e.id === baseAgentId,
|
|
283
|
+
);
|
|
284
|
+
if (baseEntry) {
|
|
285
|
+
for (const key of ["model", "subagents", "tools"]) {
|
|
286
|
+
if (existingEntry[key] == null && baseEntry[key] != null) {
|
|
287
|
+
existingEntry[key] = JSON.parse(JSON.stringify(baseEntry[key]));
|
|
288
|
+
changed = true;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
257
293
|
}
|
|
258
294
|
|
|
259
295
|
if (changed) {
|
|
@@ -263,7 +299,7 @@ export function upsertAgentIdOnlyEntry(cfg, agentId) {
|
|
|
263
299
|
return changed;
|
|
264
300
|
}
|
|
265
301
|
|
|
266
|
-
export async function ensureDynamicAgentListed(agentId, templateDir) {
|
|
302
|
+
export async function ensureDynamicAgentListed(agentId, templateDir, baseAgentId) {
|
|
267
303
|
const normalizedId = String(agentId || "")
|
|
268
304
|
.trim()
|
|
269
305
|
.toLowerCase();
|
|
@@ -285,24 +321,31 @@ export async function ensureDynamicAgentListed(agentId, templateDir) {
|
|
|
285
321
|
}
|
|
286
322
|
|
|
287
323
|
// Upsert into in-memory config so the running gateway sees it immediately.
|
|
288
|
-
const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId);
|
|
324
|
+
const changed = upsertAgentIdOnlyEntry(openclawConfig, normalizedId, baseAgentId);
|
|
289
325
|
if (changed) {
|
|
290
326
|
logger.info("WeCom: dynamic agent added to in-memory agents.list", { agentId: normalizedId });
|
|
291
327
|
|
|
292
328
|
// Persist to disk so `openclaw agents list` (separate process) can see
|
|
293
329
|
// the dynamic agent and it survives gateway restarts.
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
|
|
301
|
-
} catch (writeErr) {
|
|
302
|
-
logger.warn("WeCom: failed to persist dynamic agent to config file", {
|
|
330
|
+
// Safety check: refuse to write if critical config sections are missing,
|
|
331
|
+
// which would indicate the in-memory snapshot is incomplete and writing
|
|
332
|
+
// it would destroy the user's configuration (#136).
|
|
333
|
+
const hasChannels = openclawConfig.channels && typeof openclawConfig.channels === "object";
|
|
334
|
+
if (!hasChannels) {
|
|
335
|
+
logger.warn("WeCom: skipping config write — in-memory config is missing 'channels' section", {
|
|
303
336
|
agentId: normalizedId,
|
|
304
|
-
|
|
337
|
+
keys: Object.keys(openclawConfig),
|
|
305
338
|
});
|
|
339
|
+
} else {
|
|
340
|
+
try {
|
|
341
|
+
await configRuntime.writeConfigFile(openclawConfig);
|
|
342
|
+
logger.info("WeCom: dynamic agent persisted to config file", { agentId: normalizedId });
|
|
343
|
+
} catch (writeErr) {
|
|
344
|
+
logger.warn("WeCom: failed to persist dynamic agent to config file", {
|
|
345
|
+
agentId: normalizedId,
|
|
346
|
+
error: writeErr?.message || String(writeErr),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
306
349
|
}
|
|
307
350
|
}
|
|
308
351
|
|
package/wecom/ws-monitor.js
CHANGED
|
@@ -39,7 +39,6 @@ import {
|
|
|
39
39
|
import { setConfigProxyUrl } from "./http.js";
|
|
40
40
|
import { checkDmPolicy } from "./dm-policy.js";
|
|
41
41
|
import { checkGroupPolicy } from "./group-policy.js";
|
|
42
|
-
import { fetchAndSaveMcpConfig } from "./mcp-config.js";
|
|
43
42
|
import {
|
|
44
43
|
clearAccountDisplaced,
|
|
45
44
|
forecastActiveSendQuota,
|
|
@@ -63,6 +62,8 @@ import {
|
|
|
63
62
|
startMessageStateCleanup,
|
|
64
63
|
} from "./ws-state.js";
|
|
65
64
|
import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
65
|
+
import { listAccountIds, resolveAccount } from "./accounts.js";
|
|
66
|
+
import { loadWelcomeMessagesFromFile } from "./welcome-messages-file.js";
|
|
66
67
|
|
|
67
68
|
const DEFAULT_AGENT_ID = "main";
|
|
68
69
|
const DEFAULT_STATE_DIRNAME = ".openclaw";
|
|
@@ -138,6 +139,11 @@ function buildWaitingModelContent(seconds) {
|
|
|
138
139
|
return `<think>${lines.join("\n")}`;
|
|
139
140
|
}
|
|
140
141
|
|
|
142
|
+
function buildWaitingModelReasoningText(seconds) {
|
|
143
|
+
const normalizedSeconds = Math.max(1, Number.parseInt(String(seconds ?? 1), 10) || 1);
|
|
144
|
+
return `等待模型响应 ${normalizedSeconds}s`;
|
|
145
|
+
}
|
|
146
|
+
|
|
141
147
|
function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = false }) {
|
|
142
148
|
const normalizedReasoning = String(reasoningText ?? "").trim();
|
|
143
149
|
const normalizedVisible = String(visibleText ?? "").trim();
|
|
@@ -360,10 +366,23 @@ function resolveAgentWorkspaceDir(config, agentId) {
|
|
|
360
366
|
}
|
|
361
367
|
|
|
362
368
|
function resolveConfiguredReplyMediaLocalRoots(config) {
|
|
363
|
-
const
|
|
369
|
+
const topLevel = Array.isArray(config?.channels?.[CHANNEL_ID]?.mediaLocalRoots)
|
|
364
370
|
? config.channels[CHANNEL_ID].mediaLocalRoots
|
|
365
371
|
: [];
|
|
366
|
-
|
|
372
|
+
|
|
373
|
+
// Also collect mediaLocalRoots from each account entry (multi-account mode).
|
|
374
|
+
// In single-account mode the account config IS the top-level config, so
|
|
375
|
+
// listAccountIds returns ["default"] and the roots are already in topLevel.
|
|
376
|
+
const accountRoots = [];
|
|
377
|
+
for (const accountId of listAccountIds(config)) {
|
|
378
|
+
const accountConfig = resolveAccount(config, accountId)?.config;
|
|
379
|
+
if (Array.isArray(accountConfig?.mediaLocalRoots)) {
|
|
380
|
+
accountRoots.push(...accountConfig.mediaLocalRoots);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const merged = [...new Set([...topLevel, ...accountRoots])];
|
|
385
|
+
return merged.map((entry) => resolveUserPath(entry)).filter(Boolean);
|
|
367
386
|
}
|
|
368
387
|
|
|
369
388
|
function resolveReplyMediaLocalRoots(config, agentId) {
|
|
@@ -398,6 +417,7 @@ function buildReplyMediaGuidance(config, agentId) {
|
|
|
398
417
|
const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
|
|
399
418
|
const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
|
|
400
419
|
const configuredRoots = resolveConfiguredReplyMediaLocalRoots(config);
|
|
420
|
+
const qwenImageToolsConfig = config?.plugins?.entries?.wecom?.config?.qwenImageTools;
|
|
401
421
|
const guidance = [
|
|
402
422
|
WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
|
|
403
423
|
`Local reply files are allowed only under the current workspace: ${workspaceDir}`,
|
|
@@ -418,6 +438,22 @@ function buildReplyMediaGuidance(config, agentId) {
|
|
|
418
438
|
guidance.push(`Additional configured host roots are also allowed: ${configuredRoots.join(", ")}`);
|
|
419
439
|
}
|
|
420
440
|
|
|
441
|
+
if (qwenImageToolsConfig?.enabled === true) {
|
|
442
|
+
guidance.push(
|
|
443
|
+
"[WeCom image_studio rule]",
|
|
444
|
+
"When the user asks to generate an image, use image_studio with action=\"generate\".",
|
|
445
|
+
"When the user asks to edit an existing image, use image_studio with action=\"edit\" and pass images.",
|
|
446
|
+
"Use aspect=\"landscape\" for architecture diagrams, flowcharts, and banners unless the user asks otherwise.",
|
|
447
|
+
"Prefer model_preference=\"qwen\" for text-heavy diagrams or label-rich images, and model_preference=\"wan\" for photorealistic scenes.",
|
|
448
|
+
"For workspace-local images, always use /workspace/... paths when calling image_studio.",
|
|
449
|
+
"Prefer n=1 unless the user explicitly asks for multiple images.",
|
|
450
|
+
"If image_studio returns MEDIA: URLs, treat the image task as completed successfully.",
|
|
451
|
+
"If image_studio returns MEDIA: URLs, do NOT repeat those URLs in the visible reply.",
|
|
452
|
+
"Do NOT embed markdown images, raw image URLs, or OSS links in the text reply after image_studio succeeds.",
|
|
453
|
+
"Instead, tell the user the image will be sent separately, for example: 图片会单独发送,请查收。",
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
|
|
421
457
|
guidance.push("Never reference any other host path.");
|
|
422
458
|
return guidance.join("\n");
|
|
423
459
|
}
|
|
@@ -563,6 +599,11 @@ async function sendMediaBatch({ wsClient, frame, state, account, runtime, config
|
|
|
563
599
|
|
|
564
600
|
if (result.ok) {
|
|
565
601
|
state.hasMedia = true;
|
|
602
|
+
if (result.finalType === "image") {
|
|
603
|
+
state.hasImageMedia = true;
|
|
604
|
+
} else {
|
|
605
|
+
state.hasFileMedia = true;
|
|
606
|
+
}
|
|
566
607
|
if (result.downgraded) {
|
|
567
608
|
logger.info(`[WS] Media downgraded: ${result.downgradeNote}`);
|
|
568
609
|
}
|
|
@@ -601,7 +642,19 @@ async function finishThinkingStream({ wsClient, frame, state, accountId }) {
|
|
|
601
642
|
finish: true,
|
|
602
643
|
});
|
|
603
644
|
} else if (state.hasMedia) {
|
|
604
|
-
|
|
645
|
+
const mediaVisibleText = state.hasImageMedia && !state.hasFileMedia
|
|
646
|
+
? "图片已生成,请查收。"
|
|
647
|
+
: "文件已发送,请查收。";
|
|
648
|
+
const fallbackReasoningText = state.waitingModelSeconds > 0
|
|
649
|
+
? buildWaitingModelReasoningText(state.waitingModelSeconds)
|
|
650
|
+
: state.hasImageMedia && !state.hasFileMedia
|
|
651
|
+
? "正在生成图片"
|
|
652
|
+
: "正在整理并发送文件";
|
|
653
|
+
finishText = buildWsStreamContent({
|
|
654
|
+
reasoningText: fallbackReasoningText,
|
|
655
|
+
visibleText: mediaVisibleText,
|
|
656
|
+
finish: true,
|
|
657
|
+
});
|
|
605
658
|
} else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
606
659
|
finishText = state.mediaErrorSummary;
|
|
607
660
|
} else {
|
|
@@ -624,6 +677,12 @@ function resolveWelcomeMessage(account) {
|
|
|
624
677
|
return configured;
|
|
625
678
|
}
|
|
626
679
|
|
|
680
|
+
const fromFile = loadWelcomeMessagesFromFile(account?.config);
|
|
681
|
+
if (fromFile?.length) {
|
|
682
|
+
const pick = Math.floor(Math.random() * fromFile.length);
|
|
683
|
+
return fromFile[pick];
|
|
684
|
+
}
|
|
685
|
+
|
|
627
686
|
const index = Math.floor(Math.random() * DEFAULT_WELCOME_MESSAGES.length);
|
|
628
687
|
return DEFAULT_WELCOME_MESSAGES[index] || DEFAULT_WELCOME_MESSAGE;
|
|
629
688
|
}
|
|
@@ -1160,9 +1219,12 @@ async function processWsMessage({ frame, account, config, runtime, wsClient, req
|
|
|
1160
1219
|
replyMediaUrls: [],
|
|
1161
1220
|
pendingMediaUrls: [],
|
|
1162
1221
|
hasMedia: false,
|
|
1222
|
+
hasImageMedia: false,
|
|
1223
|
+
hasFileMedia: false,
|
|
1163
1224
|
hasMediaFailed: false,
|
|
1164
1225
|
mediaErrorSummary: "",
|
|
1165
1226
|
deliverCalled: false,
|
|
1227
|
+
waitingModelSeconds: 0,
|
|
1166
1228
|
};
|
|
1167
1229
|
setMessageState(messageId, state);
|
|
1168
1230
|
|
|
@@ -1194,6 +1256,7 @@ async function processWsMessage({ frame, account, config, runtime, wsClient, req
|
|
|
1194
1256
|
|
|
1195
1257
|
const sendWaitingModelUpdate = async (seconds) => {
|
|
1196
1258
|
const waitingText = buildWaitingModelContent(seconds);
|
|
1259
|
+
state.waitingModelSeconds = seconds;
|
|
1197
1260
|
lastStreamSentAt = Date.now();
|
|
1198
1261
|
lastNonEmptyStreamText = waitingText;
|
|
1199
1262
|
try {
|
|
@@ -1523,6 +1586,7 @@ async function processWsMessage({ frame, account, config, runtime, wsClient, req
|
|
|
1523
1586
|
if (account.sendThinkingMessage !== false) {
|
|
1524
1587
|
waitingModelActive = true;
|
|
1525
1588
|
waitingModelSeconds = 1;
|
|
1589
|
+
state.waitingModelSeconds = waitingModelSeconds;
|
|
1526
1590
|
await sendThinkingReply({
|
|
1527
1591
|
wsClient,
|
|
1528
1592
|
frame,
|
|
@@ -1547,10 +1611,6 @@ async function processWsMessage({ frame, account, config, runtime, wsClient, req
|
|
|
1547
1611
|
? generateAgentId(peerKind, peerId, account.accountId)
|
|
1548
1612
|
: null;
|
|
1549
1613
|
|
|
1550
|
-
if (dynamicAgentId) {
|
|
1551
|
-
await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate);
|
|
1552
|
-
}
|
|
1553
|
-
|
|
1554
1614
|
const route = core.routing.resolveAgentRoute({
|
|
1555
1615
|
cfg: config,
|
|
1556
1616
|
channel: CHANNEL_ID,
|
|
@@ -1565,8 +1625,15 @@ async function processWsMessage({ frame, account, config, runtime, wsClient, req
|
|
|
1565
1625
|
);
|
|
1566
1626
|
|
|
1567
1627
|
if (dynamicAgentId && !hasExplicitBinding) {
|
|
1628
|
+
const routeAgentId = route.agentId;
|
|
1629
|
+
// Use the account's configured agentId as the base for property inheritance
|
|
1630
|
+
// (model, subagents, tools). route.agentId may resolve to "main" when
|
|
1631
|
+
// there is no explicit binding, but the account's agentId points to the
|
|
1632
|
+
// actual parent agent whose properties the dynamic agent should inherit.
|
|
1633
|
+
const baseAgentId = account.config.agentId || routeAgentId;
|
|
1634
|
+
await ensureDynamicAgentListed(dynamicAgentId, account.config.workspaceTemplate, baseAgentId);
|
|
1635
|
+
route.sessionKey = route.sessionKey.replace(`agent:${routeAgentId}:`, `agent:${dynamicAgentId}:`);
|
|
1568
1636
|
route.agentId = dynamicAgentId;
|
|
1569
|
-
route.sessionKey = `agent:${dynamicAgentId}:${peerKind}:${peerId}`;
|
|
1570
1637
|
}
|
|
1571
1638
|
|
|
1572
1639
|
const { ctxPayload, storePath } = buildInboundContext({
|
|
@@ -1893,8 +1960,6 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1893
1960
|
clearAccountDisplaced(account.accountId);
|
|
1894
1961
|
setWsClient(account.accountId, wsClient);
|
|
1895
1962
|
|
|
1896
|
-
void fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
|
|
1897
|
-
|
|
1898
1963
|
// Drain pending replies that failed due to prior WS disconnection.
|
|
1899
1964
|
if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
|
|
1900
1965
|
void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
|
package/wecom/mcp-config.js
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
import os from "node:os";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
4
|
-
import { generateReqId } from "@wecom/aibot-node-sdk";
|
|
5
|
-
import { logger } from "../logger.js";
|
|
6
|
-
|
|
7
|
-
const MCP_GET_CONFIG_CMD = "aibot_get_mcp_config";
|
|
8
|
-
const MCP_CONFIG_FETCH_TIMEOUT_MS = 15_000;
|
|
9
|
-
const MCP_CONFIG_KEY = "doc";
|
|
10
|
-
const DEFAULT_MCP_TRANSPORT = "streamable-http";
|
|
11
|
-
|
|
12
|
-
let mcpConfigWriteQueue = Promise.resolve();
|
|
13
|
-
|
|
14
|
-
function withTimeout(promise, timeoutMs, message) {
|
|
15
|
-
if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
16
|
-
return promise;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
let timer = null;
|
|
20
|
-
const timeout = new Promise((_, reject) => {
|
|
21
|
-
timer = setTimeout(() => reject(new Error(message ?? `Timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
promise.catch(() => {});
|
|
25
|
-
|
|
26
|
-
return Promise.race([promise, timeout]).finally(() => {
|
|
27
|
-
if (timer) {
|
|
28
|
-
clearTimeout(timer);
|
|
29
|
-
}
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function getWecomConfigPath() {
|
|
34
|
-
return path.join(os.homedir(), ".openclaw", "wecomConfig", "config.json");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveMcpTransport(body = {}) {
|
|
38
|
-
const candidate = String(
|
|
39
|
-
body.transport_type ??
|
|
40
|
-
body.transportType ??
|
|
41
|
-
body.config_type ??
|
|
42
|
-
body.configType ??
|
|
43
|
-
body.type ??
|
|
44
|
-
"",
|
|
45
|
-
)
|
|
46
|
-
.trim()
|
|
47
|
-
.toLowerCase();
|
|
48
|
-
|
|
49
|
-
return candidate || DEFAULT_MCP_TRANSPORT;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async function readJsonFile(filePath, fallback = {}) {
|
|
53
|
-
try {
|
|
54
|
-
const raw = await readFile(filePath, "utf8");
|
|
55
|
-
return JSON.parse(raw);
|
|
56
|
-
} catch (error) {
|
|
57
|
-
if (error?.code === "ENOENT") {
|
|
58
|
-
return fallback;
|
|
59
|
-
}
|
|
60
|
-
throw error;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function writeJsonFileAtomically(filePath, value) {
|
|
65
|
-
const dir = path.dirname(filePath);
|
|
66
|
-
await mkdir(dir, { recursive: true });
|
|
67
|
-
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
68
|
-
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
69
|
-
await rename(tempPath, filePath);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export async function fetchMcpConfig(wsClient) {
|
|
73
|
-
if (!wsClient || typeof wsClient.reply !== "function") {
|
|
74
|
-
throw new Error("WS client does not support MCP config requests");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const reqId = generateReqId("mcp_config");
|
|
78
|
-
const response = await withTimeout(
|
|
79
|
-
wsClient.reply({ headers: { req_id: reqId } }, { biz_type: MCP_CONFIG_KEY }, MCP_GET_CONFIG_CMD),
|
|
80
|
-
MCP_CONFIG_FETCH_TIMEOUT_MS,
|
|
81
|
-
`MCP config fetch timed out after ${MCP_CONFIG_FETCH_TIMEOUT_MS}ms`,
|
|
82
|
-
);
|
|
83
|
-
|
|
84
|
-
if (response?.errcode && response.errcode !== 0) {
|
|
85
|
-
throw new Error(`MCP config request failed: errcode=${response.errcode}, errmsg=${response.errmsg ?? "unknown"}`);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const body = response?.body;
|
|
89
|
-
if (!body?.url) {
|
|
90
|
-
throw new Error("MCP config response missing required 'url' field");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return {
|
|
94
|
-
key: MCP_CONFIG_KEY,
|
|
95
|
-
type: resolveMcpTransport(body),
|
|
96
|
-
url: body.url,
|
|
97
|
-
isAuthed: body.is_authed,
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export async function saveMcpConfig(config, runtime) {
|
|
102
|
-
const configPath = getWecomConfigPath();
|
|
103
|
-
|
|
104
|
-
const saveTask = mcpConfigWriteQueue.then(async () => {
|
|
105
|
-
const current = await readJsonFile(configPath, {});
|
|
106
|
-
if (!current.mcpConfig || typeof current.mcpConfig !== "object") {
|
|
107
|
-
current.mcpConfig = {};
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
current.mcpConfig[config.key || MCP_CONFIG_KEY] = {
|
|
111
|
-
type: config.type,
|
|
112
|
-
url: config.url,
|
|
113
|
-
};
|
|
114
|
-
|
|
115
|
-
await writeJsonFileAtomically(configPath, current);
|
|
116
|
-
runtime?.log?.(`[WeCom] MCP config saved to ${configPath}`);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
mcpConfigWriteQueue = saveTask.catch(() => {});
|
|
120
|
-
return saveTask;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function fetchAndSaveMcpConfig(wsClient, accountId, runtime) {
|
|
124
|
-
try {
|
|
125
|
-
runtime?.log?.(`[${accountId}] Fetching MCP config...`);
|
|
126
|
-
const config = await fetchMcpConfig(wsClient);
|
|
127
|
-
runtime?.log?.(
|
|
128
|
-
`[${accountId}] MCP config fetched: url=${config.url}, type=${config.type}, is_authed=${config.isAuthed ?? "N/A"}`,
|
|
129
|
-
);
|
|
130
|
-
await saveMcpConfig(config, runtime);
|
|
131
|
-
} catch (error) {
|
|
132
|
-
if (typeof wsClient?.reply !== "function") {
|
|
133
|
-
logger.debug?.(`[${accountId}] Skipping MCP config fetch because WS client has no reply() support`);
|
|
134
|
-
return;
|
|
135
|
-
}
|
|
136
|
-
runtime?.error?.(`[${accountId}] Failed to fetch/save MCP config: ${String(error)}`);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export const mcpConfigTesting = {
|
|
141
|
-
getWecomConfigPath,
|
|
142
|
-
resolveMcpTransport,
|
|
143
|
-
resetWriteQueue() {
|
|
144
|
-
mcpConfigWriteQueue = Promise.resolve();
|
|
145
|
-
},
|
|
146
|
-
};
|