luckytri 1.2.4 → 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 +10 -0
- package/docs/zh/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
- package/public/app/assets/{Empty-y1vXDIjQ.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-DNTD2_nj.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-BjD9PkXe.js → knowledge-sUjcjbfI.js} +1 -1
- package/public/app/assets/media-Dh7c41PQ.js +1 -0
- package/public/app/assets/{mind-KkinyNC6.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 +3 -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 +25 -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/stickers/api.js +86 -19
- package/server/stickers/library.js +8 -2
- package/server/stickers/offer.js +4 -1
- package/server/stickers/zip-import.js +62 -2
- 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-C_gKz4mJ.js +0 -3
- package/public/app/assets/Field-CJG5lERx.js +0 -1
- package/public/app/assets/HeartPage-5y3avjSy.js +0 -1
- package/public/app/assets/LifePage-BtwRMDAM.js +0 -1
- package/public/app/assets/MemoryPage-54noDhWz.js +0 -1
- package/public/app/assets/NaturePage-DHK5X2Qw.js +0 -3
- package/public/app/assets/PeoplePage-ZsA7gyxP.js +0 -1
- package/public/app/assets/PluginsPage-BCRRIAhq.js +0 -2
- package/public/app/assets/SystemPage-DWr1TG49.js +0 -1
- package/public/app/assets/TimePage-6MUmtSOT.js +0 -3
- package/public/app/assets/index-BokqwNXF.js +0 -4
- package/public/app/assets/media-Ciozo_yb.js +0 -1
package/server/stickers/api.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import express from "express";
|
|
2
|
-
import {
|
|
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";
|
|
3
9
|
|
|
4
10
|
// The owner's sticker library. Uploads arrive as raw bytes, one file per
|
|
5
11
|
// request; everything else is JSON like the rest of the studio API.
|
|
6
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
|
+
}
|
|
7
23
|
app.post(
|
|
8
24
|
"/api/core/stickers/upload",
|
|
9
|
-
express.raw({ type: "*/*", limit:
|
|
25
|
+
express.raw({ type: "*/*", limit: `${Math.ceil(stickerMaxMb()) + 1}mb` }),
|
|
10
26
|
async (req, res) => {
|
|
11
27
|
try {
|
|
12
28
|
const item = await system.stickers.library.add(req.body, {
|
|
@@ -20,25 +36,76 @@ export function mountStickerUpload(app, system) {
|
|
|
20
36
|
}
|
|
21
37
|
},
|
|
22
38
|
);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
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
|
+
});
|
|
40
106
|
}
|
|
41
107
|
|
|
108
|
+
const zipJobs = new Map();
|
|
42
109
|
const queue = [];
|
|
43
110
|
let running = false;
|
|
44
111
|
let current = null; // the one being analysed right now still counts as pending
|
|
@@ -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
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readZip, stripRoot } from "../plugins/zip.js";
|
|
2
|
+
import { readZipFile, zipFileNames } from "../plugins/zip-file.js";
|
|
2
3
|
import { STICKER_MAX_BYTES } from "./library.js";
|
|
3
4
|
|
|
4
5
|
const MIB = 1024 * 1024;
|
|
@@ -16,7 +17,7 @@ function stripArchiveRoot(files) {
|
|
|
16
17
|
// Upload size of one sticker archive; STICKER_ZIP_MAX_MB overrides it.
|
|
17
18
|
export function stickerZipLimitMb(env = process.env) {
|
|
18
19
|
const n = Number(env.STICKER_ZIP_MAX_MB);
|
|
19
|
-
return Number.isFinite(n) && n > 0 ? Math.min(n,
|
|
20
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 4096) : 1024;
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
const hidden = (name) =>
|
|
@@ -51,7 +52,7 @@ export async function importStickerZip(
|
|
|
51
52
|
);
|
|
52
53
|
for (const file of files) {
|
|
53
54
|
if (file.skipped) {
|
|
54
|
-
summary.skipped.push({ name: file.name, reason: "
|
|
55
|
+
summary.skipped.push({ name: file.name, reason: "超过单张大小上限" });
|
|
55
56
|
continue;
|
|
56
57
|
}
|
|
57
58
|
const parts = file.name.split("/");
|
|
@@ -74,3 +75,62 @@ export async function importStickerZip(
|
|
|
74
75
|
}
|
|
75
76
|
return summary;
|
|
76
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
|
+
}
|