@manybot/manybot 4.0.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/LICENSE +674 -0
- package/README.md +18 -0
- package/package.json +53 -0
- package/src/client/banner.js +57 -0
- package/src/client/whatsappClient.js +103 -0
- package/src/config.js +196 -0
- package/src/download/queue.js +55 -0
- package/src/i18n/index.js +235 -0
- package/src/kernel/messageHandler.js +39 -0
- package/src/kernel/pluginApi.js +303 -0
- package/src/kernel/pluginGuard.js +37 -0
- package/src/kernel/pluginLoader.js +112 -0
- package/src/kernel/pluginState.js +99 -0
- package/src/kernel/scheduler.js +48 -0
- package/src/locales/en.json +64 -0
- package/src/locales/es.json +59 -0
- package/src/locales/pt.json +64 -0
- package/src/logger/logger.js +31 -0
- package/src/main.js +105 -0
- package/src/utils/file.js +9 -0
- package/src/utils/getChatId.js +3 -0
- package/src/utils/get_id.js +177 -0
- package/src/utils/pluginI18n.js +129 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n/index.js
|
|
3
|
+
*
|
|
4
|
+
* Internationalization system for ManyBot.
|
|
5
|
+
* Loads translations based on LANGUAGE configuration.
|
|
6
|
+
* Fallback is always English (en).
|
|
7
|
+
*
|
|
8
|
+
* Plugins can use createPluginT() to have isolated i18n.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from "fs";
|
|
12
|
+
import path from "path";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
import { CONFIG } from "#config";
|
|
15
|
+
|
|
16
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const LOCALES_DIR = path.join(__dirname, "..", "locales");
|
|
18
|
+
|
|
19
|
+
// Default language (fallback)
|
|
20
|
+
const DEFAULT_LANG = "en";
|
|
21
|
+
|
|
22
|
+
// Cache of loaded translations
|
|
23
|
+
const translations = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Loads a translation JSON file
|
|
27
|
+
* @param {string} lang - language code (en, pt, es)
|
|
28
|
+
* @returns {object|null}
|
|
29
|
+
*/
|
|
30
|
+
function loadLocale(lang) {
|
|
31
|
+
if (translations.has(lang)) {
|
|
32
|
+
return translations.get(lang);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(filePath)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
42
|
+
const data = JSON.parse(content);
|
|
43
|
+
translations.set(lang, data);
|
|
44
|
+
return data;
|
|
45
|
+
} catch (err) {
|
|
46
|
+
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Gets configured language or default
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
function getConfiguredLang() {
|
|
56
|
+
const lang = CONFIG.LANGUAGE?.trim().toLowerCase();
|
|
57
|
+
if (!lang) return DEFAULT_LANG;
|
|
58
|
+
|
|
59
|
+
// Check if file exists
|
|
60
|
+
const filePath = path.join(LOCALES_DIR, `${lang}.json`);
|
|
61
|
+
if (!fs.existsSync(filePath)) {
|
|
62
|
+
console.warn(`[i18n] Language "${lang}" not found, falling back to "${DEFAULT_LANG}"`);
|
|
63
|
+
return DEFAULT_LANG;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lang;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load languages
|
|
70
|
+
const currentLang = getConfiguredLang();
|
|
71
|
+
const currentTranslations = loadLocale(currentLang) || {};
|
|
72
|
+
const fallbackTranslations = loadLocale(DEFAULT_LANG) || {};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Gets a nested value from an object using dot path
|
|
76
|
+
* @param {object} obj
|
|
77
|
+
* @param {string} key - path like "system.connected"
|
|
78
|
+
* @returns {string|undefined}
|
|
79
|
+
*/
|
|
80
|
+
function getNestedValue(obj, key) {
|
|
81
|
+
const parts = key.split(".");
|
|
82
|
+
let current = obj;
|
|
83
|
+
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (current === null || current === undefined || typeof current !== "object") {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
current = current[part];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return current;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Replaces placeholders {{key}} with values from context
|
|
96
|
+
* @param {string} str
|
|
97
|
+
* @param {object} context
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function interpolate(str, context = {}) {
|
|
101
|
+
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
102
|
+
return context[key] !== undefined ? String(context[key]) : match;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Main translation function
|
|
108
|
+
* @param {string} key - translation key (e.g., "system.connected")
|
|
109
|
+
* @param {object} context - values to interpolate {{key}}
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
export function t(key, context = {}) {
|
|
113
|
+
// Try current language first
|
|
114
|
+
let value = getNestedValue(currentTranslations, key);
|
|
115
|
+
|
|
116
|
+
// Fallback to English if not found
|
|
117
|
+
if (value === undefined) {
|
|
118
|
+
value = getNestedValue(fallbackTranslations, key);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// If still not found, return the key
|
|
122
|
+
if (value === undefined) {
|
|
123
|
+
return key;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If not string, convert
|
|
127
|
+
if (typeof value !== "string") {
|
|
128
|
+
return String(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Interpolate values
|
|
132
|
+
return interpolate(value, context);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Creates an isolated translation function for a plugin.
|
|
137
|
+
* Plugins should have their own locale/ folder with en.json, es.json, etc.
|
|
138
|
+
*
|
|
139
|
+
* Usage in plugin:
|
|
140
|
+
* import { createPluginT } from "../../i18n/index.js";
|
|
141
|
+
* const { t } = createPluginT(import.meta.url);
|
|
142
|
+
*
|
|
143
|
+
* Folder structure:
|
|
144
|
+
* myPlugin/
|
|
145
|
+
* index.js
|
|
146
|
+
* locale/
|
|
147
|
+
* en.json
|
|
148
|
+
* es.json
|
|
149
|
+
* pt.json
|
|
150
|
+
*
|
|
151
|
+
* @param {string} pluginMetaUrl - import.meta.url from the plugin
|
|
152
|
+
* @returns {{ t: Function, lang: string }}
|
|
153
|
+
*/
|
|
154
|
+
export function createPluginT(pluginMetaUrl) {
|
|
155
|
+
const pluginDir = path.dirname(fileURLToPath(pluginMetaUrl));
|
|
156
|
+
const pluginLocaleDir = path.join(pluginDir, "locale");
|
|
157
|
+
|
|
158
|
+
// Get bot's configured language
|
|
159
|
+
const targetLang = currentLang;
|
|
160
|
+
|
|
161
|
+
// Load plugin translations
|
|
162
|
+
let pluginTranslations = {};
|
|
163
|
+
let pluginFallback = {};
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Try to load the configured language
|
|
167
|
+
const targetPath = path.join(pluginLocaleDir, `${targetLang}.json`);
|
|
168
|
+
if (fs.existsSync(targetPath)) {
|
|
169
|
+
pluginTranslations = JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Always load English as fallback
|
|
173
|
+
const fallbackPath = path.join(pluginLocaleDir, `${DEFAULT_LANG}.json`);
|
|
174
|
+
if (fs.existsSync(fallbackPath)) {
|
|
175
|
+
pluginFallback = JSON.parse(fs.readFileSync(fallbackPath, "utf8"));
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// Silent fail - plugin may not have translations
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Plugin-specific translation function
|
|
183
|
+
* @param {string} key
|
|
184
|
+
* @param {object} context
|
|
185
|
+
* @returns {string}
|
|
186
|
+
*/
|
|
187
|
+
function pluginT(key, context = {}) {
|
|
188
|
+
// Try plugin's target language first
|
|
189
|
+
let value = getNestedValue(pluginTranslations, key);
|
|
190
|
+
|
|
191
|
+
// Fallback to plugin's English
|
|
192
|
+
if (value === undefined) {
|
|
193
|
+
value = getNestedValue(pluginFallback, key);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// If still not found, return the key
|
|
197
|
+
if (value === undefined) {
|
|
198
|
+
return key;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (typeof value !== "string") {
|
|
202
|
+
return String(value);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return interpolate(value, context);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return { t: pluginT, lang: targetLang };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Reloads translations (useful for hot-reload)
|
|
213
|
+
*/
|
|
214
|
+
export function reloadTranslations() {
|
|
215
|
+
translations.clear();
|
|
216
|
+
const lang = getConfiguredLang();
|
|
217
|
+
const newTranslations = loadLocale(lang) || {};
|
|
218
|
+
const newFallback = loadLocale(DEFAULT_LANG) || {};
|
|
219
|
+
|
|
220
|
+
// Update references
|
|
221
|
+
Object.assign(currentTranslations, newTranslations);
|
|
222
|
+
Object.assign(fallbackTranslations, newFallback);
|
|
223
|
+
|
|
224
|
+
console.log(`[i18n] Translations reloaded for language: ${lang}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns current language
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
export function getCurrentLang() {
|
|
232
|
+
return currentLang;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export default { t, createPluginT, reloadTranslations, getCurrentLang };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* messageHandler.js
|
|
3
|
+
*
|
|
4
|
+
* Central pipeline for received messages.
|
|
5
|
+
*
|
|
6
|
+
* Order:
|
|
7
|
+
* 1. Filter allowed chats (CHATS from .conf)
|
|
8
|
+
* — if CHATS is empty, accepts all chats
|
|
9
|
+
* 2. Log the message
|
|
10
|
+
* 3. Pass context to all active plugins
|
|
11
|
+
*
|
|
12
|
+
* Kernel knows no commands — only distributes.
|
|
13
|
+
* Each plugin decides on its own whether to act or ignore.
|
|
14
|
+
*/
|
|
15
|
+
|
|
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";
|
|
22
|
+
|
|
23
|
+
export async function handleMessage(msg) {
|
|
24
|
+
const chat = await msg.getChat();
|
|
25
|
+
const chatId = getChatId(chat);
|
|
26
|
+
|
|
27
|
+
if (CHATS.length > 0 && !CHATS.includes(chatId)) return;
|
|
28
|
+
|
|
29
|
+
const ctx = buildApi({
|
|
30
|
+
msg,
|
|
31
|
+
chat,
|
|
32
|
+
client,
|
|
33
|
+
pluginRegistry
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
for (const plugin of pluginRegistry.values()) {
|
|
37
|
+
await runPlugin(plugin, ctx);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginApi.js
|
|
3
|
+
*
|
|
4
|
+
* Builds the `ctx` object each plugin receives.
|
|
5
|
+
* Plugins can only do what's here — never touch client directly.
|
|
6
|
+
*
|
|
7
|
+
* `chat` is already filtered by kernel (only allowed chats from .conf),
|
|
8
|
+
* so plugins don't need and can't choose destination.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { logger } from "#logger";
|
|
12
|
+
import { t, createPluginT, reloadTranslations,
|
|
13
|
+
getCurrentLang } from "#i18n";
|
|
14
|
+
import { CONFIG } from "#config";
|
|
15
|
+
import { enqueue } from "#download";
|
|
16
|
+
import { emptyFolder } from "#utils/file";
|
|
17
|
+
import { getChatId } from "#utils/getChatId";
|
|
18
|
+
import pkg from "whatsapp-web.js";
|
|
19
|
+
import { client } from "#client/whatsappClient";
|
|
20
|
+
|
|
21
|
+
const { MessageMedia } = pkg;
|
|
22
|
+
|
|
23
|
+
// ── Config API ───────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
function buildConfigApi() {
|
|
26
|
+
return {
|
|
27
|
+
/**
|
|
28
|
+
* Get a config value with optional default.
|
|
29
|
+
* @param {string} key
|
|
30
|
+
* @param {any} [defaultValue]
|
|
31
|
+
*/
|
|
32
|
+
get(key, defaultValue = null) {
|
|
33
|
+
return CONFIG[key] ?? defaultValue;
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
/** Full config object — read only. */
|
|
37
|
+
all: CONFIG,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── i18n API ─────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function buildI18nApi() {
|
|
44
|
+
return {
|
|
45
|
+
/** Translate a core key. */
|
|
46
|
+
t,
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Create a scoped t() for a plugin's own locale files.
|
|
50
|
+
* @param {string} pluginMetaUrl — pass import.meta.url from the plugin
|
|
51
|
+
*/
|
|
52
|
+
createT: createPluginT,
|
|
53
|
+
|
|
54
|
+
/** Reload all translations (e.g. after language change). */
|
|
55
|
+
reload: reloadTranslations,
|
|
56
|
+
|
|
57
|
+
/** Returns current language code. */
|
|
58
|
+
getCurrentLang,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Utils API ────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
function buildUtilsApi() {
|
|
65
|
+
return {
|
|
66
|
+
/**
|
|
67
|
+
* Empty a folder's contents without removing the folder itself.
|
|
68
|
+
* @param {string} folder
|
|
69
|
+
*/
|
|
70
|
+
emptyFolder,
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get the serialized chat ID from a chat object.
|
|
74
|
+
* @param {import("whatsapp-web.js").Chat} chat
|
|
75
|
+
*/
|
|
76
|
+
getChatId,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Download API ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function buildDownloadApi() {
|
|
83
|
+
return {
|
|
84
|
+
/**
|
|
85
|
+
* Enqueue a download work function.
|
|
86
|
+
* @param {Function} workFn
|
|
87
|
+
* @param {Function} [errorFn]
|
|
88
|
+
*/
|
|
89
|
+
enqueue,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Plugin registry API ──────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
function buildPluginsApi(pluginRegistry) {
|
|
96
|
+
return {
|
|
97
|
+
/**
|
|
98
|
+
* Return public API of another plugin, or null if not active.
|
|
99
|
+
* @param {string} name
|
|
100
|
+
* @returns {any|null}
|
|
101
|
+
*/
|
|
102
|
+
get(name) {
|
|
103
|
+
return pluginRegistry.get(name)?.exports ?? null;
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Return public API of another plugin, or throw if not active.
|
|
108
|
+
* Analogous to require() — use when the dependency is mandatory.
|
|
109
|
+
* @param {string} name
|
|
110
|
+
* @returns {any}
|
|
111
|
+
*/
|
|
112
|
+
require(name) {
|
|
113
|
+
const plugin = pluginRegistry.get(name);
|
|
114
|
+
if (!plugin || plugin.status !== "active") {
|
|
115
|
+
throw new Error(`Plugin dependency "${name}" is not active.`);
|
|
116
|
+
}
|
|
117
|
+
return plugin.exports;
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a plugin is active.
|
|
122
|
+
* @param {string} name
|
|
123
|
+
* @returns {boolean}
|
|
124
|
+
*/
|
|
125
|
+
exists(name) {
|
|
126
|
+
return pluginRegistry.get(name)?.status === "active";
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Log API ──────────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const log = {
|
|
134
|
+
info: (...a) => logger.info(...a),
|
|
135
|
+
warn: (...a) => logger.warn(...a),
|
|
136
|
+
error: (...a) => logger.error(...a),
|
|
137
|
+
success: (...a) => logger.success(...a),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// ── Internal media helpers ───────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
function mediaFromSource(source, mimetype = "image/webp") {
|
|
143
|
+
return typeof source === "string"
|
|
144
|
+
? MessageMedia.fromFilePath(source)
|
|
145
|
+
: new MessageMedia(mimetype, source.toString("base64"));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Returns send methods bound to a target that exposes `.sendMessage()`.
|
|
150
|
+
* Used for both current-chat and sendTo variants.
|
|
151
|
+
* @param {{ sendMessage: Function }} target
|
|
152
|
+
*/
|
|
153
|
+
function makeSender(target) {
|
|
154
|
+
return {
|
|
155
|
+
async text(content) {
|
|
156
|
+
return target.sendMessage(content);
|
|
157
|
+
},
|
|
158
|
+
async image(filePath, caption = "") {
|
|
159
|
+
const media = MessageMedia.fromFilePath(filePath);
|
|
160
|
+
return target.sendMessage(media, { caption });
|
|
161
|
+
},
|
|
162
|
+
async video(filePath, caption = "") {
|
|
163
|
+
const media = MessageMedia.fromFilePath(filePath);
|
|
164
|
+
return target.sendMessage(media, { caption });
|
|
165
|
+
},
|
|
166
|
+
async audio(filePath) {
|
|
167
|
+
const media = MessageMedia.fromFilePath(filePath);
|
|
168
|
+
return target.sendMessage(media, { sendAudioAsVoice: true });
|
|
169
|
+
},
|
|
170
|
+
async sticker(source) {
|
|
171
|
+
const media = mediaFromSource(source);
|
|
172
|
+
return target.sendMessage(media, { sendMediaAsSticker: true });
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Adapts client.sendMessage(chatId, ...) to the makeSender interface. */
|
|
178
|
+
function chatIdTarget(client, chatId) {
|
|
179
|
+
return {
|
|
180
|
+
sendMessage: (content, opts) => client.sendMessage(chatId, content, opts),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Setup API ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Setup API — without message context.
|
|
188
|
+
* Passed to plugin.setup(ctx) during initialization.
|
|
189
|
+
*
|
|
190
|
+
* @param {import("whatsapp-web.js").Client} client
|
|
191
|
+
* @param {Map<string, any>} pluginRegistry
|
|
192
|
+
* @returns {object}
|
|
193
|
+
*/
|
|
194
|
+
export function buildSetupApi(client, pluginRegistry) {
|
|
195
|
+
return {
|
|
196
|
+
sendTo: (chatId, text) => client.sendMessage(chatId, text),
|
|
197
|
+
sendImageTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).image(filePath, caption),
|
|
198
|
+
sendVideoTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).video(filePath, caption),
|
|
199
|
+
sendAudioTo: (chatId, filePath) => makeSender(chatIdTarget(client, chatId)).audio(filePath),
|
|
200
|
+
sendStickerTo: (chatId, source) => makeSender(chatIdTarget(client, chatId)).sticker(source),
|
|
201
|
+
|
|
202
|
+
log,
|
|
203
|
+
t,
|
|
204
|
+
config: buildConfigApi(),
|
|
205
|
+
i18n: buildI18nApi(),
|
|
206
|
+
utils: buildUtilsApi(),
|
|
207
|
+
download: buildDownloadApi(),
|
|
208
|
+
plugins: buildPluginsApi(pluginRegistry),
|
|
209
|
+
botId: client.info?.wid?._serialized ?? null,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Runtime API ──────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Runtime API — full context with message and chat.
|
|
217
|
+
* Passed to plugin.default(ctx) on every message.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} params
|
|
220
|
+
* @param {import("whatsapp-web.js").Message} params.msg
|
|
221
|
+
* @param {import("whatsapp-web.js").Chat} params.chat
|
|
222
|
+
* @param {import("whatsapp-web.js").Client} params.client
|
|
223
|
+
* @param {Map<string, any>} params.pluginRegistry
|
|
224
|
+
* @returns {object} ctx
|
|
225
|
+
*/
|
|
226
|
+
export function buildApi({ msg, chat, client, pluginRegistry }) {
|
|
227
|
+
|
|
228
|
+
const currentSender = makeSender(chat);
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
|
|
232
|
+
// ── msg ──────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
msg: {
|
|
235
|
+
body: msg.body ?? "",
|
|
236
|
+
type: msg.type,
|
|
237
|
+
fromMe: msg.fromMe,
|
|
238
|
+
sender: msg.author || msg.from,
|
|
239
|
+
senderName: msg._data?.notifyName || String(msg.from).replace(/(:\d+)?@.*$/, ""),
|
|
240
|
+
args: msg.body?.trim().split(/\s+/) ?? [],
|
|
241
|
+
|
|
242
|
+
/** Check if message starts with a command. */
|
|
243
|
+
is(cmd) {
|
|
244
|
+
return msg.body?.trim().toLowerCase().startsWith(cmd.toLowerCase());
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
hasMedia: msg.hasMedia,
|
|
248
|
+
isGif: msg._data?.isGif ?? false,
|
|
249
|
+
|
|
250
|
+
async downloadMedia() {
|
|
251
|
+
const media = await msg.downloadMedia();
|
|
252
|
+
if (!media) return null;
|
|
253
|
+
return { mimetype: media.mimetype, data: media.data };
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
hasReply: msg.hasQuotedMsg,
|
|
257
|
+
|
|
258
|
+
async getReply() {
|
|
259
|
+
if (!msg.hasQuotedMsg) return null;
|
|
260
|
+
return msg.getQuotedMessage();
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
async reply(text) {
|
|
264
|
+
return msg.reply(text);
|
|
265
|
+
},
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
// ── chat ─────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
chat: {
|
|
271
|
+
id: chat.id._serialized,
|
|
272
|
+
name: chat.name || chat.id.user,
|
|
273
|
+
isGroup: /@g\.us$/.test(chat.id._serialized),
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// ── send (current chat) ──────────────────────────────────
|
|
277
|
+
|
|
278
|
+
send: (text) => currentSender.text(text),
|
|
279
|
+
sendImage: (filePath, caption) => currentSender.image(filePath, caption),
|
|
280
|
+
sendVideo: (filePath, caption) => currentSender.video(filePath, caption),
|
|
281
|
+
sendAudio: (filePath) => currentSender.audio(filePath),
|
|
282
|
+
sendSticker: (source) => currentSender.sticker(source),
|
|
283
|
+
|
|
284
|
+
// ── sendTo (specific chat) ───────────────────────────────
|
|
285
|
+
|
|
286
|
+
sendTo: (chatId, text) => client.sendMessage(chatId, text),
|
|
287
|
+
sendImageTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).image(filePath, caption),
|
|
288
|
+
sendVideoTo: (chatId, filePath, caption) => makeSender(chatIdTarget(client, chatId)).video(filePath, caption),
|
|
289
|
+
sendAudioTo: (chatId, filePath) => makeSender(chatIdTarget(client, chatId)).audio(filePath),
|
|
290
|
+
sendStickerTo: (chatId, source) => makeSender(chatIdTarget(client, chatId)).sticker(source),
|
|
291
|
+
|
|
292
|
+
// ── system ───────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
log,
|
|
295
|
+
t,
|
|
296
|
+
config: buildConfigApi(),
|
|
297
|
+
i18n: buildI18nApi(),
|
|
298
|
+
utils: buildUtilsApi(),
|
|
299
|
+
download: buildDownloadApi(),
|
|
300
|
+
plugins: buildPluginsApi(pluginRegistry),
|
|
301
|
+
botId: client.info?.wid?._serialized ?? null,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginGuard.js
|
|
3
|
+
*
|
|
4
|
+
* Runs a plugin safely.
|
|
5
|
+
* If plugin throws an error:
|
|
6
|
+
* - Logs error with context
|
|
7
|
+
* - Marks plugin as "error" in registry
|
|
8
|
+
* - Never crashes the bot
|
|
9
|
+
*
|
|
10
|
+
* Disabled or errored plugins are silently ignored.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { logger } from "#logger";
|
|
14
|
+
import { t } from "#i18n";
|
|
15
|
+
import { pluginRegistry } from "#kernel/pluginLoader";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} plugin — pluginRegistry entry
|
|
19
|
+
* @param {object} context — buildApi ctx
|
|
20
|
+
*/
|
|
21
|
+
export async function runPlugin(plugin, context) {
|
|
22
|
+
if (plugin.status !== "active") return;
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await plugin.run(context);
|
|
26
|
+
} catch (err) {
|
|
27
|
+
// Disable plugin to prevent further breakage
|
|
28
|
+
plugin.status = "error";
|
|
29
|
+
plugin.error = err;
|
|
30
|
+
pluginRegistry.set(plugin.name, plugin);
|
|
31
|
+
|
|
32
|
+
logger.error(
|
|
33
|
+
t("system.pluginDisabledAfterError", { name: plugin.name, message: err.message }),
|
|
34
|
+
`\n ${t("errors.stack")}: ${err.stack?.split("\n")[1]?.trim() ?? ""}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|