@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.
- package/dist/client/banner.js +35 -0
- package/dist/client/store.js +135 -0
- package/dist/config.js +363 -0
- package/dist/core/adapter.js +12 -0
- package/dist/core/capabilities.js +16 -0
- package/dist/core/types.js +6 -0
- package/dist/download/queue.js +49 -0
- package/dist/drivers/index.js +14 -0
- package/dist/drivers/patches/index.js +9 -0
- package/dist/drivers/patches/libsignal.js +16 -0
- package/dist/drivers/patches/patch.js +1 -0
- package/dist/drivers/whatsapp/adapter.js +7 -0
- package/dist/drivers/whatsapp/api/index.js +1311 -0
- package/dist/drivers/whatsapp/index.js +164 -0
- package/dist/drivers/whatsapp/loginPrompt.js +81 -0
- package/dist/drivers/whatsapp/messageHandler.js +111 -0
- package/dist/drivers/whatsapp/sdk/baileysSock.js +124 -0
- package/dist/i18n/index.js +202 -0
- package/dist/kernel/pluginApi.js +11 -0
- package/dist/kernel/pluginGuard.js +88 -0
- package/dist/kernel/pluginLoader.js +322 -0
- package/dist/kernel/scheduler.js +110 -0
- package/dist/kernel/sendGuard.js +121 -0
- package/dist/kernel/settingsDb.js +205 -0
- package/dist/locales/en.json +52 -0
- package/dist/locales/es.json +52 -0
- package/dist/locales/pt.json +52 -0
- package/dist/logger/logger.js +16 -0
- package/dist/main.js +82 -0
- package/dist/types.js +16 -0
- package/dist/utils/file.js +9 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|