@tencent-connect/openclaw-qqbot 1.6.2-alpha.2 → 1.6.2-alpha.3
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/README.md +2 -2
- package/README.zh.md +2 -2
- package/dist/src/channel.d.ts +8 -0
- package/dist/src/channel.js +11 -29
- package/dist/src/gateway.js +75 -94
- package/dist/src/outbound.js +17 -18
- package/dist/src/ref-index-store.d.ts +1 -1
- package/dist/src/ref-index-store.js +2 -3
- package/dist/src/update-checker.d.ts +0 -7
- package/dist/src/update-checker.js +0 -19
- package/dist/src/user-messages.d.ts +6 -14
- package/dist/src/user-messages.js +6 -18
- package/package.json +1 -1
- package/src/channel.ts +12 -33
- package/src/gateway.ts +69 -85
- package/src/outbound.ts +17 -18
- package/src/ref-index-store.ts +3 -4
- package/src/update-checker.ts +0 -23
- package/src/user-messages.ts +5 -22
|
@@ -24,15 +24,6 @@ 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
|
-
}
|
|
36
27
|
export function triggerUpdateCheck(log) {
|
|
37
28
|
if (_checking)
|
|
38
29
|
return;
|
|
@@ -64,11 +55,6 @@ export function triggerUpdateCheck(log) {
|
|
|
64
55
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
65
56
|
if (hasUpdate) {
|
|
66
57
|
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
|
-
}
|
|
72
58
|
}
|
|
73
59
|
}
|
|
74
60
|
catch (parseErr) {
|
|
@@ -79,11 +65,6 @@ export function triggerUpdateCheck(log) {
|
|
|
79
65
|
export function getUpdateInfo() {
|
|
80
66
|
return { ..._lastInfo };
|
|
81
67
|
}
|
|
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
|
-
}
|
|
87
68
|
function compareVersions(a, b) {
|
|
88
69
|
const parse = (v) => {
|
|
89
70
|
const clean = v.replace(/^v/, "");
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
+
export {};
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
3
|
+
* 用户面向的提示文案 — 已清空
|
|
4
|
+
*
|
|
5
|
+
* 设计原则(对齐飞书插件):
|
|
6
|
+
* QQBot 插件层不生成额外的用户提示信息。
|
|
7
|
+
* 所有运行时错误仅写日志,不面向用户展示。
|
|
4
8
|
*/
|
|
5
|
-
export declare const MSG: {
|
|
6
|
-
readonly IMAGE_FORMAT_UNSUPPORTED: (ext: string) => string;
|
|
7
|
-
readonly VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~";
|
|
8
|
-
readonly VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~";
|
|
9
|
-
readonly FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~";
|
|
10
|
-
readonly VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~";
|
|
11
|
-
readonly VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~";
|
|
12
|
-
readonly FILE_MISSING_PATH: "抱歉,文件消息缺少内容~";
|
|
13
|
-
readonly PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~";
|
|
14
|
-
readonly UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~";
|
|
15
|
-
readonly UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~";
|
|
16
|
-
};
|
|
@@ -1,20 +1,8 @@
|
|
|
1
|
+
export {};
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
3
|
+
* 用户面向的提示文案 — 已清空
|
|
4
|
+
*
|
|
5
|
+
* 设计原则(对齐飞书插件):
|
|
6
|
+
* QQBot 插件层不生成额外的用户提示信息。
|
|
7
|
+
* 所有运行时错误仅写日志,不面向用户展示。
|
|
4
8
|
*/
|
|
5
|
-
export const MSG = {
|
|
6
|
-
// 图片格式校验
|
|
7
|
-
IMAGE_FORMAT_UNSUPPORTED: (ext) => `抱歉,暂不支持 ${ext} 格式的图片~`,
|
|
8
|
-
// 频道不支持的媒体类型
|
|
9
|
-
VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
|
|
10
|
-
VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
|
|
11
|
-
FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
|
|
12
|
-
// 缺少必要内容
|
|
13
|
-
VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
|
|
14
|
-
VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
|
|
15
|
-
FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
|
|
16
|
-
// 载荷解析校验
|
|
17
|
-
PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
|
|
18
|
-
UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
|
|
19
|
-
UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
|
|
20
|
-
};
|
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -13,38 +13,17 @@ 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
|
+
|
|
16
19
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
20
|
+
* Markdown 感知的文本分块函数
|
|
21
|
+
* 委托给 SDK 内置的 channel.text.chunkMarkdownText
|
|
22
|
+
* 支持代码块自动关闭/重开、括号感知等
|
|
19
23
|
*/
|
|
20
|
-
function chunkText(text: string, limit: number): string[] {
|
|
21
|
-
|
|
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;
|
|
24
|
+
export function chunkText(text: string, limit: number): string[] {
|
|
25
|
+
const runtime = getQQBotRuntime();
|
|
26
|
+
return runtime.channel.text.chunkMarkdownText(text, limit);
|
|
48
27
|
}
|
|
49
28
|
|
|
50
29
|
export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
@@ -66,7 +45,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
66
45
|
* blockStreaming: true 表示该 Channel 支持块流式
|
|
67
46
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
68
47
|
*/
|
|
69
|
-
blockStreaming:
|
|
48
|
+
blockStreaming: true,
|
|
70
49
|
},
|
|
71
50
|
reload: { configPrefixes: ["channels.qqbot"] },
|
|
72
51
|
// CLI onboarding wizard
|
|
@@ -230,9 +209,9 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
|
|
|
230
209
|
},
|
|
231
210
|
outbound: {
|
|
232
211
|
deliveryMode: "direct",
|
|
233
|
-
chunker:
|
|
212
|
+
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
234
213
|
chunkerMode: "markdown",
|
|
235
|
-
textChunkLimit:
|
|
214
|
+
textChunkLimit: 5000,
|
|
236
215
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
237
216
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
238
217
|
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
|
|
11
|
+
import { triggerUpdateCheck } 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,8 +16,9 @@ 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
|
-
|
|
19
|
+
|
|
20
20
|
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
|
21
|
+
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* 通用 OpenAI 兼容 STT(语音转文字)
|
|
@@ -221,6 +222,8 @@ function parseFaceTags(text: string): string {
|
|
|
221
222
|
});
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
// formatMediaErrorMessage 已移至 user-messages.ts 集中管理
|
|
226
|
+
|
|
224
227
|
// ============ 内部标记过滤 ============
|
|
225
228
|
|
|
226
229
|
/**
|
|
@@ -408,36 +411,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
408
411
|
}
|
|
409
412
|
}
|
|
410
413
|
|
|
411
|
-
//
|
|
414
|
+
// 后台版本检查(供 /qqbot-version、/qqbot-upgrade 指令被动查询)
|
|
412
415
|
triggerUpdateCheck(log);
|
|
413
416
|
|
|
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
|
-
|
|
441
417
|
// 初始化 API 配置(markdown 支持)
|
|
442
418
|
initApiConfig({
|
|
443
419
|
markdownSupport: account.markdownSupport,
|
|
@@ -468,7 +444,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
468
444
|
attachments.push(attachment);
|
|
469
445
|
}
|
|
470
446
|
setRefIndex(refIdx, {
|
|
471
|
-
content:
|
|
447
|
+
content: meta.text ?? "",
|
|
472
448
|
senderId: account.accountId,
|
|
473
449
|
senderName: account.accountId,
|
|
474
450
|
timestamp: Date.now(),
|
|
@@ -1684,20 +1660,24 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1684
1660
|
|
|
1685
1661
|
for (const item of sendQueue) {
|
|
1686
1662
|
if (item.type === "text") {
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1663
|
+
// 对长文本进行分块发送
|
|
1664
|
+
const textChunks = getQQBotRuntime().channel.text.chunkMarkdownText(item.content, TEXT_CHUNK_LIMIT);
|
|
1665
|
+
for (const chunk of textChunks) {
|
|
1666
|
+
try {
|
|
1667
|
+
await sendWithTokenRetry(async (token) => {
|
|
1668
|
+
const ref = consumeQuoteRef();
|
|
1669
|
+
if (event.type === "c2c") {
|
|
1670
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
1671
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
1672
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
1673
|
+
} else if (event.channelId) {
|
|
1674
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
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
|
+
}
|
|
1701
1681
|
}
|
|
1702
1682
|
} else if (item.type === "image") {
|
|
1703
1683
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
@@ -1763,9 +1743,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1763
1743
|
|
|
1764
1744
|
if (payloadResult.isPayload) {
|
|
1765
1745
|
if (payloadResult.error) {
|
|
1766
|
-
// 载荷解析失败,发送错误提示
|
|
1767
1746
|
log?.error(`[qqbot:${account.accountId}] Payload parse error: ${payloadResult.error}`);
|
|
1768
|
-
await sendErrorMessage(MSG.PAYLOAD_PARSE_ERROR);
|
|
1769
1747
|
return;
|
|
1770
1748
|
}
|
|
1771
1749
|
|
|
@@ -1839,7 +1817,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1839
1817
|
};
|
|
1840
1818
|
const mimeType = mimeTypes[ext];
|
|
1841
1819
|
if (!mimeType) {
|
|
1842
|
-
|
|
1820
|
+
log?.error(`[qqbot:${account.accountId}] Unsupported image format: ${ext}`);
|
|
1843
1821
|
return;
|
|
1844
1822
|
}
|
|
1845
1823
|
imageUrl = `data:${mimeType};base64,${base64Data}`;
|
|
@@ -1884,7 +1862,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1884
1862
|
try {
|
|
1885
1863
|
const ttsText = parsedPayload.caption || parsedPayload.path;
|
|
1886
1864
|
if (!ttsText?.trim()) {
|
|
1887
|
-
|
|
1865
|
+
log?.error(`[qqbot:${account.accountId}] Voice missing text`);
|
|
1888
1866
|
} else {
|
|
1889
1867
|
const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
|
|
1890
1868
|
if (!ttsCfg) {
|
|
@@ -1901,7 +1879,8 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1901
1879
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1902
1880
|
await sendGroupVoiceMessage(token, event.groupOpenid, silkBase64, event.messageId);
|
|
1903
1881
|
} else if (event.channelId) {
|
|
1904
|
-
|
|
1882
|
+
log?.error(`[qqbot:${account.accountId}] Voice not supported in channel, sending text fallback`);
|
|
1883
|
+
await sendChannelMessage(token, event.channelId, ttsText, event.messageId);
|
|
1905
1884
|
}
|
|
1906
1885
|
});
|
|
1907
1886
|
log?.info(`[qqbot:${account.accountId}] Voice message sent`);
|
|
@@ -1915,7 +1894,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1915
1894
|
try {
|
|
1916
1895
|
const videoPath = normalizePath(parsedPayload.path ?? "");
|
|
1917
1896
|
if (!videoPath?.trim()) {
|
|
1918
|
-
|
|
1897
|
+
log?.error(`[qqbot:${account.accountId}] Video missing path`);
|
|
1919
1898
|
} else {
|
|
1920
1899
|
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
|
1921
1900
|
log?.info(`[qqbot:${account.accountId}] Video send: "${videoPath.slice(0, 60)}..."`);
|
|
@@ -1928,7 +1907,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1928
1907
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1929
1908
|
await sendGroupVideoMessage(token, event.groupOpenid, videoPath, undefined, event.messageId);
|
|
1930
1909
|
} else if (event.channelId) {
|
|
1931
|
-
|
|
1910
|
+
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
1932
1911
|
}
|
|
1933
1912
|
} else {
|
|
1934
1913
|
// 本地文件:读取为 Base64
|
|
@@ -1948,7 +1927,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1948
1927
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1949
1928
|
await sendGroupVideoMessage(token, event.groupOpenid, undefined, videoBase64, event.messageId);
|
|
1950
1929
|
} else if (event.channelId) {
|
|
1951
|
-
|
|
1930
|
+
log?.error(`[qqbot:${account.accountId}] Video not supported in channel`);
|
|
1952
1931
|
}
|
|
1953
1932
|
}
|
|
1954
1933
|
});
|
|
@@ -1975,7 +1954,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1975
1954
|
try {
|
|
1976
1955
|
const filePath = normalizePath(parsedPayload.path ?? "");
|
|
1977
1956
|
if (!filePath?.trim()) {
|
|
1978
|
-
|
|
1957
|
+
log?.error(`[qqbot:${account.accountId}] File missing path`);
|
|
1979
1958
|
} else {
|
|
1980
1959
|
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
|
|
1981
1960
|
const fileName = sanitizeFileName(path.basename(filePath));
|
|
@@ -1988,7 +1967,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1988
1967
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
1989
1968
|
await sendGroupFileMessage(token, event.groupOpenid, undefined, filePath, event.messageId, fileName);
|
|
1990
1969
|
} else if (event.channelId) {
|
|
1991
|
-
|
|
1970
|
+
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
1992
1971
|
}
|
|
1993
1972
|
} else {
|
|
1994
1973
|
if (!(await fileExistsAsync(filePath))) {
|
|
@@ -2005,7 +1984,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2005
1984
|
} else if (event.type === "group" && event.groupOpenid) {
|
|
2006
1985
|
await sendGroupFileMessage(token, event.groupOpenid, fileBase64, undefined, event.messageId, fileName);
|
|
2007
1986
|
} else if (event.channelId) {
|
|
2008
|
-
|
|
1987
|
+
log?.error(`[qqbot:${account.accountId}] File not supported in channel`);
|
|
2009
1988
|
}
|
|
2010
1989
|
}
|
|
2011
1990
|
});
|
|
@@ -2016,7 +1995,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2016
1995
|
}
|
|
2017
1996
|
} else {
|
|
2018
1997
|
log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
|
|
2019
|
-
await sendErrorMessage(MSG.UNSUPPORTED_MEDIA_TYPE);
|
|
2020
1998
|
}
|
|
2021
1999
|
|
|
2022
2000
|
// 记录活动并返回
|
|
@@ -2029,7 +2007,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2029
2007
|
} else {
|
|
2030
2008
|
// 未知的载荷类型
|
|
2031
2009
|
log?.error(`[qqbot:${account.accountId}] Unknown payload type: ${(parsedPayload as any).type}`);
|
|
2032
|
-
await sendErrorMessage(MSG.UNSUPPORTED_PAYLOAD_TYPE);
|
|
2033
2010
|
return;
|
|
2034
2011
|
}
|
|
2035
2012
|
}
|
|
@@ -2231,20 +2208,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2231
2208
|
|
|
2232
2209
|
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2233
2210
|
if (textWithoutImages.trim()) {
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2211
|
+
const mdChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2212
|
+
for (const chunk of mdChunks) {
|
|
2213
|
+
try {
|
|
2214
|
+
await sendWithTokenRetry(async (token) => {
|
|
2215
|
+
const ref = consumeQuoteRef();
|
|
2216
|
+
if (event.type === "c2c") {
|
|
2217
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2218
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2219
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2220
|
+
} else if (event.channelId) {
|
|
2221
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
log?.info(`[qqbot:${account.accountId}] Sent markdown chunk (${chunk.length}/${textWithoutImages.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2225
|
+
} catch (err) {
|
|
2226
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2227
|
+
}
|
|
2248
2228
|
}
|
|
2249
2229
|
}
|
|
2250
2230
|
} else {
|
|
@@ -2284,19 +2264,22 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2284
2264
|
}
|
|
2285
2265
|
}
|
|
2286
2266
|
|
|
2287
|
-
//
|
|
2267
|
+
// 发送文本消息(分块)
|
|
2288
2268
|
if (textWithoutImages.trim()) {
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2269
|
+
const plainChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2270
|
+
for (const chunk of plainChunks) {
|
|
2271
|
+
await sendWithTokenRetry(async (token) => {
|
|
2272
|
+
const ref = consumeQuoteRef();
|
|
2273
|
+
if (event.type === "c2c") {
|
|
2274
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2275
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2276
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2277
|
+
} else if (event.channelId) {
|
|
2278
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2282
|
+
}
|
|
2300
2283
|
}
|
|
2301
2284
|
} catch (err) {
|
|
2302
2285
|
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
@@ -2379,7 +2362,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2379
2362
|
},
|
|
2380
2363
|
},
|
|
2381
2364
|
replyOptions: {
|
|
2382
|
-
disableBlockStreaming:
|
|
2365
|
+
disableBlockStreaming: true,
|
|
2383
2366
|
},
|
|
2384
2367
|
});
|
|
2385
2368
|
|
|
@@ -2515,6 +2498,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2515
2498
|
} // end isFirstReady
|
|
2516
2499
|
} else if (t === "RESUMED") {
|
|
2517
2500
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2501
|
+
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
2518
2502
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2519
2503
|
if (isFirstReadyGlobal) {
|
|
2520
2504
|
isFirstReadyGlobal = false;
|
package/src/outbound.ts
CHANGED
|
@@ -26,7 +26,6 @@ import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
|
26
26
|
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
27
27
|
import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, getQQBotDataDir } from "./utils/platform.js";
|
|
28
28
|
import { downloadFile } from "./image-server.js";
|
|
29
|
-
import { MSG } from "./user-messages.js";
|
|
30
29
|
|
|
31
30
|
// ============ 消息回复限流器 ============
|
|
32
31
|
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
@@ -313,7 +312,7 @@ export async function sendPhoto(
|
|
|
313
312
|
|
|
314
313
|
if (isLocal) {
|
|
315
314
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
316
|
-
return { channel: "qqbot", error: "
|
|
315
|
+
return { channel: "qqbot", error: "Image not found" };
|
|
317
316
|
}
|
|
318
317
|
const sizeCheck = checkFileSize(mediaPath);
|
|
319
318
|
if (!sizeCheck.ok) {
|
|
@@ -327,7 +326,7 @@ export async function sendPhoto(
|
|
|
327
326
|
};
|
|
328
327
|
const mimeType = mimeTypes[ext];
|
|
329
328
|
if (!mimeType) {
|
|
330
|
-
return { channel: "qqbot", error:
|
|
329
|
+
return { channel: "qqbot", error: `Unsupported image format: ${ext}` };
|
|
331
330
|
}
|
|
332
331
|
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
333
332
|
console.log(`${prefix} sendPhoto: local → Base64 (${formatFileSize(fileBuffer.length)})`);
|
|
@@ -430,8 +429,8 @@ export async function sendVoice(
|
|
|
430
429
|
const r = await sendGroupVoiceMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId);
|
|
431
430
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
432
431
|
} else {
|
|
433
|
-
|
|
434
|
-
return { channel: "qqbot",
|
|
432
|
+
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
433
|
+
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
435
434
|
}
|
|
436
435
|
} catch (err) {
|
|
437
436
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -464,7 +463,7 @@ async function sendVoiceFromLocal(
|
|
|
464
463
|
// 等待文件就绪(TTS 异步生成,文件可能还没写完)
|
|
465
464
|
const fileSize = await waitForFile(mediaPath);
|
|
466
465
|
if (fileSize === 0) {
|
|
467
|
-
return { channel: "qqbot", error: "
|
|
466
|
+
return { channel: "qqbot", error: "Voice generate failed" };
|
|
468
467
|
}
|
|
469
468
|
|
|
470
469
|
// 精细检测:是否需要转码
|
|
@@ -498,8 +497,8 @@ async function sendVoiceFromLocal(
|
|
|
498
497
|
const r = await sendGroupVoiceMessage(token, ctx.targetId, uploadBase64, undefined, ctx.replyToId);
|
|
499
498
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
500
499
|
} else {
|
|
501
|
-
|
|
502
|
-
return { channel: "qqbot",
|
|
500
|
+
console.log(`${prefix} sendVoice: voice not supported in channel`);
|
|
501
|
+
return { channel: "qqbot", error: "Voice not supported in channel" };
|
|
503
502
|
}
|
|
504
503
|
} catch (err) {
|
|
505
504
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -543,8 +542,8 @@ export async function sendVideoMsg(
|
|
|
543
542
|
const r = await sendGroupVideoMessage(token, ctx.targetId, mediaPath, undefined, ctx.replyToId);
|
|
544
543
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
545
544
|
} else {
|
|
546
|
-
|
|
547
|
-
return { channel: "qqbot",
|
|
545
|
+
console.log(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
546
|
+
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
548
547
|
}
|
|
549
548
|
}
|
|
550
549
|
|
|
@@ -570,7 +569,7 @@ export async function sendVideoMsg(
|
|
|
570
569
|
/** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
|
|
571
570
|
async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
|
|
572
571
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
573
|
-
return { channel: "qqbot", error: "
|
|
572
|
+
return { channel: "qqbot", error: "Video not found" };
|
|
574
573
|
}
|
|
575
574
|
const sizeCheck = checkFileSize(mediaPath);
|
|
576
575
|
if (!sizeCheck.ok) {
|
|
@@ -590,8 +589,8 @@ async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, pr
|
|
|
590
589
|
const r = await sendGroupVideoMessage(token, ctx.targetId, undefined, videoBase64, ctx.replyToId);
|
|
591
590
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
592
591
|
} else {
|
|
593
|
-
|
|
594
|
-
return { channel: "qqbot",
|
|
592
|
+
console.log(`${prefix} sendVideoMsg: video not supported in channel`);
|
|
593
|
+
return { channel: "qqbot", error: "Video not supported in channel" };
|
|
595
594
|
}
|
|
596
595
|
} catch (err) {
|
|
597
596
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -636,8 +635,8 @@ export async function sendDocument(
|
|
|
636
635
|
const r = await sendGroupFileMessage(token, ctx.targetId, undefined, mediaPath, ctx.replyToId, fileName);
|
|
637
636
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
638
637
|
} else {
|
|
639
|
-
|
|
640
|
-
return { channel: "qqbot",
|
|
638
|
+
console.log(`${prefix} sendDocument: file not supported in channel`);
|
|
639
|
+
return { channel: "qqbot", error: "File not supported in channel" };
|
|
641
640
|
}
|
|
642
641
|
}
|
|
643
642
|
|
|
@@ -665,7 +664,7 @@ async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string,
|
|
|
665
664
|
const fileName = sanitizeFileName(path.basename(mediaPath));
|
|
666
665
|
|
|
667
666
|
if (!(await fileExistsAsync(mediaPath))) {
|
|
668
|
-
return { channel: "qqbot", error: "
|
|
667
|
+
return { channel: "qqbot", error: "File not found" };
|
|
669
668
|
}
|
|
670
669
|
const sizeCheck = checkFileSize(mediaPath);
|
|
671
670
|
if (!sizeCheck.ok) {
|
|
@@ -687,8 +686,8 @@ async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string,
|
|
|
687
686
|
const r = await sendGroupFileMessage(token, ctx.targetId, fileBase64, undefined, ctx.replyToId, fileName);
|
|
688
687
|
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
689
688
|
} else {
|
|
690
|
-
|
|
691
|
-
return { channel: "qqbot",
|
|
689
|
+
console.log(`${prefix} sendDocument: file not supported in channel`);
|
|
690
|
+
return { channel: "qqbot", error: "File not supported in channel" };
|
|
692
691
|
}
|
|
693
692
|
} catch (err) {
|
|
694
693
|
const msg = err instanceof Error ? err.message : String(err);
|
package/src/ref-index-store.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { getQQBotDataDir } from "./utils/platform.js";
|
|
|
20
20
|
// ============ 存储的消息摘要 ============
|
|
21
21
|
|
|
22
22
|
export interface RefIndexEntry {
|
|
23
|
-
/**
|
|
23
|
+
/** 消息文本内容(完整保存) */
|
|
24
24
|
content: string;
|
|
25
25
|
/** 发送者 ID */
|
|
26
26
|
senderId: string;
|
|
@@ -56,7 +56,6 @@ export interface RefAttachmentSummary {
|
|
|
56
56
|
|
|
57
57
|
const STORAGE_DIR = getQQBotDataDir("data");
|
|
58
58
|
const REF_INDEX_FILE = path.join(STORAGE_DIR, "ref-index.jsonl");
|
|
59
|
-
const MAX_CONTENT_LENGTH = 500; // 存储的消息内容最大字符数
|
|
60
59
|
const MAX_ENTRIES = 50000; // 内存中最大缓存条目数
|
|
61
60
|
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天
|
|
62
61
|
const COMPACT_THRESHOLD_RATIO = 2; // 文件行数超过有效条目 N 倍时 compact
|
|
@@ -234,7 +233,7 @@ export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
|
|
|
234
233
|
|
|
235
234
|
const now = Date.now();
|
|
236
235
|
store.set(refIdx, {
|
|
237
|
-
content: entry.content
|
|
236
|
+
content: entry.content,
|
|
238
237
|
senderId: entry.senderId,
|
|
239
238
|
senderName: entry.senderName,
|
|
240
239
|
timestamp: entry.timestamp,
|
|
@@ -247,7 +246,7 @@ export function setRefIndex(refIdx: string, entry: RefIndexEntry): void {
|
|
|
247
246
|
appendLine({
|
|
248
247
|
k: refIdx,
|
|
249
248
|
v: {
|
|
250
|
-
content: entry.content
|
|
249
|
+
content: entry.content,
|
|
251
250
|
senderId: entry.senderId,
|
|
252
251
|
senderName: entry.senderName,
|
|
253
252
|
timestamp: entry.timestamp,
|