koishi-plugin-assets-qqbot-part-file 0.1.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.
- package/lib/index.d.ts +39 -0
- package/lib/index.js +333 -0
- package/package.json +49 -0
- package/readme.md +21 -0
- package/src/index.ts +443 -0
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Context, Schema } from 'koishi';
|
|
2
|
+
import Assets from '@koishijs/assets';
|
|
3
|
+
export declare const name = "assets-qqbot-part-file";
|
|
4
|
+
declare class QQBotPartFileAssets extends Assets<QQBotPartFileAssets.Config> {
|
|
5
|
+
types: string[];
|
|
6
|
+
private readonly api;
|
|
7
|
+
private token;
|
|
8
|
+
private tokenExpiresAt;
|
|
9
|
+
private refreshing;
|
|
10
|
+
constructor(ctx: Context, config: QQBotPartFileAssets.Config);
|
|
11
|
+
private debug;
|
|
12
|
+
private fail;
|
|
13
|
+
private describe;
|
|
14
|
+
private getClientSecret;
|
|
15
|
+
private getAccessToken;
|
|
16
|
+
private refreshAccessToken;
|
|
17
|
+
private sniffMime;
|
|
18
|
+
private mimeFromFilename;
|
|
19
|
+
private resolveMime;
|
|
20
|
+
private resolveFileType;
|
|
21
|
+
private resolveUploadFilename;
|
|
22
|
+
private buildPublicUrl;
|
|
23
|
+
upload(url: string, file: string): Promise<string>;
|
|
24
|
+
stats(): Promise<{}>;
|
|
25
|
+
}
|
|
26
|
+
declare namespace QQBotPartFileAssets {
|
|
27
|
+
interface Config extends Assets.Config {
|
|
28
|
+
appid: string;
|
|
29
|
+
appSecret: string;
|
|
30
|
+
groupId: string;
|
|
31
|
+
debug: boolean;
|
|
32
|
+
}
|
|
33
|
+
const Config: Schema<Config>;
|
|
34
|
+
}
|
|
35
|
+
export interface Config extends QQBotPartFileAssets.Config {
|
|
36
|
+
}
|
|
37
|
+
export declare const Config: Schema<QQBotPartFileAssets.Config>;
|
|
38
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
39
|
+
export default QQBotPartFileAssets;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
var __create = Object.create;
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name2 in all)
|
|
10
|
+
__defProp(target, name2, { get: all[name2], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
|
+
Config: () => Config,
|
|
34
|
+
apply: () => apply,
|
|
35
|
+
default: () => src_default,
|
|
36
|
+
name: () => name
|
|
37
|
+
});
|
|
38
|
+
module.exports = __toCommonJS(src_exports);
|
|
39
|
+
var import_node_crypto = require("node:crypto");
|
|
40
|
+
var import_node_path = require("node:path");
|
|
41
|
+
var import_koishi = require("koishi");
|
|
42
|
+
var import_assets = __toESM(require("@koishijs/assets"));
|
|
43
|
+
var name = "assets-qqbot-part-file";
|
|
44
|
+
var QQ_API = "https://api.sgroup.qq.com";
|
|
45
|
+
var QQ_TOKEN_API = "https://bots.qq.com/app/getAppAccessToken";
|
|
46
|
+
var MAX_SIZE = 30 * 1024 * 1024;
|
|
47
|
+
var MIME_TO_EXT = {
|
|
48
|
+
"image/jpeg": ".jpg",
|
|
49
|
+
"image/jpg": ".jpg",
|
|
50
|
+
"image/png": ".png",
|
|
51
|
+
"image/gif": ".gif",
|
|
52
|
+
"image/webp": ".webp",
|
|
53
|
+
"image/bmp": ".bmp",
|
|
54
|
+
"image/tiff": ".tif",
|
|
55
|
+
"image/svg+xml": ".svg",
|
|
56
|
+
"video/mp4": ".mp4",
|
|
57
|
+
"video/webm": ".webm",
|
|
58
|
+
"video/quicktime": ".mov",
|
|
59
|
+
"audio/mpeg": ".mp3",
|
|
60
|
+
"audio/mp3": ".mp3",
|
|
61
|
+
"audio/wav": ".wav",
|
|
62
|
+
"audio/ogg": ".ogg",
|
|
63
|
+
"audio/aac": ".aac",
|
|
64
|
+
"audio/flac": ".flac"
|
|
65
|
+
};
|
|
66
|
+
var _QQBotPartFileAssets = class _QQBotPartFileAssets extends import_assets.default {
|
|
67
|
+
constructor(ctx, config) {
|
|
68
|
+
super(ctx, config);
|
|
69
|
+
this.types = ["image", "img", "audio", "video"];
|
|
70
|
+
this.api = this.ctx.http.extend({
|
|
71
|
+
endpoint: QQ_API,
|
|
72
|
+
headers: {
|
|
73
|
+
accept: "application/json",
|
|
74
|
+
Authorization: ""
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
this.token = "";
|
|
78
|
+
this.tokenExpiresAt = 0;
|
|
79
|
+
this.refreshing = null;
|
|
80
|
+
}
|
|
81
|
+
debug(...args) {
|
|
82
|
+
if (!this.config.debug) return;
|
|
83
|
+
this.ctx.logger(name).info(args.join(" "));
|
|
84
|
+
}
|
|
85
|
+
fail(message, error) {
|
|
86
|
+
if (error instanceof Error) {
|
|
87
|
+
this.ctx.logger(name).error(`${message}: ${error.message}`);
|
|
88
|
+
} else if (typeof error !== "undefined") {
|
|
89
|
+
this.ctx.logger(name).error(`${message}: ${this.describe(error)}`);
|
|
90
|
+
} else {
|
|
91
|
+
this.ctx.logger(name).error(message);
|
|
92
|
+
}
|
|
93
|
+
throw error instanceof Error ? error : new Error(message);
|
|
94
|
+
}
|
|
95
|
+
describe(value) {
|
|
96
|
+
if (typeof value === "string") return value;
|
|
97
|
+
try {
|
|
98
|
+
return JSON.stringify(value);
|
|
99
|
+
} catch {
|
|
100
|
+
return String(value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
getClientSecret() {
|
|
104
|
+
const secret = this.config.appSecret;
|
|
105
|
+
if (!secret) {
|
|
106
|
+
throw new Error("缺少 QQ 机器人的 clientSecret");
|
|
107
|
+
}
|
|
108
|
+
return secret;
|
|
109
|
+
}
|
|
110
|
+
async getAccessToken() {
|
|
111
|
+
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
|
|
112
|
+
if (this.refreshing) return this.refreshing;
|
|
113
|
+
this.refreshing = this.refreshAccessToken();
|
|
114
|
+
try {
|
|
115
|
+
return await this.refreshing;
|
|
116
|
+
} finally {
|
|
117
|
+
this.refreshing = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async refreshAccessToken() {
|
|
121
|
+
const response = await this.ctx.http.post(QQ_TOKEN_API, {
|
|
122
|
+
appId: this.config.appid,
|
|
123
|
+
clientSecret: this.getClientSecret()
|
|
124
|
+
});
|
|
125
|
+
const token = response?.access_token;
|
|
126
|
+
if (!token) {
|
|
127
|
+
throw new Error(`QQ 获取 access_token 失败: ${this.describe(response)}`);
|
|
128
|
+
}
|
|
129
|
+
this.token = token;
|
|
130
|
+
const expiresIn = Number(response.expires_in || 7200);
|
|
131
|
+
this.tokenExpiresAt = Date.now() + Math.max(expiresIn - 60, 60) * 1e3;
|
|
132
|
+
this.api.config.headers.Authorization = `QQBot ${token}`;
|
|
133
|
+
return token;
|
|
134
|
+
}
|
|
135
|
+
sniffMime(buffer) {
|
|
136
|
+
if (buffer.length >= 8) {
|
|
137
|
+
if (buffer[0] === 137 && buffer[1] === 80 && buffer[2] === 78 && buffer[3] === 71 && buffer[4] === 13 && buffer[5] === 10 && buffer[6] === 26 && buffer[7] === 10) return "image/png";
|
|
138
|
+
}
|
|
139
|
+
if (buffer.length >= 3 && buffer[0] === 255 && buffer[1] === 216 && buffer[2] === 255) {
|
|
140
|
+
return "image/jpeg";
|
|
141
|
+
}
|
|
142
|
+
if (buffer.length >= 6) {
|
|
143
|
+
const head = buffer.subarray(0, 6).toString("ascii");
|
|
144
|
+
if (head === "GIF87a" || head === "GIF89a") return "image/gif";
|
|
145
|
+
}
|
|
146
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WEBP") {
|
|
147
|
+
return "image/webp";
|
|
148
|
+
}
|
|
149
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString("ascii") === "RIFF" && buffer.subarray(8, 12).toString("ascii") === "WAVE") {
|
|
150
|
+
return "audio/wav";
|
|
151
|
+
}
|
|
152
|
+
if (buffer.length >= 4) {
|
|
153
|
+
const head = buffer.subarray(0, 4).toString("ascii");
|
|
154
|
+
if (head === "fLaC") return "audio/flac";
|
|
155
|
+
if (head === "OggS") return "audio/ogg";
|
|
156
|
+
if (head === "ftyp" || buffer.length >= 8 && buffer.subarray(4, 8).toString("ascii") === "ftyp") {
|
|
157
|
+
const brand = buffer.length >= 12 ? buffer.subarray(8, 12).toString("ascii").toLowerCase() : "";
|
|
158
|
+
return brand.includes("qt") ? "video/quicktime" : "video/mp4";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (buffer.length >= 4) {
|
|
162
|
+
const head = buffer.subarray(0, 4).toString("hex");
|
|
163
|
+
if (head === "1a45dfa3") return "video/webm";
|
|
164
|
+
}
|
|
165
|
+
if (buffer.length >= 2 && buffer[0] === 66 && buffer[1] === 77) {
|
|
166
|
+
return "image/bmp";
|
|
167
|
+
}
|
|
168
|
+
if (buffer.length >= 4) {
|
|
169
|
+
const le = buffer[0] === 73 && buffer[1] === 73 && buffer[2] === 42 && buffer[3] === 0;
|
|
170
|
+
const be = buffer[0] === 77 && buffer[1] === 77 && buffer[2] === 0 && buffer[3] === 42;
|
|
171
|
+
if (le || be) return "image/tiff";
|
|
172
|
+
}
|
|
173
|
+
return void 0;
|
|
174
|
+
}
|
|
175
|
+
mimeFromFilename(filename) {
|
|
176
|
+
const ext = (0, import_node_path.extname)(filename).toLowerCase();
|
|
177
|
+
switch (ext) {
|
|
178
|
+
case ".jpg":
|
|
179
|
+
case ".jpeg":
|
|
180
|
+
return "image/jpeg";
|
|
181
|
+
case ".png":
|
|
182
|
+
return "image/png";
|
|
183
|
+
case ".gif":
|
|
184
|
+
return "image/gif";
|
|
185
|
+
case ".webp":
|
|
186
|
+
return "image/webp";
|
|
187
|
+
case ".bmp":
|
|
188
|
+
return "image/bmp";
|
|
189
|
+
case ".tif":
|
|
190
|
+
case ".tiff":
|
|
191
|
+
return "image/tiff";
|
|
192
|
+
case ".svg":
|
|
193
|
+
return "image/svg+xml";
|
|
194
|
+
case ".mp4":
|
|
195
|
+
return "video/mp4";
|
|
196
|
+
case ".webm":
|
|
197
|
+
return "video/webm";
|
|
198
|
+
case ".mov":
|
|
199
|
+
return "video/quicktime";
|
|
200
|
+
case ".mp3":
|
|
201
|
+
case ".m4a":
|
|
202
|
+
case ".aac":
|
|
203
|
+
return "audio/mpeg";
|
|
204
|
+
case ".wav":
|
|
205
|
+
return "audio/wav";
|
|
206
|
+
case ".ogg":
|
|
207
|
+
return "audio/ogg";
|
|
208
|
+
case ".flac":
|
|
209
|
+
return "audio/flac";
|
|
210
|
+
default:
|
|
211
|
+
return void 0;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
resolveMime(buffer, filename, declaredType) {
|
|
215
|
+
const sniffed = this.sniffMime(buffer);
|
|
216
|
+
if (sniffed) return sniffed;
|
|
217
|
+
const normalized = declaredType?.trim().toLowerCase();
|
|
218
|
+
if (normalized && normalized !== "application/octet-stream") return normalized;
|
|
219
|
+
return this.mimeFromFilename(filename);
|
|
220
|
+
}
|
|
221
|
+
resolveFileType(filename, mime) {
|
|
222
|
+
const lowerMime = mime?.toLowerCase();
|
|
223
|
+
if (lowerMime?.startsWith("image/")) return 1;
|
|
224
|
+
if (lowerMime?.startsWith("video/")) return 2;
|
|
225
|
+
if (lowerMime?.startsWith("audio/")) return 3;
|
|
226
|
+
const lowerName = filename.toLowerCase();
|
|
227
|
+
if (/\.(png|jpe?g|gif|webp|bmp|tiff?)$/.test(lowerName)) return 1;
|
|
228
|
+
if (/\.(mp4|webm|mov|mkv|m4v)$/.test(lowerName)) return 2;
|
|
229
|
+
if (/\.(silk|mp3|wav|ogg|m4a|aac|flac)$/.test(lowerName)) return 3;
|
|
230
|
+
return 4;
|
|
231
|
+
}
|
|
232
|
+
resolveUploadFilename(filename, mime) {
|
|
233
|
+
const ext = mime ? MIME_TO_EXT[mime.toLowerCase()] : void 0;
|
|
234
|
+
if (!ext) return filename;
|
|
235
|
+
const currentExt = (0, import_node_path.extname)(filename);
|
|
236
|
+
if (!currentExt) return `${filename}${ext}`;
|
|
237
|
+
if (currentExt.toLowerCase() === ext) return filename;
|
|
238
|
+
return `${filename.slice(0, -currentExt.length)}${ext}`;
|
|
239
|
+
}
|
|
240
|
+
buildPublicUrl(rawUrl, mime) {
|
|
241
|
+
const normalized = mime?.trim().toLowerCase();
|
|
242
|
+
if (!normalized) return rawUrl;
|
|
243
|
+
try {
|
|
244
|
+
const url = new URL(rawUrl);
|
|
245
|
+
url.searchParams.set("response-content-type", normalized);
|
|
246
|
+
return url.toString();
|
|
247
|
+
} catch {
|
|
248
|
+
return rawUrl;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async upload(url, file) {
|
|
252
|
+
const { buffer, filename, type } = await this.analyze(url, file);
|
|
253
|
+
const mime = this.resolveMime(buffer, filename, type);
|
|
254
|
+
const uploadFilename = this.resolveUploadFilename(filename, mime);
|
|
255
|
+
const fileType = this.resolveFileType(uploadFilename, mime);
|
|
256
|
+
if (buffer.byteLength > MAX_SIZE) {
|
|
257
|
+
throw new Error("QQ 分片上传当前仅支持 30MB 以内的文件");
|
|
258
|
+
}
|
|
259
|
+
await this.getAccessToken();
|
|
260
|
+
const prepare = await this.api.post(`/v2/groups/${this.config.groupId}/upload_prepare`, {
|
|
261
|
+
file_type: fileType,
|
|
262
|
+
file_size: buffer.byteLength,
|
|
263
|
+
file_name: uploadFilename,
|
|
264
|
+
md5: (0, import_node_crypto.createHash)("md5").update(buffer).digest("hex"),
|
|
265
|
+
sha1: (0, import_node_crypto.createHash)("sha1").update(buffer).digest("hex")
|
|
266
|
+
});
|
|
267
|
+
const uploadId = prepare?.upload_id;
|
|
268
|
+
const parts = [...prepare?.parts || []].sort((a, b) => a.index - b.index);
|
|
269
|
+
const blockSize = Number(prepare?.block_size || 0);
|
|
270
|
+
if (!uploadId || !parts.length || !blockSize) {
|
|
271
|
+
throw new Error(`QQ upload_prepare 返回异常: ${this.describe(prepare)}`);
|
|
272
|
+
}
|
|
273
|
+
this.debug(`开始上传 ${filename} -> ${uploadFilename}`, `mime=${mime || "unknown"}`, `parts=${parts.length}`);
|
|
274
|
+
for (const part of parts) {
|
|
275
|
+
const size = Number(part.block_size || blockSize);
|
|
276
|
+
const start = (part.index - 1) * blockSize;
|
|
277
|
+
const chunk = buffer.subarray(start, start + size);
|
|
278
|
+
await this.ctx.http.put(part.presigned_url, chunk, {
|
|
279
|
+
headers: {
|
|
280
|
+
"Content-Type": "application/octet-stream"
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
await this.api.post(`/v2/groups/${this.config.groupId}/upload_part_finish`, {
|
|
284
|
+
upload_id: uploadId,
|
|
285
|
+
part_index: part.index,
|
|
286
|
+
block_size: String(chunk.length),
|
|
287
|
+
md5: (0, import_node_crypto.createHash)("md5").update(chunk).digest("hex")
|
|
288
|
+
});
|
|
289
|
+
this.debug(`part_${part.index} uploaded`, this.buildPublicUrl(part.presigned_url, mime));
|
|
290
|
+
}
|
|
291
|
+
const merged = await this.api.post(`/v2/groups/${this.config.groupId}/files`, {
|
|
292
|
+
file_type: fileType,
|
|
293
|
+
srv_send_msg: false,
|
|
294
|
+
file_name: uploadFilename,
|
|
295
|
+
upload_id: uploadId
|
|
296
|
+
});
|
|
297
|
+
const rawUrl = merged?.raw_url;
|
|
298
|
+
if (!rawUrl) {
|
|
299
|
+
throw new Error(`QQ 分片上传完成后未返回 raw_url: ${this.describe(merged)}`);
|
|
300
|
+
}
|
|
301
|
+
const finalUrl = this.buildPublicUrl(rawUrl, mime);
|
|
302
|
+
this.debug(`uploaded ${filename} -> ${finalUrl}`);
|
|
303
|
+
return finalUrl;
|
|
304
|
+
}
|
|
305
|
+
async stats() {
|
|
306
|
+
return {};
|
|
307
|
+
}
|
|
308
|
+
};
|
|
309
|
+
__name(_QQBotPartFileAssets, "QQBotPartFileAssets");
|
|
310
|
+
var QQBotPartFileAssets = _QQBotPartFileAssets;
|
|
311
|
+
((QQBotPartFileAssets2) => {
|
|
312
|
+
QQBotPartFileAssets2.Config = import_koishi.Schema.intersect([
|
|
313
|
+
import_koishi.Schema.object({
|
|
314
|
+
appid: import_koishi.Schema.string().description("QQ 机器人 AppID").required(),
|
|
315
|
+
appSecret: import_koishi.Schema.string().description("QQ 机器人 AppSecret").role("secret").required(),
|
|
316
|
+
groupId: import_koishi.Schema.string().description("用于上传文件的群 OpenID<br>请使用inspect指令查看群组openId,任意QQ群组即可").required(),
|
|
317
|
+
debug: import_koishi.Schema.boolean().default(false).description("开启后输出调试日志").experimental()
|
|
318
|
+
}),
|
|
319
|
+
import_assets.default.Config
|
|
320
|
+
]);
|
|
321
|
+
})(QQBotPartFileAssets || (QQBotPartFileAssets = {}));
|
|
322
|
+
var Config = QQBotPartFileAssets.Config;
|
|
323
|
+
function apply(ctx, config) {
|
|
324
|
+
ctx.plugin(QQBotPartFileAssets, config);
|
|
325
|
+
}
|
|
326
|
+
__name(apply, "apply");
|
|
327
|
+
var src_default = QQBotPartFileAssets;
|
|
328
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
329
|
+
0 && (module.exports = {
|
|
330
|
+
Config,
|
|
331
|
+
apply,
|
|
332
|
+
name
|
|
333
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "koishi-plugin-assets-qqbot-part-file",
|
|
3
|
+
"description": "Serve assets on QQ Bot by multipart upload",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"main": "lib/index.js",
|
|
6
|
+
"typings": "lib/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"lib",
|
|
9
|
+
"src"
|
|
10
|
+
],
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/koishi-shangxue-plugins/service-more.git"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/koishi-shangxue-plugins/service-more/issues"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/koishi-shangxue-plugins/service-more",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"bot",
|
|
22
|
+
"chatbot",
|
|
23
|
+
"koishi",
|
|
24
|
+
"plugin",
|
|
25
|
+
"assets",
|
|
26
|
+
"qq"
|
|
27
|
+
],
|
|
28
|
+
"koishi": {
|
|
29
|
+
"category": "storage",
|
|
30
|
+
"description": {
|
|
31
|
+
"en": "Serve assets on QQ Bot by multipart upload",
|
|
32
|
+
"zh": "通过分片上传为QQ机器人提供资源服务,支持图片、音频、视频资源的上传和访问"
|
|
33
|
+
},
|
|
34
|
+
"service": {
|
|
35
|
+
"implements": [
|
|
36
|
+
"assets"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"koishi": "^4.17.7"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"koishi": "^4.17.7"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@koishijs/assets": "^1.1.2"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# assets-qqbot-part-file
|
|
2
|
+
|
|
3
|
+
QQ 机器人分片上传版 `assets` 服务。
|
|
4
|
+
|
|
5
|
+
## 支持内容
|
|
6
|
+
|
|
7
|
+
- 图片
|
|
8
|
+
- 视频
|
|
9
|
+
- 音频
|
|
10
|
+
|
|
11
|
+
## 说明
|
|
12
|
+
|
|
13
|
+
- 通过 QQ 官方 `upload_prepare -> PUT 分片 -> upload_part_finish -> files` 流程获取可公开访问的 `raw_url`
|
|
14
|
+
- 图片链接会自动补上 `response-content-type=image/jpeg`,便于嵌入 Markdown
|
|
15
|
+
- `file_type=4` 的普通文件不会返回 `raw_url`,因此不适合作为 Markdown 图片 URL
|
|
16
|
+
|
|
17
|
+
## 配置项
|
|
18
|
+
|
|
19
|
+
- `appid`:QQ 机器人 AppID
|
|
20
|
+
- `appSecret`:QQ 机器人 AppSecret
|
|
21
|
+
- `groupId`:用于上传的群 OpenID
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { extname } from 'node:path';
|
|
3
|
+
import { Context, Schema } from 'koishi';
|
|
4
|
+
import Assets from '@koishijs/assets';
|
|
5
|
+
import { } from '@koishijs/plugin-http';
|
|
6
|
+
|
|
7
|
+
export const name = 'assets-qqbot-part-file';
|
|
8
|
+
|
|
9
|
+
const QQ_API = 'https://api.sgroup.qq.com';
|
|
10
|
+
const QQ_TOKEN_API = 'https://bots.qq.com/app/getAppAccessToken';
|
|
11
|
+
const MAX_SIZE = 30 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
interface AccessTokenResponse
|
|
14
|
+
{
|
|
15
|
+
access_token?: string;
|
|
16
|
+
expires_in?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UploadPartInfo
|
|
20
|
+
{
|
|
21
|
+
index: number;
|
|
22
|
+
presigned_url: string;
|
|
23
|
+
block_size: number | string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UploadPrepareResult
|
|
27
|
+
{
|
|
28
|
+
upload_id?: string;
|
|
29
|
+
block_size?: number | string;
|
|
30
|
+
parts?: UploadPartInfo[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface UploadFinishResult
|
|
34
|
+
{
|
|
35
|
+
file_uuid?: string;
|
|
36
|
+
file_info?: string;
|
|
37
|
+
ttl?: number;
|
|
38
|
+
id?: string;
|
|
39
|
+
raw_url?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const MIME_TO_EXT: Record<string, string> = {
|
|
43
|
+
'image/jpeg': '.jpg',
|
|
44
|
+
'image/jpg': '.jpg',
|
|
45
|
+
'image/png': '.png',
|
|
46
|
+
'image/gif': '.gif',
|
|
47
|
+
'image/webp': '.webp',
|
|
48
|
+
'image/bmp': '.bmp',
|
|
49
|
+
'image/tiff': '.tif',
|
|
50
|
+
'image/svg+xml': '.svg',
|
|
51
|
+
'video/mp4': '.mp4',
|
|
52
|
+
'video/webm': '.webm',
|
|
53
|
+
'video/quicktime': '.mov',
|
|
54
|
+
'audio/mpeg': '.mp3',
|
|
55
|
+
'audio/mp3': '.mp3',
|
|
56
|
+
'audio/wav': '.wav',
|
|
57
|
+
'audio/ogg': '.ogg',
|
|
58
|
+
'audio/aac': '.aac',
|
|
59
|
+
'audio/flac': '.flac',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
class QQBotPartFileAssets extends Assets<QQBotPartFileAssets.Config>
|
|
63
|
+
{
|
|
64
|
+
types = ['image', 'img', 'audio', 'video'];
|
|
65
|
+
|
|
66
|
+
private readonly api = this.ctx.http.extend({
|
|
67
|
+
endpoint: QQ_API,
|
|
68
|
+
headers: {
|
|
69
|
+
accept: 'application/json',
|
|
70
|
+
Authorization: '',
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
private token = '';
|
|
75
|
+
private tokenExpiresAt = 0;
|
|
76
|
+
private refreshing: Promise<string> | null = null;
|
|
77
|
+
|
|
78
|
+
constructor(ctx: Context, config: QQBotPartFileAssets.Config)
|
|
79
|
+
{
|
|
80
|
+
super(ctx, config);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private debug(...args: string[])
|
|
84
|
+
{
|
|
85
|
+
if (!this.config.debug) return;
|
|
86
|
+
this.ctx.logger(name).info(args.join(' '));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private fail(message: string, error?: unknown): never
|
|
90
|
+
{
|
|
91
|
+
if (error instanceof Error)
|
|
92
|
+
{
|
|
93
|
+
this.ctx.logger(name).error(`${message}: ${error.message}`);
|
|
94
|
+
} else if (typeof error !== 'undefined')
|
|
95
|
+
{
|
|
96
|
+
this.ctx.logger(name).error(`${message}: ${this.describe(error)}`);
|
|
97
|
+
} else
|
|
98
|
+
{
|
|
99
|
+
this.ctx.logger(name).error(message);
|
|
100
|
+
}
|
|
101
|
+
throw error instanceof Error ? error : new Error(message);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private describe(value: unknown)
|
|
105
|
+
{
|
|
106
|
+
if (typeof value === 'string') return value;
|
|
107
|
+
try
|
|
108
|
+
{
|
|
109
|
+
return JSON.stringify(value);
|
|
110
|
+
} catch
|
|
111
|
+
{
|
|
112
|
+
return String(value);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private getClientSecret()
|
|
117
|
+
{
|
|
118
|
+
const secret = this.config.appSecret;
|
|
119
|
+
if (!secret)
|
|
120
|
+
{
|
|
121
|
+
throw new Error('缺少 QQ 机器人的 clientSecret');
|
|
122
|
+
}
|
|
123
|
+
return secret;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private async getAccessToken()
|
|
127
|
+
{
|
|
128
|
+
if (this.token && Date.now() < this.tokenExpiresAt) return this.token;
|
|
129
|
+
if (this.refreshing) return this.refreshing;
|
|
130
|
+
this.refreshing = this.refreshAccessToken();
|
|
131
|
+
try
|
|
132
|
+
{
|
|
133
|
+
return await this.refreshing;
|
|
134
|
+
} finally
|
|
135
|
+
{
|
|
136
|
+
this.refreshing = null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async refreshAccessToken()
|
|
141
|
+
{
|
|
142
|
+
const response = await this.ctx.http.post(QQ_TOKEN_API, {
|
|
143
|
+
appId: this.config.appid,
|
|
144
|
+
clientSecret: this.getClientSecret(),
|
|
145
|
+
}) as AccessTokenResponse;
|
|
146
|
+
|
|
147
|
+
const token = response?.access_token;
|
|
148
|
+
if (!token)
|
|
149
|
+
{
|
|
150
|
+
throw new Error(`QQ 获取 access_token 失败: ${this.describe(response)}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
this.token = token;
|
|
154
|
+
const expiresIn = Number(response.expires_in || 7200);
|
|
155
|
+
this.tokenExpiresAt = Date.now() + Math.max(expiresIn - 60, 60) * 1000;
|
|
156
|
+
this.api.config.headers.Authorization = `QQBot ${token}`;
|
|
157
|
+
return token;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private sniffMime(buffer: Buffer)
|
|
161
|
+
{
|
|
162
|
+
if (buffer.length >= 8)
|
|
163
|
+
{
|
|
164
|
+
if (
|
|
165
|
+
buffer[0] === 0x89 &&
|
|
166
|
+
buffer[1] === 0x50 &&
|
|
167
|
+
buffer[2] === 0x4e &&
|
|
168
|
+
buffer[3] === 0x47 &&
|
|
169
|
+
buffer[4] === 0x0d &&
|
|
170
|
+
buffer[5] === 0x0a &&
|
|
171
|
+
buffer[6] === 0x1a &&
|
|
172
|
+
buffer[7] === 0x0a
|
|
173
|
+
) return 'image/png';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff)
|
|
177
|
+
{
|
|
178
|
+
return 'image/jpeg';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (buffer.length >= 6)
|
|
182
|
+
{
|
|
183
|
+
const head = buffer.subarray(0, 6).toString('ascii');
|
|
184
|
+
if (head === 'GIF87a' || head === 'GIF89a') return 'image/gif';
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP')
|
|
188
|
+
{
|
|
189
|
+
return 'image/webp';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WAVE')
|
|
193
|
+
{
|
|
194
|
+
return 'audio/wav';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (buffer.length >= 4)
|
|
198
|
+
{
|
|
199
|
+
const head = buffer.subarray(0, 4).toString('ascii');
|
|
200
|
+
if (head === 'fLaC') return 'audio/flac';
|
|
201
|
+
if (head === 'OggS') return 'audio/ogg';
|
|
202
|
+
if (head === 'ftyp' || (buffer.length >= 8 && buffer.subarray(4, 8).toString('ascii') === 'ftyp'))
|
|
203
|
+
{
|
|
204
|
+
const brand = buffer.length >= 12 ? buffer.subarray(8, 12).toString('ascii').toLowerCase() : '';
|
|
205
|
+
return brand.includes('qt') ? 'video/quicktime' : 'video/mp4';
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (buffer.length >= 4)
|
|
210
|
+
{
|
|
211
|
+
const head = buffer.subarray(0, 4).toString('hex');
|
|
212
|
+
if (head === '1a45dfa3') return 'video/webm';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (buffer.length >= 2 && buffer[0] === 0x42 && buffer[1] === 0x4d)
|
|
216
|
+
{
|
|
217
|
+
return 'image/bmp';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (buffer.length >= 4)
|
|
221
|
+
{
|
|
222
|
+
const le = buffer[0] === 0x49 && buffer[1] === 0x49 && buffer[2] === 0x2a && buffer[3] === 0x00;
|
|
223
|
+
const be = buffer[0] === 0x4d && buffer[1] === 0x4d && buffer[2] === 0x00 && buffer[3] === 0x2a;
|
|
224
|
+
if (le || be) return 'image/tiff';
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private mimeFromFilename(filename: string)
|
|
231
|
+
{
|
|
232
|
+
const ext = extname(filename).toLowerCase();
|
|
233
|
+
switch (ext)
|
|
234
|
+
{
|
|
235
|
+
case '.jpg':
|
|
236
|
+
case '.jpeg':
|
|
237
|
+
return 'image/jpeg';
|
|
238
|
+
case '.png':
|
|
239
|
+
return 'image/png';
|
|
240
|
+
case '.gif':
|
|
241
|
+
return 'image/gif';
|
|
242
|
+
case '.webp':
|
|
243
|
+
return 'image/webp';
|
|
244
|
+
case '.bmp':
|
|
245
|
+
return 'image/bmp';
|
|
246
|
+
case '.tif':
|
|
247
|
+
case '.tiff':
|
|
248
|
+
return 'image/tiff';
|
|
249
|
+
case '.svg':
|
|
250
|
+
return 'image/svg+xml';
|
|
251
|
+
case '.mp4':
|
|
252
|
+
return 'video/mp4';
|
|
253
|
+
case '.webm':
|
|
254
|
+
return 'video/webm';
|
|
255
|
+
case '.mov':
|
|
256
|
+
return 'video/quicktime';
|
|
257
|
+
case '.mp3':
|
|
258
|
+
case '.m4a':
|
|
259
|
+
case '.aac':
|
|
260
|
+
return 'audio/mpeg';
|
|
261
|
+
case '.wav':
|
|
262
|
+
return 'audio/wav';
|
|
263
|
+
case '.ogg':
|
|
264
|
+
return 'audio/ogg';
|
|
265
|
+
case '.flac':
|
|
266
|
+
return 'audio/flac';
|
|
267
|
+
default:
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private resolveMime(buffer: Buffer, filename: string, declaredType?: string)
|
|
273
|
+
{
|
|
274
|
+
const sniffed = this.sniffMime(buffer);
|
|
275
|
+
if (sniffed) return sniffed;
|
|
276
|
+
|
|
277
|
+
const normalized = declaredType?.trim().toLowerCase();
|
|
278
|
+
if (normalized && normalized !== 'application/octet-stream') return normalized;
|
|
279
|
+
|
|
280
|
+
return this.mimeFromFilename(filename);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private resolveFileType(filename: string, mime?: string)
|
|
284
|
+
{
|
|
285
|
+
const lowerMime = mime?.toLowerCase();
|
|
286
|
+
if (lowerMime?.startsWith('image/')) return 1;
|
|
287
|
+
if (lowerMime?.startsWith('video/')) return 2;
|
|
288
|
+
if (lowerMime?.startsWith('audio/')) return 3;
|
|
289
|
+
|
|
290
|
+
const lowerName = filename.toLowerCase();
|
|
291
|
+
if (/\.(png|jpe?g|gif|webp|bmp|tiff?)$/.test(lowerName)) return 1;
|
|
292
|
+
if (/\.(mp4|webm|mov|mkv|m4v)$/.test(lowerName)) return 2;
|
|
293
|
+
if (/\.(silk|mp3|wav|ogg|m4a|aac|flac)$/.test(lowerName)) return 3;
|
|
294
|
+
return 4;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private resolveUploadFilename(filename: string, mime?: string)
|
|
298
|
+
{
|
|
299
|
+
const ext = mime ? MIME_TO_EXT[mime.toLowerCase()] : undefined;
|
|
300
|
+
if (!ext) return filename;
|
|
301
|
+
|
|
302
|
+
const currentExt = extname(filename);
|
|
303
|
+
if (!currentExt) return `${filename}${ext}`;
|
|
304
|
+
if (currentExt.toLowerCase() === ext) return filename;
|
|
305
|
+
return `${filename.slice(0, -currentExt.length)}${ext}`;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private buildPublicUrl(rawUrl: string, mime?: string)
|
|
309
|
+
{
|
|
310
|
+
const normalized = mime?.trim().toLowerCase();
|
|
311
|
+
if (!normalized) return rawUrl;
|
|
312
|
+
|
|
313
|
+
try
|
|
314
|
+
{
|
|
315
|
+
const url = new URL(rawUrl);
|
|
316
|
+
url.searchParams.set('response-content-type', normalized);
|
|
317
|
+
return url.toString();
|
|
318
|
+
} catch
|
|
319
|
+
{
|
|
320
|
+
return rawUrl;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async upload(url: string, file: string)
|
|
325
|
+
{
|
|
326
|
+
const { buffer, filename, type } = await this.analyze(url, file);
|
|
327
|
+
const mime = this.resolveMime(buffer, filename, type);
|
|
328
|
+
const uploadFilename = this.resolveUploadFilename(filename, mime);
|
|
329
|
+
const fileType = this.resolveFileType(uploadFilename, mime);
|
|
330
|
+
|
|
331
|
+
if (buffer.byteLength > MAX_SIZE)
|
|
332
|
+
{
|
|
333
|
+
throw new Error('QQ 分片上传当前仅支持 30MB 以内的文件');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
await this.getAccessToken();
|
|
337
|
+
|
|
338
|
+
const prepare = await this.api.post(`/v2/groups/${this.config.groupId}/upload_prepare`, {
|
|
339
|
+
file_type: fileType,
|
|
340
|
+
file_size: buffer.byteLength,
|
|
341
|
+
file_name: uploadFilename,
|
|
342
|
+
md5: createHash('md5').update(buffer).digest('hex'),
|
|
343
|
+
sha1: createHash('sha1').update(buffer).digest('hex'),
|
|
344
|
+
}) as UploadPrepareResult;
|
|
345
|
+
|
|
346
|
+
const uploadId = prepare?.upload_id;
|
|
347
|
+
const parts = [...(prepare?.parts || [])].sort((a, b) => a.index - b.index);
|
|
348
|
+
const blockSize = Number(prepare?.block_size || 0);
|
|
349
|
+
|
|
350
|
+
if (!uploadId || !parts.length || !blockSize)
|
|
351
|
+
{
|
|
352
|
+
throw new Error(`QQ upload_prepare 返回异常: ${this.describe(prepare)}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
this.debug(`开始上传 ${filename} -> ${uploadFilename}`, `mime=${mime || 'unknown'}`, `parts=${parts.length}`);
|
|
356
|
+
|
|
357
|
+
for (const part of parts)
|
|
358
|
+
{
|
|
359
|
+
const size = Number(part.block_size || blockSize);
|
|
360
|
+
const start = (part.index - 1) * blockSize;
|
|
361
|
+
const chunk = buffer.subarray(start, start + size);
|
|
362
|
+
|
|
363
|
+
await this.ctx.http.put(part.presigned_url, chunk, {
|
|
364
|
+
headers: {
|
|
365
|
+
'Content-Type': 'application/octet-stream',
|
|
366
|
+
},
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await this.api.post(`/v2/groups/${this.config.groupId}/upload_part_finish`, {
|
|
370
|
+
upload_id: uploadId,
|
|
371
|
+
part_index: part.index,
|
|
372
|
+
block_size: String(chunk.length),
|
|
373
|
+
md5: createHash('md5').update(chunk).digest('hex'),
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
this.debug(`part_${part.index} uploaded`, this.buildPublicUrl(part.presigned_url, mime));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const merged = await this.api.post(`/v2/groups/${this.config.groupId}/files`, {
|
|
380
|
+
file_type: fileType,
|
|
381
|
+
srv_send_msg: false,
|
|
382
|
+
file_name: uploadFilename,
|
|
383
|
+
upload_id: uploadId,
|
|
384
|
+
}) as UploadFinishResult;
|
|
385
|
+
|
|
386
|
+
const rawUrl = merged?.raw_url;
|
|
387
|
+
if (!rawUrl)
|
|
388
|
+
{
|
|
389
|
+
throw new Error(`QQ 分片上传完成后未返回 raw_url: ${this.describe(merged)}`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const finalUrl = this.buildPublicUrl(rawUrl, mime);
|
|
393
|
+
this.debug(`uploaded ${filename} -> ${finalUrl}`);
|
|
394
|
+
return finalUrl;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async stats()
|
|
398
|
+
{
|
|
399
|
+
return {};
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
namespace QQBotPartFileAssets
|
|
404
|
+
{
|
|
405
|
+
export interface Config extends Assets.Config
|
|
406
|
+
{
|
|
407
|
+
appid: string;
|
|
408
|
+
appSecret: string;
|
|
409
|
+
groupId: string;
|
|
410
|
+
debug: boolean;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export const Config: Schema<Config> = Schema.intersect([
|
|
414
|
+
Schema.object({
|
|
415
|
+
appid: Schema.string()
|
|
416
|
+
.description('QQ 机器人 AppID')
|
|
417
|
+
.required(),
|
|
418
|
+
appSecret: Schema.string()
|
|
419
|
+
.description('QQ 机器人 AppSecret')
|
|
420
|
+
.role('secret')
|
|
421
|
+
.required(),
|
|
422
|
+
groupId: Schema.string()
|
|
423
|
+
.description('用于上传文件的群 OpenID<br>请使用inspect指令查看群组openId,任意QQ群组即可')
|
|
424
|
+
.required(),
|
|
425
|
+
debug: Schema.boolean()
|
|
426
|
+
.default(false)
|
|
427
|
+
.description('开启后输出调试日志')
|
|
428
|
+
.experimental(),
|
|
429
|
+
}),
|
|
430
|
+
Assets.Config,
|
|
431
|
+
]);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export interface Config extends QQBotPartFileAssets.Config { }
|
|
435
|
+
|
|
436
|
+
export const Config = QQBotPartFileAssets.Config;
|
|
437
|
+
|
|
438
|
+
export function apply(ctx: Context, config: Config)
|
|
439
|
+
{
|
|
440
|
+
ctx.plugin(QQBotPartFileAssets, config);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export default QQBotPartFileAssets;
|