alvin-bot 4.8.8 → 4.9.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 +72 -0
- package/dist/handlers/message.js +5 -2
- package/dist/index.js +14 -10
- package/dist/paths.js +2 -0
- package/dist/platforms/whatsapp-auth-helpers.js +53 -0
- package/dist/platforms/whatsapp.js +6 -2
- package/dist/services/browser-manager.js +470 -95
- package/dist/services/browser-webfetch.js +93 -0
- package/dist/services/cron-scheduling.js +142 -0
- package/dist/services/cron.js +32 -6
- package/dist/services/skills.js +15 -11
- package/dist/services/subagent-delivery.js +8 -2
- package/dist/services/subagents.js +49 -8
- package/dist/services/telegram.js +12 -3
- package/dist/services/watchdog-brake.js +113 -0
- package/dist/services/watchdog.js +56 -42
- package/dist/util/console-formatter.js +109 -0
- package/dist/util/debounce.js +24 -0
- package/dist/util/telegram-error-filter.js +62 -0
- package/dist/web/server.js +56 -0
- package/package.json +1 -1
- package/skills/browse/SKILL.md +123 -98
- package/test/browser-webfetch.test.ts +121 -0
- package/test/console-timestamps.test.ts +98 -0
- package/test/cron-restart-resilience.test.ts +191 -0
- package/test/debounce.test.ts +60 -0
- package/test/subagent-final-text.test.ts +132 -0
- package/test/telegram-error-filter.test.ts +85 -0
- package/test/watchdog-brake.test.ts +157 -0
- package/test/web-server-shutdown.test.ts +111 -0
- package/test/whatsapp-auth-resilience.test.ts +96 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #1 — Web server must release port on shutdown.
|
|
3
|
+
*
|
|
4
|
+
* Regression: on bot restart the previous http.Server kept listening on
|
|
5
|
+
* :3100 (because shutdown() never called server.close()). launchd then
|
|
6
|
+
* restarted the bot, the next boot tried server.listen(3100), hit
|
|
7
|
+
* EADDRINUSE, and the bot crashed uncaught. Crash-loop.
|
|
8
|
+
*
|
|
9
|
+
* Contract we're establishing:
|
|
10
|
+
* - src/web/server.ts must export `stopWebServer(server, opts?)`
|
|
11
|
+
* - It must resolve once `server.close()` finishes.
|
|
12
|
+
* - It must force-close idle/active sockets so close() can't hang
|
|
13
|
+
* forever (otherwise shutdown would block on the 5s launchd grace).
|
|
14
|
+
* - After stopWebServer() returns, a fresh http.Server must be able
|
|
15
|
+
* to listen(port) on the same port without EADDRINUSE.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect } from "vitest";
|
|
18
|
+
import http from "http";
|
|
19
|
+
import { once } from "events";
|
|
20
|
+
import { startWebServer, stopWebServer } from "../src/web/server.js";
|
|
21
|
+
|
|
22
|
+
function getFreePort(): Promise<number> {
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const s = http.createServer();
|
|
25
|
+
s.listen(0, () => {
|
|
26
|
+
const addr = s.address();
|
|
27
|
+
if (typeof addr === "object" && addr) {
|
|
28
|
+
const p = addr.port;
|
|
29
|
+
s.close(() => resolve(p));
|
|
30
|
+
} else {
|
|
31
|
+
reject(new Error("no address"));
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("stopWebServer (Fix #1)", () => {
|
|
38
|
+
it("closes an http.Server so the port becomes reusable", async () => {
|
|
39
|
+
const port = await getFreePort();
|
|
40
|
+
|
|
41
|
+
const server = http.createServer((_req, res) => {
|
|
42
|
+
res.end("ok");
|
|
43
|
+
});
|
|
44
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
45
|
+
|
|
46
|
+
// Hold an open idle keep-alive socket — this is the exact state that
|
|
47
|
+
// prevented server.close() from resolving in production. A real
|
|
48
|
+
// stopWebServer() must break this stall.
|
|
49
|
+
const hanger = http.get(`http://127.0.0.1:${port}/`, () => { /* body */ });
|
|
50
|
+
await once(hanger, "response").catch(() => { /* swallow */ });
|
|
51
|
+
|
|
52
|
+
const t0 = Date.now();
|
|
53
|
+
await stopWebServer(server);
|
|
54
|
+
const elapsed = Date.now() - t0;
|
|
55
|
+
|
|
56
|
+
expect(elapsed).toBeLessThan(2000);
|
|
57
|
+
|
|
58
|
+
// Prove the port is actually free: a new server must be able to bind it.
|
|
59
|
+
const reuse = http.createServer();
|
|
60
|
+
await new Promise<void>((resolve, reject) => {
|
|
61
|
+
reuse.once("error", reject);
|
|
62
|
+
reuse.listen(port, () => resolve());
|
|
63
|
+
});
|
|
64
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("is safe to call on an already-closed server", async () => {
|
|
68
|
+
const port = await getFreePort();
|
|
69
|
+
const server = http.createServer();
|
|
70
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
71
|
+
await stopWebServer(server);
|
|
72
|
+
// Calling twice must not throw
|
|
73
|
+
await expect(stopWebServer(server)).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is safe to call on a server that never listened", async () => {
|
|
77
|
+
const server = http.createServer();
|
|
78
|
+
await expect(stopWebServer(server)).resolves.toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("closes even when a client is holding a long-lived connection", async () => {
|
|
82
|
+
// Production-mirror: a long-polling /api/cron?wait=1 or similar.
|
|
83
|
+
// Before the fix, server.close() would hang on these sockets and the
|
|
84
|
+
// 5s launchd grace would kill the bot before the port was released.
|
|
85
|
+
const port = await getFreePort();
|
|
86
|
+
const server = http.createServer((_req, res) => {
|
|
87
|
+
// Never send a full response — keep the socket open until close.
|
|
88
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
89
|
+
res.write("chunk-1");
|
|
90
|
+
// intentionally do NOT call res.end()
|
|
91
|
+
});
|
|
92
|
+
await new Promise<void>((r) => server.listen(port, () => r()));
|
|
93
|
+
|
|
94
|
+
const req = http.get(`http://127.0.0.1:${port}/hang`);
|
|
95
|
+
// Swallow the inevitable socket-close error once the server is torn down
|
|
96
|
+
req.on("error", () => { /* expected */ });
|
|
97
|
+
await once(req, "response").catch(() => { /* swallow */ });
|
|
98
|
+
|
|
99
|
+
const t0 = Date.now();
|
|
100
|
+
await stopWebServer(server);
|
|
101
|
+
expect(Date.now() - t0).toBeLessThan(2000);
|
|
102
|
+
|
|
103
|
+
// Port is reusable
|
|
104
|
+
const reuse = http.createServer();
|
|
105
|
+
await new Promise<void>((resolve, reject) => {
|
|
106
|
+
reuse.once("error", reject);
|
|
107
|
+
reuse.listen(port, () => resolve());
|
|
108
|
+
});
|
|
109
|
+
await new Promise<void>((r) => reuse.close(() => r()));
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fix #2 — WhatsApp saveCreds must survive a vanished auth directory.
|
|
3
|
+
*
|
|
4
|
+
* Regression: `Unhandled rejection: ENOENT creds.json` in err.log when
|
|
5
|
+
* baileys fired a delayed `creds.update` event after the auth dir was
|
|
6
|
+
* gone (crash mid-init, trash, manual cleanup, etc.).
|
|
7
|
+
*
|
|
8
|
+
* Contract: we export a helper `makeResilientSaveCreds(authDir, inner)`
|
|
9
|
+
* from src/platforms/whatsapp-auth-helpers.ts. It wraps baileys' raw
|
|
10
|
+
* saveCreds so that an ENOENT triggers a mkdir-p + one retry before
|
|
11
|
+
* surfacing the error. Any other error bubbles up unchanged.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
14
|
+
import fs from "fs";
|
|
15
|
+
import os from "os";
|
|
16
|
+
import { resolve, join } from "path";
|
|
17
|
+
import { makeResilientSaveCreds } from "../src/platforms/whatsapp-auth-helpers.js";
|
|
18
|
+
|
|
19
|
+
let authDir: string;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
authDir = resolve(os.tmpdir(), `alvin-wa-auth-${process.pid}-${Date.now()}`);
|
|
23
|
+
fs.mkdirSync(authDir, { recursive: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (fs.existsSync(authDir)) fs.rmSync(authDir, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("makeResilientSaveCreds (Fix #2)", () => {
|
|
31
|
+
it("calls the inner saveCreds on the happy path", async () => {
|
|
32
|
+
let calls = 0;
|
|
33
|
+
const inner = async () => { calls++; };
|
|
34
|
+
const wrapped = makeResilientSaveCreds(authDir, inner);
|
|
35
|
+
await wrapped();
|
|
36
|
+
expect(calls).toBe(1);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("recreates the auth dir and retries when inner throws ENOENT", async () => {
|
|
40
|
+
let calls = 0;
|
|
41
|
+
const inner = async () => {
|
|
42
|
+
calls++;
|
|
43
|
+
if (calls === 1) {
|
|
44
|
+
// Mirror baileys fs.promises.writeFile behaviour
|
|
45
|
+
const err = new Error(
|
|
46
|
+
`ENOENT: no such file or directory, open '${join(authDir, "creds.json")}'`,
|
|
47
|
+
) as NodeJS.ErrnoException;
|
|
48
|
+
err.code = "ENOENT";
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
// Simulate the vanished dir
|
|
53
|
+
fs.rmSync(authDir, { recursive: true, force: true });
|
|
54
|
+
expect(fs.existsSync(authDir)).toBe(false);
|
|
55
|
+
|
|
56
|
+
const wrapped = makeResilientSaveCreds(authDir, inner);
|
|
57
|
+
await wrapped();
|
|
58
|
+
|
|
59
|
+
expect(calls).toBe(2);
|
|
60
|
+
expect(fs.existsSync(authDir)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("only retries once — a second ENOENT surfaces as error", async () => {
|
|
64
|
+
let calls = 0;
|
|
65
|
+
const inner = async () => {
|
|
66
|
+
calls++;
|
|
67
|
+
const err = new Error("ENOENT: no such file or directory") as NodeJS.ErrnoException;
|
|
68
|
+
err.code = "ENOENT";
|
|
69
|
+
throw err;
|
|
70
|
+
};
|
|
71
|
+
const wrapped = makeResilientSaveCreds(authDir, inner);
|
|
72
|
+
await expect(wrapped()).rejects.toThrow(/ENOENT/);
|
|
73
|
+
expect(calls).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("surfaces non-ENOENT errors unchanged", async () => {
|
|
77
|
+
const inner = async () => {
|
|
78
|
+
const err = new Error("EACCES: permission denied") as NodeJS.ErrnoException;
|
|
79
|
+
err.code = "EACCES";
|
|
80
|
+
throw err;
|
|
81
|
+
};
|
|
82
|
+
const wrapped = makeResilientSaveCreds(authDir, inner);
|
|
83
|
+
await expect(wrapped()).rejects.toThrow(/EACCES/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("is safe to call concurrently", async () => {
|
|
87
|
+
let calls = 0;
|
|
88
|
+
const inner = async () => {
|
|
89
|
+
calls++;
|
|
90
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
91
|
+
};
|
|
92
|
+
const wrapped = makeResilientSaveCreds(authDir, inner);
|
|
93
|
+
await Promise.all([wrapped(), wrapped(), wrapped()]);
|
|
94
|
+
expect(calls).toBe(3);
|
|
95
|
+
});
|
|
96
|
+
});
|