@zxh126/pi-qqbot 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +408 -314
- package/README.md +531 -439
- package/approver.ts +174 -174
- package/attachment.ts +502 -502
- package/billing.ts +579 -0
- package/config.ts +281 -280
- package/constants.ts +51 -51
- package/docs/README.zh-CN.md +488 -410
- package/index.ts +1870 -1571
- package/package.json +60 -60
- package/qq/api.ts +107 -107
- package/qq/gateway.ts +265 -265
- package/qq/upload.ts +288 -288
- package/remote.ts +81 -81
- package/selfupdate.ts +212 -0
- package/slash.ts +922 -826
package/attachment.ts
CHANGED
|
@@ -1,502 +1,502 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* QQ 附件落盘。
|
|
3
|
-
*
|
|
4
|
-
* 收到消息后把附件下载到系统临时目录,再把「图片路径 <path>」这类标记行拼进
|
|
5
|
-
* 交给 pi 的文本里——pi 的 read 工具能直接打开图片,普通文件也能按路径处理。
|
|
6
|
-
*
|
|
7
|
-
* 分类(用户定的六类,一个都不忽略):
|
|
8
|
-
* 图片 → `图片路径 <path>`
|
|
9
|
-
* 语音 → `语音路径 <path>`
|
|
10
|
-
* 视频 → `视频路径 <path>`
|
|
11
|
-
* 文档 → `文档路径 <path>`
|
|
12
|
-
* 压缩包 → `压缩包路径 <path>`
|
|
13
|
-
* 其它 → `文件路径 <path>`
|
|
14
|
-
*
|
|
15
|
-
* 纯逻辑 + 可注入 fetch / 目录,方便单测(见 test/attachment.test.mjs)。
|
|
16
|
-
*/
|
|
17
|
-
import fs from "node:fs";
|
|
18
|
-
import os from "node:os";
|
|
19
|
-
import path from "node:path";
|
|
20
|
-
import {
|
|
21
|
-
ATTACHMENT_CONCURRENCY,
|
|
22
|
-
ATTACHMENT_TIMEOUT_MS,
|
|
23
|
-
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
24
|
-
DEFAULT_ATTACHMENT_MAX_FILES,
|
|
25
|
-
DEFAULT_ATTACHMENT_MAX_PER_MESSAGE,
|
|
26
|
-
DEFAULT_ATTACHMENT_RETENTION_HOURS,
|
|
27
|
-
} from "./constants";
|
|
28
|
-
|
|
29
|
-
export type AttachmentKind = "image" | "voice" | "video" | "document" | "archive" | "file";
|
|
30
|
-
|
|
31
|
-
/** 网关归一化后的附件(见 qq/gateway.ts) */
|
|
32
|
-
export interface IncomingAttachment {
|
|
33
|
-
url: string;
|
|
34
|
-
contentType?: string;
|
|
35
|
-
filename?: string;
|
|
36
|
-
size?: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface SavedAttachment {
|
|
40
|
-
kind: AttachmentKind;
|
|
41
|
-
/** 落盘后的绝对路径 */
|
|
42
|
-
path: string;
|
|
43
|
-
/** 规整后的文件名 */
|
|
44
|
-
name: string;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface AttachmentFailure {
|
|
48
|
-
/** 展示名(原始文件名或「附件 N」) */
|
|
49
|
-
name: string;
|
|
50
|
-
reason: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export interface SaveAttachmentsOptions {
|
|
54
|
-
dir: string;
|
|
55
|
-
maxBytes?: number;
|
|
56
|
-
maxPerMessage?: number;
|
|
57
|
-
timeoutMs?: number;
|
|
58
|
-
fetchFn?: typeof fetch;
|
|
59
|
-
/** 下载 401/403 时重试用的凭据(QQ 签名 URL 一般不需要) */
|
|
60
|
-
credential?: (() => Promise<string>) | null;
|
|
61
|
-
/** 文件名前缀,通常传 msgId,便于追到是哪条消息 */
|
|
62
|
-
prefix?: string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export interface SaveAttachmentsResult {
|
|
66
|
-
saved: SavedAttachment[];
|
|
67
|
-
failed: AttachmentFailure[];
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/** 拼进 pi 文本时的行首文案 */
|
|
71
|
-
export const KIND_LABEL: Record<AttachmentKind, string> = {
|
|
72
|
-
image: "图片路径",
|
|
73
|
-
voice: "语音路径",
|
|
74
|
-
video: "视频路径",
|
|
75
|
-
document: "文档路径",
|
|
76
|
-
archive: "压缩包路径",
|
|
77
|
-
file: "文件路径",
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
/** 回执里用的中文类别名 */
|
|
81
|
-
export const KIND_NAME_ZH: Record<AttachmentKind, string> = {
|
|
82
|
-
image: "图片",
|
|
83
|
-
voice: "语音",
|
|
84
|
-
video: "视频",
|
|
85
|
-
document: "文档",
|
|
86
|
-
archive: "压缩包",
|
|
87
|
-
file: "文件",
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
/** 回执里的固定顺序,避免每次排列不同 */
|
|
91
|
-
export const KIND_ORDER: AttachmentKind[] = ["image", "voice", "video", "document", "archive", "file"];
|
|
92
|
-
|
|
93
|
-
const MIME_EXT: Record<string, string> = {
|
|
94
|
-
"image/jpeg": "jpg",
|
|
95
|
-
"image/jpg": "jpg",
|
|
96
|
-
"image/png": "png",
|
|
97
|
-
"image/gif": "gif",
|
|
98
|
-
"image/webp": "webp",
|
|
99
|
-
"image/bmp": "bmp",
|
|
100
|
-
"image/heic": "heic",
|
|
101
|
-
"image/tiff": "tiff",
|
|
102
|
-
"audio/mpeg": "mp3",
|
|
103
|
-
"audio/mp3": "mp3",
|
|
104
|
-
"audio/wav": "wav",
|
|
105
|
-
"audio/x-wav": "wav",
|
|
106
|
-
"audio/amr": "amr",
|
|
107
|
-
"audio/ogg": "ogg",
|
|
108
|
-
"audio/mp4": "m4a",
|
|
109
|
-
"audio/aac": "aac",
|
|
110
|
-
"audio/flac": "flac",
|
|
111
|
-
"audio/opus": "opus",
|
|
112
|
-
"audio/silk": "silk",
|
|
113
|
-
"video/mp4": "mp4",
|
|
114
|
-
"video/quicktime": "mov",
|
|
115
|
-
"video/webm": "webm",
|
|
116
|
-
"video/x-matroska": "mkv",
|
|
117
|
-
"video/x-msvideo": "avi",
|
|
118
|
-
"application/pdf": "pdf",
|
|
119
|
-
"application/zip": "zip",
|
|
120
|
-
"application/x-zip-compressed": "zip",
|
|
121
|
-
"application/x-rar-compressed": "rar",
|
|
122
|
-
"application/vnd.rar": "rar",
|
|
123
|
-
"application/x-7z-compressed": "7z",
|
|
124
|
-
"application/gzip": "gz",
|
|
125
|
-
"application/x-tar": "tar",
|
|
126
|
-
"application/msword": "doc",
|
|
127
|
-
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
128
|
-
"application/vnd.ms-excel": "xls",
|
|
129
|
-
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
130
|
-
"application/vnd.ms-powerpoint": "ppt",
|
|
131
|
-
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
132
|
-
"text/plain": "txt",
|
|
133
|
-
"text/markdown": "md",
|
|
134
|
-
"text/csv": "csv",
|
|
135
|
-
"text/html": "html",
|
|
136
|
-
"text/xml": "xml",
|
|
137
|
-
"application/json": "json",
|
|
138
|
-
"application/xml": "xml",
|
|
139
|
-
};
|
|
140
|
-
|
|
141
|
-
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "tif", "tiff"]);
|
|
142
|
-
const VOICE_EXT = new Set(["silk", "amr", "mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]);
|
|
143
|
-
const VIDEO_EXT = new Set(["mp4", "mov", "m4v", "avi", "mkv", "webm", "flv", "wmv", "mpg", "mpeg"]);
|
|
144
|
-
const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst", "jar"]);
|
|
145
|
-
const DOC_EXT = new Set([
|
|
146
|
-
"pdf",
|
|
147
|
-
"doc",
|
|
148
|
-
"docx",
|
|
149
|
-
"xls",
|
|
150
|
-
"xlsx",
|
|
151
|
-
"ppt",
|
|
152
|
-
"pptx",
|
|
153
|
-
"odt",
|
|
154
|
-
"ods",
|
|
155
|
-
"odp",
|
|
156
|
-
"rtf",
|
|
157
|
-
"epub",
|
|
158
|
-
"txt",
|
|
159
|
-
"md",
|
|
160
|
-
"markdown",
|
|
161
|
-
"csv",
|
|
162
|
-
"json",
|
|
163
|
-
"xml",
|
|
164
|
-
"yaml",
|
|
165
|
-
"yml",
|
|
166
|
-
"html",
|
|
167
|
-
"htm",
|
|
168
|
-
"log",
|
|
169
|
-
"ini",
|
|
170
|
-
"toml",
|
|
171
|
-
"ts",
|
|
172
|
-
"js",
|
|
173
|
-
"py",
|
|
174
|
-
"java",
|
|
175
|
-
"c",
|
|
176
|
-
"cpp",
|
|
177
|
-
"h",
|
|
178
|
-
"rs",
|
|
179
|
-
"go",
|
|
180
|
-
"sh",
|
|
181
|
-
]);
|
|
182
|
-
|
|
183
|
-
/** 从文件名 / URL 里取扩展名(去掉 query 与 hash,只看最后一段) */
|
|
184
|
-
export function extFromName(value: string | undefined): string {
|
|
185
|
-
if (!value) return "";
|
|
186
|
-
const clean = String(value).split("#")[0].split("?")[0];
|
|
187
|
-
const base = clean.slice(clean.lastIndexOf("/") + 1);
|
|
188
|
-
const dot = base.lastIndexOf(".");
|
|
189
|
-
if (dot < 0 || dot === base.length - 1) return "";
|
|
190
|
-
const ext = base.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
191
|
-
return ext.length > 8 ? "" : ext;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function mimeExt(contentType: string | undefined): string {
|
|
195
|
-
const ct = String(contentType ?? "")
|
|
196
|
-
.toLowerCase()
|
|
197
|
-
.split(";")[0]
|
|
198
|
-
.trim();
|
|
199
|
-
if (!ct) return "";
|
|
200
|
-
return MIME_EXT[ct] ?? "";
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** 六类判定:content_type 优先,其次看扩展名 */
|
|
204
|
-
export function classifyAttachment(a: IncomingAttachment): AttachmentKind {
|
|
205
|
-
const ct = String(a.contentType ?? "")
|
|
206
|
-
.toLowerCase()
|
|
207
|
-
.split(";")[0]
|
|
208
|
-
.trim();
|
|
209
|
-
const ext = extFromName(a.filename) || extFromName(a.url) || mimeExt(ct);
|
|
210
|
-
if (ct.startsWith("image/") || ct === "image" || (ext !== "" && IMAGE_EXT.has(ext))) return "image";
|
|
211
|
-
if (ct.startsWith("audio/") || ct === "audio" || ct === "voice" || (ext !== "" && VOICE_EXT.has(ext)))
|
|
212
|
-
return "voice";
|
|
213
|
-
if (ct.startsWith("video/") || ct === "video" || (ext !== "" && VIDEO_EXT.has(ext))) return "video";
|
|
214
|
-
if ((ext !== "" && ARCHIVE_EXT.has(ext)) || /zip|rar|7z|tar|gzip|compressed/.test(ct)) return "archive";
|
|
215
|
-
if (
|
|
216
|
-
(ext !== "" && DOC_EXT.has(ext)) ||
|
|
217
|
-
ct.startsWith("text/") ||
|
|
218
|
-
/pdf|word|document|sheet|presentation|excel|powerpoint|msword|officedocument/.test(ct)
|
|
219
|
-
)
|
|
220
|
-
return "document";
|
|
221
|
-
return "file";
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function extensionFor(a: IncomingAttachment): string {
|
|
225
|
-
return mimeExt(a.contentType) || extFromName(a.filename) || extFromName(a.url) || "bin";
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** 落盘名里只允许 [0-9a-zA-Z._-]:路径带空格/中文会让 agent 的命令行不好写 */
|
|
229
|
-
function sanitizeSegment(value: string, fallback: string): string {
|
|
230
|
-
const cleaned = String(value ?? "")
|
|
231
|
-
.replace(/[^0-9a-zA-Z._-]+/g, "_")
|
|
232
|
-
.replace(/^[._]+/, "")
|
|
233
|
-
.slice(0, 40);
|
|
234
|
-
return cleaned || fallback;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** 默认落盘目录:系统临时文件夹 / pi-qqbot / bot-<appId 后 6 位> */
|
|
238
|
-
export function defaultAttachmentDir(appId: string): string {
|
|
239
|
-
const tail = String(appId ?? "")
|
|
240
|
-
.replace(/[^0-9a-zA-Z]/g, "")
|
|
241
|
-
.slice(-6);
|
|
242
|
-
return path.join(os.tmpdir(), "pi-qqbot", tail ? `bot-${tail}` : "default");
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function dayStamp(now: Date): string {
|
|
246
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
247
|
-
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function timeStamp(now: Date): string {
|
|
251
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
252
|
-
return `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
function formatBytes(bytes: number): string {
|
|
256
|
-
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
257
|
-
if (bytes < 1024) return `${bytes} B`;
|
|
258
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
259
|
-
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function displayName(a: IncomingAttachment, index: number): string {
|
|
263
|
-
const name = String(a.filename ?? "").trim();
|
|
264
|
-
return name || `附件 ${index + 1}`;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
function reasonOf(error: unknown): string {
|
|
268
|
-
const message = String((error as any)?.message ?? error ?? "").trim();
|
|
269
|
-
return (message || "未知错误").slice(0, 160);
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
273
|
-
const anyAbort = AbortSignal as any;
|
|
274
|
-
return typeof anyAbort?.timeout === "function" ? anyAbort.timeout(ms) : undefined;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
async function readLimitedBody(res: Response, maxBytes: number): Promise<Buffer> {
|
|
278
|
-
const declared = Number(res.headers?.get?.("content-length") ?? "");
|
|
279
|
-
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
280
|
-
throw new Error(`文件过大(${formatBytes(declared)} > 上限 ${formatBytes(maxBytes)})`);
|
|
281
|
-
}
|
|
282
|
-
const tooBig = () => new Error(`文件过大(超过上限 ${formatBytes(maxBytes)})`);
|
|
283
|
-
if (!res.body) {
|
|
284
|
-
const buf = Buffer.from(await res.arrayBuffer());
|
|
285
|
-
if (buf.byteLength > maxBytes) throw tooBig();
|
|
286
|
-
if (buf.byteLength === 0) throw new Error("下载到 0 字节");
|
|
287
|
-
return buf;
|
|
288
|
-
}
|
|
289
|
-
const chunks: Buffer[] = [];
|
|
290
|
-
let total = 0;
|
|
291
|
-
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
292
|
-
const buf = Buffer.from(chunk);
|
|
293
|
-
total += buf.byteLength;
|
|
294
|
-
if (total > maxBytes) throw tooBig();
|
|
295
|
-
chunks.push(buf);
|
|
296
|
-
}
|
|
297
|
-
if (total === 0) throw new Error("下载到 0 字节");
|
|
298
|
-
return Buffer.concat(chunks);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
async function download(a: IncomingAttachment, opts: SaveAttachmentsOptions): Promise<Buffer> {
|
|
302
|
-
const maxBytes = Math.max(1024, Number(opts.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES));
|
|
303
|
-
const timeoutMs = Math.max(1000, Number(opts.timeoutMs ?? ATTACHMENT_TIMEOUT_MS));
|
|
304
|
-
const fetchFn = opts.fetchFn ?? fetch;
|
|
305
|
-
const url = String(a.url ?? "").trim();
|
|
306
|
-
if (!url) throw new Error("没有下载地址");
|
|
307
|
-
|
|
308
|
-
const attempt = async (authorization: string | null): Promise<Response | null> => {
|
|
309
|
-
const headers: Record<string, string> = {};
|
|
310
|
-
if (authorization) headers.Authorization = authorization;
|
|
311
|
-
const res = await fetchFn(url, { headers, redirect: "follow", signal: timeoutSignal(timeoutMs) });
|
|
312
|
-
if (!authorization && (res.status === 401 || res.status === 403)) return null; // 交给带凭据的重试
|
|
313
|
-
if (!res.ok) throw new Error(`下载失败 HTTP ${res.status}`);
|
|
314
|
-
return res;
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
let res = await attempt(null);
|
|
318
|
-
if (res === null) {
|
|
319
|
-
const auth = opts.credential ? await opts.credential().catch(() => "") : "";
|
|
320
|
-
if (!auth) throw new Error("下载失败 HTTP 401/403(没有可用凭据重试)");
|
|
321
|
-
res = await attempt(`QQBot ${auth}`);
|
|
322
|
-
if (res === null) throw new Error("下载失败 HTTP 403(凭据被拒绝)");
|
|
323
|
-
}
|
|
324
|
-
return readLimitedBody(res, maxBytes);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
async function uniquePath(dir: string, filename: string): Promise<string> {
|
|
328
|
-
const ext = path.extname(filename);
|
|
329
|
-
const stem = filename.slice(0, filename.length - ext.length) || "att";
|
|
330
|
-
let candidate = path.join(dir, filename);
|
|
331
|
-
for (let i = 2; i <= 50; i += 1) {
|
|
332
|
-
try {
|
|
333
|
-
await fs.promises.access(candidate);
|
|
334
|
-
} catch {
|
|
335
|
-
return candidate;
|
|
336
|
-
}
|
|
337
|
-
candidate = path.join(dir, `${stem}-${i}${ext}`);
|
|
338
|
-
}
|
|
339
|
-
return path.join(dir, `${stem}-${Date.now()}${ext}`);
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* 下载并落盘。**不抛异常**:单个附件失败只记进 failed,正文照旧走。
|
|
344
|
-
* 返回的 saved 按入参顺序排列。
|
|
345
|
-
*/
|
|
346
|
-
export async function saveIncoming(
|
|
347
|
-
attachments: IncomingAttachment[],
|
|
348
|
-
opts: SaveAttachmentsOptions,
|
|
349
|
-
): Promise<SaveAttachmentsResult> {
|
|
350
|
-
const list = (attachments ?? []).filter((a) => a && String(a.url ?? "").trim() !== "");
|
|
351
|
-
if (list.length === 0) return { saved: [], failed: [] };
|
|
352
|
-
|
|
353
|
-
const maxPer = Math.max(1, Number(opts.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE));
|
|
354
|
-
const target = list.slice(0, maxPer);
|
|
355
|
-
const failed: AttachmentFailure[] = [];
|
|
356
|
-
if (list.length > target.length) {
|
|
357
|
-
failed.push({
|
|
358
|
-
name: `其余 ${list.length - target.length} 个附件`,
|
|
359
|
-
reason: `单条消息最多处理 ${maxPer} 个`,
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const now = new Date();
|
|
364
|
-
const dir = path.join(opts.dir, dayStamp(now));
|
|
365
|
-
await fs.promises.mkdir(dir, { recursive: true }).catch(() => {});
|
|
366
|
-
|
|
367
|
-
const prefix = sanitizeSegment(opts.prefix ?? "", "att");
|
|
368
|
-
const results: (SavedAttachment & { index: number })[] = [];
|
|
369
|
-
let cursor = 0;
|
|
370
|
-
|
|
371
|
-
const worker = async () => {
|
|
372
|
-
while (cursor < target.length) {
|
|
373
|
-
const index = cursor;
|
|
374
|
-
cursor += 1;
|
|
375
|
-
const a = target[index];
|
|
376
|
-
try {
|
|
377
|
-
const kind = classifyAttachment(a);
|
|
378
|
-
const ext = extensionFor(a);
|
|
379
|
-
const name = `${prefix}-${index + 1}-${timeStamp(now)}.${ext}`;
|
|
380
|
-
const filePath = await uniquePath(dir, name);
|
|
381
|
-
const bytes = await download(a, opts);
|
|
382
|
-
await fs.promises.writeFile(filePath, bytes);
|
|
383
|
-
results.push({ index, kind, path: filePath, name: path.basename(filePath) });
|
|
384
|
-
} catch (e) {
|
|
385
|
-
failed.push({ name: displayName(a, index), reason: reasonOf(e) });
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
const workers = Math.min(ATTACHMENT_CONCURRENCY, target.length);
|
|
391
|
-
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
392
|
-
|
|
393
|
-
const saved = results.sort((a, b) => a.index - b.index).map(({ kind, path: filePath, name }) => ({
|
|
394
|
-
kind,
|
|
395
|
-
path: filePath,
|
|
396
|
-
name,
|
|
397
|
-
}));
|
|
398
|
-
return { saved, failed };
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** 拼进 pi 文本的标记行(顺序与 saved 一致) */
|
|
402
|
-
export function renderAttachmentLines(saved: SavedAttachment[]): string[] {
|
|
403
|
-
return saved.map((item) => `${KIND_LABEL[item.kind]} ${item.path}`);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
/** 下载失败行:告诉 pi 缺了什么 */
|
|
407
|
-
export function renderAttachmentFailureLines(failed: AttachmentFailure[]): string[] {
|
|
408
|
-
return failed.map((f) => `附件下载失败:${f.name}(${f.reason})`);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/** QQ 回执:收到几个、什么类别、落在哪、哪些失败 */
|
|
412
|
-
export function renderAttachmentAck(saved: SavedAttachment[], failed: AttachmentFailure[]): string {
|
|
413
|
-
const lines: string[] = [];
|
|
414
|
-
if (saved.length > 0) {
|
|
415
|
-
const counts = new Map<AttachmentKind, number>();
|
|
416
|
-
for (const item of saved) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
|
|
417
|
-
const parts = KIND_ORDER.filter((k) => counts.has(k)).map((k) => `${KIND_NAME_ZH[k]} ×${counts.get(k)}`);
|
|
418
|
-
lines.push(`已收到 ${saved.length} 个附件(${parts.join("、")}),已存为临时文件并交给 pi:`);
|
|
419
|
-
lines.push(...renderAttachmentLines(saved));
|
|
420
|
-
}
|
|
421
|
-
if (failed.length > 0) {
|
|
422
|
-
lines.push(...renderAttachmentFailureLines(failed));
|
|
423
|
-
}
|
|
424
|
-
return lines.join("\n");
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* 清理落盘目录:先按保留时长删过期文件,再按数量上限删最旧的。
|
|
429
|
-
* 只碰自己创建的 `<dir>/<YYYY-MM-DD>/` 层,配置到自定义目录也不会误删别的文件。
|
|
430
|
-
*/
|
|
431
|
-
export async function sweepAttachmentDir(
|
|
432
|
-
dir: string,
|
|
433
|
-
opts: { retentionHours?: number; maxFiles?: number } = {},
|
|
434
|
-
): Promise<{ removed: number }> {
|
|
435
|
-
const retentionMs =
|
|
436
|
-
Math.max(0, Number(opts.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS)) * 60 * 60 * 1000;
|
|
437
|
-
const maxFiles = Math.max(1, Number(opts.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES));
|
|
438
|
-
const now = Date.now();
|
|
439
|
-
let removed = 0;
|
|
440
|
-
const kept: { path: string; mtimeMs: number }[] = [];
|
|
441
|
-
|
|
442
|
-
let days: string[] = [];
|
|
443
|
-
try {
|
|
444
|
-
days = await fs.promises.readdir(dir);
|
|
445
|
-
} catch {
|
|
446
|
-
return { removed: 0 }; // 目录还不存在
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
for (const day of days) {
|
|
450
|
-
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
451
|
-
const dayDir = path.join(dir, day);
|
|
452
|
-
let names: string[] = [];
|
|
453
|
-
try {
|
|
454
|
-
names = await fs.promises.readdir(dayDir);
|
|
455
|
-
} catch {
|
|
456
|
-
continue;
|
|
457
|
-
}
|
|
458
|
-
let alive = 0;
|
|
459
|
-
for (const name of names) {
|
|
460
|
-
const full = path.join(dayDir, name);
|
|
461
|
-
let stat: { isFile(): boolean; mtimeMs: number };
|
|
462
|
-
try {
|
|
463
|
-
stat = await fs.promises.stat(full);
|
|
464
|
-
} catch {
|
|
465
|
-
continue;
|
|
466
|
-
}
|
|
467
|
-
if (!stat.isFile()) continue;
|
|
468
|
-
if (retentionMs > 0 && now - stat.mtimeMs > retentionMs) {
|
|
469
|
-
try {
|
|
470
|
-
await fs.promises.unlink(full);
|
|
471
|
-
removed += 1;
|
|
472
|
-
} catch {
|
|
473
|
-
/* ignore */
|
|
474
|
-
}
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
kept.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
478
|
-
alive += 1;
|
|
479
|
-
}
|
|
480
|
-
if (alive === 0) {
|
|
481
|
-
try {
|
|
482
|
-
await fs.promises.rmdir(dayDir);
|
|
483
|
-
} catch {
|
|
484
|
-
/* ignore */
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
if (kept.length > maxFiles) {
|
|
490
|
-
kept.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
491
|
-
for (const item of kept.slice(0, kept.length - maxFiles)) {
|
|
492
|
-
try {
|
|
493
|
-
await fs.promises.unlink(item.path);
|
|
494
|
-
removed += 1;
|
|
495
|
-
} catch {
|
|
496
|
-
/* ignore */
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
return { removed };
|
|
502
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* QQ 附件落盘。
|
|
3
|
+
*
|
|
4
|
+
* 收到消息后把附件下载到系统临时目录,再把「图片路径 <path>」这类标记行拼进
|
|
5
|
+
* 交给 pi 的文本里——pi 的 read 工具能直接打开图片,普通文件也能按路径处理。
|
|
6
|
+
*
|
|
7
|
+
* 分类(用户定的六类,一个都不忽略):
|
|
8
|
+
* 图片 → `图片路径 <path>`
|
|
9
|
+
* 语音 → `语音路径 <path>`
|
|
10
|
+
* 视频 → `视频路径 <path>`
|
|
11
|
+
* 文档 → `文档路径 <path>`
|
|
12
|
+
* 压缩包 → `压缩包路径 <path>`
|
|
13
|
+
* 其它 → `文件路径 <path>`
|
|
14
|
+
*
|
|
15
|
+
* 纯逻辑 + 可注入 fetch / 目录,方便单测(见 test/attachment.test.mjs)。
|
|
16
|
+
*/
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import {
|
|
21
|
+
ATTACHMENT_CONCURRENCY,
|
|
22
|
+
ATTACHMENT_TIMEOUT_MS,
|
|
23
|
+
DEFAULT_ATTACHMENT_MAX_BYTES,
|
|
24
|
+
DEFAULT_ATTACHMENT_MAX_FILES,
|
|
25
|
+
DEFAULT_ATTACHMENT_MAX_PER_MESSAGE,
|
|
26
|
+
DEFAULT_ATTACHMENT_RETENTION_HOURS,
|
|
27
|
+
} from "./constants";
|
|
28
|
+
|
|
29
|
+
export type AttachmentKind = "image" | "voice" | "video" | "document" | "archive" | "file";
|
|
30
|
+
|
|
31
|
+
/** 网关归一化后的附件(见 qq/gateway.ts) */
|
|
32
|
+
export interface IncomingAttachment {
|
|
33
|
+
url: string;
|
|
34
|
+
contentType?: string;
|
|
35
|
+
filename?: string;
|
|
36
|
+
size?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SavedAttachment {
|
|
40
|
+
kind: AttachmentKind;
|
|
41
|
+
/** 落盘后的绝对路径 */
|
|
42
|
+
path: string;
|
|
43
|
+
/** 规整后的文件名 */
|
|
44
|
+
name: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface AttachmentFailure {
|
|
48
|
+
/** 展示名(原始文件名或「附件 N」) */
|
|
49
|
+
name: string;
|
|
50
|
+
reason: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SaveAttachmentsOptions {
|
|
54
|
+
dir: string;
|
|
55
|
+
maxBytes?: number;
|
|
56
|
+
maxPerMessage?: number;
|
|
57
|
+
timeoutMs?: number;
|
|
58
|
+
fetchFn?: typeof fetch;
|
|
59
|
+
/** 下载 401/403 时重试用的凭据(QQ 签名 URL 一般不需要) */
|
|
60
|
+
credential?: (() => Promise<string>) | null;
|
|
61
|
+
/** 文件名前缀,通常传 msgId,便于追到是哪条消息 */
|
|
62
|
+
prefix?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SaveAttachmentsResult {
|
|
66
|
+
saved: SavedAttachment[];
|
|
67
|
+
failed: AttachmentFailure[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 拼进 pi 文本时的行首文案 */
|
|
71
|
+
export const KIND_LABEL: Record<AttachmentKind, string> = {
|
|
72
|
+
image: "图片路径",
|
|
73
|
+
voice: "语音路径",
|
|
74
|
+
video: "视频路径",
|
|
75
|
+
document: "文档路径",
|
|
76
|
+
archive: "压缩包路径",
|
|
77
|
+
file: "文件路径",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** 回执里用的中文类别名 */
|
|
81
|
+
export const KIND_NAME_ZH: Record<AttachmentKind, string> = {
|
|
82
|
+
image: "图片",
|
|
83
|
+
voice: "语音",
|
|
84
|
+
video: "视频",
|
|
85
|
+
document: "文档",
|
|
86
|
+
archive: "压缩包",
|
|
87
|
+
file: "文件",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/** 回执里的固定顺序,避免每次排列不同 */
|
|
91
|
+
export const KIND_ORDER: AttachmentKind[] = ["image", "voice", "video", "document", "archive", "file"];
|
|
92
|
+
|
|
93
|
+
const MIME_EXT: Record<string, string> = {
|
|
94
|
+
"image/jpeg": "jpg",
|
|
95
|
+
"image/jpg": "jpg",
|
|
96
|
+
"image/png": "png",
|
|
97
|
+
"image/gif": "gif",
|
|
98
|
+
"image/webp": "webp",
|
|
99
|
+
"image/bmp": "bmp",
|
|
100
|
+
"image/heic": "heic",
|
|
101
|
+
"image/tiff": "tiff",
|
|
102
|
+
"audio/mpeg": "mp3",
|
|
103
|
+
"audio/mp3": "mp3",
|
|
104
|
+
"audio/wav": "wav",
|
|
105
|
+
"audio/x-wav": "wav",
|
|
106
|
+
"audio/amr": "amr",
|
|
107
|
+
"audio/ogg": "ogg",
|
|
108
|
+
"audio/mp4": "m4a",
|
|
109
|
+
"audio/aac": "aac",
|
|
110
|
+
"audio/flac": "flac",
|
|
111
|
+
"audio/opus": "opus",
|
|
112
|
+
"audio/silk": "silk",
|
|
113
|
+
"video/mp4": "mp4",
|
|
114
|
+
"video/quicktime": "mov",
|
|
115
|
+
"video/webm": "webm",
|
|
116
|
+
"video/x-matroska": "mkv",
|
|
117
|
+
"video/x-msvideo": "avi",
|
|
118
|
+
"application/pdf": "pdf",
|
|
119
|
+
"application/zip": "zip",
|
|
120
|
+
"application/x-zip-compressed": "zip",
|
|
121
|
+
"application/x-rar-compressed": "rar",
|
|
122
|
+
"application/vnd.rar": "rar",
|
|
123
|
+
"application/x-7z-compressed": "7z",
|
|
124
|
+
"application/gzip": "gz",
|
|
125
|
+
"application/x-tar": "tar",
|
|
126
|
+
"application/msword": "doc",
|
|
127
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
128
|
+
"application/vnd.ms-excel": "xls",
|
|
129
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
130
|
+
"application/vnd.ms-powerpoint": "ppt",
|
|
131
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
132
|
+
"text/plain": "txt",
|
|
133
|
+
"text/markdown": "md",
|
|
134
|
+
"text/csv": "csv",
|
|
135
|
+
"text/html": "html",
|
|
136
|
+
"text/xml": "xml",
|
|
137
|
+
"application/json": "json",
|
|
138
|
+
"application/xml": "xml",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "tif", "tiff"]);
|
|
142
|
+
const VOICE_EXT = new Set(["silk", "amr", "mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"]);
|
|
143
|
+
const VIDEO_EXT = new Set(["mp4", "mov", "m4v", "avi", "mkv", "webm", "flv", "wmv", "mpg", "mpeg"]);
|
|
144
|
+
const ARCHIVE_EXT = new Set(["zip", "rar", "7z", "tar", "gz", "tgz", "bz2", "xz", "zst", "jar"]);
|
|
145
|
+
const DOC_EXT = new Set([
|
|
146
|
+
"pdf",
|
|
147
|
+
"doc",
|
|
148
|
+
"docx",
|
|
149
|
+
"xls",
|
|
150
|
+
"xlsx",
|
|
151
|
+
"ppt",
|
|
152
|
+
"pptx",
|
|
153
|
+
"odt",
|
|
154
|
+
"ods",
|
|
155
|
+
"odp",
|
|
156
|
+
"rtf",
|
|
157
|
+
"epub",
|
|
158
|
+
"txt",
|
|
159
|
+
"md",
|
|
160
|
+
"markdown",
|
|
161
|
+
"csv",
|
|
162
|
+
"json",
|
|
163
|
+
"xml",
|
|
164
|
+
"yaml",
|
|
165
|
+
"yml",
|
|
166
|
+
"html",
|
|
167
|
+
"htm",
|
|
168
|
+
"log",
|
|
169
|
+
"ini",
|
|
170
|
+
"toml",
|
|
171
|
+
"ts",
|
|
172
|
+
"js",
|
|
173
|
+
"py",
|
|
174
|
+
"java",
|
|
175
|
+
"c",
|
|
176
|
+
"cpp",
|
|
177
|
+
"h",
|
|
178
|
+
"rs",
|
|
179
|
+
"go",
|
|
180
|
+
"sh",
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/** 从文件名 / URL 里取扩展名(去掉 query 与 hash,只看最后一段) */
|
|
184
|
+
export function extFromName(value: string | undefined): string {
|
|
185
|
+
if (!value) return "";
|
|
186
|
+
const clean = String(value).split("#")[0].split("?")[0];
|
|
187
|
+
const base = clean.slice(clean.lastIndexOf("/") + 1);
|
|
188
|
+
const dot = base.lastIndexOf(".");
|
|
189
|
+
if (dot < 0 || dot === base.length - 1) return "";
|
|
190
|
+
const ext = base.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
191
|
+
return ext.length > 8 ? "" : ext;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function mimeExt(contentType: string | undefined): string {
|
|
195
|
+
const ct = String(contentType ?? "")
|
|
196
|
+
.toLowerCase()
|
|
197
|
+
.split(";")[0]
|
|
198
|
+
.trim();
|
|
199
|
+
if (!ct) return "";
|
|
200
|
+
return MIME_EXT[ct] ?? "";
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 六类判定:content_type 优先,其次看扩展名 */
|
|
204
|
+
export function classifyAttachment(a: IncomingAttachment): AttachmentKind {
|
|
205
|
+
const ct = String(a.contentType ?? "")
|
|
206
|
+
.toLowerCase()
|
|
207
|
+
.split(";")[0]
|
|
208
|
+
.trim();
|
|
209
|
+
const ext = extFromName(a.filename) || extFromName(a.url) || mimeExt(ct);
|
|
210
|
+
if (ct.startsWith("image/") || ct === "image" || (ext !== "" && IMAGE_EXT.has(ext))) return "image";
|
|
211
|
+
if (ct.startsWith("audio/") || ct === "audio" || ct === "voice" || (ext !== "" && VOICE_EXT.has(ext)))
|
|
212
|
+
return "voice";
|
|
213
|
+
if (ct.startsWith("video/") || ct === "video" || (ext !== "" && VIDEO_EXT.has(ext))) return "video";
|
|
214
|
+
if ((ext !== "" && ARCHIVE_EXT.has(ext)) || /zip|rar|7z|tar|gzip|compressed/.test(ct)) return "archive";
|
|
215
|
+
if (
|
|
216
|
+
(ext !== "" && DOC_EXT.has(ext)) ||
|
|
217
|
+
ct.startsWith("text/") ||
|
|
218
|
+
/pdf|word|document|sheet|presentation|excel|powerpoint|msword|officedocument/.test(ct)
|
|
219
|
+
)
|
|
220
|
+
return "document";
|
|
221
|
+
return "file";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function extensionFor(a: IncomingAttachment): string {
|
|
225
|
+
return mimeExt(a.contentType) || extFromName(a.filename) || extFromName(a.url) || "bin";
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** 落盘名里只允许 [0-9a-zA-Z._-]:路径带空格/中文会让 agent 的命令行不好写 */
|
|
229
|
+
function sanitizeSegment(value: string, fallback: string): string {
|
|
230
|
+
const cleaned = String(value ?? "")
|
|
231
|
+
.replace(/[^0-9a-zA-Z._-]+/g, "_")
|
|
232
|
+
.replace(/^[._]+/, "")
|
|
233
|
+
.slice(0, 40);
|
|
234
|
+
return cleaned || fallback;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 默认落盘目录:系统临时文件夹 / pi-qqbot / bot-<appId 后 6 位> */
|
|
238
|
+
export function defaultAttachmentDir(appId: string): string {
|
|
239
|
+
const tail = String(appId ?? "")
|
|
240
|
+
.replace(/[^0-9a-zA-Z]/g, "")
|
|
241
|
+
.slice(-6);
|
|
242
|
+
return path.join(os.tmpdir(), "pi-qqbot", tail ? `bot-${tail}` : "default");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function dayStamp(now: Date): string {
|
|
246
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
247
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function timeStamp(now: Date): string {
|
|
251
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
252
|
+
return `${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatBytes(bytes: number): string {
|
|
256
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "?";
|
|
257
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
258
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
259
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function displayName(a: IncomingAttachment, index: number): string {
|
|
263
|
+
const name = String(a.filename ?? "").trim();
|
|
264
|
+
return name || `附件 ${index + 1}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function reasonOf(error: unknown): string {
|
|
268
|
+
const message = String((error as any)?.message ?? error ?? "").trim();
|
|
269
|
+
return (message || "未知错误").slice(0, 160);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function timeoutSignal(ms: number): AbortSignal | undefined {
|
|
273
|
+
const anyAbort = AbortSignal as any;
|
|
274
|
+
return typeof anyAbort?.timeout === "function" ? anyAbort.timeout(ms) : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function readLimitedBody(res: Response, maxBytes: number): Promise<Buffer> {
|
|
278
|
+
const declared = Number(res.headers?.get?.("content-length") ?? "");
|
|
279
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
280
|
+
throw new Error(`文件过大(${formatBytes(declared)} > 上限 ${formatBytes(maxBytes)})`);
|
|
281
|
+
}
|
|
282
|
+
const tooBig = () => new Error(`文件过大(超过上限 ${formatBytes(maxBytes)})`);
|
|
283
|
+
if (!res.body) {
|
|
284
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
285
|
+
if (buf.byteLength > maxBytes) throw tooBig();
|
|
286
|
+
if (buf.byteLength === 0) throw new Error("下载到 0 字节");
|
|
287
|
+
return buf;
|
|
288
|
+
}
|
|
289
|
+
const chunks: Buffer[] = [];
|
|
290
|
+
let total = 0;
|
|
291
|
+
for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
|
|
292
|
+
const buf = Buffer.from(chunk);
|
|
293
|
+
total += buf.byteLength;
|
|
294
|
+
if (total > maxBytes) throw tooBig();
|
|
295
|
+
chunks.push(buf);
|
|
296
|
+
}
|
|
297
|
+
if (total === 0) throw new Error("下载到 0 字节");
|
|
298
|
+
return Buffer.concat(chunks);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function download(a: IncomingAttachment, opts: SaveAttachmentsOptions): Promise<Buffer> {
|
|
302
|
+
const maxBytes = Math.max(1024, Number(opts.maxBytes ?? DEFAULT_ATTACHMENT_MAX_BYTES));
|
|
303
|
+
const timeoutMs = Math.max(1000, Number(opts.timeoutMs ?? ATTACHMENT_TIMEOUT_MS));
|
|
304
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
305
|
+
const url = String(a.url ?? "").trim();
|
|
306
|
+
if (!url) throw new Error("没有下载地址");
|
|
307
|
+
|
|
308
|
+
const attempt = async (authorization: string | null): Promise<Response | null> => {
|
|
309
|
+
const headers: Record<string, string> = {};
|
|
310
|
+
if (authorization) headers.Authorization = authorization;
|
|
311
|
+
const res = await fetchFn(url, { headers, redirect: "follow", signal: timeoutSignal(timeoutMs) });
|
|
312
|
+
if (!authorization && (res.status === 401 || res.status === 403)) return null; // 交给带凭据的重试
|
|
313
|
+
if (!res.ok) throw new Error(`下载失败 HTTP ${res.status}`);
|
|
314
|
+
return res;
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
let res = await attempt(null);
|
|
318
|
+
if (res === null) {
|
|
319
|
+
const auth = opts.credential ? await opts.credential().catch(() => "") : "";
|
|
320
|
+
if (!auth) throw new Error("下载失败 HTTP 401/403(没有可用凭据重试)");
|
|
321
|
+
res = await attempt(`QQBot ${auth}`);
|
|
322
|
+
if (res === null) throw new Error("下载失败 HTTP 403(凭据被拒绝)");
|
|
323
|
+
}
|
|
324
|
+
return readLimitedBody(res, maxBytes);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function uniquePath(dir: string, filename: string): Promise<string> {
|
|
328
|
+
const ext = path.extname(filename);
|
|
329
|
+
const stem = filename.slice(0, filename.length - ext.length) || "att";
|
|
330
|
+
let candidate = path.join(dir, filename);
|
|
331
|
+
for (let i = 2; i <= 50; i += 1) {
|
|
332
|
+
try {
|
|
333
|
+
await fs.promises.access(candidate);
|
|
334
|
+
} catch {
|
|
335
|
+
return candidate;
|
|
336
|
+
}
|
|
337
|
+
candidate = path.join(dir, `${stem}-${i}${ext}`);
|
|
338
|
+
}
|
|
339
|
+
return path.join(dir, `${stem}-${Date.now()}${ext}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 下载并落盘。**不抛异常**:单个附件失败只记进 failed,正文照旧走。
|
|
344
|
+
* 返回的 saved 按入参顺序排列。
|
|
345
|
+
*/
|
|
346
|
+
export async function saveIncoming(
|
|
347
|
+
attachments: IncomingAttachment[],
|
|
348
|
+
opts: SaveAttachmentsOptions,
|
|
349
|
+
): Promise<SaveAttachmentsResult> {
|
|
350
|
+
const list = (attachments ?? []).filter((a) => a && String(a.url ?? "").trim() !== "");
|
|
351
|
+
if (list.length === 0) return { saved: [], failed: [] };
|
|
352
|
+
|
|
353
|
+
const maxPer = Math.max(1, Number(opts.maxPerMessage ?? DEFAULT_ATTACHMENT_MAX_PER_MESSAGE));
|
|
354
|
+
const target = list.slice(0, maxPer);
|
|
355
|
+
const failed: AttachmentFailure[] = [];
|
|
356
|
+
if (list.length > target.length) {
|
|
357
|
+
failed.push({
|
|
358
|
+
name: `其余 ${list.length - target.length} 个附件`,
|
|
359
|
+
reason: `单条消息最多处理 ${maxPer} 个`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const now = new Date();
|
|
364
|
+
const dir = path.join(opts.dir, dayStamp(now));
|
|
365
|
+
await fs.promises.mkdir(dir, { recursive: true }).catch(() => {});
|
|
366
|
+
|
|
367
|
+
const prefix = sanitizeSegment(opts.prefix ?? "", "att");
|
|
368
|
+
const results: (SavedAttachment & { index: number })[] = [];
|
|
369
|
+
let cursor = 0;
|
|
370
|
+
|
|
371
|
+
const worker = async () => {
|
|
372
|
+
while (cursor < target.length) {
|
|
373
|
+
const index = cursor;
|
|
374
|
+
cursor += 1;
|
|
375
|
+
const a = target[index];
|
|
376
|
+
try {
|
|
377
|
+
const kind = classifyAttachment(a);
|
|
378
|
+
const ext = extensionFor(a);
|
|
379
|
+
const name = `${prefix}-${index + 1}-${timeStamp(now)}.${ext}`;
|
|
380
|
+
const filePath = await uniquePath(dir, name);
|
|
381
|
+
const bytes = await download(a, opts);
|
|
382
|
+
await fs.promises.writeFile(filePath, bytes);
|
|
383
|
+
results.push({ index, kind, path: filePath, name: path.basename(filePath) });
|
|
384
|
+
} catch (e) {
|
|
385
|
+
failed.push({ name: displayName(a, index), reason: reasonOf(e) });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const workers = Math.min(ATTACHMENT_CONCURRENCY, target.length);
|
|
391
|
+
await Promise.all(Array.from({ length: workers }, () => worker()));
|
|
392
|
+
|
|
393
|
+
const saved = results.sort((a, b) => a.index - b.index).map(({ kind, path: filePath, name }) => ({
|
|
394
|
+
kind,
|
|
395
|
+
path: filePath,
|
|
396
|
+
name,
|
|
397
|
+
}));
|
|
398
|
+
return { saved, failed };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** 拼进 pi 文本的标记行(顺序与 saved 一致) */
|
|
402
|
+
export function renderAttachmentLines(saved: SavedAttachment[]): string[] {
|
|
403
|
+
return saved.map((item) => `${KIND_LABEL[item.kind]} ${item.path}`);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** 下载失败行:告诉 pi 缺了什么 */
|
|
407
|
+
export function renderAttachmentFailureLines(failed: AttachmentFailure[]): string[] {
|
|
408
|
+
return failed.map((f) => `附件下载失败:${f.name}(${f.reason})`);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** QQ 回执:收到几个、什么类别、落在哪、哪些失败 */
|
|
412
|
+
export function renderAttachmentAck(saved: SavedAttachment[], failed: AttachmentFailure[]): string {
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
if (saved.length > 0) {
|
|
415
|
+
const counts = new Map<AttachmentKind, number>();
|
|
416
|
+
for (const item of saved) counts.set(item.kind, (counts.get(item.kind) ?? 0) + 1);
|
|
417
|
+
const parts = KIND_ORDER.filter((k) => counts.has(k)).map((k) => `${KIND_NAME_ZH[k]} ×${counts.get(k)}`);
|
|
418
|
+
lines.push(`已收到 ${saved.length} 个附件(${parts.join("、")}),已存为临时文件并交给 pi:`);
|
|
419
|
+
lines.push(...renderAttachmentLines(saved));
|
|
420
|
+
}
|
|
421
|
+
if (failed.length > 0) {
|
|
422
|
+
lines.push(...renderAttachmentFailureLines(failed));
|
|
423
|
+
}
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* 清理落盘目录:先按保留时长删过期文件,再按数量上限删最旧的。
|
|
429
|
+
* 只碰自己创建的 `<dir>/<YYYY-MM-DD>/` 层,配置到自定义目录也不会误删别的文件。
|
|
430
|
+
*/
|
|
431
|
+
export async function sweepAttachmentDir(
|
|
432
|
+
dir: string,
|
|
433
|
+
opts: { retentionHours?: number; maxFiles?: number } = {},
|
|
434
|
+
): Promise<{ removed: number }> {
|
|
435
|
+
const retentionMs =
|
|
436
|
+
Math.max(0, Number(opts.retentionHours ?? DEFAULT_ATTACHMENT_RETENTION_HOURS)) * 60 * 60 * 1000;
|
|
437
|
+
const maxFiles = Math.max(1, Number(opts.maxFiles ?? DEFAULT_ATTACHMENT_MAX_FILES));
|
|
438
|
+
const now = Date.now();
|
|
439
|
+
let removed = 0;
|
|
440
|
+
const kept: { path: string; mtimeMs: number }[] = [];
|
|
441
|
+
|
|
442
|
+
let days: string[] = [];
|
|
443
|
+
try {
|
|
444
|
+
days = await fs.promises.readdir(dir);
|
|
445
|
+
} catch {
|
|
446
|
+
return { removed: 0 }; // 目录还不存在
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
for (const day of days) {
|
|
450
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
451
|
+
const dayDir = path.join(dir, day);
|
|
452
|
+
let names: string[] = [];
|
|
453
|
+
try {
|
|
454
|
+
names = await fs.promises.readdir(dayDir);
|
|
455
|
+
} catch {
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
let alive = 0;
|
|
459
|
+
for (const name of names) {
|
|
460
|
+
const full = path.join(dayDir, name);
|
|
461
|
+
let stat: { isFile(): boolean; mtimeMs: number };
|
|
462
|
+
try {
|
|
463
|
+
stat = await fs.promises.stat(full);
|
|
464
|
+
} catch {
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (!stat.isFile()) continue;
|
|
468
|
+
if (retentionMs > 0 && now - stat.mtimeMs > retentionMs) {
|
|
469
|
+
try {
|
|
470
|
+
await fs.promises.unlink(full);
|
|
471
|
+
removed += 1;
|
|
472
|
+
} catch {
|
|
473
|
+
/* ignore */
|
|
474
|
+
}
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
kept.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
478
|
+
alive += 1;
|
|
479
|
+
}
|
|
480
|
+
if (alive === 0) {
|
|
481
|
+
try {
|
|
482
|
+
await fs.promises.rmdir(dayDir);
|
|
483
|
+
} catch {
|
|
484
|
+
/* ignore */
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (kept.length > maxFiles) {
|
|
490
|
+
kept.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
491
|
+
for (const item of kept.slice(0, kept.length - maxFiles)) {
|
|
492
|
+
try {
|
|
493
|
+
await fs.promises.unlink(item.path);
|
|
494
|
+
removed += 1;
|
|
495
|
+
} catch {
|
|
496
|
+
/* ignore */
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return { removed };
|
|
502
|
+
}
|