@tencent-connect/openclaw-qqbot 1.6.1 → 1.6.2-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +63 -79
- package/dist/src/update-checker.d.ts +0 -7
- package/dist/src/update-checker.js +0 -19
- package/package.json +1 -1
- package/src/channel.ts +12 -33
- package/src/gateway.ts +55 -70
- package/src/update-checker.ts +0 -23
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
**Connect your AI assistant to QQ — private chat, group chat, and rich media, all in one plugin.**
|
|
12
12
|
|
|
13
|
-
### 🚀 Current Version: `v1.6.
|
|
13
|
+
### 🚀 Current Version: `v1.6.1`
|
|
14
14
|
|
|
15
15
|
[](./LICENSE)
|
|
16
16
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -163,7 +163,7 @@ Measures end-to-end latency from QQ server push to plugin response, broken down
|
|
|
163
163
|
|
|
164
164
|
> **You**: `/qqbot-version`
|
|
165
165
|
>
|
|
166
|
-
> **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.
|
|
166
|
+
> **QQBot**: 🦞 Framework: OpenClaw 2026.3.13 (61d171a) / 🤖 Plugin: v1.6.1 / 🌟 GitHub repo
|
|
167
167
|
|
|
168
168
|
Shows framework version, plugin version, and a direct link to the official repository.
|
|
169
169
|
|
package/README.zh.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
**让你的 AI 助手接入 QQ — 私聊、群聊、富媒体,一个插件全搞定。**
|
|
11
11
|
|
|
12
|
-
### 🚀 当前版本: `v1.6.
|
|
12
|
+
### 🚀 当前版本: `v1.6.1`
|
|
13
13
|
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://bot.q.qq.com/wiki/)
|
|
@@ -158,7 +158,7 @@ AI 可直接发送视频,支持本地文件和公网 URL。
|
|
|
158
158
|
|
|
159
159
|
> **你**:`/qqbot-version`
|
|
160
160
|
>
|
|
161
|
-
> **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.
|
|
161
|
+
> **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.1 / 🌟官方 GitHub 仓库
|
|
162
162
|
|
|
163
163
|
一目了然查看框架版本、插件版本,并可直接跳转官方仓库。
|
|
164
164
|
|
package/dist/src/channel.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
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[];
|
|
3
11
|
export declare const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount>;
|
package/dist/src/channel.js
CHANGED
|
@@ -4,34 +4,16 @@ 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;
|
|
7
9
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
+
* Markdown 感知的文本分块函数
|
|
11
|
+
* 委托给 SDK 内置的 channel.text.chunkMarkdownText
|
|
12
|
+
* 支持代码块自动关闭/重开、括号感知等
|
|
10
13
|
*/
|
|
11
|
-
function chunkText(text, limit) {
|
|
12
|
-
|
|
13
|
-
|
|
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;
|
|
14
|
+
export function chunkText(text, limit) {
|
|
15
|
+
const runtime = getQQBotRuntime();
|
|
16
|
+
return runtime.channel.text.chunkMarkdownText(text, limit);
|
|
35
17
|
}
|
|
36
18
|
export const qqbotPlugin = {
|
|
37
19
|
id: "qqbot",
|
|
@@ -52,7 +34,7 @@ export const qqbotPlugin = {
|
|
|
52
34
|
* blockStreaming: true 表示该 Channel 支持块流式
|
|
53
35
|
* 框架会收集流式响应,然后通过 deliver 回调发送
|
|
54
36
|
*/
|
|
55
|
-
blockStreaming:
|
|
37
|
+
blockStreaming: true,
|
|
56
38
|
},
|
|
57
39
|
reload: { configPrefixes: ["channels.qqbot"] },
|
|
58
40
|
// CLI onboarding wizard
|
|
@@ -205,9 +187,9 @@ export const qqbotPlugin = {
|
|
|
205
187
|
},
|
|
206
188
|
outbound: {
|
|
207
189
|
deliveryMode: "direct",
|
|
208
|
-
chunker:
|
|
190
|
+
chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
|
|
209
191
|
chunkerMode: "markdown",
|
|
210
|
-
textChunkLimit:
|
|
192
|
+
textChunkLimit: 5000,
|
|
211
193
|
sendText: async ({ to, text, accountId, replyToId, cfg }) => {
|
|
212
194
|
console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
|
|
213
195
|
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
|
|
10
|
+
import { triggerUpdateCheck } 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";
|
|
@@ -17,6 +17,7 @@ import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from ".
|
|
|
17
17
|
import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
18
18
|
import { MSG, formatMediaErrorMessage } 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";
|
|
20
21
|
function resolveSTTConfig(cfg) {
|
|
21
22
|
const c = cfg;
|
|
22
23
|
// 优先使用 channels.qqbot.stt(插件专属配置)
|
|
@@ -315,36 +316,8 @@ export async function startGateway(ctx) {
|
|
|
315
316
|
log?.info(`[qqbot:${account.accountId}] ${w}`);
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
|
-
//
|
|
319
|
+
// 后台版本检查(供 /qqbot-version、/qqbot-upgrade 指令被动查询)
|
|
319
320
|
triggerUpdateCheck(log);
|
|
320
|
-
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
321
|
-
let lastUpdateNotifyAt = 0;
|
|
322
|
-
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
323
|
-
onUpdateFound(async (info) => {
|
|
324
|
-
try {
|
|
325
|
-
// 防抖:避免短时间内重复推送
|
|
326
|
-
const now = Date.now();
|
|
327
|
-
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
328
|
-
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
const notice = formatUpdateNotice(info);
|
|
332
|
-
if (!notice)
|
|
333
|
-
return;
|
|
334
|
-
const adminId = resolveAdminOpenId();
|
|
335
|
-
if (!adminId) {
|
|
336
|
-
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
340
|
-
await sendProactiveC2CMessage(token, adminId, notice);
|
|
341
|
-
lastUpdateNotifyAt = Date.now();
|
|
342
|
-
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
343
|
-
}
|
|
344
|
-
catch (err) {
|
|
345
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
346
|
-
}
|
|
347
|
-
});
|
|
348
321
|
// 初始化 API 配置(markdown 支持)
|
|
349
322
|
initApiConfig({
|
|
350
323
|
markdownSupport: account.markdownSupport,
|
|
@@ -1507,23 +1480,27 @@ export async function startGateway(ctx) {
|
|
|
1507
1480
|
};
|
|
1508
1481
|
for (const item of sendQueue) {
|
|
1509
1482
|
if (item.type === "text") {
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1483
|
+
// 对长文本进行分块发送
|
|
1484
|
+
const textChunks = getQQBotRuntime().channel.text.chunkMarkdownText(item.content, TEXT_CHUNK_LIMIT);
|
|
1485
|
+
for (const chunk of textChunks) {
|
|
1486
|
+
try {
|
|
1487
|
+
await sendWithTokenRetry(async (token) => {
|
|
1488
|
+
const ref = consumeQuoteRef();
|
|
1489
|
+
if (event.type === "c2c") {
|
|
1490
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
1491
|
+
}
|
|
1492
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
1493
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
1494
|
+
}
|
|
1495
|
+
else if (event.channelId) {
|
|
1496
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
1497
|
+
}
|
|
1498
|
+
});
|
|
1499
|
+
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${item.content.length} chars): ${chunk.slice(0, 50)}...`);
|
|
1500
|
+
}
|
|
1501
|
+
catch (err) {
|
|
1502
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send text chunk: ${err}`);
|
|
1503
|
+
}
|
|
1527
1504
|
}
|
|
1528
1505
|
}
|
|
1529
1506
|
else if (item.type === "image") {
|
|
@@ -2077,23 +2054,26 @@ export async function startGateway(ctx) {
|
|
|
2077
2054
|
}
|
|
2078
2055
|
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2079
2056
|
if (textWithoutImages.trim()) {
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2057
|
+
const mdChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2058
|
+
for (const chunk of mdChunks) {
|
|
2059
|
+
try {
|
|
2060
|
+
await sendWithTokenRetry(async (token) => {
|
|
2061
|
+
const ref = consumeQuoteRef();
|
|
2062
|
+
if (event.type === "c2c") {
|
|
2063
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2064
|
+
}
|
|
2065
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
2066
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2067
|
+
}
|
|
2068
|
+
else if (event.channelId) {
|
|
2069
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2070
|
+
}
|
|
2071
|
+
});
|
|
2072
|
+
log?.info(`[qqbot:${account.accountId}] Sent markdown chunk (${chunk.length}/${textWithoutImages.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2073
|
+
}
|
|
2074
|
+
catch (err) {
|
|
2075
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2076
|
+
}
|
|
2097
2077
|
}
|
|
2098
2078
|
}
|
|
2099
2079
|
}
|
|
@@ -2133,21 +2113,24 @@ export async function startGateway(ctx) {
|
|
|
2133
2113
|
log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
|
|
2134
2114
|
}
|
|
2135
2115
|
}
|
|
2136
|
-
//
|
|
2116
|
+
// 发送文本消息(分块)
|
|
2137
2117
|
if (textWithoutImages.trim()) {
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2118
|
+
const plainChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2119
|
+
for (const chunk of plainChunks) {
|
|
2120
|
+
await sendWithTokenRetry(async (token) => {
|
|
2121
|
+
const ref = consumeQuoteRef();
|
|
2122
|
+
if (event.type === "c2c") {
|
|
2123
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2124
|
+
}
|
|
2125
|
+
else if (event.type === "group" && event.groupOpenid) {
|
|
2126
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2127
|
+
}
|
|
2128
|
+
else if (event.channelId) {
|
|
2129
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2133
|
+
}
|
|
2151
2134
|
}
|
|
2152
2135
|
}
|
|
2153
2136
|
catch (err) {
|
|
@@ -2232,7 +2215,7 @@ export async function startGateway(ctx) {
|
|
|
2232
2215
|
},
|
|
2233
2216
|
},
|
|
2234
2217
|
replyOptions: {
|
|
2235
|
-
disableBlockStreaming:
|
|
2218
|
+
disableBlockStreaming: true,
|
|
2236
2219
|
},
|
|
2237
2220
|
});
|
|
2238
2221
|
// 等待分发完成或超时
|
|
@@ -2367,6 +2350,7 @@ export async function startGateway(ctx) {
|
|
|
2367
2350
|
}
|
|
2368
2351
|
else if (t === "RESUMED") {
|
|
2369
2352
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2353
|
+
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
2370
2354
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2371
2355
|
if (isFirstReadyGlobal) {
|
|
2372
2356
|
isFirstReadyGlobal = false;
|
|
@@ -12,16 +12,9 @@ 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;
|
|
20
15
|
export declare function triggerUpdateCheck(log?: {
|
|
21
16
|
info: (msg: string) => void;
|
|
22
17
|
error: (msg: string) => void;
|
|
23
18
|
debug?: (msg: string) => void;
|
|
24
19
|
}): void;
|
|
25
20
|
export declare function getUpdateInfo(): UpdateInfo;
|
|
26
|
-
export declare function formatUpdateNotice(info: UpdateInfo): string;
|
|
27
|
-
export {};
|
|
@@ -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/, "");
|
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";
|
|
@@ -18,6 +18,7 @@ import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileS
|
|
|
18
18
|
import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
|
|
19
19
|
import { MSG, formatMediaErrorMessage } 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";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* 通用 OpenAI 兼容 STT(语音转文字)
|
|
@@ -410,36 +411,9 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
413
|
|
|
413
|
-
//
|
|
414
|
+
// 后台版本检查(供 /qqbot-version、/qqbot-upgrade 指令被动查询)
|
|
414
415
|
triggerUpdateCheck(log);
|
|
415
416
|
|
|
416
|
-
// 注册新版本通知回调:仅发给管理员,带防抖
|
|
417
|
-
let lastUpdateNotifyAt = 0;
|
|
418
|
-
const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
|
|
419
|
-
onUpdateFound(async (info) => {
|
|
420
|
-
try {
|
|
421
|
-
// 防抖:避免短时间内重复推送
|
|
422
|
-
const now = Date.now();
|
|
423
|
-
if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
|
|
424
|
-
log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
const notice = formatUpdateNotice(info);
|
|
428
|
-
if (!notice) return;
|
|
429
|
-
const adminId = resolveAdminOpenId();
|
|
430
|
-
if (!adminId) {
|
|
431
|
-
log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
const token = await getAccessToken(account.appId, account.clientSecret);
|
|
435
|
-
await sendProactiveC2CMessage(token, adminId, notice);
|
|
436
|
-
lastUpdateNotifyAt = Date.now();
|
|
437
|
-
log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
|
|
438
|
-
} catch (err) {
|
|
439
|
-
log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
|
|
443
417
|
// 初始化 API 配置(markdown 支持)
|
|
444
418
|
initApiConfig({
|
|
445
419
|
markdownSupport: account.markdownSupport,
|
|
@@ -1686,20 +1660,24 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
1686
1660
|
|
|
1687
1661
|
for (const item of sendQueue) {
|
|
1688
1662
|
if (item.type === "text") {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
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
|
+
}
|
|
1703
1681
|
}
|
|
1704
1682
|
} else if (item.type === "image") {
|
|
1705
1683
|
const result = await sendPhoto(mediaTarget, item.content);
|
|
@@ -2245,20 +2223,23 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2245
2223
|
|
|
2246
2224
|
// 🔹 第三步:发送带公网图片的 markdown 消息
|
|
2247
2225
|
if (textWithoutImages.trim()) {
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2226
|
+
const mdChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2227
|
+
for (const chunk of mdChunks) {
|
|
2228
|
+
try {
|
|
2229
|
+
await sendWithTokenRetry(async (token) => {
|
|
2230
|
+
const ref = consumeQuoteRef();
|
|
2231
|
+
if (event.type === "c2c") {
|
|
2232
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2233
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2234
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2235
|
+
} else if (event.channelId) {
|
|
2236
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2237
|
+
}
|
|
2238
|
+
});
|
|
2239
|
+
log?.info(`[qqbot:${account.accountId}] Sent markdown chunk (${chunk.length}/${textWithoutImages.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
2240
|
+
} catch (err) {
|
|
2241
|
+
log?.error(`[qqbot:${account.accountId}] Failed to send markdown message chunk: ${err}`);
|
|
2242
|
+
}
|
|
2262
2243
|
}
|
|
2263
2244
|
}
|
|
2264
2245
|
} else {
|
|
@@ -2298,19 +2279,22 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2298
2279
|
}
|
|
2299
2280
|
}
|
|
2300
2281
|
|
|
2301
|
-
//
|
|
2282
|
+
// 发送文本消息(分块)
|
|
2302
2283
|
if (textWithoutImages.trim()) {
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2284
|
+
const plainChunks = chunkText(textWithoutImages, TEXT_CHUNK_LIMIT);
|
|
2285
|
+
for (const chunk of plainChunks) {
|
|
2286
|
+
await sendWithTokenRetry(async (token) => {
|
|
2287
|
+
const ref = consumeQuoteRef();
|
|
2288
|
+
if (event.type === "c2c") {
|
|
2289
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
2290
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
2291
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
2292
|
+
} else if (event.channelId) {
|
|
2293
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
2294
|
+
}
|
|
2295
|
+
});
|
|
2296
|
+
log?.info(`[qqbot:${account.accountId}] Sent text chunk (${chunk.length}/${textWithoutImages.length} chars) (${event.type})`);
|
|
2297
|
+
}
|
|
2314
2298
|
}
|
|
2315
2299
|
} catch (err) {
|
|
2316
2300
|
log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
|
|
@@ -2393,7 +2377,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2393
2377
|
},
|
|
2394
2378
|
},
|
|
2395
2379
|
replyOptions: {
|
|
2396
|
-
disableBlockStreaming:
|
|
2380
|
+
disableBlockStreaming: true,
|
|
2397
2381
|
},
|
|
2398
2382
|
});
|
|
2399
2383
|
|
|
@@ -2531,6 +2515,7 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
|
|
|
2531
2515
|
} // end isFirstReady
|
|
2532
2516
|
} else if (t === "RESUMED") {
|
|
2533
2517
|
log?.info(`[qqbot:${account.accountId}] Session resumed`);
|
|
2518
|
+
onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
|
|
2534
2519
|
// RESUMED 也属于首次启动(gateway restart 通常走 resume)
|
|
2535
2520
|
if (isFirstReadyGlobal) {
|
|
2536
2521
|
isFirstReadyGlobal = false;
|
package/src/update-checker.ts
CHANGED
|
@@ -38,19 +38,6 @@ 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
|
-
|
|
54
41
|
export function triggerUpdateCheck(log?: {
|
|
55
42
|
info: (msg: string) => void;
|
|
56
43
|
error: (msg: string) => void;
|
|
@@ -90,11 +77,6 @@ export function triggerUpdateCheck(log?: {
|
|
|
90
77
|
_lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
|
|
91
78
|
if (hasUpdate) {
|
|
92
79
|
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
|
-
}
|
|
98
80
|
}
|
|
99
81
|
} catch (parseErr) {
|
|
100
82
|
_lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: String(parseErr) };
|
|
@@ -107,11 +89,6 @@ export function getUpdateInfo(): UpdateInfo {
|
|
|
107
89
|
return { ..._lastInfo };
|
|
108
90
|
}
|
|
109
91
|
|
|
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
|
-
|
|
115
92
|
function compareVersions(a: string, b: string): number {
|
|
116
93
|
const parse = (v: string) => {
|
|
117
94
|
const clean = v.replace(/^v/, "");
|