@openclaw/qqbot 2026.7.1 → 2026.7.2-beta.1

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-D1UztsnG.js → channel-BbwpkiY3.js} +99 -54
  3. package/dist/{channel-entry-C5YdhX3Y.js → channel-entry-Cj1lWXpt.js} +4 -4
  4. package/dist/channel-entry-api.js +1 -1
  5. package/dist/channel-plugin-api.js +1 -1
  6. package/dist/{channel.setup-2ItDYKhz.js → channel.setup-DfhritPL.js} +1 -1
  7. package/dist/{config-C1qZbh0K.js → config-CpOXnoEc.js} +2 -2
  8. package/dist/{config-schema-JZEf1dvB.js → config-schema-B5Mle_87.js} +38 -47
  9. package/dist/doctor-contract-api.js +1 -1
  10. package/dist/{gateway-pJQppxe4.js → gateway-be5-Ckdc.js} +284 -174
  11. package/dist/{group-o0GmovSf.js → group-Dbpnjalm.js} +19 -17
  12. package/dist/{handler-runtime-zQvT6SrI.js → handler-runtime-BvR-ayNW.js} +8 -12
  13. package/dist/{log-DEtcoDWe.js → log-Da4jz75I.js} +4 -8
  14. package/dist/{outbound-BIrfvvFJ.js → outbound-BLl8Tsu7.js} +8 -142
  15. package/dist/{runtime-DodcT_fQ.js → runtime-Du28LbOJ.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-0vqsivoI.js} +220 -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-DYb23LBU.js} +48 -6
  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 +61 -89
  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-0vqsivoI.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-B5Mle_87.js";
4
+ import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-BbwpkiY3.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-Du28LbOJ.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-Dbpnjalm.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-BLl8Tsu7.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) {
@@ -1683,6 +1693,14 @@ var GatewayConnection = class {
1683
1693
  * of broad SDK text barrels.
1684
1694
  */
1685
1695
  const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
1696
+ const DEFAULT_STT_TIMEOUT_MS = 6e4;
1697
+ function resolveSTTTimeoutMs(...timeoutSeconds) {
1698
+ for (const value of timeoutSeconds) {
1699
+ const timeoutMs = finiteSecondsToTimerSafeMilliseconds(value);
1700
+ if (timeoutMs !== void 0) return timeoutMs;
1701
+ }
1702
+ return DEFAULT_STT_TIMEOUT_MS;
1703
+ }
1686
1704
  /** Resolve the STT configuration from the nested config object. */
1687
1705
  function resolveSTTConfig(cfg) {
1688
1706
  const channelStt = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.channels)?.qqbot)?.stt);
@@ -1696,10 +1714,12 @@ function resolveSTTConfig(cfg) {
1696
1714
  if (baseUrl && apiKey) return {
1697
1715
  baseUrl: baseUrl.replace(/\/+$/, ""),
1698
1716
  apiKey,
1699
- model
1717
+ model,
1718
+ timeoutMs: resolveSTTTimeoutMs(providerCfg?.timeoutSeconds)
1700
1719
  };
1701
1720
  }
1702
- const audioModels = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.tools)?.media)?.audio)?.models;
1721
+ const audio = asOptionalObjectRecord(asOptionalObjectRecord(asOptionalObjectRecord(cfg.tools)?.media)?.audio);
1722
+ const audioModels = audio?.models;
1703
1723
  const audioModelEntry = Array.isArray(audioModels) ? asOptionalObjectRecord(audioModels[0]) : void 0;
1704
1724
  if (audioModelEntry) {
1705
1725
  const providerId = readStringField(audioModelEntry, "provider") ?? "openai";
@@ -1710,7 +1730,8 @@ function resolveSTTConfig(cfg) {
1710
1730
  if (baseUrl && apiKey) return {
1711
1731
  baseUrl: baseUrl.replace(/\/+$/, ""),
1712
1732
  apiKey,
1713
- model
1733
+ model,
1734
+ timeoutMs: resolveSTTTimeoutMs(audioModelEntry.timeoutSeconds, audio?.timeoutSeconds, providerCfg?.timeoutSeconds)
1714
1735
  };
1715
1736
  }
1716
1737
  return null;
@@ -1728,6 +1749,7 @@ async function transcribeAudio(audioPath, cfg) {
1728
1749
  const { response: resp, release } = await fetchWithSsrFGuard({
1729
1750
  url: `${sttCfg.baseUrl}/audio/transcriptions`,
1730
1751
  auditContext: "qqbot-stt",
1752
+ timeoutMs: sttCfg.timeoutMs,
1731
1753
  init: {
1732
1754
  method: "POST",
1733
1755
  headers: { Authorization: `Bearer ${sttCfg.apiKey}` },
@@ -1796,18 +1818,19 @@ async function processAttachments(attachments, ctx) {
1796
1818
  });
1797
1819
  const processTasks = (await Promise.all(downloadTasks)).map(async ({ att, attUrl, isVoice, localPath, audioPath }) => {
1798
1820
  const asrReferText = normalizeOptionalString$1(att.asr_refer_text) ?? "";
1821
+ const normalizedContentType = normalizeMimeType(att.content_type) ?? "";
1799
1822
  const voiceSourceUrl = (isVoice && att.voice_wav_url ? att.voice_wav_url.startsWith("//") ? `https:${att.voice_wav_url}` : att.voice_wav_url : "") || attUrl;
1800
1823
  const meta = {
1801
1824
  voiceUrl: isVoice && voiceSourceUrl ? voiceSourceUrl : void 0,
1802
1825
  asrReferText: isVoice && asrReferText ? asrReferText : void 0
1803
1826
  };
1804
1827
  if (localPath) {
1805
- if (att.content_type?.startsWith("image/")) {
1828
+ if (normalizedContentType.startsWith("image/")) {
1806
1829
  log?.debug?.(`Downloaded attachment to: ${localPath}`);
1807
1830
  return {
1808
1831
  localPath,
1809
1832
  type: "image",
1810
- contentType: att.content_type,
1833
+ contentType: normalizedContentType,
1811
1834
  meta
1812
1835
  };
1813
1836
  }
@@ -1824,11 +1847,11 @@ async function processAttachments(attachments, ctx) {
1824
1847
  };
1825
1848
  }
1826
1849
  log?.error(`Failed to download: ${attUrl}`);
1827
- if (att.content_type?.startsWith("image/")) return {
1850
+ if (normalizedContentType.startsWith("image/")) return {
1828
1851
  localPath: null,
1829
1852
  type: "image-fallback",
1830
1853
  attUrl,
1831
- contentType: att.content_type,
1854
+ contentType: normalizedContentType,
1832
1855
  meta
1833
1856
  };
1834
1857
  if (isVoice && asrReferText) {
@@ -2106,15 +2129,52 @@ function buildQualifiedTarget(event, isGroupChat) {
2106
2129
  //#endregion
2107
2130
  //#region extensions/qqbot/src/engine/group/mention.ts
2108
2131
  const MENTION_TAG_RE = /<@!?\w+>/;
2132
+ const MENTION_PATTERN_FLAGS = "i";
2133
+ const MAX_MENTION_PATTERN_CACHE_KEYS = 256;
2134
+ const MAX_MENTION_PATTERN_WARNING_KEYS = 256;
2135
+ const mentionPatternCompileCache = /* @__PURE__ */ new Map();
2136
+ const rejectedMentionPatternWarningCache = /* @__PURE__ */ new Set();
2137
+ function warnRejectedMentionPattern(pattern, reason) {
2138
+ const key = `${MENTION_PATTERN_FLAGS}::${reason}::${pattern}`;
2139
+ if (rejectedMentionPatternWarningCache.has(key)) return;
2140
+ rejectedMentionPatternWarningCache.add(key);
2141
+ if (rejectedMentionPatternWarningCache.size > MAX_MENTION_PATTERN_WARNING_KEYS) {
2142
+ rejectedMentionPatternWarningCache.clear();
2143
+ rejectedMentionPatternWarningCache.add(key);
2144
+ }
2145
+ debugWarn(`qqbot: mentionPattern rejected (${reason}): ${pattern}`);
2146
+ }
2147
+ function cacheMentionPatterns(cacheKey, regexes) {
2148
+ mentionPatternCompileCache.set(cacheKey, regexes);
2149
+ if (mentionPatternCompileCache.size > MAX_MENTION_PATTERN_CACHE_KEYS) {
2150
+ mentionPatternCompileCache.clear();
2151
+ mentionPatternCompileCache.set(cacheKey, regexes);
2152
+ }
2153
+ return regexes;
2154
+ }
2155
+ function compileMentionPatterns(patterns) {
2156
+ if (patterns.length === 0) return [];
2157
+ const cacheKey = patterns.join("");
2158
+ const cached = mentionPatternCompileCache.get(cacheKey);
2159
+ if (cached) return cached;
2160
+ const regexes = [];
2161
+ for (const pattern of patterns) {
2162
+ const result = compileSafeRegexDetailed(pattern, MENTION_PATTERN_FLAGS);
2163
+ if (result.reason === "empty") continue;
2164
+ if (result.regex) {
2165
+ regexes.push(result.regex);
2166
+ continue;
2167
+ }
2168
+ warnRejectedMentionPattern(result.source, result.reason);
2169
+ }
2170
+ return cacheMentionPatterns(cacheKey, regexes);
2171
+ }
2109
2172
  function detectWasMentioned(input) {
2110
2173
  const { eventType, mentions, content, mentionPatterns } = input;
2111
2174
  if (mentions?.some((m) => m.is_you)) return true;
2112
2175
  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 {}
2176
+ if (mentionPatterns?.length && content) {
2177
+ for (const regex of compileMentionPatterns(mentionPatterns)) if (regex.test(content)) return true;
2118
2178
  }
2119
2179
  return false;
2120
2180
  }
@@ -2253,7 +2313,7 @@ function buildUserMessage(input) {
2253
2313
  const { event, userContent, quotePart, isGroupChat, groupInfo } = input;
2254
2314
  if (groupInfo?.isMerged && groupInfo.mergedMessages?.length) {
2255
2315
  const preceding = groupInfo.mergedMessages.slice(0, -1);
2256
- const lastMsg = groupInfo.mergedMessages[groupInfo.mergedMessages.length - 1];
2316
+ const lastMsg = expectDefined(groupInfo.mergedMessages.at(-1), "non-empty merged group turn");
2257
2317
  const atYouTag = groupInfo.gate.effectiveWasMentioned ? " (@you)" : "";
2258
2318
  return buildMergedMessageContext({
2259
2319
  precedingParts: preceding.map((m) => `[${formatSenderLabel(m)}] ${formatSub(m)}`),
@@ -2424,6 +2484,7 @@ function classifyMedia(processed) {
2424
2484
  for (let i = 0; i < processed.imageUrls.length; i++) {
2425
2485
  const u = processed.imageUrls[i];
2426
2486
  const t = processed.imageMediaTypes[i] ?? "image/png";
2487
+ if (u === void 0) continue;
2427
2488
  if (u.startsWith("http://") || u.startsWith("https://")) {
2428
2489
  remoteMediaUrls.push(u);
2429
2490
  remoteMediaTypes.push(t);
@@ -2449,6 +2510,50 @@ function classifyMedia(processed) {
2449
2510
  };
2450
2511
  }
2451
2512
  //#endregion
2513
+ //#region extensions/qqbot/src/engine/group/message-gating.ts
2514
+ /**
2515
+ * Group gate Layer 1 (ignoreOtherMentions) is QQ-specific and decided here;
2516
+ * Layer 2+3 (command gating + mention gating + command bypass) delegate to the
2517
+ * mention gate port backed by the SDK's `resolveInboundMentionDecision`.
2518
+ */
2519
+ function resolveGroupMessageGate(params) {
2520
+ if (params.ignoreOtherMentions && params.hasAnyMention && !params.wasMentioned && !params.implicitMention) return {
2521
+ action: "drop_other_mention",
2522
+ effectiveWasMentioned: false,
2523
+ shouldBypassMention: false
2524
+ };
2525
+ const decision = params.mentionGatePort.resolveInboundMentionDecision({
2526
+ facts: {
2527
+ canDetectMention: true,
2528
+ wasMentioned: params.wasMentioned,
2529
+ hasAnyMention: params.hasAnyMention,
2530
+ implicitMentionKinds: params.implicitMention ? ["reply_to_bot"] : []
2531
+ },
2532
+ policy: {
2533
+ isGroup: true,
2534
+ requireMention: params.requireMention,
2535
+ allowTextCommands: params.allowTextCommands,
2536
+ hasControlCommand: params.isControlCommand,
2537
+ commandAuthorized: params.commandAuthorized
2538
+ }
2539
+ });
2540
+ if (params.allowTextCommands && params.isControlCommand && !params.commandAuthorized) return {
2541
+ action: "block_unauthorized_command",
2542
+ effectiveWasMentioned: false,
2543
+ shouldBypassMention: false
2544
+ };
2545
+ if (decision.shouldSkip) return {
2546
+ action: "skip_no_mention",
2547
+ effectiveWasMentioned: decision.effectiveWasMentioned,
2548
+ shouldBypassMention: decision.shouldBypassMention
2549
+ };
2550
+ return {
2551
+ action: "pass",
2552
+ effectiveWasMentioned: decision.effectiveWasMentioned,
2553
+ shouldBypassMention: decision.shouldBypassMention
2554
+ };
2555
+ }
2556
+ //#endregion
2452
2557
  //#region extensions/qqbot/src/engine/gateway/stages/group-gate-stage.ts
2453
2558
  function runGroupGateStage(input) {
2454
2559
  const { event, deps, accountId, agentId, sessionKey, userContent, processedAttachments } = input;
@@ -2487,7 +2592,7 @@ function runGroupGateStage(input) {
2487
2592
  const content = (event.content ?? "").trim();
2488
2593
  const isControlCommand = Boolean(deps.isControlCommand?.(content));
2489
2594
  const commandAuthorized = deps.allowTextCommands !== false && input.access.commandAccess.authorized;
2490
- const gate = resolveGateWithPort({
2595
+ const gate = resolveGroupMessageGate({
2491
2596
  mentionGatePort: deps.adapters.mentionGate,
2492
2597
  ignoreOtherMentions,
2493
2598
  hasAnyMention: anyMention,
@@ -2543,43 +2648,6 @@ function runGroupGateStage(input) {
2543
2648
  skipReason: gate.action
2544
2649
  };
2545
2650
  }
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
2651
  function recordGroupHistory(params) {
2584
2652
  const { historyMap, groupOpenid, historyLimit, event, userContent, historyPort, localPaths } = params;
2585
2653
  if (!historyMap || historyLimit <= 0) return;
@@ -3032,7 +3100,7 @@ async function handleApprovalButtonInteraction(params) {
3032
3100
  cfg,
3033
3101
  account: params.account,
3034
3102
  event: params.event,
3035
- approvalKind: resolveApprovalKind(params.parsed.approvalId),
3103
+ approvalKind: params.parsed.approvalKind,
3036
3104
  resolveCommandAuthorized: params.resolveCommandAuthorized
3037
3105
  });
3038
3106
  if (!authorization.authorized) {
@@ -3040,19 +3108,65 @@ async function handleApprovalButtonInteraction(params) {
3040
3108
  params.log?.info(`Approval button rejected: id=${params.parsed.approvalId}`);
3041
3109
  return;
3042
3110
  }
3043
- await acknowledgeApprovalInteraction(params.creds, params.event, params.log);
3111
+ await acknowledgeApprovalInteraction(params.creds, params.event, params.log, { content: "Approval response received." });
3044
3112
  const adapter = getPlatformAdapter();
3045
3113
  if (!adapter.resolveApproval) {
3114
+ await reportApprovalInteractionOutcome({
3115
+ creds: params.creds,
3116
+ event: params.event,
3117
+ log: params.log,
3118
+ content: "Approval is unavailable."
3119
+ });
3046
3120
  params.log?.error("resolveApproval not available on PlatformAdapter");
3047
3121
  return;
3048
3122
  }
3049
3123
  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}`);
3124
+ const result = await adapter.resolveApproval(params.parsed);
3125
+ const canonicalDecision = "decision" in result.approval ? `, decision=${result.approval.decision}` : "";
3126
+ const canonicalOutcome = formatCanonicalApprovalOutcome(result.approval);
3127
+ await reportApprovalInteractionOutcome({
3128
+ creds: params.creds,
3129
+ event: params.event,
3130
+ log: params.log,
3131
+ content: result.applied ? `Approval resolved: ${canonicalOutcome}.` : `This approval was already resolved: ${canonicalOutcome}.`
3132
+ });
3133
+ 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
3134
  } catch (err) {
3135
+ await reportApprovalInteractionOutcome({
3136
+ creds: params.creds,
3137
+ event: params.event,
3138
+ log: params.log,
3139
+ content: "Approval could not be resolved."
3140
+ });
3053
3141
  params.log?.error(`Approval resolve failed: id=${params.parsed.approvalId}: ${err instanceof Error ? err.message : String(err)}`);
3054
3142
  }
3055
3143
  }
3144
+ async function reportApprovalInteractionOutcome(params) {
3145
+ const target = params.event.group_openid ? {
3146
+ type: "group",
3147
+ id: params.event.group_openid
3148
+ } : params.event.user_openid ? {
3149
+ type: "c2c",
3150
+ id: params.event.user_openid
3151
+ } : params.event.channel_id ? {
3152
+ type: "channel",
3153
+ id: params.event.channel_id
3154
+ } : null;
3155
+ if (!target) {
3156
+ params.log?.info(`Approval interaction outcome: ${params.content}`);
3157
+ return;
3158
+ }
3159
+ try {
3160
+ await sendText(target, params.content, params.creds, { msgId: params.event.data.resolved.message_id });
3161
+ } catch (err) {
3162
+ params.log?.error(`Approval outcome delivery failed: ${err instanceof Error ? err.message : String(err)}`);
3163
+ }
3164
+ }
3165
+ function formatCanonicalApprovalOutcome(approval) {
3166
+ if (approval.status === "allowed") return approval.decision === "allow-always" ? "Allowed always" : "Allowed once";
3167
+ if (approval.status === "denied") return "Denied";
3168
+ return approval.status === "expired" ? "Expired" : "Cancelled";
3169
+ }
3056
3170
  async function acknowledgeApprovalInteraction(creds, event, log, data) {
3057
3171
  try {
3058
3172
  await acknowledgeInteraction(creds, event.id, 0, data);
@@ -3135,9 +3249,6 @@ function resolveApprovalActorSenderIds(event) {
3135
3249
  return normalized ? [normalized] : [];
3136
3250
  }));
3137
3251
  }
3138
- function resolveApprovalKind(approvalId) {
3139
- return approvalId.toLowerCase().startsWith("plugin:") ? "plugin" : "exec";
3140
- }
3141
3252
  /** Execute an async handler, ACK with the result, and handle errors. */
3142
3253
  async function handleWithAck(creds, event, log, label, handler) {
3143
3254
  try {
@@ -3217,7 +3328,11 @@ function parseWebpSize(buffer) {
3217
3328
  if (riff !== "RIFF" || webp !== "WEBP") return null;
3218
3329
  const chunkType = buffer.toString("ascii", 12, 16);
3219
3330
  if (chunkType === "VP8 ") {
3220
- if (buffer.length >= 30 && buffer[23] === 157 && buffer[24] === 1 && buffer[25] === 42) return {
3331
+ if (buffer.length >= 30 && buffer.subarray(23, 26).equals(Buffer$1.from([
3332
+ 157,
3333
+ 1,
3334
+ 42
3335
+ ]))) return {
3221
3336
  width: buffer.readUInt16LE(26) & 16383,
3222
3337
  height: buffer.readUInt16LE(28) & 16383
3223
3338
  };
@@ -3233,8 +3348,8 @@ function parseWebpSize(buffer) {
3233
3348
  }
3234
3349
  if (chunkType === "VP8X") {
3235
3350
  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
3351
+ width: buffer.readUIntLE(24, 3) + 1,
3352
+ height: buffer.readUIntLE(27, 3) + 1
3238
3353
  };
3239
3354
  }
3240
3355
  return null;
@@ -3274,13 +3389,13 @@ async function getImageSizeFromUrl(url, timeoutMs = 5e3) {
3274
3389
  }
3275
3390
  });
3276
3391
  const size = parseImageSize(buffer);
3277
- if (size) debugLog(`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`);
3392
+ if (size) debugLog(`[image-size] Got size from URL: ${size.width}x${size.height} - ${truncateUtf16Safe(url, 60)}...`);
3278
3393
  return size;
3279
3394
  } finally {
3280
3395
  clearTimeout(timeoutId);
3281
3396
  }
3282
3397
  } catch (err) {
3283
- debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${formatErrorMessage(err)}`);
3398
+ debugLog(`[image-size] Error fetching ${truncateUtf16Safe(url, 60)}...: ${formatErrorMessage(err)}`);
3284
3399
  return null;
3285
3400
  }
3286
3401
  }
@@ -3290,6 +3405,7 @@ function getImageSizeFromDataUrl(dataUrl) {
3290
3405
  const matches = dataUrl.match(/^data:image\/[^;]+;base64,(.+)$/);
3291
3406
  if (!matches) return null;
3292
3407
  const base64Data = matches[1];
3408
+ if (base64Data === void 0) return null;
3293
3409
  const size = parseImageSize(Buffer$1.from(base64Data, "base64"));
3294
3410
  if (size) debugLog(`[image-size] Got size from Base64: ${size.width}x${size.height}`);
3295
3411
  return size;
@@ -3316,6 +3432,25 @@ function hasQQBotImageSize(markdownImage) {
3316
3432
  return /!\[#\d+px\s+#\d+px\]/.test(markdownImage);
3317
3433
  }
3318
3434
  //#endregion
3435
+ //#region extensions/qqbot/src/engine/messaging/race-with-timeout.ts
3436
+ async function raceWithTimeout(operation, timeoutMs, onTimeout) {
3437
+ let timedOut = false;
3438
+ let timeout;
3439
+ const state = { get timedOut() {
3440
+ return timedOut;
3441
+ } };
3442
+ try {
3443
+ return await Promise.race([operation(state), new Promise((resolve) => {
3444
+ timeout = setTimeout(() => {
3445
+ timedOut = true;
3446
+ resolve(onTimeout());
3447
+ }, timeoutMs);
3448
+ })]);
3449
+ } finally {
3450
+ if (timeout !== void 0) clearTimeout(timeout);
3451
+ }
3452
+ }
3453
+ //#endregion
3319
3454
  //#region extensions/qqbot/src/engine/messaging/outbound-deliver.ts
3320
3455
  /**
3321
3456
  * Outbound delivery helpers — core/ version.
@@ -3461,10 +3596,9 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3461
3596
  const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
3462
3597
  const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
3463
3598
  const voiceTimeout = 45e3;
3464
- const ac = new AbortController();
3465
3599
  try {
3466
- const result = await Promise.race([mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
3467
- if (ac.signal.aborted) {
3600
+ const result = await raceWithTimeout((timeoutState) => mediaSender.sendVoice(target, voicePath, uploadFormats, transcodeEnabled).then((r) => {
3601
+ if (timeoutState.timedOut) {
3468
3602
  log?.debug?.(`sendVoice completed after timeout, suppressing late delivery`);
3469
3603
  return {
3470
3604
  channel: "qqbot",
@@ -3472,15 +3606,10 @@ async function sendVoiceWithTimeout(target, voicePath, account, mediaSender, log
3472
3606
  };
3473
3607
  }
3474
3608
  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
- })]);
3609
+ }), voiceTimeout, () => ({
3610
+ channel: "qqbot",
3611
+ error: "Voice send timed out and was skipped"
3612
+ }));
3484
3613
  if (result.error) {
3485
3614
  log?.error(`sendVoice error: ${result.error}`);
3486
3615
  return false;
@@ -3738,7 +3867,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3738
3867
  log?.error(`Failed to send Base64 image via Rich Media API: ${String(imgErr)}`);
3739
3868
  }
3740
3869
  }
3741
- const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
3870
+ const existingMdUrls = new Set(mdMatches.flatMap((m) => m[2] === void 0 ? [] : [m[2]]));
3742
3871
  const imagesToAppend = [];
3743
3872
  for (const url of httpImageUrls) if (!existingMdUrls.has(url)) try {
3744
3873
  const size = await getImageSize(url);
@@ -3752,6 +3881,7 @@ async function sendMarkdownReply(textWithoutImages, imageUrls, mdMatches, bareUr
3752
3881
  for (const m of mdMatches) {
3753
3882
  const fullMatch = m[0];
3754
3883
  const imgUrl = m[2];
3884
+ if (fullMatch === void 0 || imgUrl === void 0) continue;
3755
3885
  if (isHttpUrl(imgUrl) && !hasQQBotImageSize(fullMatch)) try {
3756
3886
  const size = await getImageSize(imgUrl);
3757
3887
  result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
@@ -3888,7 +4018,7 @@ async function handleStructuredPayload(ctx, replyText, recordActivity, deps) {
3888
4018
  return true;
3889
4019
  }
3890
4020
  function formatMediaTypeLabel(mediaType) {
3891
- return mediaType[0].toUpperCase() + mediaType.slice(1);
4021
+ return mediaType.charAt(0).toUpperCase() + mediaType.slice(1);
3892
4022
  }
3893
4023
  function validateStructuredPayloadLocalPath(ctx, payloadPath, mediaType) {
3894
4024
  const candidatePaths = resolveWorkspacePathCandidates(normalizePath(payloadPath), ctx.mediaAccess?.workspaceDir);
@@ -4279,7 +4409,7 @@ function fixPathEncoding(mediaPath, log) {
4279
4409
  const code = decoded.charCodeAt(i);
4280
4410
  if (code <= 255) bytes.push(code);
4281
4411
  else {
4282
- const charBytes = Buffer.from(decoded[i], "utf8");
4412
+ const charBytes = Buffer.from(decoded.charAt(i), "utf8");
4283
4413
  bytes.push(...charBytes);
4284
4414
  }
4285
4415
  }
@@ -4308,7 +4438,9 @@ function isInsideCodeBlock(text, position) {
4308
4438
  let fenceMatch;
4309
4439
  let openFence = null;
4310
4440
  while ((fenceMatch = fenceRegex.exec(text)) !== null) {
4311
- const ticks = fenceMatch[1].length;
4441
+ const ticksText = fenceMatch[1];
4442
+ if (ticksText === void 0) continue;
4443
+ const ticks = ticksText.length;
4312
4444
  if (!openFence) openFence = {
4313
4445
  pos: fenceMatch.index,
4314
4446
  ticks
@@ -4339,7 +4471,9 @@ function findFirstClosedMediaTag(text, log) {
4339
4471
  continue;
4340
4472
  }
4341
4473
  const textBefore = text.slice(0, match.index);
4342
- const tagName = match[1].toLowerCase();
4474
+ const rawTagName = match[1];
4475
+ if (rawTagName === void 0) continue;
4476
+ const tagName = rawTagName.toLowerCase();
4343
4477
  let mediaPath = match[2]?.trim() ?? "";
4344
4478
  mediaPath = normalizePath(mediaPath);
4345
4479
  mediaPath = fixPathEncoding(mediaPath, log);
@@ -4403,12 +4537,10 @@ async function executeSendQueue(queue, ctx, options = {}) {
4403
4537
  const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
4404
4538
  const voiceTimeout = 45e3;
4405
4539
  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
- })]);
4540
+ const result = await raceWithTimeout(() => sendVoice(mediaTarget, item.content, uploadFormats, transcodeEnabled), voiceTimeout, () => ({
4541
+ channel: "qqbot",
4542
+ error: "语音发送超时,已跳过"
4543
+ }));
4412
4544
  if (result.error) {
4413
4545
  log?.error(`${prefix} sendVoice error: ${result.error}`);
4414
4546
  await sendFallbackText(resolveUserFacingMediaError(result));
@@ -4514,7 +4646,7 @@ function stripIncompleteMediaTag(text) {
4514
4646
  let searchIsClosing = false;
4515
4647
  let fallbackPos = -1;
4516
4648
  for (let i = lastLine.length - 1; i >= 0; i--) {
4517
- const ch = lastLine[i];
4649
+ const ch = lastLine.charAt(i);
4518
4650
  if (ch !== "<" && ch !== "<") continue;
4519
4651
  const after = lastLine.slice(i + 1);
4520
4652
  const isClosing = after.startsWith("/");
@@ -4522,7 +4654,9 @@ function stripIncompleteMediaTag(text) {
4522
4654
  const nameMatch = nameStr.match(/^(\w+)/);
4523
4655
  if (searchTag) {
4524
4656
  if (!nameMatch || isClosing) continue;
4525
- const cand = nameMatch[1].toLowerCase();
4657
+ const candidateName = nameMatch[1];
4658
+ if (candidateName === void 0) continue;
4659
+ const cand = candidateName.toLowerCase();
4526
4660
  if (!isMedia(cand)) continue;
4527
4661
  if (hasClosingAfter(i, cand)) continue;
4528
4662
  if (searchTag === "*") return cutAt(i);
@@ -4543,6 +4677,7 @@ function stripIncompleteMediaTag(text) {
4543
4677
  continue;
4544
4678
  }
4545
4679
  const tag = nameMatch[1];
4680
+ if (tag === void 0) continue;
4546
4681
  const restAfterName = nameStr.slice(tag.length);
4547
4682
  const hasGT = /[>>]/.test(restAfterName);
4548
4683
  if (!isMedia(tag) && !(couldBeMedia(tag) && !hasGT)) continue;
@@ -5152,6 +5287,11 @@ var StreamingController = class {
5152
5287
  return;
5153
5288
  }
5154
5289
  const firstText = safeText;
5290
+ if (!claimMessageReply(this.deps.replyToMsgId).allowed) {
5291
+ this.logWarn(`stream budget unavailable; falling back to static delivery`);
5292
+ this.transition("aborted", "doStartStreaming", "passive_budget_exhausted");
5293
+ return;
5294
+ }
5155
5295
  const resp = await this.sendStreamChunk(firstText, StreamInputState.GENERATING, "doStartStreaming");
5156
5296
  if (!resp.id) throw new Error(`Stream API returned no id: ${JSON.stringify(resp)}`);
5157
5297
  this.streamMsgId = resp.id;
@@ -5248,15 +5388,12 @@ async function sendMediaQueue(queue, ctx) {
5248
5388
  }
5249
5389
  /**
5250
5390
  * 是否对私聊走 QQ 官方 C2C `stream_messages` 流式 API。
5251
- * - `streaming: true` 等效于 `mode: "partial"` 且 `c2cStreamApi: true`。
5252
- * - 仍支持对象里显式设 `c2cStreamApi: true` 以兼容旧配置;仅 C2C 场景生效。
5391
+ * - `streaming.nativeTransport: true` 启用;仅 C2C 场景生效。
5392
+ * - 旧的 `streaming: true` 布尔与 `c2cStreamApi` 键由 `openclaw doctor --fix` 迁移。
5253
5393
  */
5254
5394
  function shouldUseOfficialC2cStream(account, targetType) {
5255
5395
  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;
5396
+ return account.config?.streaming?.nativeTransport === true;
5260
5397
  }
5261
5398
  //#endregion
5262
5399
  //#region extensions/qqbot/src/engine/utils/audio.ts
@@ -5319,11 +5456,10 @@ async function convertSilkToWav(inputPath, outputDir) {
5319
5456
  return null;
5320
5457
  }
5321
5458
  const strippedBuf = stripAmrHeader(fileBuf);
5322
- const rawData = new Uint8Array(strippedBuf.buffer, strippedBuf.byteOffset, strippedBuf.byteLength);
5323
5459
  const silk = await loadSilkWasm();
5324
- if (!silk || !silk.isSilk(rawData)) return null;
5460
+ if (!silk || !silk.isSilk(strippedBuf)) return null;
5325
5461
  const sampleRate = 24e3;
5326
- const result = await silk.decode(rawData, sampleRate);
5462
+ const result = await silk.decode(strippedBuf, sampleRate);
5327
5463
  const wavBuffer = pcmToWav(result.data, sampleRate);
5328
5464
  const dir = outputDir || path$1.dirname(inputPath);
5329
5465
  if (!fs$1.existsSync(dir)) fs$1.mkdirSync(dir, { recursive: true });
@@ -5337,7 +5473,8 @@ async function convertSilkToWav(inputPath, outputDir) {
5337
5473
  }
5338
5474
  /** Check whether an attachment is a voice file (by MIME type or extension). */
5339
5475
  function isVoiceAttachment(att) {
5340
- if (att.content_type === "voice" || att.content_type?.startsWith("audio/")) return true;
5476
+ const contentType = normalizeLowercaseStringOrEmpty$1(att.content_type);
5477
+ if (contentType === "voice" || contentType.startsWith("audio/")) return true;
5341
5478
  const ext = att.filename ? normalizeLowercaseStringOrEmpty$1(path$1.extname(att.filename)) : "";
5342
5479
  return [
5343
5480
  ".amr",
@@ -5422,39 +5559,23 @@ async function audioFileToSilkBase64(filePath, directUploadFormats) {
5422
5559
  debugLog(`[audio-convert] direct upload (QQ native format): ${ext} (${buf.length} bytes)`);
5423
5560
  return buf.toString("base64");
5424
5561
  }
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)) {
5562
+ const stripped = stripAmrHeader(buf);
5563
+ const silk = await loadSilkWasm();
5564
+ if (silk?.isSilk(buf) || silk?.isSilk(stripped)) {
5438
5565
  debugLog(`[audio-convert] SILK detected by header: ${filePath} (${buf.length} bytes)`);
5439
5566
  return buf.toString("base64");
5440
5567
  }
5441
5568
  const targetRate = 24e3;
5442
5569
  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
- }
5570
+ if (ext === ".pcm") return (await pcmToSilk(buf, targetRate)).toString("base64");
5447
5571
  if (ext === ".wav" || buf.length >= 4 && buf.toString("ascii", 0, 4) === "RIFF") {
5448
5572
  const wavInfo = parseWavFallback(buf);
5449
- if (wavInfo) {
5450
- const { silkBuffer } = await pcmToSilk(wavInfo, targetRate);
5451
- return silkBuffer.toString("base64");
5452
- }
5573
+ if (wavInfo) return (await pcmToSilk(wavInfo, targetRate)).toString("base64");
5453
5574
  }
5454
5575
  if (ext === ".mp3" || ext === ".mpeg") {
5455
5576
  const pcmBuf = await wasmDecodeMp3ToPCM(buf, targetRate);
5456
5577
  if (pcmBuf) {
5457
- const { silkBuffer } = await pcmToSilk(pcmBuf, targetRate);
5578
+ const silkBuffer = await pcmToSilk(pcmBuf, targetRate);
5458
5579
  debugLog(`[audio-convert] WASM: MP3 → SILK done (${silkBuffer.length} bytes)`);
5459
5580
  return silkBuffer.toString("base64");
5460
5581
  }
@@ -5524,12 +5645,8 @@ async function waitForFile(filePath, timeoutMs = 3e4, pollMs = 500) {
5524
5645
  async function pcmToSilk(pcmBuffer, sampleRate) {
5525
5646
  const silk = await loadSilkWasm();
5526
5647
  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
- };
5648
+ const result = await silk.encode(pcmBuffer, sampleRate);
5649
+ return Buffer.from(result.data.buffer, result.data.byteOffset, result.data.byteLength);
5533
5650
  }
5534
5651
  /** Decode MP3 to PCM via mpg123-decoder WASM. */
5535
5652
  async function wasmDecodeMp3ToPCM(buf, targetRate) {
@@ -5546,20 +5663,21 @@ async function wasmDecodeMp3ToPCM(buf, targetRate) {
5546
5663
  }
5547
5664
  debugLog(`[audio-convert] WASM MP3 decode: samples=${decoded.samplesDecoded}, sampleRate=${decoded.sampleRate}, channels=${decoded.channelData.length}`);
5548
5665
  let floatMono;
5549
- if (decoded.channelData.length === 1) floatMono = decoded.channelData[0];
5666
+ if (decoded.channelData.length === 1) floatMono = expectDefined(decoded.channelData.at(0), "single decoded MP3 channel");
5550
5667
  else {
5551
5668
  floatMono = new Float32Array(decoded.samplesDecoded);
5552
5669
  const channels = decoded.channelData.length;
5553
5670
  for (let i = 0; i < decoded.samplesDecoded; i++) {
5554
5671
  let sum = 0;
5555
- for (let ch = 0; ch < channels; ch++) sum += decoded.channelData[ch][i];
5672
+ for (const channel of decoded.channelData) sum += expectDefined(channel.at(i), "decoded MP3 channel sample");
5556
5673
  floatMono[i] = sum / channels;
5557
5674
  }
5558
5675
  }
5559
5676
  const s16 = new Uint8Array(floatMono.length * 2);
5560
5677
  const view = new DataView(s16.buffer);
5561
5678
  for (let i = 0; i < floatMono.length; i++) {
5562
- const clamped = Math.max(-1, Math.min(1, floatMono[i]));
5679
+ const sample = expectDefined(floatMono.at(i), "mono MP3 sample index");
5680
+ const clamped = Math.max(-1, Math.min(1, sample));
5563
5681
  const val = clamped < 0 ? clamped * 32768 : clamped * 32767;
5564
5682
  view.setInt16(i * 2, Math.round(val), true);
5565
5683
  }
@@ -5720,7 +5838,6 @@ const TOOL_MEDIA_SEND_TIMEOUT = 45e3;
5720
5838
  function shouldDeliverToolProgressImmediately(account, useOfficialC2cStream) {
5721
5839
  if (useOfficialC2cStream) return true;
5722
5840
  const streaming = account.config?.streaming;
5723
- if (streaming === true) return true;
5724
5841
  return typeof streaming === "object" && streaming !== null && streaming.mode !== "off";
5725
5842
  }
5726
5843
  function immediateToolProgressText(payload) {
@@ -6132,11 +6249,7 @@ async function dispatchOutbound(inbound, deps) {
6132
6249
  }
6133
6250
  },
6134
6251
  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
- })(),
6252
+ disableBlockStreaming: useOfficialC2cStream ? true : account.config?.streaming?.mode === "off",
6140
6253
  ...streamingController ? { onPartialReply: async (payload) => {
6141
6254
  try {
6142
6255
  await streamingController.onPartialReply(payload);
@@ -6262,41 +6375,35 @@ async function buildCtxPayload(inbound, runtime, cfg) {
6262
6375
  /**
6263
6376
  * Periodically refresh C2C typing state while a response is in progress.
6264
6377
  *
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.
6378
+ * Interval scheduling comes from the core typing keepalive loop; this module
6379
+ * owns the QQ passive-reply budget accounting and token-refresh retry.
6267
6380
  */
6268
6381
  /** Refresh every 5s for the QQ API's 10s input-notify window. */
6269
6382
  const TYPING_INTERVAL_MS = 5e3;
6383
+ const FINAL_REPLY_RESERVE_COUNT = 1;
6270
6384
  var TypingKeepAlive = class {
6271
- constructor(getToken, clearCache, sendInputNotify, openid, msgId, log) {
6385
+ constructor(getToken, clearCache, sendInputNotify, openid, msgId, log, claimPassiveReply = claimMessageReply) {
6272
6386
  this.getToken = getToken;
6273
6387
  this.clearCache = clearCache;
6274
6388
  this.sendInputNotify = sendInputNotify;
6275
6389
  this.openid = openid;
6276
6390
  this.msgId = msgId;
6277
6391
  this.log = log;
6278
- this.timer = null;
6392
+ this.claimPassiveReply = claimPassiveReply;
6279
6393
  this.stopped = false;
6280
- this.renewalsRemaining = 3;
6394
+ this.loop = createTypingKeepaliveLoop({
6395
+ intervalMs: TYPING_INTERVAL_MS,
6396
+ onTick: () => this.send()
6397
+ });
6281
6398
  }
6282
6399
  /** Start periodic keep-alive sends. */
6283
6400
  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);
6401
+ if (!this.stopped) this.loop.start();
6292
6402
  }
6293
6403
  /** Stop periodic keep-alive sends. */
6294
6404
  stop() {
6295
6405
  this.stopped = true;
6296
- if (this.timer) {
6297
- clearInterval(this.timer);
6298
- this.timer = null;
6299
- }
6406
+ this.loop.stop();
6300
6407
  }
6301
6408
  async send() {
6302
6409
  try {
@@ -6313,16 +6420,18 @@ var TypingKeepAlive = class {
6313
6420
  }
6314
6421
  }
6315
6422
  async sendAttempt(token) {
6316
- if (this.stopped || this.renewalsRemaining <= 0) {
6423
+ if (this.stopped) return;
6424
+ const claim = this.claimPassiveReply(this.msgId, FINAL_REPLY_RESERVE_COUNT);
6425
+ if (!claim.allowed) {
6426
+ this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`);
6317
6427
  this.stop();
6318
6428
  return;
6319
6429
  }
6320
- this.renewalsRemaining--;
6321
6430
  try {
6322
6431
  await this.sendInputNotify(token, this.openid, this.msgId, 10);
6323
6432
  this.log?.debug?.(`Typing keep-alive sent to ${this.openid}`);
6324
6433
  } finally {
6325
- if (this.renewalsRemaining <= 0) {
6434
+ if (claim.remaining <= FINAL_REPLY_RESERVE_COUNT) {
6326
6435
  this.log?.debug?.(`Typing keep-alive budget exhausted for ${this.openid}`);
6327
6436
  this.stop();
6328
6437
  }
@@ -6503,6 +6612,7 @@ async function startTypingForEvent(event, account, log) {
6503
6612
  const creds = accountToCreds(account);
6504
6613
  const rawNotifyFn = createRawInputNotifyFn(account.appId);
6505
6614
  const sendNotifyAndStartKeepAlive = async () => {
6615
+ if (!claimMessageReply(event.messageId, 1).allowed) return { keepAlive: null };
6506
6616
  const resp = await sendInputNotify({
6507
6617
  openid: event.senderId,
6508
6618
  creds,