@openclaw/qqbot 2026.7.1-beta.1 → 2026.7.1-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,16 @@
1
- import { C as getNextMsgSeq, D as downloadFile, F as StreamInputMode, I as StreamInputState, M as getMaxUploadSize, P as StreamContentType, T as openLocalFile, a as createRawInputNotifyFn, b as stopBackgroundTokenRefresh, c as getMessageApi, d as initSender, f as onMessageSent, g as sendText, h as sendMedia, i as clearTokenCache, j as getImageMimeType, k as formatFileSize, l as getPluginUserAgent, m as sendInputNotify, n as acknowledgeInteraction, o as getAccessToken, p as registerAccount, r as buildDeliveryTarget, s as getGatewayUrl, t as accountToCreds, u as initApiConfig, x as withTokenRetry, y as startBackgroundTokenRefresh } from "./sender-BHWpE_yH.js";
1
+ import { C as getNextMsgSeq, D as downloadFile, F as StreamInputMode, I as StreamInputState, M as getMaxUploadSize, P as StreamContentType, T as openLocalFile, a as createRawInputNotifyFn, b as stopBackgroundTokenRefresh, c as getMessageApi, d as initSender, f as onMessageSent, g as sendText, h as sendMedia, i as clearTokenCache, j as getImageMimeType, k as formatFileSize, l as getPluginUserAgent, m as sendInputNotify, n as acknowledgeInteraction, o as getAccessToken, p as registerAccount, r as buildDeliveryTarget, s as getGatewayUrl, t as accountToCreds, u as initApiConfig, x as withTokenRetry, y as startBackgroundTokenRefresh } from "./sender-CjDuU-uz.js";
2
2
  import { c as getPlatformAdapter, i as normalizeOptionalString$1, n as normalizeLowercaseStringOrEmpty$1, o as readStringField, s as sanitizeFileName, t as asOptionalObjectRecord } from "./string-normalize-R_0cKO7Q.js";
3
- import { c as setBridgeLogger, o as ensurePlatformAdapter } from "./config-schema-gMW9zKvQ.js";
4
- import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-Bh0MrYvU.js";
3
+ import { c as setBridgeLogger, o as ensurePlatformAdapter } from "./config-schema-JZEf1dvB.js";
4
+ import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-D1UztsnG.js";
5
5
  import { d as resolveAccountBase } from "./config-C1qZbh0K.js";
6
- import { a as formatErrorMessage, i as formatDuration, n as debugLog, r as debugWarn, t as debugError } from "./log-SDfMMBWe.js";
7
- import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-BL0xZzWe.js";
6
+ import { a as formatErrorMessage, i as formatDuration, n as debugLog, r as debugWarn, t as debugError } from "./log-DEtcoDWe.js";
7
+ import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-DodcT_fQ.js";
8
8
  import { t as buildQQBotStateKey } from "./state-keys-CQKlAFyo.js";
9
- import { a as resolveMentionPatterns, c as getPluginVersion, d as PRIVATE_CHAT_ONLY_TEXT, f as classifyCoreCommandForGroup, i as resolveGroupSettings, l as initCommands, n as resolveGroupCommandLevelFromAccountConfig, r as resolveGroupConfig, s as getFrameworkVersion, u as matchSlashCommand } from "./group-DQQsZ6Y0.js";
10
- import { A as getHomeDir, E as DEFAULT_MEDIA_SEND_ERROR, F as isLocalPath, I as isWindows, L as normalizePath, M as getQQBotMediaDir, O as setOutboundAudioPort, P as getTempDir, _ as sendVideoMsg, b as resolveUserFacingMediaError, c as isCronReminderPayload, d as normalizeMediaTags, g as sendPhoto, h as sendDocument, j as getQQBotDataDir, k as checkSilkWasmAvailable, l as isMediaPayload, o as decodeMediaPath, r as sendMedia$1, s as encodePayloadForCron, u as parseQQBotPayload, v as sendVoice, y as resolveTrustedOutboundMediaPath } from "./outbound-Dfa2uOEp.js";
9
+ import { a as resolveMentionPatterns, c as getPluginVersion, d as PRIVATE_CHAT_ONLY_TEXT, f as classifyCoreCommandForGroup, i as resolveGroupSettings, l as initCommands, n as resolveGroupCommandLevelFromAccountConfig, r as resolveGroupConfig, s as getFrameworkVersion, u as matchSlashCommand } from "./group-o0GmovSf.js";
10
+ import { B as normalizePath, C as resolveUserFacingMediaError, F as getQQBotMediaDir, L as getTempDir, M as checkSilkWasmAvailable, N as getHomeDir, P as getQQBotDataDir, R as isLocalPath, S as resolveWorkspaceScopedLocalRoots, _ as sendVideoMsg, b as resolveOutboundMediaLocalRoots, c as isCronReminderPayload, d as normalizeMediaTags, g as sendPhoto, h as sendDocument, j as setOutboundAudioPort, k as DEFAULT_MEDIA_SEND_ERROR, l as isMediaPayload, o as decodeMediaPath, r as sendMedia$1, s as encodePayloadForCron, u as parseQQBotPayload, v as sendVoice, x as resolveWorkspacePathCandidates, y as resolveTrustedOutboundMediaPath, z as isWindows } from "./outbound-BIrfvvFJ.js";
11
11
  import { n as runWithRequestContext } from "./request-context-Bm7PTBD1.js";
12
12
  import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
13
+ import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
13
14
  import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime";
14
15
  import * as fs$1 from "node:fs";
15
16
  import fs from "node:fs";
@@ -17,7 +18,7 @@ import * as os$1 from "node:os";
17
18
  import { readProviderJsonResponse, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
18
19
  import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
19
20
  import crypto from "node:crypto";
20
- import { readRegularFileSync } from "openclaw/plugin-sdk/security-runtime";
21
+ import { readRegularFileSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
21
22
  import * as path$1 from "node:path";
22
23
  import path from "node:path";
23
24
  import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
@@ -26,6 +27,7 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
26
27
  import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
27
28
  import WebSocket from "ws";
28
29
  import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
30
+ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
29
31
  import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
30
32
  import { SILENT_REPLY_TOKEN, isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking";
31
33
  import { Buffer as Buffer$1 } from "node:buffer";
@@ -458,7 +460,7 @@ async function trySlashCommand(msg, ctx) {
458
460
  const contentLower = content.toLowerCase();
459
461
  if (URGENT_COMMANDS.some((cmd) => contentLower === cmd.toLowerCase() || contentLower.startsWith(cmd.toLowerCase() + " "))) {
460
462
  if (isGroup && !commandAuthorized) return "enqueue";
461
- log?.info(`Urgent command detected: ${content.slice(0, 20)}`);
463
+ log?.info(`Urgent command detected: ${truncateUtf16Safe(content, 20)}`);
462
464
  return "urgent";
463
465
  }
464
466
  const receivedAt = Date.now();
@@ -1154,7 +1156,7 @@ function createMessageQueue(ctx) {
1154
1156
  for (const m of batch) if (isSlashCommand(m)) commands.push(m);
1155
1157
  else normal.push(m);
1156
1158
  for (const cmd of commands) {
1157
- log?.debug?.(`Processing command independently for ${peerId}: ${(cmd.content ?? "").trim().slice(0, 50)}`);
1159
+ log?.debug?.(`Processing command independently for ${peerId}: ${truncateUtf16Safe((cmd.content ?? "").trim(), 50)}`);
1158
1160
  await processOne(cmd, peerId, "Command processor");
1159
1161
  }
1160
1162
  if (normal.length > 0) {
@@ -1487,6 +1489,10 @@ var GatewayConnection = class {
1487
1489
  const { account: _account, log } = this.ctx;
1488
1490
  if (this.isAborted || this.reconnect.isExhausted()) {
1489
1491
  log?.error(`Max reconnect attempts reached or aborted`);
1492
+ if (!this.isAborted) this.ctx.onDisconnected?.({
1493
+ reason: "reconnect attempts exhausted",
1494
+ fatal: true
1495
+ });
1490
1496
  return;
1491
1497
  }
1492
1498
  if (this.reconnectTimer) {
@@ -1578,11 +1584,20 @@ var GatewayConnection = class {
1578
1584
  }
1579
1585
  case GatewayOp.HEARTBEAT_ACK: break;
1580
1586
  case GatewayOp.RECONNECT:
1587
+ this.ctx.onDisconnected?.({
1588
+ reason: "server requested reconnect",
1589
+ fatal: false
1590
+ });
1581
1591
  this.cleanup();
1582
1592
  this.scheduleReconnect();
1583
1593
  break;
1584
- case GatewayOp.INVALID_SESSION:
1585
- if (!d) {
1594
+ case GatewayOp.INVALID_SESSION: {
1595
+ const canResume = d;
1596
+ this.ctx.onDisconnected?.({
1597
+ reason: canResume ? "session resume rejected" : "session invalidated",
1598
+ fatal: false
1599
+ });
1600
+ if (!canResume) {
1586
1601
  this.sessionId = null;
1587
1602
  this.lastSeq = null;
1588
1603
  clearSession(account.accountId);
@@ -1591,6 +1606,7 @@ var GatewayConnection = class {
1591
1606
  this.cleanup();
1592
1607
  this.scheduleReconnect(3e3);
1593
1608
  break;
1609
+ }
1594
1610
  }
1595
1611
  } catch (err) {
1596
1612
  log?.error(`Message parse error: ${err instanceof Error ? err.message : String(err)}`);
@@ -1598,6 +1614,7 @@ var GatewayConnection = class {
1598
1614
  });
1599
1615
  ws.on("close", (code, reason) => {
1600
1616
  log?.info(`WebSocket closed: ${code} ${reason.toString()}`);
1617
+ if (this.currentWs !== ws) return;
1601
1618
  this.isConnecting = false;
1602
1619
  this.handleClose(code);
1603
1620
  });
@@ -1649,6 +1666,10 @@ var GatewayConnection = class {
1649
1666
  }
1650
1667
  if (action.refreshToken) this.shouldRefreshToken = true;
1651
1668
  this.cleanup();
1669
+ if (!this.isAborted) this.ctx.onDisconnected?.({
1670
+ reason: action.reason,
1671
+ fatal: action.fatal
1672
+ });
1652
1673
  if (action.fatal) return;
1653
1674
  if (action.shouldReconnect) this.scheduleReconnect(action.reconnectDelay);
1654
1675
  }
@@ -1716,7 +1737,7 @@ async function transcribeAudio(audioPath, cfg) {
1716
1737
  try {
1717
1738
  if (!resp.ok) {
1718
1739
  const detail = await readResponseTextLimited(resp, STT_ERROR_BODY_LIMIT_BYTES).catch(() => "");
1719
- throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
1740
+ throw new Error(`STT failed (HTTP ${resp.status}): ${truncateUtf16Safe(detail, 300)}`);
1720
1741
  }
1721
1742
  return normalizeOptionalString$1((await readProviderJsonResponse(resp, "qqbot.stt")).text) ?? null;
1722
1743
  } finally {
@@ -1924,7 +1945,7 @@ async function processVoiceAttachment(localPath, audioPathInput, att, asrReferTe
1924
1945
  try {
1925
1946
  const transcript = await transcribeAudio(audioPath, cfg);
1926
1947
  if (transcript) {
1927
- log?.debug?.(`STT transcript: ${transcript.slice(0, 100)}...`);
1948
+ log?.debug?.(`STT transcript: ${truncateUtf16Safe(transcript, 100)}...`);
1928
1949
  return {
1929
1950
  localPath,
1930
1951
  type: "voice",
@@ -2670,7 +2691,7 @@ async function resolveQuote(event, deps) {
2670
2691
  cfg: account.config,
2671
2692
  log
2672
2693
  }, attachmentProcessor);
2673
- log?.debug?.(`Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${(refBody ?? "").slice(0, 80)}..."`);
2694
+ log?.debug?.(`Quote detected via msg_elements[0] (cache miss): id=${event.refMsgIdx}, content="${truncateUtf16Safe(refBody ?? "", 80)}..."`);
2674
2695
  return {
2675
2696
  id: event.refMsgIdx,
2676
2697
  body: refBody || void 0,
@@ -3296,17 +3317,39 @@ function hasQQBotImageSize(markdownImage) {
3296
3317
  }
3297
3318
  //#endregion
3298
3319
  //#region extensions/qqbot/src/engine/messaging/outbound-deliver.ts
3320
+ /**
3321
+ * Outbound delivery helpers — core/ version.
3322
+ *
3323
+ * Uses the unified `sender.ts` business function layer for all text and
3324
+ * image sending. Media sends (photo/voice/video/file) are injected via
3325
+ * `DeliverDeps.mediaSender`.
3326
+ */
3299
3327
  /** Maximum text length for a single QQ Bot message. */
3300
3328
  const TEXT_CHUNK_LIMIT = 5e3;
3301
- function resolveMediaTargetContext(event, account) {
3329
+ function resolveMediaTargetContext(event, actx) {
3330
+ const { account } = actx;
3302
3331
  return {
3303
3332
  targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : event.type === "dm" ? "dm" : "channel",
3304
3333
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.type === "dm" ? event.guildId : event.channelId,
3305
3334
  account,
3306
- replyToId: event.messageId
3335
+ replyToId: event.messageId,
3336
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3337
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3338
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}
3307
3339
  };
3308
3340
  }
3341
+ function isHttpUrl(value) {
3342
+ return value.startsWith("http://") || value.startsWith("https://");
3343
+ }
3344
+ function isImageDataUrl(value) {
3345
+ return value.startsWith("data:image/");
3346
+ }
3347
+ function isBareRelativeMediaPath(value) {
3348
+ const trimmed = value.trim();
3349
+ return Boolean(trimmed) && !trimmed.startsWith("#") && !trimmed.startsWith("//") && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed);
3350
+ }
3309
3351
  async function autoMediaBatch(params) {
3352
+ let sentCount = 0;
3310
3353
  for (const mediaUrl of params.mediaUrls) try {
3311
3354
  const result = await params.mediaSender.sendMedia({
3312
3355
  to: params.qualifiedTarget,
@@ -3314,17 +3357,22 @@ async function autoMediaBatch(params) {
3314
3357
  mediaUrl,
3315
3358
  accountId: params.account.accountId,
3316
3359
  replyToId: params.replyToId,
3317
- account: params.account
3360
+ account: params.account,
3361
+ ...params.mediaAccess ? { mediaAccess: params.mediaAccess } : {},
3362
+ ...params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {},
3363
+ ...params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}
3318
3364
  });
3319
3365
  if (result.error) {
3320
3366
  params.log?.error(params.onResultError(mediaUrl, result.error));
3321
3367
  continue;
3322
3368
  }
3369
+ sentCount++;
3323
3370
  const successMessage = params.onSuccess?.(mediaUrl);
3324
3371
  if (successMessage) params.log?.info(successMessage);
3325
3372
  } catch (err) {
3326
3373
  params.log?.error(params.onThrownError(mediaUrl, formatErrorMessage(err)));
3327
3374
  }
3375
+ return sentCount;
3328
3376
  }
3329
3377
  async function sendTextChunkToTarget(params) {
3330
3378
  const { account, event, text, consumeQuoteRef, allowDm, forcePlainText } = params;
@@ -3347,7 +3395,7 @@ async function sendTextChunks(text, event, actx, sendWithRetry, consumeQuoteRef,
3347
3395
  consumeQuoteRef,
3348
3396
  allowDm: true,
3349
3397
  log,
3350
- onSuccess: (chunk) => `Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`,
3398
+ onSuccess: (chunk) => `Sent text chunk (${chunk.length}/${text.length} chars): ${truncateUtf16Safe(chunk, 50)}...`,
3351
3399
  onError: (err) => `Failed to send text chunk: ${formatErrorMessage(err)}`
3352
3400
  });
3353
3401
  }
@@ -3364,7 +3412,7 @@ async function sendTextOnlyReply(text, event, actx, sendWithRetry, consumeQuoteR
3364
3412
  allowDm: true,
3365
3413
  forcePlainText: true,
3366
3414
  log,
3367
- onSuccess: (chunk) => `Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${chunk.slice(0, 50)}...`,
3415
+ onSuccess: (chunk) => `Sent text-only chunk (${chunk.length}/${safeText.length} chars): ${truncateUtf16Safe(chunk, 50)}...`,
3368
3416
  onError: (err) => `Failed to send text-only chunk: ${formatErrorMessage(err)}`
3369
3417
  });
3370
3418
  }
@@ -3390,16 +3438,18 @@ async function sendWithResultLogging(params) {
3390
3438
  const result = await params.run();
3391
3439
  if (result.error) {
3392
3440
  params.log?.error(params.onError(result.error));
3393
- return;
3441
+ return false;
3394
3442
  }
3395
3443
  const successMessage = params.onSuccess?.();
3396
3444
  if (successMessage) params.log?.info(successMessage);
3445
+ return true;
3397
3446
  } catch (err) {
3398
3447
  params.log?.error(params.onError(formatErrorMessage(err)));
3448
+ return false;
3399
3449
  }
3400
3450
  }
3401
3451
  async function sendPhotoWithLogging(params) {
3402
- await sendWithResultLogging({
3452
+ return await sendWithResultLogging({
3403
3453
  run: async () => await params.mediaSender.sendPhoto(params.target, params.imageUrl),
3404
3454
  log: params.log,
3405
3455
  onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : void 0,
@@ -3431,9 +3481,14 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3431
3481
  });
3432
3482
  }, voiceTimeout);
3433
3483
  })]);
3434
- if (result.error) log?.error(`sendVoice error: ${result.error}`);
3484
+ if (result.error) {
3485
+ log?.error(`sendVoice error: ${result.error}`);
3486
+ return false;
3487
+ }
3488
+ return true;
3435
3489
  } catch (err) {
3436
3490
  log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`);
3491
+ return false;
3437
3492
  }
3438
3493
  }
3439
3494
  /**
@@ -3489,38 +3544,62 @@ async function parseAndSendMediaTags(replyText, event, actx, sendWithRetry, cons
3489
3544
  content: filterInternalMarkers(textAfter)
3490
3545
  });
3491
3546
  log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
3492
- const mediaTarget = resolveMediaTargetContext(event, account);
3493
- for (const item of sendQueue) if (item.type === "text") await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps);
3494
- else if (item.type === "image") await sendPhotoWithLogging({
3495
- target: mediaTarget,
3496
- imageUrl: item.content,
3497
- mediaSender: deps.mediaSender,
3498
- log,
3499
- onError: (error) => `sendPhoto error: ${error}`
3500
- });
3501
- else if (item.type === "voice") await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log);
3502
- else if (item.type === "video") await sendWithResultLogging({
3503
- run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content),
3504
- log,
3505
- onError: (error) => `sendVideoMsg error: ${error}`
3506
- });
3507
- else if (item.type === "file") await sendWithResultLogging({
3508
- run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content),
3509
- log,
3510
- onError: (error) => `sendDocument error: ${error}`
3511
- });
3512
- else if (item.type === "media") await sendWithResultLogging({
3513
- run: async () => await deps.mediaSender.sendMedia({
3514
- to: actx.qualifiedTarget,
3515
- text: "",
3516
- mediaUrl: item.content,
3517
- accountId: account.accountId,
3518
- replyToId: event.messageId,
3519
- account
3520
- }),
3521
- log,
3522
- onError: (error) => `sendMedia(auto) error: ${error}`
3523
- });
3547
+ const mediaTarget = resolveMediaTargetContext(event, actx);
3548
+ let deliveredVisibleOutput = false;
3549
+ for (const item of sendQueue) if (item.type === "text") {
3550
+ await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps);
3551
+ if (item.content.trim()) deliveredVisibleOutput = true;
3552
+ } else if (item.type === "image") {
3553
+ const sent = await sendPhotoWithLogging({
3554
+ target: mediaTarget,
3555
+ imageUrl: item.content,
3556
+ mediaSender: deps.mediaSender,
3557
+ log,
3558
+ onError: (error) => `sendPhoto error: ${error}`
3559
+ });
3560
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3561
+ } else if (item.type === "voice") {
3562
+ const sent = await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log);
3563
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3564
+ } else if (item.type === "video") {
3565
+ const sent = await sendWithResultLogging({
3566
+ run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content),
3567
+ log,
3568
+ onError: (error) => `sendVideoMsg error: ${error}`
3569
+ });
3570
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3571
+ } else if (item.type === "file") {
3572
+ const sent = await sendWithResultLogging({
3573
+ run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content),
3574
+ log,
3575
+ onError: (error) => `sendDocument error: ${error}`
3576
+ });
3577
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3578
+ } else if (item.type === "media") {
3579
+ const sent = await sendWithResultLogging({
3580
+ run: async () => await deps.mediaSender.sendMedia({
3581
+ to: actx.qualifiedTarget,
3582
+ text: "",
3583
+ mediaUrl: item.content,
3584
+ accountId: account.accountId,
3585
+ replyToId: event.messageId,
3586
+ account,
3587
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3588
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3589
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}
3590
+ }),
3591
+ log,
3592
+ onError: (error) => `sendMedia(auto) error: ${error}`
3593
+ });
3594
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3595
+ }
3596
+ if (!deliveredVisibleOutput) {
3597
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3598
+ return {
3599
+ handled: true,
3600
+ normalizedText: ""
3601
+ };
3602
+ }
3524
3603
  return {
3525
3604
  handled: true,
3526
3605
  normalizedText: text
@@ -3534,18 +3613,18 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3534
3613
  const { account, qualifiedTarget, log } = actx;
3535
3614
  const collectedImageUrls = [];
3536
3615
  const localMediaToSend = [];
3537
- const collectImageUrl = (url) => {
3616
+ const collectImageUrl = (url, allowBareRelativeMedia = false) => {
3538
3617
  if (!url) return false;
3539
- const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
3540
- const isDataUrl = url.startsWith("data:image/");
3541
- if (isHttpUrl || isDataUrl) {
3618
+ const isRemoteHttpUrl = isHttpUrl(url);
3619
+ const isDataUrl = isImageDataUrl(url);
3620
+ if (isRemoteHttpUrl || isDataUrl) {
3542
3621
  if (!collectedImageUrls.includes(url)) {
3543
3622
  collectedImageUrls.push(url);
3544
- log?.debug?.(`Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`);
3623
+ log?.debug?.(`Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : truncateUtf16Safe(url, 80) + "..."}`);
3545
3624
  }
3546
3625
  return true;
3547
3626
  }
3548
- if (isLocalPath(url)) {
3627
+ if (isLocalPath(url) || allowBareRelativeMedia && isBareRelativeMediaPath(url)) {
3549
3628
  if (!localMediaToSend.includes(url)) {
3550
3629
  localMediaToSend.push(url);
3551
3630
  log?.debug?.(`Collected local media for auto-routing: ${url}`);
@@ -3554,15 +3633,15 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3554
3633
  }
3555
3634
  return false;
3556
3635
  };
3557
- if (payload.mediaUrls?.length) for (const url of payload.mediaUrls) collectImageUrl(url);
3558
- if (payload.mediaUrl) collectImageUrl(payload.mediaUrl);
3636
+ if (payload.mediaUrls?.length) for (const url of payload.mediaUrls) collectImageUrl(url, true);
3637
+ if (payload.mediaUrl) collectImageUrl(payload.mediaUrl, true);
3559
3638
  const mdMatches = [...replyText.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gi)];
3560
3639
  for (const m of mdMatches) {
3561
3640
  const url = m[2]?.trim();
3562
3641
  if (url && !collectedImageUrls.includes(url)) {
3563
- if (url.startsWith("http://") || url.startsWith("https://")) {
3642
+ if (isHttpUrl(url)) {
3564
3643
  collectedImageUrls.push(url);
3565
- log?.debug?.(`Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
3644
+ log?.debug?.(`Extracted HTTP image from markdown: ${truncateUtf16Safe(url, 80)}...`);
3566
3645
  } else if (isLocalPath(url)) {
3567
3646
  if (!localMediaToSend.includes(url)) {
3568
3647
  localMediaToSend.push(url);
@@ -3576,7 +3655,7 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3576
3655
  const url = m[1];
3577
3656
  if (url && !collectedImageUrls.includes(url)) {
3578
3657
  collectedImageUrls.push(url);
3579
- log?.debug?.(`Extracted bare image URL: ${url.slice(0, 80)}...`);
3658
+ log?.debug?.(`Extracted bare image URL: ${truncateUtf16Safe(url, 80)}...`);
3580
3659
  }
3581
3660
  }
3582
3661
  const useMarkdown = account.markdownSupport;
@@ -3584,37 +3663,51 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3584
3663
  let textWithoutImages = filterInternalMarkers(replyText);
3585
3664
  for (const m of mdMatches) {
3586
3665
  const url = m[2]?.trim();
3587
- if (url && !url.startsWith("http://") && !url.startsWith("https://") && !isLocalPath(url)) textWithoutImages = textWithoutImages.replace(m[0], "").trim();
3666
+ if (url && !isHttpUrl(url) && !isLocalPath(url)) textWithoutImages = textWithoutImages.replace(m[0], "").trim();
3588
3667
  }
3589
3668
  if (useMarkdown) await sendMarkdownReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps);
3590
3669
  else await sendPlainTextReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps);
3670
+ const hasVisibleTextOrInlineImage = Boolean(textWithoutImages.trim() || collectedImageUrls.length > 0);
3671
+ let sentMediaCount = 0;
3672
+ let sentFailureFallback = false;
3591
3673
  if (localMediaToSend.length > 0) {
3592
3674
  log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
3593
- await autoMediaBatch({
3675
+ sentMediaCount += await autoMediaBatch({
3594
3676
  qualifiedTarget,
3595
3677
  account,
3596
3678
  replyToId: event.messageId,
3597
3679
  mediaUrls: localMediaToSend,
3598
3680
  mediaSender: deps.mediaSender,
3681
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3682
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3683
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {},
3599
3684
  log,
3600
3685
  onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`,
3601
3686
  onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`,
3602
3687
  onThrownError: (mediaPath, error) => `sendMedia(auto) failed for ${mediaPath}: ${error}`
3603
3688
  });
3689
+ if (!hasVisibleTextOrInlineImage && sentMediaCount === 0) {
3690
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3691
+ sentFailureFallback = true;
3692
+ }
3604
3693
  }
3605
3694
  if (toolMediaUrls.length > 0) {
3606
3695
  log?.debug?.(`Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
3607
- await autoMediaBatch({
3696
+ sentMediaCount += await autoMediaBatch({
3608
3697
  qualifiedTarget,
3609
3698
  account,
3610
3699
  replyToId: event.messageId,
3611
3700
  mediaUrls: toolMediaUrls,
3612
3701
  mediaSender: deps.mediaSender,
3702
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3703
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3704
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {},
3613
3705
  log,
3614
- onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`,
3706
+ onSuccess: (mediaUrl) => `Forwarded tool media: ${truncateUtf16Safe(mediaUrl, 80)}...`,
3615
3707
  onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`,
3616
3708
  onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`
3617
3709
  });
3710
+ if (!hasVisibleTextOrInlineImage && sentMediaCount === 0 && !sentFailureFallback) await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3618
3711
  toolMediaUrls.length = 0;
3619
3712
  }
3620
3713
  }
@@ -3622,8 +3715,8 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3622
3715
  const { account, log } = actx;
3623
3716
  const httpImageUrls = [];
3624
3717
  const base64ImageUrls = [];
3625
- for (const url of imageUrls) if (url.startsWith("data:image/")) base64ImageUrls.push(url);
3626
- else if (url.startsWith("http://") || url.startsWith("https://")) httpImageUrls.push(url);
3718
+ for (const url of imageUrls) if (isImageDataUrl(url)) base64ImageUrls.push(url);
3719
+ else if (isHttpUrl(url)) httpImageUrls.push(url);
3627
3720
  log?.debug?.(`Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
3628
3721
  if (base64ImageUrls.length > 0) {
3629
3722
  log?.debug?.(`Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
@@ -3650,7 +3743,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3650
3743
  for (const url of httpImageUrls) if (!existingMdUrls.has(url)) try {
3651
3744
  const size = await getImageSize(url);
3652
3745
  imagesToAppend.push(formatQQBotMarkdownImage(url, size));
3653
- log?.debug?.(`Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`);
3746
+ log?.debug?.(`Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${truncateUtf16Safe(url, 60)}...`);
3654
3747
  } catch (err) {
3655
3748
  log?.debug?.(`Failed to get image size, using default: ${formatErrorMessage(err)}`);
3656
3749
  imagesToAppend.push(formatQQBotMarkdownImage(url, null));
@@ -3659,10 +3752,10 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3659
3752
  for (const m of mdMatches) {
3660
3753
  const fullMatch = m[0];
3661
3754
  const imgUrl = m[2];
3662
- if ((imgUrl.startsWith("http://") || imgUrl.startsWith("https://")) && !hasQQBotImageSize(fullMatch)) try {
3755
+ if (isHttpUrl(imgUrl) && !hasQQBotImageSize(fullMatch)) try {
3663
3756
  const size = await getImageSize(imgUrl);
3664
3757
  result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
3665
- log?.debug?.(`Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`);
3758
+ log?.debug?.(`Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${truncateUtf16Safe(imgUrl, 60)}...`);
3666
3759
  } catch (err) {
3667
3760
  log?.debug?.(`Failed to get image size for existing md, using default: ${formatErrorMessage(err)}`);
3668
3761
  result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null));
@@ -3687,7 +3780,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3687
3780
  }
3688
3781
  async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps) {
3689
3782
  const { account, log } = actx;
3690
- const imgMediaTarget = resolveMediaTargetContext(event, account);
3783
+ const imgMediaTarget = resolveMediaTargetContext(event, actx);
3691
3784
  let result = textWithoutImages;
3692
3785
  for (const m of mdMatches) result = result.replace(m[0], "").trim();
3693
3786
  for (const m of bareUrlMatches) result = result.replace(m[0], "").trim();
@@ -3698,7 +3791,7 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
3698
3791
  imageUrl,
3699
3792
  mediaSender: deps.mediaSender,
3700
3793
  log,
3701
- onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${nextImageUrl.slice(0, 80)}...`,
3794
+ onSuccess: (nextImageUrl) => `Sent image via sendPhoto: ${truncateUtf16Safe(nextImageUrl, 80)}...`,
3702
3795
  onError: (error) => `Failed to send image: ${error}`
3703
3796
  });
3704
3797
  if (result.trim()) await sendTextChunksWithRetry({
@@ -3798,13 +3891,27 @@ function formatMediaTypeLabel(mediaType) {
3798
3891
  return mediaType[0].toUpperCase() + mediaType.slice(1);
3799
3892
  }
3800
3893
  function validateStructuredPayloadLocalPath(ctx, payloadPath, mediaType) {
3801
- const allowedPath = resolveTrustedOutboundMediaPath(payloadPath);
3802
- if (allowedPath) return allowedPath;
3894
+ const candidatePaths = resolveWorkspacePathCandidates(normalizePath(payloadPath), ctx.mediaAccess?.workspaceDir);
3895
+ const localRoots = resolveWorkspaceScopedLocalRoots(resolveOutboundMediaLocalRoots(ctx), ctx.mediaAccess?.workspaceDir);
3896
+ const allowMissingHostRead = Boolean(resolveStructuredPayloadReadFile(ctx));
3897
+ for (const candidatePath of candidatePaths) {
3898
+ const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { allowMissing: allowMissingHostRead });
3899
+ if (allowedPath) return allowedPath;
3900
+ if (localRoots) {
3901
+ const scopedPath = resolveLocalPathFromRootsSync({
3902
+ filePath: candidatePath,
3903
+ roots: localRoots,
3904
+ label: "QQ Bot local roots",
3905
+ allowMissing: allowMissingHostRead
3906
+ })?.path;
3907
+ if (scopedPath) return scopedPath;
3908
+ }
3909
+ }
3803
3910
  ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`);
3804
3911
  return null;
3805
3912
  }
3806
3913
  function isRemoteHttpUrl(p) {
3807
- return p.startsWith("http://") || p.startsWith("https://");
3914
+ return /^https?:\/\//i.test(p);
3808
3915
  }
3809
3916
  function isInlineImageDataUrl(p) {
3810
3917
  return /^data:image\/[^;]+;base64,/i.test(p);
@@ -3825,7 +3932,7 @@ function resolveStructuredPayloadPath(ctx, payload, mediaType) {
3825
3932
  };
3826
3933
  }
3827
3934
  function sanitizeForLog(value, maxLen = 200) {
3828
- return value.replace(/[\r\n\t]/g, " ").replaceAll("\0", " ").slice(0, maxLen);
3935
+ return truncateUtf16Safe(value.replace(/[\r\n\t]/g, " ").replaceAll("\0", " "), maxLen);
3829
3936
  }
3830
3937
  function describeMediaTargetForLog(pathValue, isHttpUrl) {
3831
3938
  if (!isHttpUrl) return "<local-file>";
@@ -3839,14 +3946,48 @@ function describeMediaTargetForLog(pathValue, isHttpUrl) {
3839
3946
  return "<invalid-url>";
3840
3947
  }
3841
3948
  }
3842
- /**
3843
- * Read a local file into memory for image base64 inlining.
3844
- *
3845
- * Non-image media (video / file) should pass `source: { localPath }` to
3846
- * `sender.sendMedia` directly — the sender pipeline handles chunked
3847
- * routing once this function validates the per-type ceiling.
3848
- */
3849
- async function readLocalFileForInlineBase64(filePath, fileType) {
3949
+ function resolveStructuredPayloadReadFile(ctx) {
3950
+ return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile;
3951
+ }
3952
+ function assertBufferWithinTypeLimit(buffer, fileType) {
3953
+ const maxSize = getMaxUploadSize(fileType);
3954
+ if (buffer.length > maxSize) throw new Error(`File is too large (${formatFileSize(buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
3955
+ }
3956
+ function imageBufferMatchesMime(buffer, mimeType) {
3957
+ if (mimeType === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([
3958
+ 137,
3959
+ 80,
3960
+ 78,
3961
+ 71,
3962
+ 13,
3963
+ 10,
3964
+ 26,
3965
+ 10
3966
+ ]));
3967
+ if (mimeType === "image/jpeg") return buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255;
3968
+ if (mimeType === "image/gif") {
3969
+ const header = buffer.subarray(0, 6).toString("ascii");
3970
+ return header === "GIF87a" || header === "GIF89a";
3971
+ }
3972
+ if (mimeType === "image/webp") return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
3973
+ if (mimeType === "image/bmp") return buffer.subarray(0, 2).toString("ascii") === "BM";
3974
+ return false;
3975
+ }
3976
+ async function readLocalFileForInlineBase64(ctx, filePath, fileType) {
3977
+ const mediaReadFile = resolveStructuredPayloadReadFile(ctx);
3978
+ if (mediaReadFile) {
3979
+ let buffer = null;
3980
+ try {
3981
+ buffer = await mediaReadFile(filePath);
3982
+ } catch (err) {
3983
+ ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`);
3984
+ }
3985
+ if (buffer !== null) {
3986
+ assertBufferWithinTypeLimit(buffer, fileType);
3987
+ if (buffer.length === 0) throw new Error(`File is empty: ${filePath}`);
3988
+ return buffer;
3989
+ }
3990
+ }
3850
3991
  const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
3851
3992
  try {
3852
3993
  return await opened.handle.readFile();
@@ -3854,15 +3995,20 @@ async function readLocalFileForInlineBase64(filePath, fileType) {
3854
3995
  await opened.close();
3855
3996
  }
3856
3997
  }
3857
- /**
3858
- * Enforce the per-{@link MediaFileType} upload ceiling before handing a
3859
- * local path to `sender.sendMedia`. The sender's internal `normalizeSource`
3860
- * uses an unlimited cap so it can accept whatever size the policy layer
3861
- * (outbound / reply-dispatcher) approves; the policy gate lives here.
3862
- *
3863
- * Returns the validated byte size. Throws via {@link openLocalFile} with a
3864
- * human-readable "File is too large" message when exceeding the ceiling.
3865
- */
3998
+ async function readPayloadFileBuffer(ctx, filePath, fileType) {
3999
+ const mediaReadFile = resolveStructuredPayloadReadFile(ctx);
4000
+ if (!mediaReadFile) return null;
4001
+ let buffer;
4002
+ try {
4003
+ buffer = await mediaReadFile(filePath);
4004
+ } catch (err) {
4005
+ ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`);
4006
+ return null;
4007
+ }
4008
+ assertBufferWithinTypeLimit(buffer, fileType);
4009
+ if (buffer.length === 0) throw new Error(`File is empty: ${filePath}`);
4010
+ return buffer;
4011
+ }
3866
4012
  async function assertLocalFileWithinTypeLimit(filePath, fileType) {
3867
4013
  const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
3868
4014
  try {
@@ -3884,15 +4030,15 @@ async function handleImagePayload(ctx, payload) {
3884
4030
  if (!imageUrl) return;
3885
4031
  const originalImagePath = payload.source === "file" ? imageUrl : void 0;
3886
4032
  if (payload.source === "file") try {
3887
- const fileBuffer = await readLocalFileForInlineBase64(imageUrl, 1);
3888
- const base64Data = fileBuffer.toString("base64");
4033
+ const fileBuffer = await readLocalFileForInlineBase64(ctx, imageUrl, 1);
3889
4034
  const mimeType = getImageMimeType(imageUrl);
3890
4035
  if (!mimeType) {
3891
4036
  const ext = normalizeLowercaseStringOrEmpty$1(path.extname(imageUrl));
3892
4037
  log?.error(`Unsupported image format: ${ext}`);
3893
4038
  return;
3894
4039
  }
3895
- imageUrl = `data:${mimeType};base64,${base64Data}`;
4040
+ if (!imageBufferMatchesMime(fileBuffer, mimeType)) throw new Error(`File is not an image: ${imageUrl}`);
4041
+ imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
3896
4042
  log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
3897
4043
  } catch (readErr) {
3898
4044
  log?.error(`Failed to read local image: ${readErr instanceof Error ? readErr.message : JSON.stringify(readErr)}`);
@@ -3910,8 +4056,8 @@ async function handleImagePayload(ctx, payload) {
3910
4056
  msgId: target.messageId,
3911
4057
  localPathForMeta: originalImagePath
3912
4058
  });
3913
- else if (deliveryTarget.type === "dm") await sendText(deliveryTarget, `![](${payload.path})`, creds, { msgId: target.messageId });
3914
- else await sendText(deliveryTarget, `![](${payload.path})`, creds, { msgId: target.messageId });
4059
+ else if (deliveryTarget.type === "dm") await sendText(deliveryTarget, `![](${imageUrl})`, creds, { msgId: target.messageId });
4060
+ else await sendText(deliveryTarget, `![](${imageUrl})`, creds, { msgId: target.messageId });
3915
4061
  }, log, account.accountId);
3916
4062
  log?.debug?.(`Sent image via media payload`);
3917
4063
  if (payload.caption) await sendTextToTarget(ctx, payload.caption);
@@ -3934,7 +4080,7 @@ async function sendTextAsVoiceReply(ctx, text, deps) {
3934
4080
  log?.error(`Voice missing text`);
3935
4081
  return false;
3936
4082
  }
3937
- log?.debug?.(`TTS: "${ttsText.slice(0, 50)}..."`);
4083
+ log?.debug?.(`TTS: "${truncateUtf16Safe(ttsText, 50)}..."`);
3938
4084
  const ttsResult = await deps.tts.textToSpeech({
3939
4085
  text: ttsText,
3940
4086
  cfg,
@@ -4001,6 +4147,20 @@ async function handleVideoPayload(ctx, payload) {
4001
4147
  msgId: target.messageId
4002
4148
  });
4003
4149
  else {
4150
+ const payloadBuffer = await readPayloadFileBuffer(ctx, videoPath, 2);
4151
+ if (payloadBuffer) {
4152
+ await sendMedia({
4153
+ target: deliveryTarget,
4154
+ creds,
4155
+ kind: "video",
4156
+ source: {
4157
+ buffer: payloadBuffer,
4158
+ fileName: sanitizeFileName(path.basename(videoPath))
4159
+ },
4160
+ msgId: target.messageId
4161
+ });
4162
+ return;
4163
+ }
4004
4164
  const size = await assertLocalFileWithinTypeLimit(videoPath, 2);
4005
4165
  log?.debug?.(`Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`);
4006
4166
  await sendMedia({
@@ -4044,6 +4204,21 @@ async function handleFilePayload(ctx, payload) {
4044
4204
  fileName
4045
4205
  });
4046
4206
  else {
4207
+ const payloadBuffer = await readPayloadFileBuffer(ctx, filePath, 4);
4208
+ if (payloadBuffer) {
4209
+ await sendMedia({
4210
+ target: deliveryTarget,
4211
+ creds,
4212
+ kind: "file",
4213
+ source: {
4214
+ buffer: payloadBuffer,
4215
+ fileName
4216
+ },
4217
+ msgId: target.messageId,
4218
+ fileName
4219
+ });
4220
+ return;
4221
+ }
4047
4222
  const size = await assertLocalFileWithinTypeLimit(filePath, 4);
4048
4223
  log?.debug?.(`File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`);
4049
4224
  await sendMedia({
@@ -4064,6 +4239,12 @@ async function handleFilePayload(ctx, payload) {
4064
4239
  }
4065
4240
  //#endregion
4066
4241
  //#region extensions/qqbot/src/engine/messaging/streaming-media-send.ts
4242
+ /**
4243
+ * 富媒体标签解析与发送队列
4244
+ *
4245
+ * 提供媒体标签(qqimg / qqvoice / qqvideo / qqfile / qqmedia)的检测、
4246
+ * 拆分、路径编码修复,以及统一的发送队列执行器。
4247
+ */
4067
4248
  function formatStreamSendErr(e) {
4068
4249
  return e instanceof Error ? e.message : String(e);
4069
4250
  }
@@ -4186,7 +4367,7 @@ function findFirstClosedMediaTag(text, log) {
4186
4367
  * 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
4187
4368
  */
4188
4369
  async function executeSendQueue(queue, ctx, options = {}) {
4189
- const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
4370
+ const { mediaTarget, qualifiedTarget, account, replyToId, log, mediaAccess, mediaLocalRoots, mediaReadFile } = ctx;
4190
4371
  const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
4191
4372
  /** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
4192
4373
  const sendFallbackText = async (errorMsg) => {
@@ -4210,7 +4391,7 @@ async function executeSendQueue(queue, ctx, options = {}) {
4210
4391
  else log?.info(`${prefix} executeSendQueue: no onSendText handler, skipping text`);
4211
4392
  continue;
4212
4393
  }
4213
- log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${item.content.slice(0, 80)}...`);
4394
+ log?.info(`${prefix} executeSendQueue: sending ${item.type}: ${truncateUtf16Safe(item.content, 80)}...`);
4214
4395
  if (item.type === "image") {
4215
4396
  const result = await sendPhoto(mediaTarget, item.content);
4216
4397
  if (result.error) {
@@ -4255,7 +4436,10 @@ async function executeSendQueue(queue, ctx, options = {}) {
4255
4436
  mediaUrl: item.content,
4256
4437
  accountId: account.accountId,
4257
4438
  replyToId,
4258
- account
4439
+ account,
4440
+ ...mediaAccess ? { mediaAccess } : {},
4441
+ ...mediaLocalRoots ? { mediaLocalRoots } : {},
4442
+ ...mediaReadFile ? { mediaReadFile } : {}
4259
4443
  });
4260
4444
  if (result.error) {
4261
4445
  log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
@@ -4414,11 +4598,11 @@ const THROTTLE_CONSTANTS = {
4414
4598
  BATCH_AFTER_GAP_MS: 300
4415
4599
  };
4416
4600
  /** 终态集合 */
4417
- const TERMINAL_PHASES = new Set(["completed", "aborted"]);
4601
+ const TERMINAL_PHASES = /* @__PURE__ */ new Set(["completed", "aborted"]);
4418
4602
  /** 允许的状态转换 */
4419
4603
  const PHASE_TRANSITIONS = {
4420
- idle: new Set(["streaming", "aborted"]),
4421
- streaming: new Set([
4604
+ idle: /* @__PURE__ */ new Set(["streaming", "aborted"]),
4605
+ streaming: /* @__PURE__ */ new Set([
4422
4606
  "idle",
4423
4607
  "completed",
4424
4608
  "aborted"
@@ -4701,7 +4885,7 @@ var StreamingController = class {
4701
4885
  */
4702
4886
  async onDeliver(payload) {
4703
4887
  const rawLen = payload.text?.length ?? 0;
4704
- const preview = (payload.text ?? "").slice(0, 60).replace(/\n/g, "\\n");
4888
+ const preview = truncateUtf16Safe(payload.text ?? "", 60).replace(/\n/g, "\\n");
4705
4889
  this.logDebug(`onDeliver: rawLen=${rawLen}, phase=${this.phase}, streamMsgId=${this.streamMsgId}, sentIndex=${this.sentIndex}, sentChunks=${this.sentStreamChunkCount}, firstCB=${this.firstCallbackSource}, preview="${preview}"`);
4706
4890
  if (this.isTerminalPhase) {
4707
4891
  this.logDebug(`onDeliver: skipped (terminal phase)`);
@@ -4849,7 +5033,7 @@ var StreamingController = class {
4849
5033
  if (this.isTerminalPhase) return;
4850
5034
  const found = findFirstClosedMediaTag(normalizedFull.slice(this.sentIndex));
4851
5035
  if (!found) break;
4852
- this.logInfo(`processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${found.textBefore.slice(0, 40)}"`);
5036
+ this.logInfo(`processMediaTags: found <${found.tagName}> at offset ${this.sentIndex}, textBefore="${truncateUtf16Safe(found.textBefore, 40)}"`);
4853
5037
  const textBeforeEndInFull = this.sentIndex + found.textBefore.length;
4854
5038
  await this.endCurrentStreamIfNeeded("processMediaTags:closedTag", textBeforeEndInFull);
4855
5039
  if (this.isTerminalPhase) return;
@@ -4858,7 +5042,7 @@ var StreamingController = class {
4858
5042
  type: found.itemType,
4859
5043
  content: found.mediaPath
4860
5044
  };
4861
- this.logDebug(`processMediaTags: sending ${found.itemType}: ${found.mediaPath.slice(0, 80)}`);
5045
+ this.logDebug(`processMediaTags: sending ${found.itemType}: ${truncateUtf16Safe(found.mediaPath, 80)}`);
4862
5046
  await sendMediaQueue([item], this.deps.mediaContext);
4863
5047
  this.sentMediaCount++;
4864
5048
  this.sentStreamChunkCount++;
@@ -5035,18 +5219,25 @@ var StreamingController = class {
5035
5219
  */
5036
5220
  function toMediaSendContext(ctx) {
5037
5221
  const { account, event, log } = ctx;
5222
+ const mediaAccessContext = {
5223
+ ...ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {},
5224
+ ...ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {},
5225
+ ...ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}
5226
+ };
5038
5227
  return {
5039
5228
  mediaTarget: {
5040
5229
  targetType: event.type,
5041
5230
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.channelId,
5042
5231
  account,
5043
5232
  replyToId: event.messageId,
5044
- logPrefix: `[qqbot:${account.accountId}]`
5233
+ logPrefix: `[qqbot:${account.accountId}]`,
5234
+ ...mediaAccessContext
5045
5235
  },
5046
5236
  qualifiedTarget: event.type === "group" ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`,
5047
5237
  account,
5048
5238
  replyToId: event.messageId,
5049
- log
5239
+ log,
5240
+ ...mediaAccessContext
5050
5241
  };
5051
5242
  }
5052
5243
  /**
@@ -5176,7 +5367,7 @@ function isAudioFile(filePath, mimeType) {
5176
5367
  ".pcm"
5177
5368
  ].includes(ext);
5178
5369
  }
5179
- const QQ_NATIVE_VOICE_MIMES = new Set([
5370
+ const QQ_NATIVE_VOICE_MIMES = /* @__PURE__ */ new Set([
5180
5371
  "audio/silk",
5181
5372
  "audio/amr",
5182
5373
  "audio/wav",
@@ -5185,7 +5376,7 @@ const QQ_NATIVE_VOICE_MIMES = new Set([
5185
5376
  "audio/mpeg",
5186
5377
  "audio/mp3"
5187
5378
  ]);
5188
- const QQ_NATIVE_VOICE_EXTS = new Set([
5379
+ const QQ_NATIVE_VOICE_EXTS = /* @__PURE__ */ new Set([
5189
5380
  ".silk",
5190
5381
  ".slk",
5191
5382
  ".amr",
@@ -5551,6 +5742,9 @@ function blockReplyTextForDelivery(payload) {
5551
5742
  function isSilentBlockReply(payload) {
5552
5743
  return !hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim());
5553
5744
  }
5745
+ function isMediaOnlyBlockReply(payload) {
5746
+ return hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim());
5747
+ }
5554
5748
  /**
5555
5749
  * Dispatch the AI reply for the given inbound context.
5556
5750
  *
@@ -5560,6 +5754,13 @@ function isSilentBlockReply(payload) {
5560
5754
  async function dispatchOutbound(inbound, deps) {
5561
5755
  const { runtime, cfg, account, log } = deps;
5562
5756
  const { event, qualifiedTarget } = inbound;
5757
+ const openClawCfg = cfg;
5758
+ const routeAgentId = inbound.route.agentId ?? resolveDefaultAgentId(openClawCfg);
5759
+ const workspaceDir = resolveAgentWorkspaceDir(openClawCfg, routeAgentId);
5760
+ const gatewayMediaContext = workspaceDir ? {
5761
+ mediaAccess: { workspaceDir },
5762
+ mediaLocalRoots: [workspaceDir]
5763
+ } : {};
5563
5764
  const replyCtx = {
5564
5765
  target: {
5565
5766
  type: event.type,
@@ -5571,7 +5772,8 @@ async function dispatchOutbound(inbound, deps) {
5571
5772
  },
5572
5773
  account,
5573
5774
  cfg,
5574
- log
5775
+ log,
5776
+ ...gatewayMediaContext
5575
5777
  };
5576
5778
  const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
5577
5779
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
@@ -5609,7 +5811,8 @@ async function dispatchOutbound(inbound, deps) {
5609
5811
  mediaUrl,
5610
5812
  accountId: account.accountId,
5611
5813
  replyToId: event.messageId,
5612
- account
5814
+ account,
5815
+ ...gatewayMediaContext
5613
5816
  }).then((r) => {
5614
5817
  if (ac.signal.aborted) return {
5615
5818
  channel: "qqbot",
@@ -5633,14 +5836,11 @@ async function dispatchOutbound(inbound, deps) {
5633
5836
  }
5634
5837
  };
5635
5838
  const sendToolFallback = async () => {
5636
- if (toolMediaUrls.length > 0) {
5637
- for (const mediaUrl of toolMediaUrls) await sendToolMediaWithTimeout(mediaUrl, {
5638
- resultError: "Tool fallback error",
5639
- thrownError: "Tool fallback failed"
5640
- });
5641
- return;
5642
- }
5643
- if (toolTexts.length > 0) await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2e3));
5839
+ if (toolMediaUrls.length > 0) for (const mediaUrl of toolMediaUrls) await sendToolMediaWithTimeout(mediaUrl, {
5840
+ resultError: "Tool fallback error",
5841
+ thrownError: "Tool fallback failed"
5842
+ });
5843
+ if (toolTexts.length > 0) await sendErrorMessage(truncateUtf16Safe(toolTexts.slice(-3).join("\n---\n"), 2e3));
5644
5844
  };
5645
5845
  const hasPendingToolFallbackPayload = () => toolTexts.length > 0 || toolMediaUrls.length > 0;
5646
5846
  const flushPendingToolDeliveriesOnce = async () => {
@@ -5731,7 +5931,8 @@ async function dispatchOutbound(inbound, deps) {
5731
5931
  }, {
5732
5932
  account,
5733
5933
  qualifiedTarget,
5734
- log
5934
+ log,
5935
+ ...gatewayMediaContext
5735
5936
  }, sendWithRetry, () => void 0, deliverDeps);
5736
5937
  }
5737
5938
  };
@@ -5740,7 +5941,7 @@ async function dispatchOutbound(inbound, deps) {
5740
5941
  accountId: account.accountId,
5741
5942
  direction: "outbound"
5742
5943
  });
5743
- const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, inbound.route.agentId);
5944
+ const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, routeAgentId);
5744
5945
  const useOfficialC2cStream = shouldUseOfficialC2cStream(account, event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel");
5745
5946
  const deliverToolProgressImmediately = shouldDeliverToolProgressImmediately(account, useOfficialC2cStream);
5746
5947
  let streamingController = null;
@@ -5760,12 +5961,12 @@ async function dispatchOutbound(inbound, deps) {
5760
5961
  groupOpenid: event.groupOpenid,
5761
5962
  channelId: event.channelId
5762
5963
  },
5763
- log
5964
+ log,
5965
+ ...gatewayMediaContext
5764
5966
  }
5765
5967
  });
5766
5968
  const cfgWithSession = cfg;
5767
- const agentId = inbound.route.agentId ?? "default";
5768
- const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { agentId });
5969
+ const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { agentId: routeAgentId });
5769
5970
  const dispatchPromise = runtime.channel.inbound.run({
5770
5971
  channel: "qqbot",
5771
5972
  accountId: inbound.route.accountId,
@@ -5811,7 +6012,8 @@ async function dispatchOutbound(inbound, deps) {
5811
6012
  }, {
5812
6013
  account,
5813
6014
  qualifiedTarget,
5814
- log
6015
+ log,
6016
+ ...gatewayMediaContext
5815
6017
  }, sendWithRetry, () => void 0, deliverDeps);
5816
6018
  recordOutbound();
5817
6019
  return;
@@ -5829,7 +6031,8 @@ async function dispatchOutbound(inbound, deps) {
5829
6031
  mediaUrl,
5830
6032
  accountId: account.accountId,
5831
6033
  replyToId: event.messageId,
5832
- account
6034
+ account,
6035
+ ...gatewayMediaContext
5833
6036
  });
5834
6037
  } catch {}
5835
6038
  return;
@@ -5844,7 +6047,7 @@ async function dispatchOutbound(inbound, deps) {
5844
6047
  return;
5845
6048
  }
5846
6049
  hasVisibleBlockResponse = true;
5847
- if (streamingController && !streamingController.isTerminalPhase) {
6050
+ if (streamingController && !streamingController.isTerminalPhase && !isMediaOnlyBlockReply(payload)) {
5848
6051
  try {
5849
6052
  await streamingController.onDeliver(payload);
5850
6053
  } catch (err) {
@@ -5881,7 +6084,8 @@ async function dispatchOutbound(inbound, deps) {
5881
6084
  const deliverActx = {
5882
6085
  account,
5883
6086
  qualifiedTarget,
5884
- log
6087
+ log,
6088
+ ...gatewayMediaContext
5885
6089
  };
5886
6090
  const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, deliverDeps);
5887
6091
  if (mediaResult.handled) {
@@ -6002,7 +6206,7 @@ async function buildCtxPayload(inbound, runtime, cfg) {
6002
6206
  id: inbound.peerId
6003
6207
  },
6004
6208
  route: {
6005
- agentId: inbound.route.agentId ?? "main",
6209
+ agentId: inbound.route.agentId ?? resolveDefaultAgentId(cfg),
6006
6210
  routeSessionKey: inbound.route.sessionKey,
6007
6211
  accountId: inbound.route.accountId
6008
6212
  },
@@ -6137,7 +6341,7 @@ async function startGateway$1(ctx) {
6137
6341
  initApiConfig(account.appId, { markdownSupport: account.markdownSupport });
6138
6342
  log?.debug?.(`API config: markdownSupport=${account.markdownSupport}`);
6139
6343
  onMessageSent(account.appId, (refIdx, meta) => {
6140
- log?.info(`onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText?.slice(0, 30)}`);
6344
+ log?.info(`onMessageSent called: refIdx=${refIdx}, mediaType=${meta.mediaType}, ttsText=${meta.ttsText === void 0 ? void 0 : truncateUtf16Safe(meta.ttsText, 30)}`);
6141
6345
  const attachments = [];
6142
6346
  if (meta.mediaType) {
6143
6347
  const localPath = meta.mediaLocalPath;
@@ -6284,6 +6488,7 @@ async function startGateway$1(ctx) {
6284
6488
  onReady: ctx.onReady,
6285
6489
  onResumed: ctx.onResumed,
6286
6490
  onError: ctx.onError,
6491
+ onDisconnected: ctx.onDisconnected,
6287
6492
  onInteraction: handleInteraction,
6288
6493
  handleMessage
6289
6494
  }).start();
@@ -6599,6 +6804,7 @@ async function startGateway(ctx) {
6599
6804
  onReady: ctx.onReady,
6600
6805
  onResumed: ctx.onResumed,
6601
6806
  onError: ctx.onError,
6807
+ onDisconnected: ctx.onDisconnected,
6602
6808
  log: accountLogger,
6603
6809
  runtime,
6604
6810
  adapters: createEngineAdapters()