@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 +0 -5
- package/dist/src/channel.js +16 -12
- package/dist/src/outbound-deliver.js +8 -8
- package/dist/src/outbound.d.ts +15 -0
- package/dist/src/outbound.js +41 -4
- package/dist/src/utils/media-send.js +8 -9
- package/package.json +2 -2
- package/src/api.ts +0 -4
- package/src/channel.ts +17 -11
- package/src/outbound-deliver.ts +21 -8
- package/src/outbound.ts +51 -3
- package/src/utils/media-send.ts +9 -8
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
|
}
|
package/dist/src/channel.js
CHANGED
|
@@ -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
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
367
|
+
await sendTextChunks(DEFAULT_MEDIA_SEND_ERROR, event, actx, sendWithRetry, consumeQuoteRef);
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
if (result.trim()) {
|
package/dist/src/outbound.d.ts
CHANGED
|
@@ -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
|
/** 目标类型 */
|
package/dist/src/outbound.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
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
|
-
|
|
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,
|
package/src/outbound-deliver.ts
CHANGED
|
@@ -7,8 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
-
import {
|
|
11
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
}
|
package/src/utils/media-send.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
456
|
+
await sendFallbackText(resolveUserFacingMediaError(result));
|
|
456
457
|
}
|
|
457
458
|
} catch (err) {
|
|
458
459
|
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
|
459
|
-
await sendFallbackText(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
490
|
+
await sendFallbackText(DEFAULT_MEDIA_SEND_ERROR);
|
|
490
491
|
}
|
|
491
492
|
}
|
|
492
493
|
}
|