@manybot/manybot 4.4.0 → 5.1.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/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "SyntaxError!",
6
6
  "email": "me@stxerr.dev"
7
7
  },
8
- "version": "4.4.0",
8
+ "version": "5.1.0",
9
9
  "license": "GPL-3.0-only",
10
10
  "private": false,
11
11
  "engines": {
@@ -37,6 +37,7 @@
37
37
  "dependencies": {
38
38
  "node-webpmux": "^3.2.1",
39
39
  "qrcode-terminal": "^0.12.0",
40
+ "smol-toml": "^1.7.0",
40
41
  "tar": "^7.5.16",
41
42
  "whatsapp-web.js": "^1.24.0"
42
43
  },
@@ -44,6 +45,7 @@
44
45
  "#client/*": "./src/client/*.js",
45
46
  "#kernel/*": "./src/kernel/*.js",
46
47
  "#manyapi": "./src/kernel/pluginApi.js",
48
+ "#sendguard": "./src/kernel/sendGuard.js",
47
49
  "#logger": "./src/logger/logger.js",
48
50
  "#utils/*": "./src/utils/*.js",
49
51
  "#i18n": "./src/i18n/index.js",
@@ -20,20 +20,27 @@ import { extract } from "tar";
20
20
  import { pipeline } from "node:stream/promises";
21
21
  import { Readable } from "node:stream";
22
22
  import { Transform } from "node:stream";
23
+ const os = process.platform;
23
24
 
24
25
  const __filename = fileURLToPath(import.meta.url);
25
26
  const __dirname = path.dirname(__filename);
26
27
 
27
28
  const DATA_PATH = path.join(CONFIG_DIR, "sessions");
28
29
 
29
- const chrome = path.join(CONFIG_DIR, "chrome/chrome")
30
+ const chromeDir = path.join(CONFIG_DIR, "chrome");
31
+
32
+ const bins = {
33
+ linux: "chrome",
34
+ win32: "chrome.exe"
35
+ }
36
+
37
+ const chrome = path.join(chromeDir, bins[os]);
30
38
 
31
39
  if (!fs.existsSync(chrome)) {
32
40
  try {
33
41
  logger.warn(t("errors.chromeNotFound"));
34
42
  const tmpDir = "/tmp/manybot-chrome";
35
43
  const tmpPath = path.join(tmpDir, "chrome.tar.gz");
36
- const os = process.platform;
37
44
  const urls = {
38
45
  linux: "https://api.manybot.stxerr.dev/download-chrome-linux",
39
46
  win32: "https://api.manybot.stxerr.dev/download-chrome-win"
@@ -41,7 +48,7 @@ if (!fs.existsSync(chrome)) {
41
48
 
42
49
  const url = urls[os];
43
50
  if (!url) throw new Error(t("errors.OSNotSupported", { os }));
44
-
51
+
45
52
  fs.mkdirSync(tmpDir, { recursive: true });
46
53
 
47
54
  const res = await fetch(url);
@@ -77,7 +84,7 @@ if (!fs.existsSync(chrome)) {
77
84
  );
78
85
  } finally {
79
86
  clearInterval(spinner);
80
- process.stderr.write("\r\x1b[2K"); // limpa a linha
87
+ process.stderr.write("\r\x1b[2K");
81
88
  }
82
89
 
83
90
  if (expected && received !== expected) {
package/src/config.js CHANGED
@@ -1,178 +1,277 @@
1
1
  /**
2
2
  * config.js
3
3
  *
4
- * Loads:
4
+ * Loads config from up to four files, merged in strict precedence order:
5
+ *
6
+ * defaults < legacy .conf < TOML
7
+ *
8
+ * Legacy files (frozen — no new keys):
5
9
  * ~/.manybot/manybot.conf
6
10
  * ~/.manybot/manyplug.conf
7
11
  *
8
- * Merges both files into a single configuration object.
12
+ * TOML files (all new features go here):
13
+ * ~/.manybot/manybot.toml
14
+ * ~/.manybot/manyplug.toml
15
+ *
16
+ * The final CONFIG object always has the same shape regardless of which
17
+ * files are present. Plugins must never see a structural difference.
9
18
  */
10
19
 
11
20
  import fs from "fs/promises";
12
21
  import os from "os";
13
22
  import path from "path";
14
-
23
+ import { parse as parseToml } from "smol-toml";
15
24
  import { logger } from "#logger";
16
25
 
17
- export const CONFIG_DIR = path.join(os.homedir(), ".manybot");
18
- export const CONFIG_FILE = path.join(CONFIG_DIR, "manybot.conf");
19
- export const PLUGIN_FILE = path.join(CONFIG_DIR, "manyplug.conf");
26
+ // ---------------------------------------------------------------------------
27
+ // Paths
28
+ // ---------------------------------------------------------------------------
29
+
30
+ export const CONFIG_DIR = path.join(os.homedir(), ".manybot");
31
+
32
+ /** @deprecated Use TOML_CONFIG_FILE. Frozen — no new keys. */
33
+ export const CONFIG_FILE = path.join(CONFIG_DIR, "manybot.conf");
34
+ /** @deprecated Use TOML_PLUGIN_FILE. Frozen — no new keys. */
35
+ export const PLUGIN_FILE = path.join(CONFIG_DIR, "manyplug.conf");
36
+
37
+ export const TOML_CONFIG_FILE = path.join(CONFIG_DIR, "manybot.toml");
38
+ export const TOML_PLUGIN_FILE = path.join(CONFIG_DIR, "manyplug.toml");
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Legacy .conf parser (frozen — do not extend)
42
+ // ---------------------------------------------------------------------------
20
43
 
21
- /**
22
- * Converts strings to native JS values.
23
- */
24
44
  function parseValue(value) {
25
45
  value = value.trim();
26
-
27
46
  if (
28
47
  (value.startsWith('"') && value.endsWith('"')) ||
29
48
  (value.startsWith("'") && value.endsWith("'"))
30
49
  ) {
31
50
  value = value.slice(1, -1);
32
51
  }
33
-
34
- if (value === "true")
35
- return true;
36
-
37
- if (value === "false")
38
- return false;
39
-
52
+ if (value === "true") return true;
53
+ if (value === "false") return false;
40
54
  return value;
41
55
  }
42
56
 
43
- /**
44
- * Reads comments safely.
45
- * Ignores # inside quoted strings.
46
- */
47
57
  function stripInlineComment(line) {
48
58
  let result = "";
49
- let quote = null;
50
-
59
+ let quote = null;
51
60
  for (let i = 0; i < line.length; i++) {
52
61
  const ch = line[i];
53
-
54
62
  if ((ch === '"' || ch === "'") && line[i - 1] !== "\\") {
55
- if (quote === ch)
56
- quote = null;
57
- else if (!quote)
58
- quote = ch;
63
+ if (quote === ch) quote = null;
64
+ else if (!quote) quote = ch;
59
65
  }
60
-
61
- if (ch === "#" && !quote)
62
- break;
63
-
66
+ if (ch === "#" && !quote) break;
64
67
  result += ch;
65
68
  }
66
-
67
69
  return result.trim();
68
70
  }
69
71
 
70
- /**
71
- * Parses manybot.conf syntax.
72
- */
73
72
  function parseConf(raw) {
74
- const lines = raw.split(/\r?\n/);
75
-
73
+ const lines = raw.split(/\r?\n/);
76
74
  const mergedLines = [];
77
-
78
- let insideList = false;
79
- let buffer = "";
75
+ let insideList = false;
76
+ let buffer = "";
80
77
 
81
78
  for (let line of lines) {
82
79
  line = stripInlineComment(line);
83
-
84
- if (!line)
85
- continue;
80
+ if (!line) continue;
86
81
 
87
82
  if (!insideList) {
88
83
  if (/=\s*\[$/.test(line)) {
89
84
  insideList = true;
90
- buffer = line;
85
+ buffer = line;
91
86
  } else {
92
87
  mergedLines.push(line);
93
88
  }
94
89
  } else {
95
90
  buffer += " " + line;
96
-
97
91
  if (line.includes("]")) {
98
92
  mergedLines.push(buffer);
99
- buffer = "";
93
+ buffer = "";
100
94
  insideList = false;
101
95
  }
102
96
  }
103
97
  }
104
98
 
105
99
  const config = {};
106
-
107
100
  for (const line of mergedLines) {
108
101
  const idx = line.indexOf("=");
109
-
110
- if (idx === -1)
111
- continue;
112
-
113
- const key = line.slice(0, idx).trim();
114
- let value = line.slice(idx + 1).trim();
102
+ if (idx === -1) continue;
103
+ const key = line.slice(0, idx).trim();
104
+ let value = line.slice(idx + 1).trim();
115
105
 
116
106
  if (value.startsWith("[") && value.endsWith("]")) {
117
107
  config[key] = value
118
108
  .slice(1, -1)
119
109
  .split(",")
120
- .map(v => parseValue(v))
110
+ .map(v => parseValue(v))
121
111
  .filter(v => v !== "");
122
112
  continue;
123
113
  }
124
-
125
114
  config[key] = parseValue(value);
126
115
  }
127
-
128
116
  return config;
129
117
  }
130
118
 
119
+ // ---------------------------------------------------------------------------
120
+ // TOML migration
121
+ // ---------------------------------------------------------------------------
122
+
123
+ function escapeTomlString(s) {
124
+ return s
125
+ .replaceAll("\\", "\\\\")
126
+ .replaceAll('"', '\\"');
127
+ }
128
+
129
+ function toToml(obj) {
130
+ return Object.entries(obj)
131
+ .map(([k, v]) => {
132
+ if (Array.isArray(v))
133
+ return `${k} = [${v.map(JSON.stringify).join(", ")}]`;
134
+
135
+ if (v == null || v === "")
136
+ return `${k} = ""`;
137
+
138
+ return typeof v === "string"
139
+ ? `${k} = "${escapeTomlString(v)}"`
140
+ : `${k} = ${v}`;
141
+ })
142
+ .join("\n") + "\n";
143
+ }
144
+
145
+ async function migrateLegacyIfNeeded() {
146
+ if (await fileExists(TOML_CONFIG_FILE))
147
+ return;
148
+
149
+ const migrate = async (src, dest, omit = []) => {
150
+ const raw = await readFileSafe(src);
151
+ if (!raw) return false;
152
+
153
+ const cfg = parseConf(raw);
154
+
155
+ for (const k of omit)
156
+ delete cfg[k];
157
+
158
+ await fs.writeFile(dest, toToml(cfg), "utf8");
159
+ await fs.rename(src, `${src}.bak`);
160
+
161
+ return true;
162
+ };
163
+
164
+ const migrated =
165
+ await migrate(CONFIG_FILE, TOML_CONFIG_FILE, ["PLUGINS"]) ||
166
+ await migrate(PLUGIN_FILE, TOML_PLUGIN_FILE);
167
+
168
+ if (migrated)
169
+ logger.success("Config migrated to TOML");
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // File helpers
174
+ // ---------------------------------------------------------------------------
175
+
176
+ async function fileExists(file) {
177
+ try { await fs.stat(file); return true; }
178
+ catch { return false; }
179
+ }
180
+
131
181
  async function readFileSafe(file) {
132
182
  try {
133
183
  return await fs.readFile(file, "utf-8");
134
184
  } catch (err) {
135
- if (err.code !== "ENOENT") {
136
- logger.warn(`Error reading ${file}: ${err.message}`);
137
- }
138
- return "";
185
+ if (err.code !== "ENOENT") logger.warn(`Error reading ${file}: ${err.message}`);
186
+ return null;
139
187
  }
140
188
  }
141
189
 
142
- const defaultConfig =
143
- `
144
- # Many bot configuration file
190
+ // ---------------------------------------------------------------------------
191
+ // Bootstrap: ensure at least one config file exists
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const DEFAULT_TOML =
195
+ `# ManyBot configuration file
145
196
  # See https://manybot.stxerr.dev/docs/config to learn more
146
197
 
147
- CLIENT_ID="manybot"
148
- CMD_PREFIX="!"
149
- CHATS=[]
150
- LANGUAGE=en
151
- PHONE_NUMBER=
198
+ CLIENT_ID = "manybot"
199
+ CMD_PREFIX = "!"
200
+ CHATS = []
201
+ LANGUAGE = "en"
202
+ PHONE_NUMBER = ""
152
203
  `;
153
204
 
154
- try {
155
- await fs.stat(CONFIG_FILE);
156
- } catch {
157
- logger.warn("Configuration file not found: ", CONFIG_FILE, ". Creating a new one.");
205
+ await fs.mkdir(CONFIG_DIR, { recursive: true });
206
+
207
+ await migrateLegacyIfNeeded();
208
+
209
+ if (!await fileExists(TOML_CONFIG_FILE)) {
210
+ logger.warn(`Creating ${TOML_CONFIG_FILE}`);
211
+ await fs.writeFile(TOML_CONFIG_FILE, DEFAULT_TOML);
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Layer 1 — tOML
216
+ // ---------------------------------------------------------------------------
158
217
 
159
- await fs.mkdir(CONFIG_DIR, { recursive: true });
160
- await fs.writeFile(CONFIG_FILE, defaultConfig);
218
+ const legacyLayer = {};
219
+
220
+ // ---------------------------------------------------------------------------
221
+ // Layer 2 — TOML (all new features land here)
222
+ // ---------------------------------------------------------------------------
223
+
224
+ let tomlLayer = {};
225
+
226
+ async function loadToml(file, label) {
227
+ const raw = await readFileSafe(file);
228
+ if (raw === null) return {};
229
+ try {
230
+ return parseToml(raw);
231
+ } catch (err) {
232
+ logger.warn(`Error parsing ${label}: ${err.message}`);
233
+ return {};
234
+ }
161
235
  }
162
236
 
163
- const baseConfig = await readFileSafe(CONFIG_FILE);
164
- const pluginConfig = await readFileSafe(PLUGIN_FILE);
237
+ tomlLayer = {
238
+ ...await loadToml(TOML_CONFIG_FILE, "manybot.toml"),
239
+ ...await loadToml(TOML_PLUGIN_FILE, "manyplug.toml"),
240
+ };
165
241
 
166
- export const CONFIG = {
167
- CMD_PREFIX: "!",
168
- CLIENT_ID: "manybot",
169
- CHATS: [],
170
- PLUGINS: [],
171
- LANGUAGE: "en",
242
+ // ---------------------------------------------------------------------------
243
+ // Merge + normalize
244
+ //
245
+ // Normalization runs once on the final merged object so both legacy and TOML
246
+ // layers are treated identically. Add new normalization rules here only.
247
+ // ---------------------------------------------------------------------------
248
+
249
+ const DEFAULTS = {
250
+ CMD_PREFIX: "!",
251
+ CLIENT_ID: "manybot",
252
+ CHATS: [],
253
+ PLUGINS: [],
254
+ LANGUAGE: "en",
172
255
  PHONE_NUMBER: null,
173
- ...parseConf(baseConfig + "\n" + pluginConfig),
174
256
  };
175
257
 
258
+ function normalize(cfg) {
259
+ // Empty string and absent PHONE_NUMBER are both treated as null so plugins
260
+ // can always do a simple truthiness check regardless of config source.
261
+ if (cfg.PHONE_NUMBER === "") cfg.PHONE_NUMBER = null;
262
+ return cfg;
263
+ }
264
+
265
+ export const CONFIG = normalize({
266
+ ...DEFAULTS,
267
+ ...legacyLayer, // legacy .conf overrides defaults
268
+ ...tomlLayer, // TOML overrides legacy .conf
269
+ });
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // Named exports — identical shape regardless of config source
273
+ // ---------------------------------------------------------------------------
274
+
176
275
  export const CLIENT_ID = CONFIG.CLIENT_ID;
177
276
  export const CMD_PREFIX = CONFIG.CMD_PREFIX;
178
277
  export const CHATS = CONFIG.CHATS;
@@ -180,11 +279,10 @@ export const PLUGINS = CONFIG.PLUGINS;
180
279
  export const LANGUAGE = CONFIG.LANGUAGE;
181
280
  export const PHONE_NUMBER = CONFIG.PHONE_NUMBER;
182
281
 
183
- /**
184
- * Useful paths for plugins/modules.
185
- */
186
282
  export const PATHS = {
187
- HOME: CONFIG_DIR,
283
+ HOME: CONFIG_DIR,
188
284
  CONFIG_FILE,
189
- PLUGIN_FILE
285
+ PLUGIN_FILE,
286
+ TOML_CONFIG_FILE,
287
+ TOML_PLUGIN_FILE,
190
288
  };
@@ -6,30 +6,83 @@
6
6
  * Order:
7
7
  * 1. Filter allowed chats (CHATS from .conf)
8
8
  * — if CHATS is empty, accepts all chats
9
- * 2. Log the message
10
- * 3. Pass context to all active plugins
9
+ * 2. Per-chat incoming debounce (prevents command spam from
10
+ * saturating the outbound send queue)
11
+ * 3. Log the message
12
+ * 4. Pass context to all active plugins
11
13
  *
12
14
  * Kernel knows no commands — only distributes.
13
15
  * Each plugin decides on its own whether to act or ignore.
16
+ *
17
+ * Per-plugin overrides (via plugin.guardOptions):
18
+ * typing {boolean} — set to `false` to skip the typing indicator
19
+ * and clearState for this plugin. Useful for
20
+ * plugins that reply instantly (e.g. sticker)
21
+ * where the typing state only adds latency.
22
+ */
23
+ import { CHATS } from "#config";
24
+ import { getChatId } from "#utils/getChatId";
25
+ import { buildApi } from "#manyapi";
26
+ import { pluginRegistry } from "#kernel/pluginLoader";
27
+ import { runPlugin } from "#kernel/pluginGuard";
28
+ import client from "#client/whatsappClient";
29
+ import { logger } from "#logger";
30
+
31
+ /**
32
+ * Minimum ms between processing two messages from the same chat.
33
+ * Does NOT drop messages — debounces rapid bursts so plugins
34
+ * aren't invoked faster than the send guard can pace their replies.
35
+ * Set to 0 to disable.
14
36
  */
37
+ const INCOMING_DEBOUNCE_MS = 300;
15
38
 
16
- import { CHATS } from "#config";
17
- import { getChatId } from "#utils/getChatId";
18
- import { buildApi } from "#manyapi";
19
- import { pluginRegistry } from "#kernel/pluginLoader";
20
- import { runPlugin } from "#kernel/pluginGuard";
21
- import client from "#client/whatsappClient";
39
+ /** chatId timestamp of last processed message */
40
+ const lastProcessedAt = new Map();
22
41
 
23
42
  export async function handleMessage(msg) {
24
43
  const chat = await msg.getChat();
25
44
  const chatId = getChatId(chat);
26
45
 
27
- if (CHATS.length > 0 && !CHATS.includes(chatId)) return;
46
+ if (CHATS.length > 0 && !CHATS.includes(chatId))
47
+ return;
28
48
 
29
- const baseCtx = buildApi({ msg, chat, client, pluginRegistry });
49
+ if (INCOMING_DEBOUNCE_MS > 0) {
50
+ const now = Date.now();
51
+ const last = lastProcessedAt.get(chatId) ?? 0;
52
+ const gap = now - last;
53
+ if (gap < INCOMING_DEBOUNCE_MS) {
54
+ const wait = INCOMING_DEBOUNCE_MS - gap;
55
+ logger.debug(`[messageHandler] ${chatId} delayed ${wait}ms`);
56
+ await new Promise(r => setTimeout(r, wait));
57
+ }
58
+ lastProcessedAt.set(chatId, Date.now());
59
+ }
30
60
 
31
61
  for (const plugin of pluginRegistry.values()) {
32
- const ctx = { ...baseCtx, storage: buildStorageApi(plugin.name) };
33
- await runPlugin(plugin, ctx);
62
+ const ctx = buildApi({
63
+ msg,
64
+ chat,
65
+ client,
66
+ pluginRegistry,
67
+ pluginName: plugin.name,
68
+ });
69
+
70
+ const useTyping = plugin.guardOptions?.typing !== false;
71
+ let typing;
72
+
73
+ if (useTyping) {
74
+ typing = setInterval(() => chat.sendStateTyping(), 4000);
75
+ }
76
+
77
+ try {
78
+ await runPlugin(plugin, ctx);
79
+ } finally {
80
+ if (useTyping) {
81
+ clearInterval(typing);
82
+ try {
83
+ await chat.clearState();
84
+ } catch {}
85
+ }
86
+ }
34
87
  }
35
88
  }