@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/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 = (Array.isArray(opts.owners) ? opts.owners : [opts.owners]).filter(Boolean).map(String);
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 instanceof Database ? opts.database : new Database(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
- getVar(scope, name, id) {
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
- setVar(scope, name, value, id) {
405
- this.db.set(this._varKey(scope, name, id), String(value));
460
+ async resetVar(scope, name, id) {
461
+ await this.db.delete(this._varKey(scope, name, id));
406
462
  }
407
463
 
408
- resetVar(scope, name, id) {
409
- this.db.delete(this._varKey(scope, name, id));
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 = () => { try { this.db.flush(); } catch { /* abaikan */ } process.exit(0); };
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', () => { try { this.db.flush(); } catch { /* abaikan */ } });
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 simpan database. */
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
- module.exports = { Database };
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 };
@@ -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 (ctx.bot.options.owners.includes(ctx.user?.id)) return '';
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' },
@@ -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 (!ctx.bot.options.owners.includes(ctx.user?.id)) throw new Stop('❌ Hanya owner bot yang boleh memakai eval.');
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: 3, s: '[nama;messageID?;arsipOtomatisMenit?]', d: 'Buat thread (dari pesan bila messageID diisi). Arsip: 60/1440/4320/10080. Mengembalikan ID thread' },
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
- if (!ctx.channel?.threads || typeof ctx.channel.threads.create !== 'function') throw new BotError('Channel ini tidak mendukung thread');
105
- thread = await ctx.channel.threads.create(opts);
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
  }),
@@ -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 };