@tryty/easydc-js 0.0.0-stage → 1.1.0

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