@manybot/manybot 5.2.0 → 5.2.1

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.
@@ -0,0 +1,88 @@
1
+ /**
2
+ * pluginGuard.ts
3
+ *
4
+ * Runs a plugin safely.
5
+ *
6
+ * Protections:
7
+ * - Hard timeout per plugin run (prevents infinite hangs from locking the queue)
8
+ * - Catches and logs all errors with structured context
9
+ * - Marks errored plugins so they are silently skipped from then on
10
+ * - Never crashes the bot
11
+ *
12
+ * Per-plugin overrides:
13
+ * Plugins may export a `guardOptions` object to opt out of specific
14
+ * protections. The pluginLoader is responsible for reading this export
15
+ * and storing it as `plugin.guardOptions` in the registry entry.
16
+ *
17
+ * Supported keys:
18
+ * timeout {boolean} — set to `false` to disable the hard timeout.
19
+ * Use only for plugins that intentionally block
20
+ * (e.g. heavy media processing, sticker generation).
21
+ */
22
+ import { logger } from "#logger";
23
+ import { pluginRegistry } from "#kernel/pluginLoader.js";
24
+ /** Max ms a single plugin run is allowed to take before it's force-aborted. */
25
+ const PLUGIN_TIMEOUT_MS = 120_000;
26
+ /**
27
+ * Races `promise` against a timeout rejection.
28
+ * @param {Promise} promise
29
+ * @param {number} ms
30
+ * @param {string} pluginName
31
+ */
32
+ function withTimeout(promise, ms, pluginName) {
33
+ let timer;
34
+ const timeout = new Promise((_, reject) => {
35
+ timer = setTimeout(() => reject(new Error(`[${pluginName}] timed out after ${ms}ms`)), ms);
36
+ });
37
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
38
+ }
39
+ /**
40
+ * @param {object} plugin — pluginRegistry entry
41
+ * @param {object} context — buildApi ctx
42
+ *
43
+ * plugin.guardOptions (optional, read from plugin's own export):
44
+ * @param {boolean} [plugin.guardOptions.timeout=true]
45
+ */
46
+ export async function runPlugin(plugin, context) {
47
+ if (plugin.status !== "active")
48
+ return;
49
+ const useTimeout = plugin.guardOptions?.timeout !== false;
50
+ try {
51
+ if (!plugin.run)
52
+ return;
53
+ const run = plugin.run(context);
54
+ await (useTimeout ? withTimeout(run, PLUGIN_TIMEOUT_MS, plugin.name) : run);
55
+ }
56
+ catch (e) {
57
+ const error = e instanceof Error ? e : new Error(String(e));
58
+ const errorCount = (plugin.errorCount ?? 0) + 1;
59
+ plugin.errorCount = errorCount;
60
+ plugin.error = error;
61
+ const isTimeout = useTimeout && error.message?.startsWith("timed out");
62
+ if (errorCount >= 3) {
63
+ plugin.status = "error";
64
+ pluginRegistry.set(plugin.name, plugin);
65
+ logger.error(`[pluginGuard] Plugin "${plugin.name}" threw an error and has failed 3 times. Disabling plugin.`);
66
+ logger.error(` message : ${error.message}`);
67
+ if (!isTimeout) {
68
+ const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
69
+ logger.error(` at : ${frame}`);
70
+ }
71
+ }
72
+ else {
73
+ pluginRegistry.set(plugin.name, plugin);
74
+ logger.warn(`[pluginGuard] Plugin "${plugin.name}" threw an error (attempt ${errorCount}/3). Reloading...`);
75
+ logger.warn(` message : ${error.message}`);
76
+ if (!isTimeout) {
77
+ const frame = error.stack?.split("\n")[1]?.trim() ?? "(no stack)";
78
+ logger.warn(` at : ${frame}`);
79
+ }
80
+ // Reload the plugin dynamically to avoid circular dependency
81
+ import("#kernel/pluginLoader.js").then(({ reloadPlugin }) => {
82
+ reloadPlugin(plugin.name).catch(err => {
83
+ logger.error(`[pluginGuard] Failed to reload plugin "${plugin.name}": ${err.message}`);
84
+ });
85
+ });
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,322 @@
1
+ /**
2
+ * pluginLoader.ts
3
+ *
4
+ * Responsible for:
5
+ * 1. Reading active plugins (config.ts imports this module and give the list)
6
+ * 2. Loading each plugin from ~/.manybot/plugins folder
7
+ * 3. Registering in pluginRegistry with status and public exports
8
+ * 4. Exposing pluginRegistry to kernel and pluginApi
9
+ * 5. Watching plugin files and config file for hot reloading
10
+ */
11
+ import fs from "fs";
12
+ import path from "path";
13
+ import { logger } from "#logger";
14
+ import { t } from "#i18n";
15
+ import { pathToFileURL } from "url";
16
+ import { PATHS } from "#config";
17
+ import { buildSetupApi, cleanupPluginEvents } from "#manyapi";
18
+ const PLUGINS_DIR = path.join(PATHS.HOME, "plugins");
19
+ export const pluginRegistry = new Map();
20
+ let globalSock = null;
21
+ let globalStore = null;
22
+ const pluginWatchers = new Map();
23
+ let configWatcher = null;
24
+ /**
25
+ * Load all active plugins listed in `activePlugins`.
26
+ * Called once during bot initialization.
27
+ *
28
+ * @param {string[]} activePlugins — active plugin names
29
+ */
30
+ export async function loadPlugins(activePlugins) {
31
+ if (!fs.existsSync(PLUGINS_DIR)) {
32
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
33
+ }
34
+ for (const name of activePlugins) {
35
+ await loadPlugin(name);
36
+ }
37
+ startConfigWatcher();
38
+ const total = pluginRegistry.size;
39
+ const active = [...pluginRegistry.values()].filter(p => p.status === "active").length;
40
+ const errors = total - active;
41
+ logger.success(t("system.pluginsLoaded", {
42
+ count: active,
43
+ errors: errors ? t("system.pluginsLoadedWithErrors", { count: errors }) : ""
44
+ }));
45
+ }
46
+ /**
47
+ * Call setup(api) on all plugins that export it.
48
+ * Executed once after bot connects.
49
+ */
50
+ export async function setupPlugins(sock, store) {
51
+ globalSock = sock;
52
+ globalStore = store;
53
+ for (const plugin of pluginRegistry.values()) {
54
+ if (plugin.status !== "active" || !plugin.setup)
55
+ continue;
56
+ try {
57
+ const api = buildSetupApi(sock, store, pluginRegistry, plugin.name);
58
+ await plugin.setup(api);
59
+ }
60
+ catch (e) {
61
+ const err = e instanceof Error ? e : new Error(String(e));
62
+ logger.error(t("system.pluginSetupFailed", {
63
+ name: plugin.name,
64
+ message: err.message
65
+ }));
66
+ }
67
+ }
68
+ }
69
+ async function findPluginPath(name) {
70
+ const dir = path.join(PLUGINS_DIR, name);
71
+ const manifest = path.join(dir, "manyplug.json");
72
+ if (!fs.existsSync(manifest))
73
+ return null;
74
+ const data = JSON.parse(await fs.promises.readFile(manifest, "utf8"));
75
+ const candidates = [
76
+ data.main,
77
+ "index.js",
78
+ "index.ts"
79
+ ].filter((v) => typeof v === "string" &&
80
+ v.trim().length > 0);
81
+ for (const file of candidates) {
82
+ const entry = path.join(dir, file);
83
+ if (fs.existsSync(entry))
84
+ return entry;
85
+ }
86
+ return null;
87
+ }
88
+ /**
89
+ * Load a single plugin by name.
90
+ * @param {string} name
91
+ * @param {boolean} isReload
92
+ */
93
+ export async function loadPlugin(name, isReload = false) {
94
+ const pluginPath = await findPluginPath(name);
95
+ const existing = pluginRegistry.get(name);
96
+ const errorCount = existing ? (existing.errorCount ?? 0) : 0;
97
+ if (!pluginPath) {
98
+ logger.warn(t("system.pluginNotFound", { name, path: path.join(PLUGINS_DIR, name) }));
99
+ pluginRegistry.set(name, {
100
+ name,
101
+ status: "disabled",
102
+ run: null,
103
+ setup: null,
104
+ exports: null,
105
+ error: null,
106
+ guardOptions: {},
107
+ errorCount: 0
108
+ });
109
+ unwatchPlugin(name);
110
+ return;
111
+ }
112
+ if (!fs.existsSync(pluginPath)) {
113
+ logger.warn(t("system.pluginNotFound", { name, path: pluginPath }));
114
+ pluginRegistry.set(name, {
115
+ name,
116
+ status: "disabled",
117
+ run: null,
118
+ setup: null,
119
+ exports: null,
120
+ error: null,
121
+ guardOptions: {},
122
+ errorCount: 0
123
+ });
124
+ unwatchPlugin(name);
125
+ return;
126
+ }
127
+ try {
128
+ const importUrl = pathToFileURL(pluginPath).href + (isReload ? `?update=${Date.now()}` : "");
129
+ const mod = await import(importUrl);
130
+ // Plugin must export a default function — this is called on every message
131
+ if (typeof mod.default !== "function") {
132
+ throw new Error(`Plugin "${name}" does not export a default function`);
133
+ }
134
+ pluginRegistry.set(name, {
135
+ name,
136
+ status: "active",
137
+ run: mod.default,
138
+ setup: mod.setup ?? null,
139
+ exports: mod.api ?? null,
140
+ error: null,
141
+ guardOptions: mod.guardOptions ?? {},
142
+ errorCount: 0,
143
+ });
144
+ logger.info(t(isReload ? "system.pluginReloaded" : "system.pluginLoaded", { name }));
145
+ watchPluginDirectory(name);
146
+ }
147
+ catch (e) {
148
+ const err = e instanceof Error ? e : new Error(String(e));
149
+ logger.error(t("system.pluginLoadFailed", { name, message: err.message }));
150
+ const newErrorCount = isReload ? (errorCount + 1) : 3;
151
+ pluginRegistry.set(name, {
152
+ name,
153
+ status: newErrorCount >= 3 ? "error" : "active",
154
+ run: null,
155
+ setup: null,
156
+ exports: null,
157
+ error: err,
158
+ guardOptions: {},
159
+ errorCount: newErrorCount,
160
+ });
161
+ }
162
+ }
163
+ /**
164
+ * Reload a single plugin dynamically.
165
+ */
166
+ export async function reloadPlugin(name) {
167
+ const plugin = pluginRegistry.get(name);
168
+ if (!plugin)
169
+ return;
170
+ if (globalSock) {
171
+ cleanupPluginEvents(name, globalSock);
172
+ }
173
+ await loadPlugin(name, true);
174
+ const updatedPlugin = pluginRegistry.get(name);
175
+ if (updatedPlugin && updatedPlugin.status === "active" && updatedPlugin.setup && globalSock && globalStore) {
176
+ try {
177
+ const api = buildSetupApi(globalSock, globalStore, pluginRegistry, name);
178
+ await updatedPlugin.setup(api);
179
+ }
180
+ catch (e) {
181
+ const err = e instanceof Error ? e : new Error(String(e));
182
+ logger.error(`[pluginLoader] Setup failed during reload for "${name}": ${err.message}`);
183
+ const newErrorCount = (updatedPlugin.errorCount ?? 0) + 1;
184
+ updatedPlugin.errorCount = newErrorCount;
185
+ updatedPlugin.error = err;
186
+ if (newErrorCount >= 3) {
187
+ updatedPlugin.status = "error";
188
+ logger.error(`[pluginLoader] Plugin "${name}" failed setup 3 times and was disabled.`);
189
+ }
190
+ else {
191
+ logger.warn(`[pluginLoader] Retrying reload for "${name}" (setup error, attempt ${newErrorCount}/3)`);
192
+ reloadPlugin(name).catch(() => { });
193
+ }
194
+ }
195
+ }
196
+ }
197
+ /**
198
+ * Sync active plugins based on config file updates.
199
+ */
200
+ export async function syncPlugins() {
201
+ const oldActive = new Set([...pluginRegistry.entries()]
202
+ .filter(([_, p]) => p.status === "active")
203
+ .map(([name]) => name));
204
+ const { reloadConfig, PLUGINS } = await import("#config");
205
+ await reloadConfig();
206
+ const newActive = new Set(PLUGINS);
207
+ // Disable plugins that were removed from config
208
+ for (const name of oldActive) {
209
+ if (!newActive.has(name)) {
210
+ logger.info(`[pluginLoader] Disabling plugin "${name}"`);
211
+ const plugin = pluginRegistry.get(name);
212
+ if (plugin) {
213
+ if (globalSock) {
214
+ cleanupPluginEvents(name, globalSock);
215
+ }
216
+ plugin.status = "disabled";
217
+ unwatchPlugin(name);
218
+ }
219
+ }
220
+ }
221
+ // Enable/load plugins that were added to config
222
+ for (const name of newActive) {
223
+ if (!oldActive.has(name)) {
224
+ logger.info(`[pluginLoader] Enabling plugin "${name}"`);
225
+ await loadPlugin(name);
226
+ const plugin = pluginRegistry.get(name);
227
+ if (plugin && plugin.status === "active" && plugin.setup && globalSock && globalStore) {
228
+ try {
229
+ const api = buildSetupApi(globalSock, globalStore, pluginRegistry, name);
230
+ await plugin.setup(api);
231
+ }
232
+ catch (e) {
233
+ const err = e instanceof Error ? e : new Error(String(e));
234
+ logger.error(`[pluginLoader] Setup failed for newly enabled plugin "${name}": ${err.message}`);
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ /**
241
+ * Watch a plugin's directory for changes.
242
+ */
243
+ export function watchPluginDirectory(name) {
244
+ if (pluginWatchers.has(name))
245
+ return;
246
+ const dir = path.join(PLUGINS_DIR, name);
247
+ if (!fs.existsSync(dir))
248
+ return;
249
+ try {
250
+ let watchTimeout = null;
251
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
252
+ if (watchTimeout)
253
+ clearTimeout(watchTimeout);
254
+ watchTimeout = setTimeout(async () => {
255
+ logger.info(`[watcher] Plugin "${name}" file change detected (${filename}). Reloading...`);
256
+ await reloadPlugin(name);
257
+ }, 500);
258
+ });
259
+ pluginWatchers.set(name, watcher);
260
+ }
261
+ catch (e) {
262
+ const err = e instanceof Error ? e : new Error(String(e));
263
+ logger.warn(`[watcher] Failed to watch plugin "${name}" directory: ${err.message}`);
264
+ }
265
+ }
266
+ /**
267
+ * Stop watching a plugin's directory.
268
+ */
269
+ export function unwatchPlugin(name) {
270
+ const watcher = pluginWatchers.get(name);
271
+ if (watcher) {
272
+ watcher.close();
273
+ pluginWatchers.delete(name);
274
+ }
275
+ }
276
+ /**
277
+ * Watch the config directory for manyplug.toml or manybot.toml changes.
278
+ */
279
+ function startConfigWatcher() {
280
+ if (configWatcher)
281
+ return;
282
+ try {
283
+ let configTimeout = null;
284
+ configWatcher = fs.watch(PATHS.HOME, (eventType, filename) => {
285
+ if (filename === "manyplug.toml" || filename === "manybot.toml") {
286
+ if (configTimeout)
287
+ clearTimeout(configTimeout);
288
+ configTimeout = setTimeout(async () => {
289
+ logger.info(`[watcher] Config file change detected: ${filename}. Syncing plugins...`);
290
+ await syncPlugins();
291
+ }, 500);
292
+ }
293
+ });
294
+ }
295
+ catch (e) {
296
+ const err = e instanceof Error ? e : new Error(String(e));
297
+ logger.warn(`[watcher] Failed to start config directory watcher: ${err.message}`);
298
+ }
299
+ }
300
+ export async function cleanupPlugins() {
301
+ if (configWatcher) {
302
+ configWatcher.close();
303
+ configWatcher = null;
304
+ }
305
+ for (const [name, watcher] of pluginWatchers.entries()) {
306
+ watcher.close();
307
+ }
308
+ pluginWatchers.clear();
309
+ for (const plugin of pluginRegistry.values()) {
310
+ try {
311
+ const evts = plugin.exports?.["events"];
312
+ await evts?.["cleanup"]?.();
313
+ }
314
+ catch (e) {
315
+ const err = e instanceof Error ? e : new Error(String(e));
316
+ logger.error(t("system.pluginCleanupFailed", {
317
+ name: plugin.name,
318
+ message: err.message
319
+ }));
320
+ }
321
+ }
322
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * scheduler.ts
3
+ *
4
+ * Allows plugins to register scheduled tasks via cron.
5
+ * Uses node-cron underneath, but plugins never import node-cron directly —
6
+ * they only call ctx.scheduler.schedule(cron, fn).
7
+ *
8
+ * Registrations are deduped per (pluginName, expression) and persisted to
9
+ * disk so a hot-reload never stacks duplicate crons, and a restart doesn't
10
+ * silently lose track of what each plugin previously scheduled. Plugins
11
+ * still need to call schedule() again on boot/setup — functions can't be
12
+ * serialized — but doing so now replaces the old entry instead of adding
13
+ * a new one, and getPersisted() lets the kernel/log confirm what survived.
14
+ *
15
+ * Usage in plugin:
16
+ * export default async function (ctx) {
17
+ * ctx.scheduler.schedule("0 9 * * 1", async () => {
18
+ * await ctx.send.text("Good morning!");
19
+ * });
20
+ * }
21
+ */
22
+ import cron from "node-cron";
23
+ import Database from "better-sqlite3";
24
+ import path from "path";
25
+ import { mkdirSync } from "fs";
26
+ import { logger } from "#logger";
27
+ import { t } from "#i18n";
28
+ import { CONFIG_DIR } from "#config";
29
+ // key = `${pluginName}::${expression}`
30
+ const tasks = new Map();
31
+ // ── Persistence (metadata only — fn can't be serialized) ────────────────────
32
+ mkdirSync(CONFIG_DIR, { recursive: true });
33
+ const db = new Database(path.join(CONFIG_DIR, "scheduler.db"));
34
+ db.pragma("journal_mode = WAL");
35
+ db.exec(`
36
+ CREATE TABLE IF NOT EXISTS scheduled_tasks (
37
+ plugin_name TEXT NOT NULL,
38
+ expression TEXT NOT NULL,
39
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
40
+ PRIMARY KEY (plugin_name, expression)
41
+ );
42
+ `);
43
+ const stmtUpsert = db.prepare(`INSERT INTO scheduled_tasks (plugin_name, expression) VALUES (?, ?)
44
+ ON CONFLICT(plugin_name, expression) DO UPDATE SET updated_at = unixepoch()`);
45
+ const stmtDeleteOne = db.prepare(`DELETE FROM scheduled_tasks WHERE plugin_name = ? AND expression = ?`);
46
+ const stmtDeletePlugin = db.prepare(`DELETE FROM scheduled_tasks WHERE plugin_name = ?`);
47
+ const stmtAll = db.prepare(`SELECT plugin_name, expression FROM scheduled_tasks`);
48
+ /** Rows persisted from previous runs — for diagnostics/logging on boot. */
49
+ export function getPersisted() {
50
+ return stmtAll.all().map(r => ({
51
+ pluginName: r.plugin_name,
52
+ expression: r.expression,
53
+ }));
54
+ }
55
+ // ── Scheduling ────────────────────────────────────────────────────────────
56
+ /**
57
+ * Register a cron task.
58
+ * Calling this again with the same (pluginName, expression) replaces the
59
+ * previous task instead of stacking a new one — this is what fixed the
60
+ * unbounded leak on plugin reload.
61
+ *
62
+ * @param {string} expression — cron expression e.g., "0 9 * * 1"
63
+ * @param {Function} fn — async function to execute
64
+ * @param {string} pluginName — plugin name (for logging/scoping)
65
+ */
66
+ export function schedule(expression, fn, pluginName = "unknown") {
67
+ if (!cron.validate(expression)) {
68
+ logger.warn(t("system.schedulerInvalidCron", { name: pluginName, expression }));
69
+ return { stop() { } };
70
+ }
71
+ const key = `${pluginName}::${expression}`;
72
+ tasks.get(key)?.task.stop();
73
+ const task = cron.schedule(expression, async () => {
74
+ try {
75
+ await fn();
76
+ }
77
+ catch (e) {
78
+ const err = e instanceof Error ? e : new Error(String(e));
79
+ logger.error(t("system.schedulerError", { name: pluginName, message: err.message }));
80
+ }
81
+ });
82
+ tasks.set(key, { pluginName, expression, task });
83
+ stmtUpsert.run(pluginName, expression);
84
+ logger.info(t("system.schedulerRegistered", { name: pluginName, expression }));
85
+ return {
86
+ stop() {
87
+ if (tasks.get(key)?.task !== task)
88
+ return; // already replaced/stopped
89
+ task.stop();
90
+ tasks.delete(key);
91
+ stmtDeleteOne.run(pluginName, expression);
92
+ },
93
+ };
94
+ }
95
+ /** Stop and forget every task registered by one plugin (reload/unload). */
96
+ export function cancelPlugin(pluginName) {
97
+ for (const [key, entry] of tasks) {
98
+ if (entry.pluginName !== pluginName)
99
+ continue;
100
+ entry.task.stop();
101
+ tasks.delete(key);
102
+ }
103
+ stmtDeletePlugin.run(pluginName);
104
+ }
105
+ /** Stop all schedules in memory (process shutdown) — keeps persisted rows. */
106
+ export function stopAll() {
107
+ for (const { task } of tasks.values())
108
+ task.stop();
109
+ tasks.clear();
110
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * sendGuard.ts
3
+ *
4
+ * Anti-detection throttle layer for all outbound sends.
5
+ *
6
+ * Three protections applied before every message:
7
+ * 1. Global token bucket — hard cap on messages/second across all chats
8
+ * 2. Per-chat cooldown — minimum gap between sends to the same chat
9
+ * 3. Human jitter — random delay to break robotic timing patterns
10
+ *
11
+ * Text sends simulate the typing/recording presence indicator before
12
+ * the message arrives, so the chat shows "typing..." realistically.
13
+ */
14
+ import { logger } from "#logger";
15
+ // ── Tunables ──────────────────────────────────────────────────────────────────
16
+ const GLOBAL_MSG_PER_SEC = 5;
17
+ const CHAT_COOLDOWN_MS = 150;
18
+ const JITTER_MS = { min: 50, max: 200 };
19
+ const TYPING_CPS = 90;
20
+ const TYPING_MAX_MS = 2000;
21
+ const MEDIA_INDICATOR_MS = { min: 400, max: 1000 };
22
+ // ── Global token bucket ───────────────────────────────────────────────────────
23
+ const MS_PER_TOKEN = 1000 / GLOBAL_MSG_PER_SEC;
24
+ let tokens = GLOBAL_MSG_PER_SEC;
25
+ let lastRefill = Date.now();
26
+ function consumeGlobalToken() {
27
+ const now = Date.now();
28
+ const elapsed = now - lastRefill;
29
+ tokens = Math.min(GLOBAL_MSG_PER_SEC, tokens + elapsed / MS_PER_TOKEN);
30
+ lastRefill = now;
31
+ if (tokens >= 1) {
32
+ tokens -= 1;
33
+ return 0;
34
+ }
35
+ return Math.ceil((1 - tokens) * MS_PER_TOKEN);
36
+ }
37
+ // ── Per-chat cooldown ─────────────────────────────────────────────────────────
38
+ const lastSentAt = new Map();
39
+ function chatCooldownMs(jid) {
40
+ const wait = (lastSentAt.get(jid) ?? 0) + CHAT_COOLDOWN_MS - Date.now();
41
+ return wait > 0 ? wait : 0;
42
+ }
43
+ function recordSend(jid) {
44
+ lastSentAt.set(jid, Date.now());
45
+ }
46
+ // ── Helpers ───────────────────────────────────────────────────────────────────
47
+ const sleep = (ms) => new Promise(r => setTimeout(r, ms));
48
+ function randomJitter() {
49
+ return JITTER_MS.min + Math.random() * (JITTER_MS.max - JITTER_MS.min);
50
+ }
51
+ /**
52
+ * How long the typing indicator should appear before sending text.
53
+ * @param {string} text
54
+ * @returns {number} ms
55
+ */
56
+ export function typingDuration(text) {
57
+ if (typeof text !== "string" || text.length === 0)
58
+ return 0;
59
+ return Math.min((text.length / TYPING_CPS) * 1000, TYPING_MAX_MS);
60
+ }
61
+ /**
62
+ * A human-feeling duration for media "processing" indicator.
63
+ * @returns {number} ms
64
+ */
65
+ export function mediaDuration() {
66
+ return MEDIA_INDICATOR_MS.min
67
+ + Math.random() * (MEDIA_INDICATOR_MS.max - MEDIA_INDICATOR_MS.min);
68
+ }
69
+ // ── Public API ────────────────────────────────────────────────────────────────
70
+ /**
71
+ * Wait for a safe send slot: global rate → per-chat cooldown → jitter.
72
+ * Must be called before every outbound message.
73
+ *
74
+ * @param {string} jid
75
+ * @param {object} [opts]
76
+ * @param {boolean} [opts.cooldown=true]
77
+ * @param {boolean} [opts.jitter=true]
78
+ */
79
+ export async function waitForSendSlot(jid, { cooldown = true, jitter = true } = {}) {
80
+ const tokenWait = consumeGlobalToken();
81
+ if (tokenWait > 0) {
82
+ logger.debug(`[sendGuard] global rate hit — queuing ${tokenWait}ms`);
83
+ await sleep(tokenWait);
84
+ }
85
+ if (cooldown) {
86
+ const coolWait = chatCooldownMs(jid);
87
+ if (coolWait > 0) {
88
+ logger.debug(`[sendGuard] chat cooldown (${jid}) — waiting ${coolWait}ms`);
89
+ await sleep(coolWait);
90
+ }
91
+ }
92
+ if (jitter)
93
+ await sleep(randomJitter());
94
+ recordSend(jid);
95
+ }
96
+ /**
97
+ * Show a presence indicator for `ms` milliseconds, then clear it.
98
+ * No-op on drivers without the "presence" capability. Best-effort —
99
+ * errors are swallowed.
100
+ *
101
+ * @param {PresenceCapable|null} adapter
102
+ * @param {string|null} chatId
103
+ * @param {number} ms
104
+ * @param {"typing"|"recording"} [state="typing"]
105
+ */
106
+ export async function simulateState(adapter, chatId, ms, state = "typing") {
107
+ if (!adapter || !chatId || ms <= 0)
108
+ return;
109
+ if (!adapter.capabilities.has("presence") || !adapter.setPresence)
110
+ return;
111
+ try {
112
+ // Adapter contract only knows "composing" — recording is a WhatsApp nuance
113
+ // collapsed here until a driver needs to distinguish it.
114
+ await adapter.setPresence(chatId, "composing");
115
+ await sleep(ms);
116
+ await adapter.setPresence(chatId, "paused");
117
+ }
118
+ catch (e) {
119
+ logger.debug(`[sendGuard] presence simulation failed (non-fatal): ${e.message}`);
120
+ }
121
+ }