@ryantest/openclaw-qqbot 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +483 -0
- package/README.zh.md +478 -0
- package/bin/qqbot-cli.js +243 -0
- package/clawdbot.plugin.json +16 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +26 -0
- package/dist/src/admin-resolver.d.ts +27 -0
- package/dist/src/admin-resolver.js +122 -0
- package/dist/src/api.d.ts +156 -0
- package/dist/src/api.js +599 -0
- package/dist/src/channel.d.ts +11 -0
- package/dist/src/channel.js +354 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +161 -0
- package/dist/src/credential-backup.d.ts +31 -0
- package/dist/src/credential-backup.js +66 -0
- package/dist/src/gateway.d.ts +18 -0
- package/dist/src/gateway.js +1265 -0
- package/dist/src/image-server.d.ts +68 -0
- package/dist/src/image-server.js +462 -0
- package/dist/src/inbound-attachments.d.ts +58 -0
- package/dist/src/inbound-attachments.js +234 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +263 -0
- package/dist/src/message-queue.d.ts +50 -0
- package/dist/src/message-queue.js +115 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +203 -0
- package/dist/src/outbound-deliver.d.ts +48 -0
- package/dist/src/outbound-deliver.js +462 -0
- package/dist/src/outbound.d.ts +203 -0
- package/dist/src/outbound.js +1102 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +399 -0
- package/dist/src/ref-index-store.d.ts +70 -0
- package/dist/src/ref-index-store.js +273 -0
- package/dist/src/reply-dispatcher.d.ts +35 -0
- package/dist/src/reply-dispatcher.js +311 -0
- package/dist/src/runtime.d.ts +3 -0
- package/dist/src/runtime.js +10 -0
- package/dist/src/session-store.d.ts +52 -0
- package/dist/src/session-store.js +254 -0
- package/dist/src/slash-commands.d.ts +71 -0
- package/dist/src/slash-commands.js +1179 -0
- package/dist/src/startup-greeting.d.ts +30 -0
- package/dist/src/startup-greeting.js +78 -0
- package/dist/src/stt.d.ts +21 -0
- package/dist/src/stt.js +70 -0
- package/dist/src/tools/channel.d.ts +16 -0
- package/dist/src/tools/channel.js +234 -0
- package/dist/src/tools/remind.d.ts +2 -0
- package/dist/src/tools/remind.js +247 -0
- package/dist/src/types.d.ts +175 -0
- package/dist/src/types.js +1 -0
- package/dist/src/typing-keepalive.d.ts +27 -0
- package/dist/src/typing-keepalive.js +64 -0
- package/dist/src/update-checker.d.ts +34 -0
- package/dist/src/update-checker.js +166 -0
- package/dist/src/user-messages.d.ts +8 -0
- package/dist/src/user-messages.js +8 -0
- package/dist/src/utils/audio-convert.d.ts +89 -0
- package/dist/src/utils/audio-convert.js +704 -0
- package/dist/src/utils/file-utils.d.ts +55 -0
- package/dist/src/utils/file-utils.js +150 -0
- package/dist/src/utils/image-size.d.ts +51 -0
- package/dist/src/utils/image-size.js +234 -0
- package/dist/src/utils/media-tags.d.ts +14 -0
- package/dist/src/utils/media-tags.js +164 -0
- package/dist/src/utils/payload.d.ts +112 -0
- package/dist/src/utils/payload.js +186 -0
- package/dist/src/utils/platform.d.ts +137 -0
- package/dist/src/utils/platform.js +390 -0
- package/dist/src/utils/text-parsing.d.ts +32 -0
- package/dist/src/utils/text-parsing.js +80 -0
- package/dist/src/utils/upload-cache.d.ts +34 -0
- package/dist/src/utils/upload-cache.js +93 -0
- package/index.ts +31 -0
- package/moltbot.plugin.json +16 -0
- package/node_modules/@eshaz/web-worker/LICENSE +201 -0
- package/node_modules/@eshaz/web-worker/README.md +134 -0
- package/node_modules/@eshaz/web-worker/browser.js +17 -0
- package/node_modules/@eshaz/web-worker/cjs/browser.js +16 -0
- package/node_modules/@eshaz/web-worker/cjs/node.js +219 -0
- package/node_modules/@eshaz/web-worker/index.d.ts +4 -0
- package/node_modules/@eshaz/web-worker/node.js +223 -0
- package/node_modules/@eshaz/web-worker/package.json +54 -0
- package/node_modules/@wasm-audio-decoders/common/index.js +5 -0
- package/node_modules/@wasm-audio-decoders/common/package.json +36 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderCommon.js +231 -0
- package/node_modules/@wasm-audio-decoders/common/src/WASMAudioDecoderWorker.js +129 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/README +67 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/build_puff.js +31 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.c +863 -0
- package/node_modules/@wasm-audio-decoders/common/src/puff/puff.h +35 -0
- package/node_modules/@wasm-audio-decoders/common/src/utilities.js +3 -0
- package/node_modules/@wasm-audio-decoders/common/types.d.ts +7 -0
- package/node_modules/mpg123-decoder/README.md +265 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js +185 -0
- package/node_modules/mpg123-decoder/dist/mpg123-decoder.min.js.map +1 -0
- package/node_modules/mpg123-decoder/index.js +8 -0
- package/node_modules/mpg123-decoder/package.json +58 -0
- package/node_modules/mpg123-decoder/src/EmscriptenWasm.js +464 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoder.js +200 -0
- package/node_modules/mpg123-decoder/src/MPEGDecoderWebWorker.js +21 -0
- package/node_modules/mpg123-decoder/types.d.ts +30 -0
- package/node_modules/silk-wasm/LICENSE +21 -0
- package/node_modules/silk-wasm/README.md +85 -0
- package/node_modules/silk-wasm/lib/index.cjs +16 -0
- package/node_modules/silk-wasm/lib/index.d.ts +70 -0
- package/node_modules/silk-wasm/lib/index.mjs +16 -0
- package/node_modules/silk-wasm/lib/silk.wasm +0 -0
- package/node_modules/silk-wasm/lib/utils.d.ts +4 -0
- package/node_modules/silk-wasm/package.json +39 -0
- package/node_modules/simple-yenc/.github/FUNDING.yml +1 -0
- package/node_modules/simple-yenc/.prettierignore +1 -0
- package/node_modules/simple-yenc/LICENSE +7 -0
- package/node_modules/simple-yenc/README.md +163 -0
- package/node_modules/simple-yenc/dist/esm.js +1 -0
- package/node_modules/simple-yenc/dist/index.js +1 -0
- package/node_modules/simple-yenc/package.json +50 -0
- package/node_modules/simple-yenc/rollup.config.js +27 -0
- package/node_modules/simple-yenc/src/simple-yenc.js +302 -0
- package/node_modules/ws/LICENSE +20 -0
- package/node_modules/ws/README.md +548 -0
- package/node_modules/ws/browser.js +8 -0
- package/node_modules/ws/index.js +13 -0
- package/node_modules/ws/lib/buffer-util.js +131 -0
- package/node_modules/ws/lib/constants.js +19 -0
- package/node_modules/ws/lib/event-target.js +292 -0
- package/node_modules/ws/lib/extension.js +203 -0
- package/node_modules/ws/lib/limiter.js +55 -0
- package/node_modules/ws/lib/permessage-deflate.js +528 -0
- package/node_modules/ws/lib/receiver.js +706 -0
- package/node_modules/ws/lib/sender.js +602 -0
- package/node_modules/ws/lib/stream.js +161 -0
- package/node_modules/ws/lib/subprotocol.js +62 -0
- package/node_modules/ws/lib/validation.js +152 -0
- package/node_modules/ws/lib/websocket-server.js +554 -0
- package/node_modules/ws/lib/websocket.js +1393 -0
- package/node_modules/ws/package.json +69 -0
- package/node_modules/ws/wrapper.mjs +8 -0
- package/openclaw.plugin.json +16 -0
- package/package.json +76 -0
- package/scripts/cleanup-legacy-plugins.sh +124 -0
- package/scripts/proactive-api-server.ts +369 -0
- package/scripts/send-proactive.ts +293 -0
- package/scripts/set-markdown.sh +156 -0
- package/scripts/test-sendmedia.ts +116 -0
- package/scripts/upgrade-via-alt-pkg.sh +307 -0
- package/scripts/upgrade-via-npm.ps1 +296 -0
- package/scripts/upgrade-via-npm.sh +301 -0
- package/scripts/upgrade-via-source.sh +774 -0
- package/skills/qqbot-channel/SKILL.md +263 -0
- package/skills/qqbot-channel/references/api_references.md +521 -0
- package/skills/qqbot-media/SKILL.md +56 -0
- package/skills/qqbot-remind/SKILL.md +149 -0
- package/src/admin-resolver.ts +140 -0
- package/src/api.ts +819 -0
- package/src/bot-logs-2026-03-21T11-21-47(2).txt +46 -0
- package/src/channel.ts +381 -0
- package/src/config.ts +187 -0
- package/src/credential-backup.ts +72 -0
- package/src/gateway.log +43 -0
- package/src/gateway.ts +1404 -0
- package/src/image-server.ts +539 -0
- package/src/inbound-attachments.ts +304 -0
- package/src/known-users.ts +353 -0
- package/src/message-queue.ts +169 -0
- package/src/onboarding.ts +274 -0
- package/src/openclaw-2026-03-21.log +3729 -0
- package/src/openclaw-plugin-sdk.d.ts +522 -0
- package/src/outbound-deliver.ts +552 -0
- package/src/outbound.ts +1266 -0
- package/src/proactive.ts +530 -0
- package/src/ref-index-store.ts +357 -0
- package/src/reply-dispatcher.ts +334 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +303 -0
- package/src/slash-commands.ts +1305 -0
- package/src/startup-greeting.ts +98 -0
- package/src/stt.ts +86 -0
- package/src/tools/channel.ts +281 -0
- package/src/tools/remind.ts +296 -0
- package/src/types.ts +183 -0
- package/src/typing-keepalive.ts +59 -0
- package/src/update-checker.ts +179 -0
- package/src/user-messages.ts +7 -0
- package/src/utils/audio-convert.ts +803 -0
- package/src/utils/file-utils.ts +167 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/media-tags.ts +182 -0
- package/src/utils/payload.ts +265 -0
- package/src/utils/platform.ts +435 -0
- package/src/utils/text-parsing.ts +82 -0
- package/src/utils/upload-cache.ts +128 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 出站消息投递模块
|
|
3
|
+
*
|
|
4
|
+
* 从 gateway deliver 回调中提取的两大发送管线:
|
|
5
|
+
* 1. parseAndSendMediaTags — 解析 <qqimg/qqvoice/qqvideo/qqfile/qqmedia> 标签并按顺序发送
|
|
6
|
+
* 2. sendPlainReply — 处理不含媒体标签的普通回复(markdown 图片/纯文本+图片)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ResolvedQQBotAccount } from "./types.js";
|
|
10
|
+
import { sendC2CMessage, sendGroupMessage, sendChannelMessage, sendC2CImageMessage, sendGroupImageMessage } from "./api.js";
|
|
11
|
+
import { sendPhoto, sendVoice, sendVideoMsg, sendDocument, sendMedia as sendMediaAuto, type MediaTargetContext } from "./outbound.js";
|
|
12
|
+
import { chunkText, TEXT_CHUNK_LIMIT } from "./channel.js";
|
|
13
|
+
import { getQQBotRuntime } from "./runtime.js";
|
|
14
|
+
import { getImageSize, formatQQBotMarkdownImage, hasQQBotImageSize } from "./utils/image-size.js";
|
|
15
|
+
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
16
|
+
import { normalizePath, isLocalPath as isLocalFilePath } from "./utils/platform.js";
|
|
17
|
+
import { filterInternalMarkers } from "./utils/text-parsing.js";
|
|
18
|
+
|
|
19
|
+
// ============ 类型定义 ============
|
|
20
|
+
|
|
21
|
+
export interface DeliverEventContext {
|
|
22
|
+
type: "c2c" | "guild" | "dm" | "group";
|
|
23
|
+
senderId: string;
|
|
24
|
+
messageId: string;
|
|
25
|
+
channelId?: string;
|
|
26
|
+
groupOpenid?: string;
|
|
27
|
+
msgIdx?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DeliverAccountContext {
|
|
31
|
+
account: ResolvedQQBotAccount;
|
|
32
|
+
qualifiedTarget: string;
|
|
33
|
+
log?: {
|
|
34
|
+
info: (msg: string) => void;
|
|
35
|
+
error: (msg: string) => void;
|
|
36
|
+
debug?: (msg: string) => void;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** token 重试包装 */
|
|
41
|
+
export type SendWithRetryFn = <T>(sendFn: (token: string) => Promise<T>) => Promise<T>;
|
|
42
|
+
|
|
43
|
+
/** 一次性消费引用 ref */
|
|
44
|
+
export type ConsumeQuoteRefFn = () => string | undefined;
|
|
45
|
+
|
|
46
|
+
// ============ 1. 媒体标签解析 + 发送 ============
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 解析回复文本中的媒体标签并按顺序发送。
|
|
50
|
+
*
|
|
51
|
+
* @returns true 如果检测到媒体标签并已处理;false 表示无媒体标签,调用方继续走普通文本管线
|
|
52
|
+
*/
|
|
53
|
+
export async function parseAndSendMediaTags(
|
|
54
|
+
replyText: string,
|
|
55
|
+
event: DeliverEventContext,
|
|
56
|
+
actx: DeliverAccountContext,
|
|
57
|
+
sendWithRetry: SendWithRetryFn,
|
|
58
|
+
consumeQuoteRef: ConsumeQuoteRefFn,
|
|
59
|
+
): Promise<{ handled: boolean; normalizedText: string }> {
|
|
60
|
+
const { account, log } = actx;
|
|
61
|
+
const prefix = `[qqbot:${account.accountId}]`;
|
|
62
|
+
|
|
63
|
+
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
|
64
|
+
const text = normalizeMediaTags(replyText);
|
|
65
|
+
|
|
66
|
+
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
67
|
+
const mediaTagMatches = [...text.matchAll(mediaTagRegex)];
|
|
68
|
+
|
|
69
|
+
if (mediaTagMatches.length === 0) {
|
|
70
|
+
return { handled: false, normalizedText: text };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tagCounts = mediaTagMatches.reduce((acc, m) => { const t = m[1]!.toLowerCase(); acc[t] = (acc[t] ?? 0) + 1; return acc; }, {} as Record<string, number>);
|
|
74
|
+
log?.info(`${prefix} Detected media tags: ${Object.entries(tagCounts).map(([k, v]) => `${v} <${k}>`).join(", ")}`);
|
|
75
|
+
|
|
76
|
+
// 构建发送队列
|
|
77
|
+
type QueueItem = { type: "text" | "image" | "voice" | "video" | "file" | "media"; content: string };
|
|
78
|
+
const sendQueue: QueueItem[] = [];
|
|
79
|
+
|
|
80
|
+
let lastIndex = 0;
|
|
81
|
+
const regex2 = /<(qqimg|qqvoice|qqvideo|qqfile|qqmedia)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|qqmedia|img)>/gi;
|
|
82
|
+
let match;
|
|
83
|
+
|
|
84
|
+
while ((match = regex2.exec(text)) !== null) {
|
|
85
|
+
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
86
|
+
if (textBefore) {
|
|
87
|
+
sendQueue.push({ type: "text", content: filterInternalMarkers(textBefore) });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const tagName = match[1]!.toLowerCase();
|
|
91
|
+
let mediaPath = decodeMediaPath(match[2]?.trim() ?? "", log, prefix);
|
|
92
|
+
|
|
93
|
+
if (mediaPath) {
|
|
94
|
+
const typeMap: Record<string, QueueItem["type"]> = {
|
|
95
|
+
qqmedia: "media", qqvoice: "voice", qqvideo: "video", qqfile: "file",
|
|
96
|
+
};
|
|
97
|
+
const itemType = typeMap[tagName] ?? "image";
|
|
98
|
+
sendQueue.push({ type: itemType, content: mediaPath });
|
|
99
|
+
log?.info(`${prefix} Found ${itemType} in <${tagName}>: ${mediaPath}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
lastIndex = match.index + match[0].length;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
106
|
+
if (textAfter) {
|
|
107
|
+
sendQueue.push({ type: "text", content: filterInternalMarkers(textAfter) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
log?.info(`${prefix} Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
111
|
+
|
|
112
|
+
// 按顺序发送
|
|
113
|
+
const mediaTarget: MediaTargetContext = {
|
|
114
|
+
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
115
|
+
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
|
|
116
|
+
account,
|
|
117
|
+
replyToId: event.messageId,
|
|
118
|
+
logPrefix: prefix,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (const item of sendQueue) {
|
|
122
|
+
if (item.type === "text") {
|
|
123
|
+
await sendTextChunks(item.content, event, actx, sendWithRetry, consumeQuoteRef);
|
|
124
|
+
} else if (item.type === "image") {
|
|
125
|
+
const result = await sendPhoto(mediaTarget, item.content);
|
|
126
|
+
if (result.error) log?.error(`${prefix} sendPhoto error: ${result.error}`);
|
|
127
|
+
} else if (item.type === "voice") {
|
|
128
|
+
await sendVoiceWithTimeout(mediaTarget, item.content, account, log, prefix);
|
|
129
|
+
} else if (item.type === "video") {
|
|
130
|
+
const result = await sendVideoMsg(mediaTarget, item.content);
|
|
131
|
+
if (result.error) log?.error(`${prefix} sendVideoMsg error: ${result.error}`);
|
|
132
|
+
} else if (item.type === "file") {
|
|
133
|
+
const result = await sendDocument(mediaTarget, item.content);
|
|
134
|
+
if (result.error) log?.error(`${prefix} sendDocument error: ${result.error}`);
|
|
135
|
+
} else if (item.type === "media") {
|
|
136
|
+
const result = await sendMediaAuto({
|
|
137
|
+
to: actx.qualifiedTarget,
|
|
138
|
+
text: "",
|
|
139
|
+
mediaUrl: item.content,
|
|
140
|
+
accountId: account.accountId,
|
|
141
|
+
replyToId: event.messageId,
|
|
142
|
+
account,
|
|
143
|
+
});
|
|
144
|
+
if (result.error) log?.error(`${prefix} sendMedia(auto) error: ${result.error}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { handled: true, normalizedText: text };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ============ 2. 非结构化消息发送(普通文本 + 图片) ============
|
|
152
|
+
|
|
153
|
+
export interface PlainReplyPayload {
|
|
154
|
+
text?: string;
|
|
155
|
+
mediaUrls?: string[];
|
|
156
|
+
mediaUrl?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 发送不含媒体标签的普通回复。
|
|
161
|
+
* 处理 markdown 图片嵌入、Base64 富媒体、纯文本分块、本地媒体自动路由。
|
|
162
|
+
*/
|
|
163
|
+
export async function sendPlainReply(
|
|
164
|
+
payload: PlainReplyPayload,
|
|
165
|
+
replyText: string,
|
|
166
|
+
event: DeliverEventContext,
|
|
167
|
+
actx: DeliverAccountContext,
|
|
168
|
+
sendWithRetry: SendWithRetryFn,
|
|
169
|
+
consumeQuoteRef: ConsumeQuoteRefFn,
|
|
170
|
+
toolMediaUrls: string[],
|
|
171
|
+
): Promise<void> {
|
|
172
|
+
const { account, qualifiedTarget, log } = actx;
|
|
173
|
+
const prefix = `[qqbot:${account.accountId}]`;
|
|
174
|
+
|
|
175
|
+
const collectedImageUrls: string[] = [];
|
|
176
|
+
const localMediaToSend: string[] = [];
|
|
177
|
+
|
|
178
|
+
const collectImageUrl = (url: string | undefined | null): boolean => {
|
|
179
|
+
if (!url) return false;
|
|
180
|
+
const isHttpUrl = url.startsWith("http://") || url.startsWith("https://");
|
|
181
|
+
const isDataUrl = url.startsWith("data:image/");
|
|
182
|
+
if (isHttpUrl || isDataUrl) {
|
|
183
|
+
if (!collectedImageUrls.includes(url)) {
|
|
184
|
+
collectedImageUrls.push(url);
|
|
185
|
+
log?.info(`${prefix} Collected ${isDataUrl ? "Base64" : "media URL"}: ${isDataUrl ? `(length: ${url.length})` : url.slice(0, 80) + "..."}`);
|
|
186
|
+
}
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
if (isLocalFilePath(url)) {
|
|
190
|
+
if (!localMediaToSend.includes(url)) {
|
|
191
|
+
localMediaToSend.push(url);
|
|
192
|
+
log?.info(`${prefix} Collected local media for auto-routing: ${url}`);
|
|
193
|
+
}
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
return false;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (payload.mediaUrls?.length) {
|
|
200
|
+
for (const url of payload.mediaUrls) collectImageUrl(url);
|
|
201
|
+
}
|
|
202
|
+
if (payload.mediaUrl) collectImageUrl(payload.mediaUrl);
|
|
203
|
+
|
|
204
|
+
// 提取 markdown 图片
|
|
205
|
+
const mdImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/gi;
|
|
206
|
+
const mdMatches = [...replyText.matchAll(mdImageRegex)];
|
|
207
|
+
for (const m of mdMatches) {
|
|
208
|
+
const url = m[2]?.trim();
|
|
209
|
+
if (url && !collectedImageUrls.includes(url)) {
|
|
210
|
+
if (url.startsWith("http://") || url.startsWith("https://")) {
|
|
211
|
+
collectedImageUrls.push(url);
|
|
212
|
+
log?.info(`${prefix} Extracted HTTP image from markdown: ${url.slice(0, 80)}...`);
|
|
213
|
+
} else if (isLocalFilePath(url)) {
|
|
214
|
+
if (!localMediaToSend.includes(url)) {
|
|
215
|
+
localMediaToSend.push(url);
|
|
216
|
+
log?.info(`${prefix} Collected local media from markdown for auto-routing: ${url}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 提取裸 URL 图片
|
|
223
|
+
const bareUrlRegex = /(?<![(\["'])(https?:\/\/[^\s)"'<>]+\.(?:png|jpg|jpeg|gif|webp)(?:\?[^\s"'<>]*)?)/gi;
|
|
224
|
+
const bareUrlMatches = [...replyText.matchAll(bareUrlRegex)];
|
|
225
|
+
for (const m of bareUrlMatches) {
|
|
226
|
+
const url = m[1];
|
|
227
|
+
if (url && !collectedImageUrls.includes(url)) {
|
|
228
|
+
collectedImageUrls.push(url);
|
|
229
|
+
log?.info(`${prefix} Extracted bare image URL: ${url.slice(0, 80)}...`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const useMarkdown = account.markdownSupport === true;
|
|
234
|
+
log?.info(`${prefix} Markdown mode: ${useMarkdown}, images: ${collectedImageUrls.length}`);
|
|
235
|
+
|
|
236
|
+
let textWithoutImages = filterInternalMarkers(replyText);
|
|
237
|
+
|
|
238
|
+
if (useMarkdown) {
|
|
239
|
+
await sendMarkdownReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef);
|
|
240
|
+
} else {
|
|
241
|
+
await sendPlainTextReply(textWithoutImages, collectedImageUrls, mdMatches, bareUrlMatches, event, actx, sendWithRetry, consumeQuoteRef);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 发送本地媒体(由 payload.mediaUrl 或 markdown 本地路径触发)
|
|
245
|
+
if (localMediaToSend.length > 0) {
|
|
246
|
+
log?.info(`${prefix} Sending ${localMediaToSend.length} local media via sendMedia auto-routing`);
|
|
247
|
+
for (const mediaPath of localMediaToSend) {
|
|
248
|
+
try {
|
|
249
|
+
const result = await sendMediaAuto({
|
|
250
|
+
to: qualifiedTarget, text: "", mediaUrl: mediaPath,
|
|
251
|
+
accountId: account.accountId, replyToId: event.messageId, account,
|
|
252
|
+
});
|
|
253
|
+
if (result.error) log?.error(`${prefix} sendMedia(auto) error for ${mediaPath}: ${result.error}`);
|
|
254
|
+
else log?.info(`${prefix} Sent local media: ${mediaPath}`);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
log?.error(`${prefix} sendMedia(auto) failed for ${mediaPath}: ${err}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 转发 tool 阶段收集的媒体
|
|
262
|
+
if (toolMediaUrls.length > 0) {
|
|
263
|
+
log?.info(`${prefix} Forwarding ${toolMediaUrls.length} tool-collected media URL(s) after block deliver`);
|
|
264
|
+
for (const mediaUrl of toolMediaUrls) {
|
|
265
|
+
try {
|
|
266
|
+
const result = await sendMediaAuto({
|
|
267
|
+
to: qualifiedTarget, text: "", mediaUrl,
|
|
268
|
+
accountId: account.accountId, replyToId: event.messageId, account,
|
|
269
|
+
});
|
|
270
|
+
if (result.error) log?.error(`${prefix} Tool media forward error: ${result.error}`);
|
|
271
|
+
else log?.info(`${prefix} Forwarded tool media: ${mediaUrl.slice(0, 80)}...`);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
log?.error(`${prefix} Tool media forward failed: ${err}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
toolMediaUrls.length = 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ============ 内部辅助函数 ============
|
|
281
|
+
|
|
282
|
+
/** 解码媒体路径:剥离 MEDIA: 前缀、展开 ~、修复转义 */
|
|
283
|
+
function decodeMediaPath(raw: string, log: DeliverAccountContext["log"], prefix: string): string {
|
|
284
|
+
let mediaPath = raw;
|
|
285
|
+
if (mediaPath.startsWith("MEDIA:")) {
|
|
286
|
+
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
287
|
+
}
|
|
288
|
+
mediaPath = normalizePath(mediaPath);
|
|
289
|
+
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
293
|
+
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
294
|
+
|
|
295
|
+
if (hasOctal || hasNonASCII) {
|
|
296
|
+
log?.debug?.(`${prefix} Decoding path with mixed encoding: ${mediaPath}`);
|
|
297
|
+
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_: string, octal: string) => {
|
|
298
|
+
return String.fromCharCode(parseInt(octal, 8));
|
|
299
|
+
});
|
|
300
|
+
const bytes: number[] = [];
|
|
301
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
302
|
+
const code = decoded.charCodeAt(i);
|
|
303
|
+
if (code <= 0xFF) {
|
|
304
|
+
bytes.push(code);
|
|
305
|
+
} else {
|
|
306
|
+
const charBytes = Buffer.from(decoded[i], "utf8");
|
|
307
|
+
bytes.push(...charBytes);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const buffer = Buffer.from(bytes);
|
|
311
|
+
const utf8Decoded = buffer.toString("utf8");
|
|
312
|
+
if (!utf8Decoded.includes("\uFFFD") || utf8Decoded.length < decoded.length) {
|
|
313
|
+
mediaPath = utf8Decoded;
|
|
314
|
+
log?.debug?.(`${prefix} Successfully decoded path: ${mediaPath}`);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
} catch (decodeErr) {
|
|
318
|
+
log?.error(`${prefix} Path decode error: ${decodeErr}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
return mediaPath;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 发送文本分块(共用逻辑) */
|
|
325
|
+
async function sendTextChunks(
|
|
326
|
+
text: string,
|
|
327
|
+
event: DeliverEventContext,
|
|
328
|
+
actx: DeliverAccountContext,
|
|
329
|
+
sendWithRetry: SendWithRetryFn,
|
|
330
|
+
consumeQuoteRef: ConsumeQuoteRefFn,
|
|
331
|
+
): Promise<void> {
|
|
332
|
+
const { account, log } = actx;
|
|
333
|
+
const prefix = `[qqbot:${account.accountId}]`;
|
|
334
|
+
const chunks = getQQBotRuntime().channel.text.chunkMarkdownText(text, TEXT_CHUNK_LIMIT);
|
|
335
|
+
for (const chunk of chunks) {
|
|
336
|
+
try {
|
|
337
|
+
await sendWithRetry(async (token) => {
|
|
338
|
+
const ref = consumeQuoteRef();
|
|
339
|
+
if (event.type === "c2c") {
|
|
340
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
341
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
342
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
343
|
+
} else if (event.channelId) {
|
|
344
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
log?.info(`${prefix} Sent text chunk (${chunk.length}/${text.length} chars): ${chunk.slice(0, 50)}...`);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
log?.error(`${prefix} Failed to send text chunk: ${err}`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** 语音发送(带 45s 超时保护) */
|
|
355
|
+
async function sendVoiceWithTimeout(
|
|
356
|
+
target: MediaTargetContext,
|
|
357
|
+
voicePath: string,
|
|
358
|
+
account: ResolvedQQBotAccount,
|
|
359
|
+
log: DeliverAccountContext["log"],
|
|
360
|
+
prefix: string,
|
|
361
|
+
): Promise<void> {
|
|
362
|
+
const uploadFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
363
|
+
const transcodeEnabled = account.config?.audioFormatPolicy?.transcodeEnabled !== false;
|
|
364
|
+
const voiceTimeout = 45000;
|
|
365
|
+
try {
|
|
366
|
+
const result = await Promise.race([
|
|
367
|
+
sendVoice(target, voicePath, uploadFormats, transcodeEnabled),
|
|
368
|
+
new Promise<{ channel: string; error: string }>((resolve) =>
|
|
369
|
+
setTimeout(() => resolve({ channel: "qqbot", error: "语音发送超时,已跳过" }), voiceTimeout),
|
|
370
|
+
),
|
|
371
|
+
]);
|
|
372
|
+
if (result.error) log?.error(`${prefix} sendVoice error: ${result.error}`);
|
|
373
|
+
} catch (err) {
|
|
374
|
+
log?.error(`${prefix} sendVoice unexpected error: ${err}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Markdown 模式发送 */
|
|
379
|
+
async function sendMarkdownReply(
|
|
380
|
+
textWithoutImages: string,
|
|
381
|
+
imageUrls: string[],
|
|
382
|
+
mdMatches: RegExpMatchArray[],
|
|
383
|
+
bareUrlMatches: RegExpMatchArray[],
|
|
384
|
+
event: DeliverEventContext,
|
|
385
|
+
actx: DeliverAccountContext,
|
|
386
|
+
sendWithRetry: SendWithRetryFn,
|
|
387
|
+
consumeQuoteRef: ConsumeQuoteRefFn,
|
|
388
|
+
): Promise<void> {
|
|
389
|
+
const { account, log } = actx;
|
|
390
|
+
const prefix = `[qqbot:${account.accountId}]`;
|
|
391
|
+
|
|
392
|
+
// 分离图片:公网 URL vs Base64
|
|
393
|
+
const httpImageUrls: string[] = [];
|
|
394
|
+
const base64ImageUrls: string[] = [];
|
|
395
|
+
for (const url of imageUrls) {
|
|
396
|
+
if (url.startsWith("data:image/")) base64ImageUrls.push(url);
|
|
397
|
+
else if (url.startsWith("http://") || url.startsWith("https://")) httpImageUrls.push(url);
|
|
398
|
+
}
|
|
399
|
+
log?.info(`${prefix} Image classification: httpUrls=${httpImageUrls.length}, base64=${base64ImageUrls.length}`);
|
|
400
|
+
|
|
401
|
+
// 发送 Base64 图片
|
|
402
|
+
if (base64ImageUrls.length > 0) {
|
|
403
|
+
log?.info(`${prefix} Sending ${base64ImageUrls.length} image(s) via Rich Media API...`);
|
|
404
|
+
for (const imageUrl of base64ImageUrls) {
|
|
405
|
+
try {
|
|
406
|
+
await sendWithRetry(async (token) => {
|
|
407
|
+
if (event.type === "c2c") {
|
|
408
|
+
await sendC2CImageMessage(token, event.senderId, imageUrl, event.messageId);
|
|
409
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
410
|
+
await sendGroupImageMessage(token, event.groupOpenid, imageUrl, event.messageId);
|
|
411
|
+
} else if (event.channelId) {
|
|
412
|
+
log?.info(`${prefix} Channel does not support rich media, skipping Base64 image`);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
log?.info(`${prefix} Sent Base64 image via Rich Media API (size: ${imageUrl.length} chars)`);
|
|
416
|
+
} catch (imgErr) {
|
|
417
|
+
log?.error(`${prefix} Failed to send Base64 image via Rich Media API: ${imgErr}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// 处理公网 URL 图片
|
|
423
|
+
const existingMdUrls = new Set(mdMatches.map((m) => m[2]));
|
|
424
|
+
const imagesToAppend: string[] = [];
|
|
425
|
+
|
|
426
|
+
for (const url of httpImageUrls) {
|
|
427
|
+
if (!existingMdUrls.has(url)) {
|
|
428
|
+
try {
|
|
429
|
+
const size = await getImageSize(url);
|
|
430
|
+
imagesToAppend.push(formatQQBotMarkdownImage(url, size));
|
|
431
|
+
log?.info(`${prefix} Formatted HTTP image: ${size ? `${size.width}x${size.height}` : "default size"} - ${url.slice(0, 60)}...`);
|
|
432
|
+
} catch (err) {
|
|
433
|
+
log?.info(`${prefix} Failed to get image size, using default: ${err}`);
|
|
434
|
+
imagesToAppend.push(formatQQBotMarkdownImage(url, null));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 补充已有 markdown 图片的尺寸信息
|
|
440
|
+
let result = textWithoutImages;
|
|
441
|
+
for (const m of mdMatches) {
|
|
442
|
+
const fullMatch = m[0];
|
|
443
|
+
const imgUrl = m[2];
|
|
444
|
+
const isHttpUrl = imgUrl.startsWith("http://") || imgUrl.startsWith("https://");
|
|
445
|
+
if (isHttpUrl && !hasQQBotImageSize(fullMatch)) {
|
|
446
|
+
try {
|
|
447
|
+
const size = await getImageSize(imgUrl);
|
|
448
|
+
result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, size));
|
|
449
|
+
log?.info(`${prefix} Updated image with size: ${size ? `${size.width}x${size.height}` : "default"} - ${imgUrl.slice(0, 60)}...`);
|
|
450
|
+
} catch (err) {
|
|
451
|
+
log?.info(`${prefix} Failed to get image size for existing md, using default: ${err}`);
|
|
452
|
+
result = result.replace(fullMatch, formatQQBotMarkdownImage(imgUrl, null));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 移除裸 URL 图片
|
|
458
|
+
for (const m of bareUrlMatches) {
|
|
459
|
+
result = result.replace(m[0], "").trim();
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// 追加图片
|
|
463
|
+
if (imagesToAppend.length > 0) {
|
|
464
|
+
result = result.trim();
|
|
465
|
+
result = result ? result + "\n\n" + imagesToAppend.join("\n") : imagesToAppend.join("\n");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 发送 markdown 文本
|
|
469
|
+
if (result.trim()) {
|
|
470
|
+
const mdChunks = chunkText(result, TEXT_CHUNK_LIMIT);
|
|
471
|
+
for (const chunk of mdChunks) {
|
|
472
|
+
try {
|
|
473
|
+
await sendWithRetry(async (token) => {
|
|
474
|
+
const ref = consumeQuoteRef();
|
|
475
|
+
if (event.type === "c2c") {
|
|
476
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
477
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
478
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
479
|
+
} else if (event.channelId) {
|
|
480
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
log?.info(`${prefix} Sent markdown chunk (${chunk.length}/${result.length} chars) with ${httpImageUrls.length} HTTP images (${event.type})`);
|
|
484
|
+
} catch (err) {
|
|
485
|
+
log?.error(`${prefix} Failed to send markdown message chunk: ${err}`);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** 普通文本模式发送 */
|
|
492
|
+
async function sendPlainTextReply(
|
|
493
|
+
textWithoutImages: string,
|
|
494
|
+
imageUrls: string[],
|
|
495
|
+
mdMatches: RegExpMatchArray[],
|
|
496
|
+
bareUrlMatches: RegExpMatchArray[],
|
|
497
|
+
event: DeliverEventContext,
|
|
498
|
+
actx: DeliverAccountContext,
|
|
499
|
+
sendWithRetry: SendWithRetryFn,
|
|
500
|
+
consumeQuoteRef: ConsumeQuoteRefFn,
|
|
501
|
+
): Promise<void> {
|
|
502
|
+
const { account, log } = actx;
|
|
503
|
+
const prefix = `[qqbot:${account.accountId}]`;
|
|
504
|
+
|
|
505
|
+
const imgMediaTarget: MediaTargetContext = {
|
|
506
|
+
targetType: event.type === "c2c" ? "c2c" : event.type === "group" ? "group" : "channel",
|
|
507
|
+
targetId: event.type === "c2c" ? event.senderId : event.type === "group" ? event.groupOpenid! : event.channelId!,
|
|
508
|
+
account,
|
|
509
|
+
replyToId: event.messageId,
|
|
510
|
+
logPrefix: prefix,
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
let result = textWithoutImages;
|
|
514
|
+
for (const m of mdMatches) result = result.replace(m[0], "").trim();
|
|
515
|
+
for (const m of bareUrlMatches) result = result.replace(m[0], "").trim();
|
|
516
|
+
|
|
517
|
+
// 群聊 URL 点号过滤
|
|
518
|
+
if (result && event.type !== "c2c") {
|
|
519
|
+
result = result.replace(/([a-zA-Z0-9])\.([a-zA-Z0-9])/g, "$1_$2");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
for (const imageUrl of imageUrls) {
|
|
524
|
+
try {
|
|
525
|
+
const imgResult = await sendPhoto(imgMediaTarget, imageUrl);
|
|
526
|
+
if (imgResult.error) log?.error(`${prefix} Failed to send image: ${imgResult.error}`);
|
|
527
|
+
else log?.info(`${prefix} Sent image via sendPhoto: ${imageUrl.slice(0, 80)}...`);
|
|
528
|
+
} catch (imgErr) {
|
|
529
|
+
log?.error(`${prefix} Failed to send image: ${imgErr}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (result.trim()) {
|
|
534
|
+
const plainChunks = chunkText(result, TEXT_CHUNK_LIMIT);
|
|
535
|
+
for (const chunk of plainChunks) {
|
|
536
|
+
await sendWithRetry(async (token) => {
|
|
537
|
+
const ref = consumeQuoteRef();
|
|
538
|
+
if (event.type === "c2c") {
|
|
539
|
+
return await sendC2CMessage(token, event.senderId, chunk, event.messageId, ref);
|
|
540
|
+
} else if (event.type === "group" && event.groupOpenid) {
|
|
541
|
+
return await sendGroupMessage(token, event.groupOpenid, chunk, event.messageId);
|
|
542
|
+
} else if (event.channelId) {
|
|
543
|
+
return await sendChannelMessage(token, event.channelId, chunk, event.messageId);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
log?.info(`${prefix} Sent text chunk (${chunk.length}/${result.length} chars) (${event.type})`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch (err) {
|
|
550
|
+
log?.error(`${prefix} Send failed: ${err}`);
|
|
551
|
+
}
|
|
552
|
+
}
|