@tencent-connect/openclaw-qqbot 1.6.2-alpha.1 → 1.6.2-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,3 @@
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[];
11
3
  export declare const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount>;
@@ -4,16 +4,34 @@ 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;
9
7
  /**
10
- * Markdown 感知的文本分块函数
11
- * 委托给 SDK 内置的 channel.text.chunkMarkdownText
12
- * 支持代码块自动关闭/重开、括号感知等
8
+ * 简单的文本分块函数
9
+ * 用于预先分块长文本
13
10
  */
14
- export function chunkText(text, limit) {
15
- const runtime = getQQBotRuntime();
16
- return runtime.channel.text.chunkMarkdownText(text, limit);
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;
17
35
  }
18
36
  export const qqbotPlugin = {
19
37
  id: "qqbot",
@@ -34,7 +52,7 @@ export const qqbotPlugin = {
34
52
  * blockStreaming: true 表示该 Channel 支持块流式
35
53
  * 框架会收集流式响应,然后通过 deliver 回调发送
36
54
  */
37
- blockStreaming: true,
55
+ blockStreaming: false,
38
56
  },
39
57
  reload: { configPrefixes: ["channels.qqbot"] },
40
58
  // CLI onboarding wizard
@@ -187,9 +205,9 @@ export const qqbotPlugin = {
187
205
  },
188
206
  outbound: {
189
207
  deliveryMode: "direct",
190
- chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
208
+ chunker: chunkText,
191
209
  chunkerMode: "markdown",
192
- textChunkLimit: 5000,
210
+ textChunkLimit: 20000,
193
211
  sendText: async ({ to, text, accountId, replyToId, cfg }) => {
194
212
  console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
195
213
  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 } from "./update-checker.js";
10
+ import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
11
11
  import { startImageServer, isImageServerRunning, downloadFile } from "./image-server.js";
12
12
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
13
13
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload } from "./utils/payload.js";
@@ -15,9 +15,8 @@ import { convertSilkToWav, isVoiceAttachment, formatDuration, resolveTTSConfig,
15
15
  import { normalizeMediaTags } from "./utils/media-tags.js";
16
16
  import { checkFileSize, readFileAsync, fileExistsAsync, formatFileSize } from "./utils/file-utils.js";
17
17
  import { getQQBotDataDir, isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName, runDiagnostics } from "./utils/platform.js";
18
- import { MSG, formatMediaErrorMessage } from "./user-messages.js";
18
+ import { MSG } 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";
21
20
  function resolveSTTConfig(cfg) {
22
21
  const c = cfg;
23
22
  // 优先使用 channels.qqbot.stt(插件专属配置)
@@ -173,7 +172,6 @@ function parseFaceTags(text) {
173
172
  }
174
173
  });
175
174
  }
176
- // formatMediaErrorMessage 已移至 user-messages.ts 集中管理
177
175
  // ============ 内部标记过滤 ============
178
176
  /**
179
177
  * 过滤内部标记(如 [[reply_to: xxx]])
@@ -316,8 +314,36 @@ export async function startGateway(ctx) {
316
314
  log?.info(`[qqbot:${account.accountId}] ${w}`);
317
315
  }
318
316
  }
319
- // 后台版本检查(供 /qqbot-version、/qqbot-upgrade 指令被动查询)
317
+ // 后台版本检查(detached 子进程,零阻塞)
320
318
  triggerUpdateCheck(log);
319
+ // 注册新版本通知回调:仅发给管理员,带防抖
320
+ let lastUpdateNotifyAt = 0;
321
+ const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
322
+ onUpdateFound(async (info) => {
323
+ try {
324
+ // 防抖:避免短时间内重复推送
325
+ const now = Date.now();
326
+ if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
327
+ log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
328
+ return;
329
+ }
330
+ const notice = formatUpdateNotice(info);
331
+ if (!notice)
332
+ return;
333
+ const adminId = resolveAdminOpenId();
334
+ if (!adminId) {
335
+ log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
336
+ return;
337
+ }
338
+ const token = await getAccessToken(account.appId, account.clientSecret);
339
+ await sendProactiveC2CMessage(token, adminId, notice);
340
+ lastUpdateNotifyAt = Date.now();
341
+ log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
342
+ }
343
+ catch (err) {
344
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
345
+ }
346
+ });
321
347
  // 初始化 API 配置(markdown 支持)
322
348
  initApiConfig({
323
349
  markdownSupport: account.markdownSupport,
@@ -1480,34 +1506,29 @@ export async function startGateway(ctx) {
1480
1506
  };
1481
1507
  for (const item of sendQueue) {
1482
1508
  if (item.type === "text") {
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
- }
1509
+ try {
1510
+ await sendWithTokenRetry(async (token) => {
1511
+ const ref = consumeQuoteRef();
1512
+ if (event.type === "c2c") {
1513
+ return await sendC2CMessage(token, event.senderId, item.content, event.messageId, ref);
1514
+ }
1515
+ else if (event.type === "group" && event.groupOpenid) {
1516
+ return await sendGroupMessage(token, event.groupOpenid, item.content, event.messageId);
1517
+ }
1518
+ else if (event.channelId) {
1519
+ return await sendChannelMessage(token, event.channelId, item.content, event.messageId);
1520
+ }
1521
+ });
1522
+ log?.info(`[qqbot:${account.accountId}] Sent text: ${item.content.slice(0, 50)}...`);
1523
+ }
1524
+ catch (err) {
1525
+ log?.error(`[qqbot:${account.accountId}] Failed to send text: ${err}`);
1504
1526
  }
1505
1527
  }
1506
1528
  else if (item.type === "image") {
1507
1529
  const result = await sendPhoto(mediaTarget, item.content);
1508
1530
  if (result.error) {
1509
1531
  log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
1510
- await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
1511
1532
  }
1512
1533
  }
1513
1534
  else if (item.type === "voice") {
@@ -1522,26 +1543,22 @@ export async function startGateway(ctx) {
1522
1543
  ]);
1523
1544
  if (result.error) {
1524
1545
  log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
1525
- await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
1526
1546
  }
1527
1547
  }
1528
1548
  catch (err) {
1529
1549
  log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
1530
- await sendErrorMessage(formatMediaErrorMessage("语音", err));
1531
1550
  }
1532
1551
  }
1533
1552
  else if (item.type === "video") {
1534
1553
  const result = await sendVideoMsg(mediaTarget, item.content);
1535
1554
  if (result.error) {
1536
1555
  log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
1537
- await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
1538
1556
  }
1539
1557
  }
1540
1558
  else if (item.type === "file") {
1541
1559
  const result = await sendDocument(mediaTarget, item.content);
1542
1560
  if (result.error) {
1543
1561
  log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
1544
- await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
1545
1562
  }
1546
1563
  }
1547
1564
  else if (item.type === "media") {
@@ -1556,7 +1573,6 @@ export async function startGateway(ctx) {
1556
1573
  });
1557
1574
  if (result.error) {
1558
1575
  log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
1559
- await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
1560
1576
  }
1561
1577
  }
1562
1578
  }
@@ -1625,12 +1641,12 @@ export async function startGateway(ctx) {
1625
1641
  if (parsedPayload.source === "file") {
1626
1642
  try {
1627
1643
  if (!(await fileExistsAsync(imageUrl))) {
1628
- await sendErrorMessage(MSG.IMAGE_NOT_FOUND);
1644
+ log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
1629
1645
  return;
1630
1646
  }
1631
1647
  const imgSzCheck = checkFileSize(imageUrl);
1632
1648
  if (!imgSzCheck.ok) {
1633
- await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1649
+ log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
1634
1650
  return;
1635
1651
  }
1636
1652
  const fileBuffer = await readFileAsync(imageUrl);
@@ -1654,7 +1670,6 @@ export async function startGateway(ctx) {
1654
1670
  }
1655
1671
  catch (readErr) {
1656
1672
  log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
1657
- await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1658
1673
  return;
1659
1674
  }
1660
1675
  }
@@ -1690,7 +1705,6 @@ export async function startGateway(ctx) {
1690
1705
  }
1691
1706
  catch (err) {
1692
1707
  log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
1693
- await sendErrorMessage(formatMediaErrorMessage("图片", err));
1694
1708
  }
1695
1709
  }
1696
1710
  else if (parsedPayload.mediaType === "audio") {
@@ -1704,7 +1718,6 @@ export async function startGateway(ctx) {
1704
1718
  const ttsCfg = resolveTTSConfig(cfg);
1705
1719
  if (!ttsCfg) {
1706
1720
  log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
1707
- await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
1708
1721
  }
1709
1722
  else {
1710
1723
  log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
@@ -1728,7 +1741,6 @@ export async function startGateway(ctx) {
1728
1741
  }
1729
1742
  catch (err) {
1730
1743
  log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
1731
- await sendErrorMessage(formatMediaErrorMessage("语音", err));
1732
1744
  }
1733
1745
  }
1734
1746
  else if (parsedPayload.mediaType === "video") {
@@ -1796,7 +1808,6 @@ export async function startGateway(ctx) {
1796
1808
  }
1797
1809
  catch (err) {
1798
1810
  log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
1799
- await sendErrorMessage(formatMediaErrorMessage("视频", err));
1800
1811
  }
1801
1812
  }
1802
1813
  else if (parsedPayload.mediaType === "file") {
@@ -1848,7 +1859,6 @@ export async function startGateway(ctx) {
1848
1859
  }
1849
1860
  catch (err) {
1850
1861
  log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
1851
- await sendErrorMessage(formatMediaErrorMessage("文件", err));
1852
1862
  }
1853
1863
  }
1854
1864
  else {
@@ -2054,26 +2064,23 @@ export async function startGateway(ctx) {
2054
2064
  }
2055
2065
  // 🔹 第三步:发送带公网图片的 markdown 消息
2056
2066
  if (textWithoutImages.trim()) {
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
- }
2067
+ try {
2068
+ await sendWithTokenRetry(async (token) => {
2069
+ const ref = consumeQuoteRef();
2070
+ if (event.type === "c2c") {
2071
+ return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2072
+ }
2073
+ else if (event.type === "group" && event.groupOpenid) {
2074
+ return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2075
+ }
2076
+ else if (event.channelId) {
2077
+ return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2078
+ }
2079
+ });
2080
+ log?.info(`[qqbot:${account.accountId}] Sent markdown message with ${httpImageUrls.length} HTTP images (${event.type})`);
2081
+ }
2082
+ catch (err) {
2083
+ log?.error(`[qqbot:${account.accountId}] Failed to send markdown message: ${err}`);
2077
2084
  }
2078
2085
  }
2079
2086
  }
@@ -2113,24 +2120,21 @@ export async function startGateway(ctx) {
2113
2120
  log?.error(`[qqbot:${account.accountId}] Failed to send image: ${imgErr}`);
2114
2121
  }
2115
2122
  }
2116
- // 发送文本消息(分块)
2123
+ // 发送文本消息
2117
2124
  if (textWithoutImages.trim()) {
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
- }
2125
+ await sendWithTokenRetry(async (token) => {
2126
+ const ref = consumeQuoteRef();
2127
+ if (event.type === "c2c") {
2128
+ return await sendC2CMessage(token, event.senderId, textWithoutImages, event.messageId, ref);
2129
+ }
2130
+ else if (event.type === "group" && event.groupOpenid) {
2131
+ return await sendGroupMessage(token, event.groupOpenid, textWithoutImages, event.messageId);
2132
+ }
2133
+ else if (event.channelId) {
2134
+ return await sendChannelMessage(token, event.channelId, textWithoutImages, event.messageId);
2135
+ }
2136
+ });
2137
+ log?.info(`[qqbot:${account.accountId}] Sent text reply (${event.type})`);
2134
2138
  }
2135
2139
  }
2136
2140
  catch (err) {
@@ -2207,15 +2211,15 @@ export async function startGateway(ctx) {
2207
2211
  // 发送错误提示给用户,显示完整错误信息
2208
2212
  const errMsg = String(err);
2209
2213
  if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
2210
- await sendErrorMessage(MSG.AI_AUTH_ERROR);
2214
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
2211
2215
  }
2212
2216
  else {
2213
- await sendErrorMessage(MSG.AI_PROCESS_ERROR);
2217
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
2214
2218
  }
2215
2219
  },
2216
2220
  },
2217
2221
  replyOptions: {
2218
- disableBlockStreaming: true,
2222
+ disableBlockStreaming: false,
2219
2223
  },
2220
2224
  });
2221
2225
  // 等待分发完成或超时
@@ -2228,7 +2232,6 @@ export async function startGateway(ctx) {
2228
2232
  }
2229
2233
  if (!hasResponse) {
2230
2234
  log?.error(`[qqbot:${account.accountId}] No response within timeout`);
2231
- await sendErrorMessage(MSG.TIMEOUT_HINT);
2232
2235
  }
2233
2236
  }
2234
2237
  finally {
@@ -2247,7 +2250,6 @@ export async function startGateway(ctx) {
2247
2250
  }
2248
2251
  catch (err) {
2249
2252
  log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
2250
- await sendErrorMessage(MSG.GENERIC_ERROR);
2251
2253
  }
2252
2254
  };
2253
2255
  ws.on("open", () => {
@@ -2350,7 +2352,6 @@ export async function startGateway(ctx) {
2350
2352
  }
2351
2353
  else if (t === "RESUMED") {
2352
2354
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
2353
- onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
2354
2355
  // RESUMED 也属于首次启动(gateway restart 通常走 resume)
2355
2356
  if (isFirstReadyGlobal) {
2356
2357
  isFirstReadyGlobal = false;
@@ -208,7 +208,7 @@ export async function sendPhoto(ctx, imagePath) {
208
208
  let imageUrl = mediaPath;
209
209
  if (isLocal) {
210
210
  if (!(await fileExistsAsync(mediaPath))) {
211
- return { channel: "qqbot", error: MSG.IMAGE_NOT_FOUND };
211
+ return { channel: "qqbot", error: "图片不存在或已失效" };
212
212
  }
213
213
  const sizeCheck = checkFileSize(mediaPath);
214
214
  if (!sizeCheck.ok) {
@@ -345,7 +345,7 @@ async function sendVoiceFromLocal(ctx, mediaPath, directUploadFormats, transcode
345
345
  // 等待文件就绪(TTS 异步生成,文件可能还没写完)
346
346
  const fileSize = await waitForFile(mediaPath);
347
347
  if (fileSize === 0) {
348
- return { channel: "qqbot", error: MSG.VOICE_GENERATE_FAILED };
348
+ return { channel: "qqbot", error: "语音文件未就绪" };
349
349
  }
350
350
  // 精细检测:是否需要转码
351
351
  const needsTranscode = shouldTranscodeVoice(mediaPath);
@@ -441,7 +441,7 @@ export async function sendVideoMsg(ctx, videoPath) {
441
441
  /** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
442
442
  async function sendVideoFromLocal(ctx, mediaPath, prefix) {
443
443
  if (!(await fileExistsAsync(mediaPath))) {
444
- return { channel: "qqbot", error: MSG.VIDEO_NOT_FOUND };
444
+ return { channel: "qqbot", error: "视频文件不存在或已失效" };
445
445
  }
446
446
  const sizeCheck = checkFileSize(mediaPath);
447
447
  if (!sizeCheck.ok) {
@@ -528,7 +528,7 @@ export async function sendDocument(ctx, filePath) {
528
528
  async function sendDocumentFromLocal(ctx, mediaPath, prefix) {
529
529
  const fileName = sanitizeFileName(path.basename(mediaPath));
530
530
  if (!(await fileExistsAsync(mediaPath))) {
531
- return { channel: "qqbot", error: MSG.FILE_NOT_FOUND };
531
+ return { channel: "qqbot", error: "文件不存在或已失效" };
532
532
  }
533
533
  const sizeCheck = checkFileSize(mediaPath);
534
534
  if (!sizeCheck.ok) {
@@ -12,9 +12,16 @@ export interface UpdateInfo {
12
12
  checkedAt: number;
13
13
  error?: string;
14
14
  }
15
+ type UpdateFoundCallback = (info: UpdateInfo) => void;
16
+ /**
17
+ * 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
18
+ */
19
+ export declare function onUpdateFound(cb: UpdateFoundCallback): void;
15
20
  export declare function triggerUpdateCheck(log?: {
16
21
  info: (msg: string) => void;
17
22
  error: (msg: string) => void;
18
23
  debug?: (msg: string) => void;
19
24
  }): void;
20
25
  export declare function getUpdateInfo(): UpdateInfo;
26
+ export declare function formatUpdateNotice(info: UpdateInfo): string;
27
+ export {};
@@ -24,6 +24,15 @@ let _lastInfo = {
24
24
  checkedAt: 0,
25
25
  };
26
26
  let _checking = false;
27
+ /** 已通知过的版本号,避免同一版本重复推送 */
28
+ let _notifiedVersion = null;
29
+ let _onUpdateFound = null;
30
+ /**
31
+ * 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
32
+ */
33
+ export function onUpdateFound(cb) {
34
+ _onUpdateFound = cb;
35
+ }
27
36
  export function triggerUpdateCheck(log) {
28
37
  if (_checking)
29
38
  return;
@@ -55,6 +64,11 @@ export function triggerUpdateCheck(log) {
55
64
  _lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
56
65
  if (hasUpdate) {
57
66
  log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
67
+ // 首次发现该版本时触发回调
68
+ if (_onUpdateFound && compareTarget !== _notifiedVersion) {
69
+ _notifiedVersion = compareTarget;
70
+ _onUpdateFound(_lastInfo);
71
+ }
58
72
  }
59
73
  }
60
74
  catch (parseErr) {
@@ -65,6 +79,11 @@ export function triggerUpdateCheck(log) {
65
79
  export function getUpdateInfo() {
66
80
  return { ..._lastInfo };
67
81
  }
82
+ export function formatUpdateNotice(info) {
83
+ if (!info.hasUpdate || !info.latest)
84
+ return "";
85
+ return `\u{1f195} 有新版本可用: v${info.latest}(当前 v${info.current})\n使用 /qqbot-upgrade 升级`;
86
+ }
68
87
  function compareVersions(a, b) {
69
88
  const parse = (v) => {
70
89
  const clean = v.replace(/^v/, "");
@@ -1,43 +1,16 @@
1
1
  /**
2
2
  * 用户面向的提示文案集中管理
3
- *
4
- * 设计原则(参考 Telegram / Discord / Slack / 飞书):
5
- * 1. 禁止暴露服务器路径、原始 Error、配置文件结构
6
- * 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
7
- * 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
8
- * 4. 所有面向用户的文案集中在此文件,便于维护和国际化
3
+ * 仅保留格式校验类提示,运行时错误走日志不面向用户
9
4
  */
10
5
  export declare const MSG: {
11
- readonly GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~";
12
- readonly AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~";
13
- readonly AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~";
14
- readonly TIMEOUT_HINT: "已收到消息,正在处理中…";
15
- readonly IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~";
16
6
  readonly IMAGE_FORMAT_UNSUPPORTED: (ext: string) => string;
17
- readonly IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~";
18
- readonly IMAGE_UPLOADING: (size: string) => string;
19
- readonly VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~";
20
- readonly VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~";
21
- readonly VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~";
22
- readonly VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~";
23
- readonly VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~";
24
7
  readonly VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~";
25
- readonly VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~";
26
- readonly VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~";
27
- readonly VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~";
28
8
  readonly VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~";
29
- readonly VIDEO_UPLOADING: (size: string) => string;
30
- readonly FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~";
31
- readonly FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~";
32
- readonly FILE_MISSING_PATH: "抱歉,文件消息缺少内容~";
33
9
  readonly FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~";
34
- readonly FILE_UPLOADING: (name: string, size: string) => string;
10
+ readonly VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~";
11
+ readonly VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~";
12
+ readonly FILE_MISSING_PATH: "抱歉,文件消息缺少内容~";
35
13
  readonly PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~";
36
14
  readonly UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~";
37
15
  readonly UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~";
38
16
  };
39
- /**
40
- * 将媒体上传/发送错误转为对用户友好的提示文案
41
- * 技术细节不暴露给用户,仅记录到日志
42
- */
43
- export declare function formatMediaErrorMessage(mediaType: string, err: unknown): string;
@@ -1,65 +1,20 @@
1
1
  /**
2
2
  * 用户面向的提示文案集中管理
3
- *
4
- * 设计原则(参考 Telegram / Discord / Slack / 飞书):
5
- * 1. 禁止暴露服务器路径、原始 Error、配置文件结构
6
- * 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
7
- * 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
8
- * 4. 所有面向用户的文案集中在此文件,便于维护和国际化
3
+ * 仅保留格式校验类提示,运行时错误走日志不面向用户
9
4
  */
10
- // ============ 媒体发送错误 ============
11
5
  export const MSG = {
12
- // 通用错误
13
- GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~",
14
- AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~",
15
- AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~",
16
- TIMEOUT_HINT: "已收到消息,正在处理中…",
17
- // 图片
18
- IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~",
6
+ // 图片格式校验
19
7
  IMAGE_FORMAT_UNSUPPORTED: (ext) => `抱歉,暂不支持 ${ext} 格式的图片~`,
20
- IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~",
21
- IMAGE_UPLOADING: (size) => `正在上传图片 (${size})...`,
22
- // 语音
23
- VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~",
24
- VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~",
25
- VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~",
26
- VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~",
27
- VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
8
+ // 频道不支持的媒体类型
28
9
  VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
29
- // 视频
30
- VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~",
31
- VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~",
32
- VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
33
10
  VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
34
- VIDEO_UPLOADING: (size) => `正在上传视频 (${size})...`,
35
- // 文件
36
- FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~",
37
- FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~",
38
- FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
39
11
  FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
40
- FILE_UPLOADING: (name, size) => `正在上传文件 ${name} (${size})...`,
41
- // 载荷解析
12
+ // 缺少必要内容
13
+ VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
14
+ VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
15
+ FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
16
+ // 载荷解析校验
42
17
  PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
43
18
  UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
44
19
  UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
45
20
  };
46
- /**
47
- * 将媒体上传/发送错误转为对用户友好的提示文案
48
- * 技术细节不暴露给用户,仅记录到日志
49
- */
50
- export function formatMediaErrorMessage(mediaType, err) {
51
- const msg = err instanceof Error ? err.message : String(err);
52
- if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
53
- return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
54
- }
55
- if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
56
- return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
57
- }
58
- if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
59
- return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
60
- }
61
- if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
62
- return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
63
- }
64
- return `抱歉,${mediaType}发送失败了,请稍后再试~`;
65
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tencent-connect/openclaw-qqbot",
3
- "version": "1.6.2-alpha.1",
3
+ "version": "1.6.2-alpha.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/channel.ts CHANGED
@@ -13,17 +13,38 @@ 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
-
19
16
  /**
20
- * Markdown 感知的文本分块函数
21
- * 委托给 SDK 内置的 channel.text.chunkMarkdownText
22
- * 支持代码块自动关闭/重开、括号感知等
17
+ * 简单的文本分块函数
18
+ * 用于预先分块长文本
23
19
  */
24
- export function chunkText(text: string, limit: number): string[] {
25
- const runtime = getQQBotRuntime();
26
- return runtime.channel.text.chunkMarkdownText(text, limit);
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;
27
48
  }
28
49
 
29
50
  export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
@@ -45,7 +66,7 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
45
66
  * blockStreaming: true 表示该 Channel 支持块流式
46
67
  * 框架会收集流式响应,然后通过 deliver 回调发送
47
68
  */
48
- blockStreaming: true,
69
+ blockStreaming: false,
49
70
  },
50
71
  reload: { configPrefixes: ["channels.qqbot"] },
51
72
  // CLI onboarding wizard
@@ -209,9 +230,9 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
209
230
  },
210
231
  outbound: {
211
232
  deliveryMode: "direct",
212
- chunker: (text, limit) => getQQBotRuntime().channel.text.chunkMarkdownText(text, limit),
233
+ chunker: chunkText,
213
234
  chunkerMode: "markdown",
214
- textChunkLimit: 5000,
235
+ textChunkLimit: 20000,
215
236
  sendText: async ({ to, text, accountId, replyToId, cfg }) => {
216
237
  console.log(`[qqbot:channel] sendText called — accountId=${accountId}, to=${to}, replyToId=${replyToId}, text.length=${text?.length ?? 0}`);
217
238
  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 } from "./update-checker.js";
11
+ import { triggerUpdateCheck, onUpdateFound, formatUpdateNotice } from "./update-checker.js";
12
12
  import { startImageServer, isImageServerRunning, downloadFile, type ImageServerConfig } from "./image-server.js";
13
13
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize, DEFAULT_IMAGE_SIZE } from "./utils/image-size.js";
14
14
  import { parseQQBotPayload, encodePayloadForCron, isCronReminderPayload, isMediaPayload, type CronReminderPayload, type MediaPayload } from "./utils/payload.js";
@@ -16,9 +16,8 @@ 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, formatMediaErrorMessage } from "./user-messages.js";
19
+ import { MSG } 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";
22
21
 
23
22
  /**
24
23
  * 通用 OpenAI 兼容 STT(语音转文字)
@@ -222,8 +221,6 @@ function parseFaceTags(text: string): string {
222
221
  });
223
222
  }
224
223
 
225
- // formatMediaErrorMessage 已移至 user-messages.ts 集中管理
226
-
227
224
  // ============ 内部标记过滤 ============
228
225
 
229
226
  /**
@@ -411,9 +408,36 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
411
408
  }
412
409
  }
413
410
 
414
- // 后台版本检查(供 /qqbot-version、/qqbot-upgrade 指令被动查询)
411
+ // 后台版本检查(detached 子进程,零阻塞)
415
412
  triggerUpdateCheck(log);
416
413
 
414
+ // 注册新版本通知回调:仅发给管理员,带防抖
415
+ let lastUpdateNotifyAt = 0;
416
+ const UPDATE_NOTIFY_DEBOUNCE_MS = 5 * 60 * 1000; // 5 分钟内不重复通知
417
+ onUpdateFound(async (info) => {
418
+ try {
419
+ // 防抖:避免短时间内重复推送
420
+ const now = Date.now();
421
+ if (now - lastUpdateNotifyAt < UPDATE_NOTIFY_DEBOUNCE_MS) {
422
+ log?.debug?.(`[qqbot:${account.accountId}] Update notification debounced`);
423
+ return;
424
+ }
425
+ const notice = formatUpdateNotice(info);
426
+ if (!notice) return;
427
+ const adminId = resolveAdminOpenId();
428
+ if (!adminId) {
429
+ log?.debug?.(`[qqbot:${account.accountId}] No admin or known user to send update notification`);
430
+ return;
431
+ }
432
+ const token = await getAccessToken(account.appId, account.clientSecret);
433
+ await sendProactiveC2CMessage(token, adminId, notice);
434
+ lastUpdateNotifyAt = Date.now();
435
+ log?.info(`[qqbot:${account.accountId}] Sent update notification to admin: ${adminId}`);
436
+ } catch (err) {
437
+ log?.debug?.(`[qqbot:${account.accountId}] Failed to send update notification to admin: ${err}`);
438
+ }
439
+ });
440
+
417
441
  // 初始化 API 配置(markdown 支持)
418
442
  initApiConfig({
419
443
  markdownSupport: account.markdownSupport,
@@ -1660,30 +1684,25 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1660
1684
 
1661
1685
  for (const item of sendQueue) {
1662
1686
  if (item.type === "text") {
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
- }
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}`);
1681
1701
  }
1682
1702
  } else if (item.type === "image") {
1683
1703
  const result = await sendPhoto(mediaTarget, item.content);
1684
1704
  if (result.error) {
1685
1705
  log?.error(`[qqbot:${account.accountId}] sendPhoto error: ${result.error}`);
1686
- await sendErrorMessage(formatMediaErrorMessage("图片", new Error(result.error)));
1687
1706
  }
1688
1707
  } else if (item.type === "voice") {
1689
1708
  const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
@@ -1699,23 +1718,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1699
1718
  ]);
1700
1719
  if (result.error) {
1701
1720
  log?.error(`[qqbot:${account.accountId}] sendVoice error: ${result.error}`);
1702
- await sendErrorMessage(formatMediaErrorMessage("语音", new Error(result.error)));
1703
1721
  }
1704
1722
  } catch (err) {
1705
1723
  log?.error(`[qqbot:${account.accountId}] sendVoice unexpected error: ${err}`);
1706
- await sendErrorMessage(formatMediaErrorMessage("语音", err));
1707
1724
  }
1708
1725
  } else if (item.type === "video") {
1709
1726
  const result = await sendVideoMsg(mediaTarget, item.content);
1710
1727
  if (result.error) {
1711
1728
  log?.error(`[qqbot:${account.accountId}] sendVideoMsg error: ${result.error}`);
1712
- await sendErrorMessage(formatMediaErrorMessage("视频", new Error(result.error)));
1713
1729
  }
1714
1730
  } else if (item.type === "file") {
1715
1731
  const result = await sendDocument(mediaTarget, item.content);
1716
1732
  if (result.error) {
1717
1733
  log?.error(`[qqbot:${account.accountId}] sendDocument error: ${result.error}`);
1718
- await sendErrorMessage(formatMediaErrorMessage("文件", new Error(result.error)));
1719
1734
  }
1720
1735
  } else if (item.type === "media") {
1721
1736
  // qqmedia: 自动根据扩展名路由到 sendPhoto/sendVoice/sendVideoMsg/sendDocument
@@ -1729,7 +1744,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1729
1744
  });
1730
1745
  if (result.error) {
1731
1746
  log?.error(`[qqbot:${account.accountId}] sendMedia(auto) error: ${result.error}`);
1732
- await sendErrorMessage(formatMediaErrorMessage("媒体", new Error(result.error)));
1733
1747
  }
1734
1748
  }
1735
1749
  }
@@ -1804,12 +1818,12 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1804
1818
  if (parsedPayload.source === "file") {
1805
1819
  try {
1806
1820
  if (!(await fileExistsAsync(imageUrl))) {
1807
- await sendErrorMessage(MSG.IMAGE_NOT_FOUND);
1821
+ log?.error(`[qqbot:${account.accountId}] Image not found: ${imageUrl}`);
1808
1822
  return;
1809
1823
  }
1810
1824
  const imgSzCheck = checkFileSize(imageUrl);
1811
1825
  if (!imgSzCheck.ok) {
1812
- await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1826
+ log?.error(`[qqbot:${account.accountId}] Image size check failed: ${imgSzCheck.error}`);
1813
1827
  return;
1814
1828
  }
1815
1829
  const fileBuffer = await readFileAsync(imageUrl);
@@ -1832,7 +1846,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1832
1846
  log?.info(`[qqbot:${account.accountId}] Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
1833
1847
  } catch (readErr) {
1834
1848
  log?.error(`[qqbot:${account.accountId}] Failed to read local image: ${readErr}`);
1835
- await sendErrorMessage(MSG.IMAGE_SEND_FAILED);
1836
1849
  return;
1837
1850
  }
1838
1851
  }
@@ -1865,7 +1878,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1865
1878
  }
1866
1879
  } catch (err) {
1867
1880
  log?.error(`[qqbot:${account.accountId}] Failed to send image: ${err}`);
1868
- await sendErrorMessage(formatMediaErrorMessage("图片", err));
1869
1881
  }
1870
1882
  } else if (parsedPayload.mediaType === "audio") {
1871
1883
  // TTS 语音发送:文字 → PCM → SILK → QQ 语音
@@ -1877,7 +1889,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1877
1889
  const ttsCfg = resolveTTSConfig(cfg as Record<string, unknown>);
1878
1890
  if (!ttsCfg) {
1879
1891
  log?.error(`[qqbot:${account.accountId}] TTS not configured (channels.qqbot.tts in openclaw.json)`);
1880
- await sendErrorMessage(MSG.VOICE_NOT_AVAILABLE);
1881
1892
  } else {
1882
1893
  log?.info(`[qqbot:${account.accountId}] TTS: "${ttsText.slice(0, 50)}..." via ${ttsCfg.model}`);
1883
1894
  const ttsDir = getQQBotDataDir("tts");
@@ -1898,7 +1909,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1898
1909
  }
1899
1910
  } catch (err) {
1900
1911
  log?.error(`[qqbot:${account.accountId}] TTS/voice send failed: ${err}`);
1901
- await sendErrorMessage(formatMediaErrorMessage("语音", err));
1902
1912
  }
1903
1913
  } else if (parsedPayload.mediaType === "video") {
1904
1914
  // 视频发送:支持公网 URL 和本地文件
@@ -1959,7 +1969,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
1959
1969
  }
1960
1970
  } catch (err) {
1961
1971
  log?.error(`[qqbot:${account.accountId}] Video send failed: ${err}`);
1962
- await sendErrorMessage(formatMediaErrorMessage("视频", err));
1963
1972
  }
1964
1973
  } else if (parsedPayload.mediaType === "file") {
1965
1974
  // 文件发送
@@ -2004,7 +2013,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2004
2013
  }
2005
2014
  } catch (err) {
2006
2015
  log?.error(`[qqbot:${account.accountId}] File send failed: ${err}`);
2007
- await sendErrorMessage(formatMediaErrorMessage("文件", err));
2008
2016
  }
2009
2017
  } else {
2010
2018
  log?.error(`[qqbot:${account.accountId}] Unknown media type: ${(parsedPayload as MediaPayload).mediaType}`);
@@ -2223,23 +2231,20 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2223
2231
 
2224
2232
  // 🔹 第三步:发送带公网图片的 markdown 消息
2225
2233
  if (textWithoutImages.trim()) {
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
- }
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}`);
2243
2248
  }
2244
2249
  }
2245
2250
  } else {
@@ -2279,22 +2284,19 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2279
2284
  }
2280
2285
  }
2281
2286
 
2282
- // 发送文本消息(分块)
2287
+ // 发送文本消息
2283
2288
  if (textWithoutImages.trim()) {
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
- }
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})`);
2298
2300
  }
2299
2301
  } catch (err) {
2300
2302
  log?.error(`[qqbot:${account.accountId}] Send failed: ${err}`);
@@ -2370,14 +2372,14 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2370
2372
  // 发送错误提示给用户,显示完整错误信息
2371
2373
  const errMsg = String(err);
2372
2374
  if (errMsg.includes("401") || errMsg.includes("key") || errMsg.includes("auth")) {
2373
- await sendErrorMessage(MSG.AI_AUTH_ERROR);
2375
+ log?.error(`[qqbot:${account.accountId}] AI auth error: ${errMsg}`);
2374
2376
  } else {
2375
- await sendErrorMessage(MSG.AI_PROCESS_ERROR);
2377
+ log?.error(`[qqbot:${account.accountId}] AI process error: ${errMsg}`);
2376
2378
  }
2377
2379
  },
2378
2380
  },
2379
2381
  replyOptions: {
2380
- disableBlockStreaming: true,
2382
+ disableBlockStreaming: false,
2381
2383
  },
2382
2384
  });
2383
2385
 
@@ -2390,7 +2392,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2390
2392
  }
2391
2393
  if (!hasResponse) {
2392
2394
  log?.error(`[qqbot:${account.accountId}] No response within timeout`);
2393
- await sendErrorMessage(MSG.TIMEOUT_HINT);
2394
2395
  }
2395
2396
  } finally {
2396
2397
  // 清理 tool-only 兜底定时器
@@ -2407,7 +2408,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2407
2408
  }
2408
2409
  } catch (err) {
2409
2410
  log?.error(`[qqbot:${account.accountId}] Message processing failed: ${err}`);
2410
- await sendErrorMessage(MSG.GENERIC_ERROR);
2411
2411
  }
2412
2412
  };
2413
2413
 
@@ -2515,7 +2515,6 @@ export async function startGateway(ctx: GatewayContext): Promise<void> {
2515
2515
  } // end isFirstReady
2516
2516
  } else if (t === "RESUMED") {
2517
2517
  log?.info(`[qqbot:${account.accountId}] Session resumed`);
2518
- onReady?.(d); // 通知框架连接已恢复,避免 health-monitor 误判 disconnected
2519
2518
  // RESUMED 也属于首次启动(gateway restart 通常走 resume)
2520
2519
  if (isFirstReadyGlobal) {
2521
2520
  isFirstReadyGlobal = false;
package/src/outbound.ts CHANGED
@@ -313,7 +313,7 @@ export async function sendPhoto(
313
313
 
314
314
  if (isLocal) {
315
315
  if (!(await fileExistsAsync(mediaPath))) {
316
- return { channel: "qqbot", error: MSG.IMAGE_NOT_FOUND };
316
+ return { channel: "qqbot", error: "图片不存在或已失效" };
317
317
  }
318
318
  const sizeCheck = checkFileSize(mediaPath);
319
319
  if (!sizeCheck.ok) {
@@ -464,7 +464,7 @@ async function sendVoiceFromLocal(
464
464
  // 等待文件就绪(TTS 异步生成,文件可能还没写完)
465
465
  const fileSize = await waitForFile(mediaPath);
466
466
  if (fileSize === 0) {
467
- return { channel: "qqbot", error: MSG.VOICE_GENERATE_FAILED };
467
+ return { channel: "qqbot", error: "语音文件未就绪" };
468
468
  }
469
469
 
470
470
  // 精细检测:是否需要转码
@@ -570,7 +570,7 @@ export async function sendVideoMsg(
570
570
  /** 从本地文件发送视频(sendVideoMsg 的内部辅助) */
571
571
  async function sendVideoFromLocal(ctx: MediaTargetContext, mediaPath: string, prefix: string): Promise<OutboundResult> {
572
572
  if (!(await fileExistsAsync(mediaPath))) {
573
- return { channel: "qqbot", error: MSG.VIDEO_NOT_FOUND };
573
+ return { channel: "qqbot", error: "视频文件不存在或已失效" };
574
574
  }
575
575
  const sizeCheck = checkFileSize(mediaPath);
576
576
  if (!sizeCheck.ok) {
@@ -665,7 +665,7 @@ async function sendDocumentFromLocal(ctx: MediaTargetContext, mediaPath: string,
665
665
  const fileName = sanitizeFileName(path.basename(mediaPath));
666
666
 
667
667
  if (!(await fileExistsAsync(mediaPath))) {
668
- return { channel: "qqbot", error: MSG.FILE_NOT_FOUND };
668
+ return { channel: "qqbot", error: "文件不存在或已失效" };
669
669
  }
670
670
  const sizeCheck = checkFileSize(mediaPath);
671
671
  if (!sizeCheck.ok) {
@@ -38,6 +38,19 @@ let _lastInfo: UpdateInfo = {
38
38
 
39
39
  let _checking = false;
40
40
 
41
+ /** 已通知过的版本号,避免同一版本重复推送 */
42
+ let _notifiedVersion: string | null = null;
43
+
44
+ type UpdateFoundCallback = (info: UpdateInfo) => void;
45
+ let _onUpdateFound: UpdateFoundCallback | null = null;
46
+
47
+ /**
48
+ * 注册新版本发现回调(仅在首次检测到某个新版本时触发一次)
49
+ */
50
+ export function onUpdateFound(cb: UpdateFoundCallback): void {
51
+ _onUpdateFound = cb;
52
+ }
53
+
41
54
  export function triggerUpdateCheck(log?: {
42
55
  info: (msg: string) => void;
43
56
  error: (msg: string) => void;
@@ -77,6 +90,11 @@ export function triggerUpdateCheck(log?: {
77
90
  _lastInfo = { current: CURRENT_VERSION, latest: compareTarget, hasUpdate, checkedAt: now };
78
91
  if (hasUpdate) {
79
92
  log?.info?.(`[qqbot:update-checker] new version available: ${compareTarget} (current: ${CURRENT_VERSION})`);
93
+ // 首次发现该版本时触发回调
94
+ if (_onUpdateFound && compareTarget !== _notifiedVersion) {
95
+ _notifiedVersion = compareTarget;
96
+ _onUpdateFound(_lastInfo);
97
+ }
80
98
  }
81
99
  } catch (parseErr) {
82
100
  _lastInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: now, error: String(parseErr) };
@@ -89,6 +107,11 @@ export function getUpdateInfo(): UpdateInfo {
89
107
  return { ..._lastInfo };
90
108
  }
91
109
 
110
+ export function formatUpdateNotice(info: UpdateInfo): string {
111
+ if (!info.hasUpdate || !info.latest) return "";
112
+ return `\u{1f195} 有新版本可用: v${info.latest}(当前 v${info.current})\n使用 /qqbot-upgrade 升级`;
113
+ }
114
+
92
115
  function compareVersions(a: string, b: string): number {
93
116
  const parse = (v: string) => {
94
117
  const clean = v.replace(/^v/, "");
@@ -1,73 +1,24 @@
1
1
  /**
2
2
  * 用户面向的提示文案集中管理
3
- *
4
- * 设计原则(参考 Telegram / Discord / Slack / 飞书):
5
- * 1. 禁止暴露服务器路径、原始 Error、配置文件结构
6
- * 2. 错误信息分级:用户只看到通用友好文案,技术细节走日志
7
- * 3. 统一风格:去掉 [QQBot] 前缀和 [方括号] 格式
8
- * 4. 所有面向用户的文案集中在此文件,便于维护和国际化
3
+ * 仅保留格式校验类提示,运行时错误走日志不面向用户
9
4
  */
10
5
 
11
- // ============ 媒体发送错误 ============
12
-
13
6
  export const MSG = {
14
- // 通用错误
15
- GENERIC_ERROR: "抱歉,处理消息时遇到了问题,请稍后再试~",
16
- AI_AUTH_ERROR: "抱歉,AI 服务暂时不可用,请联系管理员检查配置~",
17
- AI_PROCESS_ERROR: "抱歉,AI 处理遇到了问题,请稍后再试~",
18
- TIMEOUT_HINT: "已收到消息,正在处理中…",
19
-
20
- // 图片
21
- IMAGE_NOT_FOUND: "抱歉,图片不存在或已失效,无法发送~",
7
+ // 图片格式校验
22
8
  IMAGE_FORMAT_UNSUPPORTED: (ext: string) => `抱歉,暂不支持 ${ext} 格式的图片~`,
23
- IMAGE_SEND_FAILED: "抱歉,图片发送失败了,请稍后再试~",
24
- IMAGE_UPLOADING: (size: string) => `正在上传图片 (${size})...`,
25
9
 
26
- // 语音
27
- VOICE_GENERATE_FAILED: "抱歉,语音生成失败,请稍后重试~",
28
- VOICE_CONVERT_FAILED: "抱歉,语音格式转换失败,请稍后重试~",
29
- VOICE_SEND_FAILED: "抱歉,语音发送失败了,请稍后再试~",
30
- VOICE_NOT_AVAILABLE: "抱歉,语音功能暂未开启~",
31
- VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
10
+ // 频道不支持的媒体类型
32
11
  VOICE_CHANNEL_UNSUPPORTED: "抱歉,语音消息暂不支持在频道中发送~",
33
-
34
- // 视频
35
- VIDEO_NOT_FOUND: "抱歉,视频文件不存在或已失效,无法发送~",
36
- VIDEO_SEND_FAILED: "抱歉,视频发送失败了,请稍后再试~",
37
- VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
38
12
  VIDEO_CHANNEL_UNSUPPORTED: "抱歉,视频消息暂不支持在频道中发送~",
39
- VIDEO_UPLOADING: (size: string) => `正在上传视频 (${size})...`,
13
+ FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
40
14
 
41
- // 文件
42
- FILE_NOT_FOUND: "抱歉,文件不存在或已失效,无法发送~",
43
- FILE_SEND_FAILED: "抱歉,文件发送失败了,请稍后再试~",
15
+ // 缺少必要内容
16
+ VOICE_MISSING_TEXT: "抱歉,语音消息缺少内容~",
17
+ VIDEO_MISSING_PATH: "抱歉,视频消息缺少内容~",
44
18
  FILE_MISSING_PATH: "抱歉,文件消息缺少内容~",
45
- FILE_CHANNEL_UNSUPPORTED: "抱歉,文件消息暂不支持在频道中发送~",
46
- FILE_UPLOADING: (name: string, size: string) => `正在上传文件 ${name} (${size})...`,
47
19
 
48
- // 载荷解析
20
+ // 载荷解析校验
49
21
  PAYLOAD_PARSE_ERROR: "抱歉,消息格式异常,无法处理~",
50
22
  UNSUPPORTED_MEDIA_TYPE: "抱歉,暂不支持该媒体类型~",
51
23
  UNSUPPORTED_PAYLOAD_TYPE: "抱歉,暂不支持该消息类型~",
52
24
  } as const;
53
-
54
- /**
55
- * 将媒体上传/发送错误转为对用户友好的提示文案
56
- * 技术细节不暴露给用户,仅记录到日志
57
- */
58
- export function formatMediaErrorMessage(mediaType: string, err: unknown): string {
59
- const msg = err instanceof Error ? err.message : String(err);
60
- if (msg.includes("上传超时") || msg.includes("timeout") || msg.includes("Timeout")) {
61
- return `抱歉,${mediaType}资源加载超时,可能是网络原因或文件太大,请稍后再试~`;
62
- }
63
- if (msg.includes("文件不存在") || msg.includes("not found") || msg.includes("Not Found")) {
64
- return `抱歉,${mediaType}文件不存在或已失效,无法发送~`;
65
- }
66
- if (msg.includes("文件大小") || msg.includes("too large") || msg.includes("exceed")) {
67
- return `抱歉,${mediaType}文件太大了,超出了发送限制~`;
68
- }
69
- if (msg.includes("Network error") || msg.includes("ECONNREFUSED") || msg.includes("ENOTFOUND")) {
70
- return `抱歉,网络连接异常,${mediaType}发送失败,请稍后再试~`;
71
- }
72
- return `抱歉,${mediaType}发送失败了,请稍后再试~`;
73
- }