@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 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.0`
13
+ ### 🚀 Current Version: `v1.6.1`
14
14
 
15
15
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
16
16
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](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.0 / 🌟 GitHub repo
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.0`
12
+ ### 🚀 当前版本: `v1.6.1`
13
13
 
14
14
  [![License](https://img.shields.io/badge/license-MIT-green)](./LICENSE)
15
15
  [![QQ Bot](https://img.shields.io/badge/QQ_Bot-API_v2-red)](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.0 / 🌟官方 GitHub 仓库
161
+ > **QQBot**:🦞框架版本:OpenClaw 2026.3.13 (61d171a) / 🤖QQBot 插件版本:v1.6.1 / 🌟官方 GitHub 仓库
162
162
 
163
163
  一目了然查看框架版本、插件版本,并可直接跳转官方仓库。
164
164
 
@@ -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>;
@@ -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
- if (text.length <= limit)
13
- return [text];
14
- const chunks = [];
15
- let remaining = text;
16
- while (remaining.length > 0) {
17
- if (remaining.length <= limit) {
18
- chunks.push(remaining);
19
- break;
20
- }
21
- // 尝试在换行处分割
22
- let splitAt = remaining.lastIndexOf("\n", limit);
23
- if (splitAt <= 0 || splitAt < limit * 0.5) {
24
- // 没找到合适的换行,尝试在空格处分割
25
- splitAt = remaining.lastIndexOf(" ", limit);
26
- }
27
- if (splitAt <= 0 || splitAt < limit * 0.5) {
28
- // 还是没找到,强制在 limit 处分割
29
- splitAt = limit;
30
- }
31
- chunks.push(remaining.slice(0, splitAt));
32
- remaining = remaining.slice(splitAt).trimStart();
33
- }
34
- return chunks;
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: false,
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: chunkText,
190
+ chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
209
191
  chunkerMode: "markdown",
210
- textChunkLimit: 20000,
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 ? "..." : ""}`);
@@ -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, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
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
- // 后台版本检查(detached 子进程,零阻塞)
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
- try {
1511
- await sendWithTokenRetry(async (token) => {
1512
- const ref = consumeQuoteRef();
1513
- if (event.type === "c2c") {
1514
- return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
1515
- }
1516
- else if (event.type === "group" && event.groupOpenid) {
1517
- return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
1518
- }
1519
- else if (event.channelId) {
1520
- return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
1521
- }
1522
- });
1523
- log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
1524
- }
1525
- catch (err) {
1526
- log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
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
- try {
2081
- await sendWithTokenRetry(async (token) => {
2082
- const ref = consumeQuoteRef();
2083
- if (event.type === "c2c") {
2084
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2085
- }
2086
- else if (event.type === "group" && event.groupOpenid) {
2087
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2088
- }
2089
- else if (event.channelId) {
2090
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2091
- }
2092
- });
2093
- log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
2094
- }
2095
- catch (err) {
2096
- log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
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
- await sendWithTokenRetry(async (token) => {
2139
- const ref = consumeQuoteRef();
2140
- if (event.type === "c2c") {
2141
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2142
- }
2143
- else if (event.type === "group" && event.groupOpenid) {
2144
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2145
- }
2146
- else if (event.channelId) {
2147
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2148
- }
2149
- });
2150
- log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
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: false,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.1",
3
+ "version": "1.6.2-alpha.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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
- if (text.length <= limit) return [text];
22
-
23
- const chunks: string[] = [];
24
- let remaining = text;
25
-
26
- while (remaining.length > 0) {
27
- if (remaining.length <= limit) {
28
- chunks.push(remaining);
29
- break;
30
- }
31
-
32
- // 尝试在换行处分割
33
- let splitAt = remaining.lastIndexOf("\n", limit);
34
- if (splitAt <= 0 || splitAt < limit * 0.5) {
35
- // 没找到合适的换行,尝试在空格处分割
36
- splitAt = remaining.lastIndexOf(" ", limit);
37
- }
38
- if (splitAt <= 0 || splitAt < limit * 0.5) {
39
- // 还是没找到,强制在 limit 处分割
40
- splitAt = limit;
41
- }
42
-
43
- chunks.push(remaining.slice(0, splitAt));
44
- remaining = remaining.slice(splitAt).trimStart();
45
- }
46
-
47
- return chunks;
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: false,
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: chunkText,
212
+ chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
234
213
  chunkerMode: "markdown",
235
- textChunkLimit: 20000,
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, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
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
- // 后台版本检查(detached 子进程,零阻塞)
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
- try {
1690
- await sendWithTokenRetry(async (token) => {
1691
- const ref = consumeQuoteRef();
1692
- if (event.type === "c2c") {
1693
- return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
1694
- } else if (event.type === "group" && event.groupOpenid) {
1695
- return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
1696
- } else if (event.channelId) {
1697
- return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
1698
- }
1699
- });
1700
- log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
1701
- } catch (err) {
1702
- log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
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
- try {
2249
- await sendWithTokenRetry(async (token) => {
2250
- const ref = consumeQuoteRef();
2251
- if (event.type === "c2c") {
2252
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2253
- } else if (event.type === "group" && event.groupOpenid) {
2254
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2255
- } else if (event.channelId) {
2256
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2257
- }
2258
- });
2259
- log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
2260
- } catch (err) {
2261
- log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
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
- await sendWithTokenRetry(async (token) => {
2304
- const ref = consumeQuoteRef();
2305
- if (event.type === "c2c") {
2306
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2307
- } else if (event.type === "group" && event.groupOpenid) {
2308
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2309
- } else if (event.channelId) {
2310
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2311
- }
2312
- });
2313
- log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
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: false,
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;
@@ -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/, "");