@sliverp/qqbot 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +231 -0
  2. package/clawdbot.plugin.json +16 -0
  3. package/dist/index.d.ts +17 -0
  4. package/dist/index.js +22 -0
  5. package/dist/src/api.d.ts +194 -0
  6. package/dist/src/api.js +555 -0
  7. package/dist/src/channel.d.ts +3 -0
  8. package/dist/src/channel.js +146 -0
  9. package/dist/src/config.d.ts +25 -0
  10. package/dist/src/config.js +148 -0
  11. package/dist/src/gateway.d.ts +17 -0
  12. package/dist/src/gateway.js +722 -0
  13. package/dist/src/image-server.d.ts +62 -0
  14. package/dist/src/image-server.js +401 -0
  15. package/dist/src/known-users.d.ts +100 -0
  16. package/dist/src/known-users.js +264 -0
  17. package/dist/src/onboarding.d.ts +10 -0
  18. package/dist/src/onboarding.js +190 -0
  19. package/dist/src/outbound.d.ts +149 -0
  20. package/dist/src/outbound.js +476 -0
  21. package/dist/src/proactive.d.ts +170 -0
  22. package/dist/src/proactive.js +398 -0
  23. package/dist/src/runtime.d.ts +3 -0
  24. package/dist/src/runtime.js +10 -0
  25. package/dist/src/session-store.d.ts +49 -0
  26. package/dist/src/session-store.js +242 -0
  27. package/dist/src/types.d.ts +116 -0
  28. package/dist/src/types.js +1 -0
  29. package/dist/src/utils/image-size.d.ts +51 -0
  30. package/dist/src/utils/image-size.js +234 -0
  31. package/dist/src/utils/payload.d.ts +112 -0
  32. package/dist/src/utils/payload.js +186 -0
  33. package/index.ts +27 -0
  34. package/moltbot.plugin.json +16 -0
  35. package/node_modules/ws/LICENSE +20 -0
  36. package/node_modules/ws/README.md +548 -0
  37. package/node_modules/ws/browser.js +8 -0
  38. package/node_modules/ws/index.js +13 -0
  39. package/node_modules/ws/lib/buffer-util.js +131 -0
  40. package/node_modules/ws/lib/constants.js +19 -0
  41. package/node_modules/ws/lib/event-target.js +292 -0
  42. package/node_modules/ws/lib/extension.js +203 -0
  43. package/node_modules/ws/lib/limiter.js +55 -0
  44. package/node_modules/ws/lib/permessage-deflate.js +528 -0
  45. package/node_modules/ws/lib/receiver.js +706 -0
  46. package/node_modules/ws/lib/sender.js +602 -0
  47. package/node_modules/ws/lib/stream.js +161 -0
  48. package/node_modules/ws/lib/subprotocol.js +62 -0
  49. package/node_modules/ws/lib/validation.js +152 -0
  50. package/node_modules/ws/lib/websocket-server.js +554 -0
  51. package/node_modules/ws/lib/websocket.js +1393 -0
  52. package/node_modules/ws/package.json +69 -0
  53. package/node_modules/ws/wrapper.mjs +8 -0
  54. package/openclaw.plugin.json +16 -0
  55. package/package.json +38 -0
  56. package/qqbot-1.3.0.tgz +0 -0
  57. package/scripts/proactive-api-server.ts +346 -0
  58. package/scripts/send-proactive.ts +273 -0
  59. package/scripts/upgrade.sh +106 -0
  60. package/skills/qqbot-cron/SKILL.md +490 -0
  61. package/skills/qqbot-media/SKILL.md +138 -0
  62. package/src/api.ts +752 -0
  63. package/src/channel.ts +303 -0
  64. package/src/config.ts +172 -0
  65. package/src/gateway.ts +1588 -0
  66. package/src/image-server.ts +474 -0
  67. package/src/known-users.ts +358 -0
  68. package/src/onboarding.ts +254 -0
  69. package/src/openclaw-plugin-sdk.d.ts +483 -0
  70. package/src/outbound.ts +571 -0
  71. package/src/proactive.ts +528 -0
  72. package/src/runtime.ts +14 -0
  73. package/src/session-store.ts +292 -0
  74. package/src/types.ts +123 -0
  75. package/src/utils/image-size.ts +266 -0
  76. package/src/utils/payload.ts +265 -0
  77. package/tsconfig.json +16 -0
  78. package/upgrade-and-run.sh +89 -0
package/src/api.ts ADDED
@@ -0,0 +1,752 @@
1
+ /**
2
+ * QQ Bot API 鉴权和请求封装
3
+ */
4
+
5
+ const API_BASE = "https://api.sgroup.qq.com";
6
+ const TOKEN_URL = "https://bots.qq.com/app/getAppAccessToken";
7
+
8
+ // 运行时配置
9
+ let currentMarkdownSupport = false;
10
+
11
+ /**
12
+ * 初始化 API 配置
13
+ * @param options.markdownSupport - 是否支持 markdown 消息(默认 false,需要机器人具备该权限才能启用)
14
+ */
15
+ export function initApiConfig(options: { markdownSupport?: boolean }): void {
16
+ currentMarkdownSupport = options.markdownSupport === true; // 默认为 false,需要机器人具备 markdown 消息权限才能启用
17
+ }
18
+
19
+ /**
20
+ * 获取当前是否支持 markdown
21
+ */
22
+ export function isMarkdownSupport(): boolean {
23
+ return currentMarkdownSupport;
24
+ }
25
+
26
+ let cachedToken: { token: string; expiresAt: number } | null = null;
27
+ // Singleflight: 防止并发获取 Token 的 Promise 缓存
28
+ let tokenFetchPromise: Promise<string> | null = null;
29
+
30
+ /**
31
+ * 获取 AccessToken(带缓存 + singleflight 并发安全)
32
+ *
33
+ * 使用 singleflight 模式:当多个请求同时发现 Token 过期时,
34
+ * 只有第一个请求会真正去获取新 Token,其他请求复用同一个 Promise。
35
+ */
36
+ export async function getAccessToken(appId: string, clientSecret: string): Promise<string> {
37
+ // 检查缓存,提前 5 分钟刷新
38
+ if (cachedToken && Date.now() < cachedToken.expiresAt - 5 * 60 * 1000) {
39
+ return cachedToken.token;
40
+ }
41
+
42
+ // Singleflight: 如果已有进行中的 Token 获取请求,复用它
43
+ if (tokenFetchPromise) {
44
+ console.log(`[qqbot-api] Token fetch in progress, waiting for existing request...`);
45
+ return tokenFetchPromise;
46
+ }
47
+
48
+ // 创建新的 Token 获取 Promise(singleflight 入口)
49
+ tokenFetchPromise = (async () => {
50
+ try {
51
+ return await doFetchToken(appId, clientSecret);
52
+ } finally {
53
+ // 无论成功失败,都清除 Promise 缓存
54
+ tokenFetchPromise = null;
55
+ }
56
+ })();
57
+
58
+ return tokenFetchPromise;
59
+ }
60
+
61
+ /**
62
+ * 实际执行 Token 获取的内部函数
63
+ */
64
+ async function doFetchToken(appId: string, clientSecret: string): Promise<string> {
65
+
66
+ const requestBody = { appId, clientSecret };
67
+ const requestHeaders = { "Content-Type": "application/json" };
68
+
69
+ // 打印请求信息(隐藏敏感信息)
70
+ console.log(`[qqbot-api] >>> POST ${TOKEN_URL}`);
71
+ console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(requestHeaders, null, 2));
72
+ console.log(`[qqbot-api] >>> Body:`, JSON.stringify({ appId, clientSecret: "***" }, null, 2));
73
+
74
+ let response: Response;
75
+ try {
76
+ response = await fetch(TOKEN_URL, {
77
+ method: "POST",
78
+ headers: requestHeaders,
79
+ body: JSON.stringify(requestBody),
80
+ });
81
+ } catch (err) {
82
+ console.error(`[qqbot-api] <<< Network error:`, err);
83
+ throw new Error(`Network error getting access_token: ${err instanceof Error ? err.message : String(err)}`);
84
+ }
85
+
86
+ // 打印响应头
87
+ const responseHeaders: Record<string, string> = {};
88
+ response.headers.forEach((value, key) => {
89
+ responseHeaders[key] = value;
90
+ });
91
+ console.log(`[qqbot-api] <<< Status: ${response.status} ${response.statusText}`);
92
+ console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
93
+
94
+ let data: { access_token?: string; expires_in?: number };
95
+ let rawBody: string;
96
+ try {
97
+ rawBody = await response.text();
98
+ // 隐藏 token 值
99
+ const logBody = rawBody.replace(/"access_token"\s*:\s*"[^"]+"/g, '"access_token": "***"');
100
+ console.log(`[qqbot-api] <<< Body:`, logBody);
101
+ data = JSON.parse(rawBody) as { access_token?: string; expires_in?: number };
102
+ } catch (err) {
103
+ console.error(`[qqbot-api] <<< Parse error:`, err);
104
+ throw new Error(`Failed to parse access_token response: ${err instanceof Error ? err.message : String(err)}`);
105
+ }
106
+
107
+ if (!data.access_token) {
108
+ throw new Error(`Failed to get access_token: ${JSON.stringify(data)}`);
109
+ }
110
+
111
+ cachedToken = {
112
+ token: data.access_token,
113
+ expiresAt: Date.now() + (data.expires_in ?? 7200) * 1000,
114
+ };
115
+
116
+ console.log(`[qqbot-api] Token cached, expires at: ${new Date(cachedToken.expiresAt).toISOString()}`);
117
+ return cachedToken.token;
118
+ }
119
+
120
+ /**
121
+ * 清除 Token 缓存
122
+ */
123
+ export function clearTokenCache(): void {
124
+ cachedToken = null;
125
+ // 注意:不清除 tokenFetchPromise,让进行中的请求完成
126
+ // 下次调用 getAccessToken 时会自动获取新 Token
127
+ }
128
+
129
+ /**
130
+ * 获取 Token 缓存状态(用于监控)
131
+ */
132
+ export function getTokenStatus(): { status: "valid" | "expired" | "refreshing" | "none"; expiresAt: number | null } {
133
+ if (tokenFetchPromise) {
134
+ return { status: "refreshing", expiresAt: cachedToken?.expiresAt ?? null };
135
+ }
136
+ if (!cachedToken) {
137
+ return { status: "none", expiresAt: null };
138
+ }
139
+ const isValid = Date.now() < cachedToken.expiresAt - 5 * 60 * 1000;
140
+ return { status: isValid ? "valid" : "expired", expiresAt: cachedToken.expiresAt };
141
+ }
142
+
143
+ /**
144
+ * msg_seq 追踪器 - 用于对同一条消息的多次回复
145
+ * key: msg_id, value: 当前 seq 值
146
+ * 使用时间戳作为基础值,确保进程重启后不会重复
147
+ */
148
+ const msgSeqTracker = new Map<string, number>();
149
+ const seqBaseTime = Math.floor(Date.now() / 1000) % 100000000; // 取秒级时间戳的后8位作为基础
150
+
151
+ /**
152
+ * 获取并递增消息序号
153
+ * 返回的 seq 会基于时间戳,避免进程重启后重复
154
+ */
155
+ export function getNextMsgSeq(msgId: string): number {
156
+ const current = msgSeqTracker.get(msgId) ?? 0;
157
+ const next = current + 1;
158
+ msgSeqTracker.set(msgId, next);
159
+
160
+ // 清理过期的序号
161
+ // 简单策略:保留最近 1000 条
162
+ if (msgSeqTracker.size > 1000) {
163
+ const keys = Array.from(msgSeqTracker.keys());
164
+ for (let i = 0; i < 500; i++) {
165
+ msgSeqTracker.delete(keys[i]);
166
+ }
167
+ }
168
+
169
+ // 结合时间戳基础值,确保唯一性
170
+ return seqBaseTime + next;
171
+ }
172
+
173
+ /**
174
+ * API 请求封装
175
+ */
176
+ export async function apiRequest<T = unknown>(
177
+ accessToken: string,
178
+ method: string,
179
+ path: string,
180
+ body?: unknown
181
+ ): Promise<T> {
182
+ const url = `${API_BASE}${path}`;
183
+ const headers: Record<string, string> = {
184
+ Authorization: `QQBot ${accessToken}`,
185
+ "Content-Type": "application/json",
186
+ };
187
+ const options: RequestInit = {
188
+ method,
189
+ headers,
190
+ };
191
+
192
+ if (body) {
193
+ options.body = JSON.stringify(body);
194
+ }
195
+
196
+ // 打印请求信息
197
+ console.log(`[qqbot-api] >>> ${method} ${url}`);
198
+ console.log(`[qqbot-api] >>> Headers:`, JSON.stringify(headers, null, 2));
199
+ if (body) {
200
+ console.log(`[qqbot-api] >>> Body:`, JSON.stringify(body, null, 2));
201
+ }
202
+
203
+ let res: Response;
204
+ try {
205
+ res = await fetch(url, options);
206
+ } catch (err) {
207
+ console.error(`[qqbot-api] <<< Network error:`, err);
208
+ throw new Error(`Network error [${path}]: ${err instanceof Error ? err.message : String(err)}`);
209
+ }
210
+
211
+ // 打印响应头
212
+ const responseHeaders: Record<string, string> = {};
213
+ res.headers.forEach((value, key) => {
214
+ responseHeaders[key] = value;
215
+ });
216
+ console.log(`[qqbot-api] <<< Status: ${res.status} ${res.statusText}`);
217
+ console.log(`[qqbot-api] <<< Headers:`, JSON.stringify(responseHeaders, null, 2));
218
+
219
+ let data: T;
220
+ let rawBody: string;
221
+ try {
222
+ rawBody = await res.text();
223
+ console.log(`[qqbot-api] <<< Body:`, rawBody);
224
+ data = JSON.parse(rawBody) as T;
225
+ } catch (err) {
226
+ console.error(`[qqbot-api] <<< Parse error:`, err);
227
+ throw new Error(`Failed to parse response [${path}]: ${err instanceof Error ? err.message : String(err)}`);
228
+ }
229
+
230
+ if (!res.ok) {
231
+ const error = data as { message?: string; code?: number };
232
+ throw new Error(`API Error [${path}]: ${error.message ?? JSON.stringify(data)}`);
233
+ }
234
+
235
+ return data;
236
+ }
237
+
238
+ /**
239
+ * 获取 WebSocket Gateway URL
240
+ */
241
+ export async function getGatewayUrl(accessToken: string): Promise<string> {
242
+ const data = await apiRequest<{ url: string }>(accessToken, "GET", "/gateway");
243
+ return data.url;
244
+ }
245
+
246
+ // ============ 消息发送接口 ============
247
+
248
+ /**
249
+ * 消息响应
250
+ */
251
+ export interface MessageResponse {
252
+ id: string;
253
+ timestamp: number | string;
254
+ }
255
+
256
+ /**
257
+ * 构建消息体
258
+ * 根据 markdownSupport 配置决定消息格式:
259
+ * - markdown 模式: { markdown: { content }, msg_type: 2 }
260
+ * - 纯文本模式: { content, msg_type: 0 }
261
+ */
262
+ function buildMessageBody(
263
+ content: string,
264
+ msgId: string | undefined,
265
+ msgSeq: number
266
+ ): Record<string, unknown> {
267
+ const body: Record<string, unknown> = currentMarkdownSupport
268
+ ? {
269
+ markdown: { content },
270
+ msg_type: 2,
271
+ msg_seq: msgSeq,
272
+ }
273
+ : {
274
+ content,
275
+ msg_type: 0,
276
+ msg_seq: msgSeq,
277
+ };
278
+
279
+ if (msgId) {
280
+ body.msg_id = msgId;
281
+ }
282
+
283
+ return body;
284
+ }
285
+
286
+ /**
287
+ * 发送 C2C 单聊消息
288
+ */
289
+ export async function sendC2CMessage(
290
+ accessToken: string,
291
+ openid: string,
292
+ content: string,
293
+ msgId?: string
294
+ ): Promise<MessageResponse> {
295
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
296
+ const body = buildMessageBody(content, msgId, msgSeq);
297
+
298
+ return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
299
+ }
300
+
301
+ /**
302
+ * 发送 C2C 输入状态提示(告知用户机器人正在输入)
303
+ */
304
+ export async function sendC2CInputNotify(
305
+ accessToken: string,
306
+ openid: string,
307
+ msgId?: string,
308
+ inputSecond: number = 60
309
+ ): Promise<void> {
310
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
311
+ const body = {
312
+ msg_type: 6,
313
+ input_notify: {
314
+ input_type: 1,
315
+ input_second: inputSecond,
316
+ },
317
+ msg_seq: msgSeq,
318
+ ...(msgId ? { msg_id: msgId } : {}),
319
+ };
320
+
321
+ await apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
322
+ }
323
+
324
+ /**
325
+ * 发送频道消息(不支持流式)
326
+ */
327
+ export async function sendChannelMessage(
328
+ accessToken: string,
329
+ channelId: string,
330
+ content: string,
331
+ msgId?: string
332
+ ): Promise<{ id: string; timestamp: string }> {
333
+ return apiRequest(accessToken, "POST", `/channels/${channelId}/messages`, {
334
+ content,
335
+ ...(msgId ? { msg_id: msgId } : {}),
336
+ });
337
+ }
338
+
339
+ /**
340
+ * 发送群聊消息
341
+ */
342
+ export async function sendGroupMessage(
343
+ accessToken: string,
344
+ groupOpenid: string,
345
+ content: string,
346
+ msgId?: string
347
+ ): Promise<MessageResponse> {
348
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
349
+ const body = buildMessageBody(content, msgId, msgSeq);
350
+
351
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
352
+ }
353
+
354
+ /**
355
+ * 构建主动消息请求体
356
+ * 根据 markdownSupport 配置决定消息格式:
357
+ * - markdown 模式: { markdown: { content }, msg_type: 2 }
358
+ * - 纯文本模式: { content, msg_type: 0 }
359
+ *
360
+ * 注意:主动消息不支持流式发送
361
+ */
362
+ function buildProactiveMessageBody(content: string): Record<string, unknown> {
363
+ // 主动消息内容校验(参考 Telegram 机制)
364
+ if (!content || content.trim().length === 0) {
365
+ throw new Error("主动消息内容不能为空 (markdown.content is empty)");
366
+ }
367
+
368
+ if (currentMarkdownSupport) {
369
+ return {
370
+ markdown: { content },
371
+ msg_type: 2,
372
+ };
373
+ } else {
374
+ return {
375
+ content,
376
+ msg_type: 0,
377
+ };
378
+ }
379
+ }
380
+
381
+ /**
382
+ * 主动发送 C2C 单聊消息(不需要 msg_id,每月限 4 条/用户)
383
+ *
384
+ * 注意:
385
+ * 1. 内容不能为空(对应 markdown.content 字段)
386
+ * 2. 不支持流式发送
387
+ */
388
+ export async function sendProactiveC2CMessage(
389
+ accessToken: string,
390
+ openid: string,
391
+ content: string
392
+ ): Promise<{ id: string; timestamp: number }> {
393
+ const body = buildProactiveMessageBody(content);
394
+ console.log(`[qqbot-api] sendProactiveC2CMessage: openid=${openid}, msg_type=${body.msg_type}, content_len=${content.length}`);
395
+ return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, body);
396
+ }
397
+
398
+ /**
399
+ * 主动发送群聊消息(不需要 msg_id,每月限 4 条/群)
400
+ *
401
+ * 注意:
402
+ * 1. 内容不能为空(对应 markdown.content 字段)
403
+ * 2. 不支持流式发送
404
+ */
405
+ export async function sendProactiveGroupMessage(
406
+ accessToken: string,
407
+ groupOpenid: string,
408
+ content: string
409
+ ): Promise<{ id: string; timestamp: string }> {
410
+ const body = buildProactiveMessageBody(content);
411
+ console.log(`[qqbot-api] sendProactiveGroupMessage: group=${groupOpenid}, msg_type=${body.msg_type}, content_len=${content.length}`);
412
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, body);
413
+ }
414
+
415
+ // ============ 富媒体消息支持 ============
416
+
417
+ /**
418
+ * 媒体文件类型
419
+ */
420
+ export enum MediaFileType {
421
+ IMAGE = 1,
422
+ VIDEO = 2,
423
+ VOICE = 3,
424
+ FILE = 4, // 暂未开放
425
+ }
426
+
427
+ /**
428
+ * 上传富媒体文件的响应
429
+ */
430
+ export interface UploadMediaResponse {
431
+ file_uuid: string;
432
+ file_info: string;
433
+ ttl: number;
434
+ id?: string; // 仅当 srv_send_msg=true 时返回
435
+ }
436
+
437
+ /**
438
+ * 上传富媒体文件到 C2C 单聊
439
+ * @param url - 公网可访问的图片 URL(与 fileData 二选一)
440
+ * @param fileData - Base64 编码的文件内容(与 url 二选一)
441
+ */
442
+ export async function uploadC2CMedia(
443
+ accessToken: string,
444
+ openid: string,
445
+ fileType: MediaFileType,
446
+ url?: string,
447
+ fileData?: string,
448
+ srvSendMsg = false
449
+ ): Promise<UploadMediaResponse> {
450
+ if (!url && !fileData) {
451
+ throw new Error("uploadC2CMedia: url or fileData is required");
452
+ }
453
+
454
+ const body: Record<string, unknown> = {
455
+ file_type: fileType,
456
+ srv_send_msg: srvSendMsg,
457
+ };
458
+
459
+ if (url) {
460
+ body.url = url;
461
+ } else if (fileData) {
462
+ body.file_data = fileData;
463
+ }
464
+
465
+ return apiRequest(accessToken, "POST", `/v2/users/${openid}/files`, body);
466
+ }
467
+
468
+ /**
469
+ * 上传富媒体文件到群聊
470
+ * @param url - 公网可访问的图片 URL(与 fileData 二选一)
471
+ * @param fileData - Base64 编码的文件内容(与 url 二选一)
472
+ */
473
+ export async function uploadGroupMedia(
474
+ accessToken: string,
475
+ groupOpenid: string,
476
+ fileType: MediaFileType,
477
+ url?: string,
478
+ fileData?: string,
479
+ srvSendMsg = false
480
+ ): Promise<UploadMediaResponse> {
481
+ if (!url && !fileData) {
482
+ throw new Error("uploadGroupMedia: url or fileData is required");
483
+ }
484
+
485
+ const body: Record<string, unknown> = {
486
+ file_type: fileType,
487
+ srv_send_msg: srvSendMsg,
488
+ };
489
+
490
+ if (url) {
491
+ body.url = url;
492
+ } else if (fileData) {
493
+ body.file_data = fileData;
494
+ }
495
+
496
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/files`, body);
497
+ }
498
+
499
+ /**
500
+ * 发送 C2C 单聊富媒体消息
501
+ */
502
+ export async function sendC2CMediaMessage(
503
+ accessToken: string,
504
+ openid: string,
505
+ fileInfo: string,
506
+ msgId?: string,
507
+ content?: string
508
+ ): Promise<{ id: string; timestamp: number }> {
509
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
510
+ return apiRequest(accessToken, "POST", `/v2/users/${openid}/messages`, {
511
+ msg_type: 7, // 富媒体消息类型
512
+ media: { file_info: fileInfo },
513
+ msg_seq: msgSeq,
514
+ ...(content ? { content } : {}),
515
+ ...(msgId ? { msg_id: msgId } : {}),
516
+ });
517
+ }
518
+
519
+ /**
520
+ * 发送群聊富媒体消息
521
+ */
522
+ export async function sendGroupMediaMessage(
523
+ accessToken: string,
524
+ groupOpenid: string,
525
+ fileInfo: string,
526
+ msgId?: string,
527
+ content?: string
528
+ ): Promise<{ id: string; timestamp: string }> {
529
+ const msgSeq = msgId ? getNextMsgSeq(msgId) : 1;
530
+ return apiRequest(accessToken, "POST", `/v2/groups/${groupOpenid}/messages`, {
531
+ msg_type: 7, // 富媒体消息类型
532
+ media: { file_info: fileInfo },
533
+ msg_seq: msgSeq,
534
+ ...(content ? { content } : {}),
535
+ ...(msgId ? { msg_id: msgId } : {}),
536
+ });
537
+ }
538
+
539
+ /**
540
+ * 发送带图片的 C2C 单聊消息(封装上传+发送)
541
+ * @param imageUrl - 图片来源,支持:
542
+ * - 公网 URL: https://example.com/image.png
543
+ * - Base64 Data URL: data:image/png;base64,xxxxx
544
+ */
545
+ export async function sendC2CImageMessage(
546
+ accessToken: string,
547
+ openid: string,
548
+ imageUrl: string,
549
+ msgId?: string,
550
+ content?: string
551
+ ): Promise<{ id: string; timestamp: number }> {
552
+ let uploadResult: UploadMediaResponse;
553
+
554
+ // 检查是否是 Base64 Data URL
555
+ if (imageUrl.startsWith("data:")) {
556
+ // 解析 Base64 Data URL: data:image/png;base64,xxxxx
557
+ const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
558
+ if (!matches) {
559
+ throw new Error("Invalid Base64 Data URL format");
560
+ }
561
+ const base64Data = matches[2];
562
+ // 使用 file_data 上传
563
+ uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, undefined, base64Data, false);
564
+ } else {
565
+ // 公网 URL,使用 url 参数上传
566
+ uploadResult = await uploadC2CMedia(accessToken, openid, MediaFileType.IMAGE, imageUrl, undefined, false);
567
+ }
568
+
569
+ // 发送富媒体消息
570
+ return sendC2CMediaMessage(accessToken, openid, uploadResult.file_info, msgId, content);
571
+ }
572
+
573
+ /**
574
+ * 发送带图片的群聊消息(封装上传+发送)
575
+ * @param imageUrl - 图片来源,支持:
576
+ * - 公网 URL: https://example.com/image.png
577
+ * - Base64 Data URL: data:image/png;base64,xxxxx
578
+ */
579
+ export async function sendGroupImageMessage(
580
+ accessToken: string,
581
+ groupOpenid: string,
582
+ imageUrl: string,
583
+ msgId?: string,
584
+ content?: string
585
+ ): Promise<{ id: string; timestamp: string }> {
586
+ let uploadResult: UploadMediaResponse;
587
+
588
+ // 检查是否是 Base64 Data URL
589
+ if (imageUrl.startsWith("data:")) {
590
+ // 解析 Base64 Data URL: data:image/png;base64,xxxxx
591
+ const matches = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
592
+ if (!matches) {
593
+ throw new Error("Invalid Base64 Data URL format");
594
+ }
595
+ const base64Data = matches[2];
596
+ // 使用 file_data 上传
597
+ uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, undefined, base64Data, false);
598
+ } else {
599
+ // 公网 URL,使用 url 参数上传
600
+ uploadResult = await uploadGroupMedia(accessToken, groupOpenid, MediaFileType.IMAGE, imageUrl, undefined, false);
601
+ }
602
+
603
+ // 发送富媒体消息
604
+ return sendGroupMediaMessage(accessToken, groupOpenid, uploadResult.file_info, msgId, content);
605
+ }
606
+
607
+ // ============ 后台 Token 刷新 (P1-1) ============
608
+
609
+ /**
610
+ * 后台 Token 刷新配置
611
+ */
612
+ interface BackgroundTokenRefreshOptions {
613
+ /** 提前刷新时间(毫秒,默认 5 分钟) */
614
+ refreshAheadMs?: number;
615
+ /** 随机偏移范围(毫秒,默认 0-30 秒) */
616
+ randomOffsetMs?: number;
617
+ /** 最小刷新间隔(毫秒,默认 1 分钟) */
618
+ minRefreshIntervalMs?: number;
619
+ /** 失败后重试间隔(毫秒,默认 5 秒) */
620
+ retryDelayMs?: number;
621
+ /** 日志函数 */
622
+ log?: {
623
+ info: (msg: string) => void;
624
+ error: (msg: string) => void;
625
+ debug?: (msg: string) => void;
626
+ };
627
+ }
628
+
629
+ // 后台刷新状态
630
+ let backgroundRefreshRunning = false;
631
+ let backgroundRefreshAbortController: AbortController | null = null;
632
+
633
+ /**
634
+ * 启动后台 Token 刷新
635
+ * 在后台定时刷新 Token,避免请求时才发现过期
636
+ *
637
+ * @param appId 应用 ID
638
+ * @param clientSecret 应用密钥
639
+ * @param options 配置选项
640
+ */
641
+ export function startBackgroundTokenRefresh(
642
+ appId: string,
643
+ clientSecret: string,
644
+ options?: BackgroundTokenRefreshOptions
645
+ ): void {
646
+ if (backgroundRefreshRunning) {
647
+ console.log("[qqbot-api] Background token refresh already running");
648
+ return;
649
+ }
650
+
651
+ const {
652
+ refreshAheadMs = 5 * 60 * 1000, // 提前 5 分钟刷新
653
+ randomOffsetMs = 30 * 1000, // 0-30 秒随机偏移
654
+ minRefreshIntervalMs = 60 * 1000, // 最少 1 分钟后刷新
655
+ retryDelayMs = 5 * 1000, // 失败后 5 秒重试
656
+ log,
657
+ } = options ?? {};
658
+
659
+ backgroundRefreshRunning = true;
660
+ backgroundRefreshAbortController = new AbortController();
661
+ const signal = backgroundRefreshAbortController.signal;
662
+
663
+ const refreshLoop = async () => {
664
+ log?.info?.("[qqbot-api] Background token refresh started");
665
+
666
+ while (!signal.aborted) {
667
+ try {
668
+ // 先确保有一个有效 Token
669
+ await getAccessToken(appId, clientSecret);
670
+
671
+ // 计算下次刷新时间
672
+ if (cachedToken) {
673
+ const expiresIn = cachedToken.expiresAt - Date.now();
674
+ // 提前刷新时间 + 随机偏移(避免集群同时刷新)
675
+ const randomOffset = Math.random() * randomOffsetMs;
676
+ const refreshIn = Math.max(
677
+ expiresIn - refreshAheadMs - randomOffset,
678
+ minRefreshIntervalMs
679
+ );
680
+
681
+ log?.debug?.(
682
+ `[qqbot-api] Token valid, next refresh in ${Math.round(refreshIn / 1000)}s`
683
+ );
684
+
685
+ // 等待到刷新时间
686
+ await sleep(refreshIn, signal);
687
+ } else {
688
+ // 没有缓存的 Token,等待一段时间后重试
689
+ log?.debug?.("[qqbot-api] No cached token, retrying soon");
690
+ await sleep(minRefreshIntervalMs, signal);
691
+ }
692
+ } catch (err) {
693
+ if (signal.aborted) break;
694
+
695
+ // 刷新失败,等待后重试
696
+ log?.error?.(`[qqbot-api] Background token refresh failed: ${err}`);
697
+ await sleep(retryDelayMs, signal);
698
+ }
699
+ }
700
+
701
+ backgroundRefreshRunning = false;
702
+ log?.info?.("[qqbot-api] Background token refresh stopped");
703
+ };
704
+
705
+ // 异步启动,不阻塞调用者
706
+ refreshLoop().catch((err) => {
707
+ backgroundRefreshRunning = false;
708
+ log?.error?.(`[qqbot-api] Background token refresh crashed: ${err}`);
709
+ });
710
+ }
711
+
712
+ /**
713
+ * 停止后台 Token 刷新
714
+ */
715
+ export function stopBackgroundTokenRefresh(): void {
716
+ if (backgroundRefreshAbortController) {
717
+ backgroundRefreshAbortController.abort();
718
+ backgroundRefreshAbortController = null;
719
+ }
720
+ backgroundRefreshRunning = false;
721
+ }
722
+
723
+ /**
724
+ * 检查后台 Token 刷新是否正在运行
725
+ */
726
+ export function isBackgroundTokenRefreshRunning(): boolean {
727
+ return backgroundRefreshRunning;
728
+ }
729
+
730
+ /**
731
+ * 可中断的 sleep 函数
732
+ */
733
+ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
734
+ return new Promise((resolve, reject) => {
735
+ const timer = setTimeout(resolve, ms);
736
+
737
+ if (signal) {
738
+ if (signal.aborted) {
739
+ clearTimeout(timer);
740
+ reject(new Error("Aborted"));
741
+ return;
742
+ }
743
+
744
+ const onAbort = () => {
745
+ clearTimeout(timer);
746
+ reject(new Error("Aborted"));
747
+ };
748
+
749
+ signal.addEventListener("abort", onAbort, { once: true });
750
+ }
751
+ });
752
+ }