luckytri 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +44 -1
- package/README.md +44 -1
- package/docs/en/CHANGELOG.md +15 -0
- package/docs/zh/CHANGELOG.md +15 -0
- package/package.json +1 -1
- package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
- package/public/app/assets/{Empty-uSyRBXOT.js → Empty-DSurzLFZ.js} +1 -1
- package/public/app/assets/Field-GuXyA_aS.js +1 -0
- package/public/app/assets/HeartPage-BGmIrxCz.js +1 -0
- package/public/app/assets/LifePage-Dzln-0Y3.js +1 -0
- package/public/app/assets/MemoryPage-QsthmMZ9.js +1 -0
- package/public/app/assets/NaturePage-CIvHjgJa.js +3 -0
- package/public/app/assets/PeoplePage-bIoX24pj.js +1 -0
- package/public/app/assets/PluginsPage-EuJhCovw.js +2 -0
- package/public/app/assets/SystemPage-7t42B-Q3.js +1 -0
- package/public/app/assets/{SystemPage-B9M9g95D.css → SystemPage-BLMlD1M6.css} +1 -1
- package/public/app/assets/TimePage-CY-SwBq3.js +3 -0
- package/public/app/assets/index-Cn6TppEV.js +4 -0
- package/public/app/assets/{index-cDDC-1dI.css → index-JRbQoSJr.css} +1 -1
- package/public/app/assets/{knowledge-Ces-Bx70.js → knowledge-sUjcjbfI.js} +1 -1
- package/public/app/assets/media-Dh7c41PQ.js +1 -0
- package/public/app/assets/{mind-D3mdwFW3.js → mind-BMcoPQNd.js} +1 -1
- package/public/app/index.html +4 -4
- package/public/guide.en.html +3 -3
- package/public/guide.html +3 -3
- package/server/actions/index.js +268 -0
- package/server/app.js +4 -1
- package/server/channels/contract.js +3 -0
- package/server/channels/hub.js +15 -0
- package/server/channels/onebot/adapter.js +162 -21
- package/server/channels/onebot/enrich.js +132 -0
- package/server/channels/onebot/index.js +53 -3
- package/server/channels/onebot/music.js +43 -0
- package/server/core/conversation-grounding.js +1 -1
- package/server/core/dialogue-context.js +165 -15
- package/server/core/dialogue-contract.js +32 -33
- package/server/core/model-manager.js +35 -20
- package/server/core/model-presets.js +154 -1
- package/server/core/orchestrator.js +18 -0
- package/server/core/persona-manager.js +20 -2
- package/server/core/reply-delivery.js +328 -48
- package/server/core/response-generator.js +1 -1
- package/server/core/response-validator.js +85 -17
- package/server/core/turn-processor.js +63 -16
- package/server/core/turn.js +59 -1
- package/server/i18n/errors.js +26 -2
- package/server/index.js +30 -0
- package/server/mind/continuity.js +2 -4
- package/server/mind/discretion.js +16 -5
- package/server/mind/memory.js +7 -2
- package/server/mind/recall/index.js +2 -0
- package/server/plugins/zip-file.js +180 -0
- package/server/plugins/zip.js +32 -4
- package/server/stickers/api.js +90 -2
- package/server/stickers/library.js +8 -2
- package/server/stickers/offer.js +4 -1
- package/server/stickers/zip-import.js +136 -0
- package/server/studio/auto-update.js +224 -0
- package/server/studio/updates.js +154 -144
- package/server/voice/index.js +255 -0
- package/server/voice/sauc.js +152 -0
- package/server/web/link-reader.js +379 -0
- package/public/app/assets/ChatsPage-o-exjdFu.js +0 -3
- package/public/app/assets/Field-Bfa5dCkk.js +0 -1
- package/public/app/assets/HeartPage-Q5ts7Ko6.js +0 -1
- package/public/app/assets/LifePage-B4AsD_DM.js +0 -1
- package/public/app/assets/MemoryPage-ijD6lsxo.js +0 -1
- package/public/app/assets/NaturePage-B9-dv8vz.js +0 -3
- package/public/app/assets/PeoplePage-BG_XPian.js +0 -1
- package/public/app/assets/PluginsPage-CPYYMown.js +0 -2
- package/public/app/assets/SystemPage-CTduZhr3.js +0 -1
- package/public/app/assets/TimePage-DScn8IN-.js +0 -3
- package/public/app/assets/index-5FMs9YYQ.js +0 -4
- package/public/app/assets/media-BsG-i0Lv.js +0 -1
package/server/plugins/zip.js
CHANGED
|
@@ -27,7 +27,19 @@ function safeName(name) {
|
|
|
27
27
|
|
|
28
28
|
// Reads a zip the way a stranger's file has to be read: only stored and
|
|
29
29
|
// deflate entries, no encryption, no links, no paths that leave the folder.
|
|
30
|
-
|
|
30
|
+
//
|
|
31
|
+
// Options let another caller (the sticker library) take larger archives:
|
|
32
|
+
// maxFiles / maxBytes bound the whole archive; keep(name) skips an entry
|
|
33
|
+
// without inflating it; an entry over maxEntryBytes is reported, not read.
|
|
34
|
+
export function readZip(
|
|
35
|
+
buffer,
|
|
36
|
+
{
|
|
37
|
+
maxFiles = MAX_FILES,
|
|
38
|
+
maxBytes = MAX_BYTES,
|
|
39
|
+
maxEntryBytes = Infinity,
|
|
40
|
+
keep = () => true,
|
|
41
|
+
} = {},
|
|
42
|
+
) {
|
|
31
43
|
if (!Buffer.isBuffer(buffer) || buffer.length < 22)
|
|
32
44
|
throw new Error("压缩包无法读取");
|
|
33
45
|
const eocd = findEocd(buffer);
|
|
@@ -35,7 +47,7 @@ export function readZip(buffer) {
|
|
|
35
47
|
if (buffer.readUInt16LE(eocd + 4) !== 0)
|
|
36
48
|
throw new Error("压缩包格式不受支持");
|
|
37
49
|
const entries = buffer.readUInt16LE(eocd + 10);
|
|
38
|
-
if (!entries || entries >
|
|
50
|
+
if (!entries || entries > maxFiles) throw new Error("压缩包文件过多");
|
|
39
51
|
let offset = buffer.readUInt32LE(eocd + 16);
|
|
40
52
|
if (offset >= buffer.length) throw new Error("压缩包无法读取");
|
|
41
53
|
const files = [];
|
|
@@ -68,6 +80,11 @@ export function readZip(buffer) {
|
|
|
68
80
|
if (method !== 0 && method !== 8) throw new Error("压缩包格式不受支持");
|
|
69
81
|
if (name.endsWith("/") || name.endsWith("\\")) continue;
|
|
70
82
|
const fileName = safeName(name);
|
|
83
|
+
if (!keep(fileName)) continue;
|
|
84
|
+
if (size > maxEntryBytes) {
|
|
85
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
71
88
|
if (buffer.readUInt32LE(local) !== 0x04034b50)
|
|
72
89
|
throw new Error("压缩包无法读取");
|
|
73
90
|
const dataAt =
|
|
@@ -84,16 +101,27 @@ export function readZip(buffer) {
|
|
|
84
101
|
method === 0
|
|
85
102
|
? Buffer.from(compressed)
|
|
86
103
|
: inflateRawSync(compressed, {
|
|
87
|
-
maxOutputLength:
|
|
104
|
+
maxOutputLength: Math.min(maxBytes - total, maxEntryBytes) + 1,
|
|
88
105
|
});
|
|
89
106
|
} catch (error) {
|
|
107
|
+
if (
|
|
108
|
+
error?.code === "ERR_BUFFER_TOO_LARGE" &&
|
|
109
|
+
maxEntryBytes < maxBytes - total
|
|
110
|
+
) {
|
|
111
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
90
114
|
if (error?.code === "ERR_BUFFER_TOO_LARGE")
|
|
91
115
|
throw new Error("压缩包解压后过大");
|
|
92
116
|
throw new Error("压缩包无法读取");
|
|
93
117
|
}
|
|
94
118
|
if (size && bytes.length !== size) throw new Error("压缩包内容不完整");
|
|
119
|
+
if (bytes.length > maxEntryBytes) {
|
|
120
|
+
files.push({ name: fileName, skipped: "too-large" });
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
95
123
|
total += bytes.length;
|
|
96
|
-
if (total >
|
|
124
|
+
if (total > maxBytes) throw new Error("压缩包解压后过大");
|
|
97
125
|
files.push({ name: fileName, bytes });
|
|
98
126
|
}
|
|
99
127
|
return files;
|
package/server/stickers/api.js
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
+
import { createWriteStream, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { importStickerZipFile, stickerZipLimitMb } from "./zip-import.js";
|
|
8
|
+
import { stickerMaxMb } from "./library.js";
|
|
2
9
|
|
|
3
10
|
// The owner's sticker library. Uploads arrive as raw bytes, one file per
|
|
4
11
|
// request; everything else is JSON like the rest of the studio API.
|
|
5
12
|
export function mountStickerUpload(app, system) {
|
|
13
|
+
// An upload cut off by a crash or restart leaves its temporary archive;
|
|
14
|
+
// anything older than an hour is from a run that is gone.
|
|
15
|
+
for (const name of readdirSync(tmpdir()))
|
|
16
|
+
if (/^luckytri-stickers-[\w-]+\.zip$/.test(name))
|
|
17
|
+
try {
|
|
18
|
+
const path = join(tmpdir(), name);
|
|
19
|
+
if (Date.now() - statSync(path).mtimeMs > 3600000) rmSync(path);
|
|
20
|
+
} catch {
|
|
21
|
+
/* Already gone or not ours to remove. */
|
|
22
|
+
}
|
|
6
23
|
app.post(
|
|
7
24
|
"/api/core/stickers/upload",
|
|
8
|
-
express.raw({ type: "*/*", limit:
|
|
25
|
+
express.raw({ type: "*/*", limit: `${Math.ceil(stickerMaxMb()) + 1}mb` }),
|
|
9
26
|
async (req, res) => {
|
|
10
27
|
try {
|
|
11
28
|
const item = await system.stickers.library.add(req.body, {
|
|
@@ -19,10 +36,79 @@ export function mountStickerUpload(app, system) {
|
|
|
19
36
|
}
|
|
20
37
|
},
|
|
21
38
|
);
|
|
39
|
+
// A big archive is streamed to a temporary file and imported entry by
|
|
40
|
+
// entry; ?job=<id> lets the studio follow the progress.
|
|
41
|
+
app.get("/api/core/stickers/upload-zip/progress", (req, res) => {
|
|
42
|
+
res.json(zipJobs.get(String(req.query.job || "")) || { phase: "unknown" });
|
|
43
|
+
});
|
|
44
|
+
app.post("/api/core/stickers/upload-zip", async (req, res) => {
|
|
45
|
+
const job = String(req.query.job || randomUUID()).slice(0, 64);
|
|
46
|
+
const limit = stickerZipLimitMb() * 1024 * 1024;
|
|
47
|
+
const declared = Number(req.headers["content-length"] || 0);
|
|
48
|
+
const state = {
|
|
49
|
+
phase: "receiving",
|
|
50
|
+
received: 0,
|
|
51
|
+
size: declared,
|
|
52
|
+
done: 0,
|
|
53
|
+
total: 0,
|
|
54
|
+
};
|
|
55
|
+
zipJobs.set(job, state);
|
|
56
|
+
const path = join(tmpdir(), `luckytri-stickers-${randomUUID()}.zip`);
|
|
57
|
+
const forget = () =>
|
|
58
|
+
setTimeout(() => zipJobs.delete(job), 10 * 60000).unref?.();
|
|
59
|
+
try {
|
|
60
|
+
if (declared > limit)
|
|
61
|
+
throw Object.assign(Error("压缩包过大"), { status: 413 });
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
const out = createWriteStream(path);
|
|
64
|
+
let failed = false;
|
|
65
|
+
const fail = (error) => {
|
|
66
|
+
if (failed) return;
|
|
67
|
+
failed = true;
|
|
68
|
+
out.destroy();
|
|
69
|
+
req.unpipe?.(out);
|
|
70
|
+
req.resume();
|
|
71
|
+
reject(error);
|
|
72
|
+
};
|
|
73
|
+
req.on("data", (chunk) => {
|
|
74
|
+
state.received += chunk.length;
|
|
75
|
+
if (state.received > limit)
|
|
76
|
+
fail(Object.assign(Error("压缩包过大"), { status: 413 }));
|
|
77
|
+
});
|
|
78
|
+
req.on("error", fail);
|
|
79
|
+
req.on("aborted", () => fail(Error("上传中断")));
|
|
80
|
+
out.on("error", fail);
|
|
81
|
+
out.on("finish", () => !failed && resolve());
|
|
82
|
+
req.pipe(out);
|
|
83
|
+
});
|
|
84
|
+
if (!state.received) throw Error("文件为空");
|
|
85
|
+
state.phase = "importing";
|
|
86
|
+
const summary = await importStickerZipFile(
|
|
87
|
+
system.stickers.library,
|
|
88
|
+
path,
|
|
89
|
+
{
|
|
90
|
+
category: String(req.query.category || ""),
|
|
91
|
+
folderCategory: req.query.folderCategory === "1",
|
|
92
|
+
queue: (id) => queueDescribe(system, id),
|
|
93
|
+
progress: ({ done, total }) => Object.assign(state, { done, total }),
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
Object.assign(state, { phase: "done", summary });
|
|
97
|
+
res.json(summary);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
Object.assign(state, { phase: "failed", error: error.message });
|
|
100
|
+
res.status(error.status || 400).json({ error: error.message });
|
|
101
|
+
} finally {
|
|
102
|
+
forget();
|
|
103
|
+
await rm(path, { force: true }).catch(() => {});
|
|
104
|
+
}
|
|
105
|
+
});
|
|
22
106
|
}
|
|
23
107
|
|
|
108
|
+
const zipJobs = new Map();
|
|
24
109
|
const queue = [];
|
|
25
110
|
let running = false;
|
|
111
|
+
let current = null; // the one being analysed right now still counts as pending
|
|
26
112
|
function queueDescribe(system, id) {
|
|
27
113
|
queue.push(id);
|
|
28
114
|
if (running) return;
|
|
@@ -30,12 +116,14 @@ function queueDescribe(system, id) {
|
|
|
30
116
|
(async () => {
|
|
31
117
|
while (queue.length) {
|
|
32
118
|
const next = queue.shift();
|
|
119
|
+
current = next;
|
|
33
120
|
try {
|
|
34
121
|
await system.stickers.describe(next, { force: true });
|
|
35
122
|
} catch {
|
|
36
123
|
// The item records its own failure; the owner can retry.
|
|
37
124
|
}
|
|
38
125
|
}
|
|
126
|
+
current = null;
|
|
39
127
|
running = false;
|
|
40
128
|
})();
|
|
41
129
|
}
|
|
@@ -68,7 +156,7 @@ export function mountStickers(app, system) {
|
|
|
68
156
|
}),
|
|
69
157
|
categories: library.categories(),
|
|
70
158
|
settings: system.stickers.settings(),
|
|
71
|
-
pending: queue.length,
|
|
159
|
+
pending: queue.length + (current ? 1 : 0),
|
|
72
160
|
}),
|
|
73
161
|
),
|
|
74
162
|
);
|
|
@@ -3,7 +3,13 @@ import sharp from "sharp";
|
|
|
3
3
|
import { imageMime } from "../core/image-preprocessor.js";
|
|
4
4
|
import { ensureStickerSchema } from "./schema.js";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
// One sticker, whether uploaded alone or inside an archive. 4 MB by default
|
|
7
|
+
// (round 24); STICKER_MAX_MB overrides it, up to 20 MB.
|
|
8
|
+
export function stickerMaxMb(env = process.env) {
|
|
9
|
+
const n = Number(env.STICKER_MAX_MB);
|
|
10
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 20) : 4;
|
|
11
|
+
}
|
|
12
|
+
export const STICKER_MAX_BYTES = Math.round(stickerMaxMb() * 1024 * 1024);
|
|
7
13
|
const ALLOWED = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
8
14
|
const clip = (value, n) =>
|
|
9
15
|
String(value ?? "")
|
|
@@ -39,7 +45,7 @@ export class StickerLibrary {
|
|
|
39
45
|
async add(bytes, { name = "", category = "" } = {}) {
|
|
40
46
|
if (!Buffer.isBuffer(bytes) || !bytes.length) throw Error("文件为空");
|
|
41
47
|
if (bytes.length > STICKER_MAX_BYTES)
|
|
42
|
-
throw Error("
|
|
48
|
+
throw Error("表情超过单张大小上限,请压缩后再上传");
|
|
43
49
|
const mime = imageMime(bytes);
|
|
44
50
|
if (!ALLOWED.has(mime)) throw Error("只支持 PNG、JPEG、GIF、WebP 图片");
|
|
45
51
|
let meta;
|
package/server/stickers/offer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { actionNote } from "../actions/index.js";
|
|
1
2
|
import { isGroupSession, parseSessionKey } from "../channels/session-key.js";
|
|
2
3
|
|
|
3
4
|
// Only OneBot can carry an @ segment or a sticker image.
|
|
@@ -78,7 +79,8 @@ export function chosenSticker(raw, snapshot) {
|
|
|
78
79
|
export function expressionInput(snapshot) {
|
|
79
80
|
const stickers = snapshot.stickers || [];
|
|
80
81
|
const canAt = snapshot.canAt || [];
|
|
81
|
-
|
|
82
|
+
const actions = snapshot.actionsOffered || [];
|
|
83
|
+
if (!stickers.length && !canAt.length && !actions.length) return {};
|
|
82
84
|
return {
|
|
83
85
|
expression: {
|
|
84
86
|
...(stickers.length
|
|
@@ -95,6 +97,7 @@ export function expressionInput(snapshot) {
|
|
|
95
97
|
"群里要叫住某个人、让他注意到这句话时,可返回 at:[userId],最多两人;正在一对一接话、对方自然会看到时不用@。",
|
|
96
98
|
}
|
|
97
99
|
: {}),
|
|
100
|
+
...(actions.length ? actionNote(actions) : {}),
|
|
98
101
|
},
|
|
99
102
|
};
|
|
100
103
|
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { readZip, stripRoot } from "../plugins/zip.js";
|
|
2
|
+
import { readZipFile, zipFileNames } from "../plugins/zip-file.js";
|
|
3
|
+
import { STICKER_MAX_BYTES } from "./library.js";
|
|
4
|
+
|
|
5
|
+
const MIB = 1024 * 1024;
|
|
6
|
+
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
|
|
7
|
+
const ARCHIVE_ROOT =
|
|
8
|
+
/^(?:pack(?:age)?|stickers?(?:[-_ ]?pack)?|emojis?|images?|my[-_ ]stickers|我的表情|表情包|表情)$/i;
|
|
9
|
+
|
|
10
|
+
function stripArchiveRoot(files) {
|
|
11
|
+
if (!files.length) return files;
|
|
12
|
+
const roots = new Set(files.map((file) => file.name.split("/")[0]));
|
|
13
|
+
if (roots.size !== 1 || !ARCHIVE_ROOT.test([...roots][0])) return files;
|
|
14
|
+
return stripRoot(files);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Upload size of one sticker archive; STICKER_ZIP_MAX_MB overrides it.
|
|
18
|
+
export function stickerZipLimitMb(env = process.env) {
|
|
19
|
+
const n = Number(env.STICKER_ZIP_MAX_MB);
|
|
20
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 4096) : 1024;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const hidden = (name) =>
|
|
24
|
+
name.split("/").some((part) => part === "__MACOSX" || part.startsWith("."));
|
|
25
|
+
|
|
26
|
+
// Imports every image in an archive, in memory. Each new one joins the same
|
|
27
|
+
// one-time analysis queue as a single upload; nothing is written to disk.
|
|
28
|
+
export async function importStickerZip(
|
|
29
|
+
library,
|
|
30
|
+
buffer,
|
|
31
|
+
{ category = "", folderCategory = false, queue = () => {} } = {},
|
|
32
|
+
env = process.env,
|
|
33
|
+
) {
|
|
34
|
+
const limit = stickerZipLimitMb(env) * MIB;
|
|
35
|
+
if (!Buffer.isBuffer(buffer) || !buffer.length) throw Error("文件为空");
|
|
36
|
+
if (buffer.length > limit) throw Error("压缩包过大");
|
|
37
|
+
const summary = { imported: 0, duplicates: 0, skipped: [], failed: [] };
|
|
38
|
+
const files = stripArchiveRoot(
|
|
39
|
+
readZip(buffer, {
|
|
40
|
+
maxFiles: 5000,
|
|
41
|
+
maxBytes: Math.max(limit * 2, 400 * MIB),
|
|
42
|
+
maxEntryBytes: STICKER_MAX_BYTES,
|
|
43
|
+
keep: (name) => {
|
|
44
|
+
if (hidden(name)) return false;
|
|
45
|
+
if (!IMAGE.test(name)) {
|
|
46
|
+
summary.skipped.push({ name, reason: "不是图片" });
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
},
|
|
51
|
+
}),
|
|
52
|
+
);
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
if (file.skipped) {
|
|
55
|
+
summary.skipped.push({ name: file.name, reason: "超过单张大小上限" });
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const parts = file.name.split("/");
|
|
59
|
+
const base = parts.at(-1).replace(/\.[a-z0-9]+$/i, "");
|
|
60
|
+
// The first folder names the category: 开心/a.png, 开心/more/b.png.
|
|
61
|
+
const folder = parts.length > 1 ? parts[0] : "";
|
|
62
|
+
try {
|
|
63
|
+
const item = await library.add(file.bytes, {
|
|
64
|
+
name: base,
|
|
65
|
+
category: (folderCategory && folder) || category,
|
|
66
|
+
});
|
|
67
|
+
if (item.duplicate) summary.duplicates++;
|
|
68
|
+
else {
|
|
69
|
+
summary.imported++;
|
|
70
|
+
queue(item.id);
|
|
71
|
+
}
|
|
72
|
+
} catch (error) {
|
|
73
|
+
summary.failed.push({ name: file.name, reason: error.message });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return summary;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The same import for an archive already on disk: entries are read and
|
|
80
|
+
// added one at a time, so memory stays at one sticker however big the pack.
|
|
81
|
+
// progress({ done, total }) is called after each entry.
|
|
82
|
+
export async function importStickerZipFile(
|
|
83
|
+
library,
|
|
84
|
+
path,
|
|
85
|
+
{
|
|
86
|
+
category = "",
|
|
87
|
+
folderCategory = false,
|
|
88
|
+
queue = () => {},
|
|
89
|
+
progress = () => {},
|
|
90
|
+
} = {},
|
|
91
|
+
) {
|
|
92
|
+
const summary = { imported: 0, duplicates: 0, skipped: [], failed: [] };
|
|
93
|
+
const names = (await zipFileNames(path)).filter((name) => !hidden(name));
|
|
94
|
+
const images = names.filter((name) => IMAGE.test(name));
|
|
95
|
+
for (const name of names)
|
|
96
|
+
if (!IMAGE.test(name)) summary.skipped.push({ name, reason: "不是图片" });
|
|
97
|
+
// The same single-root folder rule as the in-memory import.
|
|
98
|
+
const roots = new Set(images.map((name) => name.split("/")[0]));
|
|
99
|
+
const root =
|
|
100
|
+
roots.size === 1 &&
|
|
101
|
+
ARCHIVE_ROOT.test([...roots][0]) &&
|
|
102
|
+
images.every((name) => name.includes("/"))
|
|
103
|
+
? [...roots][0]
|
|
104
|
+
: null;
|
|
105
|
+
let done = 0;
|
|
106
|
+
progress({ done, total: images.length });
|
|
107
|
+
for await (const file of readZipFile(path, {
|
|
108
|
+
maxEntryBytes: STICKER_MAX_BYTES,
|
|
109
|
+
keep: (name) => !hidden(name) && IMAGE.test(name),
|
|
110
|
+
})) {
|
|
111
|
+
done++;
|
|
112
|
+
const relative = root ? file.name.slice(root.length + 1) : file.name;
|
|
113
|
+
if (file.skipped) {
|
|
114
|
+
summary.skipped.push({ name: file.name, reason: "超过单张大小上限" });
|
|
115
|
+
} else {
|
|
116
|
+
const parts = relative.split("/");
|
|
117
|
+
const base = parts.at(-1).replace(/\.[a-z0-9]+$/i, "");
|
|
118
|
+
const folder = parts.length > 1 ? parts[0] : "";
|
|
119
|
+
try {
|
|
120
|
+
const item = await library.add(file.bytes, {
|
|
121
|
+
name: base,
|
|
122
|
+
category: (folderCategory && folder) || category,
|
|
123
|
+
});
|
|
124
|
+
if (item.duplicate) summary.duplicates++;
|
|
125
|
+
else {
|
|
126
|
+
summary.imported++;
|
|
127
|
+
queue(item.id);
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
summary.failed.push({ name: file.name, reason: error.message });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
progress({ done, total: images.length });
|
|
134
|
+
}
|
|
135
|
+
return summary;
|
|
136
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Automatic updates: off unless the owner turns them on. At the chosen time
|
|
2
|
+
// it checks the registry; with "install" it waits for a safe moment (no
|
|
3
|
+
// backup, cleanup, transfer or update running, and she is not in the middle
|
|
4
|
+
// of a conversation) and then runs the same verified update as the button:
|
|
5
|
+
// backup checked, migrate, restart, health check, roll back on failure.
|
|
6
|
+
import { compareVersions } from "./updates.js";
|
|
7
|
+
|
|
8
|
+
export const AUTO_UPDATE_DEFAULTS = {
|
|
9
|
+
enabled: false,
|
|
10
|
+
mode: "daily", // daily | interval
|
|
11
|
+
at: "04:00", // daily, in the owner's time zone
|
|
12
|
+
everyHours: 24, // interval
|
|
13
|
+
install: "notify", // notify | auto
|
|
14
|
+
window: "", // optional "02:00-06:00"
|
|
15
|
+
};
|
|
16
|
+
const QUIET_MS = 10 * 60000;
|
|
17
|
+
const DEFER_MAX_MS = 24 * 3600000;
|
|
18
|
+
|
|
19
|
+
function minutes(hhmm) {
|
|
20
|
+
const m = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(String(hhmm || ""));
|
|
21
|
+
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
|
22
|
+
}
|
|
23
|
+
function localMinutes(now, zone) {
|
|
24
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
25
|
+
timeZone: zone || "Asia/Shanghai",
|
|
26
|
+
hour: "2-digit",
|
|
27
|
+
minute: "2-digit",
|
|
28
|
+
hourCycle: "h23",
|
|
29
|
+
}).formatToParts(new Date(now));
|
|
30
|
+
const get = (t) => Number(parts.find((p) => p.type === t)?.value || 0);
|
|
31
|
+
return get("hour") * 60 + get("minute");
|
|
32
|
+
}
|
|
33
|
+
function localDay(now, zone) {
|
|
34
|
+
return new Intl.DateTimeFormat("en-CA", {
|
|
35
|
+
timeZone: zone || "Asia/Shanghai",
|
|
36
|
+
}).format(new Date(now));
|
|
37
|
+
}
|
|
38
|
+
export function inWindow(window, now, zone) {
|
|
39
|
+
const m = /^(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$/.exec(String(window || ""));
|
|
40
|
+
if (!m) return true;
|
|
41
|
+
const [from, to] = [minutes(m[1]), minutes(m[2])];
|
|
42
|
+
if (from == null || to == null) return true;
|
|
43
|
+
const at = localMinutes(now, zone);
|
|
44
|
+
return from <= to ? at >= from && at < to : at >= from || at < to;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeAutoUpdate(value = {}) {
|
|
48
|
+
const v = { ...AUTO_UPDATE_DEFAULTS, ...(value || {}) };
|
|
49
|
+
return {
|
|
50
|
+
enabled: v.enabled === true,
|
|
51
|
+
mode: v.mode === "interval" ? "interval" : "daily",
|
|
52
|
+
at: minutes(v.at) == null ? AUTO_UPDATE_DEFAULTS.at : v.at,
|
|
53
|
+
everyHours: [6, 12, 24, 72].includes(Number(v.everyHours))
|
|
54
|
+
? Number(v.everyHours)
|
|
55
|
+
: 24,
|
|
56
|
+
install: v.install === "auto" ? "auto" : "notify",
|
|
57
|
+
window: /^\d{1,2}:\d{2}-\d{1,2}:\d{2}$/.test(String(v.window || ""))
|
|
58
|
+
? v.window
|
|
59
|
+
: "",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class AutoUpdater {
|
|
64
|
+
constructor({
|
|
65
|
+
repo,
|
|
66
|
+
checker,
|
|
67
|
+
updater,
|
|
68
|
+
occupied = () => false,
|
|
69
|
+
talking = () => false,
|
|
70
|
+
zone = () => "Asia/Shanghai",
|
|
71
|
+
now = Date.now,
|
|
72
|
+
}) {
|
|
73
|
+
Object.assign(this, {
|
|
74
|
+
repo,
|
|
75
|
+
checker,
|
|
76
|
+
updater,
|
|
77
|
+
occupied,
|
|
78
|
+
talking,
|
|
79
|
+
zone,
|
|
80
|
+
now,
|
|
81
|
+
});
|
|
82
|
+
this.waiting = null; // { version, since, reason }
|
|
83
|
+
}
|
|
84
|
+
settings() {
|
|
85
|
+
return normalizeAutoUpdate(this.repo.config("autoUpdate"));
|
|
86
|
+
}
|
|
87
|
+
save(value) {
|
|
88
|
+
const next = normalizeAutoUpdate(value);
|
|
89
|
+
this.repo.saveConfig("autoUpdate", next);
|
|
90
|
+
return next;
|
|
91
|
+
}
|
|
92
|
+
state() {
|
|
93
|
+
return this.repo.config("autoUpdateState", {
|
|
94
|
+
lastCheckDay: "",
|
|
95
|
+
lastCheckAt: 0,
|
|
96
|
+
failedVersions: [],
|
|
97
|
+
history: [],
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
note(entry) {
|
|
101
|
+
const s = this.state();
|
|
102
|
+
s.history = [{ time: this.now(), ...entry }, ...(s.history || [])].slice(
|
|
103
|
+
0,
|
|
104
|
+
20,
|
|
105
|
+
);
|
|
106
|
+
this.repo.saveConfig("autoUpdateState", s);
|
|
107
|
+
}
|
|
108
|
+
due(settings, state, now) {
|
|
109
|
+
const zone = this.zone();
|
|
110
|
+
if (settings.mode === "interval")
|
|
111
|
+
return now - (state.lastCheckAt || 0) >= settings.everyHours * 3600000;
|
|
112
|
+
return (
|
|
113
|
+
state.lastCheckDay !== localDay(now, zone) &&
|
|
114
|
+
localMinutes(now, zone) >= minutes(settings.at)
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
view() {
|
|
118
|
+
const s = this.state();
|
|
119
|
+
return {
|
|
120
|
+
settings: this.settings(),
|
|
121
|
+
waiting: this.waiting,
|
|
122
|
+
lastCheckAt: s.lastCheckAt || null,
|
|
123
|
+
history: s.history || [],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Called from the one-minute maintenance loop.
|
|
127
|
+
async tick() {
|
|
128
|
+
const settings = this.settings();
|
|
129
|
+
if (!settings.enabled) {
|
|
130
|
+
this.waiting = null;
|
|
131
|
+
return "off";
|
|
132
|
+
}
|
|
133
|
+
const now = this.now();
|
|
134
|
+
if (this.waiting) return this.tryInstall(settings, now);
|
|
135
|
+
const state = this.state();
|
|
136
|
+
if (!this.due(settings, state, now)) return "idle";
|
|
137
|
+
state.lastCheckAt = now;
|
|
138
|
+
state.lastCheckDay = localDay(now, this.zone());
|
|
139
|
+
this.repo.saveConfig("autoUpdateState", state);
|
|
140
|
+
const info = await this.checker.check();
|
|
141
|
+
if (info.status !== "available") {
|
|
142
|
+
this.note({
|
|
143
|
+
result: info.error ? "check-failed" : "current",
|
|
144
|
+
to: info.latest || "",
|
|
145
|
+
});
|
|
146
|
+
return "checked";
|
|
147
|
+
}
|
|
148
|
+
if (settings.install !== "auto" || info.installation !== "npm") {
|
|
149
|
+
this.note({ result: "available", from: info.current, to: info.latest });
|
|
150
|
+
return "notified";
|
|
151
|
+
}
|
|
152
|
+
if ((state.failedVersions || []).includes(info.latest)) {
|
|
153
|
+
this.note({
|
|
154
|
+
result: "skipped",
|
|
155
|
+
to: info.latest,
|
|
156
|
+
reason: "这个版本上次自动更新失败过,不再自动重试",
|
|
157
|
+
});
|
|
158
|
+
return "skipped";
|
|
159
|
+
}
|
|
160
|
+
this.waiting = {
|
|
161
|
+
version: info.latest,
|
|
162
|
+
from: info.current,
|
|
163
|
+
since: now,
|
|
164
|
+
reason: "",
|
|
165
|
+
};
|
|
166
|
+
return this.tryInstall(settings, now);
|
|
167
|
+
}
|
|
168
|
+
async tryInstall(settings, now) {
|
|
169
|
+
const w = this.waiting;
|
|
170
|
+
if (now - w.since > DEFER_MAX_MS) {
|
|
171
|
+
this.note({ result: "deferred", to: w.version, reason: w.reason });
|
|
172
|
+
this.waiting = null;
|
|
173
|
+
return "deferred";
|
|
174
|
+
}
|
|
175
|
+
const block =
|
|
176
|
+
this.occupied() || this.updater?.busy
|
|
177
|
+
? "备份、整理、迁移或更新正在进行"
|
|
178
|
+
: this.talking(QUIET_MS)
|
|
179
|
+
? "她正在聊天,稍后再试"
|
|
180
|
+
: !inWindow(settings.window, now, this.zone())
|
|
181
|
+
? "不在允许的更新时段"
|
|
182
|
+
: "";
|
|
183
|
+
if (block) {
|
|
184
|
+
w.reason = block;
|
|
185
|
+
return "waiting";
|
|
186
|
+
}
|
|
187
|
+
this.waiting = null;
|
|
188
|
+
try {
|
|
189
|
+
await this.updater.install(w.version);
|
|
190
|
+
this.note({ result: "started", from: w.from, to: w.version });
|
|
191
|
+
return "installing";
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const s = this.state();
|
|
194
|
+
s.failedVersions = [
|
|
195
|
+
...new Set([...(s.failedVersions || []), w.version]),
|
|
196
|
+
].slice(-10);
|
|
197
|
+
this.repo.saveConfig("autoUpdateState", s);
|
|
198
|
+
this.note({ result: "failed", to: w.version, reason: error.message });
|
|
199
|
+
return "failed";
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// After a restart: an update that rolled back marks its version so it is
|
|
203
|
+
// not tried again automatically.
|
|
204
|
+
observe(status) {
|
|
205
|
+
if (
|
|
206
|
+
!status?.version ||
|
|
207
|
+
!/restored|recovery|error|failed/i.test(String(status.status || ""))
|
|
208
|
+
)
|
|
209
|
+
return;
|
|
210
|
+
const s = this.state();
|
|
211
|
+
if ((s.failedVersions || []).includes(status.version)) return;
|
|
212
|
+
s.failedVersions = [...(s.failedVersions || []), status.version].slice(-10);
|
|
213
|
+
this.repo.saveConfig("autoUpdateState", s);
|
|
214
|
+
this.note({ result: "rolled-back", to: status.version });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function newer(a, b) {
|
|
219
|
+
try {
|
|
220
|
+
return compareVersions(a, b) > 0;
|
|
221
|
+
} catch {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|