@tencent-connect/openclaw-qqbot 1.6.2-alpha.0 → 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.js +1 -1
- package/dist/src/gateway.js +35 -22
- 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 +1 -1
- package/src/gateway.ts +34 -23
- package/src/outbound.ts +4 -4
- package/src/update-checker.ts +23 -0
- package/src/user-messages.ts +8 -57
package/dist/src/channel.js
CHANGED
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,7 +15,7 @@ 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
20
|
function resolveSTTConfig(cfg) {
|
|
21
21
|
const c = cfg;
|
|
@@ -172,7 +172,6 @@ function parseFaceTags(text) {
|
|
|
172
172
|
}
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
|
-
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
176
175
|
// ============ 内部标记过滤 ============
|
|
177
176
|
/**
|
|
178
177
|
* 过滤内部标记(如 [[reply_to: xxx]])
|
|
@@ -315,8 +314,36 @@ export async function startGateway(ctx) {
|
|
|
315
314
|
log?.info(`[qqbot:${account.accountId}] ${w}`);
|
|
316
315
|
}
|
|
317
316
|
}
|
|
318
|
-
//
|
|
317
|
+
// 后台版本检查(detached 子进程,零阻塞)
|
|
319
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
|
+
});
|
|
320
347
|
// 初始化 API 配置(markdown 支持)
|
|
321
348
|
initApiConfig({
|
|
322
349
|
markdownSupport: account.markdownSupport,
|
|
@@ -1502,7 +1529,6 @@ export async function startGateway(ctx) {
|
|
|
1502
1529
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
1503
1530
|
if (result.error) {
|
|
1504
1531
|
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1505
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
|
|
1506
1532
|
}
|
|
1507
1533
|
}
|
|
1508
1534
|
else if (item.type === "voice") {
|
|
@@ -1517,26 +1543,22 @@ export async function startGateway(ctx) {
|
|
|
1517
1543
|
]);
|
|
1518
1544
|
if (result.error) {
|
|
1519
1545
|
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1520
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
|
|
1521
1546
|
}
|
|
1522
1547
|
}
|
|
1523
1548
|
catch (err) {
|
|
1524
1549
|
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1525
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1526
1550
|
}
|
|
1527
1551
|
}
|
|
1528
1552
|
else if (item.type === "video") {
|
|
1529
1553
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1530
1554
|
if (result.error) {
|
|
1531
1555
|
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1532
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
|
|
1533
1556
|
}
|
|
1534
1557
|
}
|
|
1535
1558
|
else if (item.type === "file") {
|
|
1536
1559
|
const result = await sendDocument(mediaTarget, item.content);
|
|
1537
1560
|
if (result.error) {
|
|
1538
1561
|
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1539
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
|
|
1540
1562
|
}
|
|
1541
1563
|
}
|
|
1542
1564
|
else if (item.type === "media") {
|
|
@@ -1551,7 +1573,6 @@ export async function startGateway(ctx) {
|
|
|
1551
1573
|
});
|
|
1552
1574
|
if (result.error) {
|
|
1553
1575
|
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1554
|
-
await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
|
|
1555
1576
|
}
|
|
1556
1577
|
}
|
|
1557
1578
|
}
|
|
@@ -1620,12 +1641,12 @@ export async function startGateway(ctx) {
|
|
|
1620
1641
|
if (parsedPayload.source === "file") {
|
|
1621
1642
|
try {
|
|
1622
1643
|
if (!(await fileExistsAsync(imageUrl))) {
|
|
1623
|
-
|
|
1644
|
+
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
1624
1645
|
return;
|
|
1625
1646
|
}
|
|
1626
1647
|
const imgSzCheck = checkFileSize(imageUrl);
|
|
1627
1648
|
if (!imgSzCheck.ok) {
|
|
1628
|
-
|
|
1649
|
+
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
1629
1650
|
return;
|
|
1630
1651
|
}
|
|
1631
1652
|
const fileBuffer = await readFileAsync(imageUrl);
|
|
@@ -1649,7 +1670,6 @@ export async function startGateway(ctx) {
|
|
|
1649
1670
|
}
|
|
1650
1671
|
catch (readErr) {
|
|
1651
1672
|
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1652
|
-
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1653
1673
|
return;
|
|
1654
1674
|
}
|
|
1655
1675
|
}
|
|
@@ -1685,7 +1705,6 @@ export async function startGateway(ctx) {
|
|
|
1685
1705
|
}
|
|
1686
1706
|
catch (err) {
|
|
1687
1707
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
|
|
1688
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", err));
|
|
1689
1708
|
}
|
|
1690
1709
|
}
|
|
1691
1710
|
else if (parsedPayload.mediaType === "audio") {
|
|
@@ -1699,7 +1718,6 @@ export async function startGateway(ctx) {
|
|
|
1699
1718
|
const ttsCfg = resolveTTSConfig(cfg);
|
|
1700
1719
|
if (!ttsCfg) {
|
|
1701
1720
|
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1702
|
-
await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
|
|
1703
1721
|
}
|
|
1704
1722
|
else {
|
|
1705
1723
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
@@ -1723,7 +1741,6 @@ export async function startGateway(ctx) {
|
|
|
1723
1741
|
}
|
|
1724
1742
|
catch (err) {
|
|
1725
1743
|
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1726
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1727
1744
|
}
|
|
1728
1745
|
}
|
|
1729
1746
|
else if (parsedPayload.mediaType === "video") {
|
|
@@ -1791,7 +1808,6 @@ export async function startGateway(ctx) {
|
|
|
1791
1808
|
}
|
|
1792
1809
|
catch (err) {
|
|
1793
1810
|
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
|
1794
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", err));
|
|
1795
1811
|
}
|
|
1796
1812
|
}
|
|
1797
1813
|
else if (parsedPayload.mediaType === "file") {
|
|
@@ -1843,7 +1859,6 @@ export async function startGateway(ctx) {
|
|
|
1843
1859
|
}
|
|
1844
1860
|
catch (err) {
|
|
1845
1861
|
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
|
1846
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", err));
|
|
1847
1862
|
}
|
|
1848
1863
|
}
|
|
1849
1864
|
else {
|
|
@@ -2196,10 +2211,10 @@ export async function startGateway(ctx) {
|
|
|
2196
2211
|
// 发送错误提示给用户,显示完整错误信息
|
|
2197
2212
|
const errMsg = String(err);
|
|
2198
2213
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
2199
|
-
|
|
2214
|
+
log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
|
|
2200
2215
|
}
|
|
2201
2216
|
else {
|
|
2202
|
-
|
|
2217
|
+
log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
|
|
2203
2218
|
}
|
|
2204
2219
|
},
|
|
2205
2220
|
},
|
|
@@ -2217,7 +2232,6 @@ export async function startGateway(ctx) {
|
|
|
2217
2232
|
}
|
|
2218
2233
|
if (!hasResponse) {
|
|
2219
2234
|
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
2220
|
-
await sendErrorMessage(MSG.TIMEOUT_HINT);
|
|
2221
2235
|
}
|
|
2222
2236
|
}
|
|
2223
2237
|
finally {
|
|
@@ -2236,7 +2250,6 @@ export async function startGateway(ctx) {
|
|
|
2236
2250
|
}
|
|
2237
2251
|
catch (err) {
|
|
2238
2252
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2239
|
-
await sendErrorMessage(MSG.GENERIC_ERROR);
|
|
2240
2253
|
}
|
|
2241
2254
|
};
|
|
2242
2255
|
ws.on("open", () => {
|
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
|
@@ -66,7 +66,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
66
66
|
* blockStreaming: true 表示该 Channel 支持块流式
|
|
67
67
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
68
68
|
*/
|
|
69
|
-
blockStreaming:
|
|
69
|
+
blockStreaming: false,
|
|
70
70
|
},
|
|
71
71
|
reload: { configPrefixes: ["channels.qqbot"] },
|
|
72
72
|
// CLI onboarding wizard
|
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,7 +16,7 @@ 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
21
|
|
|
22
22
|
/**
|
|
@@ -221,8 +221,6 @@ function parseFaceTags(text: string): string {
|
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
-
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
225
|
-
|
|
226
224
|
// ============ 内部标记过滤 ============
|
|
227
225
|
|
|
228
226
|
/**
|
|
@@ -410,9 +408,36 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
410
408
|
}
|
|
411
409
|
}
|
|
412
410
|
|
|
413
|
-
//
|
|
411
|
+
// 后台版本检查(detached 子进程,零阻塞)
|
|
414
412
|
triggerUpdateCheck(log);
|
|
415
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
|
+
|
|
416
441
|
// 初始化 API 配置(markdown 支持)
|
|
417
442
|
initApiConfig({
|
|
418
443
|
markdownSupport: account.markdownSupport,
|
|
@@ -1678,7 +1703,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1678
1703
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
1679
1704
|
if (result.error) {
|
|
1680
1705
|
log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
|
|
1681
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
|
|
1682
1706
|
}
|
|
1683
1707
|
} else if (item.type === "voice") {
|
|
1684
1708
|
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
@@ -1694,23 +1718,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1694
1718
|
]);
|
|
1695
1719
|
if (result.error) {
|
|
1696
1720
|
log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
|
|
1697
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
|
|
1698
1721
|
}
|
|
1699
1722
|
} catch (err) {
|
|
1700
1723
|
log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
|
|
1701
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1702
1724
|
}
|
|
1703
1725
|
} else if (item.type === "video") {
|
|
1704
1726
|
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
1705
1727
|
if (result.error) {
|
|
1706
1728
|
log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
|
|
1707
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
|
|
1708
1729
|
}
|
|
1709
1730
|
} else if (item.type === "file") {
|
|
1710
1731
|
const result = await sendDocument(mediaTarget, item.content);
|
|
1711
1732
|
if (result.error) {
|
|
1712
1733
|
log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
|
|
1713
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
|
|
1714
1734
|
}
|
|
1715
1735
|
} else if (item.type === "media") {
|
|
1716
1736
|
// qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
|
|
@@ -1724,7 +1744,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1724
1744
|
});
|
|
1725
1745
|
if (result.error) {
|
|
1726
1746
|
log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
|
|
1727
|
-
await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
|
|
1728
1747
|
}
|
|
1729
1748
|
}
|
|
1730
1749
|
}
|
|
@@ -1799,12 +1818,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1799
1818
|
if (parsedPayload.source === "file") {
|
|
1800
1819
|
try {
|
|
1801
1820
|
if (!(await fileExistsAsync(imageUrl))) {
|
|
1802
|
-
|
|
1821
|
+
log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
|
|
1803
1822
|
return;
|
|
1804
1823
|
}
|
|
1805
1824
|
const imgSzCheck = checkFileSize(imageUrl);
|
|
1806
1825
|
if (!imgSzCheck.ok) {
|
|
1807
|
-
|
|
1826
|
+
log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
|
|
1808
1827
|
return;
|
|
1809
1828
|
}
|
|
1810
1829
|
const fileBuffer = await readFileAsync(imageUrl);
|
|
@@ -1827,7 +1846,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1827
1846
|
log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
1828
1847
|
} catch (readErr) {
|
|
1829
1848
|
log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
|
|
1830
|
-
await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
|
|
1831
1849
|
return;
|
|
1832
1850
|
}
|
|
1833
1851
|
}
|
|
@@ -1860,7 +1878,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1860
1878
|
}
|
|
1861
1879
|
} catch (err) {
|
|
1862
1880
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
|
|
1863
|
-
await sendErrorMessage(formatMediaErrorMessage("图片", err));
|
|
1864
1881
|
}
|
|
1865
1882
|
} else if (parsedPayload.mediaType === "audio") {
|
|
1866
1883
|
// TTS 语音发送:文字 → PCM → SILK → QQ 语音
|
|
@@ -1872,7 +1889,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1872
1889
|
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
1873
1890
|
if (!ttsCfg) {
|
|
1874
1891
|
log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
|
|
1875
|
-
await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
|
|
1876
1892
|
} else {
|
|
1877
1893
|
log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
|
|
1878
1894
|
const ttsDir = getQQBotDataDir("tts");
|
|
@@ -1893,7 +1909,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1893
1909
|
}
|
|
1894
1910
|
} catch (err) {
|
|
1895
1911
|
log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
|
|
1896
|
-
await sendErrorMessage(formatMediaErrorMessage("语音", err));
|
|
1897
1912
|
}
|
|
1898
1913
|
} else if (parsedPayload.mediaType === "video") {
|
|
1899
1914
|
// 视频发送:支持公网 URL 和本地文件
|
|
@@ -1954,7 +1969,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1954
1969
|
}
|
|
1955
1970
|
} catch (err) {
|
|
1956
1971
|
log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
|
|
1957
|
-
await sendErrorMessage(formatMediaErrorMessage("视频", err));
|
|
1958
1972
|
}
|
|
1959
1973
|
} else if (parsedPayload.mediaType === "file") {
|
|
1960
1974
|
// 文件发送
|
|
@@ -1999,7 +2013,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1999
2013
|
}
|
|
2000
2014
|
} catch (err) {
|
|
2001
2015
|
log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
|
|
2002
|
-
await sendErrorMessage(formatMediaErrorMessage("文件", err));
|
|
2003
2016
|
}
|
|
2004
2017
|
} else {
|
|
2005
2018
|
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
|
|
@@ -2359,9 +2372,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2359
2372
|
// 发送错误提示给用户,显示完整错误信息
|
|
2360
2373
|
const errMsg = String(err);
|
|
2361
2374
|
if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
|
|
2362
|
-
|
|
2375
|
+
log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
|
|
2363
2376
|
} else {
|
|
2364
|
-
|
|
2377
|
+
log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
|
|
2365
2378
|
}
|
|
2366
2379
|
},
|
|
2367
2380
|
},
|
|
@@ -2379,7 +2392,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2379
2392
|
}
|
|
2380
2393
|
if (!hasResponse) {
|
|
2381
2394
|
log?.error(`[qqbot:${account.accountId}] No response within timeout`);
|
|
2382
|
-
await sendErrorMessage(MSG.TIMEOUT_HINT);
|
|
2383
2395
|
}
|
|
2384
2396
|
} finally {
|
|
2385
2397
|
// 清理 tool-only 兜底定时器
|
|
@@ -2396,7 +2408,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2396
2408
|
}
|
|
2397
2409
|
} catch (err) {
|
|
2398
2410
|
log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
|
|
2399
|
-
await sendErrorMessage(MSG.GENERIC_ERROR);
|
|
2400
2411
|
}
|
|
2401
2412
|
};
|
|
2402
2413
|
|
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
|
-
}
|