@openclaw/qqbot 2026.6.11 → 2026.7.1-beta.2

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,23 +1,23 @@
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-CRZ2af7P.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-BfVLJJYp.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 toGatewayAccount, i as openQQBotSyncKeyedStore, m as parseApprovalButtonData, n as createQQBotMarkdownChunker, o as authorizeQQBotApprovalAction, r as buildQQBotStateKey } from "./channel-DvWrqcZB.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-BQM6pDlD.js";
5
5
  import { d as resolveAccountBase } from "./config-C1qZbh0K.js";
6
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-D1hn91-s.js";
8
- import { a as getHomeDir, c as getQQBotMediaDir, d as isLocalPath, f as isWindows, i as checkSilkWasmAvailable, o as getQQBotDataDir, p as normalizePath, s as getQQBotDataPath, u as getTempDir } from "./target-parser-BdCUmxK7.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-CvVXke_k.js";
7
+ import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-B4HWCOa-.js";
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-BmS1Qwst.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-BZHwMFUX.js";
10
11
  import { n as runWithRequestContext } from "./request-context-Bm7PTBD1.js";
11
- import { E as DEFAULT_MEDIA_SEND_ERROR, O as setOutboundAudioPort, _ as sendVideoMsg, b as resolveUserFacingMediaError, c as isCronReminderPayload, d as normalizeMediaTags, g as sendPhoto, h as sendDocument, l as isMediaPayload, o as decodeMediaPath, r as sendMedia$1, s as encodePayloadForCron, u as parseQQBotPayload, v as sendVoice, y as resolveTrustedOutboundMediaPath } from "./outbound-BQcPCo-z.js";
12
12
  import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
13
13
  import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime";
14
14
  import * as fs$1 from "node:fs";
15
15
  import fs from "node:fs";
16
16
  import * as os$1 from "node:os";
17
- import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
17
+ import { readProviderJsonResponse, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
18
18
  import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
19
19
  import crypto from "node:crypto";
20
- import { privateFileStoreSync, readRegularFileSync } from "openclaw/plugin-sdk/security-runtime";
20
+ import { readRegularFileSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
21
21
  import * as path$1 from "node:path";
22
22
  import path from "node:path";
23
23
  import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
@@ -26,6 +26,7 @@ import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
26
26
  import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
27
27
  import WebSocket from "ws";
28
28
  import { resolveAmbientNodeProxyAgent } from "openclaw/plugin-sdk/extension-shared";
29
+ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
29
30
  import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
30
31
  import { SILENT_REPLY_TOKEN, isSilentReplyPayloadText } from "openclaw/plugin-sdk/reply-chunking";
31
32
  import { Buffer as Buffer$1 } from "node:buffer";
@@ -179,19 +180,10 @@ function formatRefEntryForAgent(entry) {
179
180
  //#region extensions/qqbot/src/engine/ref/store.ts
180
181
  /**
181
182
  * Ref-index store — SQLite KV-backed store for message reference index.
182
- *
183
- * Legacy JSONL entries are imported once, then deleted after SQLite has the
184
- * canonical ref-index rows.
185
183
  */
186
184
  const MAX_ENTRIES = 5e4;
187
185
  const TTL_MS = 10080 * 60 * 1e3;
188
186
  const REF_INDEX_NAMESPACE = "ref-index";
189
- const REF_INDEX_MIGRATIONS_NAMESPACE = "ref-index-migrations";
190
- const LEGACY_REF_INDEX_MIGRATION_KEY = "ref-index-jsonl-v1";
191
- let legacyImported$1 = false;
192
- function getRefIndexFile() {
193
- return path.join(getQQBotDataPath("data"), "ref-index.jsonl");
194
- }
195
187
  function createRefIndexStore() {
196
188
  return openQQBotSyncKeyedStore({
197
189
  namespace: REF_INDEX_NAMESPACE,
@@ -199,12 +191,6 @@ function createRefIndexStore() {
199
191
  defaultTtlMs: TTL_MS
200
192
  });
201
193
  }
202
- function createRefIndexMigrationStore() {
203
- return openQQBotSyncKeyedStore({
204
- namespace: REF_INDEX_MIGRATIONS_NAMESPACE,
205
- maxEntries: 100
206
- });
207
- }
208
194
  function refIndexStateKey(refIdx) {
209
195
  return buildQQBotStateKey("ref-index", refIdx);
210
196
  }
@@ -240,51 +226,9 @@ function toRefIndexEntry(entry) {
240
226
  ...entry.attachments ? { attachments: entry.attachments.map(toStoredAttachment) } : {}
241
227
  };
242
228
  }
243
- function ensureLegacyRefIndexImported() {
244
- if (legacyImported$1) return;
245
- const migrationStore = createRefIndexMigrationStore();
246
- if (migrationStore.lookup(LEGACY_REF_INDEX_MIGRATION_KEY)) {
247
- legacyImported$1 = true;
248
- return;
249
- }
250
- try {
251
- const refIndexFile = getRefIndexFile();
252
- if (!fs.existsSync(refIndexFile)) {
253
- migrationStore.register(LEGACY_REF_INDEX_MIGRATION_KEY, { importedAt: (/* @__PURE__ */ new Date()).toISOString() });
254
- legacyImported$1 = true;
255
- return;
256
- }
257
- const lines = fs.readFileSync(refIndexFile, "utf-8").split("\n");
258
- const now = Date.now();
259
- let expired = 0;
260
- let imported = 0;
261
- const store = createRefIndexStore();
262
- for (const line of lines) {
263
- const trimmed = line.trim();
264
- if (!trimmed) continue;
265
- try {
266
- const entry = JSON.parse(trimmed);
267
- if (!entry.k || !entry.v || !entry.t) continue;
268
- if (now - entry.t > TTL_MS) {
269
- expired++;
270
- continue;
271
- }
272
- store.register(refIndexStateKey(entry.k), toStoredRefIndexEntry(entry.v, entry.t), { ttlMs: Math.max(1, TTL_MS - (now - entry.t)) });
273
- imported++;
274
- } catch {}
275
- }
276
- migrationStore.register(LEGACY_REF_INDEX_MIGRATION_KEY, { importedAt: (/* @__PURE__ */ new Date()).toISOString() });
277
- legacyImported$1 = true;
278
- fs.rmSync(refIndexFile, { force: true });
279
- debugLog(`[ref-index-store] Migrated ${imported} entries to SQLite (${expired} expired)`);
280
- } catch (err) {
281
- debugError(`[ref-index-store] Failed to import legacy JSONL: ${formatErrorMessage(err)}`);
282
- }
283
- }
284
229
  /** Persist a refIdx mapping for one message. */
285
230
  function setRefIndex(refIdx, entry) {
286
231
  try {
287
- ensureLegacyRefIndexImported();
288
232
  const now = Date.now();
289
233
  createRefIndexStore().register(refIndexStateKey(refIdx), toStoredRefIndexEntry(entry, now), { ttlMs: TTL_MS });
290
234
  } catch (err) {
@@ -294,7 +238,6 @@ function setRefIndex(refIdx, entry) {
294
238
  /** Look up one quoted message by refIdx. */
295
239
  function getRefIndex(refIdx) {
296
240
  try {
297
- ensureLegacyRefIndexImported();
298
241
  const store = createRefIndexStore();
299
242
  const key = refIndexStateKey(refIdx);
300
243
  const entry = store.lookup(key);
@@ -567,34 +510,19 @@ async function trySlashCommand(msg, ctx) {
567
510
  //#region extensions/qqbot/src/engine/session/known-users.ts
568
511
  /**
569
512
  * Known user tracking — SQLite KV-backed store.
570
- *
571
- * Legacy `known-users.json` data is imported once, then deleted after SQLite
572
- * has the canonical copy.
573
513
  */
574
- function getKnownUsersFile() {
575
- return path.join(getQQBotDataPath("data"), "known-users.json");
576
- }
577
514
  function makeUserKey(user) {
578
515
  const base = `${user.accountId}:${user.type}:${user.openid}`;
579
516
  return user.type === "group" && user.groupOpenid ? `${base}:${user.groupOpenid}` : base;
580
517
  }
581
518
  const KNOWN_USERS_NAMESPACE = "known-users";
582
- const KNOWN_USERS_MIGRATIONS_NAMESPACE = "known-users-migrations";
583
- const LEGACY_KNOWN_USERS_MIGRATION_KEY = "known-users-json-v1";
584
519
  const MAX_KNOWN_USERS = 1e5;
585
- let legacyImported = false;
586
520
  function createKnownUsersStore() {
587
521
  return openQQBotSyncKeyedStore({
588
522
  namespace: KNOWN_USERS_NAMESPACE,
589
523
  maxEntries: MAX_KNOWN_USERS
590
524
  });
591
525
  }
592
- function createKnownUsersMigrationStore() {
593
- return openQQBotSyncKeyedStore({
594
- namespace: KNOWN_USERS_MIGRATIONS_NAMESPACE,
595
- maxEntries: 100
596
- });
597
- }
598
526
  function knownUserStateKey(key) {
599
527
  return crypto.createHash("sha256").update(key).digest("hex");
600
528
  }
@@ -610,32 +538,9 @@ function toStoredKnownUser(user) {
610
538
  interactionCount: user.interactionCount
611
539
  };
612
540
  }
613
- function ensureLegacyKnownUsersImported() {
614
- if (legacyImported) return;
615
- const migrationStore = createKnownUsersMigrationStore();
616
- if (migrationStore.lookup(LEGACY_KNOWN_USERS_MIGRATION_KEY)) {
617
- legacyImported = true;
618
- return;
619
- }
620
- try {
621
- const knownUsersFile = getKnownUsersFile();
622
- const users = privateFileStoreSync(path.dirname(knownUsersFile)).readJsonIfExists(path.basename(knownUsersFile));
623
- if (Array.isArray(users)) {
624
- const store = createKnownUsersStore();
625
- for (const user of users) store.registerIfAbsent(knownUserStateKey(makeUserKey(user)), toStoredKnownUser(user));
626
- debugLog(`[known-users] Migrated ${users.length} users to SQLite`);
627
- fs.rmSync(knownUsersFile, { force: true });
628
- }
629
- migrationStore.register(LEGACY_KNOWN_USERS_MIGRATION_KEY, { importedAt: (/* @__PURE__ */ new Date()).toISOString() });
630
- legacyImported = true;
631
- } catch (err) {
632
- debugError(`[known-users] Failed to import legacy users: ${formatErrorMessage(err)}`);
633
- }
634
- }
635
541
  /** Record a known user whenever a message is received. */
636
542
  function recordKnownUser(user) {
637
543
  try {
638
- ensureLegacyKnownUsersImported();
639
544
  const store = createKnownUsersStore();
640
545
  const stateKey = knownUserStateKey(makeUserKey(user));
641
546
  const now = Date.now();
@@ -669,34 +574,12 @@ function recordKnownUser(user) {
669
574
  //#region extensions/qqbot/src/engine/session/session-store.ts
670
575
  /**
671
576
  * Gateway session persistence — SQLite KV-backed store.
672
- *
673
- * Legacy JSON session files are imported on first account access, then
674
- * removed after SQLite has the canonical short-lived session entry.
675
577
  */
676
578
  const SESSION_EXPIRE_TIME = 300 * 1e3;
677
579
  const SAVE_THROTTLE_MS = 1e3;
678
580
  const SESSION_NAMESPACE = "gateway-sessions";
679
581
  const MAX_SESSIONS = 1e3;
680
582
  const throttleState = /* @__PURE__ */ new Map();
681
- function getSessionDir() {
682
- return getQQBotDataPath("sessions");
683
- }
684
- function encodeAccountIdForFileName(accountId) {
685
- return Buffer.from(accountId, "utf8").toString("base64url");
686
- }
687
- function getLegacySessionPath(accountId) {
688
- const safeId = accountId.replace(/[^a-zA-Z0-9_-]/g, "_");
689
- return path.join(getSessionDir(), `session-${safeId}.json`);
690
- }
691
- function getSessionPath(accountId) {
692
- const encodedId = encodeAccountIdForFileName(accountId);
693
- return path.join(getSessionDir(), `session-${encodedId}.json`);
694
- }
695
- function getCandidateSessionPaths(accountId) {
696
- const primaryPath = getSessionPath(accountId);
697
- const legacyPath = getLegacySessionPath(accountId);
698
- return primaryPath === legacyPath ? [primaryPath] : [primaryPath, legacyPath];
699
- }
700
583
  function createSessionStore() {
701
584
  return openQQBotSyncKeyedStore({
702
585
  namespace: SESSION_NAMESPACE,
@@ -707,9 +590,6 @@ function createSessionStore() {
707
590
  function sessionKey(accountId) {
708
591
  return buildQQBotStateKey("gateway-session", accountId);
709
592
  }
710
- function remainingSessionTtlMs(state, now = Date.now()) {
711
- return Math.max(1, SESSION_EXPIRE_TIME - (now - state.savedAt));
712
- }
713
593
  function toStoredSessionState(state) {
714
594
  return {
715
595
  sessionId: state.sessionId,
@@ -721,35 +601,11 @@ function toStoredSessionState(state) {
721
601
  ...state.appId ? { appId: state.appId } : {}
722
602
  };
723
603
  }
724
- function removeFileQuietly(filePath) {
725
- try {
726
- fs.unlinkSync(filePath);
727
- } catch {}
728
- }
729
- function loadLegacySession(accountId) {
730
- for (const candidatePath of getCandidateSessionPaths(accountId)) {
731
- const state = privateFileStoreSync(path.dirname(candidatePath)).readJsonIfExists(path.basename(candidatePath));
732
- if (state) return {
733
- state,
734
- filePath: candidatePath
735
- };
736
- }
737
- return null;
738
- }
739
- function migrateLegacySession(accountId) {
740
- const legacy = loadLegacySession(accountId);
741
- if (!legacy) return null;
742
- const now = Date.now();
743
- if (now - legacy.state.savedAt <= SESSION_EXPIRE_TIME) createSessionStore().register(sessionKey(accountId), toStoredSessionState(legacy.state), { ttlMs: remainingSessionTtlMs(legacy.state, now) });
744
- for (const filePath of getCandidateSessionPaths(accountId)) removeFileQuietly(filePath);
745
- return legacy.state;
746
- }
747
604
  /** Load a saved session, rejecting expired or mismatched appId entries. */
748
605
  function loadSession(accountId, expectedAppId) {
749
606
  try {
750
607
  const store = createSessionStore();
751
- let state = store.lookup(sessionKey(accountId));
752
- if (!state) state = migrateLegacySession(accountId) ?? void 0;
608
+ const state = store.lookup(sessionKey(accountId));
753
609
  if (!state) return null;
754
610
  const now = Date.now();
755
611
  if (now - state.savedAt > SESSION_EXPIRE_TIME) {
@@ -813,16 +669,12 @@ function saveSession(state) {
813
669
  }
814
670
  }
815
671
  function doSaveSession(state) {
816
- const filePath = getSessionPath(state.accountId);
817
- const legacyPath = getLegacySessionPath(state.accountId);
818
672
  try {
819
673
  const stateToSave = {
820
674
  ...state,
821
675
  savedAt: Date.now()
822
676
  };
823
677
  createSessionStore().register(sessionKey(state.accountId), toStoredSessionState(stateToSave), { ttlMs: SESSION_EXPIRE_TIME });
824
- removeFileQuietly(filePath);
825
- removeFileQuietly(legacyPath);
826
678
  debugLog(`[session-store] Saved session for ${state.accountId}: sessionId=${state.sessionId}, lastSeq=${state.lastSeq}`);
827
679
  } catch (err) {
828
680
  debugError(`[session-store] Failed to save session for ${state.accountId}: ${formatErrorMessage(err)}`);
@@ -836,9 +688,7 @@ function clearSession(accountId) {
836
688
  throttleState.delete(accountId);
837
689
  }
838
690
  try {
839
- const cleared = createSessionStore().delete(sessionKey(accountId));
840
- for (const filePath of getCandidateSessionPaths(accountId)) removeFileQuietly(filePath);
841
- if (cleared) debugLog(`[session-store] Cleared session for ${accountId}`);
691
+ if (createSessionStore().delete(sessionKey(accountId))) debugLog(`[session-store] Cleared session for ${accountId}`);
842
692
  } catch (err) {
843
693
  debugError(`[session-store] Failed to clear session for ${accountId}: ${formatErrorMessage(err)}`);
844
694
  }
@@ -1869,7 +1719,7 @@ async function transcribeAudio(audioPath, cfg) {
1869
1719
  const detail = await readResponseTextLimited(resp, STT_ERROR_BODY_LIMIT_BYTES).catch(() => "");
1870
1720
  throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
1871
1721
  }
1872
- return normalizeOptionalString$1((await resp.json()).text) ?? null;
1722
+ return normalizeOptionalString$1((await readProviderJsonResponse(resp, "qqbot.stt")).text) ?? null;
1873
1723
  } finally {
1874
1724
  await release();
1875
1725
  }
@@ -3449,15 +3299,30 @@ function hasQQBotImageSize(markdownImage) {
3449
3299
  //#region extensions/qqbot/src/engine/messaging/outbound-deliver.ts
3450
3300
  /** Maximum text length for a single QQ Bot message. */
3451
3301
  const TEXT_CHUNK_LIMIT = 5e3;
3452
- function resolveMediaTargetContext(event, account) {
3302
+ function resolveMediaTargetContext(event, actx) {
3303
+ const { account } = actx;
3453
3304
  return {
3454
3305
  targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : event.type === "dm" ? "dm" : "channel",
3455
3306
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.type === "dm" ? event.guildId : event.channelId,
3456
3307
  account,
3457
- replyToId: event.messageId
3308
+ replyToId: event.messageId,
3309
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3310
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3311
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}
3458
3312
  };
3459
3313
  }
3314
+ function isHttpUrl(value) {
3315
+ return value.startsWith("http://") || value.startsWith("https://");
3316
+ }
3317
+ function isImageDataUrl(value) {
3318
+ return value.startsWith("data:image/");
3319
+ }
3320
+ function isBareRelativeMediaPath(value) {
3321
+ const trimmed = value.trim();
3322
+ return Boolean(trimmed) && !trimmed.startsWith("#") && !trimmed.startsWith("//") && !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed);
3323
+ }
3460
3324
  async function autoMediaBatch(params) {
3325
+ let sentCount = 0;
3461
3326
  for (const mediaUrl of params.mediaUrls) try {
3462
3327
  const result = await params.mediaSender.sendMedia({
3463
3328
  to: params.qualifiedTarget,
@@ -3465,17 +3330,22 @@ async function autoMediaBatch(params) {
3465
3330
  mediaUrl,
3466
3331
  accountId: params.account.accountId,
3467
3332
  replyToId: params.replyToId,
3468
- account: params.account
3333
+ account: params.account,
3334
+ ...params.mediaAccess ? { mediaAccess: params.mediaAccess } : {},
3335
+ ...params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {},
3336
+ ...params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}
3469
3337
  });
3470
3338
  if (result.error) {
3471
3339
  params.log?.error(params.onResultError(mediaUrl, result.error));
3472
3340
  continue;
3473
3341
  }
3342
+ sentCount++;
3474
3343
  const successMessage = params.onSuccess?.(mediaUrl);
3475
3344
  if (successMessage) params.log?.info(successMessage);
3476
3345
  } catch (err) {
3477
3346
  params.log?.error(params.onThrownError(mediaUrl, formatErrorMessage(err)));
3478
3347
  }
3348
+ return sentCount;
3479
3349
  }
3480
3350
  async function sendTextChunkToTarget(params) {
3481
3351
  const { account, event, text, consumeQuoteRef, allowDm, forcePlainText } = params;
@@ -3541,16 +3411,18 @@ async function sendWithResultLogging(params) {
3541
3411
  const result = await params.run();
3542
3412
  if (result.error) {
3543
3413
  params.log?.error(params.onError(result.error));
3544
- return;
3414
+ return false;
3545
3415
  }
3546
3416
  const successMessage = params.onSuccess?.();
3547
3417
  if (successMessage) params.log?.info(successMessage);
3418
+ return true;
3548
3419
  } catch (err) {
3549
3420
  params.log?.error(params.onError(formatErrorMessage(err)));
3421
+ return false;
3550
3422
  }
3551
3423
  }
3552
3424
  async function sendPhotoWithLogging(params) {
3553
- await sendWithResultLogging({
3425
+ return await sendWithResultLogging({
3554
3426
  run: async () => await params.mediaSender.sendPhoto(params.target, params.imageUrl),
3555
3427
  log: params.log,
3556
3428
  onSuccess: params.onSuccess ? () => params.onSuccess?.(params.imageUrl) : void 0,
@@ -3582,9 +3454,14 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3582
3454
  });
3583
3455
  }, voiceTimeout);
3584
3456
  })]);
3585
- if (result.error) log?.error(`sendVoice error: ${result.error}`);
3457
+ if (result.error) {
3458
+ log?.error(`sendVoice error: ${result.error}`);
3459
+ return false;
3460
+ }
3461
+ return true;
3586
3462
  } catch (err) {
3587
3463
  log?.error(`sendVoice unexpected error: ${formatErrorMessage(err)}`);
3464
+ return false;
3588
3465
  }
3589
3466
  }
3590
3467
  /**
@@ -3640,38 +3517,62 @@ async function parseAndSendMediaTags(replyText, event, actx, sendWithRetry, cons
3640
3517
  content: filterInternalMarkers(textAfter)
3641
3518
  });
3642
3519
  log?.debug?.(`Send queue: ${sendQueue.map((item) => item.type).join(" -> ")}`);
3643
- const mediaTarget = resolveMediaTargetContext(event, account);
3644
- for (const item of sendQueue) if (item.type === "text") await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps);
3645
- else if (item.type === "image") await sendPhotoWithLogging({
3646
- target: mediaTarget,
3647
- imageUrl: item.content,
3648
- mediaSender: deps.mediaSender,
3649
- log,
3650
- onError: (error) => `sendPhoto error: ${error}`
3651
- });
3652
- else if (item.type === "voice") await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log);
3653
- else if (item.type === "video") await sendWithResultLogging({
3654
- run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content),
3655
- log,
3656
- onError: (error) => `sendVideoMsg error: ${error}`
3657
- });
3658
- else if (item.type === "file") await sendWithResultLogging({
3659
- run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content),
3660
- log,
3661
- onError: (error) => `sendDocument error: ${error}`
3662
- });
3663
- else if (item.type === "media") await sendWithResultLogging({
3664
- run: async () => await deps.mediaSender.sendMedia({
3665
- to: actx.qualifiedTarget,
3666
- text: "",
3667
- mediaUrl: item.content,
3668
- accountId: account.accountId,
3669
- replyToId: event.messageId,
3670
- account
3671
- }),
3672
- log,
3673
- onError: (error) => `sendMedia(auto) error: ${error}`
3674
- });
3520
+ const mediaTarget = resolveMediaTargetContext(event, actx);
3521
+ let deliveredVisibleOutput = false;
3522
+ for (const item of sendQueue) if (item.type === "text") {
3523
+ await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef, deps);
3524
+ if (item.content.trim()) deliveredVisibleOutput = true;
3525
+ } else if (item.type === "image") {
3526
+ const sent = await sendPhotoWithLogging({
3527
+ target: mediaTarget,
3528
+ imageUrl: item.content,
3529
+ mediaSender: deps.mediaSender,
3530
+ log,
3531
+ onError: (error) => `sendPhoto error: ${error}`
3532
+ });
3533
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3534
+ } else if (item.type === "voice") {
3535
+ const sent = await sendVoiceWithTimeout(mediaTarget, item.content, account, deps.mediaSender, log);
3536
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3537
+ } else if (item.type === "video") {
3538
+ const sent = await sendWithResultLogging({
3539
+ run: async () => await deps.mediaSender.sendVideoMsg(mediaTarget, item.content),
3540
+ log,
3541
+ onError: (error) => `sendVideoMsg error: ${error}`
3542
+ });
3543
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3544
+ } else if (item.type === "file") {
3545
+ const sent = await sendWithResultLogging({
3546
+ run: async () => await deps.mediaSender.sendDocument(mediaTarget, item.content),
3547
+ log,
3548
+ onError: (error) => `sendDocument error: ${error}`
3549
+ });
3550
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3551
+ } else if (item.type === "media") {
3552
+ const sent = await sendWithResultLogging({
3553
+ run: async () => await deps.mediaSender.sendMedia({
3554
+ to: actx.qualifiedTarget,
3555
+ text: "",
3556
+ mediaUrl: item.content,
3557
+ accountId: account.accountId,
3558
+ replyToId: event.messageId,
3559
+ account,
3560
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3561
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3562
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {}
3563
+ }),
3564
+ log,
3565
+ onError: (error) => `sendMedia(auto) error: ${error}`
3566
+ });
3567
+ deliveredVisibleOutput = deliveredVisibleOutput || sent;
3568
+ }
3569
+ if (!deliveredVisibleOutput) {
3570
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3571
+ return {
3572
+ handled: true,
3573
+ normalizedText: ""
3574
+ };
3575
+ }
3675
3576
  return {
3676
3577
  handled: true,
3677
3578
  normalizedText: text
@@ -3685,18 +3586,18 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3685
3586
  const { account, qualifiedTarget, log } = actx;
3686
3587
  const collectedImageUrls = [];
3687
3588
  const localMediaToSend = [];
3688
- const collectImageUrl = (url) => {
3589
+ const collectImageUrl = (url, allowBareRelativeMedia = false) => {
3689
3590
  if (!url) return false;
3690
- const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
3691
- const isDataUrl = url.startsWith("data:image/");
3692
- if (isHttpUrl || isDataUrl) {
3591
+ const isRemoteHttpUrl = isHttpUrl(url);
3592
+ const isDataUrl = isImageDataUrl(url);
3593
+ if (isRemoteHttpUrl || isDataUrl) {
3693
3594
  if (!collectedImageUrls.includes(url)) {
3694
3595
  collectedImageUrls.push(url);
3695
3596
  log?.debug?.(`Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`);
3696
3597
  }
3697
3598
  return true;
3698
3599
  }
3699
- if (isLocalPath(url)) {
3600
+ if (isLocalPath(url) || allowBareRelativeMedia && isBareRelativeMediaPath(url)) {
3700
3601
  if (!localMediaToSend.includes(url)) {
3701
3602
  localMediaToSend.push(url);
3702
3603
  log?.debug?.(`Collected local media for auto-routing: ${url}`);
@@ -3705,13 +3606,13 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3705
3606
  }
3706
3607
  return false;
3707
3608
  };
3708
- if (payload.mediaUrls?.length) for (const url of payload.mediaUrls) collectImageUrl(url);
3709
- if (payload.mediaUrl) collectImageUrl(payload.mediaUrl);
3609
+ if (payload.mediaUrls?.length) for (const url of payload.mediaUrls) collectImageUrl(url, true);
3610
+ if (payload.mediaUrl) collectImageUrl(payload.mediaUrl, true);
3710
3611
  const mdMatches = [...replyText.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/gi)];
3711
3612
  for (const m of mdMatches) {
3712
3613
  const url = m[2]?.trim();
3713
3614
  if (url && !collectedImageUrls.includes(url)) {
3714
- if (url.startsWith("http://") || url.startsWith("https://")) {
3615
+ if (isHttpUrl(url)) {
3715
3616
  collectedImageUrls.push(url);
3716
3617
  log?.debug?.(`Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
3717
3618
  } else if (isLocalPath(url)) {
@@ -3735,37 +3636,51 @@ async function sendPlainReply(payload, replyText, event, actx, sendWithRetry, co
3735
3636
  let textWithoutImages = filterInternalMarkers(replyText);
3736
3637
  for (const m of mdMatches) {
3737
3638
  const url = m[2]?.trim();
3738
- if (url && !url.startsWith("http://") && !url.startsWith("https://") && !isLocalPath(url)) textWithoutImages = textWithoutImages.replace(m[0], "").trim();
3639
+ if (url && !isHttpUrl(url) && !isLocalPath(url)) textWithoutImages = textWithoutImages.replace(m[0], "").trim();
3739
3640
  }
3740
3641
  if (useMarkdown) await sendMarkdownReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps);
3741
3642
  else await sendPlainTextReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps);
3643
+ const hasVisibleTextOrInlineImage = Boolean(textWithoutImages.trim() || collectedImageUrls.length > 0);
3644
+ let sentMediaCount = 0;
3645
+ let sentFailureFallback = false;
3742
3646
  if (localMediaToSend.length > 0) {
3743
3647
  log?.debug?.(`Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
3744
- await autoMediaBatch({
3648
+ sentMediaCount += await autoMediaBatch({
3745
3649
  qualifiedTarget,
3746
3650
  account,
3747
3651
  replyToId: event.messageId,
3748
3652
  mediaUrls: localMediaToSend,
3749
3653
  mediaSender: deps.mediaSender,
3654
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3655
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3656
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {},
3750
3657
  log,
3751
3658
  onSuccess: (mediaPath) => `Sent local media: ${mediaPath}`,
3752
3659
  onResultError: (mediaPath, error) => `sendMedia(auto) error for ${mediaPath}: ${error}`,
3753
3660
  onThrownError: (mediaPath, error) => `sendMedia(auto) failed for ${mediaPath}: ${error}`
3754
3661
  });
3662
+ if (!hasVisibleTextOrInlineImage && sentMediaCount === 0) {
3663
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3664
+ sentFailureFallback = true;
3665
+ }
3755
3666
  }
3756
3667
  if (toolMediaUrls.length > 0) {
3757
3668
  log?.debug?.(`Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
3758
- await autoMediaBatch({
3669
+ sentMediaCount += await autoMediaBatch({
3759
3670
  qualifiedTarget,
3760
3671
  account,
3761
3672
  replyToId: event.messageId,
3762
3673
  mediaUrls: toolMediaUrls,
3763
3674
  mediaSender: deps.mediaSender,
3675
+ ...actx.mediaAccess ? { mediaAccess: actx.mediaAccess } : {},
3676
+ ...actx.mediaLocalRoots ? { mediaLocalRoots: actx.mediaLocalRoots } : {},
3677
+ ...actx.mediaReadFile ? { mediaReadFile: actx.mediaReadFile } : {},
3764
3678
  log,
3765
3679
  onSuccess: (mediaUrl) => `Forwarded tool media: ${mediaUrl.slice(0, 80)}...`,
3766
3680
  onResultError: (_mediaUrl, error) => `Tool media forward error: ${error}`,
3767
3681
  onThrownError: (_mediaUrl, error) => `Tool media forward failed: ${error}`
3768
3682
  });
3683
+ if (!hasVisibleTextOrInlineImage && sentMediaCount === 0 && !sentFailureFallback) await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef, deps);
3769
3684
  toolMediaUrls.length = 0;
3770
3685
  }
3771
3686
  }
@@ -3773,8 +3688,8 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3773
3688
  const { account, log } = actx;
3774
3689
  const httpImageUrls = [];
3775
3690
  const base64ImageUrls = [];
3776
- for (const url of imageUrls) if (url.startsWith("data:image/")) base64ImageUrls.push(url);
3777
- else if (url.startsWith("http://") || url.startsWith("https://")) httpImageUrls.push(url);
3691
+ for (const url of imageUrls) if (isImageDataUrl(url)) base64ImageUrls.push(url);
3692
+ else if (isHttpUrl(url)) httpImageUrls.push(url);
3778
3693
  log?.debug?.(`Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
3779
3694
  if (base64ImageUrls.length > 0) {
3780
3695
  log?.debug?.(`Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
@@ -3810,7 +3725,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3810
3725
  for (const m of mdMatches) {
3811
3726
  const fullMatch = m[0];
3812
3727
  const imgUrl = m[2];
3813
- if ((imgUrl.startsWith("http://") || imgUrl.startsWith("https://")) && !hasQQBotImageSize(fullMatch)) try {
3728
+ if (isHttpUrl(imgUrl) && !hasQQBotImageSize(fullMatch)) try {
3814
3729
  const size = await getImageSize(imgUrl);
3815
3730
  result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
3816
3731
  log?.debug?.(`Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`);
@@ -3838,7 +3753,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3838
3753
  }
3839
3754
  async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef, deps) {
3840
3755
  const { account, log } = actx;
3841
- const imgMediaTarget = resolveMediaTargetContext(event, account);
3756
+ const imgMediaTarget = resolveMediaTargetContext(event, actx);
3842
3757
  let result = textWithoutImages;
3843
3758
  for (const m of mdMatches) result = result.replace(m[0], "").trim();
3844
3759
  for (const m of bareUrlMatches) result = result.replace(m[0], "").trim();
@@ -3949,13 +3864,27 @@ function formatMediaTypeLabel(mediaType) {
3949
3864
  return mediaType[0].toUpperCase() + mediaType.slice(1);
3950
3865
  }
3951
3866
  function validateStructuredPayloadLocalPath(ctx, payloadPath, mediaType) {
3952
- const allowedPath = resolveTrustedOutboundMediaPath(payloadPath);
3953
- if (allowedPath) return allowedPath;
3867
+ const candidatePaths = resolveWorkspacePathCandidates(normalizePath(payloadPath), ctx.mediaAccess?.workspaceDir);
3868
+ const localRoots = resolveWorkspaceScopedLocalRoots(resolveOutboundMediaLocalRoots(ctx), ctx.mediaAccess?.workspaceDir);
3869
+ const allowMissingHostRead = Boolean(resolveStructuredPayloadReadFile(ctx));
3870
+ for (const candidatePath of candidatePaths) {
3871
+ const allowedPath = resolveTrustedOutboundMediaPath(candidatePath, { allowMissing: allowMissingHostRead });
3872
+ if (allowedPath) return allowedPath;
3873
+ if (localRoots) {
3874
+ const scopedPath = resolveLocalPathFromRootsSync({
3875
+ filePath: candidatePath,
3876
+ roots: localRoots,
3877
+ label: "QQ Bot local roots",
3878
+ allowMissing: allowMissingHostRead
3879
+ })?.path;
3880
+ if (scopedPath) return scopedPath;
3881
+ }
3882
+ }
3954
3883
  ctx.log?.error(`Blocked ${mediaType} payload local path outside QQ Bot media storage`);
3955
3884
  return null;
3956
3885
  }
3957
3886
  function isRemoteHttpUrl(p) {
3958
- return p.startsWith("http://") || p.startsWith("https://");
3887
+ return /^https?:\/\//i.test(p);
3959
3888
  }
3960
3889
  function isInlineImageDataUrl(p) {
3961
3890
  return /^data:image\/[^;]+;base64,/i.test(p);
@@ -3990,14 +3919,48 @@ function describeMediaTargetForLog(pathValue, isHttpUrl) {
3990
3919
  return "<invalid-url>";
3991
3920
  }
3992
3921
  }
3993
- /**
3994
- * Read a local file into memory for image base64 inlining.
3995
- *
3996
- * Non-image media (video / file) should pass `source: { localPath }` to
3997
- * `sender.sendMedia` directly — the sender pipeline handles chunked
3998
- * routing once this function validates the per-type ceiling.
3999
- */
4000
- async function readLocalFileForInlineBase64(filePath, fileType) {
3922
+ function resolveStructuredPayloadReadFile(ctx) {
3923
+ return ctx.mediaAccess?.readFile ?? ctx.mediaReadFile;
3924
+ }
3925
+ function assertBufferWithinTypeLimit(buffer, fileType) {
3926
+ const maxSize = getMaxUploadSize(fileType);
3927
+ if (buffer.length > maxSize) throw new Error(`File is too large (${formatFileSize(buffer.length)}); QQ Bot API limit is ${formatFileSize(maxSize)}`);
3928
+ }
3929
+ function imageBufferMatchesMime(buffer, mimeType) {
3930
+ if (mimeType === "image/png") return buffer.subarray(0, 8).equals(Buffer.from([
3931
+ 137,
3932
+ 80,
3933
+ 78,
3934
+ 71,
3935
+ 13,
3936
+ 10,
3937
+ 26,
3938
+ 10
3939
+ ]));
3940
+ if (mimeType === "image/jpeg") return buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255;
3941
+ if (mimeType === "image/gif") {
3942
+ const header = buffer.subarray(0, 6).toString("ascii");
3943
+ return header === "GIF87a" || header === "GIF89a";
3944
+ }
3945
+ if (mimeType === "image/webp") return buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP";
3946
+ if (mimeType === "image/bmp") return buffer.subarray(0, 2).toString("ascii") === "BM";
3947
+ return false;
3948
+ }
3949
+ async function readLocalFileForInlineBase64(ctx, filePath, fileType) {
3950
+ const mediaReadFile = resolveStructuredPayloadReadFile(ctx);
3951
+ if (mediaReadFile) {
3952
+ let buffer = null;
3953
+ try {
3954
+ buffer = await mediaReadFile(filePath);
3955
+ } catch (err) {
3956
+ ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`);
3957
+ }
3958
+ if (buffer !== null) {
3959
+ assertBufferWithinTypeLimit(buffer, fileType);
3960
+ if (buffer.length === 0) throw new Error(`File is empty: ${filePath}`);
3961
+ return buffer;
3962
+ }
3963
+ }
4001
3964
  const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
4002
3965
  try {
4003
3966
  return await opened.handle.readFile();
@@ -4005,15 +3968,20 @@ async function readLocalFileForInlineBase64(filePath, fileType) {
4005
3968
  await opened.close();
4006
3969
  }
4007
3970
  }
4008
- /**
4009
- * Enforce the per-{@link MediaFileType} upload ceiling before handing a
4010
- * local path to `sender.sendMedia`. The sender's internal `normalizeSource`
4011
- * uses an unlimited cap so it can accept whatever size the policy layer
4012
- * (outbound / reply-dispatcher) approves; the policy gate lives here.
4013
- *
4014
- * Returns the validated byte size. Throws via {@link openLocalFile} with a
4015
- * human-readable "File is too large" message when exceeding the ceiling.
4016
- */
3971
+ async function readPayloadFileBuffer(ctx, filePath, fileType) {
3972
+ const mediaReadFile = resolveStructuredPayloadReadFile(ctx);
3973
+ if (!mediaReadFile) return null;
3974
+ let buffer;
3975
+ try {
3976
+ buffer = await mediaReadFile(filePath);
3977
+ } catch (err) {
3978
+ ctx.log?.debug?.(`Structured payload host read failed: ${formatErrorMessage(err)}`);
3979
+ return null;
3980
+ }
3981
+ assertBufferWithinTypeLimit(buffer, fileType);
3982
+ if (buffer.length === 0) throw new Error(`File is empty: ${filePath}`);
3983
+ return buffer;
3984
+ }
4017
3985
  async function assertLocalFileWithinTypeLimit(filePath, fileType) {
4018
3986
  const opened = await openLocalFile(filePath, { maxSize: getMaxUploadSize(fileType) });
4019
3987
  try {
@@ -4035,15 +4003,15 @@ async function handleImagePayload(ctx, payload) {
4035
4003
  if (!imageUrl) return;
4036
4004
  const originalImagePath = payload.source === "file" ? imageUrl : void 0;
4037
4005
  if (payload.source === "file") try {
4038
- const fileBuffer = await readLocalFileForInlineBase64(imageUrl, 1);
4039
- const base64Data = fileBuffer.toString("base64");
4006
+ const fileBuffer = await readLocalFileForInlineBase64(ctx, imageUrl, 1);
4040
4007
  const mimeType = getImageMimeType(imageUrl);
4041
4008
  if (!mimeType) {
4042
4009
  const ext = normalizeLowercaseStringOrEmpty$1(path.extname(imageUrl));
4043
4010
  log?.error(`Unsupported image format: ${ext}`);
4044
4011
  return;
4045
4012
  }
4046
- imageUrl = `data:${mimeType};base64,${base64Data}`;
4013
+ if (!imageBufferMatchesMime(fileBuffer, mimeType)) throw new Error(`File is not an image: ${imageUrl}`);
4014
+ imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
4047
4015
  log?.debug?.(`Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
4048
4016
  } catch (readErr) {
4049
4017
  log?.error(`Failed to read local image: ${readErr instanceof Error ? readErr.message : JSON.stringify(readErr)}`);
@@ -4061,8 +4029,8 @@ async function handleImagePayload(ctx, payload) {
4061
4029
  msgId: target.messageId,
4062
4030
  localPathForMeta: originalImagePath
4063
4031
  });
4064
- else if (deliveryTarget.type === "dm") await sendText(deliveryTarget, `![](${payload.path})`, creds, { msgId: target.messageId });
4065
- else await sendText(deliveryTarget, `![](${payload.path})`, creds, { msgId: target.messageId });
4032
+ else if (deliveryTarget.type === "dm") await sendText(deliveryTarget, `![](${imageUrl})`, creds, { msgId: target.messageId });
4033
+ else await sendText(deliveryTarget, `![](${imageUrl})`, creds, { msgId: target.messageId });
4066
4034
  }, log, account.accountId);
4067
4035
  log?.debug?.(`Sent image via media payload`);
4068
4036
  if (payload.caption) await sendTextToTarget(ctx, payload.caption);
@@ -4152,6 +4120,20 @@ async function handleVideoPayload(ctx, payload) {
4152
4120
  msgId: target.messageId
4153
4121
  });
4154
4122
  else {
4123
+ const payloadBuffer = await readPayloadFileBuffer(ctx, videoPath, 2);
4124
+ if (payloadBuffer) {
4125
+ await sendMedia({
4126
+ target: deliveryTarget,
4127
+ creds,
4128
+ kind: "video",
4129
+ source: {
4130
+ buffer: payloadBuffer,
4131
+ fileName: sanitizeFileName(path.basename(videoPath))
4132
+ },
4133
+ msgId: target.messageId
4134
+ });
4135
+ return;
4136
+ }
4155
4137
  const size = await assertLocalFileWithinTypeLimit(videoPath, 2);
4156
4138
  log?.debug?.(`Video local (${formatFileSize(size)}): ${describeMediaTargetForLog(videoPath, false)}`);
4157
4139
  await sendMedia({
@@ -4195,6 +4177,21 @@ async function handleFilePayload(ctx, payload) {
4195
4177
  fileName
4196
4178
  });
4197
4179
  else {
4180
+ const payloadBuffer = await readPayloadFileBuffer(ctx, filePath, 4);
4181
+ if (payloadBuffer) {
4182
+ await sendMedia({
4183
+ target: deliveryTarget,
4184
+ creds,
4185
+ kind: "file",
4186
+ source: {
4187
+ buffer: payloadBuffer,
4188
+ fileName
4189
+ },
4190
+ msgId: target.messageId,
4191
+ fileName
4192
+ });
4193
+ return;
4194
+ }
4198
4195
  const size = await assertLocalFileWithinTypeLimit(filePath, 4);
4199
4196
  log?.debug?.(`File local (${formatFileSize(size)}): ${describeMediaTargetForLog(filePath, false)}`);
4200
4197
  await sendMedia({
@@ -4337,7 +4334,7 @@ function findFirstClosedMediaTag(text, log) {
4337
4334
  * 媒体发送失败时,通过 onSendText 发送兜底文本通知用户。
4338
4335
  */
4339
4336
  async function executeSendQueue(queue, ctx, options = {}) {
4340
- const { mediaTarget, qualifiedTarget, account, replyToId, log } = ctx;
4337
+ const { mediaTarget, qualifiedTarget, account, replyToId, log, mediaAccess, mediaLocalRoots, mediaReadFile } = ctx;
4341
4338
  const prefix = mediaTarget.logPrefix ?? `[qqbot:${account.accountId}]`;
4342
4339
  /** 媒体发送失败时的兜底:通过 onSendText 发送错误文本给用户 */
4343
4340
  const sendFallbackText = async (errorMsg) => {
@@ -4406,7 +4403,10 @@ async function executeSendQueue(queue, ctx, options = {}) {
4406
4403
  mediaUrl: item.content,
4407
4404
  accountId: account.accountId,
4408
4405
  replyToId,
4409
- account
4406
+ account,
4407
+ ...mediaAccess ? { mediaAccess } : {},
4408
+ ...mediaLocalRoots ? { mediaLocalRoots } : {},
4409
+ ...mediaReadFile ? { mediaReadFile } : {}
4410
4410
  });
4411
4411
  if (result.error) {
4412
4412
  log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
@@ -5186,18 +5186,25 @@ var StreamingController = class {
5186
5186
  */
5187
5187
  function toMediaSendContext(ctx) {
5188
5188
  const { account, event, log } = ctx;
5189
+ const mediaAccessContext = {
5190
+ ...ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {},
5191
+ ...ctx.mediaLocalRoots ? { mediaLocalRoots: ctx.mediaLocalRoots } : {},
5192
+ ...ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}
5193
+ };
5189
5194
  return {
5190
5195
  mediaTarget: {
5191
5196
  targetType: event.type,
5192
5197
  targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid : event.channelId,
5193
5198
  account,
5194
5199
  replyToId: event.messageId,
5195
- logPrefix: `[qqbot:${account.accountId}]`
5200
+ logPrefix: `[qqbot:${account.accountId}]`,
5201
+ ...mediaAccessContext
5196
5202
  },
5197
5203
  qualifiedTarget: event.type === "group" ? `qqbot:group:${event.groupOpenid}` : `qqbot:c2c:${event.senderId}`,
5198
5204
  account,
5199
5205
  replyToId: event.messageId,
5200
- log
5206
+ log,
5207
+ ...mediaAccessContext
5201
5208
  };
5202
5209
  }
5203
5210
  /**
@@ -5702,6 +5709,9 @@ function blockReplyTextForDelivery(payload) {
5702
5709
  function isSilentBlockReply(payload) {
5703
5710
  return !hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim());
5704
5711
  }
5712
+ function isMediaOnlyBlockReply(payload) {
5713
+ return hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim());
5714
+ }
5705
5715
  /**
5706
5716
  * Dispatch the AI reply for the given inbound context.
5707
5717
  *
@@ -5711,6 +5721,13 @@ function isSilentBlockReply(payload) {
5711
5721
  async function dispatchOutbound(inbound, deps) {
5712
5722
  const { runtime, cfg, account, log } = deps;
5713
5723
  const { event, qualifiedTarget } = inbound;
5724
+ const openClawCfg = cfg;
5725
+ const routeAgentId = inbound.route.agentId ?? resolveDefaultAgentId(openClawCfg);
5726
+ const workspaceDir = resolveAgentWorkspaceDir(openClawCfg, routeAgentId);
5727
+ const gatewayMediaContext = workspaceDir ? {
5728
+ mediaAccess: { workspaceDir },
5729
+ mediaLocalRoots: [workspaceDir]
5730
+ } : {};
5714
5731
  const replyCtx = {
5715
5732
  target: {
5716
5733
  type: event.type,
@@ -5722,7 +5739,8 @@ async function dispatchOutbound(inbound, deps) {
5722
5739
  },
5723
5740
  account,
5724
5741
  cfg,
5725
- log
5742
+ log,
5743
+ ...gatewayMediaContext
5726
5744
  };
5727
5745
  const sendWithRetry = (sendFn) => sendWithTokenRetry(account.appId, account.clientSecret, sendFn, log, account.accountId);
5728
5746
  const sendErrorMessage = (errorText) => sendErrorToTarget(replyCtx, errorText);
@@ -5760,7 +5778,8 @@ async function dispatchOutbound(inbound, deps) {
5760
5778
  mediaUrl,
5761
5779
  accountId: account.accountId,
5762
5780
  replyToId: event.messageId,
5763
- account
5781
+ account,
5782
+ ...gatewayMediaContext
5764
5783
  }).then((r) => {
5765
5784
  if (ac.signal.aborted) return {
5766
5785
  channel: "qqbot",
@@ -5784,13 +5803,10 @@ async function dispatchOutbound(inbound, deps) {
5784
5803
  }
5785
5804
  };
5786
5805
  const sendToolFallback = async () => {
5787
- if (toolMediaUrls.length > 0) {
5788
- for (const mediaUrl of toolMediaUrls) await sendToolMediaWithTimeout(mediaUrl, {
5789
- resultError: "Tool fallback error",
5790
- thrownError: "Tool fallback failed"
5791
- });
5792
- return;
5793
- }
5806
+ if (toolMediaUrls.length > 0) for (const mediaUrl of toolMediaUrls) await sendToolMediaWithTimeout(mediaUrl, {
5807
+ resultError: "Tool fallback error",
5808
+ thrownError: "Tool fallback failed"
5809
+ });
5794
5810
  if (toolTexts.length > 0) await sendErrorMessage(toolTexts.slice(-3).join("\n---\n").slice(0, 2e3));
5795
5811
  };
5796
5812
  const hasPendingToolFallbackPayload = () => toolTexts.length > 0 || toolMediaUrls.length > 0;
@@ -5882,7 +5898,8 @@ async function dispatchOutbound(inbound, deps) {
5882
5898
  }, {
5883
5899
  account,
5884
5900
  qualifiedTarget,
5885
- log
5901
+ log,
5902
+ ...gatewayMediaContext
5886
5903
  }, sendWithRetry, () => void 0, deliverDeps);
5887
5904
  }
5888
5905
  };
@@ -5891,7 +5908,7 @@ async function dispatchOutbound(inbound, deps) {
5891
5908
  accountId: account.accountId,
5892
5909
  direction: "outbound"
5893
5910
  });
5894
- const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, inbound.route.agentId);
5911
+ const messagesConfig = runtime.channel.reply.resolveEffectiveMessagesConfig(cfg, routeAgentId);
5895
5912
  const useOfficialC2cStream = shouldUseOfficialC2cStream(account, event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel");
5896
5913
  const deliverToolProgressImmediately = shouldDeliverToolProgressImmediately(account, useOfficialC2cStream);
5897
5914
  let streamingController = null;
@@ -5911,12 +5928,12 @@ async function dispatchOutbound(inbound, deps) {
5911
5928
  groupOpenid: event.groupOpenid,
5912
5929
  channelId: event.channelId
5913
5930
  },
5914
- log
5931
+ log,
5932
+ ...gatewayMediaContext
5915
5933
  }
5916
5934
  });
5917
5935
  const cfgWithSession = cfg;
5918
- const agentId = inbound.route.agentId ?? "default";
5919
- const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { agentId });
5936
+ const storePath = runtime.channel.session.resolveStorePath(cfgWithSession.session?.store, { agentId: routeAgentId });
5920
5937
  const dispatchPromise = runtime.channel.inbound.run({
5921
5938
  channel: "qqbot",
5922
5939
  accountId: inbound.route.accountId,
@@ -5962,7 +5979,8 @@ async function dispatchOutbound(inbound, deps) {
5962
5979
  }, {
5963
5980
  account,
5964
5981
  qualifiedTarget,
5965
- log
5982
+ log,
5983
+ ...gatewayMediaContext
5966
5984
  }, sendWithRetry, () => void 0, deliverDeps);
5967
5985
  recordOutbound();
5968
5986
  return;
@@ -5980,7 +5998,8 @@ async function dispatchOutbound(inbound, deps) {
5980
5998
  mediaUrl,
5981
5999
  accountId: account.accountId,
5982
6000
  replyToId: event.messageId,
5983
- account
6001
+ account,
6002
+ ...gatewayMediaContext
5984
6003
  });
5985
6004
  } catch {}
5986
6005
  return;
@@ -5995,7 +6014,7 @@ async function dispatchOutbound(inbound, deps) {
5995
6014
  return;
5996
6015
  }
5997
6016
  hasVisibleBlockResponse = true;
5998
- if (streamingController && !streamingController.isTerminalPhase) {
6017
+ if (streamingController && !streamingController.isTerminalPhase && !isMediaOnlyBlockReply(payload)) {
5999
6018
  try {
6000
6019
  await streamingController.onDeliver(payload);
6001
6020
  } catch (err) {
@@ -6032,7 +6051,8 @@ async function dispatchOutbound(inbound, deps) {
6032
6051
  const deliverActx = {
6033
6052
  account,
6034
6053
  qualifiedTarget,
6035
- log
6054
+ log,
6055
+ ...gatewayMediaContext
6036
6056
  };
6037
6057
  const mediaResult = await parseAndSendMediaTags(replyText, deliverEvent, deliverActx, sendWithRetry, consumeQuoteRef, deliverDeps);
6038
6058
  if (mediaResult.handled) {
@@ -6153,7 +6173,7 @@ async function buildCtxPayload(inbound, runtime, cfg) {
6153
6173
  id: inbound.peerId
6154
6174
  },
6155
6175
  route: {
6156
- agentId: inbound.route.agentId ?? "main",
6176
+ agentId: inbound.route.agentId ?? resolveDefaultAgentId(cfg),
6157
6177
  routeSessionKey: inbound.route.sessionKey,
6158
6178
  accountId: inbound.route.accountId
6159
6179
  },