@leoqlin/openclaw-qqbot 1.6.8-beta.2 → 1.6.8-beta.4

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/dist/src/api.js CHANGED
@@ -850,7 +850,6 @@ async function sleep(ms, signal) {
850
850
  }
851
851
  });
852
852
  }
853
- import { StreamInputState } from "./types.js";
854
853
  /**
855
854
  * 发送流式消息(C2C 私聊)
856
855
  *
@@ -882,9 +881,5 @@ export async function sendC2CStreamMessage(accessToken, openid, req) {
882
881
  if (req.stream_msg_id) {
883
882
  body.stream_msg_id = req.stream_msg_id;
884
883
  }
885
- // 仅终结分片触发引用回调,中间分片跳过
886
- if (req.input_state === StreamInputState.DONE) {
887
- return sendAndNotify(accessToken, "POST", path, body, { text: req.content_raw });
888
- }
889
884
  return apiRequest(accessToken, "POST", path, body);
890
885
  }
@@ -1,6 +1,6 @@
1
1
  import { applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, } from "openclaw/plugin-sdk/core";
2
2
  import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
3
- import { sendText, sendMedia } from "./outbound.js";
3
+ import { sendText, sendMedia, resolveUserFacingMediaError } from "./outbound.js";
4
4
  import { startGateway } from "./gateway.js";
5
5
  import { qqbotOnboardingAdapter } from "./onboarding.js";
6
6
  import { getQQBotRuntime } from "./runtime.js";
@@ -17,6 +17,16 @@ export function chunkText(text, limit) {
17
17
  const runtime = getQQBotRuntime();
18
18
  return runtime.channel.text.chunkMarkdownText(text, limit);
19
19
  }
20
+ function buildChannelMediaError(result) {
21
+ const err = new Error(resolveUserFacingMediaError(result));
22
+ if (result.errorCode) {
23
+ err.code = result.errorCode;
24
+ }
25
+ if (result.qqBizCode !== undefined) {
26
+ err.qqBizCode = result.qqBizCode;
27
+ }
28
+ return err;
29
+ }
20
30
  export const qqbotPlugin = {
21
31
  id: "qqbot",
22
32
  meta: {
@@ -264,18 +274,12 @@ export const qqbotPlugin = {
264
274
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
265
275
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
266
276
  // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
267
- // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
268
- // gateway 消息响应走的是 deliver 回调 sendPlainReply,不经过此处。
269
- // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
277
+ // 由框架 deliver.js (deliverOutboundPayloads) message-actions 调用。
278
+ // throw Error 后,框架 pi-tool-definition-adapter 会将错误转化为
279
+ // tool 的 { status: "error" } 返回给 AI 模型,模型会自行生成错误回复给用户。
280
+ // 因此此处不应主动发送兜底文本,否则会与模型的回复重复。
270
281
  if (result.error) {
271
- try {
272
- const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
273
- console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
274
- }
275
- catch (fallbackErr) {
276
- console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
277
- }
278
- throw new Error(result.error);
282
+ throw buildChannelMediaError(result);
279
283
  }
280
284
  return {
281
285
  channel: "qqbot",
@@ -5,8 +5,8 @@
5
5
  * 1. parseAndSendMediaTags — 解析 <qqimg/qqvoice/qqvideo/qqfile/qqmedia> 标签并按顺序发送
6
6
  * 2. sendPlainReply — 处理不含媒体标签的普通回复(markdown 图片/纯文本+图片)
7
7
  */
8
- import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
9
- import { sendPhoto, sendMedia as sendMediaAuto } from "./outbound.js";
8
+ import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage, } from "./api.js";
9
+ import { sendPhoto, sendMedia as sendMediaAuto, DEFAULT_MEDIA_SEND_ERROR, resolveUserFacingMediaError, } from "./outbound.js";
10
10
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
11
11
  import { getQQBotRuntime } from "./runtime.js";
12
12
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
@@ -154,7 +154,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
154
154
  });
155
155
  if (result.error) {
156
156
  log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
157
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
157
+ await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
158
158
  }
159
159
  else {
160
160
  log?.info(`${prefix} Sent local media: ${mediaPath}`);
@@ -162,7 +162,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
162
162
  }
163
163
  catch (err) {
164
164
  log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
165
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
165
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
166
166
  }
167
167
  }
168
168
  }
@@ -183,7 +183,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
183
183
  });
184
184
  if (result.error) {
185
185
  log?.error(`${prefix} Tool media forward error: ${result.error}`);
186
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
186
+ await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
187
187
  }
188
188
  else {
189
189
  log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
@@ -191,7 +191,7 @@ export async function sendPlainReply(payload, replyText, event, actx, sendWithRe
191
191
  }
192
192
  catch (err) {
193
193
  log?.error(`${prefix} Tool media forward failed: ${err}`);
194
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
194
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
195
195
  }
196
196
  }
197
197
  }
@@ -356,7 +356,7 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
356
356
  const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
357
357
  if (imgResult.error) {
358
358
  log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
359
- await sendTextChunks(`发送图片失败:${imgResult.error}`, event, actx, sendWithRetry, consumeQuoteRef);
359
+ await sendTextChunks(resolveUserFacingMediaError(imgResult), event, actx, sendWithRetry, consumeQuoteRef);
360
360
  }
361
361
  else {
362
362
  log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
@@ -364,7 +364,7 @@ async function sendPlainTextReply(textWithoutImages, imageUrls, mdMatches, bareU
364
364
  }
365
365
  catch (imgErr) {
366
366
  log?.error(`${prefix} Failed to send image: ${imgErr}`);
367
- await sendTextChunks(`发送图片失败:${imgErr}`, event, actx, sendWithRetry, consumeQuoteRef);
367
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
368
368
  }
369
369
  }
370
370
  if (result.trim()) {
@@ -53,14 +53,29 @@ export interface MediaOutboundContext extends OutboundContext {
53
53
  /** 可选的 MIME 类型,优先于扩展名判断媒体类型 */
54
54
  mimeType?: string;
55
55
  }
56
+ export declare const OUTBOUND_ERROR_CODES: {
57
+ readonly FILE_TOO_LARGE: "file_too_large";
58
+ readonly UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded";
59
+ };
60
+ export declare const DEFAULT_MEDIA_SEND_ERROR = "\u53D1\u9001\u5931\u8D25\uFF0C\u8BF7\u7A0D\u540E\u91CD\u8BD5\u3002";
61
+ export type OutboundErrorCode = typeof OUTBOUND_ERROR_CODES[keyof typeof OUTBOUND_ERROR_CODES];
56
62
  export interface OutboundResult {
57
63
  channel: string;
58
64
  messageId?: string;
59
65
  timestamp?: string | number;
60
66
  error?: string;
67
+ /** 稳定错误码,供上层按类型处理,避免依赖 error 文案 */
68
+ errorCode?: OutboundErrorCode;
69
+ /** QQ 开放平台业务错误码(如 upload_prepare 的 40093002) */
70
+ qqBizCode?: number;
61
71
  /** 出站消息的引用索引(ext_info.ref_idx),供引用消息缓存使用 */
62
72
  refIdx?: string;
63
73
  }
74
+ /**
75
+ * 将媒体发送结果映射为可展示给用户的文案。
76
+ * 只对明确标记为可直接展示的错误码透传原文,其余统一走通用兜底。
77
+ */
78
+ export declare function resolveUserFacingMediaError(result: Pick<OutboundResult, "error" | "errorCode" | "qqBizCode">): string;
64
79
  /** 媒体发送的目标上下文(从 deliver 回调或 sendText 中提取) */
65
80
  export interface MediaTargetContext {
66
81
  /** 目标类型 */
@@ -5,7 +5,7 @@ import * as path from "path";
5
5
  import * as fs from "fs";
6
6
  import * as crypto from "crypto";
7
7
  import { decodeCronPayload } from "./utils/payload.js";
8
- import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, } from "./api.js";
8
+ import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CMediaMessage, sendGroupMediaMessage, MediaFileType, UPLOAD_PREPARE_FALLBACK_CODE, } from "./api.js";
9
9
  import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
10
10
  import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
11
11
  import { chunkedUploadC2C, chunkedUploadGroup, UploadDailyLimitExceededError } from "./utils/chunked-upload.js";
@@ -110,6 +110,29 @@ export function getMessageReplyConfig() {
110
110
  ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
111
111
  };
112
112
  }
113
+ export const OUTBOUND_ERROR_CODES = {
114
+ FILE_TOO_LARGE: "file_too_large",
115
+ UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded",
116
+ };
117
+ export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
118
+ /**
119
+ * 将媒体发送结果映射为可展示给用户的文案。
120
+ * 只对明确标记为可直接展示的错误码透传原文,其余统一走通用兜底。
121
+ */
122
+ export function resolveUserFacingMediaError(result) {
123
+ if (!result.error)
124
+ return DEFAULT_MEDIA_SEND_ERROR;
125
+ if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
126
+ return result.error;
127
+ }
128
+ switch (result.errorCode) {
129
+ case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
130
+ case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED:
131
+ return result.error;
132
+ default:
133
+ return DEFAULT_MEDIA_SEND_ERROR;
134
+ }
135
+ }
113
136
  /**
114
137
  * 解析目标地址
115
138
  * 格式:
@@ -375,7 +398,11 @@ sendMeta) {
375
398
  if (fileSize > maxSize) {
376
399
  const typeName = getFileTypeName(fileType);
377
400
  const limitMB = Math.round(maxSize / (1024 * 1024));
378
- return { channel: "qqbot", error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。` };
401
+ return {
402
+ channel: "qqbot",
403
+ error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`,
404
+ errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE,
405
+ };
379
406
  }
380
407
  if (ctx.targetType === "c2c") {
381
408
  console.log(`${prefix} ${callerName}: c2c chunked upload (${formatFileSize(fileSize)})`);
@@ -397,7 +424,12 @@ sendMeta) {
397
424
  const dir = path.dirname(err.filePath);
398
425
  const name = path.basename(err.filePath);
399
426
  const size = formatFileSize(err.fileSize);
400
- return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
427
+ return {
428
+ channel: "qqbot",
429
+ error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
430
+ errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
431
+ qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
432
+ };
401
433
  }
402
434
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
403
435
  }
@@ -422,7 +454,12 @@ sendMeta) {
422
454
  const dir = path.dirname(err.filePath);
423
455
  const name = path.basename(err.filePath);
424
456
  const size = formatFileSize(err.fileSize);
425
- return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
457
+ return {
458
+ channel: "qqbot",
459
+ error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
460
+ errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
461
+ qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
462
+ };
426
463
  }
427
464
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
428
465
  }
@@ -7,7 +7,7 @@
7
7
  /* eslint-disable no-undef -- Buffer is a Node.js global */
8
8
  import { normalizeMediaTags } from "./media-tags.js";
9
9
  import { normalizePath } from "./platform.js";
10
- import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, } from "../outbound.js";
10
+ import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, DEFAULT_MEDIA_SEND_ERROR, resolveUserFacingMediaError, } from "../outbound.js";
11
11
  /** 统一的媒体标签正则 — 匹配标准化后的 6 种标签 */
12
12
  export const MEDIA_TAG_REGEX = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
13
13
  /** 创建一个新的全局标签正则实例(每次调用 reset lastIndex) */
@@ -283,7 +283,6 @@ export async function executeSendQueue(queue, ctx, options = {}) {
283
283
  }
284
284
  };
285
285
  for (const item of queue) {
286
- const FALLBACK_MSG = "发送失败,请稍后重试。";
287
286
  try {
288
287
  if (item.type === "text") {
289
288
  if (options.skipInterTagText) {
@@ -303,7 +302,7 @@ export async function executeSendQueue(queue, ctx, options = {}) {
303
302
  const result = await sendPhoto(mediaTarget, item.content);
304
303
  if (result.error) {
305
304
  log?.error(`${prefix} sendPhoto error: ${result.error}`);
306
- await sendFallbackText(FALLBACK_MSG);
305
+ await sendFallbackText(resolveUserFacingMediaError(result));
307
306
  }
308
307
  }
309
308
  else if (item.type === "voice") {
@@ -318,26 +317,26 @@ export async function executeSendQueue(queue, ctx, options = {}) {
318
317
  ]);
319
318
  if (result.error) {
320
319
  log?.error(`${prefix} sendVoice error: ${result.error}`);
321
- await sendFallbackText(FALLBACK_MSG);
320
+ await sendFallbackText(resolveUserFacingMediaError(result));
322
321
  }
323
322
  }
324
323
  catch (err) {
325
324
  log?.error(`${prefix} sendVoice unexpected error: ${err}`);
326
- await sendFallbackText(FALLBACK_MSG);
325
+ await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
327
326
  }
328
327
  }
329
328
  else if (item.type === "video") {
330
329
  const result = await sendVideoMsg(mediaTarget, item.content);
331
330
  if (result.error) {
332
331
  log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
333
- await sendFallbackText(FALLBACK_MSG);
332
+ await sendFallbackText(resolveUserFacingMediaError(result));
334
333
  }
335
334
  }
336
335
  else if (item.type === "file") {
337
336
  const result = await sendDocument(mediaTarget, item.content);
338
337
  if (result.error) {
339
338
  log?.error(`${prefix} sendDocument error: ${result.error}`);
340
- await sendFallbackText(FALLBACK_MSG);
339
+ await sendFallbackText(resolveUserFacingMediaError(result));
341
340
  }
342
341
  }
343
342
  else if (item.type === "media") {
@@ -351,13 +350,13 @@ export async function executeSendQueue(queue, ctx, options = {}) {
351
350
  });
352
351
  if (result.error) {
353
352
  log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
354
- await sendFallbackText(FALLBACK_MSG);
353
+ await sendFallbackText(resolveUserFacingMediaError(result));
355
354
  }
356
355
  }
357
356
  }
358
357
  catch (err) {
359
358
  log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
360
- await sendFallbackText(FALLBACK_MSG);
359
+ await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
361
360
  }
362
361
  }
363
362
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leoqlin/openclaw-qqbot",
3
- "version": "1.6.8-beta.2",
3
+ "version": "1.6.8-beta.4",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -37,7 +37,7 @@
37
37
  "postbuild": "node -e \"const fs=require('fs'),p=require('path'),ext=p.join(require('os').homedir(),'.openclaw/extensions/openclaw-qqbot');if(fs.existsSync(ext)&&!fs.lstatSync(ext).isSymbolicLink()){const d=p.join(ext,'dist'),pr=p.join(ext,'preload.cjs');fs.cpSync('dist',d,{recursive:true});fs.copyFileSync('preload.cjs',pr);console.log('[postbuild] synced to',ext)}\"",
38
38
  "dev": "tsc --watch",
39
39
  "prepack": "npm install --omit=dev",
40
- "postinstall": ""
40
+ "postinstall": "node scripts/postinstall-link-sdk.js 2>/dev/null || true"
41
41
  },
42
42
  "dependencies": {
43
43
  "mpg123-decoder": "^1.0.3",
package/src/api.ts CHANGED
@@ -1276,9 +1276,5 @@ export async function sendC2CStreamMessage(
1276
1276
  if (req.stream_msg_id) {
1277
1277
  body.stream_msg_id = req.stream_msg_id;
1278
1278
  }
1279
- // 仅终结分片触发引用回调,中间分片跳过
1280
- if (req.input_state === StreamInputState.DONE) {
1281
- return sendAndNotify(accessToken, "POST", path, body, { text: req.content_raw });
1282
- }
1283
1279
  return apiRequest<MessageResponse>(accessToken, "POST", path, body);
1284
1280
  }
package/src/channel.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
 
9
9
  import type { ResolvedQQBotAccount } from "./types.js";
10
10
  import { DEFAULT_ACCOUNT_ID, listQQBotAccountIds, resolveQQBotAccount, applyQQBotAccountConfig, resolveDefaultQQBotAccountId, resolveRequireMention, resolveToolPolicy, resolveGroupConfig } from "./config.js";
11
- import { sendText, sendMedia } from "./outbound.js";
11
+ import { sendText, sendMedia, resolveUserFacingMediaError } from "./outbound.js";
12
12
  import { startGateway } from "./gateway.js";
13
13
  import { qqbotOnboardingAdapter } from "./onboarding.js";
14
14
  import { getQQBotRuntime } from "./runtime.js";
@@ -28,6 +28,17 @@ export function chunkText(text: string, limit: number): string[] {
28
28
  return runtime.channel.text.chunkMarkdownText(text, limit);
29
29
  }
30
30
 
31
+ function buildChannelMediaError(result: Parameters<typeof resolveUserFacingMediaError>[0]): Error {
32
+ const err = new Error(resolveUserFacingMediaError(result));
33
+ if (result.errorCode) {
34
+ (err as Error & { code?: string }).code = result.errorCode;
35
+ }
36
+ if (result.qqBizCode !== undefined) {
37
+ (err as Error & { qqBizCode?: number }).qqBizCode = result.qqBizCode;
38
+ }
39
+ return err;
40
+ }
41
+
31
42
  export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
32
43
  id: "qqbot",
33
44
  meta: {
@@ -283,17 +294,12 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
283
294
  const result = await sendMedia({ to, text: text ?? "", mediaUrl: mediaUrl ?? "", accountId, replyToId, account });
284
295
  console.log(`[qqbot:channel] sendMedia result: messageId=${result.messageId}, error=${result.error ?? "none"}`);
285
296
  // 此 sendMedia 是框架 Channel Plugin 的标准出站接口,
286
- // 用于非 gateway deliver 场景(如 API 直接发送、cron 等)。
287
- // gateway 消息响应走的是 deliver 回调 sendPlainReply,不经过此处。
288
- // 框架拿到 error 后不一定会给用户发文字兜底,所以这里主动发一条。
297
+ // 由框架 deliver.js (deliverOutboundPayloads) message-actions 调用。
298
+ // throw Error 后,框架 pi-tool-definition-adapter 会将错误转化为
299
+ // tool 的 { status: "error" } 返回给 AI 模型,模型会自行生成错误回复给用户。
300
+ // 因此此处不应主动发送兜底文本,否则会与模型的回复重复。
289
301
  if (result.error) {
290
- try {
291
- const fallbackResult = await sendText({ to, text: result.error, accountId, replyToId, account });
292
- console.log(`[qqbot:channel] sendMedia fallback text sent: messageId=${fallbackResult.messageId}, error=${fallbackResult.error ?? "none"}`);
293
- } catch (fallbackErr) {
294
- console.error(`[qqbot:channel] sendMedia fallback text failed: ${fallbackErr}`);
295
- }
296
- throw new Error(result.error);
302
+ throw buildChannelMediaError(result);
297
303
  }
298
304
  return {
299
305
  channel: "qqbot" as const,
@@ -7,8 +7,20 @@
7
7
  */
8
8
 
9
9
  import type { ResolvedQQBotAccount } from "./types.js";
10
- import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
11
- import { sendPhoto, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
10
+ import {
11
+ sendC2CMessage,
12
+ sendGroupMessage,
13
+ sendChannelMessage,
14
+ sendC2CImageMessage,
15
+ sendGroupImageMessage,
16
+ } from "./api.js";
17
+ import {
18
+ sendPhoto,
19
+ sendMedia as sendMediaAuto,
20
+ DEFAULT_MEDIA_SEND_ERROR,
21
+ resolveUserFacingMediaError,
22
+ type MediaTargetContext,
23
+ } from "./outbound.js";
12
24
  import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
13
25
  import { getQQBotRuntime } from "./runtime.js";
14
26
  import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
@@ -217,13 +229,13 @@ export async function sendPlainReply(
217
229
  });
218
230
  if (result.error) {
219
231
  log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
220
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
232
+ await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
221
233
  } else {
222
234
  log?.info(`${prefix} Sent local media: ${mediaPath}`);
223
235
  }
224
236
  } catch (err) {
225
237
  log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
226
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
238
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
227
239
  }
228
240
  }
229
241
  }
@@ -245,13 +257,13 @@ export async function sendPlainReply(
245
257
  });
246
258
  if (result.error) {
247
259
  log?.error(`${prefix} Tool media forward error: ${result.error}`);
248
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
260
+ await sendTextChunks(resolveUserFacingMediaError(result), event, actx, sendWithRetry, consumeQuoteRef);
249
261
  } else {
250
262
  log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
251
263
  }
252
264
  } catch (err) {
253
265
  log?.error(`${prefix} Tool media forward failed: ${err}`);
254
- await sendTextChunks("发送失败,请稍后重试。", event, actx, sendWithRetry, consumeQuoteRef);
266
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
255
267
  }
256
268
  }
257
269
  }
@@ -261,6 +273,7 @@ export async function sendPlainReply(
261
273
 
262
274
  // ============ 内部辅助函数 ============
263
275
 
276
+
264
277
  /** 发送文本分块(共用逻辑) */
265
278
  async function sendTextChunks(
266
279
  text: string,
@@ -441,13 +454,13 @@ async function sendPlainTextReply(
441
454
  const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
442
455
  if (imgResult.error) {
443
456
  log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
444
- await sendTextChunks(`发送图片失败:${imgResult.error}`, event, actx, sendWithRetry, consumeQuoteRef);
457
+ await sendTextChunks(resolveUserFacingMediaError(imgResult), event, actx, sendWithRetry, consumeQuoteRef);
445
458
  } else {
446
459
  log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
447
460
  }
448
461
  } catch (imgErr) {
449
462
  log?.error(`${prefix} Failed to send image: ${imgErr}`);
450
- await sendTextChunks(`发送图片失败:${imgErr}`, event, actx, sendWithRetry, consumeQuoteRef);
463
+ await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
451
464
  }
452
465
  }
453
466
 
package/src/outbound.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  sendC2CMediaMessage,
18
18
  sendGroupMediaMessage,
19
19
  MediaFileType,
20
+ UPLOAD_PREPARE_FALLBACK_CODE,
20
21
  } from "./api.js";
21
22
  import { isAudioFile, audioFileToSilkFile, waitForFile, shouldTranscodeVoice } from "./utils/audio-convert.js";
22
23
  import { fileExistsAsync, formatFileSize, getMaxUploadSize, getFileTypeName, getFileSizeAsync } from "./utils/file-utils.js";
@@ -166,15 +167,48 @@ export interface MediaOutboundContext extends OutboundContext {
166
167
  mimeType?: string;
167
168
  }
168
169
 
170
+ export const OUTBOUND_ERROR_CODES = {
171
+ FILE_TOO_LARGE: "file_too_large",
172
+ UPLOAD_DAILY_LIMIT_EXCEEDED: "upload_daily_limit_exceeded",
173
+ } as const;
174
+
175
+ export const DEFAULT_MEDIA_SEND_ERROR = "发送失败,请稍后重试。";
176
+
177
+ export type OutboundErrorCode = typeof OUTBOUND_ERROR_CODES[keyof typeof OUTBOUND_ERROR_CODES];
178
+
169
179
  export interface OutboundResult {
170
180
  channel: string;
171
181
  messageId?: string;
172
182
  timestamp?: string | number;
173
183
  error?: string;
184
+ /** 稳定错误码,供上层按类型处理,避免依赖 error 文案 */
185
+ errorCode?: OutboundErrorCode;
186
+ /** QQ 开放平台业务错误码(如 upload_prepare 的 40093002) */
187
+ qqBizCode?: number;
174
188
  /** 出站消息的引用索引(ext_info.ref_idx),供引用消息缓存使用 */
175
189
  refIdx?: string;
176
190
  }
177
191
 
192
+ /**
193
+ * 将媒体发送结果映射为可展示给用户的文案。
194
+ * 只对明确标记为可直接展示的错误码透传原文,其余统一走通用兜底。
195
+ */
196
+ export function resolveUserFacingMediaError(result: Pick<OutboundResult, "error" | "errorCode" | "qqBizCode">): string {
197
+ if (!result.error) return DEFAULT_MEDIA_SEND_ERROR;
198
+
199
+ if (result.qqBizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
200
+ return result.error;
201
+ }
202
+
203
+ switch (result.errorCode) {
204
+ case OUTBOUND_ERROR_CODES.FILE_TOO_LARGE:
205
+ case OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED:
206
+ return result.error;
207
+ default:
208
+ return DEFAULT_MEDIA_SEND_ERROR;
209
+ }
210
+ }
211
+
178
212
  /**
179
213
  * 解析目标地址
180
214
  * 格式:
@@ -511,7 +545,11 @@ async function chunkedUploadAndSend(
511
545
  if (fileSize > maxSize) {
512
546
  const typeName = getFileTypeName(fileType);
513
547
  const limitMB = Math.round(maxSize / (1024 * 1024));
514
- return { channel: "qqbot", error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。` };
548
+ return {
549
+ channel: "qqbot",
550
+ error: `${typeName}过大(${formatFileSize(fileSize)}),超过了${limitMB}M,暂时不能通过QQ直接发给你。`,
551
+ errorCode: OUTBOUND_ERROR_CODES.FILE_TOO_LARGE,
552
+ };
515
553
  }
516
554
 
517
555
  if (ctx.targetType === "c2c") {
@@ -537,7 +575,12 @@ async function chunkedUploadAndSend(
537
575
  const dir = path.dirname(err.filePath);
538
576
  const name = path.basename(err.filePath);
539
577
  const size = formatFileSize(err.fileSize);
540
- return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
578
+ return {
579
+ channel: "qqbot",
580
+ error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
581
+ errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
582
+ qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
583
+ };
541
584
  }
542
585
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
543
586
  }
@@ -566,7 +609,12 @@ async function chunkedUploadAndSend(
566
609
  const dir = path.dirname(err.filePath);
567
610
  const name = path.basename(err.filePath);
568
611
  const size = formatFileSize(err.fileSize);
569
- return { channel: "qqbot", error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})` };
612
+ return {
613
+ channel: "qqbot",
614
+ error: `QQBot每天发送文件有累计2G的限制,如果着急的话,可以直接来我的主机copy下载,文件目录\`${dir}/${name}\`(${size})`,
615
+ errorCode: OUTBOUND_ERROR_CODES.UPLOAD_DAILY_LIMIT_EXCEEDED,
616
+ qqBizCode: UPLOAD_PREPARE_FALLBACK_CODE,
617
+ };
570
618
  }
571
619
  return { channel: "qqbot", error: `文件发送失败,请稍后重试。` };
572
620
  }
@@ -14,6 +14,8 @@ import {
14
14
  sendVideoMsg,
15
15
  sendDocument,
16
16
  sendMedia as sendMediaAuto,
17
+ DEFAULT_MEDIA_SEND_ERROR,
18
+ resolveUserFacingMediaError,
17
19
  type MediaTargetContext,
18
20
  } from "../outbound.js";
19
21
  import type { ResolvedQQBotAccount } from "../types.js";
@@ -410,7 +412,6 @@ export async function executeSendQueue(
410
412
  };
411
413
 
412
414
  for (const item of queue) {
413
- const FALLBACK_MSG = "发送失败,请稍后重试。";
414
415
  try {
415
416
  if (item.type === "text") {
416
417
  if (options.skipInterTagText) {
@@ -431,7 +432,7 @@ export async function executeSendQueue(
431
432
  const result = await sendPhoto(mediaTarget, item.content);
432
433
  if (result.error) {
433
434
  log?.error(`${prefix} sendPhoto error: ${result.error}`);
434
- await sendFallbackText(FALLBACK_MSG);
435
+ await sendFallbackText(resolveUserFacingMediaError(result));
435
436
  }
436
437
  } else if (item.type === "voice") {
437
438
  const uploadFormats =
@@ -452,23 +453,23 @@ export async function executeSendQueue(
452
453
  ]);
453
454
  if (result.error) {
454
455
  log?.error(`${prefix} sendVoice error: ${result.error}`);
455
- await sendFallbackText(FALLBACK_MSG);
456
+ await sendFallbackText(resolveUserFacingMediaError(result));
456
457
  }
457
458
  } catch (err) {
458
459
  log?.error(`${prefix} sendVoice unexpected error: ${err}`);
459
- await sendFallbackText(FALLBACK_MSG);
460
+ await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
460
461
  }
461
462
  } else if (item.type === "video") {
462
463
  const result = await sendVideoMsg(mediaTarget, item.content);
463
464
  if (result.error) {
464
465
  log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
465
- await sendFallbackText(FALLBACK_MSG);
466
+ await sendFallbackText(resolveUserFacingMediaError(result));
466
467
  }
467
468
  } else if (item.type === "file") {
468
469
  const result = await sendDocument(mediaTarget, item.content);
469
470
  if (result.error) {
470
471
  log?.error(`${prefix} sendDocument error: ${result.error}`);
471
- await sendFallbackText(FALLBACK_MSG);
472
+ await sendFallbackText(resolveUserFacingMediaError(result));
472
473
  }
473
474
  } else if (item.type === "media") {
474
475
  const result = await sendMediaAuto({
@@ -481,12 +482,12 @@ export async function executeSendQueue(
481
482
  });
482
483
  if (result.error) {
483
484
  log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
484
- await sendFallbackText(FALLBACK_MSG);
485
+ await sendFallbackText(resolveUserFacingMediaError(result));
485
486
  }
486
487
  }
487
488
  } catch (err) {
488
489
  log?.error(`${prefix} executeSendQueue: failed to send ${item.type}: ${err}`);
489
- await sendFallbackText(FALLBACK_MSG);
490
+ await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
490
491
  }
491
492
  }
492
493
  }