@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.
@@ -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
- nextList.push({ id: normalizedId, heartbeat: {} });
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
- // Write the mutated in-memory config directly (same pattern as logoutAccount).
295
- // NOTE: loadConfig() returns runtimeConfigSnapshot in gateway mode the same
296
- // object we already mutated above so a read-modify-write pattern silently
297
- // skips the write (diskChanged=false). Writing directly avoids this.
298
- try {
299
- await configRuntime.writeConfigFile(openclawConfig);
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
- error: writeErr?.message || String(writeErr),
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
 
@@ -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 roots = Array.isArray(config?.channels?.[CHANNEL_ID]?.mediaLocalRoots)
369
+ const topLevel = Array.isArray(config?.channels?.[CHANNEL_ID]?.mediaLocalRoots)
364
370
  ? config.channels[CHANNEL_ID].mediaLocalRoots
365
371
  : [];
366
- return roots.map((entry) => resolveUserPath(entry)).filter(Boolean);
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
- finishText = "文件已发送,请查收。";
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) => {
@@ -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
- };