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.
Files changed (74) hide show
  1. package/README.en.md +44 -1
  2. package/README.md +44 -1
  3. package/docs/en/CHANGELOG.md +15 -0
  4. package/docs/zh/CHANGELOG.md +15 -0
  5. package/package.json +1 -1
  6. package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
  7. package/public/app/assets/{Empty-uSyRBXOT.js → Empty-DSurzLFZ.js} +1 -1
  8. package/public/app/assets/Field-GuXyA_aS.js +1 -0
  9. package/public/app/assets/HeartPage-BGmIrxCz.js +1 -0
  10. package/public/app/assets/LifePage-Dzln-0Y3.js +1 -0
  11. package/public/app/assets/MemoryPage-QsthmMZ9.js +1 -0
  12. package/public/app/assets/NaturePage-CIvHjgJa.js +3 -0
  13. package/public/app/assets/PeoplePage-bIoX24pj.js +1 -0
  14. package/public/app/assets/PluginsPage-EuJhCovw.js +2 -0
  15. package/public/app/assets/SystemPage-7t42B-Q3.js +1 -0
  16. package/public/app/assets/{SystemPage-B9M9g95D.css → SystemPage-BLMlD1M6.css} +1 -1
  17. package/public/app/assets/TimePage-CY-SwBq3.js +3 -0
  18. package/public/app/assets/index-Cn6TppEV.js +4 -0
  19. package/public/app/assets/{index-cDDC-1dI.css → index-JRbQoSJr.css} +1 -1
  20. package/public/app/assets/{knowledge-Ces-Bx70.js → knowledge-sUjcjbfI.js} +1 -1
  21. package/public/app/assets/media-Dh7c41PQ.js +1 -0
  22. package/public/app/assets/{mind-D3mdwFW3.js → mind-BMcoPQNd.js} +1 -1
  23. package/public/app/index.html +4 -4
  24. package/public/guide.en.html +3 -3
  25. package/public/guide.html +3 -3
  26. package/server/actions/index.js +268 -0
  27. package/server/app.js +4 -1
  28. package/server/channels/contract.js +3 -0
  29. package/server/channels/hub.js +15 -0
  30. package/server/channels/onebot/adapter.js +162 -21
  31. package/server/channels/onebot/enrich.js +132 -0
  32. package/server/channels/onebot/index.js +53 -3
  33. package/server/channels/onebot/music.js +43 -0
  34. package/server/core/conversation-grounding.js +1 -1
  35. package/server/core/dialogue-context.js +165 -15
  36. package/server/core/dialogue-contract.js +32 -33
  37. package/server/core/model-manager.js +35 -20
  38. package/server/core/model-presets.js +154 -1
  39. package/server/core/orchestrator.js +18 -0
  40. package/server/core/persona-manager.js +20 -2
  41. package/server/core/reply-delivery.js +328 -48
  42. package/server/core/response-generator.js +1 -1
  43. package/server/core/response-validator.js +85 -17
  44. package/server/core/turn-processor.js +63 -16
  45. package/server/core/turn.js +59 -1
  46. package/server/i18n/errors.js +26 -2
  47. package/server/index.js +30 -0
  48. package/server/mind/continuity.js +2 -4
  49. package/server/mind/discretion.js +16 -5
  50. package/server/mind/memory.js +7 -2
  51. package/server/mind/recall/index.js +2 -0
  52. package/server/plugins/zip-file.js +180 -0
  53. package/server/plugins/zip.js +32 -4
  54. package/server/stickers/api.js +90 -2
  55. package/server/stickers/library.js +8 -2
  56. package/server/stickers/offer.js +4 -1
  57. package/server/stickers/zip-import.js +136 -0
  58. package/server/studio/auto-update.js +224 -0
  59. package/server/studio/updates.js +154 -144
  60. package/server/voice/index.js +255 -0
  61. package/server/voice/sauc.js +152 -0
  62. package/server/web/link-reader.js +379 -0
  63. package/public/app/assets/ChatsPage-o-exjdFu.js +0 -3
  64. package/public/app/assets/Field-Bfa5dCkk.js +0 -1
  65. package/public/app/assets/HeartPage-Q5ts7Ko6.js +0 -1
  66. package/public/app/assets/LifePage-B4AsD_DM.js +0 -1
  67. package/public/app/assets/MemoryPage-ijD6lsxo.js +0 -1
  68. package/public/app/assets/NaturePage-B9-dv8vz.js +0 -3
  69. package/public/app/assets/PeoplePage-BG_XPian.js +0 -1
  70. package/public/app/assets/PluginsPage-CPYYMown.js +0 -2
  71. package/public/app/assets/SystemPage-CTduZhr3.js +0 -1
  72. package/public/app/assets/TimePage-DScn8IN-.js +0 -3
  73. package/public/app/assets/index-5FMs9YYQ.js +0 -4
  74. package/public/app/assets/media-BsG-i0Lv.js +0 -1
@@ -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
- export function readZip(buffer) {
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 > MAX_FILES) throw new Error("压缩包文件过多");
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: MAX_BYTES - total + 1,
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 > MAX_BYTES) throw new Error("压缩包解压后过大");
124
+ if (total > maxBytes) throw new Error("压缩包解压后过大");
97
125
  files.push({ name: fileName, bytes });
98
126
  }
99
127
  return files;
@@ -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: "3mb" }),
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
- export const STICKER_MAX_BYTES = 2 * 1024 * 1024;
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("表情超过 2MB,请压缩后再上传");
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;
@@ -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
- if (!stickers.length && !canAt.length) return {};
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
+ }