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