@tencent-connect/openclaw-qqbot 1.0.0-alpha.0
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 +393 -0
- package/README.zh.md +390 -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 +22 -0
- package/dist/src/api.d.ts +138 -0
- package/dist/src/api.js +523 -0
- package/dist/src/channel.d.ts +3 -0
- package/dist/src/channel.js +337 -0
- package/dist/src/config.d.ts +25 -0
- package/dist/src/config.js +156 -0
- package/dist/src/gateway.d.ts +18 -0
- package/dist/src/gateway.js +2315 -0
- package/dist/src/image-server.d.ts +62 -0
- package/dist/src/image-server.js +401 -0
- package/dist/src/known-users.d.ts +100 -0
- package/dist/src/known-users.js +263 -0
- package/dist/src/onboarding.d.ts +10 -0
- package/dist/src/onboarding.js +203 -0
- package/dist/src/outbound.d.ts +150 -0
- package/dist/src/outbound.js +1175 -0
- package/dist/src/proactive.d.ts +170 -0
- package/dist/src/proactive.js +399 -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/types.d.ts +145 -0
- package/dist/src/types.js +1 -0
- package/dist/src/utils/audio-convert.d.ts +73 -0
- package/dist/src/utils/audio-convert.js +645 -0
- package/dist/src/utils/file-utils.d.ts +46 -0
- package/dist/src/utils/file-utils.js +107 -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 +120 -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 +126 -0
- package/dist/src/utils/platform.js +358 -0
- package/dist/src/utils/upload-cache.d.ts +34 -0
- package/dist/src/utils/upload-cache.js +93 -0
- package/index.ts +27 -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/proactive-api-server.ts +356 -0
- package/scripts/pull-latest.sh +316 -0
- package/scripts/send-proactive.ts +273 -0
- package/scripts/set-markdown.sh +156 -0
- package/scripts/upgrade-and-run.sh +525 -0
- package/scripts/upgrade.sh +127 -0
- package/skills/qqbot-cron/SKILL.md +513 -0
- package/skills/qqbot-media/SKILL.md +194 -0
- package/src/api.ts +704 -0
- package/src/channel.ts +368 -0
- package/src/config.ts +182 -0
- package/src/gateway.ts +2459 -0
- package/src/image-server.ts +474 -0
- package/src/known-users.ts +353 -0
- package/src/onboarding.ts +274 -0
- package/src/openclaw-plugin-sdk.d.ts +483 -0
- package/src/outbound.ts +1301 -0
- package/src/proactive.ts +530 -0
- package/src/runtime.ts +14 -0
- package/src/session-store.ts +303 -0
- package/src/types.ts +153 -0
- package/src/utils/audio-convert.ts +738 -0
- package/src/utils/file-utils.ts +122 -0
- package/src/utils/image-size.ts +266 -0
- package/src/utils/media-tags.ts +134 -0
- package/src/utils/payload.ts +265 -0
- package/src/utils/platform.ts +404 -0
- package/src/utils/upload-cache.ts +128 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,1175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QQ Bot 消息发送模块
|
|
3
|
+
*/
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import { decodeCronPayload } from "./utils/payload.js";
|
|
6
|
+
import { getAccessToken, sendC2CMessage, sendChannelMessage, sendGroupMessage, sendProactiveC2CMessage, sendProactiveGroupMessage, sendC2CImageMessage, sendGroupImageMessage, sendC2CVoiceMessage, sendGroupVoiceMessage, sendC2CVideoMessage, sendGroupVideoMessage, sendC2CFileMessage, sendGroupFileMessage, } from "./api.js";
|
|
7
|
+
import { isAudioFile, audioFileToSilkBase64, waitForFile } from "./utils/audio-convert.js";
|
|
8
|
+
import { normalizeMediaTags } from "./utils/media-tags.js";
|
|
9
|
+
import { checkFileSize, readFileAsync, fileExistsAsync, isLargeFile, formatFileSize } from "./utils/file-utils.js";
|
|
10
|
+
import { isLocalPath as isLocalFilePath, normalizePath, sanitizeFileName } from "./utils/platform.js";
|
|
11
|
+
// ============ 消息回复限流器 ============
|
|
12
|
+
// 同一 message_id 1小时内最多回复 4 次,超过 1 小时无法被动回复(需改为主动消息)
|
|
13
|
+
const MESSAGE_REPLY_LIMIT = 4;
|
|
14
|
+
const MESSAGE_REPLY_TTL = 60 * 60 * 1000; // 1小时
|
|
15
|
+
const messageReplyTracker = new Map();
|
|
16
|
+
/**
|
|
17
|
+
* 检查是否可以回复该消息(限流检查)
|
|
18
|
+
* @param messageId 消息ID
|
|
19
|
+
* @returns ReplyLimitResult 限流检查结果
|
|
20
|
+
*/
|
|
21
|
+
export function checkMessageReplyLimit(messageId) {
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
const record = messageReplyTracker.get(messageId);
|
|
24
|
+
// 清理过期记录(定期清理,避免内存泄漏)
|
|
25
|
+
if (messageReplyTracker.size > 10000) {
|
|
26
|
+
for (const [id, rec] of messageReplyTracker) {
|
|
27
|
+
if (now - rec.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
28
|
+
messageReplyTracker.delete(id);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// 新消息,首次回复
|
|
33
|
+
if (!record) {
|
|
34
|
+
return {
|
|
35
|
+
allowed: true,
|
|
36
|
+
remaining: MESSAGE_REPLY_LIMIT,
|
|
37
|
+
shouldFallbackToProactive: false,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// 检查是否超过1小时(message_id 过期)
|
|
41
|
+
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
42
|
+
// 超过1小时,被动回复不可用,需要降级为主动消息
|
|
43
|
+
return {
|
|
44
|
+
allowed: false,
|
|
45
|
+
remaining: 0,
|
|
46
|
+
shouldFallbackToProactive: true,
|
|
47
|
+
fallbackReason: "expired",
|
|
48
|
+
message: `消息已超过1小时有效期,将使用主动消息发送`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
// 检查是否超过回复次数限制
|
|
52
|
+
const remaining = MESSAGE_REPLY_LIMIT - record.count;
|
|
53
|
+
if (remaining <= 0) {
|
|
54
|
+
return {
|
|
55
|
+
allowed: false,
|
|
56
|
+
remaining: 0,
|
|
57
|
+
shouldFallbackToProactive: true,
|
|
58
|
+
fallbackReason: "limit_exceeded",
|
|
59
|
+
message: `该消息已达到1小时内最大回复次数(${MESSAGE_REPLY_LIMIT}次),将使用主动消息发送`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
allowed: true,
|
|
64
|
+
remaining,
|
|
65
|
+
shouldFallbackToProactive: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* 记录一次消息回复
|
|
70
|
+
* @param messageId 消息ID
|
|
71
|
+
*/
|
|
72
|
+
export function recordMessageReply(messageId) {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
const record = messageReplyTracker.get(messageId);
|
|
75
|
+
if (!record) {
|
|
76
|
+
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
// 检查是否过期,过期则重新计数
|
|
80
|
+
if (now - record.firstReplyAt > MESSAGE_REPLY_TTL) {
|
|
81
|
+
messageReplyTracker.set(messageId, { count: 1, firstReplyAt: now });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
record.count++;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
console.log(`[qqbot] recordMessageReply: ${messageId}, count=${messageReplyTracker.get(messageId)?.count}`);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 获取消息回复统计信息
|
|
91
|
+
*/
|
|
92
|
+
export function getMessageReplyStats() {
|
|
93
|
+
let totalReplies = 0;
|
|
94
|
+
for (const record of messageReplyTracker.values()) {
|
|
95
|
+
totalReplies += record.count;
|
|
96
|
+
}
|
|
97
|
+
return { trackedMessages: messageReplyTracker.size, totalReplies };
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 获取消息回复限制配置(供外部查询)
|
|
101
|
+
*/
|
|
102
|
+
export function getMessageReplyConfig() {
|
|
103
|
+
return {
|
|
104
|
+
limit: MESSAGE_REPLY_LIMIT,
|
|
105
|
+
ttlMs: MESSAGE_REPLY_TTL,
|
|
106
|
+
ttlHours: MESSAGE_REPLY_TTL / (60 * 60 * 1000),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 解析目标地址
|
|
111
|
+
* 格式:
|
|
112
|
+
* - openid (32位十六进制) -> C2C 单聊
|
|
113
|
+
* - group:xxx -> 群聊
|
|
114
|
+
* - channel:xxx -> 频道
|
|
115
|
+
* - 纯数字 -> 频道
|
|
116
|
+
*/
|
|
117
|
+
function parseTarget(to) {
|
|
118
|
+
const timestamp = new Date().toISOString();
|
|
119
|
+
console.log(`[${timestamp}] [qqbot] parseTarget: input=${to}`);
|
|
120
|
+
// 去掉 qqbot: 前缀
|
|
121
|
+
let id = to.replace(/^qqbot:/i, "");
|
|
122
|
+
if (id.startsWith("c2c:")) {
|
|
123
|
+
const userId = id.slice(4);
|
|
124
|
+
if (!userId || userId.length === 0) {
|
|
125
|
+
const error = `Invalid c2c target format: ${to} - missing user ID`;
|
|
126
|
+
console.error(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
127
|
+
throw new Error(error);
|
|
128
|
+
}
|
|
129
|
+
console.log(`[${timestamp}] [qqbot] parseTarget: c2c target, user ID=${userId}`);
|
|
130
|
+
return { type: "c2c", id: userId };
|
|
131
|
+
}
|
|
132
|
+
if (id.startsWith("group:")) {
|
|
133
|
+
const groupId = id.slice(6);
|
|
134
|
+
if (!groupId || groupId.length === 0) {
|
|
135
|
+
const error = `Invalid group target format: ${to} - missing group ID`;
|
|
136
|
+
console.error(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
137
|
+
throw new Error(error);
|
|
138
|
+
}
|
|
139
|
+
console.log(`[${timestamp}] [qqbot] parseTarget: group target, group ID=${groupId}`);
|
|
140
|
+
return { type: "group", id: groupId };
|
|
141
|
+
}
|
|
142
|
+
if (id.startsWith("channel:")) {
|
|
143
|
+
const channelId = id.slice(8);
|
|
144
|
+
if (!channelId || channelId.length === 0) {
|
|
145
|
+
const error = `Invalid channel target format: ${to} - missing channel ID`;
|
|
146
|
+
console.error(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
147
|
+
throw new Error(error);
|
|
148
|
+
}
|
|
149
|
+
console.log(`[${timestamp}] [qqbot] parseTarget: channel target, channel ID=${channelId}`);
|
|
150
|
+
return { type: "channel", id: channelId };
|
|
151
|
+
}
|
|
152
|
+
// 默认当作 c2c(私聊)
|
|
153
|
+
if (!id || id.length === 0) {
|
|
154
|
+
const error = `Invalid target format: ${to} - empty ID after removing qqbot: prefix`;
|
|
155
|
+
console.error(`[${timestamp}] [qqbot] parseTarget: ${error}`);
|
|
156
|
+
throw new Error(error);
|
|
157
|
+
}
|
|
158
|
+
console.log(`[${timestamp}] [qqbot] parseTarget: default c2c target, ID=${id}`);
|
|
159
|
+
return { type: "c2c", id };
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* 发送文本消息
|
|
163
|
+
* - 有 replyToId: 被动回复,1小时内最多回复4次
|
|
164
|
+
* - 无 replyToId: 主动发送,有配额限制(每月4条/用户/群)
|
|
165
|
+
*
|
|
166
|
+
* 注意:
|
|
167
|
+
* 1. 主动消息(无 replyToId)必须有消息内容,不支持流式发送
|
|
168
|
+
* 2. 当被动回复不可用(超期或超过次数)时,自动降级为主动消息
|
|
169
|
+
* 3. 支持 <qqimg>路径</qqimg> 或 <qqimg>路径</img> 格式发送图片
|
|
170
|
+
*/
|
|
171
|
+
export async function sendText(ctx) {
|
|
172
|
+
const { to, account } = ctx;
|
|
173
|
+
let { text, replyToId } = ctx;
|
|
174
|
+
let fallbackToProactive = false;
|
|
175
|
+
console.log("[qqbot] sendText ctx:", JSON.stringify({ to, text: text?.slice(0, 50), replyToId, accountId: account.accountId }, null, 2));
|
|
176
|
+
// ============ 消息回复限流检查 ============
|
|
177
|
+
// 如果有 replyToId,检查是否可以被动回复
|
|
178
|
+
if (replyToId) {
|
|
179
|
+
const limitCheck = checkMessageReplyLimit(replyToId);
|
|
180
|
+
if (!limitCheck.allowed) {
|
|
181
|
+
// 检查是否需要降级为主动消息
|
|
182
|
+
if (limitCheck.shouldFallbackToProactive) {
|
|
183
|
+
console.warn(`[qqbot] sendText: 被动回复不可用,降级为主动消息 - ${limitCheck.message}`);
|
|
184
|
+
fallbackToProactive = true;
|
|
185
|
+
replyToId = null; // 清除 replyToId,改为主动消息
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
// 不应该发生,但作为保底
|
|
189
|
+
console.error(`[qqbot] sendText: 消息回复被限流但未设置降级 - ${limitCheck.message}`);
|
|
190
|
+
return {
|
|
191
|
+
channel: "qqbot",
|
|
192
|
+
error: limitCheck.message
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
console.log(`[qqbot] sendText: 消息 ${replyToId} 剩余被动回复次数: ${limitCheck.remaining}/${MESSAGE_REPLY_LIMIT}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ============ 媒体标签检测与处理 ============
|
|
201
|
+
// 支持四种标签:
|
|
202
|
+
// <qqimg>路径</qqimg> 或 <qqimg>路径</img> — 图片
|
|
203
|
+
// <qqvoice>路径</qqvoice> — 语音
|
|
204
|
+
// <qqvideo>路径或URL</qqvideo> — 视频
|
|
205
|
+
// <qqfile>路径</qqfile> — 文件
|
|
206
|
+
// 预处理:纠正小模型常见的标签拼写错误和格式问题
|
|
207
|
+
text = normalizeMediaTags(text);
|
|
208
|
+
const mediaTagRegex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
209
|
+
const mediaTagMatches = text.match(mediaTagRegex);
|
|
210
|
+
if (mediaTagMatches && mediaTagMatches.length > 0) {
|
|
211
|
+
console.log(`[qqbot] sendText: Detected ${mediaTagMatches.length} media tag(s), processing...`);
|
|
212
|
+
// 构建发送队列:根据内容在原文中的实际位置顺序发送
|
|
213
|
+
const sendQueue = [];
|
|
214
|
+
let lastIndex = 0;
|
|
215
|
+
const mediaTagRegexWithIndex = /<(qqimg|qqvoice|qqvideo|qqfile)>([^<>]+)<\/(?:qqimg|qqvoice|qqvideo|qqfile|img)>/gi;
|
|
216
|
+
let match;
|
|
217
|
+
while ((match = mediaTagRegexWithIndex.exec(text)) !== null) {
|
|
218
|
+
// 添加标签前的文本
|
|
219
|
+
const textBefore = text.slice(lastIndex, match.index).replace(/\n{3,}/g, "\n\n").trim();
|
|
220
|
+
if (textBefore) {
|
|
221
|
+
sendQueue.push({ type: "text", content: textBefore });
|
|
222
|
+
}
|
|
223
|
+
const tagName = match[1].toLowerCase(); // "qqimg" or "qqvoice" or "qqfile"
|
|
224
|
+
// 剥离 MEDIA: 前缀(框架可能注入),展开 ~ 路径
|
|
225
|
+
let mediaPath = match[2]?.trim() ?? "";
|
|
226
|
+
if (mediaPath.startsWith("MEDIA:")) {
|
|
227
|
+
mediaPath = mediaPath.slice("MEDIA:".length);
|
|
228
|
+
}
|
|
229
|
+
mediaPath = normalizePath(mediaPath);
|
|
230
|
+
// 处理可能被模型转义的路径
|
|
231
|
+
// 1. 双反斜杠 -> 单反斜杠(Markdown 转义)
|
|
232
|
+
mediaPath = mediaPath.replace(/\\\\/g, "\\");
|
|
233
|
+
// 2. 八进制转义序列 + UTF-8 双重编码修复
|
|
234
|
+
try {
|
|
235
|
+
const hasOctal = /\\[0-7]{1,3}/.test(mediaPath);
|
|
236
|
+
const hasNonASCII = /[\u0080-\u00FF]/.test(mediaPath);
|
|
237
|
+
if (hasOctal || hasNonASCII) {
|
|
238
|
+
console.log(`[qqbot] sendText: Decoding path with mixed encoding: ${mediaPath}`);
|
|
239
|
+
// Step 1: 将八进制转义转换为字节
|
|
240
|
+
let decoded = mediaPath.replace(/\\([0-7]{1,3})/g, (_, octal) => {
|
|
241
|
+
return String.fromCharCode(parseInt(octal, 8));
|
|
242
|
+
});
|
|
243
|
+
// Step 2: 提取所有字节(包括 Latin-1 字符)
|
|
244
|
+
const bytes = [];
|
|
245
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
246
|
+
const code = decoded.charCodeAt(i);
|
|
247
|
+
if (code <= 0xFF) {
|
|
248
|
+
bytes.push(code);
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
const charBytes = Buffer.from(decoded[i], 'utf8');
|
|
252
|
+
bytes.push(...charBytes);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Step 3: 尝试按 UTF-8 解码
|
|
256
|
+
const buffer = Buffer.from(bytes);
|
|
257
|
+
const utf8Decoded = buffer.toString('utf8');
|
|
258
|
+
if (!utf8Decoded.includes('\uFFFD') || utf8Decoded.length < decoded.length) {
|
|
259
|
+
mediaPath = utf8Decoded;
|
|
260
|
+
console.log(`[qqbot] sendText: Successfully decoded path: ${mediaPath}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (decodeErr) {
|
|
265
|
+
console.error(`[qqbot] sendText: Path decode error: ${decodeErr}`);
|
|
266
|
+
}
|
|
267
|
+
if (mediaPath) {
|
|
268
|
+
if (tagName === "qqvoice") {
|
|
269
|
+
sendQueue.push({ type: "voice", content: mediaPath });
|
|
270
|
+
console.log(`[qqbot] sendText: Found voice path in <qqvoice>: ${mediaPath}`);
|
|
271
|
+
}
|
|
272
|
+
else if (tagName === "qqvideo") {
|
|
273
|
+
sendQueue.push({ type: "video", content: mediaPath });
|
|
274
|
+
console.log(`[qqbot] sendText: Found video URL in <qqvideo>: ${mediaPath}`);
|
|
275
|
+
}
|
|
276
|
+
else if (tagName === "qqfile") {
|
|
277
|
+
sendQueue.push({ type: "file", content: mediaPath });
|
|
278
|
+
console.log(`[qqbot] sendText: Found file path in <qqfile>: ${mediaPath}`);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
sendQueue.push({ type: "image", content: mediaPath });
|
|
282
|
+
console.log(`[qqbot] sendText: Found image path in <qqimg>: ${mediaPath}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
lastIndex = match.index + match[0].length;
|
|
286
|
+
}
|
|
287
|
+
// 添加最后一个标签后的文本
|
|
288
|
+
const textAfter = text.slice(lastIndex).replace(/\n{3,}/g, "\n\n").trim();
|
|
289
|
+
if (textAfter) {
|
|
290
|
+
sendQueue.push({ type: "text", content: textAfter });
|
|
291
|
+
}
|
|
292
|
+
console.log(`[qqbot] sendText: Send queue: ${sendQueue.map(item => item.type).join(" -> ")}`);
|
|
293
|
+
// 按顺序发送
|
|
294
|
+
if (!account.appId || !account.clientSecret) {
|
|
295
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
296
|
+
}
|
|
297
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
298
|
+
const target = parseTarget(to);
|
|
299
|
+
let lastResult = { channel: "qqbot" };
|
|
300
|
+
for (const item of sendQueue) {
|
|
301
|
+
try {
|
|
302
|
+
if (item.type === "text") {
|
|
303
|
+
// 发送文本
|
|
304
|
+
if (replyToId) {
|
|
305
|
+
// 被动回复
|
|
306
|
+
if (target.type === "c2c") {
|
|
307
|
+
const result = await sendC2CMessage(accessToken, target.id, item.content, replyToId);
|
|
308
|
+
recordMessageReply(replyToId);
|
|
309
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
310
|
+
}
|
|
311
|
+
else if (target.type === "group") {
|
|
312
|
+
const result = await sendGroupMessage(accessToken, target.id, item.content, replyToId);
|
|
313
|
+
recordMessageReply(replyToId);
|
|
314
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const result = await sendChannelMessage(accessToken, target.id, item.content, replyToId);
|
|
318
|
+
recordMessageReply(replyToId);
|
|
319
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
// 主动消息
|
|
324
|
+
if (target.type === "c2c") {
|
|
325
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, item.content);
|
|
326
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
327
|
+
}
|
|
328
|
+
else if (target.type === "group") {
|
|
329
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, item.content);
|
|
330
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
const result = await sendChannelMessage(accessToken, target.id, item.content);
|
|
334
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
console.log(`[qqbot] sendText: Sent text part: ${item.content.slice(0, 30)}...`);
|
|
338
|
+
}
|
|
339
|
+
else if (item.type === "image") {
|
|
340
|
+
// 发送图片
|
|
341
|
+
const imagePath = item.content;
|
|
342
|
+
const isHttpUrl = imagePath.startsWith("http://") || imagePath.startsWith("https://");
|
|
343
|
+
let imageUrl = imagePath;
|
|
344
|
+
// 如果是本地文件路径,读取并转换为 Base64
|
|
345
|
+
if (!isHttpUrl && !imagePath.startsWith("data:")) {
|
|
346
|
+
if (!(await fileExistsAsync(imagePath))) {
|
|
347
|
+
console.error(`[qqbot] sendText: Image file not found: ${imagePath}`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// 文件大小校验
|
|
351
|
+
const sizeCheck = checkFileSize(imagePath);
|
|
352
|
+
if (!sizeCheck.ok) {
|
|
353
|
+
console.error(`[qqbot] sendText: ${sizeCheck.error}`);
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const fileBuffer = await readFileAsync(imagePath);
|
|
357
|
+
const ext = path.extname(imagePath).toLowerCase();
|
|
358
|
+
const mimeTypes = {
|
|
359
|
+
".jpg": "image/jpeg",
|
|
360
|
+
".jpeg": "image/jpeg",
|
|
361
|
+
".png": "image/png",
|
|
362
|
+
".gif": "image/gif",
|
|
363
|
+
".webp": "image/webp",
|
|
364
|
+
".bmp": "image/bmp",
|
|
365
|
+
};
|
|
366
|
+
const mimeType = mimeTypes[ext] ?? "image/png";
|
|
367
|
+
imageUrl = `data:${mimeType};base64,${fileBuffer.toString("base64")}`;
|
|
368
|
+
console.log(`[qqbot] sendText: Converted local image to Base64 (size: ${formatFileSize(fileBuffer.length)})`);
|
|
369
|
+
}
|
|
370
|
+
// 发送图片
|
|
371
|
+
if (target.type === "c2c") {
|
|
372
|
+
const result = await sendC2CImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
|
|
373
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
374
|
+
}
|
|
375
|
+
else if (target.type === "group") {
|
|
376
|
+
const result = await sendGroupImageMessage(accessToken, target.id, imageUrl, replyToId ?? undefined);
|
|
377
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
378
|
+
}
|
|
379
|
+
else if (isHttpUrl) {
|
|
380
|
+
// 频道使用 Markdown 格式(仅支持公网 URL)
|
|
381
|
+
const result = await sendChannelMessage(accessToken, target.id, ``, replyToId ?? undefined);
|
|
382
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
383
|
+
}
|
|
384
|
+
console.log(`[qqbot] sendText: Sent image via <qqimg> tag: ${imagePath.slice(0, 60)}...`);
|
|
385
|
+
}
|
|
386
|
+
else if (item.type === "voice") {
|
|
387
|
+
// 发送语音文件
|
|
388
|
+
const voicePath = item.content;
|
|
389
|
+
// 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
|
|
390
|
+
const fileSize = await waitForFile(voicePath);
|
|
391
|
+
if (fileSize === 0) {
|
|
392
|
+
console.error(`[qqbot] sendText: Voice file not ready after waiting: ${voicePath}`);
|
|
393
|
+
// 发送友好提示给用户
|
|
394
|
+
try {
|
|
395
|
+
if (target.type === "c2c") {
|
|
396
|
+
await sendC2CMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
|
|
397
|
+
}
|
|
398
|
+
else if (target.type === "group") {
|
|
399
|
+
await sendGroupMessage(accessToken, target.id, "语音生成失败,请稍后重试", replyToId ?? undefined);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
catch { }
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
// 转换为 SILK 格式(QQ Bot API 语音只支持 SILK)
|
|
406
|
+
const silkBase64 = await audioFileToSilkBase64(voicePath);
|
|
407
|
+
if (!silkBase64) {
|
|
408
|
+
const ext = path.extname(voicePath).toLowerCase();
|
|
409
|
+
console.error(`[qqbot] sendText: Voice conversion to SILK failed: ${ext} (${fileSize} bytes)`);
|
|
410
|
+
try {
|
|
411
|
+
if (target.type === "c2c") {
|
|
412
|
+
await sendC2CMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
|
|
413
|
+
}
|
|
414
|
+
else if (target.type === "group") {
|
|
415
|
+
await sendGroupMessage(accessToken, target.id, "语音格式转换失败,请稍后重试", replyToId ?? undefined);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch { }
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
console.log(`[qqbot] sendText: Voice converted to SILK (${fileSize} bytes)`);
|
|
422
|
+
if (target.type === "c2c") {
|
|
423
|
+
const result = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
424
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
425
|
+
}
|
|
426
|
+
else if (target.type === "group") {
|
|
427
|
+
const result = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
428
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
const result = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
432
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
433
|
+
}
|
|
434
|
+
console.log(`[qqbot] sendText: Sent voice via <qqvoice> tag: ${voicePath.slice(0, 60)}...`);
|
|
435
|
+
}
|
|
436
|
+
else if (item.type === "video") {
|
|
437
|
+
// 发送视频(支持公网 URL 和本地文件)
|
|
438
|
+
const videoPath = item.content;
|
|
439
|
+
const isHttpUrl = videoPath.startsWith("http://") || videoPath.startsWith("https://");
|
|
440
|
+
if (isHttpUrl) {
|
|
441
|
+
// 公网 URL
|
|
442
|
+
if (target.type === "c2c") {
|
|
443
|
+
const result = await sendC2CVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
|
|
444
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
445
|
+
}
|
|
446
|
+
else if (target.type === "group") {
|
|
447
|
+
const result = await sendGroupVideoMessage(accessToken, target.id, videoPath, undefined, replyToId ?? undefined);
|
|
448
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
452
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
// 本地文件:读取为 Base64
|
|
457
|
+
if (!(await fileExistsAsync(videoPath))) {
|
|
458
|
+
console.error(`[qqbot] sendText: Video file not found: ${videoPath}`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
const videoSizeCheck = checkFileSize(videoPath);
|
|
462
|
+
if (!videoSizeCheck.ok) {
|
|
463
|
+
console.error(`[qqbot] sendText: ${videoSizeCheck.error}`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
// 大文件进度提示
|
|
467
|
+
if (isLargeFile(videoSizeCheck.size)) {
|
|
468
|
+
try {
|
|
469
|
+
const hint = `⏳ 正在上传视频 (${formatFileSize(videoSizeCheck.size)})...`;
|
|
470
|
+
if (target.type === "c2c") {
|
|
471
|
+
await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
472
|
+
}
|
|
473
|
+
else if (target.type === "group") {
|
|
474
|
+
await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
catch { }
|
|
478
|
+
}
|
|
479
|
+
const fileBuffer = await readFileAsync(videoPath);
|
|
480
|
+
const videoBase64 = fileBuffer.toString("base64");
|
|
481
|
+
console.log(`[qqbot] sendText: Read local video (${formatFileSize(fileBuffer.length)}): ${videoPath}`);
|
|
482
|
+
if (target.type === "c2c") {
|
|
483
|
+
const result = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
484
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
485
|
+
}
|
|
486
|
+
else if (target.type === "group") {
|
|
487
|
+
const result = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
488
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const result = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
492
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
console.log(`[qqbot] sendText: Sent video via <qqvideo> tag: ${videoPath.slice(0, 60)}...`);
|
|
496
|
+
}
|
|
497
|
+
else if (item.type === "file") {
|
|
498
|
+
// 发送文件
|
|
499
|
+
const filePath = item.content;
|
|
500
|
+
const isHttpUrl = filePath.startsWith("http://") || filePath.startsWith("https://");
|
|
501
|
+
const fileName = sanitizeFileName(path.basename(filePath));
|
|
502
|
+
if (isHttpUrl) {
|
|
503
|
+
// 公网 URL:直接通过 url 参数上传
|
|
504
|
+
if (target.type === "c2c") {
|
|
505
|
+
const result = await sendC2CFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
|
|
506
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
507
|
+
}
|
|
508
|
+
else if (target.type === "group") {
|
|
509
|
+
const result = await sendGroupFileMessage(accessToken, target.id, undefined, filePath, replyToId ?? undefined, fileName);
|
|
510
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
514
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
// 本地文件:读取转 Base64 上传
|
|
519
|
+
if (!(await fileExistsAsync(filePath))) {
|
|
520
|
+
console.error(`[qqbot] sendText: File not found: ${filePath}`);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
const fileSizeCheck = checkFileSize(filePath);
|
|
524
|
+
if (!fileSizeCheck.ok) {
|
|
525
|
+
console.error(`[qqbot] sendText: ${fileSizeCheck.error}`);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// 大文件进度提示
|
|
529
|
+
if (isLargeFile(fileSizeCheck.size)) {
|
|
530
|
+
try {
|
|
531
|
+
const hint = `⏳ 正在上传文件 ${fileName} (${formatFileSize(fileSizeCheck.size)})...`;
|
|
532
|
+
if (target.type === "c2c") {
|
|
533
|
+
await sendC2CMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
534
|
+
}
|
|
535
|
+
else if (target.type === "group") {
|
|
536
|
+
await sendGroupMessage(accessToken, target.id, hint, replyToId ?? undefined);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
catch { }
|
|
540
|
+
}
|
|
541
|
+
const fileBuffer = await readFileAsync(filePath);
|
|
542
|
+
const fileBase64 = fileBuffer.toString("base64");
|
|
543
|
+
console.log(`[qqbot] sendText: Read local file (${formatFileSize(fileBuffer.length)}): ${filePath}`);
|
|
544
|
+
if (target.type === "c2c") {
|
|
545
|
+
const result = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
546
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
547
|
+
}
|
|
548
|
+
else if (target.type === "group") {
|
|
549
|
+
const result = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
550
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
551
|
+
}
|
|
552
|
+
else {
|
|
553
|
+
const result = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
554
|
+
lastResult = { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
console.log(`[qqbot] sendText: Sent file via <qqfile> tag: ${filePath.slice(0, 60)}...`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
catch (err) {
|
|
561
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
562
|
+
console.error(`[qqbot] sendText: Failed to send ${item.type}: ${errMsg}`);
|
|
563
|
+
// 继续发送队列中的其他内容
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return lastResult;
|
|
567
|
+
}
|
|
568
|
+
// ============ 主动消息校验(参考 Telegram 机制) ============
|
|
569
|
+
// 如果是主动消息(无 replyToId 或降级后),必须有消息内容
|
|
570
|
+
if (!replyToId) {
|
|
571
|
+
if (!text || text.trim().length === 0) {
|
|
572
|
+
console.error("[qqbot] sendText error: 主动消息的内容不能为空 (text is empty)");
|
|
573
|
+
return {
|
|
574
|
+
channel: "qqbot",
|
|
575
|
+
error: "主动消息必须有内容 (--message 参数不能为空)"
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
if (fallbackToProactive) {
|
|
579
|
+
console.log(`[qqbot] sendText: [降级] 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
console.log(`[qqbot] sendText: 发送主动消息到 ${to}, 内容长度: ${text.length}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (!account.appId || !account.clientSecret) {
|
|
586
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
590
|
+
const target = parseTarget(to);
|
|
591
|
+
console.log("[qqbot] sendText target:", JSON.stringify(target));
|
|
592
|
+
// 如果没有 replyToId,使用主动发送接口
|
|
593
|
+
if (!replyToId) {
|
|
594
|
+
if (target.type === "c2c") {
|
|
595
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
|
596
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
597
|
+
}
|
|
598
|
+
else if (target.type === "group") {
|
|
599
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
|
600
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
// 频道暂不支持主动消息
|
|
604
|
+
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
605
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// 有 replyToId,使用被动回复接口
|
|
609
|
+
if (target.type === "c2c") {
|
|
610
|
+
const result = await sendC2CMessage(accessToken, target.id, text, replyToId);
|
|
611
|
+
// 记录回复次数
|
|
612
|
+
recordMessageReply(replyToId);
|
|
613
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
614
|
+
}
|
|
615
|
+
else if (target.type === "group") {
|
|
616
|
+
const result = await sendGroupMessage(accessToken, target.id, text, replyToId);
|
|
617
|
+
// 记录回复次数
|
|
618
|
+
recordMessageReply(replyToId);
|
|
619
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
620
|
+
}
|
|
621
|
+
else {
|
|
622
|
+
const result = await sendChannelMessage(accessToken, target.id, text, replyToId);
|
|
623
|
+
// 记录回复次数
|
|
624
|
+
recordMessageReply(replyToId);
|
|
625
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
catch (err) {
|
|
629
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
630
|
+
return { channel: "qqbot", error: message };
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* 主动发送消息(不需要 replyToId,有配额限制:每月 4 条/用户/群)
|
|
635
|
+
*
|
|
636
|
+
* @param account - 账户配置
|
|
637
|
+
* @param to - 目标地址,格式:openid(单聊)或 group:xxx(群聊)
|
|
638
|
+
* @param text - 消息内容
|
|
639
|
+
*/
|
|
640
|
+
export async function sendProactiveMessage(account, to, text) {
|
|
641
|
+
const timestamp = new Date().toISOString();
|
|
642
|
+
if (!account.appId || !account.clientSecret) {
|
|
643
|
+
const errorMsg = "QQBot not configured (missing appId or clientSecret)";
|
|
644
|
+
console.error(`[${timestamp}] [qqbot] sendProactiveMessage: ${errorMsg}`);
|
|
645
|
+
return { channel: "qqbot", error: errorMsg };
|
|
646
|
+
}
|
|
647
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: starting, to=${to}, text length=${text.length}, accountId=${account.accountId}`);
|
|
648
|
+
try {
|
|
649
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: getting access token for appId=${account.appId}`);
|
|
650
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
651
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: parsing target=${to}`);
|
|
652
|
+
const target = parseTarget(to);
|
|
653
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: target parsed, type=${target.type}, id=${target.id}`);
|
|
654
|
+
if (target.type === "c2c") {
|
|
655
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive C2C message to user=${target.id}`);
|
|
656
|
+
const result = await sendProactiveC2CMessage(accessToken, target.id, text);
|
|
657
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive C2C message sent successfully, messageId=${result.id}`);
|
|
658
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
659
|
+
}
|
|
660
|
+
else if (target.type === "group") {
|
|
661
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending proactive group message to group=${target.id}`);
|
|
662
|
+
const result = await sendProactiveGroupMessage(accessToken, target.id, text);
|
|
663
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: proactive group message sent successfully, messageId=${result.id}`);
|
|
664
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
// 频道暂不支持主动消息,使用普通发送
|
|
668
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: sending channel message to channel=${target.id}`);
|
|
669
|
+
const result = await sendChannelMessage(accessToken, target.id, text);
|
|
670
|
+
console.log(`[${timestamp}] [qqbot] sendProactiveMessage: channel message sent successfully, messageId=${result.id}`);
|
|
671
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch (err) {
|
|
675
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
676
|
+
console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error: ${errorMessage}`);
|
|
677
|
+
console.error(`[${timestamp}] [qqbot] sendProactiveMessage: error stack: ${err instanceof Error ? err.stack : 'No stack trace'}`);
|
|
678
|
+
return { channel: "qqbot", error: errorMessage };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* 发送富媒体消息(图片)
|
|
683
|
+
*
|
|
684
|
+
* 支持以下 mediaUrl 格式:
|
|
685
|
+
* - 公网 URL: https://example.com/image.png
|
|
686
|
+
* - Base64 Data URL: data:image/png;base64,xxxxx
|
|
687
|
+
* - 本地文件路径: /path/to/image.png(自动读取并转换为 Base64)
|
|
688
|
+
*
|
|
689
|
+
* @param ctx - 发送上下文,包含 mediaUrl
|
|
690
|
+
* @returns 发送结果
|
|
691
|
+
*
|
|
692
|
+
* @example
|
|
693
|
+
* ```typescript
|
|
694
|
+
* // 发送网络图片
|
|
695
|
+
* const result = await sendMedia({
|
|
696
|
+
* to: "group:xxx",
|
|
697
|
+
* text: "这是图片说明",
|
|
698
|
+
* mediaUrl: "https://example.com/image.png",
|
|
699
|
+
* account,
|
|
700
|
+
* replyToId: msgId,
|
|
701
|
+
* });
|
|
702
|
+
*
|
|
703
|
+
* // 发送 Base64 图片
|
|
704
|
+
* const result = await sendMedia({
|
|
705
|
+
* to: "group:xxx",
|
|
706
|
+
* text: "这是图片说明",
|
|
707
|
+
* mediaUrl: "data:image/png;base64,iVBORw0KGgo...",
|
|
708
|
+
* account,
|
|
709
|
+
* replyToId: msgId,
|
|
710
|
+
* });
|
|
711
|
+
*
|
|
712
|
+
* // 发送本地文件(自动读取并转换为 Base64)
|
|
713
|
+
* const result = await sendMedia({
|
|
714
|
+
* to: "group:xxx",
|
|
715
|
+
* text: "这是图片说明",
|
|
716
|
+
* mediaUrl: "/tmp/generated-chart.png",
|
|
717
|
+
* account,
|
|
718
|
+
* replyToId: msgId,
|
|
719
|
+
* });
|
|
720
|
+
* ```
|
|
721
|
+
*/
|
|
722
|
+
export async function sendMedia(ctx) {
|
|
723
|
+
const { to, text, replyToId, account } = ctx;
|
|
724
|
+
// 展开波浪线路径:~/Desktop/file.png → /Users/xxx/Desktop/file.png
|
|
725
|
+
const mediaUrl = normalizePath(ctx.mediaUrl);
|
|
726
|
+
if (!account.appId || !account.clientSecret) {
|
|
727
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
728
|
+
}
|
|
729
|
+
if (!mediaUrl) {
|
|
730
|
+
return { channel: "qqbot", error: "mediaUrl is required for sendMedia" };
|
|
731
|
+
}
|
|
732
|
+
// 判断是否为语音文件(本地文件路径 + 音频扩展名)
|
|
733
|
+
const isLocalPath = isLocalFilePath(mediaUrl);
|
|
734
|
+
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
|
735
|
+
if (isLocalPath && isAudioFile(mediaUrl)) {
|
|
736
|
+
return sendVoiceFile(ctx);
|
|
737
|
+
}
|
|
738
|
+
// 判断是否为视频(公网 URL 或本地视频文件)
|
|
739
|
+
if (isVideoFile(mediaUrl)) {
|
|
740
|
+
if (isHttpUrl) {
|
|
741
|
+
return sendVideoUrl(ctx);
|
|
742
|
+
}
|
|
743
|
+
if (isLocalPath) {
|
|
744
|
+
return sendVideoFile(ctx);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// 判断是否为文档/文件(非图片、非音频、非视频的本地文件)
|
|
748
|
+
if (isLocalPath && !isImageFile(mediaUrl) && !isAudioFile(mediaUrl)) {
|
|
749
|
+
return sendDocumentFile(ctx);
|
|
750
|
+
}
|
|
751
|
+
// === 以下为图片发送逻辑(原有逻辑) ===
|
|
752
|
+
const isDataUrl = mediaUrl.startsWith("data:");
|
|
753
|
+
let processedMediaUrl = mediaUrl;
|
|
754
|
+
if (isLocalPath) {
|
|
755
|
+
console.log(`[qqbot] sendMedia: local file path detected: ${mediaUrl}`);
|
|
756
|
+
try {
|
|
757
|
+
if (!(await fileExistsAsync(mediaUrl))) {
|
|
758
|
+
return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
|
|
759
|
+
}
|
|
760
|
+
// 文件大小校验
|
|
761
|
+
const sizeCheck = checkFileSize(mediaUrl);
|
|
762
|
+
if (!sizeCheck.ok) {
|
|
763
|
+
return { channel: "qqbot", error: sizeCheck.error };
|
|
764
|
+
}
|
|
765
|
+
const fileBuffer = await readFileAsync(mediaUrl);
|
|
766
|
+
const base64Data = fileBuffer.toString("base64");
|
|
767
|
+
const ext = path.extname(mediaUrl).toLowerCase();
|
|
768
|
+
const mimeTypes = {
|
|
769
|
+
".jpg": "image/jpeg",
|
|
770
|
+
".jpeg": "image/jpeg",
|
|
771
|
+
".png": "image/png",
|
|
772
|
+
".gif": "image/gif",
|
|
773
|
+
".webp": "image/webp",
|
|
774
|
+
".bmp": "image/bmp",
|
|
775
|
+
};
|
|
776
|
+
const mimeType = mimeTypes[ext];
|
|
777
|
+
if (!mimeType) {
|
|
778
|
+
return {
|
|
779
|
+
channel: "qqbot",
|
|
780
|
+
error: `不支持的图片格式: ${ext}。支持的格式: ${Object.keys(mimeTypes).join(", ")}`
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
processedMediaUrl = `data:${mimeType};base64,${base64Data}`;
|
|
784
|
+
console.log(`[qqbot] sendMedia: local file converted to Base64 (size: ${fileBuffer.length} bytes, type: ${mimeType})`);
|
|
785
|
+
}
|
|
786
|
+
catch (readErr) {
|
|
787
|
+
const errMsg = readErr instanceof Error ? readErr.message : String(readErr);
|
|
788
|
+
console.error(`[qqbot] sendMedia: failed to read local file: ${errMsg}`);
|
|
789
|
+
return { channel: "qqbot", error: `读取本地文件失败: ${errMsg}` };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
else if (!isHttpUrl && !isDataUrl) {
|
|
793
|
+
console.log(`[qqbot] sendMedia: unsupported media format: ${mediaUrl.slice(0, 50)}`);
|
|
794
|
+
return {
|
|
795
|
+
channel: "qqbot",
|
|
796
|
+
error: `不支持的媒体格式: ${mediaUrl.slice(0, 50)}...。支持: 公网 URL、Base64 Data URL 或本地文件路径(图片/音频)。`
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
else if (isDataUrl) {
|
|
800
|
+
console.log(`[qqbot] sendMedia: sending Base64 image (length: ${mediaUrl.length})`);
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
console.log(`[qqbot] sendMedia: sending image URL: ${mediaUrl.slice(0, 80)}...`);
|
|
804
|
+
}
|
|
805
|
+
try {
|
|
806
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
807
|
+
const target = parseTarget(to);
|
|
808
|
+
let imageResult;
|
|
809
|
+
if (target.type === "c2c") {
|
|
810
|
+
imageResult = await sendC2CImageMessage(accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined);
|
|
811
|
+
}
|
|
812
|
+
else if (target.type === "group") {
|
|
813
|
+
imageResult = await sendGroupImageMessage(accessToken, target.id, processedMediaUrl, replyToId ?? undefined, undefined);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
const displayUrl = isLocalPath ? "[本地文件]" : mediaUrl;
|
|
817
|
+
const textWithUrl = text ? `${text}\n${displayUrl}` : displayUrl;
|
|
818
|
+
const result = await sendChannelMessage(accessToken, target.id, textWithUrl, replyToId ?? undefined);
|
|
819
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
820
|
+
}
|
|
821
|
+
if (text?.trim()) {
|
|
822
|
+
try {
|
|
823
|
+
if (target.type === "c2c") {
|
|
824
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
825
|
+
}
|
|
826
|
+
else if (target.type === "group") {
|
|
827
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch (textErr) {
|
|
831
|
+
console.error(`[qqbot] Failed to send text after image: ${textErr}`);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return { channel: "qqbot", messageId: imageResult.id, timestamp: imageResult.timestamp };
|
|
835
|
+
}
|
|
836
|
+
catch (err) {
|
|
837
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
838
|
+
return { channel: "qqbot", error: message };
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* 发送语音文件消息
|
|
843
|
+
* 流程类似图片发送:读取本地音频文件 → 转为 SILK Base64 → 上传 → 发送
|
|
844
|
+
*/
|
|
845
|
+
async function sendVoiceFile(ctx) {
|
|
846
|
+
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
847
|
+
console.log(`[qqbot] sendVoiceFile: ${mediaUrl}`);
|
|
848
|
+
// 等待文件就绪(TTS 工具异步生成,文件可能还没写完)
|
|
849
|
+
const fileSize = await waitForFile(mediaUrl);
|
|
850
|
+
if (fileSize === 0) {
|
|
851
|
+
return { channel: "qqbot", error: `语音生成失败,请稍后重试` };
|
|
852
|
+
}
|
|
853
|
+
try {
|
|
854
|
+
// 尝试转换为 SILK 格式(QQ 语音要求 SILK 格式),支持配置直传格式跳过转换
|
|
855
|
+
const directFormats = account.config?.audioFormatPolicy?.uploadDirectFormats ?? account.config?.voiceDirectUploadFormats;
|
|
856
|
+
const silkBase64 = await audioFileToSilkBase64(mediaUrl, directFormats);
|
|
857
|
+
if (!silkBase64) {
|
|
858
|
+
// 如果无法转换为 SILK,直接读取文件作为 Base64 上传(让 API 尝试处理)
|
|
859
|
+
const buf = await readFileAsync(mediaUrl);
|
|
860
|
+
const fallbackBase64 = buf.toString("base64");
|
|
861
|
+
console.log(`[qqbot] sendVoiceFile: not SILK format, uploading raw file (${formatFileSize(buf.length)})`);
|
|
862
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
863
|
+
const target = parseTarget(to);
|
|
864
|
+
let result;
|
|
865
|
+
if (target.type === "c2c") {
|
|
866
|
+
result = await sendC2CVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
|
|
867
|
+
}
|
|
868
|
+
else if (target.type === "group") {
|
|
869
|
+
result = await sendGroupVoiceMessage(accessToken, target.id, fallbackBase64, replyToId ?? undefined);
|
|
870
|
+
}
|
|
871
|
+
else {
|
|
872
|
+
const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
873
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
874
|
+
}
|
|
875
|
+
return { channel: "qqbot", messageId: result.id, timestamp: result.timestamp };
|
|
876
|
+
}
|
|
877
|
+
console.log(`[qqbot] sendVoiceFile: SILK format ready, uploading...`);
|
|
878
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
879
|
+
const target = parseTarget(to);
|
|
880
|
+
let voiceResult;
|
|
881
|
+
if (target.type === "c2c") {
|
|
882
|
+
voiceResult = await sendC2CVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
883
|
+
}
|
|
884
|
+
else if (target.type === "group") {
|
|
885
|
+
voiceResult = await sendGroupVoiceMessage(accessToken, target.id, silkBase64, replyToId ?? undefined);
|
|
886
|
+
}
|
|
887
|
+
else {
|
|
888
|
+
const r = await sendChannelMessage(accessToken, target.id, `[语音消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
889
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
890
|
+
}
|
|
891
|
+
// 如果有文本说明,再发送一条文本消息
|
|
892
|
+
if (text?.trim()) {
|
|
893
|
+
try {
|
|
894
|
+
if (target.type === "c2c") {
|
|
895
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
896
|
+
}
|
|
897
|
+
else if (target.type === "group") {
|
|
898
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
catch (textErr) {
|
|
902
|
+
console.error(`[qqbot] Failed to send text after voice: ${textErr}`);
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
console.log(`[qqbot] sendVoiceFile: voice message sent`);
|
|
906
|
+
return { channel: "qqbot", messageId: voiceResult.id, timestamp: voiceResult.timestamp };
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
910
|
+
console.error(`[qqbot] sendVoiceFile: failed: ${message}`);
|
|
911
|
+
return { channel: "qqbot", error: message };
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
/** 判断文件是否为图片格式 */
|
|
915
|
+
function isImageFile(filePath) {
|
|
916
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
917
|
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"].includes(ext);
|
|
918
|
+
}
|
|
919
|
+
/** 判断文件/URL 是否为视频格式 */
|
|
920
|
+
function isVideoFile(filePath) {
|
|
921
|
+
// 去掉 URL query 参数后判断扩展名
|
|
922
|
+
const cleanPath = filePath.split("?")[0];
|
|
923
|
+
const ext = path.extname(cleanPath).toLowerCase();
|
|
924
|
+
return [".mp4", ".mov", ".avi", ".mkv", ".webm", ".flv", ".wmv"].includes(ext);
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* 发送视频消息(公网 URL)
|
|
928
|
+
*/
|
|
929
|
+
async function sendVideoUrl(ctx) {
|
|
930
|
+
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
931
|
+
console.log(`[qqbot] sendVideoUrl: ${mediaUrl}`);
|
|
932
|
+
if (!account.appId || !account.clientSecret) {
|
|
933
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
937
|
+
const target = parseTarget(to);
|
|
938
|
+
let videoResult;
|
|
939
|
+
if (target.type === "c2c") {
|
|
940
|
+
videoResult = await sendC2CVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
|
|
941
|
+
}
|
|
942
|
+
else if (target.type === "group") {
|
|
943
|
+
videoResult = await sendGroupVideoMessage(accessToken, target.id, mediaUrl, undefined, replyToId ?? undefined);
|
|
944
|
+
}
|
|
945
|
+
else {
|
|
946
|
+
const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
947
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
948
|
+
}
|
|
949
|
+
// 如果有文本说明,再发送一条文本消息
|
|
950
|
+
if (text?.trim()) {
|
|
951
|
+
try {
|
|
952
|
+
if (target.type === "c2c") {
|
|
953
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
954
|
+
}
|
|
955
|
+
else if (target.type === "group") {
|
|
956
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
catch (textErr) {
|
|
960
|
+
console.error(`[qqbot] Failed to send text after video: ${textErr}`);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
console.log(`[qqbot] sendVideoUrl: video message sent`);
|
|
964
|
+
return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp };
|
|
965
|
+
}
|
|
966
|
+
catch (err) {
|
|
967
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
968
|
+
console.error(`[qqbot] sendVideoUrl: failed: ${message}`);
|
|
969
|
+
return { channel: "qqbot", error: message };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/**
|
|
973
|
+
* 发送本地视频文件
|
|
974
|
+
* 流程:读取本地文件 → Base64 → 上传(file_type=2) → 发送
|
|
975
|
+
*/
|
|
976
|
+
async function sendVideoFile(ctx) {
|
|
977
|
+
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
978
|
+
console.log(`[qqbot] sendVideoFile: ${mediaUrl}`);
|
|
979
|
+
if (!account.appId || !account.clientSecret) {
|
|
980
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
981
|
+
}
|
|
982
|
+
try {
|
|
983
|
+
if (!(await fileExistsAsync(mediaUrl))) {
|
|
984
|
+
return { channel: "qqbot", error: `视频文件不存在: ${mediaUrl}` };
|
|
985
|
+
}
|
|
986
|
+
// 文件大小校验
|
|
987
|
+
const sizeCheck = checkFileSize(mediaUrl);
|
|
988
|
+
if (!sizeCheck.ok) {
|
|
989
|
+
return { channel: "qqbot", error: sizeCheck.error };
|
|
990
|
+
}
|
|
991
|
+
const fileBuffer = await readFileAsync(mediaUrl);
|
|
992
|
+
const videoBase64 = fileBuffer.toString("base64");
|
|
993
|
+
console.log(`[qqbot] sendVideoFile: Read local video (${formatFileSize(fileBuffer.length)})`);
|
|
994
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
995
|
+
const target = parseTarget(to);
|
|
996
|
+
let videoResult;
|
|
997
|
+
if (target.type === "c2c") {
|
|
998
|
+
videoResult = await sendC2CVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
999
|
+
}
|
|
1000
|
+
else if (target.type === "group") {
|
|
1001
|
+
videoResult = await sendGroupVideoMessage(accessToken, target.id, undefined, videoBase64, replyToId ?? undefined);
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
const r = await sendChannelMessage(accessToken, target.id, `[视频消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1005
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1006
|
+
}
|
|
1007
|
+
// 如果有文本说明,再发送一条文本消息
|
|
1008
|
+
if (text?.trim()) {
|
|
1009
|
+
try {
|
|
1010
|
+
if (target.type === "c2c") {
|
|
1011
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1012
|
+
}
|
|
1013
|
+
else if (target.type === "group") {
|
|
1014
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
catch (textErr) {
|
|
1018
|
+
console.error(`[qqbot] Failed to send text after video: ${textErr}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
console.log(`[qqbot] sendVideoFile: video message sent`);
|
|
1022
|
+
return { channel: "qqbot", messageId: videoResult.id, timestamp: videoResult.timestamp };
|
|
1023
|
+
}
|
|
1024
|
+
catch (err) {
|
|
1025
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1026
|
+
console.error(`[qqbot] sendVideoFile: failed: ${message}`);
|
|
1027
|
+
return { channel: "qqbot", error: message };
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* 发送文件消息
|
|
1032
|
+
* 流程:读取本地文件 → Base64 → 上传(file_type=4) → 发送
|
|
1033
|
+
* 支持本地文件路径和公网 URL
|
|
1034
|
+
*/
|
|
1035
|
+
async function sendDocumentFile(ctx) {
|
|
1036
|
+
const { to, text, replyToId, account, mediaUrl } = ctx;
|
|
1037
|
+
console.log(`[qqbot] sendDocumentFile: ${mediaUrl}`);
|
|
1038
|
+
if (!account.appId || !account.clientSecret) {
|
|
1039
|
+
return { channel: "qqbot", error: "QQBot not configured (missing appId or clientSecret)" };
|
|
1040
|
+
}
|
|
1041
|
+
const isHttpUrl = mediaUrl.startsWith("http://") || mediaUrl.startsWith("https://");
|
|
1042
|
+
const fileName = sanitizeFileName(path.basename(mediaUrl));
|
|
1043
|
+
try {
|
|
1044
|
+
const accessToken = await getAccessToken(account.appId, account.clientSecret);
|
|
1045
|
+
const target = parseTarget(to);
|
|
1046
|
+
let fileResult;
|
|
1047
|
+
if (isHttpUrl) {
|
|
1048
|
+
// 公网 URL:通过 url 参数上传
|
|
1049
|
+
console.log(`[qqbot] sendDocumentFile: uploading via URL: ${mediaUrl}`);
|
|
1050
|
+
if (target.type === "c2c") {
|
|
1051
|
+
fileResult = await sendC2CFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
|
|
1052
|
+
}
|
|
1053
|
+
else if (target.type === "group") {
|
|
1054
|
+
fileResult = await sendGroupFileMessage(accessToken, target.id, undefined, mediaUrl, replyToId ?? undefined, fileName);
|
|
1055
|
+
}
|
|
1056
|
+
else {
|
|
1057
|
+
const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1058
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
else {
|
|
1062
|
+
// 本地文件:读取转 Base64 上传
|
|
1063
|
+
if (!(await fileExistsAsync(mediaUrl))) {
|
|
1064
|
+
return { channel: "qqbot", error: `本地文件不存在: ${mediaUrl}` };
|
|
1065
|
+
}
|
|
1066
|
+
// 文件大小校验
|
|
1067
|
+
const docSizeCheck = checkFileSize(mediaUrl);
|
|
1068
|
+
if (!docSizeCheck.ok) {
|
|
1069
|
+
return { channel: "qqbot", error: docSizeCheck.error };
|
|
1070
|
+
}
|
|
1071
|
+
const fileBuffer = await readFileAsync(mediaUrl);
|
|
1072
|
+
if (fileBuffer.length === 0) {
|
|
1073
|
+
return { channel: "qqbot", error: `文件内容为空: ${mediaUrl}` };
|
|
1074
|
+
}
|
|
1075
|
+
const fileBase64 = fileBuffer.toString("base64");
|
|
1076
|
+
console.log(`[qqbot] sendDocumentFile: read local file (${formatFileSize(fileBuffer.length)}), uploading...`);
|
|
1077
|
+
if (target.type === "c2c") {
|
|
1078
|
+
fileResult = await sendC2CFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
1079
|
+
}
|
|
1080
|
+
else if (target.type === "group") {
|
|
1081
|
+
fileResult = await sendGroupFileMessage(accessToken, target.id, fileBase64, undefined, replyToId ?? undefined, fileName);
|
|
1082
|
+
}
|
|
1083
|
+
else {
|
|
1084
|
+
const r = await sendChannelMessage(accessToken, target.id, `[文件消息暂不支持频道发送]`, replyToId ?? undefined);
|
|
1085
|
+
return { channel: "qqbot", messageId: r.id, timestamp: r.timestamp };
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
// 如果有附带文本说明,再发送一条文本消息
|
|
1089
|
+
if (text?.trim()) {
|
|
1090
|
+
try {
|
|
1091
|
+
if (target.type === "c2c") {
|
|
1092
|
+
await sendC2CMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1093
|
+
}
|
|
1094
|
+
else if (target.type === "group") {
|
|
1095
|
+
await sendGroupMessage(accessToken, target.id, text, replyToId ?? undefined);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
catch (textErr) {
|
|
1099
|
+
console.error(`[qqbot] Failed to send text after file: ${textErr}`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
console.log(`[qqbot] sendDocumentFile: file message sent`);
|
|
1103
|
+
return { channel: "qqbot", messageId: fileResult.id, timestamp: fileResult.timestamp };
|
|
1104
|
+
}
|
|
1105
|
+
catch (err) {
|
|
1106
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1107
|
+
console.error(`[qqbot] sendDocumentFile: failed: ${message}`);
|
|
1108
|
+
return { channel: "qqbot", error: message };
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
/**
|
|
1112
|
+
* 发送 Cron 触发的消息
|
|
1113
|
+
*
|
|
1114
|
+
* 当 OpenClaw cron 任务触发时,消息内容可能是:
|
|
1115
|
+
* 1. QQBOT_CRON:{base64} 格式的结构化载荷 - 解码后根据 targetType 和 targetAddress 发送
|
|
1116
|
+
* 2. 普通文本 - 直接发送到指定目标
|
|
1117
|
+
*
|
|
1118
|
+
* @param account - 账户配置
|
|
1119
|
+
* @param to - 目标地址(作为后备,如果载荷中没有指定)
|
|
1120
|
+
* @param message - 消息内容(可能是 QQBOT_CRON: 格式或普通文本)
|
|
1121
|
+
* @returns 发送结果
|
|
1122
|
+
*
|
|
1123
|
+
* @example
|
|
1124
|
+
* ```typescript
|
|
1125
|
+
* // 处理结构化载荷
|
|
1126
|
+
* const result = await sendCronMessage(
|
|
1127
|
+
* account,
|
|
1128
|
+
* "user_openid", // 后备地址
|
|
1129
|
+
* "QQBOT_CRON:eyJ0eXBlIjoiY3Jvbl9yZW1pbmRlciIs..." // Base64 编码的载荷
|
|
1130
|
+
* );
|
|
1131
|
+
*
|
|
1132
|
+
* // 处理普通文本
|
|
1133
|
+
* const result = await sendCronMessage(
|
|
1134
|
+
* account,
|
|
1135
|
+
* "user_openid",
|
|
1136
|
+
* "这是一条普通的提醒消息"
|
|
1137
|
+
* );
|
|
1138
|
+
* ```
|
|
1139
|
+
*/
|
|
1140
|
+
export async function sendCronMessage(account, to, message) {
|
|
1141
|
+
const timestamp = new Date().toISOString();
|
|
1142
|
+
console.log(`[${timestamp}] [qqbot] sendCronMessage: to=${to}, message length=${message.length}`);
|
|
1143
|
+
// 检测是否是 QQBOT_CRON: 格式的结构化载荷
|
|
1144
|
+
const cronResult = decodeCronPayload(message);
|
|
1145
|
+
if (cronResult.isCronPayload) {
|
|
1146
|
+
if (cronResult.error) {
|
|
1147
|
+
console.error(`[${timestamp}] [qqbot] sendCronMessage: cron payload decode error: ${cronResult.error}`);
|
|
1148
|
+
return {
|
|
1149
|
+
channel: "qqbot",
|
|
1150
|
+
error: `Cron 载荷解码失败: ${cronResult.error}`
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
if (cronResult.payload) {
|
|
1154
|
+
const payload = cronResult.payload;
|
|
1155
|
+
console.log(`[${timestamp}] [qqbot] sendCronMessage: decoded cron payload, targetType=${payload.targetType}, targetAddress=${payload.targetAddress}, content length=${payload.content.length}`);
|
|
1156
|
+
// 使用载荷中的目标地址和类型发送消息
|
|
1157
|
+
const targetTo = payload.targetType === "group"
|
|
1158
|
+
? `group:${payload.targetAddress}`
|
|
1159
|
+
: payload.targetAddress;
|
|
1160
|
+
console.log(`[${timestamp}] [qqbot] sendCronMessage: sending proactive message to targetTo=${targetTo}`);
|
|
1161
|
+
// 发送提醒内容
|
|
1162
|
+
const result = await sendProactiveMessage(account, targetTo, payload.content);
|
|
1163
|
+
if (result.error) {
|
|
1164
|
+
console.error(`[${timestamp}] [qqbot] sendCronMessage: proactive message failed, error=${result.error}`);
|
|
1165
|
+
}
|
|
1166
|
+
else {
|
|
1167
|
+
console.log(`[${timestamp}] [qqbot] sendCronMessage: proactive message sent successfully`);
|
|
1168
|
+
}
|
|
1169
|
+
return result;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
// 非结构化载荷,作为普通文本处理
|
|
1173
|
+
console.log(`[${timestamp}] [qqbot] sendCronMessage: plain text message, sending to ${to}`);
|
|
1174
|
+
return await sendProactiveMessage(account, to, message);
|
|
1175
|
+
}
|