@manybot/manybot 5.7.0 → 5.8.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 (71) hide show
  1. package/README.md +20 -3
  2. package/dist/client/banner.js +10 -0
  3. package/dist/client/banner.test.js +31 -0
  4. package/dist/client/store.js +56 -5
  5. package/dist/client/store.test.js +170 -0
  6. package/dist/config.js +28 -44
  7. package/dist/config.test.js +26 -0
  8. package/dist/drivers/baileys/adapter.js +58 -7
  9. package/dist/drivers/baileys/api/index.js +172 -24
  10. package/dist/drivers/baileys/index.js +62 -30
  11. package/dist/drivers/baileys/loginPrompt.js +0 -2
  12. package/dist/drivers/baileys/messageHandler.js +158 -4
  13. package/dist/drivers/baileys/messageHandler.test.js +203 -0
  14. package/dist/drivers/baileysAdapter.test.js +281 -0
  15. package/dist/drivers/jid.test.js +40 -0
  16. package/dist/drivers/types.js +5 -5
  17. package/dist/i18n/index.js +15 -2
  18. package/dist/kernel/activeDriverSend.js +21 -0
  19. package/dist/kernel/activeDriverSend.test.js +89 -0
  20. package/dist/kernel/alerts.js +3 -9
  21. package/dist/kernel/chatSession.js +65 -0
  22. package/dist/kernel/chatSession.test.js +46 -0
  23. package/dist/kernel/commandAccess.js +66 -0
  24. package/dist/kernel/commandAccess.test.js +74 -0
  25. package/dist/kernel/commandDeprecation.js +168 -0
  26. package/dist/kernel/commandDeprecation.test.js +107 -0
  27. package/dist/kernel/commandMenu.js +268 -0
  28. package/dist/kernel/commandMenu.test.js +234 -0
  29. package/dist/kernel/commandPermissions.js +125 -0
  30. package/dist/kernel/commandPermissions.test.js +159 -0
  31. package/dist/kernel/commandRegistry.js +459 -0
  32. package/dist/kernel/commandRegistry.test.js +156 -0
  33. package/dist/kernel/commandsConfig.js +517 -0
  34. package/dist/kernel/commandsConfig.test.js +236 -0
  35. package/dist/kernel/contactAutoSave.test.js +87 -0
  36. package/dist/kernel/driverManager.js +10 -6
  37. package/dist/kernel/driverManager.test.js +90 -0
  38. package/dist/kernel/integrationMode.js +88 -0
  39. package/dist/kernel/integrationMode.test.js +95 -0
  40. package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
  41. package/dist/kernel/pluginApi.test.js +583 -0
  42. package/dist/kernel/pluginGuard.js +15 -12
  43. package/dist/kernel/pluginGuard.test.js +39 -0
  44. package/dist/kernel/pluginLoader.js +96 -1
  45. package/dist/kernel/pluginLoader.test.js +80 -0
  46. package/dist/kernel/runCommand.js +245 -0
  47. package/dist/kernel/runCommand.test.js +235 -0
  48. package/dist/kernel/sendFallbackGuard.js +19 -48
  49. package/dist/kernel/sendFallbackGuard.test.js +80 -0
  50. package/dist/kernel/sendGuard.js +38 -42
  51. package/dist/kernel/sendGuard.test.js +102 -0
  52. package/dist/kernel/settingsDb.js +4 -3
  53. package/dist/kernel/statusServer.js +9 -2
  54. package/dist/kernel/statusServer.test.js +70 -0
  55. package/dist/kernel/testConfig.js +183 -0
  56. package/dist/kernel/testConfig.test.js +181 -0
  57. package/dist/kernel/updateCheck.js +33 -10
  58. package/dist/locales/en.json +64 -13
  59. package/dist/locales/es.json +64 -13
  60. package/dist/locales/pt.json +64 -13
  61. package/dist/logger/logger.js +23 -3
  62. package/dist/logger/logger.test.js +45 -0
  63. package/dist/main.js +5 -76
  64. package/dist/plugins/__manybot_integration__/index.js +167 -0
  65. package/dist/plugins/__manybot_integration__/index.test.js +184 -0
  66. package/package.json +74 -17
  67. package/dist/drivers/whatsmeow/client.js +0 -252
  68. package/dist/drivers/whatsmeow/index.js +0 -79
  69. package/dist/drivers/whatsmeow/installer.js +0 -86
  70. package/dist/drivers/whatsmeow/supervisor.js +0 -328
  71. package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
@@ -11,11 +11,17 @@ let status = {
11
11
  since: new Date().toISOString(),
12
12
  };
13
13
  export function setStatus(online, lastError) {
14
- if (status.online === online)
14
+ // Same-state updates are a no-op for `online` / `since` so polling the
15
+ // status page doesn't see the timestamp flicker on every redundant
16
+ // call (the connection.update listener fires more than once per
17
+ // reconnect). An explicit `lastError` always wins — if the caller is
18
+ // reporting a new failure while we're already marked offline, that
19
+ // message is more useful than the stale one from before.
20
+ if (status.online === online && !lastError)
15
21
  return;
16
22
  status = {
17
23
  online,
18
- since: new Date().toISOString(),
24
+ since: status.online === online ? status.since : new Date().toISOString(),
19
25
  ...(lastError ? { lastError } : {}),
20
26
  };
21
27
  }
@@ -36,4 +42,5 @@ export function startStatusServer(port) {
36
42
  server.listen(port, () => {
37
43
  logger.info(`[status] JSON endpoint em http://localhost:${port}`);
38
44
  });
45
+ return server;
39
46
  }
@@ -0,0 +1,70 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, test } from "node:test";
3
+ import { getStatus, setStatus, startStatusServer } from "#kernel/statusServer.js";
4
+ describe("kernel/statusServer", () => {
5
+ let activeServer = null;
6
+ afterEach(async () => {
7
+ if (activeServer) {
8
+ await new Promise((resolve) => activeServer.close(() => resolve()));
9
+ activeServer = null;
10
+ }
11
+ // Reset status back to offline
12
+ setStatus(false);
13
+ });
14
+ test("getStatus returns initial state", () => {
15
+ const status = getStatus();
16
+ assert.equal(typeof status.online, "boolean");
17
+ assert.equal(typeof status.since, "string");
18
+ });
19
+ test("setStatus updates online state and timestamps", async () => {
20
+ setStatus(false, "Initial error");
21
+ const initial = getStatus();
22
+ assert.equal(initial.online, false);
23
+ assert.equal(initial.lastError, "Initial error");
24
+ // Allow timestamp to advance
25
+ await new Promise((r) => setTimeout(r, 10));
26
+ setStatus(true);
27
+ const updated = getStatus();
28
+ assert.equal(updated.online, true);
29
+ assert.equal(updated.lastError, undefined);
30
+ assert.notEqual(updated.since, initial.since);
31
+ // Setting same status is a no-op for since timestamp
32
+ const sinceBefore = updated.since;
33
+ setStatus(true);
34
+ assert.equal(getStatus().since, sinceBefore);
35
+ });
36
+ test("setStatus captures error message when going offline", () => {
37
+ setStatus(true);
38
+ setStatus(false, "Stream closed");
39
+ const status = getStatus();
40
+ assert.equal(status.online, false);
41
+ assert.equal(status.lastError, "Stream closed");
42
+ });
43
+ test("startStatusServer responds with JSON status and CORS headers", async () => {
44
+ setStatus(true);
45
+ activeServer = startStatusServer(0);
46
+ // Wait until the server is listening
47
+ await new Promise((resolve) => {
48
+ if (activeServer.listening)
49
+ resolve();
50
+ else
51
+ activeServer.once("listening", () => resolve());
52
+ });
53
+ const addr = activeServer.address();
54
+ const url = `http://127.0.0.1:${addr.port}/status`;
55
+ const res = await fetch(url);
56
+ assert.equal(res.status, 200);
57
+ assert.equal(res.headers.get("content-type"), "application/json");
58
+ assert.equal(res.headers.get("access-control-allow-origin"), "*");
59
+ const body = (await res.json());
60
+ assert.equal(body.online, true);
61
+ assert.equal(typeof body.since, "string");
62
+ assert.equal(body.lastError, undefined);
63
+ // Mutate status and verify dynamic response on subsequent request
64
+ setStatus(false, "Socket hung up");
65
+ const res2 = await fetch(url);
66
+ const body2 = (await res2.json());
67
+ assert.equal(body2.online, false);
68
+ assert.equal(body2.lastError, "Socket hung up");
69
+ });
70
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * kernel/testConfig.ts
3
+ *
4
+ * Reads the configuration that gates the WhatsApp integration test suite.
5
+ *
6
+ * Two values are exposed:
7
+ *
8
+ * 1. `chat` — the JID (or bare phone number) of the chat the integration
9
+ * suite is allowed to exercise. Comes from, in precedence order:
10
+ * a. environment variable `TEST_CHAT`
11
+ * b. `TEST_CHAT` key in `manybot.toml`
12
+ * c. otherwise absent (`chat === null`) — integration tests skip
13
+ * with an explanatory message instead of crashing.
14
+ *
15
+ * 2. `runWhatsApp` — explicit opt-in flag (env `MANYBOT_RUN_WHATSAPP_TESTS=1`).
16
+ * A saved WhatsApp session plus a `TEST_CHAT` is NOT enough to fire real
17
+ * messages; this is the single, deliberate signal that the operator
18
+ * actually wants the integration suite to run.
19
+ *
20
+ * The module never throws on its own. `getTestConfig()` returns the
21
+ * resolved state and lets the caller decide what to do (skip vs run vs
22
+ * fail). `requireTestConfig()` is the hard version for code paths that
23
+ * must not run without a configured chat + opt-in.
24
+ *
25
+ * Deliberately kept out of `CONFIG` — `TEST_CHAT` is a test-time
26
+ * concern and shouldn't pollute the runtime config object's shape.
27
+ */
28
+ import fs from "fs/promises";
29
+ import { parse as parseToml } from "smol-toml";
30
+ import { CONFIG_DIR, TOML_CONFIG_FILE } from "#config";
31
+ import { logger } from "#logger";
32
+ // ── Constants ───────────────────────────────────────────────────────────────
33
+ /** Env var that signals "yes, the integration suite should really run". */
34
+ export const RUN_WHATSAPP_TESTS_ENV = "MANYBOT_RUN_WHATSAPP_TESTS";
35
+ /** Env var that overrides any value set in manybot.toml. */
36
+ export const TEST_CHAT_ENV = "TEST_CHAT";
37
+ /** Key read from manybot.toml as a fallback when the env var is unset. */
38
+ export const TEST_CHAT_TOML_KEY = "TEST_CHAT";
39
+ // ── JID normalization ──────────────────────────────────────────────────────
40
+ /**
41
+ * Acceptable chat-id shapes:
42
+ * - bare number: "5516999999999"
43
+ * - WhatsApp PN JID: "5516999999999@s.whatsapp.net"
44
+ * - legacy framework PN JID: "5516999999999@c.us"
45
+ * - LID JID: "1234@lid"
46
+ * - group JID: "120363…@g.us"
47
+ *
48
+ * Any other suffix is rejected so the integration plugin can rely on
49
+ * `chat.endsWith(...)` checks and not silently mismatch. Bare numbers
50
+ * are normalized to the WhatsApp PN JID form (the form the bot's own
51
+ * contract uses to reach that contact).
52
+ */
53
+ export function normalizeTestChat(raw) {
54
+ if (typeof raw !== "string") {
55
+ throw new TypeError(`TEST_CHAT must be a string, got ${typeof raw}`);
56
+ }
57
+ const trimmed = raw.trim();
58
+ if (trimmed === "") {
59
+ throw new Error("TEST_CHAT is empty");
60
+ }
61
+ const ALLOWED_SUFFIXES = ["@s.whatsapp.net", "@c.us", "@lid", "@g.us"];
62
+ for (const suffix of ALLOWED_SUFFIXES) {
63
+ if (trimmed.endsWith(suffix)) {
64
+ const local = trimmed.slice(0, -suffix.length);
65
+ if (local === "" || /[^\dA-Za-z._-]/.test(local)) {
66
+ throw new Error(`TEST_CHAT has invalid local part: "${raw}"`);
67
+ }
68
+ return trimmed;
69
+ }
70
+ }
71
+ // Bare number — accept digits, plus, and the leading "+".
72
+ if (/^\+?\d+$/.test(trimmed)) {
73
+ return `${trimmed.replace(/^\+/, "")}@s.whatsapp.net`;
74
+ }
75
+ throw new Error(`TEST_CHAT must be a bare phone number or a JID with one of: ` +
76
+ `${ALLOWED_SUFFIXES.join(", ")} — got "${raw}"`);
77
+ }
78
+ // ── Resolution ──────────────────────────────────────────────────────────────
79
+ async function readTomlTestChat() {
80
+ let raw;
81
+ try {
82
+ raw = await fs.readFile(TOML_CONFIG_FILE, "utf-8");
83
+ }
84
+ catch (e) {
85
+ if (e.code !== "ENOENT") {
86
+ logger.warn(`[testConfig] could not read ${TOML_CONFIG_FILE}: ${e.message}`);
87
+ }
88
+ return null;
89
+ }
90
+ let parsed;
91
+ try {
92
+ parsed = parseToml(raw);
93
+ }
94
+ catch (e) {
95
+ logger.warn(`[testConfig] invalid TOML in ${TOML_CONFIG_FILE}: ${e.message}`);
96
+ return null;
97
+ }
98
+ const value = parsed[TEST_CHAT_TOML_KEY];
99
+ if (value === undefined || value === null)
100
+ return null;
101
+ if (typeof value !== "string") {
102
+ logger.warn(`[testConfig] ${TEST_CHAT_TOML_KEY} in TOML is not a string, ignoring`);
103
+ return null;
104
+ }
105
+ return value.trim() === "" ? null : value;
106
+ }
107
+ let cached = null;
108
+ /**
109
+ * Resolve the test configuration. Result is cached after the first call
110
+ * because the env and `manybot.toml` don't change mid-process; the
111
+ * cache gives every test a stable view without re-reading disk.
112
+ */
113
+ export async function getTestConfig() {
114
+ if (cached)
115
+ return cached;
116
+ const envValue = process.env[TEST_CHAT_ENV];
117
+ let raw = null;
118
+ let source = null;
119
+ if (typeof envValue === "string" && envValue.trim() !== "") {
120
+ raw = envValue;
121
+ source = "env";
122
+ }
123
+ else {
124
+ const tomlValue = await readTomlTestChat();
125
+ if (tomlValue) {
126
+ raw = tomlValue;
127
+ source = "toml";
128
+ }
129
+ }
130
+ let chat = null;
131
+ if (raw !== null) {
132
+ try {
133
+ chat = normalizeTestChat(raw);
134
+ }
135
+ catch (e) {
136
+ logger.warn(`[testConfig] ${e.message}`);
137
+ }
138
+ }
139
+ const runWhatsApp = process.env[RUN_WHATSAPP_TESTS_ENV] === "1";
140
+ let skipReason = null;
141
+ if (chat === null) {
142
+ skipReason =
143
+ `TEST_CHAT is not set (env ${TEST_CHAT_ENV} or key ` +
144
+ `${TEST_CHAT_TOML_KEY} in ${TOML_CONFIG_FILE})`;
145
+ }
146
+ else if (!runWhatsApp) {
147
+ skipReason =
148
+ `${RUN_WHATSAPP_TESTS_ENV}=1 is required to run the WhatsApp ` +
149
+ `integration suite (TEST_CHAT alone is not enough)`;
150
+ }
151
+ cached = { chat, source, runWhatsApp, skipReason };
152
+ return cached;
153
+ }
154
+ /**
155
+ * Hard version of {@link getTestConfig}: throws if the chat is not
156
+ * configured or the opt-in flag is missing. Use this in code paths that
157
+ * should never run unless the operator has consciously opted in.
158
+ */
159
+ export async function requireTestConfig() {
160
+ const cfg = await getTestConfig();
161
+ if (cfg.chat === null) {
162
+ throw new Error(`[testConfig] cannot run: ${cfg.skipReason}. ` +
163
+ `Set ${TEST_CHAT_ENV} or add '${TEST_CHAT_TOML_KEY} = "…"' to ${TOML_CONFIG_FILE}.`);
164
+ }
165
+ if (!cfg.runWhatsApp) {
166
+ throw new Error(`[testConfig] cannot run without opt-in: ${cfg.skipReason}. ` +
167
+ `Re-run with ${RUN_WHATSAPP_TESTS_ENV}=1.`);
168
+ }
169
+ return cfg;
170
+ }
171
+ /**
172
+ * Test-only — drops the cached value so the next `getTestConfig()`
173
+ * call re-reads env and disk. Use this after mutating env vars in a
174
+ * test; production code must not call it.
175
+ */
176
+ export function _resetTestConfigForTests() {
177
+ cached = null;
178
+ }
179
+ // Keep `CONFIG_DIR` referenced so this module participates in the
180
+ // project's path-resolution behavior the same way other kernel modules
181
+ // do, and so future test fixtures that need to point at a different
182
+ // config dir (e.g. MANYBOT_CONFIG_DIR) keep working.
183
+ void CONFIG_DIR;
@@ -0,0 +1,181 @@
1
+ import test, { describe, before, after, beforeEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "fs/promises";
4
+ import os from "os";
5
+ import path from "path";
6
+ const configDir = await fs.mkdtemp(path.join(os.tmpdir(), "manybot-test-config-"));
7
+ process.env.MANYBOT_CONFIG_DIR = configDir;
8
+ const { getTestConfig, requireTestConfig, normalizeTestChat, _resetTestConfigForTests, TEST_CHAT_ENV, RUN_WHATSAPP_TESTS_ENV, TEST_CHAT_TOML_KEY, } = await import("#kernel/testConfig.js");
9
+ const { TOML_CONFIG_FILE } = await import("#config");
10
+ const tomlPath = path.join(configDir, "manybot.toml");
11
+ async function writeToml(contents) {
12
+ await fs.writeFile(tomlPath, contents, "utf8");
13
+ }
14
+ function clearEnv() {
15
+ delete process.env[TEST_CHAT_ENV];
16
+ delete process.env[RUN_WHATSAPP_TESTS_ENV];
17
+ }
18
+ before(async () => {
19
+ await fs.writeFile(tomlPath, "", "utf8");
20
+ });
21
+ beforeEach(async () => {
22
+ clearEnv();
23
+ await writeToml("");
24
+ _resetTestConfigForTests();
25
+ });
26
+ after(async () => {
27
+ clearEnv();
28
+ await fs.rm(configDir, { recursive: true, force: true });
29
+ });
30
+ describe("kernel/testConfig — normalizeTestChat", () => {
31
+ test("accepts a bare number and normalizes to @s.whatsapp.net", () => {
32
+ assert.equal(normalizeTestChat("5516999999999"), "5516999999999@s.whatsapp.net");
33
+ });
34
+ test("accepts a leading + on bare numbers", () => {
35
+ assert.equal(normalizeTestChat("+5516999999999"), "5516999999999@s.whatsapp.net");
36
+ });
37
+ test("preserves @c.us", () => {
38
+ assert.equal(normalizeTestChat("5516999999999@c.us"), "5516999999999@c.us");
39
+ });
40
+ test("preserves @s.whatsapp.net", () => {
41
+ assert.equal(normalizeTestChat("5516999999999@s.whatsapp.net"), "5516999999999@s.whatsapp.net");
42
+ });
43
+ test("preserves @lid", () => {
44
+ assert.equal(normalizeTestChat("12345@lid"), "12345@lid");
45
+ });
46
+ test("preserves @g.us", () => {
47
+ assert.equal(normalizeTestChat("120363012345678@g.us"), "120363012345678@g.us");
48
+ });
49
+ test("trims surrounding whitespace", () => {
50
+ assert.equal(normalizeTestChat(" 5516999999999 "), "5516999999999@s.whatsapp.net");
51
+ });
52
+ test("rejects empty string", () => {
53
+ assert.throws(() => normalizeTestChat(""), /empty/);
54
+ });
55
+ test("rejects non-string input", () => {
56
+ assert.throws(() => normalizeTestChat(undefined), /string/);
57
+ });
58
+ test("rejects unknown suffix", () => {
59
+ assert.throws(() => normalizeTestChat("foo@example.com"), /JID/);
60
+ });
61
+ test("rejects JID with bad chars in the local part", () => {
62
+ assert.throws(() => normalizeTestChat("5516!@c.us"), /local part/);
63
+ });
64
+ });
65
+ describe("kernel/testConfig — getTestConfig", () => {
66
+ test("returns null chat and skipReason when nothing is configured", async () => {
67
+ const cfg = await getTestConfig();
68
+ assert.equal(cfg.chat, null);
69
+ assert.equal(cfg.source, null);
70
+ assert.equal(cfg.runWhatsApp, false);
71
+ assert.match(cfg.skipReason, /TEST_CHAT is not set/);
72
+ });
73
+ test("env wins over TOML", async () => {
74
+ await writeToml(`${TEST_CHAT_TOML_KEY} = "5516000000001"\n`);
75
+ process.env[TEST_CHAT_ENV] = "5516000000002";
76
+ const cfg = await getTestConfig();
77
+ assert.equal(cfg.chat, "5516000000002@s.whatsapp.net");
78
+ assert.equal(cfg.source, "env");
79
+ });
80
+ test("falls back to TOML when env is unset", async () => {
81
+ await writeToml(`${TEST_CHAT_TOML_KEY} = "5516000000003"\n`);
82
+ const cfg = await getTestConfig();
83
+ assert.equal(cfg.chat, "5516000000003@s.whatsapp.net");
84
+ assert.equal(cfg.source, "toml");
85
+ });
86
+ test("empty string in TOML is treated as absent", async () => {
87
+ await writeToml(`${TEST_CHAT_TOML_KEY} = ""\n`);
88
+ const cfg = await getTestConfig();
89
+ assert.equal(cfg.chat, null);
90
+ });
91
+ test("whitespace-only env is treated as absent", async () => {
92
+ process.env[TEST_CHAT_ENV] = " ";
93
+ const cfg = await getTestConfig();
94
+ assert.equal(cfg.chat, null);
95
+ });
96
+ test("invalid value in TOML is ignored (warning logged, not thrown)", async () => {
97
+ await writeToml(`${TEST_CHAT_TOML_KEY} = "not-a-valid-jid@x.com"\n`);
98
+ const cfg = await getTestConfig();
99
+ assert.equal(cfg.chat, null);
100
+ });
101
+ test("non-string TOML value is ignored", async () => {
102
+ await writeToml(`${TEST_CHAT_TOML_KEY} = 42\n`);
103
+ const cfg = await getTestConfig();
104
+ assert.equal(cfg.chat, null);
105
+ });
106
+ test("runWhatsApp is true only when env equals '1'", async () => {
107
+ process.env[TEST_CHAT_ENV] = "5516000000004";
108
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "1";
109
+ const on = await getTestConfig();
110
+ assert.equal(on.runWhatsApp, true);
111
+ _resetTestConfigForTests();
112
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "true";
113
+ const off1 = await getTestConfig();
114
+ assert.equal(off1.runWhatsApp, false);
115
+ _resetTestConfigForTests();
116
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "0";
117
+ const off2 = await getTestConfig();
118
+ assert.equal(off2.runWhatsApp, false);
119
+ _resetTestConfigForTests();
120
+ delete process.env[RUN_WHATSAPP_TESTS_ENV];
121
+ const off3 = await getTestConfig();
122
+ assert.equal(off3.runWhatsApp, false);
123
+ });
124
+ test("skipReason explains the missing opt-in even when TEST_CHAT is set", async () => {
125
+ await writeToml(`${TEST_CHAT_TOML_KEY} = "5516000000005"\n`);
126
+ const cfg = await getTestConfig();
127
+ assert.equal(cfg.chat, "5516000000005@s.whatsapp.net");
128
+ assert.equal(cfg.runWhatsApp, false);
129
+ assert.match(cfg.skipReason, /MANYBOT_RUN_WHATSAPP_TESTS=1/);
130
+ });
131
+ test("skipReason is null when everything is configured", async () => {
132
+ process.env[TEST_CHAT_ENV] = "5516000000006";
133
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "1";
134
+ const cfg = await getTestConfig();
135
+ assert.equal(cfg.skipReason, null);
136
+ });
137
+ test("result is cached across calls within the same process", async () => {
138
+ process.env[TEST_CHAT_ENV] = "5516000000007";
139
+ const first = await getTestConfig();
140
+ process.env[TEST_CHAT_ENV] = "5516000000008";
141
+ const second = await getTestConfig();
142
+ assert.equal(first.chat, second.chat, "cache must not re-read env");
143
+ _resetTestConfigForTests();
144
+ const third = await getTestConfig();
145
+ assert.equal(third.chat, "5516000000008@s.whatsapp.net");
146
+ });
147
+ test("missing manybot.toml is treated as 'no value'", async () => {
148
+ await fs.rm(tomlPath, { force: true });
149
+ const cfg = await getTestConfig();
150
+ assert.equal(cfg.chat, null);
151
+ assert.equal(cfg.source, null);
152
+ });
153
+ test("malformed TOML is logged and treated as 'no value'", async () => {
154
+ await fs.writeFile(tomlPath, "this is not valid = = toml =", "utf8");
155
+ const cfg = await getTestConfig();
156
+ assert.equal(cfg.chat, null);
157
+ });
158
+ });
159
+ describe("kernel/testConfig — requireTestConfig", () => {
160
+ test("returns config when fully configured", async () => {
161
+ process.env[TEST_CHAT_ENV] = "5516000000009";
162
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "1";
163
+ const cfg = await requireTestConfig();
164
+ assert.equal(cfg.chat, "5516000000009@s.whatsapp.net");
165
+ assert.equal(cfg.runWhatsApp, true);
166
+ });
167
+ test("throws when TEST_CHAT is missing", async () => {
168
+ process.env[RUN_WHATSAPP_TESTS_ENV] = "1";
169
+ await assert.rejects(requireTestConfig(), /TEST_CHAT is not set/);
170
+ });
171
+ test("throws when opt-in is missing", async () => {
172
+ process.env[TEST_CHAT_ENV] = "5516000000010";
173
+ await assert.rejects(requireTestConfig(), /MANYBOT_RUN_WHATSAPP_TESTS=1/);
174
+ });
175
+ });
176
+ // smoke: TOML_CONFIG_FILE should point inside the temp config dir
177
+ describe("kernel/testConfig — wiring", () => {
178
+ test("TOML_CONFIG_FILE respects MANYBOT_CONFIG_DIR", () => {
179
+ assert.equal(TOML_CONFIG_FILE, tomlPath);
180
+ });
181
+ });
@@ -2,13 +2,19 @@
2
2
  * updateCheck.ts
3
3
  *
4
4
  * Compares the locally installed manybot version against the latest
5
- * published on npm, and fires an "info" alert (via alerts.ts) when a
5
+ * GitHub Release and fires an "info" alert (via alerts.ts) when a
6
6
  * newer version is available. Runs once on startup and then on a
7
7
  * schedule — both configurable (UPDATE_CHECK_ENABLED,
8
8
  * UPDATE_CHECK_INTERVAL_HOURS).
9
9
  *
10
- * Never throws a failed check (offline, npm down) is logged at debug
11
- * level and silently skipped; it'll just try again next cycle.
10
+ * GitHub Releases is the source of truth for distribution: release
11
+ * candidate tags (-rc.N) skip npm entirely, and stable releases stay
12
+ * in "staged" on npm until a manual 2FA approval lands, so reading
13
+ * registry.npmjs.org here would either miss an already-published
14
+ * version or notify about one only after a long delay.
15
+ *
16
+ * Never throws — a failed check (offline, GitHub down) is logged at
17
+ * debug level and silently skipped; it'll just try again next cycle.
12
18
  */
13
19
  import { readFileSync } from "fs";
14
20
  import { fileURLToPath } from "url";
@@ -16,10 +22,11 @@ import path from "path";
16
22
  import { UPDATE_CHECK_ENABLED, UPDATE_CHECK_INTERVAL_HOURS } from "#config";
17
23
  import { sendAlert } from "#kernel/alerts.js";
18
24
  import { logger } from "#logger";
25
+ import { t } from "#i18n";
19
26
  const __filename = fileURLToPath(import.meta.url);
20
27
  const __dirname = path.dirname(__filename);
21
28
  const pkg = JSON.parse(readFileSync(path.join(__dirname, "../../package.json"), "utf8"));
22
- const REGISTRY_URL = `https://registry.npmjs.org/${encodeURIComponent(pkg.name)}/latest`;
29
+ const GITHUB_RELEASES_LATEST = "https://api.github.com/repos/many-bot/manybot/releases/latest";
23
30
  /** Naive semver compare — good enough for x.y.z, no pre-release handling. */
24
31
  function isNewer(latest, current) {
25
32
  const a = latest.split(".").map(Number);
@@ -34,6 +41,14 @@ function isNewer(latest, current) {
34
41
  }
35
42
  return false;
36
43
  }
44
+ /**
45
+ * GitHub tags come prefixed with "v" (e.g. "v5.7.0"). Strip it so we
46
+ * can compare against pkg.version directly and render the upgrade
47
+ * command without the prefix.
48
+ */
49
+ function stripTagPrefix(tag) {
50
+ return tag.startsWith("v") ? tag.slice(1) : tag;
51
+ }
37
52
  let alreadyNotifiedFor = null;
38
53
  /**
39
54
  * Runs a single check. Safe to call anytime (startup, interval, manual
@@ -43,23 +58,31 @@ export async function checkForUpdate() {
43
58
  if (!UPDATE_CHECK_ENABLED)
44
59
  return;
45
60
  try {
46
- const res = await fetch(REGISTRY_URL);
61
+ const res = await fetch(GITHUB_RELEASES_LATEST);
47
62
  if (!res.ok) {
48
- logger.debug(`[updateCheck] npm registry responded ${res.status}`);
63
+ logger.debug(`[updateCheck] GitHub Releases responded ${res.status}`);
49
64
  return;
50
65
  }
51
66
  const data = await res.json();
52
- const latest = data.version;
53
- if (!latest || !isNewer(latest, pkg.version))
67
+ const rawTag = data.tag_name;
68
+ if (!rawTag)
69
+ return;
70
+ const latest = stripTagPrefix(rawTag);
71
+ if (!isNewer(latest, pkg.version))
54
72
  return;
55
73
  // Don't re-alert every cycle for the same version once already notified.
56
74
  if (alreadyNotifiedFor === latest)
57
75
  return;
58
76
  alreadyNotifiedFor = latest;
77
+ const releaseUrl = `https://github.com/many-bot/manybot/releases/tag/${rawTag}`;
59
78
  await sendAlert({
60
79
  level: "info",
61
- title: "Nova versão do manybot disponível",
62
- message: `Instalada: ${pkg.version} → disponível: ${latest}. Rode "npm install -g ${pkg.name}@${latest}" (ou equivalente) para atualizar.`,
80
+ title: t("alerts.updateAvailableTitle"),
81
+ message: t("alerts.updateAvailableMessage", {
82
+ installed: pkg.version,
83
+ available: latest,
84
+ url: releaseUrl,
85
+ }),
63
86
  });
64
87
  }
65
88
  catch (e) {
@@ -36,13 +36,75 @@
36
36
  "cacheLoaded": "{{count}} chat(s) loaded from cache.",
37
37
  "cacheLoadedStale": "{{count}} chat(s) loaded from cache (stale).",
38
38
  "sendFailedNoFallback": "manybot: no fallback driver available (jid={{jid}}, primary={{driver}})",
39
- "sendFailedBothDrivers": "manybot: send failed on both drivers (jid={{jid}}, tried={{primary}} then {{secondary}})"
39
+ "sendFailedBothDrivers": "manybot: send failed on both drivers (jid={{jid}}, tried={{primary}} then {{secondary}})",
40
+ "commandsConfigMissingCmd": "commands.yaml: command \"{{id}}\" is missing required field \"cmd\" — skipped",
41
+ "commandRegistryInvalidPluginCommand": "commandRegistry: plugin \"{{plugin}}\" command \"{{function}}\" ({{id}}) has an invalid or missing \"cmd\" — skipped",
42
+ "commandsConfigArgumentMissingName": "commands.yaml: argument entry under \"{{id}}\" is missing required field \"name\" — skipped",
43
+ "commandsConfigArgumentMissingType": "commands.yaml: argument \"{{name}}\" under \"{{id}}\" is missing required field \"type\" — skipped",
44
+ "commandsConfigUnknownArgType": "commands.yaml: argument \"{{name}}\" under \"{{id}}\" has unknown type \"{{type}}\" — skipped",
45
+ "commandsConfigChoiceArgumentNoChoices": "commands.yaml: argument \"{{name}}\" under \"{{id}}\" is type \"choice\" but declared no choices — falling back to free placeholder in usage",
46
+ "commandsConfigArgumentsNotList": "commands.yaml: arguments under \"{{id}}\" must be a list — ignored",
47
+ "commandsConfigSubcommandMissingCmd": "commands.yaml: subcommand under \"{{id}}\" is missing required field \"cmd\" — skipped",
48
+ "commandsConfigSubcommandsNotList": "commands.yaml: subcommands under \"{{id}}\" must be a list — ignored",
49
+ "commandsConfigDuplicateSubcommand": "commands.yaml: duplicate subcommand \"{{cmd}}\" under \"{{id}}\" — keeping the first one",
50
+ "commandsConfigReadFailed": "commands.yaml: could not be read ({{path}}): {{message}}",
51
+ "commandsConfigParseFailed": "commands.yaml: YAML parse error: {{message}}",
52
+ "commandsConfigInvalidRoot": "commands.yaml: top-level must be a map of command entries",
53
+ "commandsConfigInvalidEntry": "commands.yaml: entry \"{{id}}\" is not an object — skipped",
54
+ "commandsConfigImportReadFailed": "commands.yaml: import \"{{path}}\" could not be read: {{message}}",
55
+ "commandsConfigImportParseFailed": "commands.yaml: import \"{{path}}\" YAML parse error: {{message}}",
56
+ "commandsConfigImportInvalidRoot": "commands.yaml: import \"{{path}}\" top-level must be a map of sections — skipped",
57
+ "commandsConfigImportNested": "commands.yaml: import \"{{path}}\" declares its own \"import\" — nested imports are not supported and were ignored",
58
+ "commandsConfigImportKeyConflict": "commands.yaml: import \"{{path}}\" redefines \"{{key}}\", already owned by \"{{owner}}\" — keeping the first one",
59
+ "commandRegistryOrphanEntry": "commandRegistry: entry \"{{id}}\" targets {{plugin}}.{{function}} but no active plugin provides it — skipped",
60
+ "commandRegistryInvalidEntry": "commandRegistry: entry \"{{id}}\" has no plugin/function and no text — skipped",
61
+ "commandRegistryInvocationCollision": "commandRegistry: invocation \"{{text}}\" already owned by \"{{winner}}\" — dropping {{loser}} ({{via}})",
62
+ "commandDeprecationRenamed": "commandRegistry: command \"{{id}}\" renamed from \"{{old}}\" to \"{{new}}\" — notifying old callers for {{days}} days",
63
+ "commandDeprecationRemoved": "commandRegistry: command \"{{id}}\" (cmd \"{{old}}\") was removed — notifying old callers for {{days}} days",
64
+ "commandDeprecationFallbackRenamed": "Command \"{{old}}\" was renamed to \"{{new}}\". Please update your message.",
65
+ "commandDeprecationFallbackRemoved": "Command \"{{old}}\" is no longer available.",
66
+ "commandDeprecationReservedInvocation": "commandRegistry: invocation \"{{text}}\" is reserved by active deprecation for \"{{id}}\" — refusing registration",
67
+ "commandManualMissing": "No detailed manual available for command \"{{cmd}}\".",
68
+ "commandNotFound": "Command or category \"{{cmd}}\" not found. Use {{prefix}}{{menuCmd}} to view the command list.",
69
+ "commandRegistryMenuAliasCollision": "commandRegistry: menu alias \"{{alias}}\" collided with real command \"{{winner}}\" — menu alias ignored"
40
70
  },
41
71
  "errors": {
42
72
  "stack": "Stack",
43
73
  "invalid_config": "Invalid config file {{file}}: {{error}}",
44
74
  "fix_config": "Fix the syntax in {{file}} before starting the bot."
45
75
  },
76
+ "menu": {
77
+ "intro": "Use {prefix}<command> to run it or {prefix}help <command> to view its manual.",
78
+ "other": "Other",
79
+ "category": "Category",
80
+ "manual": "Manual",
81
+ "description": "Description"
82
+ },
83
+ "commandPermissions": {
84
+ "botNotAdmin": "This command requires the bot to be a group administrator.",
85
+ "senderNotAdmin": "This command is restricted to group administrators.",
86
+ "ownerOnly": "This command can only be run by the bot owner.",
87
+ "wrongScope": "This command cannot be used in this context.",
88
+ "cooldown": "Wait {{seconds}}s before using this command again."
89
+ },
90
+ "commandRun": {
91
+ "missingRequiredArg": "Missing required argument(s).",
92
+ "unknownSubcommand": "Unknown subcommand \"{{sub}}\" for !{{cmd}}. Valid: {{valid}}."
93
+ },
94
+ "alerts": {
95
+ "noFallbackTitle": "manybot: no fallback driver",
96
+ "bothDriversFailedTitle": "manybot: sending failed on both drivers",
97
+ "updateAvailableTitle": "New manybot version available",
98
+ "updateAvailableMessage": "Installed: {{installed}} → available: {{available}}. See {{url}} for update instructions.",
99
+ "reconnectHaltedTitle": "manybot stopped reconnecting",
100
+ "reconnectHaltedMessage": "Gave up after {{attempts}} attempts — possible account restriction. Run connect() manually to retry."
101
+ },
102
+ "driver": {
103
+ "groupParticipantNotFound": "\"{{id}}\" does not match a current participant in group \"{{group}}\" (check whether the person is still in the group, or use a reply/mention instead of typing the number).",
104
+ "groupParticipantsUpdateRejected": "groupParticipantsUpdate(\"{{action}}\") rejected for: {{detail}}",
105
+ "groupParticipantsUpdateFailed": "groupParticipantsUpdate(\"{{action}}\") failed for group \"{{group}}\" with participants [{{users}}]: {{message}}",
106
+ "pollDecryptFailed": "Failed to decrypt poll vote: {{error}}"
107
+ },
46
108
  "onboarding": {
47
109
  "intro": "ManyBot — first login",
48
110
  "methodPrompt": "How do you want to connect your WhatsApp account?",
@@ -69,17 +131,6 @@
69
131
  "retrying": "Retrying ({{attempt}}/{{max}})...",
70
132
  "sessionWiped": "Session discarded, pairing again ({{round}}/{{max}})...",
71
133
  "connectGaveUp": "Couldn't connect after several attempts. Check your network and try again."
72
- },
73
- "whatsmeow": {
74
- "installPrompt": "Install whatsmeow driver for fallback support? (EXPERIMENTAL — only text send & history work; other methods throw)",
75
- "unsupportedArch": "whatsmeow driver not available for {{os}}-{{arch}}. The bot will use Baileys only.",
76
- "fetchingTag": "Fetching latest whatsmeow release...",
77
- "fetchFailed": "Could not reach Codeberg. Skipping whatsmeow install.",
78
- "downloading": "Downloading {{url}}...",
79
- "downloadFailed": "Download failed: {{reason}}",
80
- "installTitle": "whatsmeow driver",
81
- "installed": "whatsmeow driver installed at {{path}}",
82
- "restartNotice": "Restart the bot for the whatsmeow driver to take effect.",
83
- "experimentalNotice": "whatsmeow is EXPERIMENTAL: only sendText and getHistory are implemented. sendImage, sendPoll, groupMetadata, and other methods throw."
84
134
  }
85
135
  }
136
+