luckytri 1.2.4 → 1.3.2

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 (78) hide show
  1. package/README.en.md +44 -1
  2. package/README.md +44 -1
  3. package/docs/en/CHANGELOG.md +20 -0
  4. package/docs/zh/CHANGELOG.md +20 -0
  5. package/package.json +1 -1
  6. package/public/app/assets/ChatsPage-C5-w410L.js +3 -0
  7. package/public/app/assets/{Empty-y1vXDIjQ.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-DNTD2_nj.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-BjD9PkXe.js → knowledge-sUjcjbfI.js} +1 -1
  21. package/public/app/assets/media-Dh7c41PQ.js +1 -0
  22. package/public/app/assets/{mind-KkinyNC6.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 +3 -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 +158 -1
  36. package/server/core/dialogue-contract.js +32 -33
  37. package/server/core/message-scheduler.js +13 -10
  38. package/server/core/model-manager.js +35 -20
  39. package/server/core/model-presets.js +154 -1
  40. package/server/core/orchestrator.js +18 -0
  41. package/server/core/persona-manager.js +20 -2
  42. package/server/core/reply-delivery.js +360 -60
  43. package/server/core/response-generator.js +1 -1
  44. package/server/core/response-validator.js +85 -17
  45. package/server/core/turn-processor.js +63 -16
  46. package/server/core/turn.js +59 -1
  47. package/server/i18n/decisions.js +10 -5
  48. package/server/i18n/errors.js +25 -2
  49. package/server/index.js +30 -0
  50. package/server/mind/continuity.js +2 -4
  51. package/server/mind/discretion.js +16 -5
  52. package/server/mind/guard.js +88 -68
  53. package/server/mind/memory.js +7 -2
  54. package/server/mind/recall/index.js +2 -0
  55. package/server/mind/relationship-context.js +110 -71
  56. package/server/plugins/zip-file.js +180 -0
  57. package/server/runtime/update-manager.js +34 -2
  58. package/server/stickers/api.js +86 -19
  59. package/server/stickers/library.js +8 -2
  60. package/server/stickers/offer.js +48 -16
  61. package/server/stickers/zip-import.js +62 -2
  62. package/server/studio/auto-update.js +224 -0
  63. package/server/studio/updates.js +154 -144
  64. package/server/voice/index.js +255 -0
  65. package/server/voice/sauc.js +152 -0
  66. package/server/web/link-reader.js +379 -0
  67. package/public/app/assets/ChatsPage-C_gKz4mJ.js +0 -3
  68. package/public/app/assets/Field-CJG5lERx.js +0 -1
  69. package/public/app/assets/HeartPage-5y3avjSy.js +0 -1
  70. package/public/app/assets/LifePage-BtwRMDAM.js +0 -1
  71. package/public/app/assets/MemoryPage-54noDhWz.js +0 -1
  72. package/public/app/assets/NaturePage-DHK5X2Qw.js +0 -3
  73. package/public/app/assets/PeoplePage-ZsA7gyxP.js +0 -1
  74. package/public/app/assets/PluginsPage-BCRRIAhq.js +0 -2
  75. package/public/app/assets/SystemPage-DWr1TG49.js +0 -1
  76. package/public/app/assets/TimePage-6MUmtSOT.js +0 -3
  77. package/public/app/assets/index-BokqwNXF.js +0 -4
  78. package/public/app/assets/media-Ciozo_yb.js +0 -1
@@ -1,12 +1,28 @@
1
1
  import express from "express";
2
- import { importStickerZip, stickerZipLimitMb } from "./zip-import.js";
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: "3mb" }),
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
- app.post(
24
- "/api/core/stickers/upload-zip",
25
- express.raw({ type: "*/*", limit: `${stickerZipLimitMb()}mb` }),
26
- async (req, res) => {
27
- try {
28
- res.json(
29
- await importStickerZip(system.stickers.library, req.body, {
30
- category: String(req.query.category || ""),
31
- folderCategory: req.query.folderCategory === "1",
32
- queue: (id) => queueDescribe(system, id),
33
- }),
34
- );
35
- } catch (error) {
36
- res.status(400).json({ error: error.message });
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
- 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,29 +97,59 @@ export function expressionInput(snapshot) {
95
97
  "群里要叫住某个人、让他注意到这句话时,可返回 at:[userId],最多两人;正在一对一接话、对方自然会看到时不用@。",
96
98
  }
97
99
  : {}),
100
+ ...(actions.length ? actionNote(actions) : {}),
98
101
  },
99
102
  };
100
103
  }
101
104
 
102
105
  // "@小红" typed as text is not an @ on QQ. A name she wrote that way for
103
- // someone offered becomes a real @, and the typed copy is removed so the
104
- // message does not show the name twice.
106
+ // someone offered becomes a real @ on THAT bubble only, and the typed copy
107
+ // is removed so the message does not show the name twice. Two bubbles for
108
+ // two people must not dump both @s onto the first bubble.
105
109
  export function typedAt(bubbles, at, snapshot) {
106
110
  const offered = snapshot.canAt || [];
107
- const chosen = new Map(at.map((p) => [p.userId, p]));
108
- const out = bubbles.map((line, i) => {
109
- let text = line;
111
+ const fromTurn = new Map((at || []).map((p) => [String(p.userId), p]));
112
+ const out = [];
113
+ const atByBubble = [];
114
+ const typedIds = new Set();
115
+ for (const line of bubbles || []) {
116
+ let text = String(line || "");
117
+ const onThis = new Map();
110
118
  for (const p of offered) {
119
+ if (!p.name) continue;
111
120
  const mark = `@${p.name}`;
112
- if (!p.name || !text.includes(mark)) continue;
113
- if (i === 0 && chosen.size < 2) chosen.set(p.userId, p);
114
- if (chosen.has(p.userId))
115
- text = text
116
- .split(mark)
117
- .join("")
118
- .replace(/^[\s,,]+/, "");
121
+ if (!text.includes(mark)) continue;
122
+ const id = String(p.userId);
123
+ typedIds.add(id);
124
+ onThis.set(id, fromTurn.get(id) || p);
125
+ text = text
126
+ .split(mark)
127
+ .join("")
128
+ .replace(/^[\s,,]+/, "");
119
129
  }
120
- return text.trim();
121
- });
122
- return { bubbles: out.filter(Boolean), at: [...chosen.values()] };
130
+ text = text.trim();
131
+ if (!text) continue;
132
+ out.push(text);
133
+ atByBubble.push([...onThis.values()].slice(0, 2));
134
+ }
135
+ const placed = new Set(atByBubble.flat().map((p) => String(p.userId)));
136
+ const unplaced = [...fromTurn.values()].filter(
137
+ (p) => !placed.has(String(p.userId)) && !typedIds.has(String(p.userId)),
138
+ );
139
+ if (unplaced.length) {
140
+ if (out.length === 1) {
141
+ atByBubble[0] = [...(atByBubble[0] || []), ...unplaced].slice(0, 2);
142
+ } else if (out.length > 1 && atByBubble.every((list) => !list.length)) {
143
+ // turn.at with no typed @ marks: pair person i with bubble i
144
+ unplaced.forEach((p, i) => {
145
+ if (i < atByBubble.length) atByBubble[i] = [p];
146
+ });
147
+ }
148
+ }
149
+ return {
150
+ bubbles: out,
151
+ atByBubble,
152
+ // First bubble only — never the union of every addressee.
153
+ at: atByBubble[0] || [],
154
+ };
123
155
  }
@@ -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, 2048) : 200;
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: "超过 2MB" });
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
+ }