@tencent-connect/openclaw-qqbot 1.6.2-alpha.1 → 1.6.2-alpha.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/src/channel.d.ts +0 -8
- package/dist/src/channel.js +29 -11
- package/dist/src/gateway.js +84 -83
- package/dist/src/outbound.js +4 -4
- package/dist/src/update-checker.d.ts +7 -0
- package/dist/src/update-checker.js +19 -0
- package/dist/src/user-messages.d.ts +4 -31
- package/dist/src/user-messages.js +8 -53
- package/package.json +1 -1
- package/src/channel.ts +33 -12
- package/src/gateway.ts +75 -76
- package/src/outbound.ts +4 -4
- package/src/update-checker.ts +23 -0
- package/src/user-messages.ts +8 -57
package/dist/src/channel.d.ts
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
1
|
import { type ChannelPlugin } from "openclaw/plugin-sdk";
|
|
2
2
|
import type { ResolvedQQBotAccount } from "./types.js";
|
|
3
|
-
/** QQ Bot 单条消息文本长度上限 */
|
|
4
|
-
export declare const TEXT_CHUNK_LIMIT = 5000;
|
|
5
|
-
/**
|
|
6
|
-
* Markdown 感知的文本分块函数
|
|
7
|
-
* 委托给 SDK 内置的 channel.text.chunkMarkdownText
|
|
8
|
-
* 支持代码块自动关闭/重开、括号感知等
|
|
9
|
-
*/
|
|
10
|
-
export declare function chunkText(text: string, limit: number): string[];
|
|
11
3
|
export declare const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount>;
|
package/dist/src/channel.js
CHANGED
|
@@ -4,16 +4,34 @@ import { sendText, sendMedia } from "./outbound.js";
|
|
|
4
4
|
import { startGateway } from "./gateway.js";
|
|
5
5
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
6
6
|
import { getQQBotRuntime } from "./runtime.js";
|
|
7
|
-
/** QQ Bot 单条消息文本长度上限 */
|
|
8
|
-
export const TEXT_CHUNK_LIMIT = 5000;
|
|
9
7
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* 支持代码块自动关闭/重开、括号感知等
|
|
8
|
+
* 简单的文本分块函数
|
|
9
|
+
* 用于预先分块长文本
|
|
13
10
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
function chunkText(text, limit) {
|
|
12
|
+
if (text.length <= limit)
|
|
13
|
+
return [text];
|
|
14
|
+
const chunks = [];
|
|
15
|
+
let remaining = text;
|
|
16
|
+
while (remaining.length > 0) {
|
|
17
|
+
if (remaining.length <= limit) {
|
|
18
|
+
chunks.push(remaining);
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
// 尝试在换行处分割
|
|
22
|
+
let splitAt = remaining.lastIndexOf("\n", limit);
|
|
23
|
+
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
|
24
|
+
// 没找到合适的换行,尝试在空格处分割
|
|
25
|
+
splitAt = remaining.lastIndexOf(" ", limit);
|
|
26
|
+
}
|
|
27
|
+
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
|
28
|
+
// 还是没找到,强制在 limit 处分割
|
|
29
|
+
splitAt = limit;
|
|
30
|
+
}
|
|
31
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
32
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
33
|
+
}
|
|
34
|
+
return chunks;
|
|
17
35
|
}
|
|
18
36
|
export const qqbotPlugin = {
|
|
19
37
|
id: "qqbot",
|
|
@@ -34,7 +52,7 @@ export const qqbotPlugin = {
|
|
|
34
52
|
* blockStreaming: true 表示该 Channel 支持块流式
|
|
35
53
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
36
54
|
*/
|
|
37
|
-
blockStreaming:
|
|
55
|
+
blockStreaming: false,
|
|
38
56
|
},
|
|
39
57
|
reload: { configPrefixes: ["channels.qqbot"] },
|
|
40
58
|
// CLI onboarding wizard
|
|
@@ -187,9 +205,9 @@ export const qqbotPlugin = {
|
|
|
187
205
|
},
|
|
188
206
|
outbound: {
|
|
189
207
|
deliveryMode: "direct",
|
|
190
|
-
chunker:
|
|
208
|
+
chunker: chunkText,
|
|
191
209
|
chunkerMode: "markdown",
|
|
192
|
-
textChunkLimit:
|
|
210
|
+
textChunkLimit: 20000,
|
|
193
211
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
194
212
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
195
213
|
console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
|
package/dist/src/gateway.js
CHANGED
|
@@ -7,7 +7,7 @@ import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.
|
|
|
7
7
|
import { getQQBotRuntime } from "./runtime.js";
|
|
8
8
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex } from "./ref-index-store.js";
|
|
9
9
|
import { matchSlashCommand, getPluginVersion } from "./slash-commands.js";
|
|
10
|
-
import { triggerUpdateCheck } from "./update-checker.js";
|
|
10
|
+
import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
|
|
11
11
|
import { startImageServer, isImageServerRunning, downloadFile } from "./image-server.js";
|
|
12
12
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
|
13
13
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
|
|
@@ -15,9 +15,8 @@ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig,
|
|
|
15
15
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
16
16
|
import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from "./utils/file-utils.js";
|
|
17
17
|
import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
18
|
-
import { MSG
|
|
18
|
+
import { MSG } from "./user-messages.js";
|
|
19
19
|
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto } from "./outbound.js";
|
|
20
|
-
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
|
21
20
|
function resolveSTTConfig(cfg) {
|
|
22
21
|
const c = cfg;
|
|
23
22
|
// 优先使用 channels.qqbot.stt(插件专属配置)
|
|
@@ -173,7 +172,6 @@ function parseFaceTags(text) {
|
|
|
173
172
|
}
|
|
174
173
|
});
|
|
175
174
|
}
|
|
176
|
-
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
177
175
|
// ============ 内部标记过滤 ============
|
|
178
176
|
/**
|
|
179
177
|
* 过滤内部标记(如 [[reply_to: xxx]])
|
|
@@ -316,8 +314,36 @@ export async function startGateway(ctx) {
|
|
|
316
314
|
log?.info(`[qqbot:${account.accountId}] ${w}`);
|
|
317
315
|
}
|
|
318
316
|
}
|
|
319
|
-
//
|
|
317
|
+
// 后台版本检查(detached 子进程,零阻塞)
|
|
320
318
|
triggerUpdateCheck(log);
|
|
319
|
+
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
320
|
+
let lastUpdateNotifyAt = 0;
|
|
321
|
+
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
322
|
+
onUpdateFound(async (info) => {
|
|
323
|
+
try {
|
|
324
|
+
// 防抖:避免短时间内重复推送
|
|
325
|
+
const now = Date.now();
|
|
326
|
+
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
327
|
+
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const notice = formatUpdateNotice(info);
|
|
331
|
+
if (!notice)
|
|
332
|
+
return;
|
|
333
|
+
const adminId = resolveAdminOpenId();
|
|
334
|
+
if (!adminId) {
|
|
335
|
+
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
339
|
+
await sendProactiveC2CMessage(token, adminId, notice);
|
|
340
|
+
lastUpdateNotifyAt = Date.now();
|
|
341
|
+
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
321
347
|
// 初始化 API 配置(markdown 支持)
|
|
322
348
|
initApiConfig({
|
|
323
349
|
markdownSupport: account.markdownSupport,
|
|
@@ -1480,34 +1506,29 @@ export async function startGateway(ctx) {
|
|
|
1480
1506
|
};
|
|
1481
1507
|
for (const item of sendQueue) {
|
|
1482
1508
|
if (item.type === "text") {
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
}
|
|
1501
|
-
catch (err) {
|
|
1502
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send text chunk: ${err}`);
|
|
1503
|
-
}
|
|
1509
|
+
try {
|
|
1510
|
+
await sendWithTokenRetry(async (token) => {
|
|
1511
|
+
const ref = consumeQuoteRef();
|
|
1512
|
+
if (event.type === "c2c") {
|
|
1513
|
+
return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
|
|
1514
|
+
}
|
|
1515
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
1516
|
+
return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
|
|
1517
|
+
}
|
|
1518
|
+
else if (event.channelId) {
|
|
1519
|
+
return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
|
|
1520
|
+
}
|
|
1521
|
+
});
|
|
1522
|
+
log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
|
|
1523
|
+
}
|
|
1524
|
+
catch (err) {
|
|
1525
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
|
|
1504
1526
|
}
|
|
1505
1527
|
}
|
|
1506
1528
|
else if (item.type === "image") {
|
|
1507
1529
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
1508
1530
|
if (result.error) {
|
|
1509
1531
|
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1510
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
|
|
1511
1532
|
}
|
|
1512
1533
|
}
|
|
1513
1534
|
else if (item.type === "voice") {
|
|
@@ -1522,26 +1543,22 @@ export async function startGateway(ctx) {
|
|
|
1522
1543
|
]);
|
|
1523
1544
|
if (result.error) {
|
|
1524
1545
|
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1525
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
|
|
1526
1546
|
}
|
|
1527
1547
|
}
|
|
1528
1548
|
catch (err) {
|
|
1529
1549
|
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1530
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1531
1550
|
}
|
|
1532
1551
|
}
|
|
1533
1552
|
else if (item.type === "video") {
|
|
1534
1553
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1535
1554
|
if (result.error) {
|
|
1536
1555
|
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1537
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
|
|
1538
1556
|
}
|
|
1539
1557
|
}
|
|
1540
1558
|
else if (item.type === "file") {
|
|
1541
1559
|
const result = await sendDocument(mediaTarget, item.content);
|
|
1542
1560
|
if (result.error) {
|
|
1543
1561
|
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1544
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
|
|
1545
1562
|
}
|
|
1546
1563
|
}
|
|
1547
1564
|
else if (item.type === "media") {
|
|
@@ -1556,7 +1573,6 @@ export async function startGateway(ctx) {
|
|
|
1556
1573
|
});
|
|
1557
1574
|
if (result.error) {
|
|
1558
1575
|
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1559
|
-
await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
|
|
1560
1576
|
}
|
|
1561
1577
|
}
|
|
1562
1578
|
}
|
|
@@ -1625,12 +1641,12 @@ export async function startGateway(ctx) {
|
|
|
1625
1641
|
if (parsedPayload.source === "file") {
|
|
1626
1642
|
try {
|
|
1627
1643
|
if (!(await fileExistsAsync(imageUrl))) {
|
|
1628
|
-
|
|
1644
|
+
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
1629
1645
|
return;
|
|
1630
1646
|
}
|
|
1631
1647
|
const imgSzCheck = checkFileSize(imageUrl);
|
|
1632
1648
|
if (!imgSzCheck.ok) {
|
|
1633
|
-
|
|
1649
|
+
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
1634
1650
|
return;
|
|
1635
1651
|
}
|
|
1636
1652
|
const fileBuffer = await readFileAsync(imageUrl);
|
|
@@ -1654,7 +1670,6 @@ export async function startGateway(ctx) {
|
|
|
1654
1670
|
}
|
|
1655
1671
|
catch (readErr) {
|
|
1656
1672
|
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1657
|
-
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1658
1673
|
return;
|
|
1659
1674
|
}
|
|
1660
1675
|
}
|
|
@@ -1690,7 +1705,6 @@ export async function startGateway(ctx) {
|
|
|
1690
1705
|
}
|
|
1691
1706
|
catch (err) {
|
|
1692
1707
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
|
|
1693
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", err));
|
|
1694
1708
|
}
|
|
1695
1709
|
}
|
|
1696
1710
|
else if (parsedPayload.mediaType === "audio") {
|
|
@@ -1704,7 +1718,6 @@ export async function startGateway(ctx) {
|
|
|
1704
1718
|
const ttsCfg = resolveTTSConfig(cfg);
|
|
1705
1719
|
if (!ttsCfg) {
|
|
1706
1720
|
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1707
|
-
await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
|
|
1708
1721
|
}
|
|
1709
1722
|
else {
|
|
1710
1723
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
@@ -1728,7 +1741,6 @@ export async function startGateway(ctx) {
|
|
|
1728
1741
|
}
|
|
1729
1742
|
catch (err) {
|
|
1730
1743
|
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1731
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1732
1744
|
}
|
|
1733
1745
|
}
|
|
1734
1746
|
else if (parsedPayload.mediaType === "video") {
|
|
@@ -1796,7 +1808,6 @@ export async function startGateway(ctx) {
|
|
|
1796
1808
|
}
|
|
1797
1809
|
catch (err) {
|
|
1798
1810
|
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
|
1799
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", err));
|
|
1800
1811
|
}
|
|
1801
1812
|
}
|
|
1802
1813
|
else if (parsedPayload.mediaType === "file") {
|
|
@@ -1848,7 +1859,6 @@ export async function startGateway(ctx) {
|
|
|
1848
1859
|
}
|
|
1849
1860
|
catch (err) {
|
|
1850
1861
|
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
|
1851
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", err));
|
|
1852
1862
|
}
|
|
1853
1863
|
}
|
|
1854
1864
|
else {
|
|
@@ -2054,26 +2064,23 @@ export async function startGateway(ctx) {
|
|
|
2054
2064
|
}
|
|
2055
2065
|
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2056
2066
|
if (textWithoutImages.trim()) {
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
}
|
|
2074
|
-
catch (err) {
|
|
2075
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2076
|
-
}
|
|
2067
|
+
try {
|
|
2068
|
+
await sendWithTokenRetry(async (token) => {
|
|
2069
|
+
const ref = consumeQuoteRef();
|
|
2070
|
+
if (event.type === "c2c") {
|
|
2071
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2072
|
+
}
|
|
2073
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
2074
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2075
|
+
}
|
|
2076
|
+
else if (event.channelId) {
|
|
2077
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2078
|
+
}
|
|
2079
|
+
});
|
|
2080
|
+
log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2081
|
+
}
|
|
2082
|
+
catch (err) {
|
|
2083
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
|
|
2077
2084
|
}
|
|
2078
2085
|
}
|
|
2079
2086
|
}
|
|
@@ -2113,24 +2120,21 @@ export async function startGateway(ctx) {
|
|
|
2113
2120
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
|
2114
2121
|
}
|
|
2115
2122
|
}
|
|
2116
|
-
//
|
|
2123
|
+
// 发送文本消息
|
|
2117
2124
|
if (textWithoutImages.trim()) {
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
});
|
|
2132
|
-
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2133
|
-
}
|
|
2125
|
+
await sendWithTokenRetry(async (token) => {
|
|
2126
|
+
const ref = consumeQuoteRef();
|
|
2127
|
+
if (event.type === "c2c") {
|
|
2128
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2129
|
+
}
|
|
2130
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
2131
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2132
|
+
}
|
|
2133
|
+
else if (event.channelId) {
|
|
2134
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2135
|
+
}
|
|
2136
|
+
});
|
|
2137
|
+
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
|
|
2134
2138
|
}
|
|
2135
2139
|
}
|
|
2136
2140
|
catch (err) {
|
|
@@ -2207,15 +2211,15 @@ export async function startGateway(ctx) {
|
|
|
2207
2211
|
// 发送错误提示给用户,显示完整错误信息
|
|
2208
2212
|
const errMsg = String(err);
|
|
2209
2213
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
2210
|
-
|
|
2214
|
+
log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
|
|
2211
2215
|
}
|
|
2212
2216
|
else {
|
|
2213
|
-
|
|
2217
|
+
log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
|
|
2214
2218
|
}
|
|
2215
2219
|
},
|
|
2216
2220
|
},
|
|
2217
2221
|
replyOptions: {
|
|
2218
|
-
disableBlockStreaming:
|
|
2222
|
+
disableBlockStreaming: false,
|
|
2219
2223
|
},
|
|
2220
2224
|
});
|
|
2221
2225
|
// 等待分发完成或超时
|
|
@@ -2228,7 +2232,6 @@ export async function startGateway(ctx) {
|
|
|
2228
2232
|
}
|
|
2229
2233
|
if (!hasResponse) {
|
|
2230
2234
|
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
2231
|
-
await sendErrorMessage(MSG.TIMEOUT_HINT);
|
|
2232
2235
|
}
|
|
2233
2236
|
}
|
|
2234
2237
|
finally {
|
|
@@ -2247,7 +2250,6 @@ export async function startGateway(ctx) {
|
|
|
2247
2250
|
}
|
|
2248
2251
|
catch (err) {
|
|
2249
2252
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2250
|
-
await sendErrorMessage(MSG.GENERIC_ERROR);
|
|
2251
2253
|
}
|
|
2252
2254
|
};
|
|
2253
2255
|
ws.on("open", () => {
|
|
@@ -2350,7 +2352,6 @@ export async function startGateway(ctx) {
|
|
|
2350
2352
|
}
|
|
2351
2353
|
else if (t === "RESUMED") {
|
|
2352
2354
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2353
|
-
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
2354
2355
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2355
2356
|
if (isFirstReadyGlobal) {
|
|
2356
2357
|
isFirstReadyGlobal = false;
|
package/dist/src/outbound.js
CHANGED
|
@@ -208,7 +208,7 @@ export async function sendPhoto(ctx, imagePath) {
|
|
|
208
208
|
let imageUrl = mediaPath;
|
|
209
209
|
if (isLocal) {
|
|
210
210
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
211
|
-
return { channel: "qqbot", error:
|
|
211
|
+
return { channel: "qqbot", error: "图片不存在或已失效" };
|
|
212
212
|
}
|
|
213
213
|
const sizeCheck = checkFileSize(mediaPath);
|
|
214
214
|
if (!sizeCheck.ok) {
|
|
@@ -345,7 +345,7 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
|
|
|
345
345
|
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
346
346
|
const fileSize = await waitForFile(mediaPath);
|
|
347
347
|
if (fileSize === 0) {
|
|
348
|
-
return { channel: "qqbot", error:
|
|
348
|
+
return { channel: "qqbot", error: "语音文件未就绪" };
|
|
349
349
|
}
|
|
350
350
|
// 精细检测:是否需要转码
|
|
351
351
|
const needsTranscode = shouldTranscodeVoice(mediaPath);
|
|
@@ -441,7 +441,7 @@ export async function sendVideoMsg(ctx, videoPath) {
|
|
|
441
441
|
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
442
442
|
async function sendVideoFromLocal(ctx, mediaPath, prefix) {
|
|
443
443
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
444
|
-
return { channel: "qqbot", error:
|
|
444
|
+
return { channel: "qqbot", error: "视频文件不存在或已失效" };
|
|
445
445
|
}
|
|
446
446
|
const sizeCheck = checkFileSize(mediaPath);
|
|
447
447
|
if (!sizeCheck.ok) {
|
|
@@ -528,7 +528,7 @@ export async function sendDocument(ctx, filePath) {
|
|
|
528
528
|
async function sendDocumentFromLocal(ctx, mediaPath, prefix) {
|
|
529
529
|
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
530
530
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
531
|
-
return { channel: "qqbot", error:
|
|
531
|
+
return { channel: "qqbot", error: "文件不存在或已失效" };
|
|
532
532
|
}
|
|
533
533
|
const sizeCheck = checkFileSize(mediaPath);
|
|
534
534
|
if (!sizeCheck.ok) {
|
|
@@ -12,9 +12,16 @@ export interface UpdateInfo {
|
|
|
12
12
|
checkedAt: number;
|
|
13
13
|
error?: string;
|
|
14
14
|
}
|
|
15
|
+
type UpdateFoundCallback = (info: UpdateInfo) => void;
|
|
16
|
+
/**
|
|
17
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
18
|
+
*/
|
|
19
|
+
export declare function onUpdateFound(cb: UpdateFoundCallback): void;
|
|
15
20
|
export declare function triggerUpdateCheck(log?: {
|
|
16
21
|
info: (msg: string) => void;
|
|
17
22
|
error: (msg: string) => void;
|
|
18
23
|
debug?: (msg: string) => void;
|
|
19
24
|
}): void;
|
|
20
25
|
export declare function getUpdateInfo(): UpdateInfo;
|
|
26
|
+
export declare function formatUpdateNotice(info: UpdateInfo): string;
|
|
27
|
+
export {};
|
|
@@ -24,6 +24,15 @@ let _lastInfo = {
|
|
|
24
24
|
checkedAt: 0,
|
|
25
25
|
};
|
|
26
26
|
let _checking = false;
|
|
27
|
+
/** 已通知过的版本号,避免同一版本重复推送 */
|
|
28
|
+
let _notifiedVersion = null;
|
|
29
|
+
let _onUpdateFound = null;
|
|
30
|
+
/**
|
|
31
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
32
|
+
*/
|
|
33
|
+
export function onUpdateFound(cb) {
|
|
34
|
+
_onUpdateFound = cb;
|
|
35
|
+
}
|
|
27
36
|
export function triggerUpdateCheck(log) {
|
|
28
37
|
if (_checking)
|
|
29
38
|
return;
|
|
@@ -55,6 +64,11 @@ export function triggerUpdateCheck(log) {
|
|
|
55
64
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
56
65
|
if (hasUpdate) {
|
|
57
66
|
log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
|
|
67
|
+
// 首次发现该版本时触发回调
|
|
68
|
+
if (_onUpdateFound && compareTarget !== _notifiedVersion) {
|
|
69
|
+
_notifiedVersion = compareTarget;
|
|
70
|
+
_onUpdateFound(_lastInfo);
|
|
71
|
+
}
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
catch (parseErr) {
|
|
@@ -65,6 +79,11 @@ export function triggerUpdateCheck(log) {
|
|
|
65
79
|
export function getUpdateInfo() {
|
|
66
80
|
return { ..._lastInfo };
|
|
67
81
|
}
|
|
82
|
+
export function formatUpdateNotice(info) {
|
|
83
|
+
if (!info.hasUpdate || !info.latest)
|
|
84
|
+
return "";
|
|
85
|
+
return `\u{1f195} 有新版本可用: v${info.latest}(当前 v${info.current})\n使用 /qqbot-upgrade 升级`;
|
|
86
|
+
}
|
|
68
87
|
function compareVersions(a, b) {
|
|
69
88
|
const parse = (v) => {
|
|
70
89
|
const clean = v.replace(/^v/, "");
|
|
@@ -1,43 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 用户面向的提示文案集中管理
|
|
3
|
-
*
|
|
4
|
-
* 设计原则(参考 Telegram / Discord / Slack / 飞书):
|
|
5
|
-
* 1. 禁止暴露服务器路径、原始 Error、配置文件结构
|
|
6
|
-
* 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
|
|
7
|
-
* 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
|
|
8
|
-
* 4. 所有面向用户的文案集中在此文件,便于维护和国际化
|
|
3
|
+
* 仅保留格式校验类提示,运行时错误走日志不面向用户
|
|
9
4
|
*/
|
|
10
5
|
export declare const MSG: {
|
|
11
|
-
readonly GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~";
|
|
12
|
-
readonly AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~";
|
|
13
|
-
readonly AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~";
|
|
14
|
-
readonly TIMEOUT_HINT: "已收到消息,正在处理中…";
|
|
15
|
-
readonly IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~";
|
|
16
6
|
readonly IMAGE_FORMAT_UNSUPPORTED: (ext: string) => string;
|
|
17
|
-
readonly IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~";
|
|
18
|
-
readonly IMAGE_UPLOADING: (size: string) => string;
|
|
19
|
-
readonly VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~";
|
|
20
|
-
readonly VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~";
|
|
21
|
-
readonly VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~";
|
|
22
|
-
readonly VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~";
|
|
23
|
-
readonly VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~";
|
|
24
7
|
readonly VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~";
|
|
25
|
-
readonly VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~";
|
|
26
|
-
readonly VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~";
|
|
27
|
-
readonly VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~";
|
|
28
8
|
readonly VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~";
|
|
29
|
-
readonly VIDEO_UPLOADING: (size: string) => string;
|
|
30
|
-
readonly FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~";
|
|
31
|
-
readonly FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~";
|
|
32
|
-
readonly FILE_MISSING_PATH: "抱歉,文件消息缺少内容~";
|
|
33
9
|
readonly FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~";
|
|
34
|
-
readonly
|
|
10
|
+
readonly VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~";
|
|
11
|
+
readonly VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~";
|
|
12
|
+
readonly FILE_MISSING_PATH: "抱歉,文件消息缺少内容~";
|
|
35
13
|
readonly PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~";
|
|
36
14
|
readonly UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~";
|
|
37
15
|
readonly UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~";
|
|
38
16
|
};
|
|
39
|
-
/**
|
|
40
|
-
* 将媒体上传/发送错误转为对用户友好的提示文案
|
|
41
|
-
* 技术细节不暴露给用户,仅记录到日志
|
|
42
|
-
*/
|
|
43
|
-
export declare function formatMediaErrorMessage(mediaType: string, err: unknown): string;
|
|
@@ -1,65 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 用户面向的提示文案集中管理
|
|
3
|
-
*
|
|
4
|
-
* 设计原则(参考 Telegram / Discord / Slack / 飞书):
|
|
5
|
-
* 1. 禁止暴露服务器路径、原始 Error、配置文件结构
|
|
6
|
-
* 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
|
|
7
|
-
* 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
|
|
8
|
-
* 4. 所有面向用户的文案集中在此文件,便于维护和国际化
|
|
3
|
+
* 仅保留格式校验类提示,运行时错误走日志不面向用户
|
|
9
4
|
*/
|
|
10
|
-
// ============ 媒体发送错误 ============
|
|
11
5
|
export const MSG = {
|
|
12
|
-
//
|
|
13
|
-
GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~",
|
|
14
|
-
AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~",
|
|
15
|
-
AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~",
|
|
16
|
-
TIMEOUT_HINT: "已收到消息,正在处理中…",
|
|
17
|
-
// 图片
|
|
18
|
-
IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~",
|
|
6
|
+
// 图片格式校验
|
|
19
7
|
IMAGE_FORMAT_UNSUPPORTED: (ext) => `抱歉,暂不支持 ${ext} 格式的图片~`,
|
|
20
|
-
|
|
21
|
-
IMAGE_UPLOADING: (size) => `正在上传图片 (${size})...`,
|
|
22
|
-
// 语音
|
|
23
|
-
VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~",
|
|
24
|
-
VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~",
|
|
25
|
-
VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~",
|
|
26
|
-
VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~",
|
|
27
|
-
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
8
|
+
// 频道不支持的媒体类型
|
|
28
9
|
VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
|
|
29
|
-
// 视频
|
|
30
|
-
VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~",
|
|
31
|
-
VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~",
|
|
32
|
-
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
33
10
|
VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
|
|
34
|
-
VIDEO_UPLOADING: (size) => `正在上传视频 (${size})...`,
|
|
35
|
-
// 文件
|
|
36
|
-
FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~",
|
|
37
|
-
FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~",
|
|
38
|
-
FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
|
|
39
11
|
FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
|
|
40
|
-
|
|
41
|
-
|
|
12
|
+
// 缺少必要内容
|
|
13
|
+
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
14
|
+
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
15
|
+
FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
|
|
16
|
+
// 载荷解析校验
|
|
42
17
|
PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
|
|
43
18
|
UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
|
|
44
19
|
UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
|
|
45
20
|
};
|
|
46
|
-
/**
|
|
47
|
-
* 将媒体上传/发送错误转为对用户友好的提示文案
|
|
48
|
-
* 技术细节不暴露给用户,仅记录到日志
|
|
49
|
-
*/
|
|
50
|
-
export function formatMediaErrorMessage(mediaType, err) {
|
|
51
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
52
|
-
if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
|
|
53
|
-
return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
|
|
54
|
-
}
|
|
55
|
-
if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
|
|
56
|
-
return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
|
|
57
|
-
}
|
|
58
|
-
if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
|
|
59
|
-
return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
|
|
60
|
-
}
|
|
61
|
-
if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
|
|
62
|
-
return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
|
|
63
|
-
}
|
|
64
|
-
return `抱歉,${mediaType}发送失败了,请稍后再试~`;
|
|
65
|
-
}
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -13,17 +13,38 @@ import { startGateway } from "./gateway.js";
|
|
|
13
13
|
import { qqbotOnboardingAdapter } from "./onboarding.js";
|
|
14
14
|
import { getQQBotRuntime } from "./runtime.js";
|
|
15
15
|
|
|
16
|
-
/** QQ Bot 单条消息文本长度上限 */
|
|
17
|
-
export const TEXT_CHUNK_LIMIT = 5000;
|
|
18
|
-
|
|
19
16
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* 支持代码块自动关闭/重开、括号感知等
|
|
17
|
+
* 简单的文本分块函数
|
|
18
|
+
* 用于预先分块长文本
|
|
23
19
|
*/
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
20
|
+
function chunkText(text: string, limit: number): string[] {
|
|
21
|
+
if (text.length <= limit) return [text];
|
|
22
|
+
|
|
23
|
+
const chunks: string[] = [];
|
|
24
|
+
let remaining = text;
|
|
25
|
+
|
|
26
|
+
while (remaining.length > 0) {
|
|
27
|
+
if (remaining.length <= limit) {
|
|
28
|
+
chunks.push(remaining);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// 尝试在换行处分割
|
|
33
|
+
let splitAt = remaining.lastIndexOf("\n", limit);
|
|
34
|
+
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
|
35
|
+
// 没找到合适的换行,尝试在空格处分割
|
|
36
|
+
splitAt = remaining.lastIndexOf(" ", limit);
|
|
37
|
+
}
|
|
38
|
+
if (splitAt <= 0 || splitAt < limit * 0.5) {
|
|
39
|
+
// 还是没找到,强制在 limit 处分割
|
|
40
|
+
splitAt = limit;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
44
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return chunks;
|
|
27
48
|
}
|
|
28
49
|
|
|
29
50
|
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
@@ -45,7 +66,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
45
66
|
* blockStreaming: true 表示该 Channel 支持块流式
|
|
46
67
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
47
68
|
*/
|
|
48
|
-
blockStreaming:
|
|
69
|
+
blockStreaming: false,
|
|
49
70
|
},
|
|
50
71
|
reload: { configPrefixes: ["channels.qqbot"] },
|
|
51
72
|
// CLI onboarding wizard
|
|
@@ -209,9 +230,9 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
209
230
|
},
|
|
210
231
|
outbound: {
|
|
211
232
|
deliveryMode: "direct",
|
|
212
|
-
chunker:
|
|
233
|
+
chunker: chunkText,
|
|
213
234
|
chunkerMode: "markdown",
|
|
214
|
-
textChunkLimit:
|
|
235
|
+
textChunkLimit: 20000,
|
|
215
236
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
216
237
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
217
238
|
console.log(`[qqbot:channel] sendText text preview: ${text?.slice(0, 100)}${(text?.length ?? 0) > 100 ? "..." : ""}`);
|
package/src/gateway.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { recordKnownUser, flushKnownUsers, listKnownUsers } from "./known-users.
|
|
|
8
8
|
import { getQQBotRuntime } from "./runtime.js";
|
|
9
9
|
import { setRefIndex, getRefIndex, formatRefEntryForAgent, flushRefIndex, type RefAttachmentSummary } from "./ref-index-store.js";
|
|
10
10
|
import { matchSlashCommand, getPluginVersion, type SlashCommandContext, type SlashCommandFileResult, type QueueSnapshot } from "./slash-commands.js";
|
|
11
|
-
import { triggerUpdateCheck } from "./update-checker.js";
|
|
11
|
+
import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
|
|
12
12
|
import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
|
|
13
13
|
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
|
|
14
14
|
import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
|
|
@@ -16,9 +16,8 @@ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig,
|
|
|
16
16
|
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
17
17
|
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
18
18
|
import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
19
|
-
import { MSG
|
|
19
|
+
import { MSG } from "./user-messages.js";
|
|
20
20
|
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
|
21
|
-
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* 通用 OpenAI 兼容 STT(语音转文字)
|
|
@@ -222,8 +221,6 @@ function parseFaceTags(text: string): string {
|
|
|
222
221
|
});
|
|
223
222
|
}
|
|
224
223
|
|
|
225
|
-
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
226
|
-
|
|
227
224
|
// ============ 内部标记过滤 ============
|
|
228
225
|
|
|
229
226
|
/**
|
|
@@ -411,9 +408,36 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
411
408
|
}
|
|
412
409
|
}
|
|
413
410
|
|
|
414
|
-
//
|
|
411
|
+
// 后台版本检查(detached 子进程,零阻塞)
|
|
415
412
|
triggerUpdateCheck(log);
|
|
416
413
|
|
|
414
|
+
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
415
|
+
let lastUpdateNotifyAt = 0;
|
|
416
|
+
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
417
|
+
onUpdateFound(async (info) => {
|
|
418
|
+
try {
|
|
419
|
+
// 防抖:避免短时间内重复推送
|
|
420
|
+
const now = Date.now();
|
|
421
|
+
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
422
|
+
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const notice = formatUpdateNotice(info);
|
|
426
|
+
if (!notice) return;
|
|
427
|
+
const adminId = resolveAdminOpenId();
|
|
428
|
+
if (!adminId) {
|
|
429
|
+
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
433
|
+
await sendProactiveC2CMessage(token, adminId, notice);
|
|
434
|
+
lastUpdateNotifyAt = Date.now();
|
|
435
|
+
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
|
|
417
441
|
// 初始化 API 配置(markdown 支持)
|
|
418
442
|
initApiConfig({
|
|
419
443
|
markdownSupport: account.markdownSupport,
|
|
@@ -1660,30 +1684,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1660
1684
|
|
|
1661
1685
|
for (const item of sendQueue) {
|
|
1662
1686
|
if (item.type === "text") {
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${item.content.length} chars): ${chunk.slice(0, 50)}...`);
|
|
1678
|
-
} catch (err) {
|
|
1679
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send text chunk: ${err}`);
|
|
1680
|
-
}
|
|
1687
|
+
try {
|
|
1688
|
+
await sendWithTokenRetry(async (token) => {
|
|
1689
|
+
const ref = consumeQuoteRef();
|
|
1690
|
+
if (event.type === "c2c") {
|
|
1691
|
+
return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
|
|
1692
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
1693
|
+
return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
|
|
1694
|
+
} else if (event.channelId) {
|
|
1695
|
+
return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
|
|
1696
|
+
}
|
|
1697
|
+
});
|
|
1698
|
+
log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
|
|
1681
1701
|
}
|
|
1682
1702
|
} else if (item.type === "image") {
|
|
1683
1703
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
1684
1704
|
if (result.error) {
|
|
1685
1705
|
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1686
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
|
|
1687
1706
|
}
|
|
1688
1707
|
} else if (item.type === "voice") {
|
|
1689
1708
|
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
@@ -1699,23 +1718,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1699
1718
|
]);
|
|
1700
1719
|
if (result.error) {
|
|
1701
1720
|
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1702
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
|
|
1703
1721
|
}
|
|
1704
1722
|
} catch (err) {
|
|
1705
1723
|
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1706
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1707
1724
|
}
|
|
1708
1725
|
} else if (item.type === "video") {
|
|
1709
1726
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1710
1727
|
if (result.error) {
|
|
1711
1728
|
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1712
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
|
|
1713
1729
|
}
|
|
1714
1730
|
} else if (item.type === "file") {
|
|
1715
1731
|
const result = await sendDocument(mediaTarget, item.content);
|
|
1716
1732
|
if (result.error) {
|
|
1717
1733
|
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1718
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
|
|
1719
1734
|
}
|
|
1720
1735
|
} else if (item.type === "media") {
|
|
1721
1736
|
// qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
|
|
@@ -1729,7 +1744,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1729
1744
|
});
|
|
1730
1745
|
if (result.error) {
|
|
1731
1746
|
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1732
|
-
await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
|
|
1733
1747
|
}
|
|
1734
1748
|
}
|
|
1735
1749
|
}
|
|
@@ -1804,12 +1818,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1804
1818
|
if (parsedPayload.source === "file") {
|
|
1805
1819
|
try {
|
|
1806
1820
|
if (!(await fileExistsAsync(imageUrl))) {
|
|
1807
|
-
|
|
1821
|
+
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
1808
1822
|
return;
|
|
1809
1823
|
}
|
|
1810
1824
|
const imgSzCheck = checkFileSize(imageUrl);
|
|
1811
1825
|
if (!imgSzCheck.ok) {
|
|
1812
|
-
|
|
1826
|
+
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
1813
1827
|
return;
|
|
1814
1828
|
}
|
|
1815
1829
|
const fileBuffer = await readFileAsync(imageUrl);
|
|
@@ -1832,7 +1846,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1832
1846
|
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
1833
1847
|
} catch (readErr) {
|
|
1834
1848
|
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1835
|
-
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1836
1849
|
return;
|
|
1837
1850
|
}
|
|
1838
1851
|
}
|
|
@@ -1865,7 +1878,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1865
1878
|
}
|
|
1866
1879
|
} catch (err) {
|
|
1867
1880
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
|
|
1868
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", err));
|
|
1869
1881
|
}
|
|
1870
1882
|
} else if (parsedPayload.mediaType === "audio") {
|
|
1871
1883
|
// TTS 语音发送:文字 → PCM → SILK → QQ 语音
|
|
@@ -1877,7 +1889,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1877
1889
|
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
1878
1890
|
if (!ttsCfg) {
|
|
1879
1891
|
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1880
|
-
await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
|
|
1881
1892
|
} else {
|
|
1882
1893
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
1883
1894
|
const ttsDir = getQQBotDataDir("tts");
|
|
@@ -1898,7 +1909,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1898
1909
|
}
|
|
1899
1910
|
} catch (err) {
|
|
1900
1911
|
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1901
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1902
1912
|
}
|
|
1903
1913
|
} else if (parsedPayload.mediaType === "video") {
|
|
1904
1914
|
// 视频发送:支持公网 URL 和本地文件
|
|
@@ -1959,7 +1969,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1959
1969
|
}
|
|
1960
1970
|
} catch (err) {
|
|
1961
1971
|
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
|
1962
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", err));
|
|
1963
1972
|
}
|
|
1964
1973
|
} else if (parsedPayload.mediaType === "file") {
|
|
1965
1974
|
// 文件发送
|
|
@@ -2004,7 +2013,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2004
2013
|
}
|
|
2005
2014
|
} catch (err) {
|
|
2006
2015
|
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
|
2007
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", err));
|
|
2008
2016
|
}
|
|
2009
2017
|
} else {
|
|
2010
2018
|
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
|
|
@@ -2223,23 +2231,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2223
2231
|
|
|
2224
2232
|
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2225
2233
|
if (textWithoutImages.trim()) {
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
} catch (err) {
|
|
2241
|
-
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2242
|
-
}
|
|
2234
|
+
try {
|
|
2235
|
+
await sendWithTokenRetry(async (token) => {
|
|
2236
|
+
const ref = consumeQuoteRef();
|
|
2237
|
+
if (event.type === "c2c") {
|
|
2238
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2239
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2240
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2241
|
+
} else if (event.channelId) {
|
|
2242
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2246
|
+
} catch (err) {
|
|
2247
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
|
|
2243
2248
|
}
|
|
2244
2249
|
}
|
|
2245
2250
|
} else {
|
|
@@ -2279,22 +2284,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2279
2284
|
}
|
|
2280
2285
|
}
|
|
2281
2286
|
|
|
2282
|
-
//
|
|
2287
|
+
// 发送文本消息
|
|
2283
2288
|
if (textWithoutImages.trim()) {
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
});
|
|
2296
|
-
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2297
|
-
}
|
|
2289
|
+
await sendWithTokenRetry(async (token) => {
|
|
2290
|
+
const ref = consumeQuoteRef();
|
|
2291
|
+
if (event.type === "c2c") {
|
|
2292
|
+
return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
|
|
2293
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2294
|
+
return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
|
|
2295
|
+
} else if (event.channelId) {
|
|
2296
|
+
return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
|
|
2298
2300
|
}
|
|
2299
2301
|
} catch (err) {
|
|
2300
2302
|
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
@@ -2370,14 +2372,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2370
2372
|
// 发送错误提示给用户,显示完整错误信息
|
|
2371
2373
|
const errMsg = String(err);
|
|
2372
2374
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
2373
|
-
|
|
2375
|
+
log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
|
|
2374
2376
|
} else {
|
|
2375
|
-
|
|
2377
|
+
log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
|
|
2376
2378
|
}
|
|
2377
2379
|
},
|
|
2378
2380
|
},
|
|
2379
2381
|
replyOptions: {
|
|
2380
|
-
disableBlockStreaming:
|
|
2382
|
+
disableBlockStreaming: false,
|
|
2381
2383
|
},
|
|
2382
2384
|
});
|
|
2383
2385
|
|
|
@@ -2390,7 +2392,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2390
2392
|
}
|
|
2391
2393
|
if (!hasResponse) {
|
|
2392
2394
|
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
2393
|
-
await sendErrorMessage(MSG.TIMEOUT_HINT);
|
|
2394
2395
|
}
|
|
2395
2396
|
} finally {
|
|
2396
2397
|
// 清理 tool-only 兜底定时器
|
|
@@ -2407,7 +2408,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2407
2408
|
}
|
|
2408
2409
|
} catch (err) {
|
|
2409
2410
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2410
|
-
await sendErrorMessage(MSG.GENERIC_ERROR);
|
|
2411
2411
|
}
|
|
2412
2412
|
};
|
|
2413
2413
|
|
|
@@ -2515,7 +2515,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2515
2515
|
} // end isFirstReady
|
|
2516
2516
|
} else if (t === "RESUMED") {
|
|
2517
2517
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2518
|
-
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
2519
2518
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2520
2519
|
if (isFirstReadyGlobal) {
|
|
2521
2520
|
isFirstReadyGlobal = false;
|
package/src/outbound.ts
CHANGED
|
@@ -313,7 +313,7 @@ export async function sendPhoto(
|
|
|
313
313
|
|
|
314
314
|
if (isLocal) {
|
|
315
315
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
316
|
-
return { channel: "qqbot", error:
|
|
316
|
+
return { channel: "qqbot", error: "图片不存在或已失效" };
|
|
317
317
|
}
|
|
318
318
|
const sizeCheck = checkFileSize(mediaPath);
|
|
319
319
|
if (!sizeCheck.ok) {
|
|
@@ -464,7 +464,7 @@ async function sendVoiceFromLocal(
|
|
|
464
464
|
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
465
465
|
const fileSize = await waitForFile(mediaPath);
|
|
466
466
|
if (fileSize === 0) {
|
|
467
|
-
return { channel: "qqbot", error:
|
|
467
|
+
return { channel: "qqbot", error: "语音文件未就绪" };
|
|
468
468
|
}
|
|
469
469
|
|
|
470
470
|
// 精细检测:是否需要转码
|
|
@@ -570,7 +570,7 @@ export async function sendVideoMsg(
|
|
|
570
570
|
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
571
571
|
async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
572
572
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
573
|
-
return { channel: "qqbot", error:
|
|
573
|
+
return { channel: "qqbot", error: "视频文件不存在或已失效" };
|
|
574
574
|
}
|
|
575
575
|
const sizeCheck = checkFileSize(mediaPath);
|
|
576
576
|
if (!sizeCheck.ok) {
|
|
@@ -665,7 +665,7 @@ async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string,
|
|
|
665
665
|
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
666
666
|
|
|
667
667
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
668
|
-
return { channel: "qqbot", error:
|
|
668
|
+
return { channel: "qqbot", error: "文件不存在或已失效" };
|
|
669
669
|
}
|
|
670
670
|
const sizeCheck = checkFileSize(mediaPath);
|
|
671
671
|
if (!sizeCheck.ok) {
|
package/src/update-checker.ts
CHANGED
|
@@ -38,6 +38,19 @@ let _lastInfo: UpdateInfo = {
|
|
|
38
38
|
|
|
39
39
|
let _checking = false;
|
|
40
40
|
|
|
41
|
+
/** 已通知过的版本号,避免同一版本重复推送 */
|
|
42
|
+
let _notifiedVersion: string | null = null;
|
|
43
|
+
|
|
44
|
+
type UpdateFoundCallback = (info: UpdateInfo) => void;
|
|
45
|
+
let _onUpdateFound: UpdateFoundCallback | null = null;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
|
|
49
|
+
*/
|
|
50
|
+
export function onUpdateFound(cb: UpdateFoundCallback): void {
|
|
51
|
+
_onUpdateFound = cb;
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export function triggerUpdateCheck(log?: {
|
|
42
55
|
info: (msg: string) => void;
|
|
43
56
|
error: (msg: string) => void;
|
|
@@ -77,6 +90,11 @@ export function triggerUpdateCheck(log?: {
|
|
|
77
90
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
78
91
|
if (hasUpdate) {
|
|
79
92
|
log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
|
|
93
|
+
// 首次发现该版本时触发回调
|
|
94
|
+
if (_onUpdateFound && compareTarget !== _notifiedVersion) {
|
|
95
|
+
_notifiedVersion = compareTarget;
|
|
96
|
+
_onUpdateFound(_lastInfo);
|
|
97
|
+
}
|
|
80
98
|
}
|
|
81
99
|
} catch (parseErr) {
|
|
82
100
|
_lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: String(parseErr) };
|
|
@@ -89,6 +107,11 @@ export function getUpdateInfo(): UpdateInfo {
|
|
|
89
107
|
return { ..._lastInfo };
|
|
90
108
|
}
|
|
91
109
|
|
|
110
|
+
export function formatUpdateNotice(info: UpdateInfo): string {
|
|
111
|
+
if (!info.hasUpdate || !info.latest) return "";
|
|
112
|
+
return `\u{1f195} 有新版本可用: v${info.latest}(当前 v${info.current})\n使用 /qqbot-upgrade 升级`;
|
|
113
|
+
}
|
|
114
|
+
|
|
92
115
|
function compareVersions(a: string, b: string): number {
|
|
93
116
|
const parse = (v: string) => {
|
|
94
117
|
const clean = v.replace(/^v/, "");
|
package/src/user-messages.ts
CHANGED
|
@@ -1,73 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 用户面向的提示文案集中管理
|
|
3
|
-
*
|
|
4
|
-
* 设计原则(参考 Telegram / Discord / Slack / 飞书):
|
|
5
|
-
* 1. 禁止暴露服务器路径、原始 Error、配置文件结构
|
|
6
|
-
* 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
|
|
7
|
-
* 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
|
|
8
|
-
* 4. 所有面向用户的文案集中在此文件,便于维护和国际化
|
|
3
|
+
* 仅保留格式校验类提示,运行时错误走日志不面向用户
|
|
9
4
|
*/
|
|
10
5
|
|
|
11
|
-
// ============ 媒体发送错误 ============
|
|
12
|
-
|
|
13
6
|
export const MSG = {
|
|
14
|
-
//
|
|
15
|
-
GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~",
|
|
16
|
-
AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~",
|
|
17
|
-
AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~",
|
|
18
|
-
TIMEOUT_HINT: "已收到消息,正在处理中…",
|
|
19
|
-
|
|
20
|
-
// 图片
|
|
21
|
-
IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~",
|
|
7
|
+
// 图片格式校验
|
|
22
8
|
IMAGE_FORMAT_UNSUPPORTED: (ext: string) => `抱歉,暂不支持 ${ext} 格式的图片~`,
|
|
23
|
-
IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~",
|
|
24
|
-
IMAGE_UPLOADING: (size: string) => `正在上传图片 (${size})...`,
|
|
25
9
|
|
|
26
|
-
//
|
|
27
|
-
VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~",
|
|
28
|
-
VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~",
|
|
29
|
-
VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~",
|
|
30
|
-
VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~",
|
|
31
|
-
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
10
|
+
// 频道不支持的媒体类型
|
|
32
11
|
VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
|
|
33
|
-
|
|
34
|
-
// 视频
|
|
35
|
-
VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~",
|
|
36
|
-
VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~",
|
|
37
|
-
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
38
12
|
VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
|
|
39
|
-
|
|
13
|
+
FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
|
|
40
14
|
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
15
|
+
// 缺少必要内容
|
|
16
|
+
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
17
|
+
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
44
18
|
FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
|
|
45
|
-
FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
|
|
46
|
-
FILE_UPLOADING: (name: string, size: string) => `正在上传文件 ${name} (${size})...`,
|
|
47
19
|
|
|
48
|
-
//
|
|
20
|
+
// 载荷解析校验
|
|
49
21
|
PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
|
|
50
22
|
UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
|
|
51
23
|
UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
|
|
52
24
|
} as const;
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* 将媒体上传/发送错误转为对用户友好的提示文案
|
|
56
|
-
* 技术细节不暴露给用户,仅记录到日志
|
|
57
|
-
*/
|
|
58
|
-
export function formatMediaErrorMessage(mediaType: string, err: unknown): string {
|
|
59
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
60
|
-
if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
|
|
61
|
-
return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
|
|
62
|
-
}
|
|
63
|
-
if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
|
|
64
|
-
return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
|
|
65
|
-
}
|
|
66
|
-
if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
|
|
67
|
-
return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
|
|
68
|
-
}
|
|
69
|
-
if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
|
|
70
|
-
return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
|
|
71
|
-
}
|
|
72
|
-
return `抱歉,${mediaType}发送失败了,请稍后再试~`;
|
|
73
|
-
}
|