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