@tryty/easydc-js 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +912 -2
- package/examples/advanced/lanjutan.js +60 -0
- package/examples/basic.js +100 -0
- package/examples/commands/ping.js +6 -0
- package/examples/commands/profil.js +28 -0
- package/examples/mongo.js +44 -0
- package/index.d.ts +150 -0
- package/mongodb.d.ts +31 -0
- package/package.json +53 -4
- package/src/bot.js +868 -0
- package/src/context.js +218 -0
- package/src/database.js +96 -0
- package/src/errors.js +38 -0
- package/src/functions/actions.js +103 -0
- package/src/functions/components.js +108 -0
- package/src/functions/control.js +142 -0
- package/src/functions/embed.js +71 -0
- package/src/functions/eval.js +73 -0
- package/src/functions/helpers.js +86 -0
- package/src/functions/index.js +29 -0
- package/src/functions/info.js +117 -0
- package/src/functions/interact.js +63 -0
- package/src/functions/manage.js +171 -0
- package/src/functions/messages.js +128 -0
- package/src/functions/text.js +118 -0
- package/src/functions/vars.js +41 -0
- package/src/index.js +20 -0
- package/src/interpreter.js +88 -0
- package/src/parser.js +168 -0
- package/src/plugins/mongodb.js +140 -0
- package/src/util.js +188 -0
package/src/bot.js
ADDED
|
@@ -0,0 +1,868 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { BotError, Stop, ValidationError } = require('./errors');
|
|
5
|
+
const { parse } = require('./parser');
|
|
6
|
+
const { Interpreter } = require('./interpreter');
|
|
7
|
+
const { Context } = require('./context');
|
|
8
|
+
const { Database } = require('./database');
|
|
9
|
+
const { createRegistry } = require('./functions');
|
|
10
|
+
const { formatNumber } = require('./util');
|
|
11
|
+
const { f } = require('./functions/helpers');
|
|
12
|
+
|
|
13
|
+
const DEFAULT_INTENTS = [
|
|
14
|
+
'Guilds', 'GuildMessages', 'MessageContent', 'GuildMembers',
|
|
15
|
+
'GuildMessageReactions', 'DirectMessages', 'GuildModeration', 'GuildVoiceStates',
|
|
16
|
+
];
|
|
17
|
+
const FENCE = '```';
|
|
18
|
+
const EVAL_CODE = [
|
|
19
|
+
'$onlyOwner[❌ Perintah ini hanya untuk owner bot.]',
|
|
20
|
+
'$argsCheck[1;Pemakaian: eval <kode port> atau eval js <kode javascript>]',
|
|
21
|
+
`$if[$message[1]==js]${FENCE}js`,
|
|
22
|
+
'$jsEval[$messageFrom[2]]',
|
|
23
|
+
FENCE,
|
|
24
|
+
`$else${FENCE}`,
|
|
25
|
+
'$eval[$messageFrom[1]]',
|
|
26
|
+
FENCE,
|
|
27
|
+
'$endif',
|
|
28
|
+
].join('\n');
|
|
29
|
+
const DEFAULT_PARTIALS = ['Channel', 'Message', 'Reaction'];
|
|
30
|
+
const SLASH_TYPES = {
|
|
31
|
+
string: 3, integer: 4, boolean: 5, user: 6, channel: 7, role: 8, mentionable: 9, number: 10, attachment: 11,
|
|
32
|
+
};
|
|
33
|
+
const ACTIVITY_TYPES = { Playing: 0, Streaming: 1, Listening: 2, Watching: 3, Competing: 5 };
|
|
34
|
+
const EVENT_TYPES = [
|
|
35
|
+
'ready', 'memberJoin', 'memberLeave', 'messageDelete', 'messageUpdate',
|
|
36
|
+
'reactionAdd', 'reactionRemove', 'guildJoin', 'guildLeave', 'message',
|
|
37
|
+
'memberBan', 'memberUnban', 'channelCreate', 'channelDelete', 'roleCreate', 'roleDelete',
|
|
38
|
+
'threadCreate', 'voiceJoin', 'voiceLeave',
|
|
39
|
+
];
|
|
40
|
+
const VAR_SCOPES = ['user', 'server', 'channel', 'global'];
|
|
41
|
+
|
|
42
|
+
const ADAPTER_METHODS = ['get', 'set', 'delete'];
|
|
43
|
+
const looksLikeAdapter = (d) => !!d && typeof d === 'object' && ADAPTER_METHODS.some((m) => typeof d[m] === 'function');
|
|
44
|
+
|
|
45
|
+
/** Pastikan adapter database punya method wajib: get(key), set(key, value), delete(key). */
|
|
46
|
+
function checkAdapter(adapter) {
|
|
47
|
+
const missing = ADAPTER_METHODS.filter((m) => typeof adapter?.[m] !== 'function');
|
|
48
|
+
if (missing.length) throw new BotError(`Adapter database tidak lengkap, method yang kurang: ${missing.join(', ')}`);
|
|
49
|
+
return adapter;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const wildcardToRegExp = (pattern) => new RegExp(
|
|
53
|
+
`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Bot Discord yang mudah dipakai. Contoh:
|
|
58
|
+
* const bot = new Bot({ token, prefix: '!' });
|
|
59
|
+
* bot.command({ name: 'ping', code: 'Pong! $ping ms' });
|
|
60
|
+
* bot.connect();
|
|
61
|
+
*/
|
|
62
|
+
class Bot {
|
|
63
|
+
constructor(options = {}) {
|
|
64
|
+
const opts = {
|
|
65
|
+
prefix: '!',
|
|
66
|
+
mentionPrefix: true,
|
|
67
|
+
ignoreBots: true,
|
|
68
|
+
owners: [],
|
|
69
|
+
intents: 'default',
|
|
70
|
+
syncSlash: true,
|
|
71
|
+
slashGuild: null,
|
|
72
|
+
timezone: 'UTC',
|
|
73
|
+
allowedMentions: { parse: ['users'], repliedUser: false },
|
|
74
|
+
debug: false,
|
|
75
|
+
handleSignals: true,
|
|
76
|
+
eval: false,
|
|
77
|
+
database: {},
|
|
78
|
+
messages: {},
|
|
79
|
+
...options,
|
|
80
|
+
};
|
|
81
|
+
opts.prefix = (Array.isArray(opts.prefix) ? opts.prefix : [opts.prefix]).filter((p) => typeof p === 'string' && p !== '');
|
|
82
|
+
if (!opts.prefix.length && !opts.mentionPrefix) throw new BotError('Isi minimal satu prefix atau aktifkan mentionPrefix');
|
|
83
|
+
opts.owners = (Array.isArray(opts.owners) ? opts.owners : [opts.owners]).filter(Boolean).map(String);
|
|
84
|
+
opts.messages = {
|
|
85
|
+
cooldown: '⏳ Tunggu %time% lagi sebelum memakai perintah ini.',
|
|
86
|
+
error: '❌ Terjadi kesalahan saat menjalankan perintah.',
|
|
87
|
+
...opts.messages,
|
|
88
|
+
};
|
|
89
|
+
this.options = opts;
|
|
90
|
+
|
|
91
|
+
this.client = null;
|
|
92
|
+
this.registry = createRegistry();
|
|
93
|
+
this.interpreter = new Interpreter();
|
|
94
|
+
this.db = looksLikeAdapter(opts.database) ? checkAdapter(opts.database) : new Database(opts.database);
|
|
95
|
+
this.plugins = new Map();
|
|
96
|
+
this._locks = new Map();
|
|
97
|
+
this.cooldowns = new Map();
|
|
98
|
+
|
|
99
|
+
this.commands = new Map(); // nama/alias -> handler
|
|
100
|
+
this.slashes = new Map();
|
|
101
|
+
this.autocompletes = new Map();
|
|
102
|
+
this.contextMenus = new Map();
|
|
103
|
+
this.componentHandlers = [];
|
|
104
|
+
this.eventHandlers = new Map();
|
|
105
|
+
this.declaredVars = new Map();
|
|
106
|
+
this._all = [];
|
|
107
|
+
this._signalsBound = false;
|
|
108
|
+
this._ids = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ===== Pendaftaran ========================================================
|
|
112
|
+
|
|
113
|
+
_add(handler) {
|
|
114
|
+
handler.key = `${handler.kind}:${handler.name ?? handler.type ?? this._ids}:${this._ids++}`;
|
|
115
|
+
if (typeof handler.code !== 'string' || !handler.code.trim()) {
|
|
116
|
+
throw new BotError(`${handler.label}: "code" wajib berupa teks yang tidak kosong`);
|
|
117
|
+
}
|
|
118
|
+
handler.ast = null;
|
|
119
|
+
this._all.push(handler);
|
|
120
|
+
return handler;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Command berawalan prefix. { name, aliases?, code } */
|
|
124
|
+
command(def) {
|
|
125
|
+
if (!def || typeof def.name !== 'string' || !def.name.trim() || /\s/.test(def.name)) {
|
|
126
|
+
throw new BotError('command: "name" wajib berupa teks tanpa spasi');
|
|
127
|
+
}
|
|
128
|
+
const aliases = def.aliases ?? [];
|
|
129
|
+
if (!Array.isArray(aliases) || aliases.some((a) => typeof a !== 'string' || !a || /\s/.test(a))) {
|
|
130
|
+
throw new BotError(`command ${def.name}: "aliases" harus berupa array teks tanpa spasi`);
|
|
131
|
+
}
|
|
132
|
+
const handler = { kind: 'command', name: def.name, aliases, code: def.code, label: `command "${def.name}"` };
|
|
133
|
+
const names = [def.name, ...aliases].map((n) => n.toLowerCase());
|
|
134
|
+
for (const n of names) {
|
|
135
|
+
if (this.commands.has(n)) throw new BotError(`Command/alias "${n}" sudah terdaftar`);
|
|
136
|
+
}
|
|
137
|
+
this._add(handler);
|
|
138
|
+
for (const n of names) this.commands.set(n, handler);
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Slash command. { name, description, options?, code } */
|
|
143
|
+
slashCommand(def) {
|
|
144
|
+
const label = `slash "${def?.name}"`;
|
|
145
|
+
if (!def || typeof def.name !== 'string' || !/^[-_\p{L}\p{N}]{1,32}$/u.test(def.name) || def.name !== def.name.toLowerCase()) {
|
|
146
|
+
throw new BotError('slash: "name" harus huruf kecil, 1-32 karakter (huruf/angka/-/_)');
|
|
147
|
+
}
|
|
148
|
+
if (typeof def.description !== 'string' || !def.description.length || def.description.length > 100) {
|
|
149
|
+
throw new BotError(`${label}: "description" wajib diisi (1-100 karakter)`);
|
|
150
|
+
}
|
|
151
|
+
if (this.slashes.has(def.name)) throw new BotError(`Slash command "${def.name}" sudah terdaftar`);
|
|
152
|
+
const options = def.options ?? [];
|
|
153
|
+
if (!Array.isArray(options) || options.length > 25) throw new BotError(`${label}: "options" maksimal 25`);
|
|
154
|
+
let sawOptional = false;
|
|
155
|
+
const apiOptions = options.map((o) => {
|
|
156
|
+
if (!o || typeof o.name !== 'string' || !/^[-_\p{L}\p{N}]{1,32}$/u.test(o.name) || o.name !== o.name.toLowerCase()) {
|
|
157
|
+
throw new BotError(`${label}: nama opsi harus huruf kecil, 1-32 karakter`);
|
|
158
|
+
}
|
|
159
|
+
if (typeof o.description !== 'string' || !o.description || o.description.length > 100) {
|
|
160
|
+
throw new BotError(`${label}: opsi "${o.name}" butuh description (1-100 karakter)`);
|
|
161
|
+
}
|
|
162
|
+
const type = SLASH_TYPES[o.type ?? 'string'];
|
|
163
|
+
if (!type) throw new BotError(`${label}: tipe opsi "${o.type}" tidak dikenal (${Object.keys(SLASH_TYPES).join(', ')})`);
|
|
164
|
+
if (o.required && sawOptional) throw new BotError(`${label}: opsi wajib harus ditulis sebelum opsi opsional`);
|
|
165
|
+
if (!o.required) sawOptional = true;
|
|
166
|
+
const api = { type, name: o.name, description: o.description, required: !!o.required };
|
|
167
|
+
if (o.choices) {
|
|
168
|
+
if (!Array.isArray(o.choices) || o.choices.length > 25) throw new BotError(`${label}: "choices" maksimal 25`);
|
|
169
|
+
api.choices = o.choices.map((c) => ({ name: String(c.name), value: c.value }));
|
|
170
|
+
}
|
|
171
|
+
if (o.autocomplete) {
|
|
172
|
+
if (o.choices) throw new BotError(`${label}: opsi "${o.name}" tidak bisa memakai "choices" dan "autocomplete" bersamaan`);
|
|
173
|
+
if (!['string', 'integer', 'number'].includes(o.type ?? 'string')) {
|
|
174
|
+
throw new BotError(`${label}: autocomplete hanya untuk opsi string, integer, atau number`);
|
|
175
|
+
}
|
|
176
|
+
api.autocomplete = true;
|
|
177
|
+
}
|
|
178
|
+
if (o.minValue !== undefined) api.min_value = o.minValue;
|
|
179
|
+
if (o.maxValue !== undefined) api.max_value = o.maxValue;
|
|
180
|
+
if (o.minLength !== undefined) api.min_length = o.minLength;
|
|
181
|
+
if (o.maxLength !== undefined) api.max_length = o.maxLength;
|
|
182
|
+
return api;
|
|
183
|
+
});
|
|
184
|
+
const handler = {
|
|
185
|
+
kind: 'slash', name: def.name, code: def.code, label,
|
|
186
|
+
optionNames: options.map((o) => o.name),
|
|
187
|
+
autocompleteOptions: new Set(options.filter((o) => o.autocomplete).map((o) => o.name)),
|
|
188
|
+
api: { name: def.name, description: def.description, options: apiOptions },
|
|
189
|
+
};
|
|
190
|
+
this._add(handler);
|
|
191
|
+
this.slashes.set(def.name, handler);
|
|
192
|
+
return this;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
_component(type, def) {
|
|
196
|
+
const id = def?.customId ?? def?.id;
|
|
197
|
+
const label = `${type} "${id}"`;
|
|
198
|
+
if (typeof id !== 'string' || !id) throw new BotError(`${type}: "customId" wajib diisi (boleh pakai * sebagai wildcard)`);
|
|
199
|
+
const handler = { kind: type, name: id, code: def.code, label, matcher: wildcardToRegExp(id) };
|
|
200
|
+
this._add(handler);
|
|
201
|
+
this.componentHandlers.push(handler);
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Handler tombol. { customId, code } — customId boleh pakai * (mis. "tiket_*"). */
|
|
206
|
+
button(def) { return this._component('button', def); }
|
|
207
|
+
|
|
208
|
+
/** Handler select menu. { customId, code } */
|
|
209
|
+
select(def) { return this._component('select', def); }
|
|
210
|
+
|
|
211
|
+
/** Handler modal (form pop-up). { customId, code } — isi kolom dibaca dengan $modalValue[id]. */
|
|
212
|
+
modal(def) { return this._component('modal', def); }
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Autocomplete untuk opsi slash. { command, option?, code }.
|
|
216
|
+
* Opsi harus dibuat dengan `autocomplete: true`; isi pilihan lewat $addChoice[nama;nilai?].
|
|
217
|
+
*/
|
|
218
|
+
autocomplete(def) {
|
|
219
|
+
if (!def || typeof def.command !== 'string' || !def.command) {
|
|
220
|
+
throw new BotError('autocomplete: "command" (nama slash command) wajib diisi');
|
|
221
|
+
}
|
|
222
|
+
const key = `${def.command}:${def.option ?? '*'}`;
|
|
223
|
+
if (this.autocompletes.has(key)) throw new BotError(`Autocomplete "${key}" sudah terdaftar`);
|
|
224
|
+
const handler = { kind: 'autocomplete', name: key, code: def.code, label: `autocomplete "${key}"`, command: def.command, option: def.option ?? null };
|
|
225
|
+
this._add(handler);
|
|
226
|
+
this.autocompletes.set(key, handler);
|
|
227
|
+
return this;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Menu klik-kanan. { name, menu: 'user'|'message', code } — sasaran lewat $targetID / $targetName / $targetContent. */
|
|
231
|
+
contextMenu(def) {
|
|
232
|
+
const menu = def?.menu;
|
|
233
|
+
if (!['user', 'message'].includes(menu)) throw new BotError('contextMenu: "menu" harus "user" atau "message"');
|
|
234
|
+
if (typeof def.name !== 'string' || !def.name.trim() || def.name.length > 32) {
|
|
235
|
+
throw new BotError('contextMenu: "name" wajib diisi (1-32 karakter)');
|
|
236
|
+
}
|
|
237
|
+
const key = `${menu}:${def.name}`;
|
|
238
|
+
if (this.contextMenus.has(key)) throw new BotError(`Context menu "${key}" sudah terdaftar`);
|
|
239
|
+
const handler = {
|
|
240
|
+
kind: 'context', name: def.name, code: def.code, label: `context menu "${def.name}"`,
|
|
241
|
+
menu, api: { name: def.name, type: menu === 'user' ? 2 : 3 },
|
|
242
|
+
};
|
|
243
|
+
this._add(handler);
|
|
244
|
+
this.contextMenus.set(key, handler);
|
|
245
|
+
return this;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Pasang command eval siap pakai (khusus owner). Butuh `eval: true` dan `owners` pada opsi Bot.
|
|
250
|
+
* !eval $math[1+1] -> kode versi port
|
|
251
|
+
* !eval js client.guilds.cache.size -> kode discord.js asli
|
|
252
|
+
*/
|
|
253
|
+
evalCommand({ name = 'eval', aliases = [] } = {}) {
|
|
254
|
+
if (!this.options.eval) throw new BotError('evalCommand butuh opsi eval: true pada new Bot({...})');
|
|
255
|
+
if (!this.options.owners.length) throw new BotError('evalCommand butuh owners: ["ID_KAMU"] pada new Bot({...})');
|
|
256
|
+
return this.command({ name, aliases, code: EVAL_CODE });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Handler event. { type, channel?, code } */
|
|
260
|
+
event(def) {
|
|
261
|
+
if (!def || !EVENT_TYPES.includes(def.type)) {
|
|
262
|
+
throw new BotError(`event: "type" harus salah satu dari ${EVENT_TYPES.join(', ')}`);
|
|
263
|
+
}
|
|
264
|
+
const handler = { kind: 'event', type: def.type, channel: def.channel ?? null, code: def.code, label: `event "${def.type}"` };
|
|
265
|
+
this._add(handler);
|
|
266
|
+
if (!this.eventHandlers.has(def.type)) this.eventHandlers.set(def.type, []);
|
|
267
|
+
this.eventHandlers.get(def.type).push(handler);
|
|
268
|
+
return this;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
_eventShortcut(type, arg) {
|
|
272
|
+
return this.event(typeof arg === 'string' ? { type, code: arg } : { ...arg, type });
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
onReady(arg) { return this._eventShortcut('ready', arg); }
|
|
276
|
+
onMemberJoin(arg) { return this._eventShortcut('memberJoin', arg); }
|
|
277
|
+
onMemberLeave(arg) { return this._eventShortcut('memberLeave', arg); }
|
|
278
|
+
onMessageDelete(arg) { return this._eventShortcut('messageDelete', arg); }
|
|
279
|
+
onMessageUpdate(arg) { return this._eventShortcut('messageUpdate', arg); }
|
|
280
|
+
onReactionAdd(arg) { return this._eventShortcut('reactionAdd', arg); }
|
|
281
|
+
onReactionRemove(arg) { return this._eventShortcut('reactionRemove', arg); }
|
|
282
|
+
onGuildJoin(arg) { return this._eventShortcut('guildJoin', arg); }
|
|
283
|
+
onGuildLeave(arg) { return this._eventShortcut('guildLeave', arg); }
|
|
284
|
+
onMemberBan(arg) { return this._eventShortcut('memberBan', arg); }
|
|
285
|
+
onMemberUnban(arg) { return this._eventShortcut('memberUnban', arg); }
|
|
286
|
+
onChannelCreate(arg) { return this._eventShortcut('channelCreate', arg); }
|
|
287
|
+
onChannelDelete(arg) { return this._eventShortcut('channelDelete', arg); }
|
|
288
|
+
onRoleCreate(arg) { return this._eventShortcut('roleCreate', arg); }
|
|
289
|
+
onRoleDelete(arg) { return this._eventShortcut('roleDelete', arg); }
|
|
290
|
+
onThreadCreate(arg) { return this._eventShortcut('threadCreate', arg); }
|
|
291
|
+
onVoiceJoin(arg) { return this._eventShortcut('voiceJoin', arg); }
|
|
292
|
+
onVoiceLeave(arg) { return this._eventShortcut('voiceLeave', arg); }
|
|
293
|
+
/** Jalan untuk setiap pesan yang BUKAN command. */
|
|
294
|
+
onMessage(arg) { return this._eventShortcut('message', arg); }
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Pasang plugin. Plugin = function(bot, opsi) atau object { name, install(bot, opsi) }.
|
|
298
|
+
* install harus sinkron (koneksi jaringan dilakukan adapter lewat connect()).
|
|
299
|
+
* bot.use(mongodb({ uri: process.env.MONGO_URI, dbName: 'botku' }))
|
|
300
|
+
*/
|
|
301
|
+
use(plugin, pluginOptions) {
|
|
302
|
+
const p = typeof plugin === 'function' ? { name: plugin.name || 'anonim', install: plugin } : plugin;
|
|
303
|
+
if (!p || typeof p.install !== 'function') {
|
|
304
|
+
throw new BotError('use: plugin harus berupa function atau object { name, install(bot) }');
|
|
305
|
+
}
|
|
306
|
+
const name = String(p.name ?? 'anonim');
|
|
307
|
+
if (name !== 'anonim' && this.plugins.has(name)) throw new BotError(`Plugin "${name}" sudah terpasang`);
|
|
308
|
+
const result = p.install(this, pluginOptions);
|
|
309
|
+
if (result && typeof result.then === 'function') {
|
|
310
|
+
throw new BotError(`Plugin "${name}": install() tidak boleh async. Lakukan koneksi di method connect() milik adapter`);
|
|
311
|
+
}
|
|
312
|
+
this.plugins.set(name, p);
|
|
313
|
+
return this;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Ganti database. Adapter wajib punya get/set/delete; opsional: has, keys, flush, connect, close. */
|
|
317
|
+
setDatabase(adapter) {
|
|
318
|
+
if (this._started) throw new BotError('setDatabase harus dipanggil sebelum connect()');
|
|
319
|
+
this.db = checkAdapter(adapter);
|
|
320
|
+
return this;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Deklarasi variabel + nilai awal: bot.variables({ uang: 0, bio: '-' }) */
|
|
324
|
+
variables(vars) {
|
|
325
|
+
if (!vars || typeof vars !== 'object') throw new BotError('variables: berikan object { nama: nilaiAwal }');
|
|
326
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
327
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new BotError(`Nama variabel "${name}" tidak valid (huruf, angka, _)`);
|
|
328
|
+
this.declaredVars.set(name, String(value ?? ''));
|
|
329
|
+
}
|
|
330
|
+
return this;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Tambah fungsi sendiri.
|
|
335
|
+
* bot.addFunction({ name: '$halo', params: 1, run: (ctx, [nama]) => `Halo ${nama}` })
|
|
336
|
+
*/
|
|
337
|
+
addFunction(spec) {
|
|
338
|
+
if (!spec || typeof spec.run !== 'function') throw new BotError('addFunction: "run" wajib berupa function');
|
|
339
|
+
const raw = String(spec.name ?? '').replace(/^\$/, '');
|
|
340
|
+
if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(raw)) throw new BotError(`Nama fungsi "${spec.name}" tidak valid`);
|
|
341
|
+
if (this.registry.has(raw.toLowerCase()) && !spec.override) {
|
|
342
|
+
throw new BotError(`Fungsi $${raw} sudah ada (pakai override: true untuk menggantinya)`);
|
|
343
|
+
}
|
|
344
|
+
const params = spec.params;
|
|
345
|
+
const min = spec.min ?? (typeof params === 'number' ? params : 0);
|
|
346
|
+
const max = spec.max ?? (typeof params === 'number' ? params : Infinity);
|
|
347
|
+
const def = f(raw, {
|
|
348
|
+
b: spec.brackets ?? (min > 0 ? 'required' : 'optional'),
|
|
349
|
+
min, max, lazy: false, s: spec.signature ?? '', d: spec.description ?? '',
|
|
350
|
+
}, spec.run);
|
|
351
|
+
def.category = 'Custom';
|
|
352
|
+
this.registry.set(def.name, def);
|
|
353
|
+
return this;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Muat semua file .js dari folder. Tiap file: module.exports = { type?, ... } atau array. */
|
|
357
|
+
loadCommands(dir) {
|
|
358
|
+
const root = path.resolve(dir);
|
|
359
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
|
|
360
|
+
throw new BotError(`Folder command tidak ditemukan: ${root}`);
|
|
361
|
+
}
|
|
362
|
+
const files = [];
|
|
363
|
+
const walk = (d) => {
|
|
364
|
+
for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
|
|
365
|
+
const full = path.join(d, entry.name);
|
|
366
|
+
if (entry.isDirectory()) walk(full);
|
|
367
|
+
else if (/\.c?js$/.test(entry.name)) files.push(full);
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
walk(root);
|
|
371
|
+
files.sort();
|
|
372
|
+
for (const file of files) {
|
|
373
|
+
let mod;
|
|
374
|
+
try {
|
|
375
|
+
// eslint-disable-next-line global-require, import/no-dynamic-require
|
|
376
|
+
mod = require(file);
|
|
377
|
+
} catch (e) {
|
|
378
|
+
throw new BotError(`Gagal memuat ${file}: ${e.message}`, { cause: e });
|
|
379
|
+
}
|
|
380
|
+
for (const def of Array.isArray(mod) ? mod : [mod]) {
|
|
381
|
+
try {
|
|
382
|
+
this._register(def);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
throw new BotError(`${file}: ${e.message}`, { cause: e });
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return this;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_register(def) {
|
|
392
|
+
const type = def?.type ?? 'command';
|
|
393
|
+
if (type === 'command') return this.command(def);
|
|
394
|
+
if (type === 'slash') return this.slashCommand(def);
|
|
395
|
+
if (type === 'button') return this.button(def);
|
|
396
|
+
if (type === 'select') return this.select(def);
|
|
397
|
+
if (type === 'modal') return this.modal(def);
|
|
398
|
+
if (type === 'autocomplete') return this.autocomplete(def);
|
|
399
|
+
if (type === 'contextMenu') return this.contextMenu(def);
|
|
400
|
+
return this.event(def);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ===== Validasi kode ========================================================
|
|
404
|
+
|
|
405
|
+
/** Kompilasi semua kode; lempar ValidationError berisi SEMUA masalah sekaligus. */
|
|
406
|
+
validate() {
|
|
407
|
+
const problems = [];
|
|
408
|
+
for (const h of this._all) {
|
|
409
|
+
try {
|
|
410
|
+
h.ast = parse(h.code, this.registry);
|
|
411
|
+
} catch (e) {
|
|
412
|
+
problems.push(`${h.label}: ${e.message}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
for (const h of this.autocompletes.values()) {
|
|
416
|
+
const slash = this.slashes.get(h.command);
|
|
417
|
+
if (!slash) problems.push(`${h.label}: slash command "${h.command}" tidak ada`);
|
|
418
|
+
else if (h.option && !slash.autocompleteOptions.has(h.option)) {
|
|
419
|
+
problems.push(`${h.label}: opsi "${h.option}" tidak ada atau belum diberi autocomplete: true`);
|
|
420
|
+
} else if (!h.option && !slash.autocompleteOptions.size) {
|
|
421
|
+
problems.push(`${h.label}: slash "${h.command}" tidak punya opsi dengan autocomplete: true`);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (problems.length) throw new ValidationError(problems);
|
|
425
|
+
return true;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ===== Variabel =============================================================
|
|
429
|
+
|
|
430
|
+
_varKey(scope, name, id) {
|
|
431
|
+
if (!VAR_SCOPES.includes(scope)) throw new BotError(`Scope variabel "${scope}" tidak dikenal`);
|
|
432
|
+
if (!this.declaredVars.has(name)) {
|
|
433
|
+
throw new BotError(`Variabel "${name}" belum dideklarasikan. Tambahkan bot.variables({ ${name}: nilaiAwal })`);
|
|
434
|
+
}
|
|
435
|
+
return scope === 'global' ? `global:${name}` : `${scope}:${id}:${name}`;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Semua akses variabel bersifat asinkron karena adapter database (mis. MongoDB) berjalan lewat jaringan.
|
|
439
|
+
async getVar(scope, name, id) {
|
|
440
|
+
const key = this._varKey(scope, name, id);
|
|
441
|
+
const stored = await this.db.get(key);
|
|
442
|
+
return stored === undefined || stored === null ? this.declaredVars.get(name) : stored;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async setVar(scope, name, value, id) {
|
|
446
|
+
await this.db.set(this._varKey(scope, name, id), String(value));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async resetVar(scope, name, id) {
|
|
450
|
+
await this.db.delete(this._varKey(scope, name, id));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Tambah (atau kurangi, bila negatif) variabel angka. Dijalankan berurutan per variabel di dalam satu proses,
|
|
455
|
+
* sehingga dua command bersamaan tidak saling menimpa. Mengembalikan nilai baru.
|
|
456
|
+
*/
|
|
457
|
+
async incVar(scope, name, amount, id) {
|
|
458
|
+
const key = this._varKey(scope, name, id);
|
|
459
|
+
const add = Number(String(amount).trim());
|
|
460
|
+
if (String(amount).trim() === '' || !Number.isFinite(add)) throw new BotError(`Jumlah "${amount}" bukan angka`);
|
|
461
|
+
const prev = this._locks.get(key) ?? Promise.resolve();
|
|
462
|
+
let release;
|
|
463
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
464
|
+
const chain = prev.then(() => gate);
|
|
465
|
+
this._locks.set(key, chain);
|
|
466
|
+
await prev;
|
|
467
|
+
try {
|
|
468
|
+
const stored = await this.db.get(key);
|
|
469
|
+
const current = stored === undefined || stored === null ? this.declaredVars.get(name) : stored;
|
|
470
|
+
const base = Number(String(current).trim());
|
|
471
|
+
if (String(current).trim() === '' || !Number.isFinite(base)) {
|
|
472
|
+
throw new BotError(`Variabel "${name}" bernilai "${current}", bukan angka`);
|
|
473
|
+
}
|
|
474
|
+
const next = formatNumber(base + add);
|
|
475
|
+
await this.db.set(key, next);
|
|
476
|
+
return next;
|
|
477
|
+
} finally {
|
|
478
|
+
release();
|
|
479
|
+
if (this._locks.get(key) === chain) this._locks.delete(key);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ===== Menjalankan =========================================================
|
|
484
|
+
|
|
485
|
+
async _execute(handler, ctx) {
|
|
486
|
+
try {
|
|
487
|
+
const ast = handler.ast ?? (handler.ast = parse(handler.code, this.registry));
|
|
488
|
+
let text;
|
|
489
|
+
try {
|
|
490
|
+
text = await this.interpreter.run(ast, ctx);
|
|
491
|
+
} catch (e) {
|
|
492
|
+
if (e instanceof Stop) {
|
|
493
|
+
if (e.message) await ctx.sendPlain(e.message);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
throw e;
|
|
497
|
+
}
|
|
498
|
+
await ctx.respond(text.trim());
|
|
499
|
+
await ctx.finish();
|
|
500
|
+
} catch (e) {
|
|
501
|
+
await this._reportError(e, ctx, handler);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async _reportError(err, ctx, handler) {
|
|
506
|
+
const where = handler?.label ?? 'handler';
|
|
507
|
+
console.error(`[easydc] Error di ${where}: ${this.options.debug ? (err.stack ?? err.message) : err.message}`);
|
|
508
|
+
try {
|
|
509
|
+
if (typeof this.options.onError === 'function') {
|
|
510
|
+
await this.options.onError(err, ctx);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (!ctx || !(ctx.channel || ctx.interaction)) return;
|
|
514
|
+
ctx.ephemeral = true;
|
|
515
|
+
const text = this.options.debug
|
|
516
|
+
? `❌ ${String(err.message).slice(0, 1800)}`
|
|
517
|
+
: this.options.messages.error;
|
|
518
|
+
await ctx.sendPlain(text);
|
|
519
|
+
} catch (e2) {
|
|
520
|
+
console.error(`[easydc] Gagal melaporkan error: ${e2.message}`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
_matchPrefix(content) {
|
|
525
|
+
for (const p of this.options.prefix) if (content.startsWith(p)) return { text: p, mention: false };
|
|
526
|
+
if (this.options.mentionPrefix && this.client?.user) {
|
|
527
|
+
const m = new RegExp(`^<@!?${this.client.user.id}>\\s*`).exec(content);
|
|
528
|
+
if (m) return { text: m[0], mention: true };
|
|
529
|
+
}
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async _onMessage(message) {
|
|
534
|
+
if (!message || !message.author) return;
|
|
535
|
+
if (message.author.id === this.client.user?.id) return;
|
|
536
|
+
if (message.author.bot && this.options.ignoreBots) return;
|
|
537
|
+
if (message.system) return;
|
|
538
|
+
|
|
539
|
+
const content = message.content ?? '';
|
|
540
|
+
const base = {
|
|
541
|
+
message, guild: message.guild ?? null, channel: message.channel,
|
|
542
|
+
member: message.member ?? null, user: message.author,
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
const prefix = this._matchPrefix(content);
|
|
546
|
+
if (prefix) {
|
|
547
|
+
const rest = content.slice(prefix.text.length).trimStart();
|
|
548
|
+
const m = /^(\S+)/.exec(rest);
|
|
549
|
+
if (m) {
|
|
550
|
+
const handler = this.commands.get(m[1].toLowerCase());
|
|
551
|
+
if (handler) {
|
|
552
|
+
const rawArgs = rest.slice(m[1].length).trim();
|
|
553
|
+
const ctx = new Context(this, {
|
|
554
|
+
...base, kind: 'message', command: handler,
|
|
555
|
+
args: rawArgs ? rawArgs.split(/\s+/) : [], rawArgs,
|
|
556
|
+
mentionSkipId: prefix.mention ? this.client.user.id : null,
|
|
557
|
+
});
|
|
558
|
+
await this._execute(handler, ctx);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
for (const handler of this.eventHandlers.get('message') ?? []) {
|
|
564
|
+
const ctx = new Context(this, {
|
|
565
|
+
...base, kind: 'event', eventName: 'message', command: handler,
|
|
566
|
+
args: content.split(/\s+/).filter(Boolean), rawArgs: content,
|
|
567
|
+
});
|
|
568
|
+
await this._execute(handler, ctx);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
async _onInteraction(i) {
|
|
573
|
+
if (i.isAutocomplete?.()) return this._onAutocomplete(i);
|
|
574
|
+
if (i.isModalSubmit?.()) return this._onModal(i);
|
|
575
|
+
if (i.isContextMenuCommand?.()) return this._onContextMenu(i);
|
|
576
|
+
return this._onBasicInteraction(i);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async _onAutocomplete(i) {
|
|
580
|
+
const focused = i.options.getFocused(true);
|
|
581
|
+
const handler = this.autocompletes.get(`${i.commandName}:${focused.name}`) ?? this.autocompletes.get(`${i.commandName}:*`);
|
|
582
|
+
const answer = async (list) => { try { await i.respond(list); } catch (e) { console.error(`[easydc] Gagal menjawab autocomplete: ${e.message}`); } };
|
|
583
|
+
if (!handler) { await answer([]); return; }
|
|
584
|
+
const options = new Map();
|
|
585
|
+
for (const o of i.options?.data ?? []) options.set(o.name, o.value);
|
|
586
|
+
const ctx = new Context(this, {
|
|
587
|
+
kind: 'autocomplete', interaction: i, command: handler, guild: i.guild ?? null, channel: i.channel ?? null,
|
|
588
|
+
member: i.member ?? null, user: i.user, options,
|
|
589
|
+
focused: { name: focused.name, value: String(focused.value ?? ''), type: focused.type },
|
|
590
|
+
});
|
|
591
|
+
try {
|
|
592
|
+
const ast = handler.ast ?? (handler.ast = parse(handler.code, this.registry));
|
|
593
|
+
try {
|
|
594
|
+
await this.interpreter.run(ast, ctx);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
if (!(e instanceof Stop)) throw e;
|
|
597
|
+
}
|
|
598
|
+
const numeric = [4, 10].includes(focused.type);
|
|
599
|
+
const choices = ctx.choices.slice(0, 25).map((c) => {
|
|
600
|
+
if (!numeric) return c;
|
|
601
|
+
const n = Number(c.value);
|
|
602
|
+
if (!Number.isFinite(n)) throw new BotError(`Nilai pilihan "${c.value}" harus angka untuk opsi "${focused.name}"`);
|
|
603
|
+
return { name: c.name, value: n };
|
|
604
|
+
});
|
|
605
|
+
await answer(choices);
|
|
606
|
+
} catch (e) {
|
|
607
|
+
console.error(`[easydc] Error di ${handler.label}: ${this.options.debug ? (e.stack ?? e.message) : e.message}`);
|
|
608
|
+
await answer([]);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async _onModal(i) {
|
|
613
|
+
const handler = this.componentHandlers.find((h) => h.kind === 'modal' && h.matcher.test(i.customId));
|
|
614
|
+
if (!handler) return;
|
|
615
|
+
const fields = new Map();
|
|
616
|
+
const col = i.fields?.fields;
|
|
617
|
+
if (col && typeof col.values === 'function') for (const fld of col.values()) fields.set(fld.customId, fld.value ?? '');
|
|
618
|
+
const ctx = new Context(this, {
|
|
619
|
+
kind: 'component', interaction: i, command: handler, guild: i.guild ?? null, channel: i.channel ?? null,
|
|
620
|
+
member: i.member ?? null, user: i.user, message: i.message ?? null, customId: i.customId, fields,
|
|
621
|
+
args: [...fields.values()], rawArgs: [...fields.values()].join(' '),
|
|
622
|
+
});
|
|
623
|
+
await this._execute(handler, ctx);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async _onContextMenu(i) {
|
|
627
|
+
const menu = i.isUserContextMenuCommand?.() ? 'user' : 'message';
|
|
628
|
+
const handler = this.contextMenus.get(`${menu}:${i.commandName}`);
|
|
629
|
+
if (!handler) return;
|
|
630
|
+
const target = menu === 'user'
|
|
631
|
+
? { id: i.targetId, name: i.targetUser?.username ?? '' }
|
|
632
|
+
: { id: i.targetId, name: i.targetMessage?.author?.username ?? '', content: i.targetMessage?.content ?? '' };
|
|
633
|
+
const ctx = new Context(this, {
|
|
634
|
+
kind: 'slash', interaction: i, command: handler, guild: i.guild ?? null, channel: i.channel ?? null,
|
|
635
|
+
member: i.member ?? null, user: i.user, target, args: [i.targetId], rawArgs: String(i.targetId),
|
|
636
|
+
});
|
|
637
|
+
await this._execute(handler, ctx);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async _onBasicInteraction(i) {
|
|
641
|
+
if (i.isChatInputCommand?.()) {
|
|
642
|
+
const handler = this.slashes.get(i.commandName);
|
|
643
|
+
if (!handler) return;
|
|
644
|
+
const options = new Map();
|
|
645
|
+
for (const o of i.options?.data ?? []) options.set(o.name, o.attachment ? o.attachment.url : o.value);
|
|
646
|
+
const args = handler.optionNames.filter((n) => options.has(n)).map((n) => String(options.get(n)));
|
|
647
|
+
const ctx = new Context(this, {
|
|
648
|
+
kind: 'slash', interaction: i, command: handler, guild: i.guild ?? null,
|
|
649
|
+
channel: i.channel ?? null, member: i.member ?? null, user: i.user,
|
|
650
|
+
options, args, rawArgs: args.join(' '),
|
|
651
|
+
});
|
|
652
|
+
await this._execute(handler, ctx);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const isButton = i.isButton?.() === true;
|
|
656
|
+
const isSelect = i.isAnySelectMenu?.() === true || i.isStringSelectMenu?.() === true;
|
|
657
|
+
if (!isButton && !isSelect) return;
|
|
658
|
+
const type = isButton ? 'button' : 'select';
|
|
659
|
+
const handler = this.componentHandlers.find((h) => h.kind === type && h.matcher.test(i.customId));
|
|
660
|
+
if (!handler) return;
|
|
661
|
+
const ctx = new Context(this, {
|
|
662
|
+
kind: 'component', interaction: i, command: handler, guild: i.guild ?? null,
|
|
663
|
+
channel: i.channel ?? null, member: i.member ?? null, user: i.user,
|
|
664
|
+
message: i.message ?? null, customId: i.customId, values: i.values ?? [],
|
|
665
|
+
});
|
|
666
|
+
await this._execute(handler, ctx);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
async _resolveChannel(id) {
|
|
670
|
+
if (!id) return null;
|
|
671
|
+
const cached = this.client.channels.cache.get(id);
|
|
672
|
+
if (cached) return cached;
|
|
673
|
+
try { return (await this.client.channels.fetch(id)) ?? null; } catch { return null; }
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
async _fire(type, build) {
|
|
677
|
+
const handlers = this.eventHandlers.get(type);
|
|
678
|
+
if (!handlers?.length) return;
|
|
679
|
+
const base = await build();
|
|
680
|
+
if (!base) return;
|
|
681
|
+
for (const handler of handlers) {
|
|
682
|
+
const channel = handler.channel ? await this._resolveChannel(handler.channel) : (base.channel ?? null);
|
|
683
|
+
const ctx = new Context(this, { ...base, kind: 'event', eventName: type, command: handler, channel });
|
|
684
|
+
await this._execute(handler, ctx);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async _onReady() {
|
|
689
|
+
const st = this.options.status;
|
|
690
|
+
if (st && st.text) {
|
|
691
|
+
try {
|
|
692
|
+
await this.client.user.setPresence({
|
|
693
|
+
activities: [{ name: st.text, type: ACTIVITY_TYPES[st.type ?? 'Playing'] ?? 0 }],
|
|
694
|
+
status: st.status ?? 'online',
|
|
695
|
+
});
|
|
696
|
+
} catch (e) { console.error(`[easydc] Gagal mengatur status: ${e.message}`); }
|
|
697
|
+
}
|
|
698
|
+
if (this.options.syncSlash && (this.slashes.size || this.contextMenus.size)) {
|
|
699
|
+
try { await this._registerSlash(); } catch (e) {
|
|
700
|
+
console.error(`[easydc] Gagal mendaftarkan slash command: ${e.message}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
await this._fire('ready', () => ({ user: this.client.user }));
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async _registerSlash() {
|
|
707
|
+
const list = [...this.slashes.values(), ...this.contextMenus.values()].map((s) => s.api);
|
|
708
|
+
if (this.options.slashGuild) {
|
|
709
|
+
const guild = this.client.guilds.cache.get(this.options.slashGuild) ?? await this.client.guilds.fetch(this.options.slashGuild);
|
|
710
|
+
await guild.commands.set(list);
|
|
711
|
+
} else {
|
|
712
|
+
await this.client.application.commands.set(list);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
_safe(fn) {
|
|
717
|
+
return Promise.resolve().then(fn).catch((e) => {
|
|
718
|
+
console.error(`[easydc] Error tak terduga: ${this.options.debug ? (e.stack ?? e.message) : e.message}`);
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_bind(client, discord) {
|
|
723
|
+
const readyEvent = discord?.Events?.ClientReady ?? 'ready';
|
|
724
|
+
client.once(readyEvent, () => this._safe(() => this._onReady()));
|
|
725
|
+
client.on('messageCreate', (m) => this._safe(() => this._onMessage(m)));
|
|
726
|
+
client.on('interactionCreate', (i) => this._safe(() => this._onInteraction(i)));
|
|
727
|
+
client.on('guildMemberAdd', (member) => this._safe(() => this._fire('memberJoin', () => ({
|
|
728
|
+
guild: member.guild, member, user: member.user, channel: member.guild?.systemChannel ?? null,
|
|
729
|
+
}))));
|
|
730
|
+
client.on('guildMemberRemove', (member) => this._safe(() => this._fire('memberLeave', () => {
|
|
731
|
+
if (!member.user) return null;
|
|
732
|
+
return { guild: member.guild, member, user: member.user, channel: member.guild?.systemChannel ?? null };
|
|
733
|
+
})));
|
|
734
|
+
client.on('messageDelete', (message) => this._safe(() => this._fire('messageDelete', () => {
|
|
735
|
+
if (message.author?.bot && this.options.ignoreBots) return null;
|
|
736
|
+
return { message, guild: message.guild ?? null, channel: message.channel ?? null, user: message.author ?? null, member: message.member ?? null };
|
|
737
|
+
})));
|
|
738
|
+
client.on('messageUpdate', (oldM, newM) => this._safe(() => this._fire('messageUpdate', async () => {
|
|
739
|
+
if (newM.partial && typeof newM.fetch === 'function') await newM.fetch();
|
|
740
|
+
if (newM.author?.bot && this.options.ignoreBots) return null;
|
|
741
|
+
if (oldM && oldM.content !== null && oldM.content !== undefined && oldM.content === newM.content) return null;
|
|
742
|
+
return { message: newM, guild: newM.guild ?? null, channel: newM.channel ?? null, user: newM.author ?? null, member: newM.member ?? null };
|
|
743
|
+
})));
|
|
744
|
+
const reaction = (type) => (r, user) => this._safe(() => this._fire(type, async () => {
|
|
745
|
+
if (user?.bot) return null;
|
|
746
|
+
if (r.partial && typeof r.fetch === 'function') await r.fetch();
|
|
747
|
+
const msg = r.message;
|
|
748
|
+
return {
|
|
749
|
+
reaction: r, message: msg, user, guild: msg?.guild ?? null, channel: msg?.channel ?? null,
|
|
750
|
+
member: msg?.guild?.members?.cache?.get(user.id) ?? null,
|
|
751
|
+
};
|
|
752
|
+
}));
|
|
753
|
+
client.on('messageReactionAdd', reaction('reactionAdd'));
|
|
754
|
+
client.on('messageReactionRemove', reaction('reactionRemove'));
|
|
755
|
+
client.on('guildCreate', (guild) => this._safe(() => this._fire('guildJoin', () => ({ guild, channel: guild.systemChannel ?? null }))));
|
|
756
|
+
client.on('guildDelete', (guild) => this._safe(() => this._fire('guildLeave', () => ({ guild, channel: null }))));
|
|
757
|
+
const entity = (event, type, build) => client.on(event, (...a) => this._safe(() => this._fire(type, () => build(...a))));
|
|
758
|
+
entity('guildBanAdd', 'memberBan', (ban) => ({
|
|
759
|
+
guild: ban.guild, user: ban.user, channel: ban.guild?.systemChannel ?? null, target: { id: ban.user.id, name: ban.user.username },
|
|
760
|
+
}));
|
|
761
|
+
entity('guildBanRemove', 'memberUnban', (ban) => ({
|
|
762
|
+
guild: ban.guild, user: ban.user, channel: ban.guild?.systemChannel ?? null, target: { id: ban.user.id, name: ban.user.username },
|
|
763
|
+
}));
|
|
764
|
+
for (const [event, type] of [['channelCreate', 'channelCreate'], ['channelDelete', 'channelDelete']]) {
|
|
765
|
+
entity(event, type, (ch) => (ch.guild
|
|
766
|
+
? { guild: ch.guild, channel: ch.guild.systemChannel ?? null, target: { id: ch.id, name: ch.name ?? '' } }
|
|
767
|
+
: null));
|
|
768
|
+
}
|
|
769
|
+
for (const [event, type] of [['roleCreate', 'roleCreate'], ['roleDelete', 'roleDelete']]) {
|
|
770
|
+
entity(event, type, (role) => ({
|
|
771
|
+
guild: role.guild, channel: role.guild?.systemChannel ?? null, target: { id: role.id, name: role.name ?? '' },
|
|
772
|
+
}));
|
|
773
|
+
}
|
|
774
|
+
entity('threadCreate', 'threadCreate', (thread) => ({
|
|
775
|
+
guild: thread.guild ?? null, channel: thread, target: { id: thread.id, name: thread.name ?? '' },
|
|
776
|
+
}));
|
|
777
|
+
client.on('voiceStateUpdate', (oldS, newS) => this._safe(async () => {
|
|
778
|
+
const member = newS.member ?? oldS.member;
|
|
779
|
+
if (!member || oldS.channelId === newS.channelId) return;
|
|
780
|
+
if (member.user?.bot && this.options.ignoreBots) return;
|
|
781
|
+
const base = (ch) => ({
|
|
782
|
+
guild: newS.guild ?? oldS.guild, member, user: member.user,
|
|
783
|
+
channel: (newS.guild ?? oldS.guild)?.systemChannel ?? null, target: { id: ch.id, name: ch.name ?? '' },
|
|
784
|
+
});
|
|
785
|
+
if (oldS.channel) await this._fire('voiceLeave', () => base(oldS.channel));
|
|
786
|
+
if (newS.channel) await this._fire('voiceJoin', () => base(newS.channel));
|
|
787
|
+
}));
|
|
788
|
+
client.on('error', (e) => console.error(`[easydc] Error client: ${e.message}`));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
_discord() {
|
|
792
|
+
if (this.options.discord) return this.options.discord;
|
|
793
|
+
try {
|
|
794
|
+
// eslint-disable-next-line global-require
|
|
795
|
+
return require('discord.js');
|
|
796
|
+
} catch (e) {
|
|
797
|
+
throw new BotError('Package "discord.js" tidak ditemukan. Jalankan: npm install discord.js', { cause: e });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
_resolveIntents(discord) {
|
|
802
|
+
const bits = discord.GatewayIntentBits;
|
|
803
|
+
let names = this.options.intents;
|
|
804
|
+
if (names === 'default') names = DEFAULT_INTENTS;
|
|
805
|
+
else if (names === 'all') names = Object.keys(bits).filter((k) => Number.isNaN(Number(k)));
|
|
806
|
+
if (!Array.isArray(names)) throw new BotError('intents harus "default", "all", atau array nama intent');
|
|
807
|
+
return names.map((n) => {
|
|
808
|
+
if (typeof n === 'number') return n;
|
|
809
|
+
if (!Object.hasOwn(bits, n) || typeof bits[n] !== 'number') {
|
|
810
|
+
throw new BotError(`Intent "${n}" tidak dikenal. Contoh yang benar: ${DEFAULT_INTENTS.join(', ')}`);
|
|
811
|
+
}
|
|
812
|
+
return bits[n];
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** Cek kode lalu login ke Discord. Mengembalikan bot itu sendiri. */
|
|
817
|
+
async connect(token = this.options.token) {
|
|
818
|
+
if (!token || typeof token !== 'string') {
|
|
819
|
+
throw new BotError('Token bot belum diisi. Contoh: new Bot({ token: process.env.TOKEN })');
|
|
820
|
+
}
|
|
821
|
+
this.validate();
|
|
822
|
+
this._started = true;
|
|
823
|
+
const discord = this._discord();
|
|
824
|
+
const client = this.options.client ?? new discord.Client({
|
|
825
|
+
intents: this._resolveIntents(discord),
|
|
826
|
+
partials: DEFAULT_PARTIALS.map((p) => discord.Partials[p]).filter((p) => p !== undefined),
|
|
827
|
+
});
|
|
828
|
+
this.client = client;
|
|
829
|
+
this._bind(client, discord);
|
|
830
|
+
if (this.options.handleSignals && !this._signalsBound) {
|
|
831
|
+
this._signalsBound = true;
|
|
832
|
+
const shutdown = async () => {
|
|
833
|
+
const timeout = new Promise((resolve) => { setTimeout(resolve, 5000); });
|
|
834
|
+
try { await Promise.race([this.destroy(), timeout]); } catch { /* abaikan */ }
|
|
835
|
+
process.exit(0);
|
|
836
|
+
};
|
|
837
|
+
process.once('SIGINT', shutdown);
|
|
838
|
+
process.once('SIGTERM', shutdown);
|
|
839
|
+
process.once('exit', () => {
|
|
840
|
+
try {
|
|
841
|
+
const r = this.db.flush?.();
|
|
842
|
+
if (r && typeof r.catch === 'function') r.catch(() => {});
|
|
843
|
+
} catch { /* abaikan */ }
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
await this.db.connect?.();
|
|
848
|
+
} catch (e) {
|
|
849
|
+
throw new BotError(`Gagal terhubung ke database: ${e.message}`, { cause: e });
|
|
850
|
+
}
|
|
851
|
+
try {
|
|
852
|
+
await client.login(token);
|
|
853
|
+
} catch (e) {
|
|
854
|
+
try { await this.db.close?.(); } catch { /* abaikan */ }
|
|
855
|
+
throw e;
|
|
856
|
+
}
|
|
857
|
+
return this;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/** Matikan bot, simpan database, dan tutup koneksinya. */
|
|
861
|
+
async destroy() {
|
|
862
|
+
await this.db.flush?.();
|
|
863
|
+
if (this.client && typeof this.client.destroy === 'function') await this.client.destroy();
|
|
864
|
+
await this.db.close?.();
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
module.exports = { Bot };
|