@tryty/easydc-js 1.1.0 → 1.2.2
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/README.md +192 -17
- package/examples/basic.js +1 -1
- package/examples/mongo.js +44 -0
- package/index.d.ts +29 -6
- package/mongodb.d.ts +31 -0
- package/package.json +37 -4
- package/src/bot.js +117 -14
- package/src/database.js +24 -1
- package/src/functions/control.js +2 -1
- package/src/functions/eval.js +2 -1
- package/src/functions/messages.js +6 -5
- package/src/functions/vars.js +8 -4
- package/src/index.js +2 -1
- package/src/owners.js +36 -0
- package/src/plugins/mongodb.js +140 -0
- package/src/plugins/voice.js +1030 -0
- package/src/selftest.js +215 -0
- package/voice.d.ts +29 -0
package/src/bot.js
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
const fs = require('node:fs');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const { BotError, Stop, ValidationError } = require('./errors');
|
|
5
|
+
const { normalizeOwners } = require('./owners');
|
|
5
6
|
const { parse } = require('./parser');
|
|
6
7
|
const { Interpreter } = require('./interpreter');
|
|
7
8
|
const { Context } = require('./context');
|
|
8
9
|
const { Database } = require('./database');
|
|
9
10
|
const { createRegistry } = require('./functions');
|
|
11
|
+
const { formatNumber } = require('./util');
|
|
10
12
|
const { f } = require('./functions/helpers');
|
|
11
13
|
|
|
12
14
|
const DEFAULT_INTENTS = [
|
|
@@ -38,6 +40,16 @@ const EVENT_TYPES = [
|
|
|
38
40
|
];
|
|
39
41
|
const VAR_SCOPES = ['user', 'server', 'channel', 'global'];
|
|
40
42
|
|
|
43
|
+
const ADAPTER_METHODS = ['get', 'set', 'delete'];
|
|
44
|
+
const looksLikeAdapter = (d) => !!d && typeof d === 'object' && ADAPTER_METHODS.some((m) => typeof d[m] === 'function');
|
|
45
|
+
|
|
46
|
+
/** Pastikan adapter database punya method wajib: get(key), set(key, value), delete(key). */
|
|
47
|
+
function checkAdapter(adapter) {
|
|
48
|
+
const missing = ADAPTER_METHODS.filter((m) => typeof adapter?.[m] !== 'function');
|
|
49
|
+
if (missing.length) throw new BotError(`Adapter database tidak lengkap, method yang kurang: ${missing.join(', ')}`);
|
|
50
|
+
return adapter;
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
const wildcardToRegExp = (pattern) => new RegExp(
|
|
42
54
|
`^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`,
|
|
43
55
|
);
|
|
@@ -69,7 +81,7 @@ class Bot {
|
|
|
69
81
|
};
|
|
70
82
|
opts.prefix = (Array.isArray(opts.prefix) ? opts.prefix : [opts.prefix]).filter((p) => typeof p === 'string' && p !== '');
|
|
71
83
|
if (!opts.prefix.length && !opts.mentionPrefix) throw new BotError('Isi minimal satu prefix atau aktifkan mentionPrefix');
|
|
72
|
-
opts.owners = (
|
|
84
|
+
opts.owners = normalizeOwners(opts.owners);
|
|
73
85
|
opts.messages = {
|
|
74
86
|
cooldown: '⏳ Tunggu %time% lagi sebelum memakai perintah ini.',
|
|
75
87
|
error: '❌ Terjadi kesalahan saat menjalankan perintah.',
|
|
@@ -80,7 +92,9 @@ class Bot {
|
|
|
80
92
|
this.client = null;
|
|
81
93
|
this.registry = createRegistry();
|
|
82
94
|
this.interpreter = new Interpreter();
|
|
83
|
-
this.db = opts.database
|
|
95
|
+
this.db = looksLikeAdapter(opts.database) ? checkAdapter(opts.database) : new Database(opts.database);
|
|
96
|
+
this.plugins = new Map();
|
|
97
|
+
this._locks = new Map();
|
|
84
98
|
this.cooldowns = new Map();
|
|
85
99
|
|
|
86
100
|
this.commands = new Map(); // nama/alias -> handler
|
|
@@ -243,6 +257,16 @@ class Bot {
|
|
|
243
257
|
return this.command({ name, aliases, code: EVAL_CODE });
|
|
244
258
|
}
|
|
245
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Pasang command tes otomatis (khusus owner) untuk menguji bot di Discord asli.
|
|
262
|
+
* bot.selfTestCommand(); // lalu ketik: !selftest atau !selftest full
|
|
263
|
+
* bot.selfTestCommand({ ui: true }); // + /tesauto & 2 menu klik-kanan untuk dicoba manual
|
|
264
|
+
*/
|
|
265
|
+
selfTestCommand(def) {
|
|
266
|
+
// eslint-disable-next-line global-require
|
|
267
|
+
return require('./selftest').installSelfTest(this, def);
|
|
268
|
+
}
|
|
269
|
+
|
|
246
270
|
/** Handler event. { type, channel?, code } */
|
|
247
271
|
event(def) {
|
|
248
272
|
if (!def || !EVENT_TYPES.includes(def.type)) {
|
|
@@ -280,6 +304,33 @@ class Bot {
|
|
|
280
304
|
/** Jalan untuk setiap pesan yang BUKAN command. */
|
|
281
305
|
onMessage(arg) { return this._eventShortcut('message', arg); }
|
|
282
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Pasang plugin. Plugin = function(bot, opsi) atau object { name, install(bot, opsi) }.
|
|
309
|
+
* install harus sinkron (koneksi jaringan dilakukan adapter lewat connect()).
|
|
310
|
+
* bot.use(mongodb({ uri: process.env.MONGO_URI, dbName: 'botku' }))
|
|
311
|
+
*/
|
|
312
|
+
use(plugin, pluginOptions) {
|
|
313
|
+
const p = typeof plugin === 'function' ? { name: plugin.name || 'anonim', install: plugin } : plugin;
|
|
314
|
+
if (!p || typeof p.install !== 'function') {
|
|
315
|
+
throw new BotError('use: plugin harus berupa function atau object { name, install(bot) }');
|
|
316
|
+
}
|
|
317
|
+
const name = String(p.name ?? 'anonim');
|
|
318
|
+
if (name !== 'anonim' && this.plugins.has(name)) throw new BotError(`Plugin "${name}" sudah terpasang`);
|
|
319
|
+
const result = p.install(this, pluginOptions);
|
|
320
|
+
if (result && typeof result.then === 'function') {
|
|
321
|
+
throw new BotError(`Plugin "${name}": install() tidak boleh async. Lakukan koneksi di method connect() milik adapter`);
|
|
322
|
+
}
|
|
323
|
+
this.plugins.set(name, p);
|
|
324
|
+
return this;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Ganti database. Adapter wajib punya get/set/delete; opsional: has, keys, flush, connect, close. */
|
|
328
|
+
setDatabase(adapter) {
|
|
329
|
+
if (this._started) throw new BotError('setDatabase harus dipanggil sebelum connect()');
|
|
330
|
+
this.db = checkAdapter(adapter);
|
|
331
|
+
return this;
|
|
332
|
+
}
|
|
333
|
+
|
|
283
334
|
/** Deklarasi variabel + nilai awal: bot.variables({ uang: 0, bio: '-' }) */
|
|
284
335
|
variables(vars) {
|
|
285
336
|
if (!vars || typeof vars !== 'object') throw new BotError('variables: berikan object { nama: nilaiAwal }');
|
|
@@ -395,18 +446,49 @@ class Bot {
|
|
|
395
446
|
return scope === 'global' ? `global:${name}` : `${scope}:${id}:${name}`;
|
|
396
447
|
}
|
|
397
448
|
|
|
398
|
-
|
|
449
|
+
// Semua akses variabel bersifat asinkron karena adapter database (mis. MongoDB) berjalan lewat jaringan.
|
|
450
|
+
async getVar(scope, name, id) {
|
|
399
451
|
const key = this._varKey(scope, name, id);
|
|
400
|
-
const stored = this.db.get(key);
|
|
401
|
-
return stored === undefined ? this.declaredVars.get(name) : stored;
|
|
452
|
+
const stored = await this.db.get(key);
|
|
453
|
+
return stored === undefined || stored === null ? this.declaredVars.get(name) : stored;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
async setVar(scope, name, value, id) {
|
|
457
|
+
await this.db.set(this._varKey(scope, name, id), String(value));
|
|
402
458
|
}
|
|
403
459
|
|
|
404
|
-
|
|
405
|
-
this.db.
|
|
460
|
+
async resetVar(scope, name, id) {
|
|
461
|
+
await this.db.delete(this._varKey(scope, name, id));
|
|
406
462
|
}
|
|
407
463
|
|
|
408
|
-
|
|
409
|
-
|
|
464
|
+
/**
|
|
465
|
+
* Tambah (atau kurangi, bila negatif) variabel angka. Dijalankan berurutan per variabel di dalam satu proses,
|
|
466
|
+
* sehingga dua command bersamaan tidak saling menimpa. Mengembalikan nilai baru.
|
|
467
|
+
*/
|
|
468
|
+
async incVar(scope, name, amount, id) {
|
|
469
|
+
const key = this._varKey(scope, name, id);
|
|
470
|
+
const add = Number(String(amount).trim());
|
|
471
|
+
if (String(amount).trim() === '' || !Number.isFinite(add)) throw new BotError(`Jumlah "${amount}" bukan angka`);
|
|
472
|
+
const prev = this._locks.get(key) ?? Promise.resolve();
|
|
473
|
+
let release;
|
|
474
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
475
|
+
const chain = prev.then(() => gate);
|
|
476
|
+
this._locks.set(key, chain);
|
|
477
|
+
await prev;
|
|
478
|
+
try {
|
|
479
|
+
const stored = await this.db.get(key);
|
|
480
|
+
const current = stored === undefined || stored === null ? this.declaredVars.get(name) : stored;
|
|
481
|
+
const base = Number(String(current).trim());
|
|
482
|
+
if (String(current).trim() === '' || !Number.isFinite(base)) {
|
|
483
|
+
throw new BotError(`Variabel "${name}" bernilai "${current}", bukan angka`);
|
|
484
|
+
}
|
|
485
|
+
const next = formatNumber(base + add);
|
|
486
|
+
await this.db.set(key, next);
|
|
487
|
+
return next;
|
|
488
|
+
} finally {
|
|
489
|
+
release();
|
|
490
|
+
if (this._locks.get(key) === chain) this._locks.delete(key);
|
|
491
|
+
}
|
|
410
492
|
}
|
|
411
493
|
|
|
412
494
|
// ===== Menjalankan =========================================================
|
|
@@ -748,6 +830,7 @@ class Bot {
|
|
|
748
830
|
throw new BotError('Token bot belum diisi. Contoh: new Bot({ token: process.env.TOKEN })');
|
|
749
831
|
}
|
|
750
832
|
this.validate();
|
|
833
|
+
this._started = true;
|
|
751
834
|
const discord = this._discord();
|
|
752
835
|
const client = this.options.client ?? new discord.Client({
|
|
753
836
|
intents: this._resolveIntents(discord),
|
|
@@ -757,19 +840,39 @@ class Bot {
|
|
|
757
840
|
this._bind(client, discord);
|
|
758
841
|
if (this.options.handleSignals && !this._signalsBound) {
|
|
759
842
|
this._signalsBound = true;
|
|
760
|
-
const shutdown = () => {
|
|
843
|
+
const shutdown = async () => {
|
|
844
|
+
const timeout = new Promise((resolve) => { setTimeout(resolve, 5000); });
|
|
845
|
+
try { await Promise.race([this.destroy(), timeout]); } catch { /* abaikan */ }
|
|
846
|
+
process.exit(0);
|
|
847
|
+
};
|
|
761
848
|
process.once('SIGINT', shutdown);
|
|
762
849
|
process.once('SIGTERM', shutdown);
|
|
763
|
-
process.once('exit', () => {
|
|
850
|
+
process.once('exit', () => {
|
|
851
|
+
try {
|
|
852
|
+
const r = this.db.flush?.();
|
|
853
|
+
if (r && typeof r.catch === 'function') r.catch(() => {});
|
|
854
|
+
} catch { /* abaikan */ }
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
try {
|
|
858
|
+
await this.db.connect?.();
|
|
859
|
+
} catch (e) {
|
|
860
|
+
throw new BotError(`Gagal terhubung ke database: ${e.message}`, { cause: e });
|
|
861
|
+
}
|
|
862
|
+
try {
|
|
863
|
+
await client.login(token);
|
|
864
|
+
} catch (e) {
|
|
865
|
+
try { await this.db.close?.(); } catch { /* abaikan */ }
|
|
866
|
+
throw e;
|
|
764
867
|
}
|
|
765
|
-
await client.login(token);
|
|
766
868
|
return this;
|
|
767
869
|
}
|
|
768
870
|
|
|
769
|
-
/** Matikan bot dan
|
|
871
|
+
/** Matikan bot, simpan database, dan tutup koneksinya. */
|
|
770
872
|
async destroy() {
|
|
771
|
-
this.db.flush();
|
|
873
|
+
await this.db.flush?.();
|
|
772
874
|
if (this.client && typeof this.client.destroy === 'function') await this.client.destroy();
|
|
875
|
+
await this.db.close?.();
|
|
773
876
|
}
|
|
774
877
|
}
|
|
775
878
|
|
package/src/database.js
CHANGED
|
@@ -60,6 +60,9 @@ class Database {
|
|
|
60
60
|
if (this._timer.unref) this._timer.unref();
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** Dipanggil saat bot dimatikan: simpan data. */
|
|
64
|
+
close() { this.flush(); }
|
|
65
|
+
|
|
63
66
|
/** Tulis ke disk sekarang juga (sinkron). */
|
|
64
67
|
flush() {
|
|
65
68
|
if (!this.file) return;
|
|
@@ -70,4 +73,24 @@ class Database {
|
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
75
|
|
|
73
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Salin semua data dari satu database ke database lain (mis. file JSON -> MongoDB).
|
|
78
|
+
* Data yang sudah ada di tujuan tidak ditimpa, kecuali overwrite: true.
|
|
79
|
+
* Kedua adapter harus sudah connect() bila perlu, dan `from` wajib punya method keys().
|
|
80
|
+
* Mengembalikan { copied, skipped }.
|
|
81
|
+
*/
|
|
82
|
+
async function copyDatabase(from, to, { overwrite = false, prefix = '' } = {}) {
|
|
83
|
+
if (typeof from?.keys !== 'function') throw new Error('copyDatabase: database asal harus punya method keys()');
|
|
84
|
+
let copied = 0;
|
|
85
|
+
let skipped = 0;
|
|
86
|
+
for (const key of await from.keys(prefix)) {
|
|
87
|
+
if (!overwrite && (await to.get(key)) !== undefined) { skipped++; continue; }
|
|
88
|
+
const value = await from.get(key);
|
|
89
|
+
if (value === undefined) continue;
|
|
90
|
+
await to.set(key, value);
|
|
91
|
+
copied++;
|
|
92
|
+
}
|
|
93
|
+
return { copied, skipped };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { Database, copyDatabase };
|
package/src/functions/control.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const { BotError, Stop } = require('../errors');
|
|
3
3
|
const { parseTime, formatDuration } = require('../util');
|
|
4
4
|
const { f, list, cleanId } = require('./helpers');
|
|
5
|
+
const { isOwner } = require('../owners');
|
|
5
6
|
|
|
6
7
|
const MAX_WAIT = 60 * 1000;
|
|
7
8
|
const MAX_DELETE_IN = 24 * 60 * 60 * 1000;
|
|
@@ -45,7 +46,7 @@ module.exports = [
|
|
|
45
46
|
}),
|
|
46
47
|
f('onlyOwner', { max: 1, lazy: true, s: '[pesan?]', d: 'Lanjut hanya jika pengirim ada di opsi owners' },
|
|
47
48
|
async (ctx, args, api) => {
|
|
48
|
-
if (
|
|
49
|
+
if (isOwner(ctx, '$onlyOwner')) return '';
|
|
49
50
|
throw new Stop(await msgOf(api, args, 0));
|
|
50
51
|
}),
|
|
51
52
|
f('onlyForIDs', { b: 'required', min: 1, max: 2, lazy: true, s: '[id1,id2;pesan?]', d: 'Lanjut hanya untuk user dengan ID tertentu' },
|
package/src/functions/eval.js
CHANGED
|
@@ -3,6 +3,7 @@ const util = require('node:util');
|
|
|
3
3
|
const { BotError, Stop } = require('../errors');
|
|
4
4
|
const { parse } = require('../parser');
|
|
5
5
|
const { f } = require('./helpers');
|
|
6
|
+
const { isOwner } = require('../owners');
|
|
6
7
|
|
|
7
8
|
const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;
|
|
8
9
|
const MAX_OUTPUT = 1800;
|
|
@@ -13,7 +14,7 @@ function guard(ctx, label) {
|
|
|
13
14
|
if (!ctx.bot.options.eval) {
|
|
14
15
|
throw new Stop(`❌ ${label} dimatikan. Nyalakan dengan new Bot({ eval: true, owners: ['ID_KAMU'] }).`);
|
|
15
16
|
}
|
|
16
|
-
if (!
|
|
17
|
+
if (!isOwner(ctx, label)) throw new Stop('❌ Hanya owner bot yang boleh memakai eval.');
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
function clean(ctx, text) {
|
|
@@ -88,8 +88,8 @@ module.exports = [
|
|
|
88
88
|
return '';
|
|
89
89
|
}),
|
|
90
90
|
|
|
91
|
-
f('createThread', { b: 'required', min: 1, max:
|
|
92
|
-
async (ctx, [name, mid, archive]) => {
|
|
91
|
+
f('createThread', { b: 'required', min: 1, max: 4, s: '[nama;messageID?;arsipOtomatisMenit?;channelID?]', d: 'Buat thread (dari pesan bila messageID diisi; channelID = channel tempat pesan itu, bawaan channel ini). Arsip: 60/1440/4320/10080. Mengembalikan ID thread' },
|
|
92
|
+
async (ctx, [name, mid, archive, cid]) => {
|
|
93
93
|
if (!name.trim()) throw new BotError('Nama thread tidak boleh kosong');
|
|
94
94
|
const opts = { name: limit('nama', name.trim(), 100) };
|
|
95
95
|
if (has(archive)) {
|
|
@@ -99,10 +99,11 @@ module.exports = [
|
|
|
99
99
|
}
|
|
100
100
|
let thread;
|
|
101
101
|
if (has(mid)) {
|
|
102
|
-
thread = await (await fetchMessage(ctx, mid)).startThread(opts);
|
|
102
|
+
thread = await (await fetchMessage(ctx, mid, cid)).startThread(opts);
|
|
103
103
|
} else {
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
const ch = await channelOrCurrent(ctx, cid);
|
|
105
|
+
if (!ch.threads || typeof ch.threads.create !== 'function') throw new BotError('Channel ini tidak mendukung thread');
|
|
106
|
+
thread = await ch.threads.create(opts);
|
|
106
107
|
}
|
|
107
108
|
return thread.id;
|
|
108
109
|
}),
|
package/src/functions/vars.js
CHANGED
|
@@ -17,21 +17,25 @@ for (const [Scope, scope, defaultId, label] of SCOPES) {
|
|
|
17
17
|
};
|
|
18
18
|
out.push(
|
|
19
19
|
f(`set${Scope}Var`, { b: 'required', min: 2, max: 3, s: `[nama;nilai;${label}ID?]`, d: `Simpan variabel ${label} (harus dideklarasikan dengan bot.variables)` },
|
|
20
|
-
(ctx, [name, value, id]) => { ctx.bot.setVar(scope, name, value, idFor(ctx, id)); return ''; }),
|
|
20
|
+
async (ctx, [name, value, id]) => { await ctx.bot.setVar(scope, name, value, idFor(ctx, id)); return ''; }),
|
|
21
21
|
f(`get${Scope}Var`, { b: 'required', min: 1, max: 2, s: `[nama;${label}ID?]`, d: `Baca variabel ${label}` },
|
|
22
22
|
(ctx, [name, id]) => ctx.bot.getVar(scope, name, idFor(ctx, id))),
|
|
23
23
|
f(`reset${Scope}Var`, { b: 'required', min: 1, max: 2, s: `[nama;${label}ID?]`, d: `Kembalikan variabel ${label} ke nilai awal` },
|
|
24
|
-
(ctx, [name, id]) => { ctx.bot.resetVar(scope, name, idFor(ctx, id)); return ''; }),
|
|
24
|
+
async (ctx, [name, id]) => { await ctx.bot.resetVar(scope, name, idFor(ctx, id)); return ''; }),
|
|
25
|
+
f(`inc${Scope}Var`, { b: 'required', min: 2, max: 3, s: `[nama;jumlah;${label}ID?]`, d: `Tambah variabel ${label} berupa angka (jumlah negatif = kurangi). Aman dari tabrakan antar command. Tidak menampilkan apa pun` },
|
|
26
|
+
async (ctx, [name, amount, id]) => { await ctx.bot.incVar(scope, name, amount, idFor(ctx, id)); return ''; }),
|
|
25
27
|
);
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
out.push(
|
|
29
31
|
f('setGlobalVar', { b: 'required', min: 2, max: 2, s: '[nama;nilai]', d: 'Simpan variabel global' },
|
|
30
|
-
(ctx, [name, value]) => { ctx.bot.setVar('global', name, value, null); return ''; }),
|
|
32
|
+
async (ctx, [name, value]) => { await ctx.bot.setVar('global', name, value, null); return ''; }),
|
|
31
33
|
f('getGlobalVar', { b: 'required', min: 1, max: 1, s: '[nama]', d: 'Baca variabel global' },
|
|
32
34
|
(ctx, [name]) => ctx.bot.getVar('global', name, null)),
|
|
33
35
|
f('resetGlobalVar', { b: 'required', min: 1, max: 1, s: '[nama]', d: 'Kembalikan variabel global ke nilai awal' },
|
|
34
|
-
(ctx, [name]) => { ctx.bot.resetVar('global', name, null); return ''; }),
|
|
36
|
+
async (ctx, [name]) => { await ctx.bot.resetVar('global', name, null); return ''; }),
|
|
37
|
+
f('incGlobalVar', { b: 'required', min: 2, max: 2, s: '[nama;jumlah]', d: 'Tambah variabel global berupa angka (jumlah negatif = kurangi)' },
|
|
38
|
+
async (ctx, [name, amount]) => { await ctx.bot.incVar('global', name, amount, null); return ''; }),
|
|
35
39
|
);
|
|
36
40
|
|
|
37
41
|
module.exports = out;
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
const { Bot } = require('./bot');
|
|
3
|
-
const { Database } = require('./database');
|
|
3
|
+
const { Database, copyDatabase } = require('./database');
|
|
4
4
|
const { Context } = require('./context');
|
|
5
5
|
const { BotError, ParseError, ValidationError } = require('./errors');
|
|
6
6
|
const { parse } = require('./parser');
|
|
@@ -9,6 +9,7 @@ const { createRegistry, CATEGORIES } = require('./functions');
|
|
|
9
9
|
module.exports = {
|
|
10
10
|
Bot,
|
|
11
11
|
Database,
|
|
12
|
+
copyDatabase,
|
|
12
13
|
Context,
|
|
13
14
|
BotError,
|
|
14
15
|
ParseError,
|
package/src/owners.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('./errors');
|
|
3
|
+
|
|
4
|
+
/** Rapikan daftar owners: wajib string; angka besar ditolak karena ID Discord kehilangan ketelitian. */
|
|
5
|
+
function normalizeOwners(input) {
|
|
6
|
+
const list = Array.isArray(input) ? input : [input];
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const raw of list) {
|
|
9
|
+
if (raw === undefined || raw === null || raw === '' || raw === false) continue;
|
|
10
|
+
if (typeof raw === 'number' && !Number.isSafeInteger(raw)) {
|
|
11
|
+
throw new BotError(
|
|
12
|
+
`owners: angka ${raw} tidak akurat karena ID Discord terlalu besar untuk angka JavaScript. ` +
|
|
13
|
+
'Tulis ID dengan tanda kutip, contoh: owners: ["123456789012345678"]',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
let id = String(raw).trim();
|
|
17
|
+
const mention = /^<@!?(\d+)>$/.exec(id);
|
|
18
|
+
if (mention) id = mention[1];
|
|
19
|
+
id = id.replace(/[-]/g, '');
|
|
20
|
+
if (id) out.push(id);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Cek owner; bila ditolak, tulis petunjuk ke konsol agar mudah dicari penyebabnya. */
|
|
26
|
+
function isOwner(ctx, label) {
|
|
27
|
+
const id = ctx.user?.id;
|
|
28
|
+
const owners = ctx.bot.options.owners;
|
|
29
|
+
if (owners.includes(id)) return true;
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
console.warn(`[easydc] ${label} ditolak: ID pengirim = "${id}", owners yang terdaftar = ${JSON.stringify(owners)}. ` +
|
|
32
|
+
'Salin ID pengirim ke owners (dengan tanda kutip) bila itu memang akunmu.');
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = { normalizeOwners, isOwner };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('../errors');
|
|
3
|
+
|
|
4
|
+
const MISSING = Symbol('missing');
|
|
5
|
+
const escapeRegex = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Adapter database MongoDB. Setiap variabel = satu dokumen { _id: kunci, v: nilai, t: waktuUbah }.
|
|
9
|
+
*
|
|
10
|
+
* Opsi:
|
|
11
|
+
* - uri alamat MongoDB (wajib kecuali memberi `client`), mis. process.env.MONGO_URI
|
|
12
|
+
* - dbName nama database (bila kosong dipakai nama dari URI, atau "test")
|
|
13
|
+
* - collection nama collection, bawaan "easydc"
|
|
14
|
+
* - cache true (bawaan): simpan data yang sudah dibaca di RAM. Matikan (false) bila
|
|
15
|
+
* LEBIH DARI SATU proses bot memakai database yang sama.
|
|
16
|
+
* - cacheLimit maksimal jumlah kunci di cache (bawaan 10000)
|
|
17
|
+
* - client MongoClient milik sendiri (tidak ditutup otomatis)
|
|
18
|
+
* - driver modul mongodb sendiri (untuk pengujian)
|
|
19
|
+
* - clientOptions opsi tambahan untuk new MongoClient(uri, opsi)
|
|
20
|
+
*/
|
|
21
|
+
class MongoDatabase {
|
|
22
|
+
constructor(options = {}) {
|
|
23
|
+
if (!options.uri && !options.client) {
|
|
24
|
+
throw new BotError('mongodb: opsi "uri" wajib diisi. Contoh: mongodb({ uri: process.env.MONGO_URI, dbName: "botku" })');
|
|
25
|
+
}
|
|
26
|
+
this.options = options;
|
|
27
|
+
this.client = options.client ?? null;
|
|
28
|
+
this._ownsClient = !options.client;
|
|
29
|
+
this.collectionName = options.collection ?? 'easydc';
|
|
30
|
+
if (typeof this.collectionName !== 'string' || !/^[^$\0]{1,120}$/.test(this.collectionName) || this.collectionName.startsWith('system.')) {
|
|
31
|
+
throw new BotError(`mongodb: nama collection "${this.collectionName}" tidak valid`);
|
|
32
|
+
}
|
|
33
|
+
this.useCache = options.cache !== false;
|
|
34
|
+
this.cacheLimit = options.cacheLimit ?? 10000;
|
|
35
|
+
this.cache = new Map();
|
|
36
|
+
this._gen = new Map();
|
|
37
|
+
this.col = null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_loadDriver() {
|
|
41
|
+
if (this.options.driver) return this.options.driver;
|
|
42
|
+
try {
|
|
43
|
+
// eslint-disable-next-line global-require
|
|
44
|
+
return require('mongodb');
|
|
45
|
+
} catch (e) {
|
|
46
|
+
throw new BotError('Package "mongodb" tidak ditemukan. Jalankan: npm install mongodb', { cause: e });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async connect() {
|
|
51
|
+
if (this.col) return;
|
|
52
|
+
if (!this.client) {
|
|
53
|
+
const { MongoClient } = this._loadDriver();
|
|
54
|
+
this.client = new MongoClient(this.options.uri, { serverSelectionTimeoutMS: 10000, ...this.options.clientOptions });
|
|
55
|
+
}
|
|
56
|
+
await this.client.connect();
|
|
57
|
+
this.col = this.client.db(this.options.dbName).collection(this.collectionName);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_ready() {
|
|
61
|
+
if (!this.col) throw new BotError('Database MongoDB belum terhubung. Pastikan bot.connect() sudah dipanggil');
|
|
62
|
+
return this.col;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_touch(key) { this._gen.set(key, (this._gen.get(key) ?? 0) + 1); }
|
|
66
|
+
|
|
67
|
+
_remember(key, value) {
|
|
68
|
+
if (!this.useCache) return;
|
|
69
|
+
this.cache.delete(key); // pindahkan ke posisi terbaru
|
|
70
|
+
this.cache.set(key, value);
|
|
71
|
+
if (this.cache.size > this.cacheLimit) this.cache.delete(this.cache.keys().next().value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get(key) {
|
|
75
|
+
if (this.useCache && this.cache.has(key)) {
|
|
76
|
+
const hit = this.cache.get(key);
|
|
77
|
+
return hit === MISSING ? undefined : hit;
|
|
78
|
+
}
|
|
79
|
+
const col = this._ready();
|
|
80
|
+
const gen = this._gen.get(key) ?? 0;
|
|
81
|
+
const doc = await col.findOne({ _id: key });
|
|
82
|
+
// Jangan isi cache bila kunci ini diubah selagi kita membaca (supaya tidak menyimpan data basi)
|
|
83
|
+
if ((this._gen.get(key) ?? 0) === gen) this._remember(key, doc ? doc.v : MISSING);
|
|
84
|
+
return doc ? doc.v : undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async has(key) { return (await this.get(key)) !== undefined; }
|
|
88
|
+
|
|
89
|
+
async set(key, value) {
|
|
90
|
+
const col = this._ready();
|
|
91
|
+
this._touch(key);
|
|
92
|
+
await col.updateOne({ _id: key }, { $set: { v: value, t: new Date() } }, { upsert: true });
|
|
93
|
+
this._touch(key);
|
|
94
|
+
this._remember(key, value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async delete(key) {
|
|
98
|
+
const col = this._ready();
|
|
99
|
+
this._touch(key);
|
|
100
|
+
const res = await col.deleteOne({ _id: key });
|
|
101
|
+
this._touch(key);
|
|
102
|
+
this._remember(key, MISSING);
|
|
103
|
+
return (res?.deletedCount ?? 0) > 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Daftar kunci berawalan `prefix` (langsung dari database, bukan dari cache). */
|
|
107
|
+
async keys(prefix = '') {
|
|
108
|
+
const col = this._ready();
|
|
109
|
+
const filter = prefix ? { _id: { $regex: `^${escapeRegex(prefix)}` } } : {};
|
|
110
|
+
const docs = await col.find(filter, { projection: { _id: 1 } }).toArray();
|
|
111
|
+
return docs.map((d) => d._id);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Penulisan ke MongoDB langsung (tanpa antrean), jadi tidak ada yang perlu di-flush. */
|
|
115
|
+
async flush() {}
|
|
116
|
+
|
|
117
|
+
async close() {
|
|
118
|
+
this.cache.clear();
|
|
119
|
+
this._gen.clear();
|
|
120
|
+
const had = this.col;
|
|
121
|
+
this.col = null;
|
|
122
|
+
if (had && this._ownsClient && this.client) await this.client.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Plugin MongoDB untuk Bot.
|
|
128
|
+
* const { mongodb } = require('@tryty/easydc-js/mongodb');
|
|
129
|
+
* bot.use(mongodb({ uri: process.env.MONGO_URI, dbName: 'botku' }));
|
|
130
|
+
*/
|
|
131
|
+
function mongodb(options) {
|
|
132
|
+
return {
|
|
133
|
+
name: 'mongodb',
|
|
134
|
+
install(bot) {
|
|
135
|
+
bot.setDatabase(new MongoDatabase(options));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
module.exports = { mongodb, MongoDatabase };
|