alvin-bot 4.16.0 โ 4.17.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/CHANGELOG.md +48 -0
- package/dist/handlers/commands.js +43 -0
- package/dist/index.js +7 -1
- package/dist/platforms/whatsapp.js +13 -0
- package/dist/services/cdp-bootstrap.js +6 -0
- package/dist/services/disk-cleanup.js +203 -0
- package/dist/services/heartbeat.js +4 -0
- package/dist/services/release-highlights.js +79 -0
- package/dist/services/updater.js +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,54 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Alvin Bot are documented here.
|
|
4
4
|
|
|
5
|
+
## [4.17.0] โ 2026-04-20
|
|
6
|
+
|
|
7
|
+
### ๐ก๏ธ Hardening: long-running stability audit + leak fixes
|
|
8
|
+
|
|
9
|
+
Ran a full audit of leak/stability hazards for 24/7 operation. Fixed the critical findings and added a disk-cleanup service so the bot stays lean over months of uptime.
|
|
10
|
+
|
|
11
|
+
**Fixes:**
|
|
12
|
+
- **WhatsApp event-listener leak on reconnect** (`src/platforms/whatsapp.ts`): Before every new socket, the previous socket's listeners are now removed and the old socket is ended. Without this, every reconnect stacked new listeners on top of old ones โ causing memory growth and duplicate message processing after long sessions.
|
|
13
|
+
- **CDP file-descriptor leak** (`src/services/cdp-bootstrap.ts`): The log-file fd passed to the detached Chromium spawn is now closed in the parent after the child inherits it. Previously leaked one fd per browser bootstrap.
|
|
14
|
+
- **Heartbeat + auto-update timers now `.unref()`'d** and explicitly stopped in the shutdown handler. Prevents timers from keeping the process alive during graceful exit.
|
|
15
|
+
|
|
16
|
+
### ๐งน Feature: disk-cleanup service
|
|
17
|
+
|
|
18
|
+
New service (`src/services/disk-cleanup.ts`) that runs automatically once a day. Deletes transient files that grow without bound on long-running installs:
|
|
19
|
+
- Bot log rotation (>100 MB by default)
|
|
20
|
+
- Browser screenshots (>30 days)
|
|
21
|
+
- Subagent output streams (>30 days)
|
|
22
|
+
- `/tmp/alvin-bot/` media (>7 days)
|
|
23
|
+
- WhatsApp media cache (>30 days)
|
|
24
|
+
- CDP log file
|
|
25
|
+
|
|
26
|
+
**NEVER touched:** memory, assets, workspaces, cron-jobs, .env, session-store, delivery-queue. Memory is protected.
|
|
27
|
+
|
|
28
|
+
**Configuration via env:** `CLEANUP_LOG_MAX_MB`, `CLEANUP_SCREENSHOTS_DAYS`, `CLEANUP_SUBAGENTS_DAYS`, `CLEANUP_TMP_DAYS`, `CLEANUP_WA_MEDIA_DAYS`. Set any to `0` to disable that category.
|
|
29
|
+
|
|
30
|
+
**Telegram command:**
|
|
31
|
+
- `/cleanup` โ show current policy + protected paths
|
|
32
|
+
- `/cleanup run` โ trigger manual pass, get stats back
|
|
33
|
+
|
|
34
|
+
## [4.16.1] โ 2026-04-20
|
|
35
|
+
|
|
36
|
+
### ๐ Feature: /update shows release highlights
|
|
37
|
+
|
|
38
|
+
After a successful `/update`, the bot now sends a second short message with a bullet-point summary of what actually changed in the newly installed version. Pulled from the CHANGELOG entry matching the version string in the update result.
|
|
39
|
+
|
|
40
|
+
**Implementation:**
|
|
41
|
+
- New module `src/services/release-highlights.ts` parses the CHANGELOG block for a given version and returns at most 5 bullet points, โค500 chars total.
|
|
42
|
+
- Strategy: prefer `### ` subsection headlines (feature/fix titles); fall back to first non-empty paragraph lines.
|
|
43
|
+
- Telegram-friendly output: plain bullets (`โข ...`), no tables, no code blocks, truncates gracefully with an ellipsis line if too long.
|
|
44
|
+
|
|
45
|
+
**Result format in chat:**
|
|
46
|
+
```
|
|
47
|
+
โ
Installed v4.16.1 (was v4.16.0). Restarting...
|
|
48
|
+
๐ What's new in v4.16.1
|
|
49
|
+
|
|
50
|
+
โข Feature: /update shows release highlights
|
|
51
|
+
```
|
|
52
|
+
|
|
5
53
|
## [4.16.0] โ 2026-04-20
|
|
6
54
|
|
|
7
55
|
### ๐ Feature: bot-owned CDP Chromium โ no more hub dependency
|
|
@@ -27,6 +27,8 @@ import { BOT_VERSION } from "../version.js";
|
|
|
27
27
|
import { getWebPort } from "../web/server.js";
|
|
28
28
|
import { getUsageSummary, getAllRateLimits, formatTokens } from "../services/usage-tracker.js";
|
|
29
29
|
import { runUpdate, getAutoUpdate, setAutoUpdate, startAutoUpdateLoop } from "../services/updater.js";
|
|
30
|
+
import { getReleaseHighlights } from "../services/release-highlights.js";
|
|
31
|
+
import { runCleanup, getCleanupPolicy } from "../services/disk-cleanup.js";
|
|
30
32
|
import { getHealthStatus, isFailedOver } from "../services/heartbeat.js";
|
|
31
33
|
import { t, LOCALE_NAMES, LOCALE_FLAGS } from "../i18n.js";
|
|
32
34
|
// Kick off auto-update loop on module load if the persistent flag is set.
|
|
@@ -1875,6 +1877,17 @@ export function registerCommands(bot) {
|
|
|
1875
1877
|
const result = await runUpdate();
|
|
1876
1878
|
if (result.ok) {
|
|
1877
1879
|
await ctx.reply(`โ
${result.message}`);
|
|
1880
|
+
// Extract the installed version from the message (e.g. "Installed v4.16.1 ...")
|
|
1881
|
+
// so we can look up its CHANGELOG block. Falls silently if no match.
|
|
1882
|
+
const versionMatch = result.message.match(/v(\d+\.\d+\.\d+)/);
|
|
1883
|
+
if (versionMatch) {
|
|
1884
|
+
const highlights = getReleaseHighlights(versionMatch[1]);
|
|
1885
|
+
if (highlights) {
|
|
1886
|
+
await ctx.reply(`๐ *What's new in v${versionMatch[1]}*\n\n${highlights}`, {
|
|
1887
|
+
parse_mode: "Markdown",
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1878
1891
|
if (result.requiresRestart) {
|
|
1879
1892
|
await ctx.reply(t("bot.update.restarting", lang));
|
|
1880
1893
|
setTimeout(() => process.exit(0), 500);
|
|
@@ -1907,6 +1920,36 @@ export function registerCommands(bot) {
|
|
|
1907
1920
|
await ctx.reply(`${t("bot.autoupdate.statusLabel", lang)} *${status ? "ON" : "OFF"}*\n\n${t("bot.autoupdate.commandsLabel", lang)}\n\`/autoupdate on\`\n\`/autoupdate off\``, { parse_mode: "Markdown" });
|
|
1908
1921
|
}
|
|
1909
1922
|
});
|
|
1923
|
+
// /cleanup โ trigger disk cleanup manually, or show current policy.
|
|
1924
|
+
// /cleanup โ show policy
|
|
1925
|
+
// /cleanup run โ run a cleanup pass and report what was deleted
|
|
1926
|
+
bot.command("cleanup", async (ctx) => {
|
|
1927
|
+
const arg = (ctx.match || "").trim().toLowerCase();
|
|
1928
|
+
if (arg === "run" || arg === "now") {
|
|
1929
|
+
await ctx.reply("๐งน Running disk cleanup...");
|
|
1930
|
+
const r = await runCleanup();
|
|
1931
|
+
const bytes = r.bytesReclaimed;
|
|
1932
|
+
const human = bytes < 1024 * 1024
|
|
1933
|
+
? `${(bytes / 1024).toFixed(1)} KB`
|
|
1934
|
+
: bytes < 1024 * 1024 * 1024
|
|
1935
|
+
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
1936
|
+
: `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
1937
|
+
const errLine = r.errors.length > 0 ? `\nโ ๏ธ ${r.errors.length} error(s)` : "";
|
|
1938
|
+
await ctx.reply(`โ
Cleanup done\nโข Files deleted: ${r.filesDeleted}\nโข Logs rotated: ${r.logsRotated}\nโข Reclaimed: ${human}${errLine}`);
|
|
1939
|
+
}
|
|
1940
|
+
else {
|
|
1941
|
+
const p = getCleanupPolicy();
|
|
1942
|
+
await ctx.reply(`๐งน *Cleanup policy*\n` +
|
|
1943
|
+
`โข Log rotation: >${p.logMaxSizeMb} MB\n` +
|
|
1944
|
+
`โข Screenshots: >${p.screenshotsMaxAgeDays} days\n` +
|
|
1945
|
+
`โข Subagent outputs: >${p.subagentsMaxAgeDays} days\n` +
|
|
1946
|
+
`โข /tmp/alvin-bot: >${p.tmpMaxAgeDays} days\n` +
|
|
1947
|
+
`โข WhatsApp media: >${p.waMediaMaxAgeDays} days\n\n` +
|
|
1948
|
+
`Memory, assets, workspaces, cron jobs are NEVER touched.\n\n` +
|
|
1949
|
+
`Configure via env: \`CLEANUP_LOG_MAX_MB\`, \`CLEANUP_SCREENSHOTS_DAYS\`, \`CLEANUP_SUBAGENTS_DAYS\`, \`CLEANUP_TMP_DAYS\`, \`CLEANUP_WA_MEDIA_DAYS\`\n\n` +
|
|
1950
|
+
`Run manually: \`/cleanup run\``, { parse_mode: "Markdown" });
|
|
1951
|
+
}
|
|
1952
|
+
});
|
|
1910
1953
|
// โโ /sub-agents โ manage background subagents (cron jobs + manual spawns) โโ
|
|
1911
1954
|
//
|
|
1912
1955
|
// /sub-agents โ show current config + running agents
|
package/dist/index.js
CHANGED
|
@@ -155,7 +155,9 @@ import { startSessionCleanup, stopSessionCleanup, attachPersistHook } from "./se
|
|
|
155
155
|
import { loadPersistedSessions, flushSessions, schedulePersist, } from "./services/session-persistence.js";
|
|
156
156
|
import { processQueue, cleanupQueue, setSenders, enqueue } from "./services/delivery-queue.js";
|
|
157
157
|
import { discoverTools } from "./services/tool-discovery.js";
|
|
158
|
-
import { startHeartbeat } from "./services/heartbeat.js";
|
|
158
|
+
import { startHeartbeat, stopHeartbeat } from "./services/heartbeat.js";
|
|
159
|
+
import { stopAutoUpdateLoop } from "./services/updater.js";
|
|
160
|
+
import { startCleanupLoop, stopCleanupLoop } from "./services/disk-cleanup.js";
|
|
159
161
|
import { initEmbeddings } from "./services/embeddings.js";
|
|
160
162
|
import { loadSkills } from "./services/skills.js";
|
|
161
163
|
import { loadHooks } from "./services/hooks.js";
|
|
@@ -335,6 +337,9 @@ const shutdown = async () => {
|
|
|
335
337
|
stopAsyncAgentWatcher();
|
|
336
338
|
stopSessionCleanup();
|
|
337
339
|
stopWorkspaceWatcher();
|
|
340
|
+
stopHeartbeat();
|
|
341
|
+
stopAutoUpdateLoop();
|
|
342
|
+
stopCleanupLoop();
|
|
338
343
|
// v4.11.0 โ Final immediate flush of in-memory sessions to disk before exit.
|
|
339
344
|
// The debounced timer might be pending; flushSessions() cancels it and writes
|
|
340
345
|
// synchronously so the next boot can rehydrate the latest state.
|
|
@@ -612,5 +617,6 @@ else {
|
|
|
612
617
|
// Start heartbeat monitor even without Telegram
|
|
613
618
|
startHeartbeat();
|
|
614
619
|
startWatchdog();
|
|
620
|
+
startCleanupLoop();
|
|
615
621
|
initEmbeddings().catch(() => { });
|
|
616
622
|
}
|
|
@@ -252,6 +252,19 @@ export class WhatsAppAdapter {
|
|
|
252
252
|
fs.mkdirSync(authDir, { recursive: true });
|
|
253
253
|
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
254
254
|
const { version } = await fetchLatestBaileysVersion();
|
|
255
|
+
// Cleanup previous socket (reconnect path) โ without this, every reconnect
|
|
256
|
+
// stacks a new set of listeners on baileys' EventEmitter, so messages get
|
|
257
|
+
// processed N times after N reconnects and closures leak.
|
|
258
|
+
if (this.sock) {
|
|
259
|
+
try {
|
|
260
|
+
this.sock.ev?.removeAllListeners?.();
|
|
261
|
+
this.sock.end?.(new Error("reconnect"));
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// best-effort cleanup โ ignore failures from already-dead socket
|
|
265
|
+
}
|
|
266
|
+
this.sock = null;
|
|
267
|
+
}
|
|
255
268
|
const sock = makeWASocket({
|
|
256
269
|
version,
|
|
257
270
|
auth: {
|
|
@@ -196,6 +196,12 @@ export async function ensureRunning(opts = {}) {
|
|
|
196
196
|
detached: true,
|
|
197
197
|
});
|
|
198
198
|
child.unref();
|
|
199
|
+
// The child inherits its own copy of the fd. Close our copy so the parent
|
|
200
|
+
// process doesn't leak a file descriptor per Chromium bootstrap.
|
|
201
|
+
try {
|
|
202
|
+
fs.closeSync(logStream);
|
|
203
|
+
}
|
|
204
|
+
catch { /* already closed โ fine */ }
|
|
199
205
|
if (!child.pid) {
|
|
200
206
|
throw new Error("Failed to spawn Chromium (no PID)");
|
|
201
207
|
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Disk Cleanup Service โ periodic cleanup of transient bot files.
|
|
3
|
+
*
|
|
4
|
+
* Targets files that are SAFE to delete (logs, temp screenshots, browser
|
|
5
|
+
* artifacts, old subagent streams) and leaves critical data alone
|
|
6
|
+
* (memory, assets, workspaces, cron-jobs, .env, session-store).
|
|
7
|
+
*
|
|
8
|
+
* Strategy:
|
|
9
|
+
* - Each path has a max age (days) OR a max size (MB, with rotation)
|
|
10
|
+
* - Defaults are conservative: keep 30 days of artifacts, rotate logs >100MB
|
|
11
|
+
* - All knobs overridable via env (CLEANUP_* vars) and via /cleanup set <key>
|
|
12
|
+
* - Runs once at boot + every 24h thereafter, unref'd so it doesn't
|
|
13
|
+
* prevent shutdown
|
|
14
|
+
*
|
|
15
|
+
* NEVER cleaned:
|
|
16
|
+
* ~/.alvin-bot/memory/ (daily logs, long-term memory)
|
|
17
|
+
* ~/.alvin-bot/assets/ (user-supplied files)
|
|
18
|
+
* ~/.alvin-bot/workspaces/ (user configuration)
|
|
19
|
+
* ~/.alvin-bot/cron-jobs.json (scheduled tasks)
|
|
20
|
+
* ~/.alvin-bot/.env (secrets)
|
|
21
|
+
* ~/.alvin-bot/session-store.json (resume tokens)
|
|
22
|
+
* ~/.alvin-bot/delivery-queue.json
|
|
23
|
+
* ~/.alvin-bot/standing-orders
|
|
24
|
+
* ~/.alvin-bot/auto-update.flag
|
|
25
|
+
*/
|
|
26
|
+
import fs from "fs";
|
|
27
|
+
import path from "path";
|
|
28
|
+
import os from "os";
|
|
29
|
+
import { DATA_DIR } from "../paths.js";
|
|
30
|
+
const DEFAULT_POLICY = {
|
|
31
|
+
logMaxSizeMb: parseInt(process.env.CLEANUP_LOG_MAX_MB || "100", 10),
|
|
32
|
+
screenshotsMaxAgeDays: parseInt(process.env.CLEANUP_SCREENSHOTS_DAYS || "30", 10),
|
|
33
|
+
subagentsMaxAgeDays: parseInt(process.env.CLEANUP_SUBAGENTS_DAYS || "30", 10),
|
|
34
|
+
tmpMaxAgeDays: parseInt(process.env.CLEANUP_TMP_DAYS || "7", 10),
|
|
35
|
+
waMediaMaxAgeDays: parseInt(process.env.CLEANUP_WA_MEDIA_DAYS || "30", 10),
|
|
36
|
+
};
|
|
37
|
+
const CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000; // once a day
|
|
38
|
+
let cleanupTimer = null;
|
|
39
|
+
/**
|
|
40
|
+
* Return the current effective policy (env-overridden defaults).
|
|
41
|
+
*/
|
|
42
|
+
export function getCleanupPolicy() {
|
|
43
|
+
return { ...DEFAULT_POLICY };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Run a cleanup pass once. Safe to call manually (e.g. /cleanup command).
|
|
47
|
+
*/
|
|
48
|
+
export async function runCleanup(policyOverride) {
|
|
49
|
+
const policy = { ...DEFAULT_POLICY, ...policyOverride };
|
|
50
|
+
const result = {
|
|
51
|
+
filesDeleted: 0,
|
|
52
|
+
bytesReclaimed: 0,
|
|
53
|
+
logsRotated: 0,
|
|
54
|
+
errors: [],
|
|
55
|
+
details: [],
|
|
56
|
+
};
|
|
57
|
+
// 1. Rotate large log files (launchd stdout/stderr)
|
|
58
|
+
if (policy.logMaxSizeMb > 0) {
|
|
59
|
+
const logsDir = path.join(DATA_DIR, "logs");
|
|
60
|
+
try {
|
|
61
|
+
if (fs.existsSync(logsDir)) {
|
|
62
|
+
for (const name of fs.readdirSync(logsDir)) {
|
|
63
|
+
if (!name.endsWith(".log"))
|
|
64
|
+
continue;
|
|
65
|
+
const full = path.join(logsDir, name);
|
|
66
|
+
try {
|
|
67
|
+
const st = fs.statSync(full);
|
|
68
|
+
if (st.size > policy.logMaxSizeMb * 1024 * 1024) {
|
|
69
|
+
// Rotate: keep a .old, overwrite current. Launchd will reopen on next write.
|
|
70
|
+
const oldPath = full + ".old";
|
|
71
|
+
try {
|
|
72
|
+
fs.rmSync(oldPath, { force: true });
|
|
73
|
+
}
|
|
74
|
+
catch { }
|
|
75
|
+
fs.renameSync(full, oldPath);
|
|
76
|
+
fs.writeFileSync(full, "");
|
|
77
|
+
result.logsRotated++;
|
|
78
|
+
result.bytesReclaimed += st.size;
|
|
79
|
+
result.details.push({ path: full, action: "rotated", size: st.size });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
result.errors.push(`log-rotate ${full}: ${err.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
result.errors.push(`logs scan: ${err.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 2. Browser screenshots (bot-owned CDP)
|
|
93
|
+
if (policy.screenshotsMaxAgeDays > 0) {
|
|
94
|
+
const dir = path.join(DATA_DIR, "browser", "screenshots");
|
|
95
|
+
cleanupOldFiles(dir, policy.screenshotsMaxAgeDays, result);
|
|
96
|
+
}
|
|
97
|
+
// 3. Subagent streaming outputs โ only delete FINISHED ones (older than N days).
|
|
98
|
+
// We trust that the async-agent-watcher has already marked them done โ files
|
|
99
|
+
// older than a few days are either delivered or definitively abandoned.
|
|
100
|
+
if (policy.subagentsMaxAgeDays > 0) {
|
|
101
|
+
const dir = path.join(DATA_DIR, "subagents");
|
|
102
|
+
cleanupOldFiles(dir, policy.subagentsMaxAgeDays, result, [".jsonl", ".err"]);
|
|
103
|
+
}
|
|
104
|
+
// 4. /tmp/alvin-bot/* (media, temp scrapes)
|
|
105
|
+
if (policy.tmpMaxAgeDays > 0) {
|
|
106
|
+
cleanupOldFiles("/tmp/alvin-bot", policy.tmpMaxAgeDays, result);
|
|
107
|
+
}
|
|
108
|
+
// 5. WhatsApp media cache
|
|
109
|
+
if (policy.waMediaMaxAgeDays > 0) {
|
|
110
|
+
const dir = path.join(DATA_DIR, "data", "wa-media");
|
|
111
|
+
cleanupOldFiles(dir, policy.waMediaMaxAgeDays, result);
|
|
112
|
+
}
|
|
113
|
+
// 6. CDP log (/tmp/chrome-cdp.log) โ always keep just the latest boot
|
|
114
|
+
const cdpLog = path.join(os.tmpdir(), "chrome-cdp.log");
|
|
115
|
+
try {
|
|
116
|
+
if (fs.existsSync(cdpLog)) {
|
|
117
|
+
const st = fs.statSync(cdpLog);
|
|
118
|
+
const ageDays = (Date.now() - st.mtimeMs) / (24 * 60 * 60 * 1000);
|
|
119
|
+
if (ageDays > 7) {
|
|
120
|
+
fs.unlinkSync(cdpLog);
|
|
121
|
+
result.filesDeleted++;
|
|
122
|
+
result.bytesReclaimed += st.size;
|
|
123
|
+
result.details.push({ path: cdpLog, action: "deleted", size: st.size });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// Not critical
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Delete files in `dir` older than `maxAgeDays`. Safe if `dir` doesn't exist.
|
|
134
|
+
* Optional extension filter โ e.g. [".jsonl", ".err"] restricts to those types.
|
|
135
|
+
*/
|
|
136
|
+
function cleanupOldFiles(dir, maxAgeDays, result, extensions) {
|
|
137
|
+
if (!fs.existsSync(dir))
|
|
138
|
+
return;
|
|
139
|
+
const cutoffMs = Date.now() - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
140
|
+
try {
|
|
141
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
142
|
+
const full = path.join(dir, entry.name);
|
|
143
|
+
if (!entry.isFile())
|
|
144
|
+
continue;
|
|
145
|
+
if (extensions && !extensions.some((ext) => entry.name.endsWith(ext)))
|
|
146
|
+
continue;
|
|
147
|
+
try {
|
|
148
|
+
const st = fs.statSync(full);
|
|
149
|
+
if (st.mtimeMs < cutoffMs) {
|
|
150
|
+
fs.unlinkSync(full);
|
|
151
|
+
result.filesDeleted++;
|
|
152
|
+
result.bytesReclaimed += st.size;
|
|
153
|
+
result.details.push({ path: full, action: "deleted", size: st.size });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (err) {
|
|
157
|
+
result.errors.push(`${full}: ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch (err) {
|
|
162
|
+
result.errors.push(`scan ${dir}: ${err.message}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Start the periodic cleanup loop. Runs first pass after 5 minutes (let the
|
|
167
|
+
* bot fully boot and avoid competing with startup I/O), then every 24h.
|
|
168
|
+
*/
|
|
169
|
+
export function startCleanupLoop() {
|
|
170
|
+
if (cleanupTimer)
|
|
171
|
+
return;
|
|
172
|
+
// First run delayed so we don't step on a restart that's still writing logs
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
void runCleanup().then((r) => {
|
|
175
|
+
if (r.filesDeleted > 0 || r.logsRotated > 0) {
|
|
176
|
+
console.log(`[cleanup] ${r.filesDeleted} files deleted, ${r.logsRotated} logs rotated, ${formatBytes(r.bytesReclaimed)} reclaimed`);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}, 5 * 60 * 1000);
|
|
180
|
+
cleanupTimer = setInterval(() => {
|
|
181
|
+
void runCleanup().then((r) => {
|
|
182
|
+
if (r.filesDeleted > 0 || r.logsRotated > 0) {
|
|
183
|
+
console.log(`[cleanup] ${r.filesDeleted} files deleted, ${r.logsRotated} logs rotated, ${formatBytes(r.bytesReclaimed)} reclaimed`);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}, CLEANUP_INTERVAL_MS);
|
|
187
|
+
cleanupTimer.unref?.();
|
|
188
|
+
}
|
|
189
|
+
export function stopCleanupLoop() {
|
|
190
|
+
if (cleanupTimer) {
|
|
191
|
+
clearInterval(cleanupTimer);
|
|
192
|
+
cleanupTimer = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function formatBytes(n) {
|
|
196
|
+
if (n < 1024)
|
|
197
|
+
return `${n} B`;
|
|
198
|
+
if (n < 1024 * 1024)
|
|
199
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
200
|
+
if (n < 1024 * 1024 * 1024)
|
|
201
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
202
|
+
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
|
203
|
+
}
|
|
@@ -72,6 +72,10 @@ export function startHeartbeat() {
|
|
|
72
72
|
setTimeout(() => {
|
|
73
73
|
runHeartbeat();
|
|
74
74
|
state.intervalId = setInterval(runHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
75
|
+
// .unref() so this interval alone doesn't keep the process alive during
|
|
76
|
+
// graceful shutdown โ the bot's main loop (grammy, platforms) keeps it
|
|
77
|
+
// running, and once those stop we want the process to exit cleanly.
|
|
78
|
+
state.intervalId?.unref?.();
|
|
75
79
|
}, 30_000);
|
|
76
80
|
}
|
|
77
81
|
/**
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Release Highlights โ extract a short human-readable summary for a given
|
|
3
|
+
* version from CHANGELOG.md.
|
|
4
|
+
*
|
|
5
|
+
* Used by the /update command to tell users what actually changed after a
|
|
6
|
+
* successful upgrade. Deliberately short โ Telegram-friendly (<500 chars),
|
|
7
|
+
* headline + up to 5 bullets, no markdown tables, no code blocks.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "fs";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { BOT_ROOT } from "../paths.js";
|
|
12
|
+
const CHANGELOG_PATH = path.resolve(BOT_ROOT, "CHANGELOG.md");
|
|
13
|
+
const MAX_BULLETS = 5;
|
|
14
|
+
const MAX_CHARS = 500;
|
|
15
|
+
/**
|
|
16
|
+
* Find the block for `## [<version>]` in CHANGELOG.md and return a
|
|
17
|
+
* compact summary (headline + a few bullet points). Returns null if the
|
|
18
|
+
* version block is not found or CHANGELOG.md is missing.
|
|
19
|
+
*/
|
|
20
|
+
export function getReleaseHighlights(version) {
|
|
21
|
+
let content;
|
|
22
|
+
try {
|
|
23
|
+
content = fs.readFileSync(CHANGELOG_PATH, "utf8");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const versionEscaped = version.replace(/\./g, "\\.");
|
|
29
|
+
// Match from "## [X.Y.Z]" up to the next "## [" (or end of file)
|
|
30
|
+
const blockRe = new RegExp(`^##\\s*\\[${versionEscaped}\\][^\\n]*\\n([\\s\\S]*?)(?=^##\\s*\\[|\\Z)`, "m");
|
|
31
|
+
const match = content.match(blockRe);
|
|
32
|
+
if (!match)
|
|
33
|
+
return null;
|
|
34
|
+
return compactHighlights(match[1]);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Extract up to MAX_BULLETS short lines from a CHANGELOG block.
|
|
38
|
+
* Strategy:
|
|
39
|
+
* 1. Prefer "### " subsection headlines (feature/fix titles)
|
|
40
|
+
* 2. Otherwise the first few non-empty lines of the first paragraph
|
|
41
|
+
* Truncate to MAX_CHARS total so it fits comfortably in a Telegram message.
|
|
42
|
+
*/
|
|
43
|
+
function compactHighlights(block) {
|
|
44
|
+
const lines = block.split("\n");
|
|
45
|
+
const headlines = [];
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
const m = line.match(/^###\s+(.+?)\s*$/);
|
|
48
|
+
if (!m)
|
|
49
|
+
continue;
|
|
50
|
+
// Strip leading emoji/punctuation like "๐ Feature: ..."
|
|
51
|
+
const title = m[1].replace(/^[^a-zA-Z0-9]+/, "").replace(/\s+/g, " ").trim();
|
|
52
|
+
if (title)
|
|
53
|
+
headlines.push(title);
|
|
54
|
+
}
|
|
55
|
+
let bullets;
|
|
56
|
+
if (headlines.length > 0) {
|
|
57
|
+
bullets = headlines.slice(0, MAX_BULLETS);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
// Fallback: grab the first non-empty lines (skip bold marker paragraphs,
|
|
61
|
+
// keep narrative). Limit to MAX_BULLETS lines.
|
|
62
|
+
const flat = lines
|
|
63
|
+
.map((l) => l.trim())
|
|
64
|
+
.filter((l) => l.length > 0 && !l.startsWith("```") && !l.startsWith("|"));
|
|
65
|
+
bullets = flat.slice(0, MAX_BULLETS);
|
|
66
|
+
}
|
|
67
|
+
const rendered = bullets.map((b) => `โข ${b}`).join("\n");
|
|
68
|
+
if (rendered.length <= MAX_CHARS)
|
|
69
|
+
return rendered;
|
|
70
|
+
// Trim to fit โ add a soft ellipsis on a whole line
|
|
71
|
+
let out = "";
|
|
72
|
+
for (const b of bullets) {
|
|
73
|
+
const next = out ? out + "\nโข " + b : "โข " + b;
|
|
74
|
+
if (next.length > MAX_CHARS - 4)
|
|
75
|
+
break;
|
|
76
|
+
out = next;
|
|
77
|
+
}
|
|
78
|
+
return out + "\nโฆ";
|
|
79
|
+
}
|
package/dist/services/updater.js
CHANGED
|
@@ -272,6 +272,7 @@ export function startAutoUpdateLoop() {
|
|
|
272
272
|
console.log(`[auto-update] check failed: ${result.message}`);
|
|
273
273
|
}
|
|
274
274
|
}, AUTO_CHECK_INTERVAL_MS);
|
|
275
|
+
autoTimer.unref?.();
|
|
275
276
|
console.log(`[auto-update] loop started (interval: 6h)`);
|
|
276
277
|
}
|
|
277
278
|
export function stopAutoUpdateLoop() {
|