@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
package/src/api.ts ADDED
@@ -0,0 +1,1138 @@
1
+ /**
2
+ * QQ Bot API 鉴权和请求封装
3
+ * [修复版] 已重构为支持多实例并发,消除全局变量冲突
4
+ */
5
+
6
+ import os from "node:os";
7
+ import { computeFileHash, getCachedFileInfo, setCachedFileInfo } from "./utils/upload-cache.js";
8
+ import { sanitizeFileName } from "./utils/platform.js";
9
+
10
+ // ============ 自定义错误 ============
11
+
12
+ /** API 请求错误,携带 HTTP status code */
13
+ export class ApiError extends Error {
14
+ constructor(
15
+ message: string,
16
+ public readonly status: number,
17
+ public readonly path: string,
18
+ ) {
19
+ super(message);
20
+ this.name = "ApiError";
21
+ }
22
+ }
23
+
24
+ const API_BASE = "https://api.sgroup.qq.com";
25
+ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
26
+
27
+ // ============ Plugin User-Agent ============
28
+ // 格式: QQBotPlugin/{version} (Node/{nodeVersion}; {os})
29
+ // 示例: QQBotPlugin/1.6.0 (Node/22.14.0; darwin)
30
+ import { getPackageVersion } from "./utils/pkg-version.js";
31
+ const _pluginVersion = getPackageVersion(import.meta.url);
32
+ export const PLUGIN_USER_AGENT = `QQBotPlugin/${_pluginVersion} (Node/${process.versions.node}; ${os.platform()})`;
33
+
34
+ // 运行时配置
35
+ let currentMarkdownSupport = false;
36
+
37
+ // 出站消息回调钩子:消息发送成功且回包含 ext_info.ref_idx 时触发
38
+ // 由外层(gateway/outbound)注册,用于统一缓存 bot 出站消息的 refIdx
39
+
40
+ /** 出站消息元信息(结构化存储,不做预格式化) */
41
+ export interface OutboundMeta {
42
+ /** 消息文本内容 */
43
+ text?: string;
44
+ /** 媒体类型 */
45
+ mediaType?: "image" | "voice" | "video" | "file";
46
+ /** 媒体来源:在线 URL */
47
+ mediaUrl?: string;
48
+ /** 媒体来源:本地文件路径或文件名 */
49
+ mediaLocalPath?: string;
50
+ /** TTS 原文本(仅 voice 类型有效,用于保存 TTS 前的文本内容) */
51
+ ttsText?: string;
52
+ }
53
+
54
+ type OnMessageSentCallback = (refIdx: string, meta: OutboundMeta) => void;
55
+ let onMessageSentHook: OnMessageSentCallback | null = null;
56
+
57
+ /**
58
+ * 注册出站消息回调
59
+ * 当消息发送成功且 QQ 返回 ref_idx 时,自动回调此函数
60
+ * 用于在最底层统一缓存 bot 出站消息的 refIdx
61
+ */
62
+ export function onMessageSent(callback: OnMessageSentCallback): void {
63
+ onMessageSentHook = callback;
64
+ }
65
+
66
+ /**
67
+ * 初始化 API 配置
68
+ */
69
+ export function initApiConfig(options: { markdownSupport?: boolean }): void {
70
+ currentMarkdownSupport = options.markdownSupport === true;
71
+ }
72
+
73
+ /**
74
+ * 获取当前是否支持 markdown
75
+ */
76
+ export function isMarkdownSupport(): boolean {
77
+ return currentMarkdownSupport;
78
+ }
79
+
80
+ // =========================================================================
81
+ // 🚀 [核心修复] 将全局状态改为 Map,按 appId 隔离,彻底解决多账号串号问题
82
+ // =========================================================================
83
+ const tokenCacheMap = new Map<string, { token: string; expiresAt: number; appId: string }>();
84
+ const tokenFetchPromises = new Map<string, Promise<string>>();
85
+
86
+ /**
87
+ * 获取 AccessToken(带缓存 + singleflight 并发安全)
88
+ *
89
+ * 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
90
+ * 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
91
+ *
92
+ * 按 appId 隔离,支持多机器人并发请求。
93
+ */
94
+ export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
95
+ const normalizedAppId = String(appId).trim();
96
+ const cachedToken = tokenCacheMap.get(normalizedAppId);
97
+
98
+ // 检查缓存:未过期时复用
99
+ // 提前刷新阈值:取 expiresIn 的 1/3 和 5 分钟的较小值,避免短有效期 token 永远被判定过期
100
+ const REFRESH_AHEAD_MS = cachedToken
101
+ ? Math.min(5 * 60 * 1000, (cachedToken.expiresAt - Date.now()) / 3)
102
+ : 0;
103
+ if (cachedToken && Date.now() < cachedToken.expiresAt - REFRESH_AHEAD_MS) {
104
+ return cachedToken.token;
105
+ }
106
+
107
+ // Singleflight: 如果当前 appId 已有进行中的 Token 获取请求,复用它
108
+ let fetchPromise = tokenFetchPromises.get(normalizedAppId);
109
+ if (fetchPromise) {
110
+ console.log(`[qqbot-api:${normalizedAppId}] Token fetch in progress, waiting for existing request...`);
111
+ return fetchPromise;
112
+ }
113
+
114
+ // 创建新的 Token 获取 Promise(singleflight 入口)
115
+ fetchPromise = (async () => {
116
+ try {
117
+ return await doFetchToken(normalizedAppId, clientSecret);
118
+ } finally {
119
+ // 无论成功失败,都清除 Promise 缓存
120
+ tokenFetchPromises.delete(normalizedAppId);
121
+ }
122
+ })();
123
+
124
+ tokenFetchPromises.set(normalizedAppId, fetchPromise);
125
+ return fetchPromise;
126
+ }
127
+
128
+ /**
129
+ * 实际执行 Token 获取的内部函数
130
+ */
131
+ async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
132
+ const requestBody = { appId, clientSecret };
133
+ const requestHeaders = { "Content-Type": "application/json", "User-Agent": PLUGIN_USER_AGENT };
134
+
135
+ // 打印请求信息(隐藏敏感信息)
136
+ console.log(`[qqbot-api:${appId}] >>> POST ${TOKEN_URL}`);
137
+
138
+ let response: Response;
139
+ try {
140
+ response = await fetch(TOKEN_URL, {
141
+ method: "POST",
142
+ headers: requestHeaders,
143
+ body: JSON.stringify(requestBody),
144
+ });
145
+ } catch (err) {
146
+ console.error(`[qqbot-api:${appId}] <<< Network error:`, err);
147
+ throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
148
+ }
149
+
150
+ // 打印响应头
151
+ const responseHeaders: Record<string, string> = {};
152
+ response.headers.forEach((value, key) => {
153
+ responseHeaders[key] = value;
154
+ });
155
+ const tokenTraceId = response.headers.get("x-tps-trace-id") ?? "";
156
+ console.log(`[qqbot-api:${appId}] <<< Status: ${response.status} ${response.statusText}${tokenTraceId ? ` | TraceId: ${tokenTraceId}` : ""}`);
157
+
158
+ let data: { access_token?: string; expires_in?: number };
159
+ let rawBody: string;
160
+ try {
161
+ rawBody = await response.text();
162
+ // 隐藏 token 值
163
+ const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
164
+ console.log(`[qqbot-api:${appId}] <<< Body:`, logBody);
165
+ data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
166
+ } catch (err) {
167
+ console.error(`[qqbot-api:${appId}] <<< Parse error:`, err);
168
+ throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+
171
+ if (!data.access_token) {
172
+ throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
173
+ }
174
+
175
+ const expiresAt = Date.now() + (data.expires_in ?? 7200) * 1000;
176
+
177
+ tokenCacheMap.set(appId, {
178
+ token: data.access_token,
179
+ expiresAt,
180
+ appId,
181
+ });
182
+
183
+ console.log(`[qqbot-api:${appId}] Token cached, expires at: ${new Date(expiresAt).toISOString()}`);
184
+ return data.access_token;
185
+ }
186
+
187
+ /**
188
+ * 清除 Token 缓存
189
+ * @param appId 选填。如果有,只清空特定账号的缓存;如果没有,清空所有账号。
190
+ */
191
+ export function clearTokenCache(appId?: string): void {
192
+ if (appId) {
193
+ const normalizedAppId = String(appId).trim();
194
+ tokenCacheMap.delete(normalizedAppId);
195
+ console.log(`[qqbot-api:${normalizedAppId}] Token cache cleared manually.`);
196
+ } else {
197
+ tokenCacheMap.clear();
198
+ console.log(`[qqbot-api] All token caches cleared.`);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * 获取 Token 缓存状态(用于监控)
204
+ */
205
+ export function getTokenStatus(appId: string): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
206
+ if (tokenFetchPromises.has(appId)) {
207
+ return { status: "refreshing", expiresAt: tokenCacheMap.get(appId)?.expiresAt ?? null };
208
+ }
209
+ const cached = tokenCacheMap.get(appId);
210
+ if (!cached) {
211
+ return { status: "none", expiresAt: null };
212
+ }
213
+ const remaining = cached.expiresAt - Date.now();
214
+ const isValid = remaining > Math.min(5 * 60 * 1000, remaining / 3);
215
+ return { status: isValid ? "valid" : "expired", expiresAt: cached.expiresAt };
216
+ }
217
+
218
+ /**
219
+ * 获取全局唯一的消息序号(范围 0 ~ 65535)
220
+ * 使用毫秒级时间戳低位 + 随机数异或混合,无状态,避免碰撞
221
+ */
222
+ export function getNextMsgSeq(_msgId: string): number {
223
+ const timePart = Date.now() % 100000000; // 毫秒时间戳后8位
224
+ const random = Math.floor(Math.random() * 65536); // 0~65535
225
+ return (timePart ^ random) % 65536; // 异或混合后限制在 0~65535
226
+ }
227
+
228
+ // API 请求超时配置(毫秒)
229
+ const DEFAULT_API_TIMEOUT = 30000; // 默认 30 秒
230
+ const FILE_UPLOAD_TIMEOUT = 120000; // 文件上传 120 秒
231
+
232
+ /**
233
+ * API 请求封装
234
+ */
235
+ export async function apiRequest<T = unknown>(
236
+ accessToken: string,
237
+ method: string,
238
+ path: string,
239
+ body?: unknown,
240
+ timeoutMs?: number
241
+ ): Promise<T> {
242
+ const url = `${API_BASE}${path}`;
243
+ const headers: Record<string, string> = {
244
+ Authorization: `QQBot ${accessToken}`,
245
+ "Content-Type": "application/json",
246
+ "User-Agent": PLUGIN_USER_AGENT,
247
+ };
248
+
249
+ const isFileUpload = path.includes("/files");
250
+ const timeout = timeoutMs ?? (isFileUpload ? FILE_UPLOAD_TIMEOUT : DEFAULT_API_TIMEOUT);
251
+
252
+ const controller = new AbortController();
253
+ const timeoutId = setTimeout(() => {
254
+ controller.abort();
255
+ }, timeout);
256
+
257
+ const options: RequestInit = {
258
+ method,
259
+ headers,
260
+ signal: controller.signal,
261
+ };
262
+
263
+ if (body) {
264
+ options.body = JSON.stringify(body);
265
+ }
266
+
267
+ // 打印请求信息
268
+ console.log(`[qqbot-api] >>> ${method} ${url} (timeout: ${timeout}ms)`);
269
+ if (body) {
270
+ const logBody = { ...body } as Record<string, unknown>;
271
+ if (typeof logBody.file_data === "string") {
272
+ logBody.file_data = `<base64 ${(logBody.file_data as string).length} chars>`;
273
+ }
274
+ console.log(`[qqbot-api] >>> Body:`, JSON.stringify(logBody));
275
+ }
276
+
277
+ let res: Response;
278
+ try {
279
+ res = await fetch(url, options);
280
+ } catch (err) {
281
+ clearTimeout(timeoutId);
282
+ if (err instanceof Error && err.name === "AbortError") {
283
+ console.error(`[qqbot-api] <<< Request timeout after ${timeout}ms`);
284
+ throw new Error(`Request timeout[${path}]: exceeded ${timeout}ms`);
285
+ }
286
+ console.error(`[qqbot-api] <<< Network error:`, err);
287
+ throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
288
+ } finally {
289
+ clearTimeout(timeoutId);
290
+ }
291
+
292
+ const responseHeaders: Record<string, string> = {};
293
+ res.headers.forEach((value, key) => {
294
+ responseHeaders[key] = value;
295
+ });
296
+ const traceId = res.headers.get("x-tps-trace-id") ?? "";
297
+ console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}${traceId ? ` | TraceId: ${traceId}` : ""}`);
298
+
299
+ let rawBody: string;
300
+ try {
301
+ rawBody = await res.text();
302
+ } catch (err) {
303
+ throw new Error(`读取响应失败[${path}]: ${err instanceof Error ? err.message : String(err)}`);
304
+ }
305
+ console.log(`[qqbot-api] <<< Body:`, rawBody);
306
+
307
+ // 检测非 JSON 响应(HTML 网关错误页 / CDN 限流页等)
308
+ const contentType = res.headers.get("content-type") ?? "";
309
+ const isHtmlResponse = contentType.includes("text/html") || rawBody.trimStart().startsWith("<");
310
+
311
+ if (!res.ok) {
312
+ if (isHtmlResponse) {
313
+ // HTML 响应 = 网关/限流层返回的错误页,给出友好提示
314
+ const statusHint = res.status === 502 || res.status === 503 || res.status === 504
315
+ ? "调用发生异常,请稍候重试"
316
+ : res.status === 429
317
+ ? "请求过于频繁,已被限流"
318
+ : `开放平台返回 HTTP ${res.status}`;
319
+ throw new ApiError(`${statusHint}(${path}),请稍后重试`, res.status, path);
320
+ }
321
+ // JSON 错误响应
322
+ try {
323
+ const error = JSON.parse(rawBody) as { message?: string; code?: number };
324
+ throw new ApiError(`API Error [${path}]: ${error.message ?? rawBody}`, res.status, path);
325
+ } catch (parseErr) {
326
+ if (parseErr instanceof ApiError) throw parseErr;
327
+ throw new ApiError(`API Error [${path}] HTTP ${res.status}: ${rawBody.slice(0, 200)}`, res.status, path);
328
+ }
329
+ }
330
+
331
+ // 成功响应但不是 JSON(极端异常情况)
332
+ if (isHtmlResponse) {
333
+ throw new Error(`QQ 服务端返回了非 JSON 响应(${path}),可能是临时故障,请稍后重试`);
334
+ }
335
+
336
+ try {
337
+ return JSON.parse(rawBody) as T;
338
+ } catch {
339
+ throw new Error(`开放平台响应格式异常(${path}),请稍后重试`);
340
+ }
341
+ }
342
+
343
+ // ============ 上传重试(指数退避) ============
344
+
345
+ const UPLOAD_MAX_RETRIES = 2;
346
+ const UPLOAD_BASE_DELAY_MS = 1000;
347
+
348
+ async function apiRequestWithRetry<T = unknown>(
349
+ accessToken: string,
350
+ method: string,
351
+ path: string,
352
+ body?: unknown,
353
+ maxRetries = UPLOAD_MAX_RETRIES,
354
+ ): Promise<T> {
355
+ let lastError: Error | null = null;
356
+
357
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
358
+ try {
359
+ return await apiRequest<T>(accessToken, method, path, body);
360
+ } catch (err) {
361
+ lastError = err instanceof Error ? err : new Error(String(err));
362
+
363
+ const errMsg = lastError.message;
364
+ if (
365
+ errMsg.includes("400") || errMsg.includes("401") || errMsg.includes("Invalid") ||
366
+ errMsg.includes("上传超时") || errMsg.includes("timeout") || errMsg.includes("Timeout")
367
+ ) {
368
+ throw lastError;
369
+ }
370
+
371
+ if (attempt < maxRetries) {
372
+ const delay = UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
373
+ console.log(`[qqbot-api] Upload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${errMsg.slice(0, 100)}`);
374
+ await new Promise(resolve => setTimeout(resolve, delay));
375
+ }
376
+ }
377
+ }
378
+
379
+ throw lastError!;
380
+ }
381
+
382
+ // ============ 完成上传重试(无条件,任何错误都重试) ============
383
+
384
+ const COMPLETE_UPLOAD_MAX_RETRIES = 2;
385
+ const COMPLETE_UPLOAD_BASE_DELAY_MS = 2000;
386
+
387
+ /**
388
+ * 完成上传专用重试:无条件重试所有错误(包括 4xx、5xx、网络错误、超时等)
389
+ * 分片上传完成接口的失败往往是平台侧异步处理未就绪,重试通常能成功
390
+ */
391
+ async function completeUploadWithRetry(
392
+ accessToken: string,
393
+ method: string,
394
+ path: string,
395
+ body?: unknown,
396
+ ): Promise<MediaUploadResponse> {
397
+ let lastError: Error | null = null;
398
+
399
+ for (let attempt = 0; attempt <= COMPLETE_UPLOAD_MAX_RETRIES; attempt++) {
400
+ try {
401
+ return await apiRequest<MediaUploadResponse>(accessToken, method, path, body);
402
+ } catch (err) {
403
+ lastError = err instanceof Error ? err : new Error(String(err));
404
+
405
+ if (attempt < COMPLETE_UPLOAD_MAX_RETRIES) {
406
+ const delay = COMPLETE_UPLOAD_BASE_DELAY_MS * Math.pow(2, attempt);
407
+ console.warn(`[qqbot-api] CompleteUpload attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
408
+ await new Promise(resolve => setTimeout(resolve, delay));
409
+ }
410
+ }
411
+ }
412
+
413
+ throw lastError!;
414
+ }
415
+
416
+ // ============ 分片完成重试(无条件,与 completeUpload 策略一致) ============
417
+
418
+ const PART_FINISH_MAX_RETRIES = 2;
419
+ const PART_FINISH_BASE_DELAY_MS = 1000;
420
+
421
+ async function partFinishWithRetry(
422
+ accessToken: string,
423
+ method: string,
424
+ path: string,
425
+ body?: unknown,
426
+ ): Promise<void> {
427
+ let lastError: Error | null = null;
428
+
429
+ for (let attempt = 0; attempt <= PART_FINISH_MAX_RETRIES; attempt++) {
430
+ try {
431
+ await apiRequest<Record<string, unknown>>(accessToken, method, path, body);
432
+ return;
433
+ } catch (err) {
434
+ lastError = err instanceof Error ? err : new Error(String(err));
435
+
436
+ if (attempt < PART_FINISH_MAX_RETRIES) {
437
+ const delay = PART_FINISH_BASE_DELAY_MS * Math.pow(2, attempt);
438
+ console.warn(`[qqbot-api] PartFinish attempt ${attempt + 1} failed, retrying in ${delay}ms: ${lastError.message.slice(0, 200)}`);
439
+ await new Promise(resolve => setTimeout(resolve, delay));
440
+ }
441
+ }
442
+ }
443
+
444
+ throw lastError!;
445
+ }
446
+
447
+ export async function getGatewayUrl(accessToken: string): Promise<string> {
448
+ const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
449
+ return data.url;
450
+ }
451
+
452
+ /** 回应按钮交互(INTERACTION_CREATE),避免客户端按钮持续 loading */
453
+ export async function acknowledgeInteraction(
454
+ accessToken: string,
455
+ interactionId: string,
456
+ code: 0 | 1 | 2 | 3 | 4 | 5 = 0,
457
+ data?: Record<string, unknown>
458
+ ): Promise<void> {
459
+ await apiRequest(accessToken, "PUT", `/interactions/${interactionId}`, { code, ...(data ? { data } : {}) });
460
+ }
461
+
462
+ /** 获取插件版本号(从 package.json 读取,和 PLUGIN_USER_AGENT 同源) */
463
+ export function getApiPluginVersion(): string {
464
+ return _pluginVersion;
465
+ }
466
+
467
+ // ============ 消息发送接口 ============
468
+
469
+ export interface MessageResponse {
470
+ id: string;
471
+ timestamp: number | string;
472
+ /** 消息的引用索引信息(出站时由 QQ 服务端返回) */
473
+ ext_info?: {
474
+ ref_idx?: string;
475
+ };
476
+ }
477
+
478
+ /**
479
+ * 发送消息并自动触发 refIdx 回调
480
+ * 所有消息发送函数统一经过此处,确保每条出站消息的 refIdx 都被捕获
481
+ */
482
+ async function sendAndNotify(
483
+ accessToken: string,
484
+ method: string,
485
+ path: string,
486
+ body: unknown,
487
+ meta: OutboundMeta,
488
+ ): Promise<MessageResponse> {
489
+ const result = await apiRequest<MessageResponse>(accessToken, method, path, body);
490
+ if (result.ext_info?.ref_idx && onMessageSentHook) {
491
+ try {
492
+ onMessageSentHook(result.ext_info.ref_idx, meta);
493
+ } catch (err) {
494
+ console.error(`[qqbot-api] onMessageSent hook error: ${err}`);
495
+ }
496
+ }
497
+ return result;
498
+ }
499
+
500
+ function buildMessageBody(
501
+ content: string,
502
+ msgId: string | undefined,
503
+ msgSeq: number,
504
+ messageReference?: string
505
+ ): Record<string, unknown> {
506
+ const body: Record<string, unknown> = currentMarkdownSupport
507
+ ? {
508
+ markdown: { content },
509
+ msg_type: 2,
510
+ msg_seq: msgSeq,
511
+ }
512
+ : {
513
+ content,
514
+ msg_type: 0,
515
+ msg_seq: msgSeq,
516
+ };
517
+
518
+ if (msgId) {
519
+ body.msg_id = msgId;
520
+ }
521
+ if (messageReference && !currentMarkdownSupport) {
522
+ body.message_reference = { message_id: messageReference };
523
+ }
524
+ return body;
525
+ }
526
+
527
+ export async function sendC2CMessage(
528
+ accessToken: string,
529
+ openid: string,
530
+ content: string,
531
+ msgId?: string,
532
+ messageReference?: string
533
+ ): Promise<MessageResponse> {
534
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
535
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
536
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
537
+ }
538
+
539
+ export async function sendC2CInputNotify(
540
+ accessToken: string,
541
+ openid: string,
542
+ msgId?: string,
543
+ inputSecond: number = 60
544
+ ): Promise<{ refIdx?: string }> {
545
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
546
+ const body = {
547
+ msg_type: 6,
548
+ input_notify: {
549
+ input_type: 1,
550
+ input_second: inputSecond,
551
+ },
552
+ msg_seq: msgSeq,
553
+ ...(msgId ? { msg_id: msgId } : {}),
554
+ };
555
+ const response = await apiRequest<{ ext_info?: { ref_idx?: string } }>(accessToken, "POST", `/v2/users/${openid}/messages`, body);
556
+ return { refIdx: response.ext_info?.ref_idx };
557
+ }
558
+
559
+ export async function sendChannelMessage(
560
+ accessToken: string,
561
+ channelId: string,
562
+ content: string,
563
+ msgId?: string
564
+ ): Promise<{ id: string; timestamp: string }> {
565
+ return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
566
+ content,
567
+ ...(msgId ? { msg_id: msgId } : {}),
568
+ });
569
+ }
570
+
571
+ /**
572
+ * 发送频道私信消息
573
+ * @param guildId - 私信会话的 guild_id(由 DIRECT_MESSAGE_CREATE 事件提供)
574
+ * @param msgId - 被动回复时必填
575
+ */
576
+ export async function sendDmMessage(
577
+ accessToken: string,
578
+ guildId: string,
579
+ content: string,
580
+ msgId?: string
581
+ ): Promise<{ id: string; timestamp: string }> {
582
+ return apiRequest(accessToken, "POST", `/dms/${guildId}/messages`, {
583
+ content,
584
+ ...(msgId ? { msg_id: msgId } : {}),
585
+ });
586
+ }
587
+
588
+ export async function sendGroupMessage(
589
+ accessToken: string,
590
+ groupOpenid: string,
591
+ content: string,
592
+ msgId?: string,
593
+ messageReference?: string
594
+ ): Promise<MessageResponse> {
595
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
596
+ const body = buildMessageBody(content, msgId, msgSeq, messageReference);
597
+ return sendAndNotify(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body, { text: content });
598
+ }
599
+
600
+ function buildProactiveMessageBody(content: string): Record<string, unknown> {
601
+ if (!content || content.trim().length === 0) {
602
+ throw new Error("主动消息内容不能为空 (markdown.content is empty)");
603
+ }
604
+ if (currentMarkdownSupport) {
605
+ return { markdown: { content }, msg_type: 2 };
606
+ } else {
607
+ return { content, msg_type: 0 };
608
+ }
609
+ }
610
+
611
+ export async function sendProactiveC2CMessage(
612
+ accessToken: string,
613
+ openid: string,
614
+ content: string
615
+ ): Promise<MessageResponse> {
616
+ const body = buildProactiveMessageBody(content);
617
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, body, { text: content });
618
+ }
619
+
620
+ export async function sendProactiveGroupMessage(
621
+ accessToken: string,
622
+ groupOpenid: string,
623
+ content: string
624
+ ): Promise<{ id: string; timestamp: string }> {
625
+ const body = buildProactiveMessageBody(content);
626
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
627
+ }
628
+
629
+ // ============ 富媒体消息支持 ============
630
+
631
+ export enum MediaFileType {
632
+ IMAGE = 1,
633
+ VIDEO = 2,
634
+ VOICE = 3,
635
+ FILE = 4,
636
+ }
637
+
638
+ export interface UploadMediaResponse {
639
+ file_uuid: string;
640
+ file_info: string;
641
+ ttl: number;
642
+ id?: string;
643
+ }
644
+
645
+ // ============ 大文件分片上传 API ============
646
+
647
+ /** 分片信息 */
648
+ export interface UploadPart {
649
+ /** 分片索引(从 1 开始) */
650
+ index: number;
651
+ /** 预签名上传链接 */
652
+ presigned_url: string;
653
+ }
654
+
655
+ /** 申请上传响应 */
656
+ export interface UploadPrepareResponse {
657
+ /** 上传任务 ID */
658
+ upload_id: string;
659
+ /** 分块大小(字节) */
660
+ block_size: number;
661
+ /** 分片列表(含预签名链接) */
662
+ parts: UploadPart[];
663
+ }
664
+
665
+ /** 完成文件上传响应(与 UploadMediaResponse 一致) */
666
+ export interface MediaUploadResponse {
667
+ /** 文件 UUID */
668
+ file_uuid: string;
669
+ /** 文件信息(用于发送消息),是 InnerUploadRsp 的序列化 */
670
+ file_info: string;
671
+ /** 文件信息过期时长(秒) */
672
+ ttl: number;
673
+ }
674
+
675
+ /** 申请上传时的文件哈希信息 */
676
+ export interface UploadPrepareHashes {
677
+ /** 整个文件的 MD5(十六进制) */
678
+ md5: string;
679
+ /** 整个文件的 SHA1(十六进制) */
680
+ sha1: string;
681
+ /** 文件前 10002432 Bytes 的 MD5(十六进制);文件不足该大小时为整文件 MD5 */
682
+ md5_10m: string;
683
+ }
684
+
685
+ /**
686
+ * 申请上传(C2C)
687
+ * POST /v2/users/{user_id}/upload_prepare
688
+ *
689
+ * @param accessToken - 访问令牌
690
+ * @param userId - 用户 openid
691
+ * @param fileType - 业务类型(1=图片, 2=视频, 3=语音, 4=文件)
692
+ * @param fileName - 文件名
693
+ * @param fileSize - 文件大小(字节)
694
+ * @param hashes - 文件哈希信息(md5, sha1, md5_10m)
695
+ * @returns 上传任务 ID、分块大小、分片预签名链接列表
696
+ */
697
+ export async function c2cUploadPrepare(
698
+ accessToken: string,
699
+ userId: string,
700
+ fileType: MediaFileType,
701
+ fileName: string,
702
+ fileSize: number,
703
+ hashes: UploadPrepareHashes,
704
+ ): Promise<UploadPrepareResponse> {
705
+ return apiRequest<UploadPrepareResponse>(
706
+ accessToken, "POST", `/v2/users/${userId}/upload_prepare`,
707
+ { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m },
708
+ );
709
+ }
710
+
711
+ /**
712
+ * 完成分片上传(C2C)
713
+ * POST /v2/users/{user_id}/upload_part_finish
714
+ *
715
+ * @param accessToken - 访问令牌
716
+ * @param userId - 用户 openid
717
+ * @param uploadId - 上传任务 ID
718
+ * @param partIndex - 分片索引(从 1 开始)
719
+ * @param blockSize - 分块大小(字节)
720
+ * @param md5 - 分片数据的 MD5(十六进制)
721
+ */
722
+ export async function c2cUploadPartFinish(
723
+ accessToken: string,
724
+ userId: string,
725
+ uploadId: string,
726
+ partIndex: number,
727
+ blockSize: number,
728
+ md5: string,
729
+ ): Promise<void> {
730
+ await partFinishWithRetry(
731
+ accessToken, "POST", `/v2/users/${userId}/upload_part_finish`,
732
+ { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
733
+ );
734
+ }
735
+
736
+ /**
737
+ * 完成文件上传(C2C)
738
+ * POST /v2/users/{user_id}/files
739
+ *
740
+ * @param accessToken - 访问令牌
741
+ * @param userId - 用户 openid
742
+ * @param uploadId - 上传任务 ID
743
+ * @returns 文件信息(file_uuid, file_info, ttl)
744
+ */
745
+ export async function c2cCompleteUpload(
746
+ accessToken: string,
747
+ userId: string,
748
+ uploadId: string,
749
+ ): Promise<MediaUploadResponse> {
750
+ return completeUploadWithRetry(
751
+ accessToken, "POST", `/v2/users/${userId}/files`,
752
+ { upload_id: uploadId },
753
+ );
754
+ }
755
+
756
+ /**
757
+ * 申请上传(Group)
758
+ * POST /v2/groups/{group_id}/upload_prepare
759
+ */
760
+ export async function groupUploadPrepare(
761
+ accessToken: string,
762
+ groupId: string,
763
+ fileType: MediaFileType,
764
+ fileName: string,
765
+ fileSize: number,
766
+ hashes: UploadPrepareHashes,
767
+ ): Promise<UploadPrepareResponse> {
768
+ return apiRequest<UploadPrepareResponse>(
769
+ accessToken, "POST", `/v2/groups/${groupId}/upload_prepare`,
770
+ { file_type: fileType, file_name: fileName, file_size: fileSize, md5: hashes.md5, sha1: hashes.sha1, md5_10m: hashes.md5_10m },
771
+ );
772
+ }
773
+
774
+ /**
775
+ * 完成分片上传(Group)
776
+ * POST /v2/groups/{group_id}/upload_part_finish
777
+ */
778
+ export async function groupUploadPartFinish(
779
+ accessToken: string,
780
+ groupId: string,
781
+ uploadId: string,
782
+ partIndex: number,
783
+ blockSize: number,
784
+ md5: string,
785
+ ): Promise<void> {
786
+ await partFinishWithRetry(
787
+ accessToken, "POST", `/v2/groups/${groupId}/upload_part_finish`,
788
+ { upload_id: uploadId, part_index: partIndex, block_size: blockSize, md5 },
789
+ );
790
+ }
791
+
792
+ /**
793
+ * 完成文件上传(Group)
794
+ * POST /v2/groups/{group_id}/files
795
+ */
796
+ export async function groupCompleteUpload(
797
+ accessToken: string,
798
+ groupId: string,
799
+ uploadId: string,
800
+ ): Promise<MediaUploadResponse> {
801
+ return completeUploadWithRetry(
802
+ accessToken, "POST", `/v2/groups/${groupId}/files`,
803
+ { upload_id: uploadId },
804
+ );
805
+ }
806
+
807
+ export async function uploadC2CMedia(
808
+ accessToken: string,
809
+ openid: string,
810
+ fileType: MediaFileType,
811
+ url?: string,
812
+ fileData?: string,
813
+ srvSendMsg = false,
814
+ fileName?: string,
815
+ ): Promise<UploadMediaResponse> {
816
+ if (!url && !fileData) throw new Error("uploadC2CMedia: url or fileData is required");
817
+
818
+ if (fileData) {
819
+ const contentHash = computeFileHash(fileData);
820
+ const cachedInfo = getCachedFileInfo(contentHash, "c2c", openid, fileType);
821
+ if (cachedInfo) {
822
+ return { file_uuid: "", file_info: cachedInfo, ttl: 0 };
823
+ }
824
+ }
825
+
826
+ const body: Record<string, unknown> = { file_type: fileType, srv_send_msg: srvSendMsg };
827
+ if (url) body.url = url;
828
+ else if (fileData) body.file_data = fileData;
829
+ if (fileType === MediaFileType.FILE && fileName) body.file_name = sanitizeFileName(fileName);
830
+
831
+ const result = await apiRequestWithRetry<UploadMediaResponse>(
832
+ accessToken, "POST", `/v2/users/${openid}/files`, body
833
+ );
834
+
835
+ if (fileData && result.file_info && result.ttl > 0) {
836
+ const contentHash = computeFileHash(fileData);
837
+ setCachedFileInfo(contentHash, "c2c", openid, fileType, result.file_info, result.file_uuid, result.ttl);
838
+ }
839
+ return result;
840
+ }
841
+
842
+ export async function uploadGroupMedia(
843
+ accessToken: string,
844
+ groupOpenid: string,
845
+ fileType: MediaFileType,
846
+ url?: string,
847
+ fileData?: string,
848
+ srvSendMsg = false,
849
+ fileName?: string,
850
+ ): Promise<UploadMediaResponse> {
851
+ if (!url && !fileData) throw new Error("uploadGroupMedia: url or fileData is required");
852
+
853
+ if (fileData) {
854
+ const contentHash = computeFileHash(fileData);
855
+ const cachedInfo = getCachedFileInfo(contentHash, "group", groupOpenid, fileType);
856
+ if (cachedInfo) {
857
+ return { file_uuid: "", file_info: cachedInfo, ttl: 0 };
858
+ }
859
+ }
860
+
861
+ const body: Record<string, unknown> = { file_type: fileType, srv_send_msg: srvSendMsg };
862
+ if (url) body.url = url;
863
+ else if (fileData) body.file_data = fileData;
864
+ if (fileType === MediaFileType.FILE && fileName) body.file_name = sanitizeFileName(fileName);
865
+
866
+ const result = await apiRequestWithRetry<UploadMediaResponse>(
867
+ accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body
868
+ );
869
+
870
+ if (fileData && result.file_info && result.ttl > 0) {
871
+ const contentHash = computeFileHash(fileData);
872
+ setCachedFileInfo(contentHash, "group", groupOpenid, fileType, result.file_info, result.file_uuid, result.ttl);
873
+ }
874
+ return result;
875
+ }
876
+
877
+ export async function sendC2CMediaMessage(
878
+ accessToken: string,
879
+ openid: string,
880
+ fileInfo: string,
881
+ msgId?: string,
882
+ content?: string,
883
+ meta?: OutboundMeta,
884
+ ): Promise<MessageResponse> {
885
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
886
+ return sendAndNotify(accessToken, "POST", `/v2/users/${openid}/messages`, {
887
+ msg_type: 7,
888
+ media: { file_info: fileInfo },
889
+ msg_seq: msgSeq,
890
+ ...(content ? { content } : {}),
891
+ ...(msgId ? { msg_id: msgId } : {}),
892
+ }, meta ?? { text: content });
893
+ }
894
+
895
+ export async function sendGroupMediaMessage(
896
+ accessToken: string,
897
+ groupOpenid: string,
898
+ fileInfo: string,
899
+ msgId?: string,
900
+ content?: string
901
+ ): Promise<{ id: string; timestamp: string }> {
902
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
903
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
904
+ msg_type: 7,
905
+ media: { file_info: fileInfo },
906
+ msg_seq: msgSeq,
907
+ ...(content ? { content } : {}),
908
+ ...(msgId ? { msg_id: msgId } : {}),
909
+ });
910
+ }
911
+
912
+ export async function sendC2CImageMessage(accessToken: string, openid: string, imageUrl: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse> {
913
+ let uploadResult: UploadMediaResponse;
914
+ const isBase64 = imageUrl.startsWith("data:");
915
+ if (isBase64) {
916
+ const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
917
+ if (!matches) throw new Error("Invalid Base64 Data URL format");
918
+ uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, matches[2], false);
919
+ } else {
920
+ uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
921
+ }
922
+ const meta: OutboundMeta = {
923
+ text: content,
924
+ mediaType: "image",
925
+ ...(!isBase64 ? { mediaUrl: imageUrl } : {}),
926
+ ...(localPath ? { mediaLocalPath: localPath } : {}),
927
+ };
928
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content, meta);
929
+ }
930
+
931
+ export async function sendGroupImageMessage(accessToken: string, groupOpenid: string, imageUrl: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: string }> {
932
+ let uploadResult: UploadMediaResponse;
933
+ const isBase64 = imageUrl.startsWith("data:");
934
+ if (isBase64) {
935
+ const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
936
+ if (!matches) throw new Error("Invalid Base64 Data URL format");
937
+ uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, matches[2], false);
938
+ } else {
939
+ uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
940
+ }
941
+ return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
942
+ }
943
+
944
+ export async function sendC2CVoiceMessage(accessToken: string, openid: string, voiceBase64?: string, voiceUrl?: string, msgId?: string, ttsText?: string, filePath?: string): Promise<MessageResponse> {
945
+ const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VOICE, voiceUrl, voiceBase64, false);
946
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined, {
947
+ mediaType: "voice",
948
+ ...(ttsText ? { ttsText } : {}),
949
+ ...(filePath ? { mediaLocalPath: filePath } : {})
950
+ });
951
+ }
952
+
953
+ export async function sendGroupVoiceMessage(accessToken: string, groupOpenid: string, voiceBase64?: string, voiceUrl?: string, msgId?: string): Promise<{ id: string; timestamp: string }> {
954
+ const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VOICE, voiceUrl, voiceBase64, false);
955
+ return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
956
+ }
957
+
958
+ export async function sendC2CFileMessage(accessToken: string, openid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string, localFilePath?: string): Promise<MessageResponse> {
959
+ const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.FILE, fileUrl, fileBase64, false, fileName);
960
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, undefined,
961
+ { mediaType: "file", mediaUrl: fileUrl, mediaLocalPath: localFilePath ?? fileName });
962
+ }
963
+
964
+ export async function sendGroupFileMessage(accessToken: string, groupOpenid: string, fileBase64?: string, fileUrl?: string, msgId?: string, fileName?: string): Promise<{ id: string; timestamp: string }> {
965
+ const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.FILE, fileUrl, fileBase64, false, fileName);
966
+ return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId);
967
+ }
968
+
969
+ export async function sendC2CVideoMessage(accessToken: string, openid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string, localPath?: string): Promise<MessageResponse> {
970
+ const uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.VIDEO, videoUrl, videoBase64, false);
971
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content,
972
+ { text: content, mediaType: "video", ...(videoUrl ? { mediaUrl: videoUrl } : {}), ...(localPath ? { mediaLocalPath: localPath } : {}) });
973
+ }
974
+
975
+ export async function sendGroupVideoMessage(accessToken: string, groupOpenid: string, videoUrl?: string, videoBase64?: string, msgId?: string, content?: string): Promise<{ id: string; timestamp: string }> {
976
+ const uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.VIDEO, videoUrl, videoBase64, false);
977
+ return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
978
+ }
979
+
980
+ // ==========================================
981
+ // 后台 Token 刷新 (P1-1) - 按 appId 隔离
982
+ // ==========================================
983
+
984
+ interface BackgroundTokenRefreshOptions {
985
+ refreshAheadMs?: number;
986
+ randomOffsetMs?: number;
987
+ minRefreshIntervalMs?: number;
988
+ retryDelayMs?: number;
989
+ log?: {
990
+ info: (msg: string) => void;
991
+ error: (msg: string) => void;
992
+ debug?: (msg: string) => void;
993
+ };
994
+ }
995
+
996
+ const backgroundRefreshControllers = new Map<string, AbortController>();
997
+
998
+ export function startBackgroundTokenRefresh(
999
+ appId: string,
1000
+ clientSecret: string,
1001
+ options?: BackgroundTokenRefreshOptions
1002
+ ): void {
1003
+ if (backgroundRefreshControllers.has(appId)) {
1004
+ console.log(`[qqbot-api:${appId}] Background token refresh already running`);
1005
+ return;
1006
+ }
1007
+
1008
+ const {
1009
+ refreshAheadMs = 5 * 60 * 1000,
1010
+ randomOffsetMs = 30 * 1000,
1011
+ minRefreshIntervalMs = 60 * 1000,
1012
+ retryDelayMs = 5 * 1000,
1013
+ log,
1014
+ } = options ?? {};
1015
+
1016
+ const controller = new AbortController();
1017
+ backgroundRefreshControllers.set(appId, controller);
1018
+ const signal = controller.signal;
1019
+
1020
+ const refreshLoop = async () => {
1021
+ log?.info?.(`[qqbot-api:${appId}] Background token refresh started`);
1022
+
1023
+ while (!signal.aborted) {
1024
+ try {
1025
+ await getAccessToken(appId, clientSecret);
1026
+ const cached = tokenCacheMap.get(appId);
1027
+
1028
+ if (cached) {
1029
+ const expiresIn = cached.expiresAt - Date.now();
1030
+ const randomOffset = Math.random() * randomOffsetMs;
1031
+ const refreshIn = Math.max(
1032
+ expiresIn - refreshAheadMs - randomOffset,
1033
+ minRefreshIntervalMs
1034
+ );
1035
+
1036
+ log?.debug?.(`[qqbot-api:${appId}] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`);
1037
+ await sleep(refreshIn, signal);
1038
+ } else {
1039
+ log?.debug?.(`[qqbot-api:${appId}] No cached token, retrying soon`);
1040
+ await sleep(minRefreshIntervalMs, signal);
1041
+ }
1042
+ } catch (err) {
1043
+ if (signal.aborted) break;
1044
+ log?.error?.(`[qqbot-api:${appId}] Background token refresh failed: ${err}`);
1045
+ await sleep(retryDelayMs, signal);
1046
+ }
1047
+ }
1048
+
1049
+ backgroundRefreshControllers.delete(appId);
1050
+ log?.info?.(`[qqbot-api:${appId}] Background token refresh stopped`);
1051
+ };
1052
+
1053
+ refreshLoop().catch((err) => {
1054
+ backgroundRefreshControllers.delete(appId);
1055
+ log?.error?.(`[qqbot-api:${appId}] Background token refresh crashed: ${err}`);
1056
+ });
1057
+ }
1058
+
1059
+ /**
1060
+ * 停止后台 Token 刷新
1061
+ * @param appId 选填。如果有,仅停止该账号的定时刷新。
1062
+ */
1063
+ export function stopBackgroundTokenRefresh(appId?: string): void {
1064
+ if (appId) {
1065
+ const controller = backgroundRefreshControllers.get(appId);
1066
+ if (controller) {
1067
+ controller.abort();
1068
+ backgroundRefreshControllers.delete(appId);
1069
+ }
1070
+ } else {
1071
+ for (const controller of backgroundRefreshControllers.values()) {
1072
+ controller.abort();
1073
+ }
1074
+ backgroundRefreshControllers.clear();
1075
+ }
1076
+ }
1077
+
1078
+ export function isBackgroundTokenRefreshRunning(appId?: string): boolean {
1079
+ if (appId) return backgroundRefreshControllers.has(appId);
1080
+ return backgroundRefreshControllers.size > 0;
1081
+ }
1082
+
1083
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
1084
+ return new Promise((resolve, reject) => {
1085
+ const timer = setTimeout(resolve, ms);
1086
+ if (signal) {
1087
+ if (signal.aborted) {
1088
+ clearTimeout(timer);
1089
+ reject(new Error("Aborted"));
1090
+ return;
1091
+ }
1092
+ const onAbort = () => {
1093
+ clearTimeout(timer);
1094
+ reject(new Error("Aborted"));
1095
+ };
1096
+ signal.addEventListener("abort", onAbort, { once: true });
1097
+ }
1098
+ });
1099
+ }
1100
+
1101
+ // ============ 流式消息 API ============
1102
+
1103
+ import type { StreamMessageRequest, StreamMessageResponse } from "./types.js";
1104
+
1105
+ /**
1106
+ * 发送流式消息(C2C 私聊)
1107
+ *
1108
+ * 流式协议:
1109
+ * - 首次调用时不传 stream_msg_id,由平台返回
1110
+ * - 后续分片携带 stream_msg_id 和递增 msg_seq
1111
+ * - input_state="1" 表示生成中,"10" 表示生成结束(终结状态)
1112
+ *
1113
+ * @param accessToken - access_token
1114
+ * @param openid - 用户 openid
1115
+ * @param req - 流式消息请求体
1116
+ * @returns 流式消息响应
1117
+ */
1118
+ export async function sendC2CStreamMessage(
1119
+ accessToken: string,
1120
+ openid: string,
1121
+ req: StreamMessageRequest,
1122
+ ): Promise<StreamMessageResponse> {
1123
+ const path = `/v2/users/${openid}/stream_messages`;
1124
+ const body: Record<string, unknown> = {
1125
+ input_mode: req.input_mode,
1126
+ input_state: req.input_state,
1127
+ content_type: req.content_type,
1128
+ content_raw: req.content_raw,
1129
+ event_id: req.event_id,
1130
+ msg_id: req.msg_id,
1131
+ msg_seq: req.msg_seq,
1132
+ index: req.index,
1133
+ };
1134
+ if (req.stream_msg_id) {
1135
+ body.stream_msg_id = req.stream_msg_id;
1136
+ }
1137
+ return apiRequest<StreamMessageResponse>(accessToken, "POST", path, body);
1138
+ }