@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.
- package/dist/api.js +8 -7
- package/dist/{channel-entry-C5YdhX3Y.js → channel-entry-CA2T2sf8.js} +4 -4
- package/dist/channel-entry-api.js +1 -1
- package/dist/{channel-D1UztsnG.js → channel-nz3Mkify.js} +99 -54
- package/dist/channel-plugin-api.js +1 -1
- package/dist/{channel.setup-2ItDYKhz.js → channel.setup-CNoBtKXQ.js} +1 -1
- package/dist/{config-C1qZbh0K.js → config-CpOXnoEc.js} +2 -2
- package/dist/{config-schema-JZEf1dvB.js → config-schema-D7MaH5X5.js} +61 -76
- package/dist/doctor-contract-api.js +4 -10
- package/dist/{gateway-pJQppxe4.js → gateway-D8uYPtoy.js} +286 -174
- package/dist/{group-o0GmovSf.js → group-BVHG8qUZ.js} +40 -23
- package/dist/{handler-runtime-zQvT6SrI.js → handler-runtime-S1_XF8on.js} +8 -12
- package/dist/{log-DEtcoDWe.js → log-Da4jz75I.js} +4 -8
- package/dist/{outbound-BIrfvvFJ.js → outbound-FOG4zNLY.js} +8 -142
- package/dist/{runtime-DodcT_fQ.js → runtime-CyjBiGD2.js} +2 -2
- package/dist/runtime-api.js +1 -1
- package/dist/secret-contract-api.js +6 -22
- package/dist/{sender-CjDuU-uz.js → sender-BAUHZqDW.js} +225 -66
- package/dist/setup-plugin-api.js +1 -1
- package/dist/state-keys-jLJ2SmJA.js +225 -0
- package/dist/{tools-UJJ-tLHP.js → tools-CC5CKQig.js} +71 -26
- package/dist/tools-api.js +1 -1
- package/node_modules/p-map/index.d.ts +155 -0
- package/node_modules/p-map/index.js +284 -0
- package/node_modules/p-map/license +9 -0
- package/node_modules/p-map/package.json +57 -0
- package/node_modules/p-map/readme.md +190 -0
- package/node_modules/parse-ms/index.d.ts +30 -0
- package/node_modules/parse-ms/index.js +45 -0
- package/node_modules/parse-ms/license +9 -0
- package/node_modules/parse-ms/package.json +47 -0
- package/node_modules/parse-ms/readme.md +46 -0
- package/node_modules/pretty-ms/index.d.ts +157 -0
- package/node_modules/pretty-ms/index.js +149 -0
- package/node_modules/pretty-ms/license +9 -0
- package/node_modules/pretty-ms/package.json +55 -0
- package/node_modules/pretty-ms/readme.md +179 -0
- package/npm-shrinkwrap.json +44 -3
- package/openclaw.plugin.json +105 -133
- package/package.json +8 -4
- package/dist/state-keys-CQKlAFyo.js +0 -141
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
4
|
-
import { a as authorizeQQBotApprovalAction, i as toGatewayAccount, n as createQQBotMarkdownChunker, p as parseApprovalButtonData, r as openQQBotSyncKeyedStore } from "./channel-
|
|
5
|
-
import { d as resolveAccountBase } from "./config-
|
|
6
|
-
import { a as formatErrorMessage, i as formatDuration, n as debugLog, r as debugWarn, t as debugError } from "./log-
|
|
7
|
-
import { n as getQQBotRuntimeForEngine, t as getQQBotRuntime } from "./runtime-
|
|
8
|
-
import { t as buildQQBotStateKey } from "./state-keys-
|
|
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-
|
|
10
|
-
import {
|
|
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
|
|
1083
|
-
const first = batch
|
|
1084
|
-
const last = batch
|
|
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
|
|
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} (${
|
|
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
|
|
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
|
|
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 (
|
|
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:
|
|
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 (
|
|
1852
|
+
if (normalizedContentType.startsWith("image/")) return {
|
|
1828
1853
|
localPath: null,
|
|
1829
1854
|
type: "image-fallback",
|
|
1830
1855
|
attUrl,
|
|
1831
|
-
contentType:
|
|
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)
|
|
2114
|
-
if (
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
3051
|
-
|
|
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
|
|
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: (
|
|
3237
|
-
height: (
|
|
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
|
|
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
|
|
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
|
|
3467
|
-
if (
|
|
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
|
-
}),
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
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
|
|
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
|
|
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`
|
|
5252
|
-
* -
|
|
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
|
-
|
|
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(
|
|
5462
|
+
if (!silk || !silk.isSilk(strippedBuf)) return null;
|
|
5325
5463
|
const sampleRate = 24e3;
|
|
5326
|
-
const result = await silk.decode(
|
|
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
|
-
|
|
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
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
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
|
|
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
|
|
5528
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
*
|
|
6266
|
-
*
|
|
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.
|
|
6394
|
+
this.claimPassiveReply = claimPassiveReply;
|
|
6279
6395
|
this.stopped = false;
|
|
6280
|
-
this.
|
|
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)
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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,
|