@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
package/README.md
CHANGED
|
@@ -18,8 +18,6 @@ Open-source framework for message automation, extensible via community plugins.
|
|
|
18
18
|
|
|
19
19
|
- Node.js >= 24
|
|
20
20
|
- npm >= 9
|
|
21
|
-
- Go >= 1.23 (for whatsmeow driver)
|
|
22
|
-
- protoc (for protobuf codegen)
|
|
23
21
|
|
|
24
22
|
## Getting started
|
|
25
23
|
|
|
@@ -39,7 +37,6 @@ On first run, a configuration file is created at `~/.manybot/manybot.toml`. Edit
|
|
|
39
37
|
git clone <repo-url>
|
|
40
38
|
cd dev
|
|
41
39
|
npm install
|
|
42
|
-
go build -o whatsmeow-service/bin/whatsmeow-service ./whatsmeow-service
|
|
43
40
|
npm start
|
|
44
41
|
```
|
|
45
42
|
|
|
@@ -55,6 +52,34 @@ manyplug install <plugin-name>
|
|
|
55
52
|
|
|
56
53
|
Browse available plugins at **[manybot.org/plugins](https://manybot.org/plugins/)**.
|
|
57
54
|
|
|
55
|
+
## Testing
|
|
56
|
+
|
|
57
|
+
ManyBot includes both local unit/contract tests and opt-in real WhatsApp integration tests:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Run unit and mock contract test suite (fast, offline)
|
|
61
|
+
npm test
|
|
62
|
+
|
|
63
|
+
# Run all verification gates (typecheck + lint + unit tests)
|
|
64
|
+
npm run check
|
|
65
|
+
|
|
66
|
+
# Run real WhatsApp integration tests against a live account.
|
|
67
|
+
# Requires: TEST_CHAT, MANYBOT_RUN_WHATSAPP_TESTS=1, and a saved
|
|
68
|
+
# WhatsApp session in your CONFIG_DIR. The bare variant below
|
|
69
|
+
# always skips every test (no live socket). Use `:local` to boot
|
|
70
|
+
# the bot first — it preloads src/main.ts so the driver connects
|
|
71
|
+
# before the test runner kicks in.
|
|
72
|
+
TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 npm run test:integration:local
|
|
73
|
+
|
|
74
|
+
# Manual smoke probe: connects, runs a few marker round-trips, and
|
|
75
|
+
# prints the IContact shape (id / number / numberRaw / numberPretty /
|
|
76
|
+
# country / countryCallingCode) returned by contacts.get(...) — useful
|
|
77
|
+
# for verifying the LID-aware contact API against your real account
|
|
78
|
+
# without the full test runner overhead.
|
|
79
|
+
TEST_CHAT="5516999999999" MANYBOT_RUN_WHATSAPP_TESTS=1 \
|
|
80
|
+
node --import ./src/main.ts scripts/probe-contacts.mjs
|
|
81
|
+
```
|
|
82
|
+
|
|
58
83
|
## Contributing
|
|
59
84
|
|
|
60
85
|
All kinds of contributions are welcome:
|
package/dist/client/banner.js
CHANGED
|
@@ -10,10 +10,20 @@ const C = {
|
|
|
10
10
|
import { readFileSync } from "fs";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
12
|
import path from "path";
|
|
13
|
+
import { getLogLevel } from "#logger";
|
|
13
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
15
|
const __dirname = path.dirname(__filename);
|
|
15
16
|
const v = JSON.parse(readFileSync(path.join(__dirname, "../../package.json"), "utf8")).version;
|
|
17
|
+
/**
|
|
18
|
+
* Startup ASCII banner. Optional — respects `LOG_LEVEL` (Phase 9): shown
|
|
19
|
+
* only at `"normal"`, silent at `"clean"`/`"minimal"` for users who prefer
|
|
20
|
+
* quiet logs. Caller is responsible for only invoking this once per process
|
|
21
|
+
* (see `drivers/baileys/index.ts`'s `bannerShown` guard) — reconnects
|
|
22
|
+
* shouldn't reprint it.
|
|
23
|
+
*/
|
|
16
24
|
export function printBanner() {
|
|
25
|
+
if (getLogLevel() !== "normal")
|
|
26
|
+
return;
|
|
17
27
|
const banner = [
|
|
18
28
|
` _ _ `,
|
|
19
29
|
` | | | | `,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import test, { describe, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { printBanner } from "./banner.js";
|
|
4
|
+
import { setLogLevel } from "#logger";
|
|
5
|
+
describe("printBanner LOG_LEVEL gating", () => {
|
|
6
|
+
const originalLog = console.log;
|
|
7
|
+
let lines;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
lines = [];
|
|
10
|
+
console.log = (...a) => { lines.push(a.join(" ")); };
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
console.log = originalLog;
|
|
14
|
+
setLogLevel("normal");
|
|
15
|
+
});
|
|
16
|
+
test("prints at normal", () => {
|
|
17
|
+
setLogLevel("normal");
|
|
18
|
+
printBanner();
|
|
19
|
+
assert.ok(lines.length > 0);
|
|
20
|
+
});
|
|
21
|
+
test("stays silent at clean", () => {
|
|
22
|
+
setLogLevel("clean");
|
|
23
|
+
printBanner();
|
|
24
|
+
assert.equal(lines.length, 0);
|
|
25
|
+
});
|
|
26
|
+
test("stays silent at minimal", () => {
|
|
27
|
+
setLogLevel("minimal");
|
|
28
|
+
printBanner();
|
|
29
|
+
assert.equal(lines.length, 0);
|
|
30
|
+
});
|
|
31
|
+
});
|
package/dist/client/store.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* @whiskeysockets/baileys import line never leaks past the driver
|
|
19
19
|
* boundary.
|
|
20
20
|
*/
|
|
21
|
+
import { normalizeJid } from "#drivers/jid.js";
|
|
21
22
|
// ── Factory ───────────────────────────────────────────────────────────────────
|
|
22
23
|
/**
|
|
23
24
|
* Create a new BotStore instance.
|
|
@@ -30,9 +31,23 @@ export function createStore() {
|
|
|
30
31
|
// @lid JID → traditional @s.whatsapp.net JID, learned from contact and
|
|
31
32
|
// message-key pairs (Baileys exposes both forms during the LID rollout).
|
|
32
33
|
const lidMap = new Map();
|
|
34
|
+
// traditional @s.whatsapp.net JID → @lid JID, reverse mapping for lookups.
|
|
35
|
+
const pnMap = new Map();
|
|
33
36
|
function learnLid(lid, pn) {
|
|
34
|
-
if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid"))
|
|
37
|
+
if (lid && pn && lid.endsWith("@lid") && !pn.endsWith("@lid")) {
|
|
38
|
+
// lidMap keeps the wire-format value as given — resolveJid()
|
|
39
|
+
// consumers (e.g. adapter.ts building self/participant candidates
|
|
40
|
+
// via jidNormalizedUser) expect the raw "@s.whatsapp.net" shape.
|
|
35
41
|
lidMap.set(lid, pn);
|
|
42
|
+
// pnMap's key is canonicalized to ManyBot's internal "@c.us" form
|
|
43
|
+
// so a lookup matches regardless of which format the caller has on
|
|
44
|
+
// hand — callers deep in the driver (adapter.ts) query it with raw
|
|
45
|
+
// wire JIDs, while callers in the plugin-facing API (index.ts) query
|
|
46
|
+
// it with already-normalized JIDs. Without this, pn entries learned
|
|
47
|
+
// in one format silently never matched a resolvePn() call made in
|
|
48
|
+
// the other.
|
|
49
|
+
pnMap.set(normalizeJid(pn), lid);
|
|
50
|
+
}
|
|
36
51
|
}
|
|
37
52
|
function resolveJid(jid) {
|
|
38
53
|
if (!jid || !jid.endsWith("@lid"))
|
|
@@ -40,15 +55,54 @@ export function createStore() {
|
|
|
40
55
|
return lidMap.get(jid) ?? jid;
|
|
41
56
|
}
|
|
42
57
|
function forgetLid(lid) {
|
|
58
|
+
const pn = lidMap.get(lid);
|
|
59
|
+
if (pn)
|
|
60
|
+
pnMap.delete(normalizeJid(pn));
|
|
43
61
|
lidMap.delete(lid);
|
|
44
62
|
}
|
|
63
|
+
function resolvePn(pn) {
|
|
64
|
+
if (!pn)
|
|
65
|
+
return null;
|
|
66
|
+
return pnMap.get(normalizeJid(pn)) ?? null;
|
|
67
|
+
}
|
|
68
|
+
// Coerce the various shapes Baileys delivers `ephemeralExpiration` in
|
|
69
|
+
// (string-numbered from the wire, `null` when the timer was cleared,
|
|
70
|
+
// or absent entirely) into a plain finite number. Anything we can't
|
|
71
|
+
// parse becomes 0 — the wire convention for "no disappearing timer".
|
|
72
|
+
function normalizeExpiration(raw) {
|
|
73
|
+
if (raw === null || raw === undefined)
|
|
74
|
+
return 0;
|
|
75
|
+
const n = Number(raw);
|
|
76
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
77
|
+
}
|
|
78
|
+
function setChatEphemeralExpiration(jid, seconds) {
|
|
79
|
+
if (!jid)
|
|
80
|
+
return;
|
|
81
|
+
const existing = chatsMap.get(jid);
|
|
82
|
+
const next = normalizeExpiration(seconds);
|
|
83
|
+
if (existing) {
|
|
84
|
+
existing.ephemeralExpiration = next;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
chatsMap.set(jid, { id: jid, name: jid.split("@")[0], ephemeralExpiration: next });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
45
90
|
// Max messages kept per chat (prevents unbounded memory growth)
|
|
46
91
|
const MAX_MSGS_PER_CHAT = 200;
|
|
47
92
|
function upsertChat(chat) {
|
|
48
93
|
if (!chat.id)
|
|
49
94
|
return;
|
|
50
95
|
const name = chat.name ?? chat.id.split("@")[0];
|
|
51
|
-
|
|
96
|
+
const rawExp = chat.ephemeralExpiration;
|
|
97
|
+
const existing = chatsMap.get(chat.id);
|
|
98
|
+
chatsMap.set(chat.id, {
|
|
99
|
+
id: chat.id,
|
|
100
|
+
name,
|
|
101
|
+
// Preserve a previously-learned timer if this upsert doesn't carry
|
|
102
|
+
// one (chats.upsert often ships without ephemeralExpiration — the
|
|
103
|
+
// value arrives later via chats.update or an ephemeralMessage).
|
|
104
|
+
ephemeralExpiration: rawExp !== undefined ? normalizeExpiration(rawExp) : (existing?.ephemeralExpiration ?? 0),
|
|
105
|
+
});
|
|
52
106
|
}
|
|
53
107
|
function upsertContact(contact) {
|
|
54
108
|
// Don't let a later, poorer sync round (e.g. a bare history-sync stub)
|
|
@@ -124,9 +178,17 @@ export function createStore() {
|
|
|
124
178
|
if (!update.id)
|
|
125
179
|
continue;
|
|
126
180
|
const existing = chatsMap.get(update.id);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
181
|
+
if (!existing)
|
|
182
|
+
continue;
|
|
183
|
+
const u = update;
|
|
184
|
+
if (u.name)
|
|
185
|
+
existing.name = u.name;
|
|
186
|
+
// Baileys sends `ephemeralExpiration: null` to signal the timer
|
|
187
|
+
// was cleared — store 0 in that case so a later `chats.update`
|
|
188
|
+
// carrying a positive value can take over again.
|
|
189
|
+
if ("ephemeralExpiration" in u) {
|
|
190
|
+
existing.ephemeralExpiration = normalizeExpiration(u.ephemeralExpiration);
|
|
191
|
+
}
|
|
130
192
|
}
|
|
131
193
|
});
|
|
132
194
|
ev.on("contacts.upsert", (newContacts) => {
|
|
@@ -144,8 +206,19 @@ export function createStore() {
|
|
|
144
206
|
}
|
|
145
207
|
});
|
|
146
208
|
ev.on("messages.upsert", ({ messages: msgs }) => {
|
|
147
|
-
for (const msg of msgs)
|
|
209
|
+
for (const msg of msgs) {
|
|
148
210
|
storeMessage(msg);
|
|
211
|
+
// Disappearing-message timer: when a message arrives wrapped in
|
|
212
|
+
// an `ephemeralMessage`, the inner contextInfo carries the
|
|
213
|
+
// per-message timer WhatsApp negotiated for the chat. Promote
|
|
214
|
+
// it to the chat-level entry so subsequent sends can pick it up
|
|
215
|
+
// without re-scanning every message.
|
|
216
|
+
const m = msg.message;
|
|
217
|
+
const expiration = m?.ephemeralMessage?.message?.extendedTextMessage?.contextInfo?.expiration;
|
|
218
|
+
if (expiration !== undefined && msg.key.remoteJid) {
|
|
219
|
+
setChatEphemeralExpiration(msg.key.remoteJid, normalizeExpiration(expiration));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
149
222
|
capturePushNames(msgs);
|
|
150
223
|
});
|
|
151
224
|
ev.on("messages.update", (updates) => {
|
|
@@ -173,6 +246,7 @@ export function createStore() {
|
|
|
173
246
|
chats: [...chatsMap.values()],
|
|
174
247
|
contacts: { ...contacts },
|
|
175
248
|
lidMap: [...lidMap.entries()],
|
|
249
|
+
pnMap: [...pnMap.entries()],
|
|
176
250
|
};
|
|
177
251
|
}
|
|
178
252
|
function hydrate(snapshot) {
|
|
@@ -181,6 +255,15 @@ export function createStore() {
|
|
|
181
255
|
for (const [id, contact] of Object.entries(snapshot.contacts ?? {})) {
|
|
182
256
|
contacts[id] = { ...contacts[id], ...contact };
|
|
183
257
|
}
|
|
258
|
+
// learnLid() always mirrors into both lidMap and pnMap, so replaying
|
|
259
|
+
// snapshot.lidMap alone fully reconstructs pnMap too — every pnMap
|
|
260
|
+
// entry has a corresponding lidMap entry, they're never written
|
|
261
|
+
// independently. snapshot.pnMap itself is redundant to replay here:
|
|
262
|
+
// it exists only so `toJSON()`'s output is self-describing/inspectable,
|
|
263
|
+
// and re-learning from it a second time would re-derive pnMap's key
|
|
264
|
+
// (already-canonicalized "@c.us" form) as if it were lidMap's raw
|
|
265
|
+
// wire-format value, corrupting the wire-format invariant lidMap's
|
|
266
|
+
// consumers (e.g. adapter.ts) depend on.
|
|
184
267
|
for (const [lid, pn] of snapshot.lidMap ?? [])
|
|
185
268
|
learnLid(lid, pn);
|
|
186
269
|
}
|
|
@@ -194,6 +277,8 @@ export function createStore() {
|
|
|
194
277
|
resolveJid,
|
|
195
278
|
learnLid,
|
|
196
279
|
forgetLid,
|
|
280
|
+
resolvePn,
|
|
281
|
+
setChatEphemeralExpiration,
|
|
197
282
|
bind,
|
|
198
283
|
toJSON,
|
|
199
284
|
hydrate,
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import { createStore } from "#client/store.js";
|
|
5
|
+
test("store tracks disappearing-message timers from chats and message context", () => {
|
|
6
|
+
const store = createStore();
|
|
7
|
+
const ev = new EventEmitter();
|
|
8
|
+
store.bind(ev);
|
|
9
|
+
ev.emit("chats.upsert", [{ id: "chat@s.whatsapp.net", name: "Chat", ephemeralExpiration: "86400" }]);
|
|
10
|
+
assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 86400);
|
|
11
|
+
ev.emit("chats.update", [{ id: "chat@s.whatsapp.net", ephemeralExpiration: null }]);
|
|
12
|
+
assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 0);
|
|
13
|
+
ev.emit("messages.upsert", {
|
|
14
|
+
messages: [{
|
|
15
|
+
key: { id: "message", remoteJid: "chat@s.whatsapp.net" },
|
|
16
|
+
message: {
|
|
17
|
+
ephemeralMessage: {
|
|
18
|
+
message: { extendedTextMessage: { contextInfo: { expiration: "604800" } } },
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
}],
|
|
22
|
+
});
|
|
23
|
+
assert.equal(store.chats.get("chat@s.whatsapp.net")?.ephemeralExpiration, 604800);
|
|
24
|
+
});
|
|
25
|
+
test("store keeps timers in snapshots and accepts a timer learned by metadata", () => {
|
|
26
|
+
const store = createStore();
|
|
27
|
+
store.setChatEphemeralExpiration("group@g.us", 7776000);
|
|
28
|
+
const snapshot = store.toJSON();
|
|
29
|
+
assert.equal(snapshot.chats[0]?.ephemeralExpiration, 7776000);
|
|
30
|
+
const hydrated = createStore();
|
|
31
|
+
hydrated.hydrate(snapshot);
|
|
32
|
+
assert.equal(hydrated.chats.get("group@g.us")?.ephemeralExpiration, 7776000);
|
|
33
|
+
});
|
|
34
|
+
test("store learns, resolves, and forgets LID to PN mappings", () => {
|
|
35
|
+
const store = createStore();
|
|
36
|
+
// Invalid or identity mappings are ignored
|
|
37
|
+
store.learnLid("invalid_jid", "551199999999@s.whatsapp.net");
|
|
38
|
+
store.learnLid("123@lid", "456@lid");
|
|
39
|
+
assert.equal(store.resolveJid("invalid_jid"), "invalid_jid");
|
|
40
|
+
assert.equal(store.resolveJid("123@lid"), "123@lid");
|
|
41
|
+
// Valid mapping is learned and resolved
|
|
42
|
+
store.learnLid("123456789@lid", "551199999999@s.whatsapp.net");
|
|
43
|
+
assert.equal(store.resolveJid("123456789@lid"), "551199999999@s.whatsapp.net");
|
|
44
|
+
// Non-LID JIDs are returned untouched
|
|
45
|
+
assert.equal(store.resolveJid("551188888888@s.whatsapp.net"), "551188888888@s.whatsapp.net");
|
|
46
|
+
// Forget LID removes mapping
|
|
47
|
+
store.forgetLid("123456789@lid");
|
|
48
|
+
assert.equal(store.resolveJid("123456789@lid"), "123456789@lid");
|
|
49
|
+
});
|
|
50
|
+
test("store preserves rich contact fields during updates", () => {
|
|
51
|
+
const store = createStore();
|
|
52
|
+
const ev = new EventEmitter();
|
|
53
|
+
store.bind(ev);
|
|
54
|
+
// Initial contact with full details
|
|
55
|
+
ev.emit("contacts.upsert", [
|
|
56
|
+
{ id: "user1@s.whatsapp.net", name: "Alice", notify: "Alice N", verifiedName: "Alice Verified" }
|
|
57
|
+
]);
|
|
58
|
+
let contact = store.contacts["user1@s.whatsapp.net"];
|
|
59
|
+
assert.equal(contact?.name, "Alice");
|
|
60
|
+
assert.equal(contact?.notify, "Alice N");
|
|
61
|
+
assert.equal(contact?.verifiedName, "Alice Verified");
|
|
62
|
+
// Poorer update (e.g., from a stub) should not wipe out previous fields
|
|
63
|
+
ev.emit("contacts.upsert", [
|
|
64
|
+
{ id: "user1@s.whatsapp.net" }
|
|
65
|
+
]);
|
|
66
|
+
contact = store.contacts["user1@s.whatsapp.net"];
|
|
67
|
+
assert.equal(contact?.name, "Alice");
|
|
68
|
+
assert.equal(contact?.notify, "Alice N");
|
|
69
|
+
assert.equal(contact?.verifiedName, "Alice Verified");
|
|
70
|
+
});
|
|
71
|
+
test("store captures pushName from messages and respects guards", () => {
|
|
72
|
+
const store = createStore();
|
|
73
|
+
const ev = new EventEmitter();
|
|
74
|
+
store.bind(ev);
|
|
75
|
+
ev.emit("messages.upsert", {
|
|
76
|
+
messages: [
|
|
77
|
+
// DM message from other person -> should capture
|
|
78
|
+
{
|
|
79
|
+
key: { id: "m1", remoteJid: "user2@s.whatsapp.net", fromMe: false },
|
|
80
|
+
pushName: "Bob",
|
|
81
|
+
},
|
|
82
|
+
// Message fromMe -> should NOT capture
|
|
83
|
+
{
|
|
84
|
+
key: { id: "m2", remoteJid: "user3@s.whatsapp.net", fromMe: true },
|
|
85
|
+
pushName: "SelfBot",
|
|
86
|
+
},
|
|
87
|
+
// Message in group -> captures participant
|
|
88
|
+
{
|
|
89
|
+
key: { id: "m3", remoteJid: "group1@g.us", participant: "user4@s.whatsapp.net", fromMe: false },
|
|
90
|
+
pushName: "Charlie",
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
assert.equal(store.contacts["user2@s.whatsapp.net"]?.notify, "Bob");
|
|
95
|
+
assert.equal(store.contacts["user3@s.whatsapp.net"], undefined);
|
|
96
|
+
assert.equal(store.contacts["user4@s.whatsapp.net"]?.notify, "Charlie");
|
|
97
|
+
});
|
|
98
|
+
test("store evicts oldest messages when exceeding MAX_MSGS_PER_CHAT (200)", () => {
|
|
99
|
+
const store = createStore();
|
|
100
|
+
const ev = new EventEmitter();
|
|
101
|
+
store.bind(ev);
|
|
102
|
+
const jid = "chat-flood@s.whatsapp.net";
|
|
103
|
+
const messages = [];
|
|
104
|
+
for (let i = 1; i <= 205; i++) {
|
|
105
|
+
messages.push({
|
|
106
|
+
key: { id: `msg-${i}`, remoteJid: jid },
|
|
107
|
+
message: { conversation: `text ${i}` },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
ev.emit("messages.upsert", { messages });
|
|
111
|
+
const chatMap = store.messages.get(jid);
|
|
112
|
+
assert.ok(chatMap);
|
|
113
|
+
assert.equal(chatMap?.size, 200);
|
|
114
|
+
assert.equal(chatMap?.has("msg-1"), false); // evicted
|
|
115
|
+
assert.equal(chatMap?.has("msg-5"), false); // evicted
|
|
116
|
+
assert.equal(chatMap?.has("msg-6"), true); // kept
|
|
117
|
+
assert.equal(chatMap?.has("msg-205"), true); // kept
|
|
118
|
+
});
|
|
119
|
+
test("store accumulates poll updates on messages.update", () => {
|
|
120
|
+
const store = createStore();
|
|
121
|
+
const ev = new EventEmitter();
|
|
122
|
+
store.bind(ev);
|
|
123
|
+
const jid = "poll-chat@s.whatsapp.net";
|
|
124
|
+
const msgId = "poll-1";
|
|
125
|
+
// Initial poll message
|
|
126
|
+
ev.emit("messages.upsert", {
|
|
127
|
+
messages: [
|
|
128
|
+
{
|
|
129
|
+
key: { id: msgId, remoteJid: jid },
|
|
130
|
+
message: { pollCreationMessage: { name: "Question?" } },
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
});
|
|
134
|
+
// First vote update
|
|
135
|
+
ev.emit("messages.update", [
|
|
136
|
+
{
|
|
137
|
+
key: { id: msgId, remoteJid: jid },
|
|
138
|
+
update: { pollUpdates: [{ voter: "user1" }] },
|
|
139
|
+
}
|
|
140
|
+
]);
|
|
141
|
+
// Second vote update
|
|
142
|
+
ev.emit("messages.update", [
|
|
143
|
+
{
|
|
144
|
+
key: { id: msgId, remoteJid: jid },
|
|
145
|
+
update: { pollUpdates: [{ voter: "user2" }] },
|
|
146
|
+
}
|
|
147
|
+
]);
|
|
148
|
+
const stored = store.messages.get(jid)?.get(msgId);
|
|
149
|
+
assert.ok(stored);
|
|
150
|
+
assert.equal(stored.pollUpdates?.length, 2);
|
|
151
|
+
assert.deepEqual(stored.pollUpdates, [{ voter: "user1" }, { voter: "user2" }]);
|
|
152
|
+
});
|
|
153
|
+
test("store processes messaging-history.set backfill", () => {
|
|
154
|
+
const store = createStore();
|
|
155
|
+
const ev = new EventEmitter();
|
|
156
|
+
store.bind(ev);
|
|
157
|
+
ev.emit("messaging-history.set", {
|
|
158
|
+
chats: [{ id: "hist-chat@s.whatsapp.net", name: "History Chat" }],
|
|
159
|
+
contacts: [{ id: "hist-user@s.whatsapp.net", name: "Dave" }],
|
|
160
|
+
messages: [
|
|
161
|
+
{
|
|
162
|
+
key: { id: "m-hist", remoteJid: "hist-chat@s.whatsapp.net", fromMe: false },
|
|
163
|
+
pushName: "Dave Push",
|
|
164
|
+
}
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
assert.equal(store.chats.get("hist-chat@s.whatsapp.net")?.name, "History Chat");
|
|
168
|
+
assert.equal(store.contacts["hist-user@s.whatsapp.net"]?.name, "Dave");
|
|
169
|
+
assert.equal(store.contacts["hist-chat@s.whatsapp.net"]?.notify, "Dave Push");
|
|
170
|
+
});
|
package/dist/config.js
CHANGED
|
@@ -25,7 +25,7 @@ import { t } from "#i18n";
|
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
// Paths
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
|
-
export const CONFIG_DIR = path.join(os.homedir(), ".manybot");
|
|
28
|
+
export const CONFIG_DIR = process.env.MANYBOT_CONFIG_DIR ?? path.join(os.homedir(), ".manybot");
|
|
29
29
|
/** @deprecated Use TOML_CONFIG_FILE. Frozen — no new keys. */
|
|
30
30
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "manybot.conf");
|
|
31
31
|
/** @deprecated Use TOML_PLUGIN_FILE. Frozen — no new keys. */
|
|
@@ -210,6 +210,11 @@ PHONE_NUMBER = ""
|
|
|
210
210
|
# but reduce the risk of WhatsApp flagging the account.
|
|
211
211
|
SECURITY_LEVEL = "medium"
|
|
212
212
|
|
|
213
|
+
# Startup log verbosity: "normal" (everything), "clean" (hides routine
|
|
214
|
+
# chatter, keeps success/warnings/errors), or "minimal" (warnings/errors
|
|
215
|
+
# only — silent on the happy path).
|
|
216
|
+
LOG_LEVEL = "normal"
|
|
217
|
+
|
|
213
218
|
# ── Local alerts (critical warnings, update notices) ──────────────────────
|
|
214
219
|
# Phone number (or full JID) that receives WhatsApp alerts when the bot
|
|
215
220
|
# itself is up — e.g. "5511999999999" or "5511999999999@s.whatsapp.net".
|
|
@@ -248,18 +253,6 @@ LOGIN_METHOD = ""
|
|
|
248
253
|
STATUS_ENABLED = true
|
|
249
254
|
STATUS_PORT = 8080
|
|
250
255
|
|
|
251
|
-
# ── Driver selection (advanced) ────────────────────────────────────────────
|
|
252
|
-
# ManyBot can fall back from a primary driver to a secondary one when the
|
|
253
|
-
# primary fails to confirm a send. Today only Baileys is wired in; leave
|
|
254
|
-
# driver_whatsmeow_enabled = false to disable the fallback entirely (no
|
|
255
|
-
# extra processes are spawned, no sockets are opened).
|
|
256
|
-
driver_primary = "baileys"
|
|
257
|
-
driver_fallback_cooldown_ms = 60000
|
|
258
|
-
driver_verify_window_ms = [500, 1000, 1500, 2500, 5000]
|
|
259
|
-
driver_baileys_enabled = true
|
|
260
|
-
driver_whatsmeow_enabled = false
|
|
261
|
-
driver_whatsmeow_grpc_address = "localhost:50051"
|
|
262
|
-
driver_whatsmeow_binary_path = ""
|
|
263
256
|
`;
|
|
264
257
|
const DEFAULT_TOML_PT = `# Arquivo de configuração do ManyBot
|
|
265
258
|
# Veja https://manybot.org/docs/config para saber mais
|
|
@@ -276,6 +269,11 @@ PHONE_NUMBER = ""
|
|
|
276
269
|
# maiores), mas reduzem o risco do WhatsApp sinalizar a conta.
|
|
277
270
|
SECURITY_LEVEL = "medium"
|
|
278
271
|
|
|
272
|
+
# Verbosidade dos logs de inicialização: "normal" (tudo), "clean" (esconde
|
|
273
|
+
# ruído de rotina, mantém sucesso/avisos/erros), ou "minimal" (só avisos
|
|
274
|
+
# e erros — silencioso quando está tudo bem).
|
|
275
|
+
LOG_LEVEL = "normal"
|
|
276
|
+
|
|
279
277
|
# ── Avisos locais (alertas críticos, aviso de update) ──────────────────────
|
|
280
278
|
# Número de telefone (ou JID completo) que recebe alertas via WhatsApp
|
|
281
279
|
# quando o bot está de pé — ex. "5511999999999" ou
|
|
@@ -316,18 +314,6 @@ LOGIN_METHOD = ""
|
|
|
316
314
|
STATUS_ENABLED = true
|
|
317
315
|
STATUS_PORT = 8080
|
|
318
316
|
|
|
319
|
-
# ── Seleção de driver (avançado) ───────────────────────────────────────────
|
|
320
|
-
# ManyBot pode cair de um driver primário pra um secundário quando o
|
|
321
|
-
# primário não confirma um envio. Hoje só Baileys está ligado; deixe
|
|
322
|
-
# driver_whatsmeow_enabled = false pra desligar o fallback por completo
|
|
323
|
-
# (nenhum processo extra é spawnado, nenhum socket é aberto).
|
|
324
|
-
driver_primary = "baileys"
|
|
325
|
-
driver_fallback_cooldown_ms = 60000
|
|
326
|
-
driver_verify_window_ms = [500, 1000, 1500, 2500, 5000]
|
|
327
|
-
driver_baileys_enabled = true
|
|
328
|
-
driver_whatsmeow_enabled = false
|
|
329
|
-
driver_whatsmeow_grpc_address = "localhost:50051"
|
|
330
|
-
driver_whatsmeow_binary_path = ""
|
|
331
317
|
`;
|
|
332
318
|
const DEFAULT_TOML = detectSystemLang() === "pt" ? DEFAULT_TOML_PT : DEFAULT_TOML_EN;
|
|
333
319
|
await fs.mkdir(CONFIG_DIR, { recursive: true });
|
|
@@ -368,9 +354,11 @@ const DEFAULTS = {
|
|
|
368
354
|
CHATS: [],
|
|
369
355
|
EXCLUDE_CHATS: [],
|
|
370
356
|
SECURITY_LEVEL: "medium",
|
|
357
|
+
LOG_LEVEL: "normal",
|
|
371
358
|
PLUGINS: [],
|
|
372
359
|
LANGUAGE: "en",
|
|
373
360
|
PHONE_NUMBER: null,
|
|
361
|
+
OWNER_NUMBER: null,
|
|
374
362
|
LOGIN_METHOD: null,
|
|
375
363
|
ADMIN_JID: "",
|
|
376
364
|
SMTP_HOST: "",
|
|
@@ -390,7 +378,6 @@ const DEFAULTS = {
|
|
|
390
378
|
fallbackCooldownMs: 60000,
|
|
391
379
|
verifyWindowMs: [500, 1000, 1500, 2500, 5000],
|
|
392
380
|
baileys: { enabled: true },
|
|
393
|
-
whatsmeow: { enabled: false, grpcAddress: "localhost:50051", binaryPath: "" },
|
|
394
381
|
},
|
|
395
382
|
};
|
|
396
383
|
function normalize(cfg) {
|
|
@@ -398,6 +385,13 @@ function normalize(cfg) {
|
|
|
398
385
|
// can always do a simple truthiness check regardless of config source.
|
|
399
386
|
if (cfg.PHONE_NUMBER === "")
|
|
400
387
|
cfg.PHONE_NUMBER = null;
|
|
388
|
+
const rawOwner = cfg.owner_number ?? cfg.OWNER_NUMBER;
|
|
389
|
+
if (typeof rawOwner === "string" && rawOwner.trim().length > 0) {
|
|
390
|
+
cfg.OWNER_NUMBER = rawOwner.trim();
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
cfg.OWNER_NUMBER = null;
|
|
394
|
+
}
|
|
401
395
|
// Anything other than "phone"/"qr" is treated as "not configured yet",
|
|
402
396
|
// so a typo or leftover garbage value falls back to the interactive
|
|
403
397
|
// prompt instead of silently misbehaving.
|
|
@@ -407,6 +401,9 @@ function normalize(cfg) {
|
|
|
407
401
|
if (!["low", "medium", "high"].includes(cfg.SECURITY_LEVEL)) {
|
|
408
402
|
cfg.SECURITY_LEVEL = "medium";
|
|
409
403
|
}
|
|
404
|
+
if (!["normal", "clean", "minimal"].includes(cfg.LOG_LEVEL)) {
|
|
405
|
+
cfg.LOG_LEVEL = "normal";
|
|
406
|
+
}
|
|
410
407
|
// Legacy .conf values arrive as strings; TOML gives real types already —
|
|
411
408
|
// Number()/coercion here is a no-op for the TOML path.
|
|
412
409
|
cfg.SMTP_PORT = Number(cfg.SMTP_PORT) || 587;
|
|
@@ -423,8 +420,8 @@ function normalize(cfg) {
|
|
|
423
420
|
cfg.STATUS_PORT = Number(cfg.STATUS_PORT) || 8080;
|
|
424
421
|
// drivers.* — the fallback guard and DriverManager read these at every
|
|
425
422
|
// send. The TOML surface is flat lowercase keys (driver_primary,
|
|
426
|
-
// driver_baileys_enabled,
|
|
427
|
-
// rest of
|
|
423
|
+
// driver_baileys_enabled, …) — same style as the
|
|
424
|
+
// rest of this file. `normalize()` rebuilds the nested object below
|
|
428
425
|
// from those flat keys so callers (main.ts, sendFallbackGuard.ts,
|
|
429
426
|
// supervisor.ts, client.ts) keep using `CONFIG.drivers.*`.
|
|
430
427
|
// Coerce strings (legacy .conf arrives as strings) and reject anything
|
|
@@ -443,15 +440,11 @@ function normalize(cfg) {
|
|
|
443
440
|
// Allow either the new flat keys or (for backward compatibility) the
|
|
444
441
|
// old nested `[drivers]` block. Flat keys take precedence.
|
|
445
442
|
const nestedDrv = (cfg.drivers ?? {});
|
|
446
|
-
const flatPrimary = cfg.driver_primary ?? nestedDrv.primary;
|
|
447
443
|
const flatCooldown = cfg.driver_fallback_cooldown_ms ?? nestedDrv.fallbackCooldownMs;
|
|
448
444
|
const flatVerify = cfg.driver_verify_window_ms ?? nestedDrv.verifyWindowMs;
|
|
449
445
|
const flatBaileysEnabled = cfg.driver_baileys_enabled ?? nestedDrv.baileys?.enabled;
|
|
450
|
-
const flatWhatsmeowEnabled = cfg.driver_whatsmeow_enabled ?? nestedDrv.whatsmeow?.enabled;
|
|
451
|
-
const flatWhatsmeowGrpc = cfg.driver_whatsmeow_grpc_address ?? nestedDrv.whatsmeow?.grpcAddress;
|
|
452
|
-
const flatWhatsmeowBinary = cfg.driver_whatsmeow_binary_path ?? nestedDrv.whatsmeow?.binaryPath;
|
|
453
446
|
cfg.drivers = {
|
|
454
|
-
primary:
|
|
447
|
+
primary: "baileys",
|
|
455
448
|
fallbackCooldownMs: Number(flatCooldown) || 60000,
|
|
456
449
|
verifyWindowMs: Array.isArray(flatVerify) && flatVerify.length
|
|
457
450
|
? flatVerify.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0)
|
|
@@ -459,17 +452,6 @@ function normalize(cfg) {
|
|
|
459
452
|
baileys: {
|
|
460
453
|
enabled: coerceEnabled(flatBaileysEnabled, true),
|
|
461
454
|
},
|
|
462
|
-
whatsmeow: {
|
|
463
|
-
// MANYBOT_SMOKE=1 force-enables whatsmeow regardless of the TOML
|
|
464
|
-
// value — used by scripts/smoke-whatsmeow-supervisor.mjs so the
|
|
465
|
-
// smoke test doesn't depend on the local manybot.toml already
|
|
466
|
-
// having driver_whatsmeow_enabled = true. Not a documented
|
|
467
|
-
// user-facing setting; TOML remains the source of truth outside
|
|
468
|
-
// smoke runs.
|
|
469
|
-
enabled: process.env.MANYBOT_SMOKE === "1" ? true : isTruthy(flatWhatsmeowEnabled),
|
|
470
|
-
grpcAddress: typeof flatWhatsmeowGrpc === "string" ? flatWhatsmeowGrpc : "localhost:50051",
|
|
471
|
-
binaryPath: typeof flatWhatsmeowBinary === "string" ? flatWhatsmeowBinary : "",
|
|
472
|
-
},
|
|
473
455
|
};
|
|
474
456
|
// Drop the flat keys from the merged object so they don't leak as
|
|
475
457
|
// loose top-level Config keys (the `Config` interface exposes only
|
|
@@ -515,9 +497,11 @@ export const CMD_PREFIX = CONFIG.CMD_PREFIX;
|
|
|
515
497
|
export const CHATS = CONFIG.CHATS;
|
|
516
498
|
export const EXCLUDE_CHATS = CONFIG.EXCLUDE_CHATS;
|
|
517
499
|
export const SECURITY_LEVEL = CONFIG.SECURITY_LEVEL;
|
|
500
|
+
export const LOG_LEVEL = CONFIG.LOG_LEVEL;
|
|
518
501
|
export const PLUGINS = CONFIG.PLUGINS;
|
|
519
502
|
export const LANGUAGE = CONFIG.LANGUAGE;
|
|
520
503
|
export const PHONE_NUMBER = CONFIG.PHONE_NUMBER;
|
|
504
|
+
export const OWNER_NUMBER = CONFIG.OWNER_NUMBER;
|
|
521
505
|
export const LOGIN_METHOD = CONFIG.LOGIN_METHOD;
|
|
522
506
|
export const ADMIN_JID = CONFIG.ADMIN_JID;
|
|
523
507
|
export const SMTP_HOST = CONFIG.SMTP_HOST;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import test, { describe } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import { CONFIG_DIR, CONFIG } from "#config";
|
|
6
|
+
describe("config", () => {
|
|
7
|
+
test("CONFIG_DIR defaults to ~/.manybot or respects env override", () => {
|
|
8
|
+
if (process.env.MANYBOT_CONFIG_DIR) {
|
|
9
|
+
assert.equal(CONFIG_DIR, process.env.MANYBOT_CONFIG_DIR);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
assert.equal(CONFIG_DIR, path.join(os.homedir(), ".manybot"));
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
test("CONFIG has required structure and default fallback values", () => {
|
|
16
|
+
assert.ok(typeof CONFIG.CMD_PREFIX === "string");
|
|
17
|
+
assert.ok(typeof CONFIG.CLIENT_ID === "string");
|
|
18
|
+
assert.ok(Array.isArray(CONFIG.CHATS));
|
|
19
|
+
assert.ok(Array.isArray(CONFIG.EXCLUDE_CHATS));
|
|
20
|
+
assert.ok(["low", "medium", "high"].includes(CONFIG.SECURITY_LEVEL));
|
|
21
|
+
assert.ok(["normal", "clean", "minimal"].includes(CONFIG.LOG_LEVEL));
|
|
22
|
+
assert.ok(CONFIG.drivers);
|
|
23
|
+
assert.ok(CONFIG.drivers.primary === "baileys");
|
|
24
|
+
assert.ok(typeof CONFIG.drivers.baileys.enabled === "boolean");
|
|
25
|
+
});
|
|
26
|
+
});
|
package/dist/download/queue.js
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* Usage:
|
|
11
11
|
* import { enqueue } from "#download";
|
|
12
12
|
* enqueue(async () => { ... all plugin logic ... }, onError);
|
|
13
|
+
*
|
|
14
|
+
* `errorFn` is optional — if omitted, a failure is still logged via
|
|
15
|
+
* `logger.warn` so it's never silently swallowed.
|
|
13
16
|
*/
|
|
14
17
|
import { logger } from "#logger";
|
|
15
18
|
import { t } from "#i18n";
|
|
@@ -18,7 +21,8 @@ let processing = false;
|
|
|
18
21
|
/**
|
|
19
22
|
* Add a job to the queue and start processing if idle.
|
|
20
23
|
* @param workFn All plugin logic — runs exclusively until resolved.
|
|
21
|
-
* @param errorFn Called with the thrown error if workFn rejects.
|
|
24
|
+
* @param errorFn Called with the thrown error if workFn rejects. If omitted,
|
|
25
|
+
* the error is logged via `logger.warn` instead.
|
|
22
26
|
*/
|
|
23
27
|
export function enqueue(workFn, errorFn) {
|
|
24
28
|
queue.push({ workFn, errorFn });
|
|
@@ -41,9 +45,14 @@ async function processJob({ workFn, errorFn }) {
|
|
|
41
45
|
catch (e) {
|
|
42
46
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
43
47
|
logger.error(t("system.downloadJobFailed", { message: err.message }));
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
if (errorFn) {
|
|
49
|
+
try {
|
|
50
|
+
await errorFn(err);
|
|
51
|
+
}
|
|
52
|
+
catch { }
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
logger.warn(t("system.downloadJobNoErrorFn"));
|
|
46
56
|
}
|
|
47
|
-
catch { }
|
|
48
57
|
}
|
|
49
58
|
}
|