@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.2-alpha.2",
3
+ "version": "1.6.2-alpha.3",
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";
@@ -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
- import { MSG } from "./user-messages.js";
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
- // 后台版本检查(detached 子进程,零阻塞)
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: (meta.text ?? "").slice(0, 500),
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
- try {
1688
- await sendWithTokenRetry(async (token) => {
1689
- const ref = consumeQuoteRef();
1690
- if (event.type === "c2c") {
1691
- return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
1692
- } else if (event.type === "group" && event.groupOpenid) {
1693
- return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
1694
- } else if (event.channelId) {
1695
- return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
1696
- }
1697
- });
1698
- log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
1699
- } catch (err) {
1700
- log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
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
- await sendErrorMessage(MSG.IMAGE_FORMAT_UNSUPPORTED(ext));
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
- await sendErrorMessage(MSG.VOICE_MISSING_TEXT);
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
- await sendChannelMessage(token, event.channelId, `${MSG.VOICE_CHANNEL_UNSUPPORTED}\n${ttsText}`, event.messageId);
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
- await sendErrorMessage(MSG.VIDEO_MISSING_PATH);
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
- await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
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
- await sendChannelMessage(token, event.channelId, MSG.VIDEO_CHANNEL_UNSUPPORTED, event.messageId);
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
- await sendErrorMessage(MSG.FILE_MISSING_PATH);
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
- await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
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
- await sendChannelMessage(token, event.channelId, MSG.FILE_CHANNEL_UNSUPPORTED, event.messageId);
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
- try {
2235
- await sendWithTokenRetry(async (token) => {
2236
- const ref = consumeQuoteRef();
2237
- if (event.type === "c2c") {
2238
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2239
- } else if (event.type === "group" && event.groupOpenid) {
2240
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2241
- } else if (event.channelId) {
2242
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2243
- }
2244
- });
2245
- log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
2246
- } catch (err) {
2247
- log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
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
- await sendWithTokenRetry(async (token) => {
2290
- const ref = consumeQuoteRef();
2291
- if (event.type === "c2c") {
2292
- return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2293
- } else if (event.type === "group" && event.groupOpenid) {
2294
- return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2295
- } else if (event.channelId) {
2296
- return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2297
- }
2298
- });
2299
- log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
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: false,
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: MSG.IMAGE_FORMAT_UNSUPPORTED(ext) };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
434
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.VOICE_CHANNEL_UNSUPPORTED, ctx.replyToId);
502
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
547
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.VIDEO_CHANNEL_UNSUPPORTED, ctx.replyToId);
594
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
640
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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
- const r = await sendChannelMessage(token, ctx.targetId, MSG.FILE_CHANNEL_UNSUPPORTED, ctx.replyToId);
691
- return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
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);
@@ -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.slice(0, MAX_CONTENT_LENGTH),
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.slice(0, MAX_CONTENT_LENGTH),
249
+ content: entry.content,
251
250
  senderId: entry.senderId,
252
251
  senderName: entry.senderName,
253
252
  timestamp: entry.timestamp,