@sunnoy/wecom 2.1.0 → 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.
@@ -41,10 +41,13 @@ import {
41
41
  } from "./constants.js";
42
42
  import { verifyCallbackSignature, decryptCallbackMessage } from "./callback-crypto.js";
43
43
  import { downloadCallbackMedia } from "./callback-media.js";
44
+ import { assertPathInsideSandbox } from "./sandbox.js";
44
45
  import {
45
46
  buildInboundContext,
47
+ ensureDefaultSessionReasoningLevel,
46
48
  resolveChannelCore,
47
49
  normalizeReplyPayload,
50
+ normalizeReplyMediaUrlForLoad,
48
51
  resolveReplyMediaLocalRoots,
49
52
  } from "./ws-monitor.js";
50
53
 
@@ -169,19 +172,36 @@ async function loadLocalReplyMedia(mediaUrl, config, agentId, runtime) {
169
172
  if (!normalized.startsWith("/") && !normalized.startsWith("sandbox:")) {
170
173
  throw new Error(`Unsupported callback reply media URL scheme: ${mediaUrl}`);
171
174
  }
175
+ const normalizedLocalPath = normalizeReplyMediaUrlForLoad(normalized, config, agentId);
176
+ if (!normalizedLocalPath) {
177
+ throw new Error(`Invalid callback reply media path: ${mediaUrl}`);
178
+ }
172
179
 
173
180
  if (typeof runtime?.media?.loadWebMedia === "function") {
174
181
  const localRoots = resolveReplyMediaLocalRoots(config, agentId);
175
- const loaded = await runtime.media.loadWebMedia(normalized.replace(/^sandbox:\/{0,2}/, "/"), { localRoots });
176
- const filename = loaded.fileName || path.basename(normalized.replace(/^sandbox:\/+/, "")) || "file";
182
+ const loaded = await runtime.media.loadWebMedia(normalizedLocalPath, { localRoots });
183
+ const filename = loaded.fileName || path.basename(normalizedLocalPath) || "file";
177
184
  return { buffer: loaded.buffer, filename, contentType: loaded.contentType || "" };
178
185
  }
179
186
 
180
- // Fallback when runtime.media is unavailable
187
+ // Fallback when runtime.media is unavailable — enforce local roots check manually
188
+ const localRoots = resolveReplyMediaLocalRoots(config, agentId);
189
+ const resolvedPath = path.resolve(normalizedLocalPath);
190
+ await assertPathInsideSandbox(resolvedPath, localRoots);
181
191
  const { readFile } = await import("node:fs/promises");
182
- const localPath = normalized.replace(/^sandbox:\/{0,2}/, "");
183
- const buffer = await readFile(localPath);
184
- return { buffer, filename: path.basename(localPath) || "file", contentType: "" };
192
+ const buffer = await readFile(resolvedPath);
193
+ return { buffer, filename: path.basename(resolvedPath) || "file", contentType: "" };
194
+ }
195
+
196
+ function resolveCallbackFinalText(accumulatedText, replyMediaUrls = []) {
197
+ const normalizedText = normalizeThinkingTags(String(accumulatedText ?? "").trim());
198
+ if (normalizedText) {
199
+ return normalizedText;
200
+ }
201
+ if (replyMediaUrls.length > 0) {
202
+ return "";
203
+ }
204
+ return "模型暂时无法响应,请稍后重试。";
185
205
  }
186
206
 
187
207
  // ---------------------------------------------------------------------------
@@ -347,20 +367,43 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
347
367
  });
348
368
  ctxPayload.CommandAuthorized = commandAuthorized;
349
369
 
350
- void core.session
351
- .recordSessionMetaFromInbound({
352
- storePath,
353
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
354
- ctx: ctxPayload,
355
- })
356
- .catch((err) => logger.error(`[CB] Session meta record failed: ${err.message}`));
370
+ await ensureDefaultSessionReasoningLevel({
371
+ core,
372
+ storePath,
373
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
374
+ ctx: ctxPayload,
375
+ channelTag: "CB",
376
+ });
357
377
 
358
378
  // --- Dispatch ---
359
- const state = { accumulatedText: "", replyMediaUrls: [] };
379
+ const dispatchStartedAt = Date.now();
380
+ const logPerf = (event, extra = {}) => {
381
+ logger.info(`[CB:${account.accountId}] ${event}`, {
382
+ msgId,
383
+ senderId,
384
+ chatId,
385
+ routeAgentId: route.agentId,
386
+ sessionKey: route.sessionKey,
387
+ elapsedMs: Date.now() - dispatchStartedAt,
388
+ ...extra,
389
+ });
390
+ };
391
+
392
+ const state = {
393
+ accumulatedText: "",
394
+ replyMediaUrls: [],
395
+ deliveryCount: 0,
396
+ firstDeliveryAt: 0,
397
+ };
360
398
  const streamId = `cb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
361
399
 
362
400
  const runDispatch = async () => {
363
401
  try {
402
+ logPerf("dispatch_start", {
403
+ mediaCount: mediaList.length,
404
+ hasText: Boolean(effectiveText),
405
+ streamId,
406
+ });
364
407
  await streamContext.run(
365
408
  { streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
366
409
  async () => {
@@ -370,8 +413,18 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
370
413
  // Disable block-streaming since Agent API replies are sent atomically
371
414
  replyOptions: { disableBlockStreaming: true },
372
415
  dispatcherOptions: {
373
- deliver: async (payload) => {
416
+ deliver: async (payload, info = {}) => {
374
417
  const normalized = normalizeReplyPayload(payload);
418
+ state.deliveryCount += 1;
419
+ if (!state.firstDeliveryAt) {
420
+ state.firstDeliveryAt = Date.now();
421
+ logPerf("first_reply_block_received", {
422
+ kind: info.kind ?? "unknown",
423
+ textLength: normalized.text.length,
424
+ mediaCount: normalized.mediaUrls.length,
425
+ deliveryCount: state.deliveryCount,
426
+ });
427
+ }
375
428
  state.accumulatedText += normalized.text;
376
429
  for (const mediaUrl of normalized.mediaUrls) {
377
430
  if (!state.replyMediaUrls.includes(mediaUrl)) {
@@ -387,7 +440,13 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
387
440
  },
388
441
  );
389
442
 
390
- const finalText = normalizeThinkingTags(state.accumulatedText.trim()) || "模型暂时无法响应,请稍后重试。";
443
+ logPerf("dispatch_returned", {
444
+ totalOutputChars: state.accumulatedText.length,
445
+ replyMediaCount: state.replyMediaUrls.length,
446
+ deliveryCount: state.deliveryCount,
447
+ });
448
+
449
+ const finalText = resolveCallbackFinalText(state.accumulatedText, state.replyMediaUrls);
391
450
 
392
451
  if (!account.agentCredentials) {
393
452
  logger.warn(`[CB:${account.accountId}] No agent credentials configured; callback reply skipped`);
@@ -397,24 +456,32 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
397
456
  const target = isGroupChat ? { chatId } : { toUser: senderId };
398
457
 
399
458
  // Send reply text (chunked to stay within WeCom message size limits)
400
- const chunks = splitTextByByteLimit(finalText, TEXT_CHUNK_LIMIT);
401
- logger.info(`[CB:${account.accountId}] outbound`, {
402
- senderId,
403
- chatId,
404
- format: account.agentReplyFormat,
405
- chunks: chunks.length,
406
- totalLength: finalText.length,
407
- preview: finalText.slice(0, 80),
408
- });
409
- for (const chunk of chunks) {
410
- await agentSendText({
411
- agent: account.agentCredentials,
412
- ...target,
413
- text: chunk,
459
+ const chunks = finalText ? splitTextByByteLimit(finalText, TEXT_CHUNK_LIMIT) : [];
460
+ if (chunks.length > 0) {
461
+ logger.info(`[CB:${account.accountId}] → outbound`, {
462
+ senderId,
463
+ chatId,
414
464
  format: account.agentReplyFormat,
465
+ chunks: chunks.length,
466
+ totalLength: finalText.length,
467
+ preview: finalText.slice(0, 80),
468
+ });
469
+ for (const chunk of chunks) {
470
+ await agentSendText({
471
+ agent: account.agentCredentials,
472
+ ...target,
473
+ text: chunk,
474
+ format: account.agentReplyFormat,
475
+ });
476
+ }
477
+ recordOutboundActivity({ accountId: account.accountId });
478
+ } else {
479
+ logger.info(`[CB:${account.accountId}] → outbound text skipped`, {
480
+ senderId,
481
+ chatId,
482
+ reason: state.replyMediaUrls.length > 0 ? "media_only_reply" : "empty_reply",
415
483
  });
416
484
  }
417
- recordOutboundActivity({ accountId: account.accountId });
418
485
 
419
486
  // Send any LLM-generated media (MEDIA:/FILE: directives in reply)
420
487
  for (const mediaUrl of state.replyMediaUrls) {
@@ -443,8 +510,19 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
443
510
  logger.error(`[CB:${account.accountId}] Failed to send reply media: ${mediaError.message}`);
444
511
  }
445
512
  }
513
+ logPerf("dispatch_complete", {
514
+ totalOutputChars: state.accumulatedText.length,
515
+ replyMediaCount: state.replyMediaUrls.length,
516
+ deliveryCount: state.deliveryCount,
517
+ });
446
518
  } catch (error) {
447
519
  logger.error(`[CB:${account.accountId}] Dispatch error: ${error.message}`);
520
+ logPerf("dispatch_failed", {
521
+ error: error.message,
522
+ totalOutputChars: state.accumulatedText.length,
523
+ replyMediaCount: state.replyMediaUrls.length,
524
+ deliveryCount: state.deliveryCount,
525
+ });
448
526
  if (account.agentCredentials) {
449
527
  const target = isGroupChat ? { chatId } : { toUser: senderId };
450
528
  try {
@@ -463,10 +541,27 @@ async function processCallbackMessage({ parsedMsg, account, config, runtime }) {
463
541
 
464
542
  // Serialise per-sender to prevent concurrent replies to the same user
465
543
  const lockKey = `${account.accountId}:${peerId}`;
544
+ const queuedAt = Date.now();
545
+ logPerf("dispatch_enqueued", { lockKey });
466
546
  const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
467
- const current = previous.then(runDispatch, runDispatch);
547
+ const current = previous.then(
548
+ async () => {
549
+ const queueWaitMs = Date.now() - queuedAt;
550
+ if (queueWaitMs >= 50) {
551
+ logPerf("dispatch_lock_acquired", { queueWaitMs });
552
+ }
553
+ return await runDispatch();
554
+ },
555
+ async () => {
556
+ const queueWaitMs = Date.now() - queuedAt;
557
+ if (queueWaitMs >= 50) {
558
+ logPerf("dispatch_lock_acquired", { queueWaitMs, previousFailed: true });
559
+ }
560
+ return await runDispatch();
561
+ },
562
+ );
468
563
  dispatchLocks.set(lockKey, current);
469
- current.finally(() => {
564
+ return await current.finally(() => {
470
565
  if (dispatchLocks.get(lockKey) === current) {
471
566
  dispatchLocks.delete(lockKey);
472
567
  }
@@ -616,3 +711,8 @@ export function createCallbackHandler({ account, config, runtime }) {
616
711
  return true;
617
712
  };
618
713
  }
714
+
715
+ export const callbackInboundTesting = {
716
+ loadLocalReplyMedia,
717
+ resolveCallbackFinalText,
718
+ };
@@ -1,6 +1,5 @@
1
1
  import crypto from "node:crypto";
2
2
  import { basename } from "node:path";
3
- import { readFile } from "node:fs/promises";
4
3
  import {
5
4
  buildBaseAccountStatusSnapshot,
6
5
  buildBaseChannelStatusSummary,
@@ -23,9 +22,10 @@ import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js"
23
22
  import { setConfigProxyUrl, wecomFetch } from "./http.js";
24
23
  import { wecomOnboardingAdapter } from "./onboarding.js";
25
24
  import { getAccountTelemetry, recordOutboundActivity } from "./runtime-telemetry.js";
26
- import { getRuntime, setOpenclawConfig } from "./state.js";
25
+ import { getOpenclawConfig, getRuntime, setOpenclawConfig } from "./state.js";
27
26
  import { resolveWecomTarget } from "./target.js";
28
27
  import { webhookSendFile, webhookSendImage, webhookSendMarkdown, webhookUploadFile } from "./webhook-bot.js";
28
+ import { loadOutboundMediaFromUrl as loadOutboundMediaFromUrlCompat } from "./openclaw-compat.js";
29
29
  import {
30
30
  CHANNEL_ID,
31
31
  DEFAULT_ACCOUNT_ID,
@@ -34,7 +34,10 @@ import {
34
34
  getWebhookBotSendUrl,
35
35
  setApiBaseUrl,
36
36
  } from "./constants.js";
37
+ import { uploadAndSendMedia } from "./media-uploader.js";
38
+ import { getExtendedMediaLocalRoots } from "./openclaw-compat.js";
37
39
  import { sendWsMessage, startWsMonitor } from "./ws-monitor.js";
40
+ import { getWsClient } from "./ws-state.js";
38
41
 
39
42
  function normalizePairingEntry(entry) {
40
43
  return String(entry ?? "")
@@ -79,40 +82,32 @@ function normalizeMediaPath(mediaUrl) {
79
82
  return value;
80
83
  }
81
84
 
82
- async function loadMediaPayload(mediaUrl, { mediaLocalRoots } = {}) {
85
+ async function loadMediaPayload(mediaUrl, { accountConfig, mediaLocalRoots } = {}) {
83
86
  const normalized = normalizeMediaPath(mediaUrl);
84
- if (normalized.startsWith("/")) {
85
- // Prefer core's loadWebMedia with sandbox enforcement when available.
86
- const runtime = getRuntime();
87
- if (typeof runtime?.media?.loadWebMedia === "function" && Array.isArray(mediaLocalRoots) && mediaLocalRoots.length > 0) {
88
- const loaded = await runtime.media.loadWebMedia(normalized, { localRoots: mediaLocalRoots });
89
- return {
90
- buffer: loaded.buffer,
91
- filename: loaded.fileName || basename(normalized) || "file",
92
- contentType: loaded.contentType || "",
93
- };
94
- }
95
- const buffer = await readFile(normalized);
96
- return {
97
- buffer,
98
- filename: basename(normalized) || "file",
99
- contentType: "",
100
- };
101
- }
87
+ let runtime = null;
88
+ try {
89
+ runtime = getRuntime();
90
+ } catch {}
91
+
92
+ const loaded = await loadOutboundMediaFromUrlCompat(normalized, {
93
+ accountConfig,
94
+ fetchImpl: wecomFetch,
95
+ mediaLocalRoots,
96
+ runtimeLoadMedia:
97
+ typeof runtime?.media?.loadWebMedia === "function"
98
+ ? (path, options) => runtime.media.loadWebMedia(path, options)
99
+ : undefined,
100
+ });
102
101
 
103
- const response = await wecomFetch(normalized);
104
- if (!response.ok) {
105
- throw new Error(`failed to download media: ${response.status}`);
106
- }
107
102
  return {
108
- buffer: Buffer.from(await response.arrayBuffer()),
109
- filename: basename(new URL(normalized).pathname) || "file",
110
- contentType: response.headers.get("content-type") || "",
103
+ buffer: loaded.buffer,
104
+ filename: loaded.fileName || basename(normalized) || "file",
105
+ contentType: loaded.contentType || "",
111
106
  };
112
107
  }
113
108
 
114
- async function loadResolvedMedia(mediaUrl, { mediaLocalRoots } = {}) {
115
- const media = await loadMediaPayload(mediaUrl, { mediaLocalRoots });
109
+ async function loadResolvedMedia(mediaUrl, { accountConfig, mediaLocalRoots } = {}) {
110
+ const media = await loadMediaPayload(mediaUrl, { accountConfig, mediaLocalRoots });
116
111
  return {
117
112
  ...media,
118
113
  mediaType: resolveAgentMediaType(media.filename, media.contentType),
@@ -134,45 +129,6 @@ export function resolveAgentMediaTypeFromFilename(filename) {
134
129
  return resolveAgentMediaType(filename, "");
135
130
  }
136
131
 
137
- function resolveWsNoticeTarget(target, rawTo) {
138
- if (target?.webhook || target?.toParty || target?.toTag) {
139
- return null;
140
- }
141
- const fallback = String(rawTo ?? "").trim();
142
- return target?.chatId || target?.toUser || fallback || null;
143
- }
144
-
145
- function buildUnsupportedMediaNotice({ text, mediaType, deliveredViaAgent }) {
146
- let notice;
147
- if (mediaType === "file") {
148
- notice = deliveredViaAgent
149
- ? "由于当前企业微信bot不支持给用户发送文件,文件通过自建应用发送。"
150
- : "由于当前企业微信bot不支持给用户发送文件,且当前未配置自建应用发送渠道。";
151
- } else if (mediaType === "image") {
152
- notice = deliveredViaAgent
153
- ? "由于当前企业微信bot不支持直接发送图片,图片通过自建应用发送。"
154
- : "由于当前企业微信bot不支持直接发送图片,且当前未配置自建应用发送渠道。";
155
- } else {
156
- notice = deliveredViaAgent
157
- ? "由于当前企业微信bot不支持直接发送媒体,媒体通过自建应用发送。"
158
- : "由于当前企业微信bot不支持直接发送媒体,且当前未配置自建应用发送渠道。";
159
- }
160
-
161
- return [text, notice].filter(Boolean).join("\n\n");
162
- }
163
-
164
- async function sendUnsupportedMediaNoticeViaWs({ to, text, mediaType, accountId }) {
165
- return sendWsMessage({
166
- to,
167
- content: buildUnsupportedMediaNotice({
168
- text,
169
- mediaType,
170
- deliveredViaAgent: true,
171
- }),
172
- accountId,
173
- });
174
- }
175
-
176
132
  function resolveOutboundAccountId(cfg, accountId) {
177
133
  return accountId || resolveDefaultAccountId(cfg);
178
134
  }
@@ -199,7 +155,8 @@ async function sendViaWebhook({ cfg, accountId, webhookName, text, mediaUrl, pre
199
155
  return { channel: CHANNEL_ID, messageId: `wecom-webhook-${Date.now()}` };
200
156
  }
201
157
 
202
- const { buffer, filename, mediaType } = preparedMedia ?? (await loadResolvedMedia(mediaUrl));
158
+ const { buffer, filename, mediaType } =
159
+ preparedMedia ?? (await loadResolvedMedia(mediaUrl, { accountConfig: account?.config }));
203
160
 
204
161
  if (text) {
205
162
  await webhookSendMarkdown({ url, content: text });
@@ -237,7 +194,8 @@ async function sendViaAgent({ cfg, accountId, target, text, mediaUrl, preparedMe
237
194
  return { channel: CHANNEL_ID, messageId: `wecom-agent-${Date.now()}` };
238
195
  }
239
196
 
240
- const { buffer, filename, mediaType } = preparedMedia ?? (await loadResolvedMedia(mediaUrl));
197
+ const { buffer, filename, mediaType } =
198
+ preparedMedia ?? (await loadResolvedMedia(mediaUrl, { accountConfig: resolveAccount(cfg, accountId)?.config }));
241
199
  const mediaId = await agentUploadMedia({
242
200
  agent,
243
201
  type: mediaType,
@@ -308,6 +266,8 @@ export const wecomChannelPlugin = {
308
266
  allowFrom: { type: "array", items: { type: "string" } },
309
267
  groupPolicy: { enum: ["open", "allowlist", "disabled"] },
310
268
  groupAllowFrom: { type: "array", items: { type: "string" } },
269
+ deliveryMode: { enum: ["direct", "gateway"] },
270
+ mediaLocalRoots: { type: "array", items: { type: "string" } },
311
271
  agent: {
312
272
  type: "object",
313
273
  additionalProperties: true,
@@ -381,7 +341,29 @@ export const wecomChannelPlugin = {
381
341
  messaging: {
382
342
  normalizeTarget: (target) => {
383
343
  const trimmed = String(target ?? "").trim();
384
- return trimmed || undefined;
344
+ if (!trimmed) {
345
+ return undefined;
346
+ }
347
+ const resolved = resolveWecomTarget(trimmed);
348
+ if (!resolved) {
349
+ return undefined;
350
+ }
351
+ if (resolved.webhook) {
352
+ return `webhook:${resolved.webhook}`;
353
+ }
354
+ if (resolved.toParty) {
355
+ return `party:${resolved.toParty}`;
356
+ }
357
+ if (resolved.toTag) {
358
+ return `tag:${resolved.toTag}`;
359
+ }
360
+ if (resolved.chatId) {
361
+ return `chat:${resolved.chatId}`;
362
+ }
363
+ if (resolved.toUser) {
364
+ return `user:${resolved.toUser}`;
365
+ }
366
+ return trimmed;
385
367
  },
386
368
  targetResolver: {
387
369
  looksLikeId: (value) => Boolean(String(value ?? "").trim()),
@@ -394,7 +376,14 @@ export const wecomChannelPlugin = {
394
376
  listGroups: async () => [],
395
377
  },
396
378
  outbound: {
397
- deliveryMode: "direct",
379
+ get deliveryMode() {
380
+ try {
381
+ const cfg = getOpenclawConfig();
382
+ const mode = cfg?.channels?.wecom?.deliveryMode;
383
+ if (mode === "direct" || mode === "gateway") return mode;
384
+ } catch {}
385
+ return "gateway";
386
+ },
398
387
  chunker: (text, limit) => resolveRuntimeTextChunker(text, limit),
399
388
  textChunkLimit: TEXT_CHUNK_LIMIT,
400
389
  sendText: async ({ cfg, to, text, accountId }) => {
@@ -437,10 +426,11 @@ export const wecomChannelPlugin = {
437
426
  setOpenclawConfig(cfg);
438
427
  const account = applyNetworkConfig(cfg, resolvedAccountId);
439
428
  const target = resolveWecomTarget(to) ?? {};
440
- const wsNoticeTarget = resolveWsNoticeTarget(target, to);
441
429
 
442
430
  if (target.webhook) {
443
- const preparedMedia = mediaUrl ? await loadResolvedMedia(mediaUrl, { mediaLocalRoots }) : undefined;
431
+ const preparedMedia = mediaUrl
432
+ ? await loadResolvedMedia(mediaUrl, { accountConfig: account?.config, mediaLocalRoots })
433
+ : undefined;
444
434
  return sendViaWebhook({
445
435
  cfg,
446
436
  accountId: resolvedAccountId,
@@ -451,14 +441,6 @@ export const wecomChannelPlugin = {
451
441
  });
452
442
  }
453
443
 
454
- const agentTarget =
455
- target.toParty || target.toTag
456
- ? target
457
- : target.chatId
458
- ? { chatId: target.chatId }
459
- : { toUser: target.toUser || String(to).replace(/^wecom:/i, "") };
460
- const preparedMedia = await loadResolvedMedia(mediaUrl, { mediaLocalRoots });
461
-
462
444
  if (target.toParty || target.toTag) {
463
445
  if (!account?.agentCredentials) {
464
446
  throw new Error("Agent API is required for party/tag media delivery");
@@ -466,61 +448,63 @@ export const wecomChannelPlugin = {
466
448
  return sendViaAgent({
467
449
  cfg,
468
450
  accountId: resolvedAccountId,
469
- target: agentTarget,
451
+ target,
470
452
  text,
471
453
  mediaUrl,
472
- preparedMedia,
454
+ preparedMedia: await loadResolvedMedia(mediaUrl, { accountConfig: account?.config, mediaLocalRoots }),
473
455
  });
474
456
  }
475
457
 
476
- if (account?.agentCredentials) {
477
- const agentResult = await sendViaAgent({
478
- cfg,
479
- accountId: resolvedAccountId,
480
- target: agentTarget,
481
- text: wsNoticeTarget ? undefined : text,
482
- mediaUrl,
483
- preparedMedia,
484
- });
458
+ const chatId = target.chatId || target.toUser || String(to).replace(/^wecom:/i, "");
459
+ const wsClient = getWsClient(resolvedAccountId);
485
460
 
486
- if (wsNoticeTarget) {
461
+ let textAlreadySent = false;
462
+ if (wsClient?.isConnected && mediaUrl) {
463
+ if (text) {
487
464
  try {
488
- await sendUnsupportedMediaNoticeViaWs({
489
- to: wsNoticeTarget,
490
- text,
491
- mediaType: preparedMedia.mediaType,
492
- accountId: resolvedAccountId,
493
- });
494
- } catch (error) {
495
- logger.warn(`[wecom] WS media notice failed, falling back to Agent text delivery: ${error.message}`);
496
- if (text) {
497
- await sendViaAgent({
498
- cfg,
499
- accountId: resolvedAccountId,
500
- target: agentTarget,
501
- text,
502
- });
503
- }
465
+ await sendWsMessage({ to: chatId, content: text, accountId: resolvedAccountId });
466
+ textAlreadySent = true;
467
+ } catch (textErr) {
468
+ logger.warn(`[wecom] WS text send failed before media upload: ${textErr.message}`);
504
469
  }
505
470
  }
506
471
 
507
- return agentResult;
472
+ const extendedRoots = await getExtendedMediaLocalRoots({
473
+ accountConfig: account?.config,
474
+ mediaLocalRoots,
475
+ });
476
+ const result = await uploadAndSendMedia({
477
+ wsClient,
478
+ mediaUrl,
479
+ chatId,
480
+ mediaLocalRoots: extendedRoots,
481
+ log: (...args) => logger.info(...args),
482
+ errorLog: (...args) => logger.error(...args),
483
+ });
484
+
485
+ if (result.ok) {
486
+ recordOutboundActivity({ accountId: resolvedAccountId });
487
+ return { channel: CHANNEL_ID, messageId: result.messageId, chatId };
488
+ }
489
+ logger.warn(`[wecom] WS media upload failed, falling back: ${result.error || result.rejectReason}`);
508
490
  }
509
491
 
510
- if (wsNoticeTarget) {
511
- logger.warn("[wecom] Agent API is not configured for unsupported WS media; sending notice only");
512
- return sendWsMessage({
513
- to: wsNoticeTarget,
514
- content: buildUnsupportedMediaNotice({
515
- text,
516
- mediaType: preparedMedia.mediaType,
517
- deliveredViaAgent: false,
518
- }),
492
+ const agentTarget = target.chatId
493
+ ? { chatId: target.chatId }
494
+ : { toUser: target.toUser || String(to).replace(/^wecom:/i, "") };
495
+
496
+ if (account?.agentCredentials) {
497
+ return sendViaAgent({
498
+ cfg,
519
499
  accountId: resolvedAccountId,
500
+ target: agentTarget,
501
+ text: textAlreadySent ? undefined : text,
502
+ mediaUrl,
503
+ preparedMedia: await loadResolvedMedia(mediaUrl, { accountConfig: account?.config, mediaLocalRoots }),
520
504
  });
521
505
  }
522
506
 
523
- throw new Error("Agent API is not configured for unsupported WeCom media delivery");
507
+ throw new Error("No media delivery channel available: WS upload failed and Agent API is not configured");
524
508
  },
525
509
  },
526
510
  status: {
@@ -649,6 +633,4 @@ export const wecomChannelPlugin = {
649
633
  },
650
634
  };
651
635
 
652
- export const wecomChannelPluginTesting = {
653
- buildUnsupportedMediaNotice,
654
- };
636
+ export const wecomChannelPluginTesting = {};
@@ -26,11 +26,87 @@ export const REQID_FLUSH_DEBOUNCE_MS = 1_000;
26
26
  export const PENDING_REPLY_TTL_MS = 5 * 60 * 1000;
27
27
  export const PENDING_REPLY_MAX_SIZE = 50;
28
28
 
29
+ export const IMAGE_MAX_BYTES = 10 * 1024 * 1024;
30
+ export const VIDEO_MAX_BYTES = 10 * 1024 * 1024;
31
+ export const VOICE_MAX_BYTES = 2 * 1024 * 1024;
32
+ export const FILE_MAX_BYTES = 20 * 1024 * 1024;
33
+ export const ABSOLUTE_MAX_BYTES = FILE_MAX_BYTES;
34
+
29
35
  export const DEFAULT_MEDIA_MAX_MB = 5;
30
36
  export const TEXT_CHUNK_LIMIT = 4000;
31
- export const DEFAULT_WELCOME_MESSAGE = ["你好,我是 AI 助手。", "", "可用命令:", "/new", "/compact", "/help", "/status"].join(
32
- "\n",
33
- );
37
+ export const DEFAULT_WELCOME_MESSAGES = [
38
+ [
39
+ "新的一天,元气满满!🌞",
40
+ "",
41
+ "你可以通过斜杠指令管理会话:",
42
+ "/new 新建对话",
43
+ "/compact 压缩对话",
44
+ "/help 帮助",
45
+ "/status 查看状态",
46
+ "/reasoning stream 打开思考动画",
47
+ ].join("\n"),
48
+ [
49
+ "终于唤醒我啦,我已经准备就绪!😄",
50
+ "",
51
+ "试试这些常用指令:",
52
+ "/new 新建对话",
53
+ "/compact 压缩对话",
54
+ "/help 帮助",
55
+ "/status 查看状态",
56
+ "/reasoning stream 打开思考动画",
57
+ ].join("\n"),
58
+ [
59
+ "欢迎回来,准备开始今天的工作吧!✨",
60
+ "",
61
+ "会话管理指令:",
62
+ "/new 新建对话",
63
+ "/compact 压缩对话",
64
+ "/help 帮助",
65
+ "/status 查看状态",
66
+ "/reasoning stream 打开思考动画",
67
+ ].join("\n"),
68
+ [
69
+ "嗨,我已经在线!🤖",
70
+ "",
71
+ "你可以先试试这些命令:",
72
+ "/new 新建对话",
73
+ "/compact 压缩对话",
74
+ "/help 帮助",
75
+ "/status 查看状态",
76
+ "/reasoning stream 打开思考动画",
77
+ ].join("\n"),
78
+ [
79
+ "今天也一起高效开工吧!🚀",
80
+ "",
81
+ "先来看看这些指令:",
82
+ "/new 新建对话",
83
+ "/compact 压缩对话",
84
+ "/help 帮助",
85
+ "/status 查看状态",
86
+ "/reasoning stream 打开思考动画",
87
+ ].join("\n"),
88
+ [
89
+ "叮咚,你的数字助手已就位!🎉",
90
+ "",
91
+ "常用操作给你备好了:",
92
+ "/new 新建对话",
93
+ "/compact 压缩对话",
94
+ "/help 帮助",
95
+ "/status 查看状态",
96
+ "/reasoning stream 打开思考动画",
97
+ ].join("\n"),
98
+ [
99
+ "灵感加载完成,随时可以开聊!💡",
100
+ "",
101
+ "你可以这样开始:",
102
+ "/new 新建对话",
103
+ "/compact 压缩对话",
104
+ "/help 帮助",
105
+ "/status 查看状态",
106
+ "/reasoning stream 打开思考动画",
107
+ ].join("\n"),
108
+ ];
109
+ export const DEFAULT_WELCOME_MESSAGE = DEFAULT_WELCOME_MESSAGES[0];
34
110
 
35
111
  export const MEDIA_CACHE_DIR = join(process.env.HOME || "/tmp", ".openclaw", "media", "wecom");
36
112