@leoqlin/openclaw-qqbot 1.6.7-alpha1

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 +484 -0
  3. package/README.zh.md +479 -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 +301 -0
  10. package/dist/src/api.js +890 -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 +2005 -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 +89 -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 +938 -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 +101 -0
  42. package/dist/src/ref-index-store.js +298 -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 +25 -0
  46. package/dist/src/request-context.js +37 -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 +1866 -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 +247 -0
  56. package/dist/src/streaming.js +899 -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 +256 -0
  63. package/dist/src/types.d.ts +367 -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 +36 -0
  68. package/dist/src/update-checker.js +171 -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 +68 -0
  72. package/dist/src/utils/chunked-upload.js +341 -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 +158 -0
  78. package/dist/src/utils/media-send.js +499 -0
  79. package/dist/src/utils/media-tags.d.ts +14 -0
  80. package/dist/src/utils/media-tags.js +165 -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 +61 -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 +36 -0
  90. package/dist/src/utils/text-parsing.js +75 -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 +22 -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 +70 -0
  156. package/node_modules/ws/wrapper.mjs +21 -0
  157. package/openclaw.plugin.json +17 -0
  158. package/package.json +70 -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 +126 -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 +460 -0
  168. package/scripts/upgrade-via-npm.sh +652 -0
  169. package/scripts/upgrade-via-source.sh +1026 -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 +159 -0
  174. package/src/admin-resolver.ts +181 -0
  175. package/src/api.ts +1284 -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 +2245 -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 +352 -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 +1131 -0
  191. package/src/proactive.ts +530 -0
  192. package/src/ref-index-store.ts +412 -0
  193. package/src/reply-dispatcher.ts +334 -0
  194. package/src/request-context.ts +49 -0
  195. package/src/runtime.ts +14 -0
  196. package/src/session-store.ts +303 -0
  197. package/src/slash-commands.ts +2030 -0
  198. package/src/startup-greeting.ts +120 -0
  199. package/src/streaming.ts +1077 -0
  200. package/src/stt.ts +86 -0
  201. package/src/tools/channel.ts +281 -0
  202. package/src/tools/remind.ts +308 -0
  203. package/src/types.ts +391 -0
  204. package/src/typing-keepalive.ts +59 -0
  205. package/src/update-checker.ts +186 -0
  206. package/src/utils/audio-convert.ts +859 -0
  207. package/src/utils/chunked-upload.ts +483 -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 +631 -0
  211. package/src/utils/media-tags.ts +183 -0
  212. package/src/utils/payload.ts +265 -0
  213. package/src/utils/pkg-version.ts +64 -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 +85 -0
  217. package/src/utils/upload-cache.ts +128 -0
  218. package/tsconfig.json +16 -0
@@ -0,0 +1,483 @@
1
+ /**
2
+ * 大文件分片上传模块
3
+ *
4
+ * 流程(对照序列图):
5
+ * 1. 申请上传 (upload_prepare) → 获取 upload_id + block_size + 分片预签名链接
6
+ * 2. 并行上传所有分片:
7
+ * 对于每个分片 i(并行执行,但分片内部串行):
8
+ * a. 读取文件的第 i 块数据
9
+ * b. PUT 到预签名 URL (COS)
10
+ * c. 调用 upload_part_finish 通知开放平台分片 i 已完成
11
+ * 3. 所有分片完成后,调用完成文件上传接口 → 获取 file_info
12
+ *
13
+ * 注意:N 个分片之间是并行的,但每个分片的"上传 + 完成"是串行的。
14
+ */
15
+
16
+ import * as crypto from "node:crypto";
17
+ import * as fs from "node:fs";
18
+ import {
19
+ type MediaFileType,
20
+ type UploadPrepareResponse,
21
+ type UploadPrepareHashes,
22
+ type MediaUploadResponse,
23
+ ApiError,
24
+ UPLOAD_PREPARE_FALLBACK_CODE,
25
+ c2cUploadPrepare,
26
+ c2cUploadPartFinish,
27
+ c2cCompleteUpload,
28
+ groupUploadPrepare,
29
+ groupUploadPartFinish,
30
+ groupCompleteUpload,
31
+ getAccessToken,
32
+ } from "../api.js";
33
+ import { formatFileSize } from "./file-utils.js";
34
+
35
+ /**
36
+ * upload_prepare 返回特定错误码(40093002)时抛出:文件超过每日累积上传限制
37
+ * 调用方根据携带的文件信息构造兜底文案发送给用户
38
+ */
39
+ export class UploadDailyLimitExceededError extends Error {
40
+ /** 触发错误的本地文件路径 */
41
+ public readonly filePath: string;
42
+ /** 文件大小(字节) */
43
+ public readonly fileSize: number;
44
+
45
+ constructor(filePath: string, fileSize: number, originalMessage: string) {
46
+ super(originalMessage);
47
+ this.name = "UploadDailyLimitExceededError";
48
+ this.filePath = filePath;
49
+ this.fileSize = fileSize;
50
+ }
51
+ }
52
+
53
+ /** 分片上传默认并发数(服务端未返回 concurrency 时的兜底) */
54
+ const DEFAULT_CONCURRENT_PARTS = 1;
55
+
56
+ /** 分片上传并发上限(即使服务端返回更大的值也不超过此限制) */
57
+ const MAX_CONCURRENT_PARTS = 10;
58
+
59
+ /** partFinish 特定错误码重试超时上限(10 分钟),即使服务端 retry_timeout 更大也不超过此值 */
60
+ const MAX_PART_FINISH_RETRY_TIMEOUT_MS = 10 * 60 * 1000;
61
+
62
+ /** 单个分片上传超时(毫秒)— 5 分钟,兼容低带宽场景 */
63
+ const PART_UPLOAD_TIMEOUT = 300_000;
64
+
65
+ /** 单个分片上传最大重试次数 */
66
+ const PART_UPLOAD_MAX_RETRIES = 2;
67
+
68
+ /** 分片上传进度回调 */
69
+ export interface ChunkedUploadProgress {
70
+ /** 当前已完成分片数 */
71
+ completedParts: number;
72
+ /** 总分片数 */
73
+ totalParts: number;
74
+ /** 已上传字节数 */
75
+ uploadedBytes: number;
76
+ /** 总字节数 */
77
+ totalBytes: number;
78
+ }
79
+
80
+ /** 分片上传选项 */
81
+ export interface ChunkedUploadOptions {
82
+ /** 进度回调 */
83
+ onProgress?: (progress: ChunkedUploadProgress) => void;
84
+ /** 日志前缀 */
85
+ logPrefix?: string;
86
+ }
87
+
88
+ /**
89
+ * C2C 大文件分片上传
90
+ *
91
+ * @param appId - 应用 ID
92
+ * @param clientSecret - 应用密钥
93
+ * @param userId - 用户 openid
94
+ * @param filePath - 本地文件路径
95
+ * @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
96
+ * @param options - 上传选项
97
+ * @returns 上传结果(包含 file_info 可直接用于发送消息)
98
+ */
99
+ export async function chunkedUploadC2C(
100
+ appId: string,
101
+ clientSecret: string,
102
+ userId: string,
103
+ filePath: string,
104
+ fileType: MediaFileType,
105
+ options?: ChunkedUploadOptions,
106
+ ): Promise<MediaUploadResponse> {
107
+ const prefix = options?.logPrefix ?? "[chunked-upload]";
108
+
109
+ // 1. 读取文件信息
110
+ const stat = await fs.promises.stat(filePath);
111
+ const fileSize = stat.size;
112
+ const fileName = filePath.split(/[/\\]/).pop() ?? "file";
113
+
114
+ console.log(`${prefix} Starting chunked upload: file=${fileName}, size=${formatFileSize(fileSize)}, type=${fileType}`);
115
+
116
+ // 2. 计算文件哈希(md5, sha1, md5_10m)
117
+ console.log(`${prefix} Computing file hashes...`);
118
+ const hashes = await computeFileHashes(filePath, fileSize);
119
+ console.log(`${prefix} File hashes: md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m}`);
120
+
121
+ // 3. 申请上传 → 获取 upload_id + block_size + 预签名链接
122
+ const accessToken = await getAccessToken(appId, clientSecret);
123
+ console.log(`${prefix} >>> Calling c2cUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
124
+ let prepareResp: UploadPrepareResponse;
125
+ try {
126
+ prepareResp = await c2cUploadPrepare(accessToken, userId, fileType, fileName, fileSize, hashes);
127
+ } catch (err) {
128
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
129
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
130
+ console.warn(`${prefix} c2cUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
131
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
132
+ }
133
+ throw err;
134
+ }
135
+ console.log(`${prefix} <<< c2cUploadPrepare response:`, JSON.stringify(prepareResp));
136
+ const { upload_id, parts } = prepareResp;
137
+ // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
138
+ const block_size = Number(prepareResp.block_size);
139
+
140
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
141
+ const maxConcurrent = Math.min(
142
+ prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
143
+ MAX_CONCURRENT_PARTS,
144
+ );
145
+
146
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
147
+ const retryTimeoutMs = prepareResp.retry_timeout
148
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
149
+ : undefined;
150
+
151
+ console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
152
+
153
+ // 4. 并行上传所有分片(带并发控制)
154
+ let completedParts = 0;
155
+ let uploadedBytes = 0;
156
+
157
+ const uploadPart = async (part: { index: number; presigned_url: string }): Promise<void> => {
158
+ const partIndex = part.index; // API 返回的 1-based index
159
+ const partNum = partIndex; // 显示用序号(与 API 一致)
160
+ // 计算本分片在文件中的偏移和长度(index 是 1-based,需要减 1)
161
+ const offset = (partIndex - 1) * block_size;
162
+ const length = Math.min(block_size, fileSize - offset);
163
+
164
+ // 读取分片数据
165
+ const partBuffer = await readFileChunk(filePath, offset, length);
166
+
167
+ // 计算 MD5
168
+ const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
169
+
170
+ console.log(`${prefix} Part ${partNum}/${parts.length}: uploading ${formatFileSize(length)} (offset=${offset}, md5=${md5Hex})`);
171
+
172
+ // a. PUT 到预签名 URL(带重试)
173
+ await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
174
+
175
+ // b. 通知开放平台分片上传完成(需要重新获取 token,避免长时间上传后 token 过期)
176
+ const token = await getAccessToken(appId, clientSecret);
177
+ console.log(`${prefix} >>> Calling c2cUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
178
+ await c2cUploadPartFinish(token, userId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
179
+ console.log(`${prefix} <<< c2cUploadPartFinish(partIndex=${partIndex}) done`);
180
+
181
+ // 更新进度
182
+ completedParts++;
183
+ uploadedBytes += length;
184
+ console.log(`${prefix} Part ${partNum}/${parts.length}: completed (${completedParts}/${parts.length})`);
185
+
186
+ if (options?.onProgress) {
187
+ options.onProgress({
188
+ completedParts,
189
+ totalParts: parts.length,
190
+ uploadedBytes,
191
+ totalBytes: fileSize,
192
+ });
193
+ }
194
+ };
195
+
196
+ // 并发控制:同时最多执行 maxConcurrent 个分片上传
197
+ await runWithConcurrency(
198
+ parts.map(part => () => uploadPart(part)),
199
+ maxConcurrent,
200
+ );
201
+
202
+ console.log(`${prefix} All ${parts.length} parts uploaded successfully, completing upload...`);
203
+
204
+ // 5. 完成文件上传
205
+ const finalToken = await getAccessToken(appId, clientSecret);
206
+ console.log(`${prefix} >>> Calling c2cCompleteUpload(upload_id=${upload_id})`);
207
+ const result = await c2cCompleteUpload(finalToken, userId, upload_id);
208
+ console.log(`${prefix} <<< c2cCompleteUpload response:`, JSON.stringify(result));
209
+
210
+ console.log(`${prefix} Upload completed: file_uuid=${result.file_uuid}, ttl=${result.ttl}s`);
211
+
212
+ return result;
213
+ }
214
+
215
+ /**
216
+ * Group 大文件分片上传
217
+ *
218
+ * @param appId - 应用 ID
219
+ * @param clientSecret - 应用密钥
220
+ * @param groupId - 群 openid
221
+ * @param filePath - 本地文件路径
222
+ * @param fileType - 文件类型(1=图片, 2=视频, 3=语音, 4=文件)
223
+ * @param options - 上传选项
224
+ * @returns 上传结果(包含 file_info 可直接用于发送消息)
225
+ */
226
+ export async function chunkedUploadGroup(
227
+ appId: string,
228
+ clientSecret: string,
229
+ groupId: string,
230
+ filePath: string,
231
+ fileType: MediaFileType,
232
+ options?: ChunkedUploadOptions,
233
+ ): Promise<MediaUploadResponse> {
234
+ const prefix = options?.logPrefix ?? "[chunked-upload]";
235
+
236
+ // 1. 读取文件信息
237
+ const stat = await fs.promises.stat(filePath);
238
+ const fileSize = stat.size;
239
+ const fileName = filePath.split(/[/\\]/).pop() ?? "file";
240
+
241
+ console.log(`${prefix} Starting chunked upload (group): file=${fileName}, size=${formatFileSize(fileSize)}, type=${fileType}`);
242
+
243
+ // 2. 计算文件哈希(md5, sha1, md5_10m)
244
+ console.log(`${prefix} Computing file hashes...`);
245
+ const hashes = await computeFileHashes(filePath, fileSize);
246
+ console.log(`${prefix} File hashes: md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m}`);
247
+
248
+ // 3. 申请上传
249
+ const accessToken = await getAccessToken(appId, clientSecret);
250
+ console.log(`${prefix} >>> Calling groupUploadPrepare(fileType=${fileType}, fileName=${fileName}, fileSize=${fileSize}, md5=${hashes.md5}, sha1=${hashes.sha1}, md5_10m=${hashes.md5_10m})`);
251
+ let prepareResp: UploadPrepareResponse;
252
+ try {
253
+ prepareResp = await groupUploadPrepare(accessToken, groupId, fileType, fileName, fileSize, hashes);
254
+ } catch (err) {
255
+ // 命中特定错误码 → 携带文件信息抛出,由上层构造兜底文案
256
+ if (err instanceof ApiError && err.bizCode === UPLOAD_PREPARE_FALLBACK_CODE) {
257
+ console.warn(`${prefix} groupUploadPrepare hit fallback code ${UPLOAD_PREPARE_FALLBACK_CODE}`);
258
+ throw new UploadDailyLimitExceededError(filePath, fileSize, err.message);
259
+ }
260
+ throw err;
261
+ }
262
+ console.log(`${prefix} <<< groupUploadPrepare response:`, JSON.stringify(prepareResp));
263
+ const { upload_id, parts } = prepareResp;
264
+ // QQ 开放平台返回的 block_size 可能是字符串,需要转为数字
265
+ const block_size = Number(prepareResp.block_size);
266
+
267
+ // 并发数:使用 API 返回的 concurrency,未返回则用默认值,且不超过上限
268
+ const maxConcurrent = Math.min(
269
+ prepareResp.concurrency ? Number(prepareResp.concurrency) : DEFAULT_CONCURRENT_PARTS,
270
+ MAX_CONCURRENT_PARTS,
271
+ );
272
+
273
+ // partFinish 特定错误码的重试超时:使用 API 返回的 retry_timeout(秒),上限 10 分钟
274
+ const retryTimeoutMs = prepareResp.retry_timeout
275
+ ? Math.min(Number(prepareResp.retry_timeout) * 1000, MAX_PART_FINISH_RETRY_TIMEOUT_MS)
276
+ : undefined;
277
+
278
+ console.log(`${prefix} Upload prepared: upload_id=${upload_id}, block_size=${formatFileSize(block_size)}, parts=${parts.length}, concurrency=${maxConcurrent}, retryTimeout=${retryTimeoutMs ? retryTimeoutMs / 1000 + 's' : 'default'}`);
279
+
280
+ // 4. 并行上传所有分片(带并发控制)
281
+ let completedParts = 0;
282
+ let uploadedBytes = 0;
283
+
284
+ const uploadPart = async (part: { index: number; presigned_url: string }): Promise<void> => {
285
+ const partIndex = part.index; // API 返回的 1-based index
286
+ const partNum = partIndex; // 显示用序号(与 API 一致)
287
+ const offset = (partIndex - 1) * block_size;
288
+ const length = Math.min(block_size, fileSize - offset);
289
+
290
+ const partBuffer = await readFileChunk(filePath, offset, length);
291
+ const md5Hex = crypto.createHash("md5").update(partBuffer).digest("hex");
292
+
293
+ console.log(`${prefix} Part ${partNum}/${parts.length}: uploading ${formatFileSize(length)} (offset=${offset}, md5=${md5Hex})`);
294
+
295
+ await putToPresignedUrl(part.presigned_url, partBuffer, prefix, partNum, parts.length);
296
+
297
+ const token = await getAccessToken(appId, clientSecret);
298
+ console.log(`${prefix} >>> Calling groupUploadPartFinish(upload_id=${upload_id}, partIndex=${partIndex}, blockSize=${length}, md5=${md5Hex})`);
299
+ await groupUploadPartFinish(token, groupId, upload_id, partIndex, length, md5Hex, retryTimeoutMs);
300
+ console.log(`${prefix} <<< groupUploadPartFinish(partIndex=${partIndex}) done`);
301
+
302
+ completedParts++;
303
+ uploadedBytes += length;
304
+ console.log(`${prefix} Part ${partNum}/${parts.length}: completed (${completedParts}/${parts.length})`);
305
+
306
+ if (options?.onProgress) {
307
+ options.onProgress({
308
+ completedParts,
309
+ totalParts: parts.length,
310
+ uploadedBytes,
311
+ totalBytes: fileSize,
312
+ });
313
+ }
314
+ };
315
+
316
+ await runWithConcurrency(
317
+ parts.map(part => () => uploadPart(part)),
318
+ maxConcurrent,
319
+ );
320
+
321
+ console.log(`${prefix} All ${parts.length} parts uploaded successfully, completing upload...`);
322
+
323
+ // 5. 完成文件上传
324
+ const finalToken = await getAccessToken(appId, clientSecret);
325
+ console.log(`${prefix} >>> Calling groupCompleteUpload(upload_id=${upload_id})`);
326
+ const result = await groupCompleteUpload(finalToken, groupId, upload_id);
327
+ console.log(`${prefix} <<< groupCompleteUpload response:`, JSON.stringify(result));
328
+
329
+ console.log(`${prefix} Upload completed: file_uuid=${result.file_uuid}, ttl=${result.ttl}s`);
330
+
331
+ return result;
332
+ }
333
+
334
+ /**
335
+ * 读取文件的指定区间(分片)
336
+ */
337
+ async function readFileChunk(filePath: string, offset: number, length: number): Promise<Buffer> {
338
+ const fd = await fs.promises.open(filePath, "r");
339
+ try {
340
+ const buffer = Buffer.alloc(length);
341
+ const { bytesRead } = await fd.read(buffer, 0, length, offset);
342
+ if (bytesRead < length) {
343
+ // 文件末尾,返回实际读取的部分
344
+ return buffer.subarray(0, bytesRead);
345
+ }
346
+ return buffer;
347
+ } finally {
348
+ await fd.close();
349
+ }
350
+ }
351
+
352
+ /**
353
+ * PUT 分片数据到预签名 URL(带重试)
354
+ */
355
+ async function putToPresignedUrl(
356
+ presignedUrl: string,
357
+ data: Buffer,
358
+ prefix: string,
359
+ partIndex: number,
360
+ totalParts: number,
361
+ ): Promise<void> {
362
+ let lastError: Error | null = null;
363
+
364
+ for (let attempt = 0; attempt <= PART_UPLOAD_MAX_RETRIES; attempt++) {
365
+ try {
366
+ const controller = new AbortController();
367
+ const timeoutId = setTimeout(() => controller.abort(), PART_UPLOAD_TIMEOUT);
368
+
369
+ try {
370
+ // 将 Buffer 转为标准 ArrayBuffer 再包装为 Blob,兼容 bun-types 类型定义
371
+ const ab = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
372
+
373
+ const attemptLabel = attempt > 0 ? ` (retry ${attempt})` : "";
374
+ console.log(`${prefix} >>> PUT Part ${partIndex}/${totalParts}${attemptLabel}: url=${presignedUrl}, size=${data.length}`);
375
+ const startTime = Date.now();
376
+
377
+ const response = await fetch(presignedUrl, {
378
+ method: "PUT",
379
+ body: new Blob([ab]),
380
+ headers: {
381
+ "Content-Length": String(data.length),
382
+ },
383
+ signal: controller.signal,
384
+ });
385
+
386
+ const elapsed = Date.now() - startTime;
387
+ const etag = response.headers.get("ETag") ?? "-";
388
+ const requestId = response.headers.get("x-cos-request-id") ?? "-";
389
+
390
+ if (!response.ok) {
391
+ const body = await response.text().catch(() => "");
392
+ console.error(`${prefix} <<< PUT Part ${partIndex}/${totalParts}: FAILED ${response.status} ${response.statusText} (${elapsed}ms, requestId=${requestId}) body=${body}`);
393
+ throw new Error(`COS PUT failed: ${response.status} ${response.statusText} - ${body}`);
394
+ }
395
+
396
+ console.log(`${prefix} <<< PUT Part ${partIndex}/${totalParts}: ${response.status} OK (${elapsed}ms, ETag=${etag}, requestId=${requestId})`);
397
+ return; // 成功
398
+ } finally {
399
+ clearTimeout(timeoutId);
400
+ }
401
+ } catch (err) {
402
+ lastError = err instanceof Error ? err : new Error(String(err));
403
+
404
+ if (lastError.name === "AbortError") {
405
+ lastError = new Error(`Part ${partIndex}/${totalParts} upload timeout after ${PART_UPLOAD_TIMEOUT}ms`);
406
+ }
407
+
408
+ if (attempt < PART_UPLOAD_MAX_RETRIES) {
409
+ const delay = 1000 * Math.pow(2, attempt);
410
+ console.warn(`${prefix} Part ${partIndex}/${totalParts}: attempt ${attempt + 1} failed (${lastError.message}), retrying in ${delay}ms...`);
411
+ await new Promise(resolve => setTimeout(resolve, delay));
412
+ }
413
+ }
414
+ }
415
+
416
+ throw lastError!;
417
+ }
418
+
419
+ /**
420
+ * 带并发限制的异步任务执行器(批次模式)
421
+ * 每批最多执行 maxConcurrent 个任务,等全部完成后再启动下一批
422
+ */
423
+ async function runWithConcurrency(
424
+ tasks: Array<() => Promise<void>>,
425
+ maxConcurrent: number,
426
+ ): Promise<void> {
427
+ for (let i = 0; i < tasks.length; i += maxConcurrent) {
428
+ const batch = tasks.slice(i, i + maxConcurrent);
429
+ await Promise.all(batch.map(task => task()));
430
+ }
431
+ }
432
+
433
+ // ============ 文件哈希计算 ============
434
+
435
+ /** 文件前 N 字节用于计算 md5_10m(与协议定义一致:10002432 Bytes) */
436
+ const MD5_10M_SIZE = 10002432;
437
+
438
+ /**
439
+ * 流式计算文件的 MD5、SHA1、md5_10m(前 10002432 Bytes 的 MD5)
440
+ * 只遍历文件一次,内存友好
441
+ */
442
+ async function computeFileHashes(
443
+ filePath: string,
444
+ fileSize: number,
445
+ ): Promise<UploadPrepareHashes> {
446
+ return new Promise((resolve, reject) => {
447
+ const md5Hash = crypto.createHash("md5");
448
+ const sha1Hash = crypto.createHash("sha1");
449
+ const md5_10mHash = crypto.createHash("md5");
450
+
451
+ let bytesRead = 0;
452
+ const need10m = fileSize > MD5_10M_SIZE; // 文件超过阈值才需要单独计算 md5_10m
453
+
454
+ const stream = fs.createReadStream(filePath);
455
+
456
+ stream.on("data", (chunk: Buffer | string) => {
457
+ // ReadStream 默认 encoding=null,chunk 一定是 Buffer,但类型声明要求兼容 string
458
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
459
+ md5Hash.update(buf);
460
+ sha1Hash.update(buf);
461
+
462
+ if (need10m) {
463
+ const remaining = MD5_10M_SIZE - bytesRead;
464
+ if (remaining > 0) {
465
+ md5_10mHash.update(remaining >= buf.length ? buf : buf.subarray(0, remaining));
466
+ }
467
+ }
468
+
469
+ bytesRead += buf.length;
470
+ });
471
+
472
+ stream.on("end", () => {
473
+ const md5 = md5Hash.digest("hex");
474
+ const sha1 = sha1Hash.digest("hex");
475
+ // 文件不足 MD5_10M_SIZE 时,md5_10m 等于整文件 MD5
476
+ const md5_10m = need10m ? md5_10mHash.digest("hex") : md5;
477
+
478
+ resolve({ md5, sha1, md5_10m });
479
+ });
480
+
481
+ stream.on("error", reject);
482
+ });
483
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * 文件操作工具 — 异步读取 + 大小校验 + 进度提示
3
+ */
4
+
5
+ import * as fs from "node:fs";
6
+ import * as path from "node:path";
7
+ import crypto from "node:crypto";
8
+
9
+ /** QQ Bot API 各类型文件上传大小限制(QQ 机器人上行) */
10
+ export const UPLOAD_SIZE_LIMITS: Record<number, number> = {
11
+ 1: 30 * 1024 * 1024, // IMAGE: 30MB
12
+ 2: 100 * 1024 * 1024, // VIDEO: 100MB
13
+ 3: 20 * 1024 * 1024, // VOICE: 20MB
14
+ 4: 100 * 1024 * 1024, // FILE: 100MB
15
+ };
16
+
17
+ /** 文件类型中文名映射 */
18
+ const FILE_TYPE_NAMES: Record<number, string> = {
19
+ 1: "图片",
20
+ 2: "视频",
21
+ 3: "语音",
22
+ 4: "文件",
23
+ };
24
+
25
+ /** 获取文件类型的中文名称;未知类型返回 "文件" */
26
+ export function getFileTypeName(fileType: number): string {
27
+ return FILE_TYPE_NAMES[fileType] ?? "文件";
28
+ }
29
+
30
+ /** 获取指定文件类型的上传大小限制;未知类型默认 100MB */
31
+ export function getMaxUploadSize(fileType: number): number {
32
+ return UPLOAD_SIZE_LIMITS[fileType] ?? 100 * 1024 * 1024;
33
+ }
34
+
35
+ /** @deprecated 使用 getMaxUploadSize(fileType) 代替 */
36
+ export const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
37
+
38
+ /** 大文件阈值(超过此值发送进度提示):5MB */
39
+ export const LARGE_FILE_THRESHOLD = 5 * 1024 * 1024;
40
+
41
+ /**
42
+ * 文件大小校验结果
43
+ */
44
+ export interface FileSizeCheckResult {
45
+ ok: boolean;
46
+ size: number;
47
+ error?: string;
48
+ }
49
+
50
+ /**
51
+ * 校验文件大小是否在上传限制内
52
+ * @param filePath 文件路径
53
+ * @param maxSize 最大允许大小(字节),默认 20MB
54
+ */
55
+ export function checkFileSize(filePath: string, maxSize = MAX_UPLOAD_SIZE): FileSizeCheckResult {
56
+ try {
57
+ const stat = fs.statSync(filePath);
58
+ if (stat.size > maxSize) {
59
+ const sizeMB = (stat.size / (1024 * 1024)).toFixed(1);
60
+ const limitMB = (maxSize / (1024 * 1024)).toFixed(0);
61
+ return {
62
+ ok: false,
63
+ size: stat.size,
64
+ error: `文件过大 (${sizeMB}MB),QQ Bot API 上传限制为 ${limitMB}MB`,
65
+ };
66
+ }
67
+ return { ok: true, size: stat.size };
68
+ } catch (err) {
69
+ return {
70
+ ok: false,
71
+ size: 0,
72
+ error: `无法读取文件信息: ${err instanceof Error ? err.message : String(err)}`,
73
+ };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * 异步读取文件内容
79
+ * 替代 fs.readFileSync,避免阻塞事件循环
80
+ */
81
+ export async function readFileAsync(filePath: string): Promise<Buffer> {
82
+ return fs.promises.readFile(filePath);
83
+ }
84
+
85
+ /**
86
+ * 异步检查文件是否存在
87
+ */
88
+ export async function fileExistsAsync(filePath: string): Promise<boolean> {
89
+ try {
90
+ await fs.promises.access(filePath, fs.constants.R_OK);
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 异步获取文件大小
99
+ */
100
+ export async function getFileSizeAsync(filePath: string): Promise<number> {
101
+ const stat = await fs.promises.stat(filePath);
102
+ return stat.size;
103
+ }
104
+
105
+ /**
106
+ * 判断文件是否为"大文件"(需要进度提示)
107
+ */
108
+ export function isLargeFile(sizeBytes: number): boolean {
109
+ return sizeBytes >= LARGE_FILE_THRESHOLD;
110
+ }
111
+
112
+ /**
113
+ * 格式化文件大小为人类可读的字符串
114
+ */
115
+ export function formatFileSize(bytes: number): string {
116
+ if (bytes < 1024) return `${bytes}B`;
117
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
118
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
119
+ }
120
+
121
+ /**
122
+ * 根据文件扩展名获取 MIME 类型
123
+ */
124
+ export function getMimeType(filePath: string): string {
125
+ const ext = path.extname(filePath).toLowerCase();
126
+ const mimeTypes: Record<string, string> = {
127
+ ".jpg": "image/jpeg",
128
+ ".jpeg": "image/jpeg",
129
+ ".png": "image/png",
130
+ ".gif": "image/gif",
131
+ ".webp": "image/webp",
132
+ ".bmp": "image/bmp",
133
+ ".mp4": "video/mp4",
134
+ ".mov": "video/quicktime",
135
+ ".avi": "video/x-msvideo",
136
+ ".mkv": "video/x-matroska",
137
+ ".webm": "video/webm",
138
+ ".pdf": "application/pdf",
139
+ ".doc": "application/msword",
140
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
141
+ ".xls": "application/vnd.ms-excel",
142
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
143
+ ".zip": "application/zip",
144
+ ".tar": "application/x-tar",
145
+ ".gz": "application/gzip",
146
+ ".txt": "text/plain",
147
+ };
148
+ return mimeTypes[ext] ?? "application/octet-stream";
149
+ }
150
+
151
+ /**
152
+ * 将远端文件下载到本地目录。
153
+ *
154
+ * @param url 远端 URL
155
+ * @param destDir 目标目录(不存在时自动创建)
156
+ * @param originalFilename 可选的原始文件名(覆盖 URL 推断)
157
+ * @returns 本地文件完整路径;下载失败返回 null
158
+ */
159
+ export async function downloadFile(url: string, destDir: string, originalFilename?: string): Promise<string | null> {
160
+ try {
161
+ if (!fs.existsSync(destDir)) {
162
+ fs.mkdirSync(destDir, { recursive: true });
163
+ }
164
+
165
+ const resp = await fetch(url, { redirect: "follow" });
166
+ if (!resp.ok || !resp.body) return null;
167
+
168
+ // 确定文件名:优先使用 originalFilename,否则从 URL 推断
169
+ let filename = originalFilename?.trim() || "";
170
+ if (!filename) {
171
+ try {
172
+ const urlPath = new URL(url).pathname;
173
+ filename = path.basename(urlPath) || "download";
174
+ } catch {
175
+ filename = "download";
176
+ }
177
+ }
178
+
179
+ // 加上时间戳避免同名冲突
180
+ const ts = Date.now();
181
+ const ext = path.extname(filename);
182
+ const base = path.basename(filename, ext) || "file";
183
+ const rand = crypto.randomBytes(3).toString("hex");
184
+ const safeFilename = `${base}_${ts}_${rand}${ext}`;
185
+
186
+ const destPath = path.join(destDir, safeFilename);
187
+ const buffer = Buffer.from(await resp.arrayBuffer());
188
+ await fs.promises.writeFile(destPath, buffer);
189
+ return destPath;
190
+ } catch {
191
+ return null;
192
+ }
193
+ }