@openclaw/qqbot 2026.7.1 → 2026.7.2-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.
Files changed (41) hide show
  1. package/dist/api.js +8 -7
  2. package/dist/{channel-entry-C5YdhX3Y.js → channel-entry-CA2T2sf8.js} +4 -4
  3. package/dist/channel-entry-api.js +1 -1
  4. package/dist/{channel-D1UztsnG.js → channel-nz3Mkify.js} +99 -54
  5. package/dist/channel-plugin-api.js +1 -1
  6. package/dist/{channel.setup-2ItDYKhz.js → channel.setup-CNoBtKXQ.js} +1 -1
  7. package/dist/{config-C1qZbh0K.js → config-CpOXnoEc.js} +2 -2
  8. package/dist/{config-schema-JZEf1dvB.js → config-schema-D7MaH5X5.js} +61 -76
  9. package/dist/doctor-contract-api.js +4 -10
  10. package/dist/{gateway-pJQppxe4.js → gateway-D8uYPtoy.js} +286 -174
  11. package/dist/{group-o0GmovSf.js → group-BVHG8qUZ.js} +40 -23
  12. package/dist/{handler-runtime-zQvT6SrI.js → handler-runtime-S1_XF8on.js} +8 -12
  13. package/dist/{log-DEtcoDWe.js → log-Da4jz75I.js} +4 -8
  14. package/dist/{outbound-BIrfvvFJ.js → outbound-FOG4zNLY.js} +8 -142
  15. package/dist/{runtime-DodcT_fQ.js → runtime-CyjBiGD2.js} +2 -2
  16. package/dist/runtime-api.js +1 -1
  17. package/dist/secret-contract-api.js +6 -22
  18. package/dist/{sender-CjDuU-uz.js → sender-BAUHZqDW.js} +225 -66
  19. package/dist/setup-plugin-api.js +1 -1
  20. package/dist/state-keys-jLJ2SmJA.js +225 -0
  21. package/dist/{tools-UJJ-tLHP.js → tools-CC5CKQig.js} +71 -26
  22. package/dist/tools-api.js +1 -1
  23. package/node_modules/p-map/index.d.ts +155 -0
  24. package/node_modules/p-map/index.js +284 -0
  25. package/node_modules/p-map/license +9 -0
  26. package/node_modules/p-map/package.json +57 -0
  27. package/node_modules/p-map/readme.md +190 -0
  28. package/node_modules/parse-ms/index.d.ts +30 -0
  29. package/node_modules/parse-ms/index.js +45 -0
  30. package/node_modules/parse-ms/license +9 -0
  31. package/node_modules/parse-ms/package.json +47 -0
  32. package/node_modules/parse-ms/readme.md +46 -0
  33. package/node_modules/pretty-ms/index.d.ts +157 -0
  34. package/node_modules/pretty-ms/index.js +149 -0
  35. package/node_modules/pretty-ms/license +9 -0
  36. package/node_modules/pretty-ms/package.json +55 -0
  37. package/node_modules/pretty-ms/readme.md +179 -0
  38. package/npm-shrinkwrap.json +44 -3
  39. package/openclaw.plugin.json +105 -133
  40. package/package.json +8 -4
  41. package/dist/state-keys-CQKlAFyo.js +0 -141
@@ -1,14 +1,15 @@
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";
1
+ import { B as StreamContentType, F as formatFileSize, H as StreamInputState, L as getImageMimeType, N as downloadFile, R as getMaxUploadSize, V as StreamInputMode, 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 openLocalFile, k as getNextMsgSeq, 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, w as claimMessageReply, x as withTokenRetry, y as startBackgroundTokenRefresh } from "./sender-BAUHZqDW.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-JZEf1dvB.js";
4
- import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-D1UztsnG.js";
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-DEtcoDWe.js";
7
- import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-DodcT_fQ.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-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";
3
+ import { c as setBridgeLogger, o as ensurePlatformAdapter } from "./config-schema-D7MaH5X5.js";
4
+ import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-nz3Mkify.js";
5
+ import { d as resolveAccountBase } from "./config-CpOXnoEc.js";
6
+ import { a as formatErrorMessage, i as formatDuration, n as debugLog, r as debugWarn, t as debugError } from "./log-Da4jz75I.js";
7
+ import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-CyjBiGD2.js";
8
+ import { t as buildQQBotStateKey } from "./state-keys-jLJ2SmJA.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-BVHG8qUZ.js";
10
+ import { A as getQQBotMediaDir, C as resolveUserFacingMediaError, D as checkSilkWasmAvailable, E as setOutboundAudioPort, F as normalizePath, M as getTempDir, N as isLocalPath, O as getHomeDir, P as isWindows, S as resolveWorkspaceScopedLocalRoots, _ as sendVideoMsg, b as resolveOutboundMediaLocalRoots, c as isCronReminderPayload, d as normalizeMediaTags, g as sendPhoto, h as sendDocument, k as getQQBotDataDir, l as isMediaPayload, o as decodeMediaPath, r as sendMedia$1, s as encodePayloadForCron, u as parseQQBotPayload, v as sendVoice, w as DEFAULT_MEDIA_SEND_ERROR, x as resolveWorkspacePathCandidates, y as resolveTrustedOutboundMediaPath } from "./outbound-FOG4zNLY.js";
11
11
  import { n as runWithRequestContext } from "./request-context-Bm7PTBD1.js";
12
+ import { createTypingKeepaliveLoop } from "openclaw/plugin-sdk/channel-outbound";
12
13
  import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
13
14
  import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
14
15
  import { isImplicitSameChatApprovalAuthorization } from "openclaw/plugin-sdk/approval-auth-runtime";
@@ -18,11 +19,12 @@ import * as os$1 from "node:os";
18
19
  import { readProviderJsonResponse, readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
19
20
  import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
20
21
  import crypto from "node:crypto";
21
- import { readRegularFileSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
22
+ import { compileSafeRegexDetailed, readRegularFileSync, resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
22
23
  import * as path$1 from "node:path";
23
24
  import path from "node:path";
24
- import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
25
+ import { mimeTypeFromFilePath, normalizeMimeType } from "openclaw/plugin-sdk/media-mime";
25
26
  import { MAX_TIMER_TIMEOUT_MS, finiteSecondsToTimerSafeMilliseconds } from "openclaw/plugin-sdk/number-runtime";
27
+ import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
26
28
  import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
27
29
  import { resolveRuntimeServiceVersion } from "openclaw/plugin-sdk/cli-runtime";
28
30
  import WebSocket from "ws";
@@ -1079,9 +1081,9 @@ function isSlashCommand(msg) {
1079
1081
  */
1080
1082
  function mergeGroupMessages(batch) {
1081
1083
  if (batch.length === 0) throw new Error("mergeGroupMessages: empty batch");
1082
- if (batch.length === 1) return batch[0];
1083
- const first = batch[0];
1084
- const last = batch[batch.length - 1];
1084
+ if (batch.length === 1) return expectDefined(batch.at(0), "single-message merge batch");
1085
+ const first = expectDefined(batch.at(0), "non-empty merge batch first message");
1086
+ const last = expectDefined(batch.at(-1), "non-empty merge batch last message");
1085
1087
  const mergedContent = batch.map((m) => `[${m.senderName ?? m.senderId}]: ${m.content}`).join("\n");
1086
1088
  const mergedAttachments = [];
1087
1089
  for (const m of batch) if (m.attachments?.length) mergedAttachments.push(...m.attachments);
@@ -1275,6 +1277,14 @@ function createMessageQueue(ctx) {
1275
1277
  //#endregion
1276
1278
  //#region extensions/qqbot/src/engine/gateway/reconnect.ts
1277
1279
  /**
1280
+ * WebSocket reconnection state machine and close-code handler.
1281
+ *
1282
+ * Encapsulates the reconnect delay scheduling, quick-disconnect detection,
1283
+ * and close-code interpretation that both plugin versions share.
1284
+ *
1285
+ * Zero external dependencies — uses only the constants from `./constants.ts`.
1286
+ */
1287
+ /**
1278
1288
  * Reconnection state machine.
1279
1289
  *
1280
1290
  * Usage:
@@ -1314,7 +1324,7 @@ var ReconnectState = class {
1314
1324
  * @returns Delay in milliseconds.
1315
1325
  */
1316
1326
  getNextDelay(customDelay) {
1317
- const delay = customDelay ?? RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)];
1327
+ const delay = customDelay ?? expectDefined(RECONNECT_DELAYS[Math.min(this.attempts, RECONNECT_DELAYS.length - 1)], "non-empty reconnect delay schedule");
1318
1328
  this.attempts++;
1319
1329
  this.log?.debug?.(`Reconnecting ${this.accountId} in ${delay}ms (attempt ${this.attempts})`);
1320
1330
  return delay;
@@ -1356,18 +1366,18 @@ var ReconnectState = class {
1356
1366
  };
1357
1367
  }
1358
1368
  if (code === GatewayCloseCode.INVALID_SESSION || code === GatewayCloseCode.SEQ_OUT_OF_RANGE || code === GatewayCloseCode.SESSION_TIMEOUT) {
1359
- const codeDesc = {
1369
+ const reason = expectDefined({
1360
1370
  [GatewayCloseCode.INVALID_SESSION]: "session no longer valid",
1361
1371
  [GatewayCloseCode.SEQ_OUT_OF_RANGE]: "invalid seq on resume",
1362
1372
  [GatewayCloseCode.SESSION_TIMEOUT]: "session timed out"
1363
- };
1364
- this.log?.info(`Error ${code} (${codeDesc[code]}), will re-identify`);
1373
+ }[code], "recognized session close code");
1374
+ this.log?.info(`Error ${code} (${reason}), will re-identify`);
1365
1375
  return {
1366
1376
  shouldReconnect: !isAborted,
1367
1377
  clearSession: true,
1368
1378
  refreshToken: true,
1369
1379
  fatal: false,
1370
- reason: codeDesc[code]
1380
+ reason
1371
1381
  };
1372
1382
  }
1373
1383
  if (code >= GatewayCloseCode.SERVER_ERROR_START && code <= GatewayCloseCode.SERVER_ERROR_END) {
@@ -1408,10 +1418,12 @@ var ReconnectState = class {
1408
1418
  };
1409
1419
  //#endregion
1410
1420
  //#region extensions/qqbot/src/engine/gateway/ws-client.ts
1421
+ const QQBOT_WEBSOCKET_HANDSHAKE_TIMEOUT_MS = 3e4;
1411
1422
  async function createQQWSClient(options) {
1412
1423
  const wsAgent = await resolveAmbientNodeProxyAgent();
1413
1424
  return new WebSocket(options.gatewayUrl, {
1414
1425
  headers: { "User-Agent": options.userAgent },
1426
+ handshakeTimeout: QQBOT_WEBSOCKET_HANDSHAKE_TIMEOUT_MS,
1415
1427
  ...wsAgent ? { agent: wsAgent } : {}
1416
1428
  });
1417
1429
  }
@@ -1683,6 +1695,14 @@ var GatewayConnection = class {
1683
1695
  * of broad SDK text barrels.
1684
1696
  */
1685
1697
  const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
1698
+ const DEFAULT_STT_TIMEOUT_MS = 6e4;
1699
+ function resolveSTTTimeoutMs(...timeoutSeconds) {
1700
+ for (const value of timeoutSeconds) {
1701
+ const timeoutMs = finiteSecondsToTimerSafeMilliseconds(value);
1702
+ if (timeoutMs !== void 0) return timeoutMs;
1703
+ }
1704
+ return DEFAULT_STT_TIMEOUT_MS;
1705
+ }
1686
1706
  /** Resolve the STT configuration from the nested config object. */
1687
1707
  function resolveSTTConfig(cfg) {
1688
1708
  const channelStt = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.channels)?.qqbot)?.stt);
@@ -1696,10 +1716,12 @@ function resolveSTTConfig(cfg) {
1696
1716
  if (baseUrl && apiKey) return {
1697
1717
  baseUrl: baseUrl.replace(/\/+$/, ""),
1698
1718
  apiKey,
1699
- model
1719
+ model,
1720
+ timeoutMs: resolveSTTTimeoutMs(providerCfg?.timeoutSeconds)
1700
1721
  };
1701
1722
  }
1702
- const audioModels = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.tools)?.media)?.audio)?.models;
1723
+ const audio = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.tools)?.media)?.audio);
1724
+ const audioModels = audio?.models;
1703
1725
  const audioModelEntry = Array.isArray(audioModels) ? asOptionalObjectRecord(audioModels[0]) : void 0;
1704
1726
  if (audioModelEntry) {
1705
1727
  const providerId = readStringField(audioModelEntry, "provider") ?? "openai";
@@ -1710,7 +1732,8 @@ function resolveSTTConfig(cfg) {
1710
1732
  if (baseUrl && apiKey) return {
1711
1733
  baseUrl: baseUrl.replace(/\/+$/, ""),
1712
1734
  apiKey,
1713
- model
1735
+ model,
1736
+ timeoutMs: resolveSTTTimeoutMs(audioModelEntry.timeoutSeconds, audio?.timeoutSeconds, providerCfg?.timeoutSeconds)
1714
1737
  };
1715
1738
  }
1716
1739
  return null;
@@ -1728,6 +1751,7 @@ async function transcribeAudio(audioPath, cfg) {
1728
1751
  const { response: resp, release } = await fetchWithSsrFGuard({
1729
1752
  url: `${sttCfg.baseUrl}/audio/transcriptions`,
1730
1753
  auditContext: "qqbot-stt",
1754
+ timeoutMs: sttCfg.timeoutMs,
1731
1755
  init: {
1732
1756
  method: "POST",
1733
1757
  headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
@@ -1796,18 +1820,19 @@ async function processAttachments(attachments, ctx) {
1796
1820
  });
1797
1821
  const processTasks = (await Promise.all(downloadTasks)).map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
1798
1822
  const asrReferText = normalizeOptionalString$1(att.asr_refer_text) ?? "";
1823
+ const normalizedContentType = normalizeMimeType(att.content_type) ?? "";
1799
1824
  const voiceSourceUrl = (isVoice && att.voice_wav_url ? att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url : "") || attUrl;
1800
1825
  const meta = {
1801
1826
  voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : void 0,
1802
1827
  asrReferText: isVoice && asrReferText ? asrReferText : void 0
1803
1828
  };
1804
1829
  if (localPath) {
1805
- if (att.content_type?.startsWith("image/")) {
1830
+ if (normalizedContentType.startsWith("image/")) {
1806
1831
  log?.debug?.(`Downloaded attachment to: ${localPath}`);
1807
1832
  return {
1808
1833
  localPath,
1809
1834
  type: "image",
1810
- contentType: att.content_type,
1835
+ contentType: normalizedContentType,
1811
1836
  meta
1812
1837
  };
1813
1838
  }
@@ -1824,11 +1849,11 @@ async function processAttachments(attachments, ctx) {
1824
1849
  };
1825
1850
  }
1826
1851
  log?.error(`Failed to download: ${attUrl}`);
1827
- if (att.content_type?.startsWith("image/")) return {
1852
+ if (normalizedContentType.startsWith("image/")) return {
1828
1853
  localPath: null,
1829
1854
  type: "image-fallback",
1830
1855
  attUrl,
1831
- contentType: att.content_type,
1856
+ contentType: normalizedContentType,
1832
1857
  meta
1833
1858
  };
1834
1859
  if (isVoice && asrReferText) {
@@ -2106,15 +2131,52 @@ function buildQualifiedTarget(event, isGroupChat) {
2106
2131
  //#endregion
2107
2132
  //#region extensions/qqbot/src/engine/group/mention.ts
2108
2133
  const MENTION_TAG_RE = /<@!?\w+>/;
2134
+ const MENTION_PATTERN_FLAGS = "i";
2135
+ const MAX_MENTION_PATTERN_CACHE_KEYS = 256;
2136
+ const MAX_MENTION_PATTERN_WARNING_KEYS = 256;
2137
+ const mentionPatternCompileCache = /* @__PURE__ */ new Map();
2138
+ const rejectedMentionPatternWarningCache = /* @__PURE__ */ new Set();
2139
+ function warnRejectedMentionPattern(pattern, reason) {
2140
+ const key = `${MENTION_PATTERN_FLAGS}::${reason}::${pattern}`;
2141
+ if (rejectedMentionPatternWarningCache.has(key)) return;
2142
+ rejectedMentionPatternWarningCache.add(key);
2143
+ if (rejectedMentionPatternWarningCache.size > MAX_MENTION_PATTERN_WARNING_KEYS) {
2144
+ rejectedMentionPatternWarningCache.clear();
2145
+ rejectedMentionPatternWarningCache.add(key);
2146
+ }
2147
+ debugWarn(`qqbot: mentionPattern rejected (${reason}): ${pattern}`);
2148
+ }
2149
+ function cacheMentionPatterns(cacheKey, regexes) {
2150
+ mentionPatternCompileCache.set(cacheKey, regexes);
2151
+ if (mentionPatternCompileCache.size > MAX_MENTION_PATTERN_CACHE_KEYS) {
2152
+ mentionPatternCompileCache.clear();
2153
+ mentionPatternCompileCache.set(cacheKey, regexes);
2154
+ }
2155
+ return regexes;
2156
+ }
2157
+ function compileMentionPatterns(patterns) {
2158
+ if (patterns.length === 0) return [];
2159
+ const cacheKey = patterns.join("");
2160
+ const cached = mentionPatternCompileCache.get(cacheKey);
2161
+ if (cached) return cached;
2162
+ const regexes = [];
2163
+ for (const pattern of patterns) {
2164
+ const result = compileSafeRegexDetailed(pattern, MENTION_PATTERN_FLAGS);
2165
+ if (result.reason === "empty") continue;
2166
+ if (result.regex) {
2167
+ regexes.push(result.regex);
2168
+ continue;
2169
+ }
2170
+ warnRejectedMentionPattern(result.source, result.reason);
2171
+ }
2172
+ return cacheMentionPatterns(cacheKey, regexes);
2173
+ }
2109
2174
  function detectWasMentioned(input) {
2110
2175
  const { eventType, mentions, content, mentionPatterns } = input;
2111
2176
  if (mentions?.some((m) => m.is_you)) return true;
2112
2177
  if (eventType === "GROUP_AT_MESSAGE_CREATE") return true;
2113
- if (mentionPatterns?.length && content) for (const pattern of mentionPatterns) {
2114
- if (!pattern) continue;
2115
- try {
2116
- if (new RegExp(pattern, "i").test(content)) return true;
2117
- } catch {}
2178
+ if (mentionPatterns?.length && content) {
2179
+ for (const regex of compileMentionPatterns(mentionPatterns)) if (regex.test(content)) return true;
2118
2180
  }
2119
2181
  return false;
2120
2182
  }
@@ -2253,7 +2315,7 @@ function buildUserMessage(input) {
2253
2315
  const { event, userContent, quotePart, isGroupChat, groupInfo } = input;
2254
2316
  if (groupInfo?.isMerged && groupInfo.mergedMessages?.length) {
2255
2317
  const preceding = groupInfo.mergedMessages.slice(0, -1);
2256
- const lastMsg = groupInfo.mergedMessages[groupInfo.mergedMessages.length - 1];
2318
+ const lastMsg = expectDefined(groupInfo.mergedMessages.at(-1), "non-empty merged group turn");
2257
2319
  const atYouTag = groupInfo.gate.effectiveWasMentioned ? " (@you)" : "";
2258
2320
  return buildMergedMessageContext({
2259
2321
  precedingParts: preceding.map((m) => `[${formatSenderLabel(m)}] ${formatSub(m)}`),
@@ -2424,6 +2486,7 @@ function classifyMedia(processed) {
2424
2486
  for (let i = 0; i < processed.imageUrls.length; i++) {
2425
2487
  const u = processed.imageUrls[i];
2426
2488
  const t = processed.imageMediaTypes[i] ?? "image/png";
2489
+ if (u === void 0) continue;
2427
2490
  if (u.startsWith("http://") || u.startsWith("https://")) {
2428
2491
  remoteMediaUrls.push(u);
2429
2492
  remoteMediaTypes.push(t);
@@ -2449,6 +2512,50 @@ function classifyMedia(processed) {
2449
2512
  };
2450
2513
  }
2451
2514
  //#endregion
2515
+ //#region extensions/qqbot/src/engine/group/message-gating.ts
2516
+ /**
2517
+ * Group gate Layer 1 (ignoreOtherMentions) is QQ-specific and decided here;
2518
+ * Layer 2+3 (command gating + mention gating + command bypass) delegate to the
2519
+ * mention gate port backed by the SDK's `resolveInboundMentionDecision`.
2520
+ */
2521
+ function resolveGroupMessageGate(params) {
2522
+ if (params.ignoreOtherMentions && params.hasAnyMention && !params.wasMentioned && !params.implicitMention) return {
2523
+ action: "drop_other_mention",
2524
+ effectiveWasMentioned: false,
2525
+ shouldBypassMention: false
2526
+ };
2527
+ const decision = params.mentionGatePort.resolveInboundMentionDecision({
2528
+ facts: {
2529
+ canDetectMention: true,
2530
+ wasMentioned: params.wasMentioned,
2531
+ hasAnyMention: params.hasAnyMention,
2532
+ implicitMentionKinds: params.implicitMention ? ["reply_to_bot"] : []
2533
+ },
2534
+ policy: {
2535
+ isGroup: true,
2536
+ requireMention: params.requireMention,
2537
+ allowTextCommands: params.allowTextCommands,
2538
+ hasControlCommand: params.isControlCommand,
2539
+ commandAuthorized: params.commandAuthorized
2540
+ }
2541
+ });
2542
+ if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) return {
2543
+ action: "block_unauthorized_command",
2544
+ effectiveWasMentioned: false,
2545
+ shouldBypassMention: false
2546
+ };
2547
+ if (decision.shouldSkip) return {
2548
+ action: "skip_no_mention",
2549
+ effectiveWasMentioned: decision.effectiveWasMentioned,
2550
+ shouldBypassMention: decision.shouldBypassMention
2551
+ };
2552
+ return {
2553
+ action: "pass",
2554
+ effectiveWasMentioned: decision.effectiveWasMentioned,
2555
+ shouldBypassMention: decision.shouldBypassMention
2556
+ };
2557
+ }
2558
+ //#endregion
2452
2559
  //#region extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts
2453
2560
  function runGroupGateStage(input) {
2454
2561
  const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
@@ -2487,7 +2594,7 @@ function runGroupGateStage(input) {
2487
2594
  const content = (event.content ?? "").trim();
2488
2595
  const isControlCommand = Boolean(deps.isControlCommand?.(content));
2489
2596
  const commandAuthorized = deps.allowTextCommands !== false && input.access.commandAccess.authorized;
2490
- const gate = resolveGateWithPort({
2597
+ const gate = resolveGroupMessageGate({
2491
2598
  mentionGatePort: deps.adapters.mentionGate,
2492
2599
  ignoreOtherMentions,
2493
2600
  hasAnyMention: anyMention,
@@ -2543,43 +2650,6 @@ function runGroupGateStage(input) {
2543
2650
  skipReason: gate.action
2544
2651
  };
2545
2652
  }
2546
- function resolveGateWithPort(params) {
2547
- if (params.ignoreOtherMentions && params.hasAnyMention && !params.wasMentioned && !params.implicitMention) return {
2548
- action: "drop_other_mention",
2549
- effectiveWasMentioned: false,
2550
- shouldBypassMention: false
2551
- };
2552
- const decision = params.mentionGatePort.resolveInboundMentionDecision({
2553
- facts: {
2554
- canDetectMention: true,
2555
- wasMentioned: params.wasMentioned,
2556
- hasAnyMention: params.hasAnyMention,
2557
- implicitMentionKinds: params.implicitMention ? ["reply_to_bot"] : []
2558
- },
2559
- policy: {
2560
- isGroup: true,
2561
- requireMention: params.requireMention,
2562
- allowTextCommands: params.allowTextCommands,
2563
- hasControlCommand: params.isControlCommand,
2564
- commandAuthorized: params.commandAuthorized
2565
- }
2566
- });
2567
- if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) return {
2568
- action: "block_unauthorized_command",
2569
- effectiveWasMentioned: false,
2570
- shouldBypassMention: false
2571
- };
2572
- if (decision.shouldSkip) return {
2573
- action: "skip_no_mention",
2574
- effectiveWasMentioned: decision.effectiveWasMentioned,
2575
- shouldBypassMention: decision.shouldBypassMention
2576
- };
2577
- return {
2578
- action: "pass",
2579
- effectiveWasMentioned: decision.effectiveWasMentioned,
2580
- shouldBypassMention: decision.shouldBypassMention
2581
- };
2582
- }
2583
2653
  function recordGroupHistory(params) {
2584
2654
  const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } = params;
2585
2655
  if (!historyMap || historyLimit <= 0) return;
@@ -3032,7 +3102,7 @@ async function handleApprovalButtonInteraction(params) {
3032
3102
  cfg,
3033
3103
  account: params.account,
3034
3104
  event: params.event,
3035
- approvalKind: resolveApprovalKind(params.parsed.approvalId),
3105
+ approvalKind: params.parsed.approvalKind,
3036
3106
  resolveCommandAuthorized: params.resolveCommandAuthorized
3037
3107
  });
3038
3108
  if (!authorization.authorized) {
@@ -3040,19 +3110,65 @@ async function handleApprovalButtonInteraction(params) {
3040
3110
  params.log?.info(`Approval button rejected: id=${params.parsed.approvalId}`);
3041
3111
  return;
3042
3112
  }
3043
- await acknowledgeApprovalInteraction(params.creds, params.event, params.log);
3113
+ await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { content: "Approval response received." });
3044
3114
  const adapter = getPlatformAdapter();
3045
3115
  if (!adapter.resolveApproval) {
3116
+ await reportApprovalInteractionOutcome({
3117
+ creds: params.creds,
3118
+ event: params.event,
3119
+ log: params.log,
3120
+ content: "Approval is unavailable."
3121
+ });
3046
3122
  params.log?.error("resolveApproval not available on PlatformAdapter");
3047
3123
  return;
3048
3124
  }
3049
3125
  try {
3050
- if (await adapter.resolveApproval(params.parsed.approvalId, params.parsed.decision)) params.log?.info(`Approval resolved: id=${params.parsed.approvalId}, decision=${params.parsed.decision}`);
3051
- else params.log?.error(`Approval resolve failed: id=${params.parsed.approvalId}`);
3126
+ const result = await adapter.resolveApproval(params.parsed);
3127
+ const canonicalDecision = "decision" in result.approval ? `, decision=${result.approval.decision}` : "";
3128
+ const canonicalOutcome = formatCanonicalApprovalOutcome(result.approval);
3129
+ await reportApprovalInteractionOutcome({
3130
+ creds: params.creds,
3131
+ event: params.event,
3132
+ log: params.log,
3133
+ content: result.applied ? `Approval resolved: ${canonicalOutcome}.` : `This approval was already resolved: ${canonicalOutcome}.`
3134
+ });
3135
+ params.log?.info(result.applied ? `Approval resolved: id=${result.approval.id}, status=${result.approval.status}${canonicalDecision}` : `Approval already resolved: id=${result.approval.id}, status=${result.approval.status}${canonicalDecision}`);
3052
3136
  } catch (err) {
3137
+ await reportApprovalInteractionOutcome({
3138
+ creds: params.creds,
3139
+ event: params.event,
3140
+ log: params.log,
3141
+ content: "Approval could not be resolved."
3142
+ });
3053
3143
  params.log?.error(`Approval resolve failed: id=${params.parsed.approvalId}: ${err instanceof Error ? err.message : String(err)}`);
3054
3144
  }
3055
3145
  }
3146
+ async function reportApprovalInteractionOutcome(params) {
3147
+ const target = params.event.group_openid ? {
3148
+ type: "group",
3149
+ id: params.event.group_openid
3150
+ } : params.event.user_openid ? {
3151
+ type: "c2c",
3152
+ id: params.event.user_openid
3153
+ } : params.event.channel_id ? {
3154
+ type: "channel",
3155
+ id: params.event.channel_id
3156
+ } : null;
3157
+ if (!target) {
3158
+ params.log?.info(`Approval interaction outcome: ${params.content}`);
3159
+ return;
3160
+ }
3161
+ try {
3162
+ await sendText(target, params.content, params.creds, { msgId: params.event.data.resolved.message_id });
3163
+ } catch (err) {
3164
+ params.log?.error(`Approval outcome delivery failed: ${err instanceof Error ? err.message : String(err)}`);
3165
+ }
3166
+ }
3167
+ function formatCanonicalApprovalOutcome(approval) {
3168
+ if (approval.status === "allowed") return approval.decision === "allow-always" ? "Allowed always" : "Allowed once";
3169
+ if (approval.status === "denied") return "Denied";
3170
+ return approval.status === "expired" ? "Expired" : "Cancelled";
3171
+ }
3056
3172
  async function acknowledgeApprovalInteraction(creds, event, log, data) {
3057
3173
  try {
3058
3174
  await acknowledgeInteraction(creds, event.id, 0, data);
@@ -3135,9 +3251,6 @@ function resolveApprovalActorSenderIds(event) {
3135
3251
  return normalized ? [normalized] : [];
3136
3252
  }));
3137
3253
  }
3138
- function resolveApprovalKind(approvalId) {
3139
- return approvalId.toLowerCase().startsWith("plugin:") ? "plugin" : "exec";
3140
- }
3141
3254
  /** Execute an async handler, ACK with the result, and handle errors. */
3142
3255
  async function handleWithAck(creds, event, log, label, handler) {
3143
3256
  try {
@@ -3217,7 +3330,11 @@ function parseWebpSize(buffer) {
3217
3330
  if (riff !== "RIFF" || webp !== "WEBP") return null;
3218
3331
  const chunkType = buffer.toString("ascii", 12, 16);
3219
3332
  if (chunkType === "VP8 ") {
3220
- if (buffer.length >= 30 && buffer[23] === 157 && buffer[24] === 1 && buffer[25] === 42) return {
3333
+ if (buffer.length >= 30 && buffer.subarray(23, 26).equals(Buffer$1.from([
3334
+ 157,
3335
+ 1,
3336
+ 42
3337
+ ]))) return {
3221
3338
  width: buffer.readUInt16LE(26) & 16383,
3222
3339
  height: buffer.readUInt16LE(28) & 16383
3223
3340
  };
@@ -3233,8 +3350,8 @@ function parseWebpSize(buffer) {
3233
3350
  }
3234
3351
  if (chunkType === "VP8X") {
3235
3352
  if (buffer.length >= 30) return {
3236
- width: (buffer[24] | buffer[25] << 8 | buffer[26] << 16) + 1,
3237
- height: (buffer[27] | buffer[28] << 8 | buffer[29] << 16) + 1
3353
+ width: buffer.readUIntLE(24, 3) + 1,
3354
+ height: buffer.readUIntLE(27, 3) + 1
3238
3355
  };
3239
3356
  }
3240
3357
  return null;
@@ -3274,13 +3391,13 @@ async function getImageSizeFromUrl(url, timeoutMs = 5e3) {
3274
3391
  }
3275
3392
  });
3276
3393
  const size = parseImageSize(buffer);
3277
- if (size) debugLog(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
3394
+ if (size) debugLog(`[image-size] Got size from URL: ${size.width}x${size.height} - ${truncateUtf16Safe(url, 60)}...`);
3278
3395
  return size;
3279
3396
  } finally {
3280
3397
  clearTimeout(timeoutId);
3281
3398
  }
3282
3399
  } catch (err) {
3283
- debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${formatErrorMessage(err)}`);
3400
+ debugLog(`[image-size] Error fetching ${truncateUtf16Safe(url, 60)}...: ${formatErrorMessage(err)}`);
3284
3401
  return null;
3285
3402
  }
3286
3403
  }
@@ -3290,6 +3407,7 @@ function getImageSizeFromDataUrl(dataUrl) {
3290
3407
  const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
3291
3408
  if (!matches) return null;
3292
3409
  const base64Data = matches[1];
3410
+ if (base64Data === void 0) return null;
3293
3411
  const size = parseImageSize(Buffer$1.from(base64Data, "base64"));
3294
3412
  if (size) debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
3295
3413
  return size;
@@ -3316,6 +3434,25 @@ function hasQQBotImageSize(markdownImage) {
3316
3434
  return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
3317
3435
  }
3318
3436
  //#endregion
3437
+ //#region extensions/qqbot/src/engine/messaging/race-with-timeout.ts
3438
+ async function raceWithTimeout(operation, timeoutMs, onTimeout) {
3439
+ let timedOut = false;
3440
+ let timeout;
3441
+ const state = { get timedOut() {
3442
+ return timedOut;
3443
+ } };
3444
+ try {
3445
+ return await Promise.race([operation(state), new Promise((resolve) => {
3446
+ timeout = setTimeout(() => {
3447
+ timedOut = true;
3448
+ resolve(onTimeout());
3449
+ }, timeoutMs);
3450
+ })]);
3451
+ } finally {
3452
+ if (timeout !== void 0) clearTimeout(timeout);
3453
+ }
3454
+ }
3455
+ //#endregion
3319
3456
  //#region extensions/qqbot/src/engine/messaging/outbound-deliver.ts
3320
3457
  /**
3321
3458
  * Outbound delivery helpers — core/ version.
@@ -3461,10 +3598,9 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3461
3598
  const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
3462
3599
  const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
3463
3600
  const voiceTimeout = 45e3;
3464
- const ac = new AbortController();
3465
3601
  try {
3466
- const result = await Promise.race([mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
3467
- if (ac.signal.aborted) {
3602
+ const result = await raceWithTimeout((timeoutState) => mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
3603
+ if (timeoutState.timedOut) {
3468
3604
  log?.debug?.(`sendVoice completed after timeout, suppressing late delivery`);
3469
3605
  return {
3470
3606
  channel: "qqbot",
@@ -3472,15 +3608,10 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3472
3608
  };
3473
3609
  }
3474
3610
  return r;
3475
- }), new Promise((resolve) => {
3476
- setTimeout(() => {
3477
- ac.abort();
3478
- resolve({
3479
- channel: "qqbot",
3480
- error: "Voice send timed out and was skipped"
3481
- });
3482
- }, voiceTimeout);
3483
- })]);
3611
+ }), voiceTimeout, () => ({
3612
+ channel: "qqbot",
3613
+ error: "Voice send timed out and was skipped"
3614
+ }));
3484
3615
  if (result.error) {
3485
3616
  log?.error(`sendVoice error: ${result.error}`);
3486
3617
  return false;
@@ -3738,7 +3869,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3738
3869
  log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`);
3739
3870
  }
3740
3871
  }
3741
- const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
3872
+ const existingMdUrls = new Set(mdMatches.flatMap((m) => m[2] === void 0 ? [] : [m[2]]));
3742
3873
  const imagesToAppend = [];
3743
3874
  for (const url of httpImageUrls) if (!existingMdUrls.has(url)) try {
3744
3875
  const size = await getImageSize(url);
@@ -3752,6 +3883,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3752
3883
  for (const m of mdMatches) {
3753
3884
  const fullMatch = m[0];
3754
3885
  const imgUrl = m[2];
3886
+ if (fullMatch === void 0 || imgUrl === void 0) continue;
3755
3887
  if (isHttpUrl(imgUrl) && !hasQQBotImageSize(fullMatch)) try {
3756
3888
  const size = await getImageSize(imgUrl);
3757
3889
  result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
@@ -3888,7 +4020,7 @@ async function handleStructuredPayload(ctx, replyText, recordActivity, deps) {
3888
4020
  return true;
3889
4021
  }
3890
4022
  function formatMediaTypeLabel(mediaType) {
3891
- return mediaType[0].toUpperCase() + mediaType.slice(1);
4023
+ return mediaType.charAt(0).toUpperCase() + mediaType.slice(1);
3892
4024
  }
3893
4025
  function validateStructuredPayloadLocalPath(ctx, payloadPath, mediaType) {
3894
4026
  const candidatePaths = resolveWorkspacePathCandidates(normalizePath(payloadPath), ctx.mediaAccess?.workspaceDir);
@@ -4279,7 +4411,7 @@ function fixPathEncoding(mediaPath, log) {
4279
4411
  const code = decoded.charCodeAt(i);
4280
4412
  if (code <= 255) bytes.push(code);
4281
4413
  else {
4282
- const charBytes = Buffer.from(decoded[i], "utf8");
4414
+ const charBytes = Buffer.from(decoded.charAt(i), "utf8");
4283
4415
  bytes.push(...charBytes);
4284
4416
  }
4285
4417
  }
@@ -4308,7 +4440,9 @@ function isInsideCodeBlock(text, position) {
4308
4440
  let fenceMatch;
4309
4441
  let openFence = null;
4310
4442
  while ((fenceMatch = fenceRegex.exec(text)) !== null) {
4311
- const ticks = fenceMatch[1].length;
4443
+ const ticksText = fenceMatch[1];
4444
+ if (ticksText === void 0) continue;
4445
+ const ticks = ticksText.length;
4312
4446
  if (!openFence) openFence = {
4313
4447
  pos: fenceMatch.index,
4314
4448
  ticks
@@ -4339,7 +4473,9 @@ function findFirstClosedMediaTag(text, log) {
4339
4473
  continue;
4340
4474
  }
4341
4475
  const textBefore = text.slice(0, match.index);
4342
- const tagName = match[1].toLowerCase();
4476
+ const rawTagName = match[1];
4477
+ if (rawTagName === void 0) continue;
4478
+ const tagName = rawTagName.toLowerCase();
4343
4479
  let mediaPath = match[2]?.trim() ?? "";
4344
4480
  mediaPath = normalizePath(mediaPath);
4345
4481
  mediaPath = fixPathEncoding(mediaPath, log);
@@ -4403,12 +4539,10 @@ async function executeSendQueue(queue, ctx, options = {}) {
4403
4539
  const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
4404
4540
  const voiceTimeout = 45e3;
4405
4541
  try {
4406
- const result = await Promise.race([sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled), new Promise((resolve) => {
4407
- setTimeout(() => resolve({
4408
- channel: "qqbot",
4409
- error: "语音发送超时,已跳过"
4410
- }), voiceTimeout);
4411
- })]);
4542
+ const result = await raceWithTimeout(() => sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled), voiceTimeout, () => ({
4543
+ channel: "qqbot",
4544
+ error: "语音发送超时,已跳过"
4545
+ }));
4412
4546
  if (result.error) {
4413
4547
  log?.error(`${prefix} sendVoice error: ${result.error}`);
4414
4548
  await sendFallbackText(resolveUserFacingMediaError(result));
@@ -4514,7 +4648,7 @@ function stripIncompleteMediaTag(text) {
4514
4648
  let searchIsClosing = false;
4515
4649
  let fallbackPos = -1;
4516
4650
  for (let i = lastLine.length - 1; i >= 0; i--) {
4517
- const ch = lastLine[i];
4651
+ const ch = lastLine.charAt(i);
4518
4652
  if (ch !== "<" && ch !== "<") continue;
4519
4653
  const after = lastLine.slice(i + 1);
4520
4654
  const isClosing = after.startsWith("/");
@@ -4522,7 +4656,9 @@ function stripIncompleteMediaTag(text) {
4522
4656
  const nameMatch = nameStr.match(/^(\w+)/);
4523
4657
  if (searchTag) {
4524
4658
  if (!nameMatch || isClosing) continue;
4525
- const cand = nameMatch[1].toLowerCase();
4659
+ const candidateName = nameMatch[1];
4660
+ if (candidateName === void 0) continue;
4661
+ const cand = candidateName.toLowerCase();
4526
4662
  if (!isMedia(cand)) continue;
4527
4663
  if (hasClosingAfter(i, cand)) continue;
4528
4664
  if (searchTag === "*") return cutAt(i);
@@ -4543,6 +4679,7 @@ function stripIncompleteMediaTag(text) {
4543
4679
  continue;
4544
4680
  }
4545
4681
  const tag = nameMatch[1];
4682
+ if (tag === void 0) continue;
4546
4683
  const restAfterName = nameStr.slice(tag.length);
4547
4684
  const hasGT = /[>>]/.test(restAfterName);
4548
4685
  if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) continue;
@@ -5152,6 +5289,11 @@ var StreamingController = class {
5152
5289
  return;
5153
5290
  }
5154
5291
  const firstText = safeText;
5292
+ if (!claimMessageReply(this.deps.replyToMsgId).allowed) {
5293
+ this.logWarn(`stream budget unavailable; falling back to static delivery`);
5294
+ this.transition("aborted", "doStartStreaming", "passive_budget_exhausted");
5295
+ return;
5296
+ }
5155
5297
  const resp = await this.sendStreamChunk(firstText, StreamInputState.GENERATING, "doStartStreaming");
5156
5298
  if (!resp.id) throw new Error(`Stream API returned no id: ${JSON.stringify(resp)}`);
5157
5299
  this.streamMsgId = resp.id;
@@ -5248,15 +5390,12 @@ async function sendMediaQueue(queue, ctx) {
5248
5390
  }
5249
5391
  /**
5250
5392
  * 是否对私聊走 QQ 官方 C2C `stream_messages` 流式 API。
5251
- * - `streaming: true` 等效于 `mode: "partial"` 且 `c2cStreamApi: true`。
5252
- * - 仍支持对象里显式设 `c2cStreamApi: true` 以兼容旧配置;仅 C2C 场景生效。
5393
+ * - `streaming.nativeTransport: true` 启用;仅 C2C 场景生效。
5394
+ * - 旧的 `streaming: true` 布尔与 `c2cStreamApi` 键由 `openclaw doctor --fix` 迁移。
5253
5395
  */
5254
5396
  function shouldUseOfficialC2cStream(account, targetType) {
5255
5397
  if (targetType !== "c2c") return false;
5256
- const s = account.config?.streaming;
5257
- if (s === true) return true;
5258
- if (s && typeof s === "object" && s.c2cStreamApi === true) return true;
5259
- return false;
5398
+ return account.config?.streaming?.nativeTransport === true;
5260
5399
  }
5261
5400
  //#endregion
5262
5401
  //#region extensions/qqbot/src/engine/utils/audio.ts
@@ -5319,11 +5458,10 @@ async function convertSilkToWav(inputPath, outputDir) {
5319
5458
  return null;
5320
5459
  }
5321
5460
  const strippedBuf = stripAmrHeader(fileBuf);
5322
- const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);
5323
5461
  const silk = await loadSilkWasm();
5324
- if (!silk || !silk.isSilk(rawData)) return null;
5462
+ if (!silk || !silk.isSilk(strippedBuf)) return null;
5325
5463
  const sampleRate = 24e3;
5326
- const result = await silk.decode(rawData, sampleRate);
5464
+ const result = await silk.decode(strippedBuf, sampleRate);
5327
5465
  const wavBuffer = pcmToWav(result.data, sampleRate);
5328
5466
  const dir = outputDir || path$1.dirname(inputPath);
5329
5467
  if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
@@ -5337,7 +5475,8 @@ async function convertSilkToWav(inputPath, outputDir) {
5337
5475
  }
5338
5476
  /** Check whether an attachment is a voice file (by MIME type or extension). */
5339
5477
  function isVoiceAttachment(att) {
5340
- if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) return true;
5478
+ const contentType = normalizeLowercaseStringOrEmpty$1(att.content_type);
5479
+ if (contentType === "voice" || contentType.startsWith("audio/")) return true;
5341
5480
  const ext = att.filename ? normalizeLowercaseStringOrEmpty$1(path$1.extname(att.filename)) : "";
5342
5481
  return [
5343
5482
  ".amr",
@@ -5422,39 +5561,23 @@ async function audioFileToSilkBase64(filePath, directUploadFormats) {
5422
5561
  debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
5423
5562
  return buf.toString("base64");
5424
5563
  }
5425
- if ([".slk", ".slac"].includes(ext)) {
5426
- const stripped = stripAmrHeader(buf);
5427
- const raw = new Uint8Array(stripped.buffer, stripped.byteOffset, stripped.byteLength);
5428
- if ((await loadSilkWasm())?.isSilk(raw)) {
5429
- debugLog(`[audio-convert] SILK file, direct use: ${filePath} (${buf.length} bytes)`);
5430
- return buf.toString("base64");
5431
- }
5432
- }
5433
- const rawCheck = new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
5434
- const strippedCheck = stripAmrHeader(buf);
5435
- const strippedRaw = new Uint8Array(strippedCheck.buffer, strippedCheck.byteOffset, strippedCheck.byteLength);
5436
- const silkForCheck = await loadSilkWasm();
5437
- if (silkForCheck?.isSilk(rawCheck) || silkForCheck?.isSilk(strippedRaw)) {
5564
+ const stripped = stripAmrHeader(buf);
5565
+ const silk = await loadSilkWasm();
5566
+ if (silk?.isSilk(buf) || silk?.isSilk(stripped)) {
5438
5567
  debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
5439
5568
  return buf.toString("base64");
5440
5569
  }
5441
5570
  const targetRate = 24e3;
5442
5571
  debugLog(`[audio-convert] fallback: trying WASM decoders for ${ext}`);
5443
- if (ext === ".pcm") {
5444
- const { silkBuffer } = await pcmToSilk(Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength), targetRate);
5445
- return silkBuffer.toString("base64");
5446
- }
5572
+ if (ext === ".pcm") return (await pcmToSilk(buf, targetRate)).toString("base64");
5447
5573
  if (ext === ".wav" || buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF") {
5448
5574
  const wavInfo = parseWavFallback(buf);
5449
- if (wavInfo) {
5450
- const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
5451
- return silkBuffer.toString("base64");
5452
- }
5575
+ if (wavInfo) return (await pcmToSilk(wavInfo, targetRate)).toString("base64");
5453
5576
  }
5454
5577
  if (ext === ".mp3" || ext === ".mpeg") {
5455
5578
  const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
5456
5579
  if (pcmBuf) {
5457
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
5580
+ const silkBuffer = await pcmToSilk(pcmBuf, targetRate);
5458
5581
  debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
5459
5582
  return silkBuffer.toString("base64");
5460
5583
  }
@@ -5524,12 +5647,8 @@ async function waitForFile(filePath, timeoutMs = 3e4, pollMs = 500) {
5524
5647
  async function pcmToSilk(pcmBuffer, sampleRate) {
5525
5648
  const silk = await loadSilkWasm();
5526
5649
  if (!silk) throw new Error("silk-wasm is not available; cannot encode PCM to SILK");
5527
- const pcmData = new Uint8Array(pcmBuffer.buffer, pcmBuffer.byteOffset, pcmBuffer.byteLength);
5528
- const result = await silk.encode(pcmData, sampleRate);
5529
- return {
5530
- silkBuffer: Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength),
5531
- duration: result.duration
5532
- };
5650
+ const result = await silk.encode(pcmBuffer, sampleRate);
5651
+ return Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength);
5533
5652
  }
5534
5653
  /** Decode MP3 to PCM via mpg123-decoder WASM. */
5535
5654
  async function wasmDecodeMp3ToPCM(buf, targetRate) {
@@ -5546,20 +5665,21 @@ async function wasmDecodeMp3ToPCM(buf, targetRate) {
5546
5665
  }
5547
5666
  debugLog(`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`);
5548
5667
  let floatMono;
5549
- if (decoded.channelData.length === 1) floatMono = decoded.channelData[0];
5668
+ if (decoded.channelData.length === 1) floatMono = expectDefined(decoded.channelData.at(0), "single decoded MP3 channel");
5550
5669
  else {
5551
5670
  floatMono = new Float32Array(decoded.samplesDecoded);
5552
5671
  const channels = decoded.channelData.length;
5553
5672
  for (let i = 0; i < decoded.samplesDecoded; i++) {
5554
5673
  let sum = 0;
5555
- for (let ch = 0; ch < channels; ch++) sum += decoded.channelData[ch][i];
5674
+ for (const channel of decoded.channelData) sum += expectDefined(channel.at(i), "decoded MP3 channel sample");
5556
5675
  floatMono[i] = sum / channels;
5557
5676
  }
5558
5677
  }
5559
5678
  const s16 = new Uint8Array(floatMono.length * 2);
5560
5679
  const view = new DataView(s16.buffer);
5561
5680
  for (let i = 0; i < floatMono.length; i++) {
5562
- const clamped = Math.max(-1, Math.min(1, floatMono[i]));
5681
+ const sample = expectDefined(floatMono.at(i), "mono MP3 sample index");
5682
+ const clamped = Math.max(-1, Math.min(1, sample));
5563
5683
  const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
5564
5684
  view.setInt16(i * 2, Math.round(val), true);
5565
5685
  }
@@ -5720,7 +5840,6 @@ const TOOL_MEDIA_SEND_TIMEOUT = 45e3;
5720
5840
  function shouldDeliverToolProgressImmediately(account, useOfficialC2cStream) {
5721
5841
  if (useOfficialC2cStream) return true;
5722
5842
  const streaming = account.config?.streaming;
5723
- if (streaming === true) return true;
5724
5843
  return typeof streaming === "object" && streaming !== null && streaming.mode !== "off";
5725
5844
  }
5726
5845
  function immediateToolProgressText(payload) {
@@ -6132,11 +6251,7 @@ async function dispatchOutbound(inbound, deps) {
6132
6251
  }
6133
6252
  },
6134
6253
  replyOptions: {
6135
- disableBlockStreaming: useOfficialC2cStream ? true : (() => {
6136
- const s = account.config?.streaming;
6137
- if (s === false) return true;
6138
- return typeof s === "object" && s !== null && s.mode === "off";
6139
- })(),
6254
+ disableBlockStreaming: useOfficialC2cStream ? true : account.config?.streaming?.mode === "off",
6140
6255
  ...streamingController ? { onPartialReply: async (payload) => {
6141
6256
  try {
6142
6257
  await streamingController.onPartialReply(payload);
@@ -6262,41 +6377,35 @@ async function buildCtxPayload(inbound, runtime, cfg) {
6262
6377
  /**
6263
6378
  * Periodically refresh C2C typing state while a response is in progress.
6264
6379
  *
6265
- * All I/O operations are injected via constructor parameters so this
6266
- * module has zero external dependencies and can run in both plugin versions.
6380
+ * Interval scheduling comes from the core typing keepalive loop; this module
6381
+ * owns the QQ passive-reply budget accounting and token-refresh retry.
6267
6382
  */
6268
6383
  /** Refresh every 5s for the QQ API's 10s input-notify window. */
6269
6384
  const TYPING_INTERVAL_MS = 5e3;
6385
+ const FINAL_REPLY_RESERVE_COUNT = 1;
6270
6386
  var TypingKeepAlive = class {
6271
- constructor(getToken, clearCache, sendInputNotify, openid, msgId, log) {
6387
+ constructor(getToken, clearCache, sendInputNotify, openid, msgId, log, claimPassiveReply = claimMessageReply) {
6272
6388
  this.getToken = getToken;
6273
6389
  this.clearCache = clearCache;
6274
6390
  this.sendInputNotify = sendInputNotify;
6275
6391
  this.openid = openid;
6276
6392
  this.msgId = msgId;
6277
6393
  this.log = log;
6278
- this.timer = null;
6394
+ this.claimPassiveReply = claimPassiveReply;
6279
6395
  this.stopped = false;
6280
- this.renewalsRemaining = 3;
6396
+ this.loop = createTypingKeepaliveLoop({
6397
+ intervalMs: TYPING_INTERVAL_MS,
6398
+ onTick: () => this.send()
6399
+ });
6281
6400
  }
6282
6401
  /** Start periodic keep-alive sends. */
6283
6402
  start() {
6284
- if (this.stopped) return;
6285
- this.timer = setInterval(() => {
6286
- if (this.stopped) {
6287
- this.stop();
6288
- return;
6289
- }
6290
- this.send().catch(() => {});
6291
- }, TYPING_INTERVAL_MS);
6403
+ if (!this.stopped) this.loop.start();
6292
6404
  }
6293
6405
  /** Stop periodic keep-alive sends. */
6294
6406
  stop() {
6295
6407
  this.stopped = true;
6296
- if (this.timer) {
6297
- clearInterval(this.timer);
6298
- this.timer = null;
6299
- }
6408
+ this.loop.stop();
6300
6409
  }
6301
6410
  async send() {
6302
6411
  try {
@@ -6313,16 +6422,18 @@ var TypingKeepAlive = class {
6313
6422
  }
6314
6423
  }
6315
6424
  async sendAttempt(token) {
6316
- if (this.stopped || this.renewalsRemaining <= 0) {
6425
+ if (this.stopped) return;
6426
+ const claim = this.claimPassiveReply(this.msgId, FINAL_REPLY_RESERVE_COUNT);
6427
+ if (!claim.allowed) {
6428
+ this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`);
6317
6429
  this.stop();
6318
6430
  return;
6319
6431
  }
6320
- this.renewalsRemaining--;
6321
6432
  try {
6322
6433
  await this.sendInputNotify(token, this.openid, this.msgId, 10);
6323
6434
  this.log?.debug?.(`Typing keep-alive sent to ${this.openid}`);
6324
6435
  } finally {
6325
- if (this.renewalsRemaining <= 0) {
6436
+ if (claim.remaining <= FINAL_REPLY_RESERVE_COUNT) {
6326
6437
  this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`);
6327
6438
  this.stop();
6328
6439
  }
@@ -6503,6 +6614,7 @@ async function startTypingForEvent(event, account, log) {
6503
6614
  const creds = accountToCreds(account);
6504
6615
  const rawNotifyFn = createRawInputNotifyFn(account.appId);
6505
6616
  const sendNotifyAndStartKeepAlive = async () => {
6617
+ if (!claimMessageReply(event.messageId, 1).allowed) return { keepAlive: null };
6506
6618
  const resp = await sendInputNotify({
6507
6619
  openid: event.senderId,
6508
6620
  creds,