@manybot/manybot 5.7.0 → 5.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/README.md +28 -3
- package/dist/client/banner.js +10 -0
- package/dist/client/banner.test.js +31 -0
- package/dist/client/store.js +91 -6
- package/dist/client/store.test.js +170 -0
- package/dist/config.js +28 -44
- package/dist/config.test.js +26 -0
- package/dist/download/queue.js +13 -4
- package/dist/drivers/baileys/adapter.js +133 -15
- package/dist/drivers/baileys/api/contacts.integration.test.js +261 -0
- package/dist/drivers/baileys/api/groupMeta.test.js +235 -0
- package/dist/drivers/baileys/api/index.js +384 -62
- package/dist/drivers/baileys/index.js +92 -36
- package/dist/drivers/baileys/loginPrompt.js +0 -2
- package/dist/drivers/baileys/messageHandler.js +344 -4
- package/dist/drivers/baileys/messageHandler.test.js +445 -0
- package/dist/drivers/baileysAdapter.test.js +378 -0
- package/dist/drivers/jid.js +26 -0
- package/dist/drivers/jid.test.js +74 -0
- package/dist/drivers/types.js +5 -5
- package/dist/i18n/index.js +20 -24
- package/dist/kernel/activeDriverSend.js +21 -0
- package/dist/kernel/activeDriverSend.test.js +89 -0
- package/dist/kernel/alerts.js +3 -9
- package/dist/kernel/chatOverrides.js +46 -0
- package/dist/kernel/chatOverrides.test.js +59 -0
- package/dist/kernel/chatSession.js +65 -0
- package/dist/kernel/chatSession.test.js +46 -0
- package/dist/kernel/commandAccess.js +66 -0
- package/dist/kernel/commandAccess.test.js +74 -0
- package/dist/kernel/commandDeprecation.js +170 -0
- package/dist/kernel/commandDeprecation.test.js +114 -0
- package/dist/kernel/commandMenu.js +357 -0
- package/dist/kernel/commandMenu.test.js +363 -0
- package/dist/kernel/commandPermissions.js +171 -0
- package/dist/kernel/commandPermissions.test.js +227 -0
- package/dist/kernel/commandRegistry.js +583 -0
- package/dist/kernel/commandRegistry.test.js +158 -0
- package/dist/kernel/commandsConfig.js +949 -0
- package/dist/kernel/commandsConfig.test.js +482 -0
- package/dist/kernel/contactAutoSave.js +6 -6
- package/dist/kernel/contactAutoSave.test.js +87 -0
- package/dist/kernel/coreCommands.js +62 -0
- package/dist/kernel/driverManager.js +10 -6
- package/dist/kernel/driverManager.test.js +90 -0
- package/dist/kernel/integrationMode.js +88 -0
- package/dist/kernel/integrationMode.test.js +95 -0
- package/dist/kernel/loadIntegrationPlugin.test.js +67 -0
- package/dist/kernel/pluginApi.test.js +600 -0
- package/dist/kernel/pluginGuard.js +18 -13
- package/dist/kernel/pluginGuard.test.js +39 -0
- package/dist/kernel/pluginLoader.js +169 -11
- package/dist/kernel/pluginLoader.test.js +190 -0
- package/dist/kernel/runCommand.js +284 -0
- package/dist/kernel/runCommand.test.js +497 -0
- package/dist/kernel/sendFallbackGuard.js +19 -48
- package/dist/kernel/sendFallbackGuard.test.js +80 -0
- package/dist/kernel/sendGuard.js +38 -42
- package/dist/kernel/sendGuard.test.js +102 -0
- package/dist/kernel/settingsDb.js +19 -5
- package/dist/kernel/statusServer.js +9 -2
- package/dist/kernel/statusServer.test.js +70 -0
- package/dist/kernel/testConfig.js +192 -0
- package/dist/kernel/testConfig.test.js +181 -0
- package/dist/kernel/updateCheck.js +33 -10
- package/dist/locales/en.json +77 -13
- package/dist/locales/es.json +77 -13
- package/dist/locales/pt.json +77 -13
- package/dist/logger/logger.js +23 -3
- package/dist/logger/logger.test.js +45 -0
- package/dist/main.js +5 -76
- package/dist/plugins/__manybot_integration__/index.js +184 -0
- package/dist/plugins/__manybot_integration__/index.test.js +218 -0
- package/dist/utils/phoneNumber.js +83 -0
- package/dist/utils/phoneNumber.test.js +53 -0
- package/package.json +76 -18
- package/dist/drivers/whatsmeow/client.js +0 -252
- package/dist/drivers/whatsmeow/index.js +0 -79
- package/dist/drivers/whatsmeow/installer.js +0 -86
- package/dist/drivers/whatsmeow/supervisor.js +0 -328
- package/dist/drivers/whatsmeow/whatsmeow.proto +0 -64
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import test, { describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { sendWithFallback, SendFailedError } from "#kernel/sendFallbackGuard.js";
|
|
4
|
+
import { getDriverManager, _resetDriverManagerForTests } from "#kernel/driverManager.js";
|
|
5
|
+
function createMockDriver(name, ready = true, failsSend = false, failsVerify = false) {
|
|
6
|
+
const mockRef = (id) => ({ id, chatId: "123@c.us", timestamp: Date.now() });
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
isReady: () => ready,
|
|
10
|
+
sendText: async (jid, text) => {
|
|
11
|
+
if (failsSend)
|
|
12
|
+
throw new Error(`${name} sendText failed`);
|
|
13
|
+
return mockRef(`msg_${name}`);
|
|
14
|
+
},
|
|
15
|
+
getHistory: async () => {
|
|
16
|
+
if (failsVerify)
|
|
17
|
+
return [];
|
|
18
|
+
return [{ id: `msg_${name}`, fromMe: true }];
|
|
19
|
+
},
|
|
20
|
+
connect: async () => { },
|
|
21
|
+
disconnect: async () => { },
|
|
22
|
+
me: () => ({ id: "123@c.us" }),
|
|
23
|
+
sendImage: async () => mockRef("image"),
|
|
24
|
+
sendVideo: async () => mockRef("video"),
|
|
25
|
+
sendAudio: async () => mockRef("audio"),
|
|
26
|
+
sendDocument: async () => mockRef("doc"),
|
|
27
|
+
sendSticker: async () => mockRef("sticker"),
|
|
28
|
+
sendLocation: async () => mockRef("loc"),
|
|
29
|
+
sendContact: async () => mockRef("contact"),
|
|
30
|
+
sendReaction: async () => { },
|
|
31
|
+
sendPoll: async () => mockRef("poll"),
|
|
32
|
+
react: async () => { },
|
|
33
|
+
deleteMessage: async () => { },
|
|
34
|
+
editMessage: async () => { },
|
|
35
|
+
sendPresenceUpdate: async () => { },
|
|
36
|
+
readMessages: async () => { },
|
|
37
|
+
onWhatsApp: async () => null,
|
|
38
|
+
getBusinessProfile: async () => null,
|
|
39
|
+
profilePictureUrl: async () => null,
|
|
40
|
+
fetchStatus: async () => null,
|
|
41
|
+
updateBlockStatus: async () => { },
|
|
42
|
+
addOrEditContact: async () => { },
|
|
43
|
+
removeContact: async () => { },
|
|
44
|
+
groupMetadata: async () => ({ subject: "Test Group", participants: [] }),
|
|
45
|
+
groupParticipantsUpdate: async () => [],
|
|
46
|
+
groupUpdateSubject: async () => { },
|
|
47
|
+
groupUpdateDescription: async () => { },
|
|
48
|
+
groupInviteCode: async () => "",
|
|
49
|
+
groupRevokeInvite: async () => "",
|
|
50
|
+
updateProfilePicture: async () => { },
|
|
51
|
+
updateProfileName: async () => { },
|
|
52
|
+
updateProfileStatus: async () => { },
|
|
53
|
+
downloadMedia: async () => null,
|
|
54
|
+
on: () => () => { },
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
describe("kernel/sendFallbackGuard", () => {
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
_resetDriverManagerForTests();
|
|
60
|
+
});
|
|
61
|
+
test("delivers text via primary driver when healthy", async () => {
|
|
62
|
+
const dm = getDriverManager();
|
|
63
|
+
const primary = createMockDriver("baileys");
|
|
64
|
+
dm.register(primary, { isPrimary: true });
|
|
65
|
+
const ref = await sendWithFallback("5511999999999@c.us", "hello");
|
|
66
|
+
assert.equal(ref.id, "msg_baileys");
|
|
67
|
+
});
|
|
68
|
+
test("throws SendFailedError with no_fallback when send fails", async () => {
|
|
69
|
+
const dm = getDriverManager();
|
|
70
|
+
const primaryFailing = createMockDriver("baileys", true, true);
|
|
71
|
+
dm.register(primaryFailing, { isPrimary: true });
|
|
72
|
+
await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "no fallback"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
|
|
73
|
+
});
|
|
74
|
+
test("throws SendFailedError with no_fallback when verification fails", async () => {
|
|
75
|
+
const dm = getDriverManager();
|
|
76
|
+
const primaryFailing = createMockDriver("baileys", true, false, true);
|
|
77
|
+
dm.register(primaryFailing, { isPrimary: true });
|
|
78
|
+
await assert.rejects(async () => sendWithFallback("5511999999999@c.us", "verify fail"), (err) => err instanceof SendFailedError && err.reason === "no_fallback");
|
|
79
|
+
});
|
|
80
|
+
});
|
package/dist/kernel/sendGuard.js
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* 4. Chat-concurrency gate — caps how many different chats the bot can be
|
|
11
11
|
* actively answering at the same time
|
|
12
12
|
* 5. Edit throttle — jittered minimum gap + cap on edits per
|
|
13
|
-
* message
|
|
14
|
-
*
|
|
13
|
+
* message. Only active at SECURITY_LEVEL
|
|
14
|
+
* "high"; low/medium leave edit timing to the
|
|
15
|
+
* caller.
|
|
15
16
|
*
|
|
16
17
|
* All of the above scale with SECURITY_LEVEL ("low" | "medium" | "high").
|
|
17
18
|
* Higher levels are slower and more conservative — lower risk of WhatsApp's
|
|
@@ -28,8 +29,6 @@ const PROFILES = {
|
|
|
28
29
|
chatCooldownMs: 100,
|
|
29
30
|
jitterMs: { min: 30, max: 120 },
|
|
30
31
|
concurrency: Infinity,
|
|
31
|
-
editIntervalMs: { min: 800, max: 2000 },
|
|
32
|
-
maxEditsPerMessage: 20,
|
|
33
32
|
typingMaxMs: 2000,
|
|
34
33
|
},
|
|
35
34
|
medium: {
|
|
@@ -37,8 +36,6 @@ const PROFILES = {
|
|
|
37
36
|
chatCooldownMs: 150,
|
|
38
37
|
jitterMs: { min: 50, max: 200 },
|
|
39
38
|
concurrency: 2,
|
|
40
|
-
editIntervalMs: { min: 1200, max: 3000 },
|
|
41
|
-
maxEditsPerMessage: 12,
|
|
42
39
|
typingMaxMs: 4000,
|
|
43
40
|
},
|
|
44
41
|
high: {
|
|
@@ -46,9 +43,11 @@ const PROFILES = {
|
|
|
46
43
|
chatCooldownMs: 400,
|
|
47
44
|
jitterMs: { min: 150, max: 500 },
|
|
48
45
|
concurrency: 1,
|
|
49
|
-
editIntervalMs: { min: 2000, max: 5000 },
|
|
50
|
-
maxEditsPerMessage: 6,
|
|
51
46
|
typingMaxMs: 8000,
|
|
47
|
+
editThrottle: {
|
|
48
|
+
minGapMs: { min: 800, max: 2000 },
|
|
49
|
+
maxEditsPerMessage: 5,
|
|
50
|
+
},
|
|
52
51
|
},
|
|
53
52
|
};
|
|
54
53
|
function currentProfile() {
|
|
@@ -147,40 +146,6 @@ export async function acquireChatSlot(jid) {
|
|
|
147
146
|
});
|
|
148
147
|
});
|
|
149
148
|
}
|
|
150
|
-
const editState = new Map();
|
|
151
|
-
const EDIT_STATE_STALE_MS = 10 * 60 * 1000;
|
|
152
|
-
function cleanupEditState(now) {
|
|
153
|
-
for (const [id, s] of editState) {
|
|
154
|
-
if (now - s.lastEditAt > EDIT_STATE_STALE_MS)
|
|
155
|
-
editState.delete(id);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
/**
|
|
159
|
-
* Waits for a safe edit slot for `messageId`, applying a jittered minimum
|
|
160
|
-
* gap since its last edit. Returns false once the message has hit its
|
|
161
|
-
* per-level edit cap — callers should skip the edit silently in that case.
|
|
162
|
-
* @param {string} messageId
|
|
163
|
-
* @returns {Promise<boolean>} true if the edit may proceed
|
|
164
|
-
*/
|
|
165
|
-
export async function waitForEditSlot(messageId) {
|
|
166
|
-
const now = Date.now();
|
|
167
|
-
cleanupEditState(now);
|
|
168
|
-
const profile = currentProfile();
|
|
169
|
-
const s = editState.get(messageId) ?? { lastEditAt: 0, count: 0 };
|
|
170
|
-
if (s.count >= profile.maxEditsPerMessage) {
|
|
171
|
-
editState.set(messageId, s);
|
|
172
|
-
logger.debug(`[sendGuard] edit cap reached for ${messageId}`);
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
const minGap = randomBetween(profile.editIntervalMs);
|
|
176
|
-
const wait = s.lastEditAt + minGap - Date.now();
|
|
177
|
-
if (wait > 0)
|
|
178
|
-
await sleep(wait);
|
|
179
|
-
s.lastEditAt = Date.now();
|
|
180
|
-
s.count += 1;
|
|
181
|
-
editState.set(messageId, s);
|
|
182
|
-
return true;
|
|
183
|
-
}
|
|
184
149
|
// ── Public API ────────────────────────────────────────────────────────────────
|
|
185
150
|
/**
|
|
186
151
|
* Wait for a safe send slot: global rate → per-chat cooldown → jitter.
|
|
@@ -208,6 +173,37 @@ export async function waitForSendSlot(jid, { cooldown = true, jitter = true } =
|
|
|
208
173
|
await sleep(randomBetween(currentProfile().jitterMs));
|
|
209
174
|
recordSend(jid);
|
|
210
175
|
}
|
|
176
|
+
// ── Edit throttle ─────────────────────────────────────────────────────────────
|
|
177
|
+
// Only enforced when the active profile defines `editThrottle` (currently
|
|
178
|
+
// just "high"). low/medium always allow immediately.
|
|
179
|
+
const editState = new Map();
|
|
180
|
+
/**
|
|
181
|
+
* Wait for a safe edit slot for `messageId`, then record the edit.
|
|
182
|
+
* Returns `false` if the per-message edit cap has been reached — the
|
|
183
|
+
* caller should drop the edit instead of sending it.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} messageId
|
|
186
|
+
* @returns {Promise<boolean>} whether the edit is allowed to proceed
|
|
187
|
+
*/
|
|
188
|
+
export async function waitForEditSlot(messageId) {
|
|
189
|
+
const throttle = currentProfile().editThrottle;
|
|
190
|
+
if (!throttle)
|
|
191
|
+
return true;
|
|
192
|
+
const state = editState.get(messageId) ?? { count: 0, lastEditAt: 0 };
|
|
193
|
+
if (state.count >= throttle.maxEditsPerMessage) {
|
|
194
|
+
logger.debug(`[sendGuard] edit cap (${throttle.maxEditsPerMessage}) reached for message ${messageId} — dropping edit`);
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const gap = randomBetween(throttle.minGapMs);
|
|
198
|
+
const elapsed = Date.now() - state.lastEditAt;
|
|
199
|
+
if (state.lastEditAt > 0 && elapsed < gap) {
|
|
200
|
+
await sleep(gap - elapsed);
|
|
201
|
+
}
|
|
202
|
+
state.count++;
|
|
203
|
+
state.lastEditAt = Date.now();
|
|
204
|
+
editState.set(messageId, state);
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
211
207
|
/**
|
|
212
208
|
* Show a presence indicator for `ms` milliseconds, then clear it.
|
|
213
209
|
* Best-effort — errors are swallowed.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import test, { describe, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { CONFIG } from "#config";
|
|
4
|
+
import { typingDuration, mediaDuration, acquireChatSlot, simulateState, waitForSendSlot } from "#kernel/sendGuard.js";
|
|
5
|
+
describe("kernel/sendGuard", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
CONFIG.SECURITY_LEVEL = "medium";
|
|
8
|
+
});
|
|
9
|
+
describe("typingDuration", () => {
|
|
10
|
+
test("returns 0 for empty or invalid text", () => {
|
|
11
|
+
assert.equal(typingDuration(""), 0);
|
|
12
|
+
assert.equal(typingDuration(null), 0);
|
|
13
|
+
});
|
|
14
|
+
test("calculates duration based on CPS and caps at profile typingMaxMs", () => {
|
|
15
|
+
CONFIG.SECURITY_LEVEL = "medium"; // typingMaxMs = 4000
|
|
16
|
+
// 90 chars at 90 CPS = 1000 ms
|
|
17
|
+
const text90 = "a".repeat(90);
|
|
18
|
+
assert.equal(typingDuration(text90), 1000);
|
|
19
|
+
// 900 chars at 90 CPS = 10000 ms -> capped at 4000 ms
|
|
20
|
+
const text900 = "a".repeat(900);
|
|
21
|
+
assert.equal(typingDuration(text900), 4000);
|
|
22
|
+
});
|
|
23
|
+
test("respects SECURITY_LEVEL profile caps", () => {
|
|
24
|
+
const text900 = "a".repeat(900);
|
|
25
|
+
CONFIG.SECURITY_LEVEL = "low"; // cap = 2000
|
|
26
|
+
assert.equal(typingDuration(text900), 2000);
|
|
27
|
+
CONFIG.SECURITY_LEVEL = "high"; // cap = 8000
|
|
28
|
+
assert.equal(typingDuration(text900), 8000);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
describe("mediaDuration", () => {
|
|
32
|
+
test("returns base jitter range when no caption is provided", () => {
|
|
33
|
+
const duration = mediaDuration();
|
|
34
|
+
assert.ok(duration >= 400 && duration <= 1000);
|
|
35
|
+
});
|
|
36
|
+
test("adds typing duration when caption is provided", () => {
|
|
37
|
+
CONFIG.SECURITY_LEVEL = "medium";
|
|
38
|
+
const text90 = "a".repeat(90); // 1000ms typing
|
|
39
|
+
const duration = mediaDuration(text90);
|
|
40
|
+
assert.ok(duration >= 1400 && duration <= 2000);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("acquireChatSlot (concurrency gate)", () => {
|
|
44
|
+
test("allows up to profile concurrency before blocking", async () => {
|
|
45
|
+
CONFIG.SECURITY_LEVEL = "high"; // concurrency = 1
|
|
46
|
+
const release1 = await acquireChatSlot("chat1");
|
|
47
|
+
let slot2Acquired = false;
|
|
48
|
+
const promise2 = acquireChatSlot("chat2").then(rel => {
|
|
49
|
+
slot2Acquired = true;
|
|
50
|
+
return rel;
|
|
51
|
+
});
|
|
52
|
+
// Give microtask tick to verify slot2 is waiting
|
|
53
|
+
await new Promise(r => setImmediate(r));
|
|
54
|
+
assert.equal(slot2Acquired, false);
|
|
55
|
+
// Release slot 1 allows slot 2 to proceed
|
|
56
|
+
release1();
|
|
57
|
+
const release2 = await promise2;
|
|
58
|
+
assert.equal(slot2Acquired, true);
|
|
59
|
+
release2();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe("simulateState", () => {
|
|
63
|
+
test("sends presence update composing/recording, waits, then sends paused", async (t) => {
|
|
64
|
+
t.mock.timers.enable({ apis: ["setTimeout"] });
|
|
65
|
+
const updates = [];
|
|
66
|
+
const mockContract = {
|
|
67
|
+
sendPresenceUpdate: async (state, jid) => {
|
|
68
|
+
updates.push({ state, jid });
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const simPromise = simulateState(mockContract, "123@c.us", 1000, "typing");
|
|
72
|
+
// simulateState awaits the first presence update before scheduling its
|
|
73
|
+
// timeout, so let that continuation run before advancing mock time.
|
|
74
|
+
await Promise.resolve();
|
|
75
|
+
t.mock.timers.tick(1000);
|
|
76
|
+
await simPromise;
|
|
77
|
+
assert.deepEqual(updates, [
|
|
78
|
+
{ state: "composing", jid: "123@c.us" },
|
|
79
|
+
{ state: "paused", jid: "123@c.us" }
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
test("handles non-fatal contract errors gracefully", async () => {
|
|
83
|
+
const failingContract = {
|
|
84
|
+
sendPresenceUpdate: async () => {
|
|
85
|
+
throw new Error("Network error");
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
// Should not throw exception
|
|
89
|
+
await assert.doesNotReject(async () => {
|
|
90
|
+
await simulateState(failingContract, "123@c.us", 100, "typing");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe("waitForSendSlot", () => {
|
|
95
|
+
test("completes send throttle without errors", async (t) => {
|
|
96
|
+
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
|
97
|
+
const sendPromise = waitForSendSlot("123@c.us", { cooldown: false, jitter: false });
|
|
98
|
+
t.mock.timers.tick(500);
|
|
99
|
+
await sendPromise;
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -14,9 +14,10 @@ import { DatabaseSync } from "node:sqlite";
|
|
|
14
14
|
import path from "path";
|
|
15
15
|
import { mkdirSync } from "fs";
|
|
16
16
|
import { CONFIG_DIR } from "#config";
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
17
|
+
const DB_PATH = process.env.NODE_ENV === "test" ? ":memory:" : path.join(CONFIG_DIR, "settings.db");
|
|
18
|
+
if (DB_PATH !== ":memory:") {
|
|
19
|
+
mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
20
|
+
}
|
|
20
21
|
const db = new DatabaseSync(DB_PATH);
|
|
21
22
|
db.exec("PRAGMA journal_mode = WAL");
|
|
22
23
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -93,6 +94,17 @@ function dbDelete(pluginName, chatId, key) {
|
|
|
93
94
|
function dbDeleteAll(pluginName, chatId) {
|
|
94
95
|
stmts.deleteAll.run(pluginName, chatId);
|
|
95
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Direct read of a single (plugin, chat, key) setting, bypassing the
|
|
99
|
+
* `ctx.settings` scoped-accessor pattern. For call sites that need a
|
|
100
|
+
* value before a `PluginContext` exists yet — e.g. resolving the
|
|
101
|
+
* per-chat command prefix while still parsing the incoming message,
|
|
102
|
+
* or resolving the per-chat language from outside the "core" plugin's
|
|
103
|
+
* own context. See `kernel/chatOverrides.ts`.
|
|
104
|
+
*/
|
|
105
|
+
export function getPluginSetting(pluginName, chatId, key) {
|
|
106
|
+
return dbGet(pluginName, chatId, key);
|
|
107
|
+
}
|
|
96
108
|
// ── Scoped accessor factory ───────────────────────────────────────────────────
|
|
97
109
|
/**
|
|
98
110
|
* Returns a settings accessor for a specific (pluginName, chatId) pair.
|
|
@@ -105,9 +117,11 @@ function scopedAccessor(pluginName, chatId) {
|
|
|
105
117
|
* @param {string} key
|
|
106
118
|
* @param {*} [defaultValue]
|
|
107
119
|
*/
|
|
108
|
-
get(key, defaultValue
|
|
120
|
+
get(key, defaultValue) {
|
|
109
121
|
const val = dbGet(pluginName, chatId, key);
|
|
110
|
-
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- `get<T>` is a
|
|
123
|
+
// type-level convenience for callers; the store itself is untyped JSON.
|
|
124
|
+
return (val !== undefined ? val : defaultValue);
|
|
111
125
|
},
|
|
112
126
|
/**
|
|
113
127
|
* Get all settings for this chat as a plain object.
|
|
@@ -11,11 +11,17 @@ let status = {
|
|
|
11
11
|
since: new Date().toISOString(),
|
|
12
12
|
};
|
|
13
13
|
export function setStatus(online, lastError) {
|
|
14
|
-
|
|
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,192 @@
|
|
|
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
|
+
* This module accepts any chat shape `normalizeTestChat()` allows,
|
|
16
|
+
* including a group (`@g.us`) — it has no opinion on what a given
|
|
17
|
+
* test file actually needs. Individual suites are the ones with
|
|
18
|
+
* that requirement: contacts.integration.test.ts, for instance,
|
|
19
|
+
* needs an individual chat (a phone number, or a JID ending in
|
|
20
|
+
* `@s.whatsapp.net`/`@c.us`/`@lid`) because it asserts on
|
|
21
|
+
* per-person contact fields (LID, number, country) that a group
|
|
22
|
+
* simply doesn't have — see that file's own header for details.
|
|
23
|
+
*
|
|
24
|
+
* 2. `runWhatsApp` — explicit opt-in flag (env `MANYBOT_RUN_WHATSAPP_TESTS=1`).
|
|
25
|
+
* A saved WhatsApp session plus a `TEST_CHAT` is NOT enough to fire real
|
|
26
|
+
* messages; this is the single, deliberate signal that the operator
|
|
27
|
+
* actually wants the integration suite to run.
|
|
28
|
+
*
|
|
29
|
+
* The module never throws on its own. `getTestConfig()` returns the
|
|
30
|
+
* resolved state and lets the caller decide what to do (skip vs run vs
|
|
31
|
+
* fail). `requireTestConfig()` is the hard version for code paths that
|
|
32
|
+
* must not run without a configured chat + opt-in.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately kept out of `CONFIG` — `TEST_CHAT` is a test-time
|
|
35
|
+
* concern and shouldn't pollute the runtime config object's shape.
|
|
36
|
+
*/
|
|
37
|
+
import fs from "fs/promises";
|
|
38
|
+
import { parse as parseToml } from "smol-toml";
|
|
39
|
+
import { CONFIG_DIR, TOML_CONFIG_FILE } from "#config";
|
|
40
|
+
import { logger } from "#logger";
|
|
41
|
+
// ── Constants ───────────────────────────────────────────────────────────────
|
|
42
|
+
/** Env var that signals "yes, the integration suite should really run". */
|
|
43
|
+
export const RUN_WHATSAPP_TESTS_ENV = "MANYBOT_RUN_WHATSAPP_TESTS";
|
|
44
|
+
/** Env var that overrides any value set in manybot.toml. */
|
|
45
|
+
export const TEST_CHAT_ENV = "TEST_CHAT";
|
|
46
|
+
/** Key read from manybot.toml as a fallback when the env var is unset. */
|
|
47
|
+
export const TEST_CHAT_TOML_KEY = "TEST_CHAT";
|
|
48
|
+
// ── JID normalization ──────────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Acceptable chat-id shapes:
|
|
51
|
+
* - bare number: "5516999999999"
|
|
52
|
+
* - WhatsApp PN JID: "5516999999999@s.whatsapp.net"
|
|
53
|
+
* - legacy framework PN JID: "5516999999999@c.us"
|
|
54
|
+
* - LID JID: "1234@lid"
|
|
55
|
+
* - group JID: "120363…@g.us"
|
|
56
|
+
*
|
|
57
|
+
* Any other suffix is rejected so the integration plugin can rely on
|
|
58
|
+
* `chat.endsWith(...)` checks and not silently mismatch. Bare numbers
|
|
59
|
+
* are normalized to the WhatsApp PN JID form (the form the bot's own
|
|
60
|
+
* contract uses to reach that contact).
|
|
61
|
+
*/
|
|
62
|
+
export function normalizeTestChat(raw) {
|
|
63
|
+
if (typeof raw !== "string") {
|
|
64
|
+
throw new TypeError(`TEST_CHAT must be a string, got ${typeof raw}`);
|
|
65
|
+
}
|
|
66
|
+
const trimmed = raw.trim();
|
|
67
|
+
if (trimmed === "") {
|
|
68
|
+
throw new Error("TEST_CHAT is empty");
|
|
69
|
+
}
|
|
70
|
+
const ALLOWED_SUFFIXES = ["@s.whatsapp.net", "@c.us", "@lid", "@g.us"];
|
|
71
|
+
for (const suffix of ALLOWED_SUFFIXES) {
|
|
72
|
+
if (trimmed.endsWith(suffix)) {
|
|
73
|
+
const local = trimmed.slice(0, -suffix.length);
|
|
74
|
+
if (local === "" || /[^\dA-Za-z._-]/.test(local)) {
|
|
75
|
+
throw new Error(`TEST_CHAT has invalid local part: "${raw}"`);
|
|
76
|
+
}
|
|
77
|
+
return trimmed;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// Bare number — accept digits, plus, and the leading "+".
|
|
81
|
+
if (/^\+?\d+$/.test(trimmed)) {
|
|
82
|
+
return `${trimmed.replace(/^\+/, "")}@s.whatsapp.net`;
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`TEST_CHAT must be a bare phone number or a JID with one of: ` +
|
|
85
|
+
`${ALLOWED_SUFFIXES.join(", ")} — got "${raw}"`);
|
|
86
|
+
}
|
|
87
|
+
// ── Resolution ──────────────────────────────────────────────────────────────
|
|
88
|
+
async function readTomlTestChat() {
|
|
89
|
+
let raw;
|
|
90
|
+
try {
|
|
91
|
+
raw = await fs.readFile(TOML_CONFIG_FILE, "utf-8");
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (e.code !== "ENOENT") {
|
|
95
|
+
logger.warn(`[testConfig] could not read ${TOML_CONFIG_FILE}: ${e.message}`);
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
let parsed;
|
|
100
|
+
try {
|
|
101
|
+
parsed = parseToml(raw);
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
logger.warn(`[testConfig] invalid TOML in ${TOML_CONFIG_FILE}: ${e.message}`);
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const value = parsed[TEST_CHAT_TOML_KEY];
|
|
108
|
+
if (value === undefined || value === null)
|
|
109
|
+
return null;
|
|
110
|
+
if (typeof value !== "string") {
|
|
111
|
+
logger.warn(`[testConfig] ${TEST_CHAT_TOML_KEY} in TOML is not a string, ignoring`);
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return value.trim() === "" ? null : value;
|
|
115
|
+
}
|
|
116
|
+
let cached = null;
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the test configuration. Result is cached after the first call
|
|
119
|
+
* because the env and `manybot.toml` don't change mid-process; the
|
|
120
|
+
* cache gives every test a stable view without re-reading disk.
|
|
121
|
+
*/
|
|
122
|
+
export async function getTestConfig() {
|
|
123
|
+
if (cached)
|
|
124
|
+
return cached;
|
|
125
|
+
const envValue = process.env[TEST_CHAT_ENV];
|
|
126
|
+
let raw = null;
|
|
127
|
+
let source = null;
|
|
128
|
+
if (typeof envValue === "string" && envValue.trim() !== "") {
|
|
129
|
+
raw = envValue;
|
|
130
|
+
source = "env";
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const tomlValue = await readTomlTestChat();
|
|
134
|
+
if (tomlValue) {
|
|
135
|
+
raw = tomlValue;
|
|
136
|
+
source = "toml";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
let chat = null;
|
|
140
|
+
if (raw !== null) {
|
|
141
|
+
try {
|
|
142
|
+
chat = normalizeTestChat(raw);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
logger.warn(`[testConfig] ${e.message}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const runWhatsApp = process.env[RUN_WHATSAPP_TESTS_ENV] === "1";
|
|
149
|
+
let skipReason = null;
|
|
150
|
+
if (chat === null) {
|
|
151
|
+
skipReason =
|
|
152
|
+
`TEST_CHAT is not set (env ${TEST_CHAT_ENV} or key ` +
|
|
153
|
+
`${TEST_CHAT_TOML_KEY} in ${TOML_CONFIG_FILE})`;
|
|
154
|
+
}
|
|
155
|
+
else if (!runWhatsApp) {
|
|
156
|
+
skipReason =
|
|
157
|
+
`${RUN_WHATSAPP_TESTS_ENV}=1 is required to run the WhatsApp ` +
|
|
158
|
+
`integration suite (TEST_CHAT alone is not enough)`;
|
|
159
|
+
}
|
|
160
|
+
cached = { chat, source, runWhatsApp, skipReason };
|
|
161
|
+
return cached;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Hard version of {@link getTestConfig}: throws if the chat is not
|
|
165
|
+
* configured or the opt-in flag is missing. Use this in code paths that
|
|
166
|
+
* should never run unless the operator has consciously opted in.
|
|
167
|
+
*/
|
|
168
|
+
export async function requireTestConfig() {
|
|
169
|
+
const cfg = await getTestConfig();
|
|
170
|
+
if (cfg.chat === null) {
|
|
171
|
+
throw new Error(`[testConfig] cannot run: ${cfg.skipReason}. ` +
|
|
172
|
+
`Set ${TEST_CHAT_ENV} or add '${TEST_CHAT_TOML_KEY} = "…"' to ${TOML_CONFIG_FILE}.`);
|
|
173
|
+
}
|
|
174
|
+
if (!cfg.runWhatsApp) {
|
|
175
|
+
throw new Error(`[testConfig] cannot run without opt-in: ${cfg.skipReason}. ` +
|
|
176
|
+
`Re-run with ${RUN_WHATSAPP_TESTS_ENV}=1.`);
|
|
177
|
+
}
|
|
178
|
+
return cfg;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Test-only — drops the cached value so the next `getTestConfig()`
|
|
182
|
+
* call re-reads env and disk. Use this after mutating env vars in a
|
|
183
|
+
* test; production code must not call it.
|
|
184
|
+
*/
|
|
185
|
+
export function _resetTestConfigForTests() {
|
|
186
|
+
cached = null;
|
|
187
|
+
}
|
|
188
|
+
// Keep `CONFIG_DIR` referenced so this module participates in the
|
|
189
|
+
// project's path-resolution behavior the same way other kernel modules
|
|
190
|
+
// do, and so future test fixtures that need to point at a different
|
|
191
|
+
// config dir (e.g. MANYBOT_CONFIG_DIR) keep working.
|
|
192
|
+
void CONFIG_DIR;
|