@tryty/easydc-js 1.1.0 → 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.
@@ -0,0 +1,44 @@
1
+ // Bot dengan database MongoDB.
2
+ // Pasang dulu: npm install easydc-js discord.js mongodb
3
+ // Jalankan: TOKEN=token_bot MONGO_URI="mongodb+srv://..." node examples/mongo.js
4
+ const { Bot } = require('easydc-js'); // saat dicoba dari folder ini: require('../src')
5
+ const { mongodb } = require('easydc-js/mongodb'); // saat dicoba dari folder ini: require('../src/plugins/mongodb')
6
+
7
+ const bot = new Bot({ token: process.env.TOKEN, prefix: '!', timezone: 'Asia/Jakarta' });
8
+
9
+ bot.use(mongodb({
10
+ uri: process.env.MONGO_URI,
11
+ dbName: 'botku',
12
+ // cache: false, // aktifkan baris ini jika bot dijalankan di lebih dari satu proses/server
13
+ }));
14
+
15
+ bot.variables({ uang: 100 });
16
+
17
+ bot.command({ name: 'uang', code: '💰 $username punya $numberSeparator[$getUserVar[uang];.] koin' });
18
+
19
+ bot.command({
20
+ name: 'kerja',
21
+ code: `
22
+ $cooldown[1m;Istirahat dulu, tunggu %time%.]
23
+ $incUserVar[uang;$random[10;50]]
24
+ Kamu bekerja! Uangmu sekarang $getUserVar[uang]
25
+ `,
26
+ });
27
+
28
+ bot.command({
29
+ name: 'kirim',
30
+ code: `
31
+ $argsCheck[2;Format: !kirim @user jumlah]
32
+ $onlyIf[$mentioned[1]!=;Mention user tujuan.]
33
+ $onlyIf[$isNumber[$message[2]]==true&&$message[2]>0;Jumlah harus angka lebih dari 0.]
34
+ $onlyIf[$getUserVar[uang]>=$message[2];Uangmu tidak cukup.]
35
+ $incUserVar[uang;-$message[2]]
36
+ $incUserVar[uang;$message[2];$mentioned[1]]
37
+ ✅ Terkirim $message[2] koin ke <@$mentioned[1]>
38
+ `,
39
+ });
40
+
41
+ bot.connect().then(() => console.log('Bot online, database terhubung.')).catch((e) => {
42
+ console.error(e.message);
43
+ process.exit(1);
44
+ });
package/index.d.ts CHANGED
@@ -15,7 +15,7 @@ export interface BotOptions {
15
15
  handleSignals?: boolean;
16
16
  /** Nyalakan $eval / $jsEval / bot.evalCommand (hanya untuk ID di `owners`). Bawaan: false */
17
17
  eval?: boolean;
18
- database?: { path?: string; memory?: boolean; saveDelay?: number } | Database;
18
+ database?: { path?: string; memory?: boolean; saveDelay?: number } | DatabaseAdapter;
19
19
  messages?: { cooldown?: string; error?: string };
20
20
  status?: { text: string; type?: 'Playing' | 'Streaming' | 'Listening' | 'Watching' | 'Competing'; status?: 'online' | 'idle' | 'dnd' | 'invisible' };
21
21
  onError?: (error: Error, ctx: Context) => unknown;
@@ -70,7 +70,19 @@ export class Context {
70
70
  rawArgs: string;
71
71
  }
72
72
 
73
- export class Database {
73
+ /** Adapter database: hanya get/set/delete yang wajib. Nilai balik boleh Promise. */
74
+ export interface DatabaseAdapter {
75
+ get(key: string): unknown | Promise<unknown>;
76
+ set(key: string, value: unknown): unknown | Promise<unknown>;
77
+ delete(key: string): unknown | Promise<unknown>;
78
+ has?(key: string): boolean | Promise<boolean>;
79
+ keys?(prefix?: string): string[] | Promise<string[]>;
80
+ flush?(): unknown | Promise<unknown>;
81
+ connect?(): Promise<void>;
82
+ close?(): unknown | Promise<unknown>;
83
+ }
84
+
85
+ export class Database implements DatabaseAdapter {
74
86
  constructor(options?: { path?: string; memory?: boolean; saveDelay?: number });
75
87
  get(key: string): unknown;
76
88
  set(key: string, value: unknown): void;
@@ -78,12 +90,18 @@ export class Database {
78
90
  delete(key: string): boolean;
79
91
  keys(prefix?: string): string[];
80
92
  flush(): void;
93
+ close(): void;
81
94
  }
82
95
 
96
+ export function copyDatabase(from: DatabaseAdapter, to: DatabaseAdapter, options?: { overwrite?: boolean; prefix?: string }): Promise<{ copied: number; skipped: number }>;
97
+
98
+ export type Plugin = ((bot: Bot, options?: unknown) => void) | { name?: string; install(bot: Bot, options?: unknown): void };
99
+
83
100
  export class Bot {
84
101
  constructor(options?: BotOptions);
85
102
  client: Client | null;
86
- db: Database;
103
+ db: DatabaseAdapter;
104
+ plugins: Map<string, unknown>;
87
105
  command(def: CommandDef): this;
88
106
  slashCommand(def: SlashDef): this;
89
107
  button(def: ComponentDef): this;
@@ -117,9 +135,12 @@ export class Bot {
117
135
  addFunction(fn: CustomFunction): this;
118
136
  loadCommands(dir: string): this;
119
137
  validate(): true;
120
- getVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, id?: string | null): string;
121
- setVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, value: unknown, id?: string | null): void;
122
- resetVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, id?: string | null): void;
138
+ use(plugin: Plugin, options?: unknown): this;
139
+ setDatabase(adapter: DatabaseAdapter): this;
140
+ getVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, id?: string | null): Promise<string>;
141
+ setVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, value: unknown, id?: string | null): Promise<void>;
142
+ resetVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, id?: string | null): Promise<void>;
143
+ incVar(scope: 'user' | 'server' | 'channel' | 'global', name: string, amount: number | string, id?: string | null): Promise<string>;
123
144
  connect(token?: string): Promise<this>;
124
145
  destroy(): Promise<void>;
125
146
  }
package/mongodb.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import type { Database } from './index';
2
+
3
+ export interface MongoOptions {
4
+ /** Alamat MongoDB, mis. process.env.MONGO_URI. Wajib kecuali `client` diisi. */
5
+ uri?: string;
6
+ dbName?: string;
7
+ /** Bawaan: "easydc" */
8
+ collection?: string;
9
+ /** Bawaan true. Matikan bila lebih dari satu proses bot memakai database yang sama. */
10
+ cache?: boolean;
11
+ cacheLimit?: number;
12
+ /** MongoClient milik sendiri (tidak ditutup otomatis). */
13
+ client?: unknown;
14
+ driver?: unknown;
15
+ clientOptions?: Record<string, unknown>;
16
+ }
17
+
18
+ export class MongoDatabase {
19
+ constructor(options: MongoOptions);
20
+ connect(): Promise<void>;
21
+ get(key: string): Promise<string | undefined>;
22
+ has(key: string): Promise<boolean>;
23
+ set(key: string, value: unknown): Promise<void>;
24
+ delete(key: string): Promise<boolean>;
25
+ keys(prefix?: string): Promise<string[]>;
26
+ flush(): Promise<void>;
27
+ close(): Promise<void>;
28
+ }
29
+
30
+ /** Plugin: bot.use(mongodb({ uri, dbName })) */
31
+ export function mongodb(options: MongoOptions): { name: string; install(bot: unknown): void };
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@tryty/easydc-js",
3
- "version": "1.1.0",
4
- "description": "Bot Discord super mudah di atas discord.js: tulis perintah dengan sintaks $fungsi[arg] ala aoi.js.",
3
+ "version": "1.2.0",
4
+ "description": "Super easy Discord bot on top of discord.js: write commands with $function[arg] syntax.",
5
5
  "main": "src/index.js",
6
6
  "types": "index.d.ts",
7
7
  "files": [
8
8
  "src",
9
9
  "examples",
10
10
  "index.d.ts",
11
+ "mongodb.d.ts",
11
12
  "README.md",
12
13
  "LICENSE"
13
14
  ],
@@ -16,11 +17,12 @@
16
17
  "docs": "node scripts/gen-docs.js"
17
18
  },
18
19
  "keywords": [
20
+ "bot",
19
21
  "discord",
20
22
  "discord.js",
21
- "bot",
22
- "aoi.js",
23
23
  "easy",
24
+ "mongodb",
25
+ "plugin",
24
26
  "wrapper"
25
27
  ],
26
28
  "author": "",
@@ -30,5 +32,24 @@
30
32
  },
31
33
  "dependencies": {
32
34
  "discord.js": "^14.16.0"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "types": "./index.d.ts",
39
+ "default": "./src/index.js"
40
+ },
41
+ "./mongodb": {
42
+ "types": "./mongodb.d.ts",
43
+ "default": "./src/plugins/mongodb.js"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "peerDependencies": {
48
+ "mongodb": ">=5.0.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "mongodb": {
52
+ "optional": true
53
+ }
33
54
  }
34
55
  }
package/src/bot.js CHANGED
@@ -7,6 +7,7 @@ const { Interpreter } = require('./interpreter');
7
7
  const { Context } = require('./context');
8
8
  const { Database } = require('./database');
9
9
  const { createRegistry } = require('./functions');
10
+ const { formatNumber } = require('./util');
10
11
  const { f } = require('./functions/helpers');
11
12
 
12
13
  const DEFAULT_INTENTS = [
@@ -38,6 +39,16 @@ const EVENT_TYPES = [
38
39
  ];
39
40
  const VAR_SCOPES = ['user', 'server', 'channel', 'global'];
40
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
+
41
52
  const wildcardToRegExp = (pattern) => new RegExp(
42
53
  `^${pattern.split('*').map((s) => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')}$`,
43
54
  );
@@ -80,7 +91,9 @@ class Bot {
80
91
  this.client = null;
81
92
  this.registry = createRegistry();
82
93
  this.interpreter = new Interpreter();
83
- this.db = opts.database instanceof Database ? opts.database : new Database(opts.database);
94
+ this.db = looksLikeAdapter(opts.database) ? checkAdapter(opts.database) : new Database(opts.database);
95
+ this.plugins = new Map();
96
+ this._locks = new Map();
84
97
  this.cooldowns = new Map();
85
98
 
86
99
  this.commands = new Map(); // nama/alias -> handler
@@ -280,6 +293,33 @@ class Bot {
280
293
  /** Jalan untuk setiap pesan yang BUKAN command. */
281
294
  onMessage(arg) { return this._eventShortcut('message', arg); }
282
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
+
283
323
  /** Deklarasi variabel + nilai awal: bot.variables({ uang: 0, bio: '-' }) */
284
324
  variables(vars) {
285
325
  if (!vars || typeof vars !== 'object') throw new BotError('variables: berikan object { nama: nilaiAwal }');
@@ -395,18 +435,49 @@ class Bot {
395
435
  return scope === 'global' ? `global:${name}` : `${scope}:${id}:${name}`;
396
436
  }
397
437
 
398
- getVar(scope, name, id) {
438
+ // Semua akses variabel bersifat asinkron karena adapter database (mis. MongoDB) berjalan lewat jaringan.
439
+ async getVar(scope, name, id) {
399
440
  const key = this._varKey(scope, name, id);
400
- const stored = this.db.get(key);
401
- return stored === undefined ? this.declaredVars.get(name) : stored;
441
+ const stored = await this.db.get(key);
442
+ return stored === undefined || stored === null ? this.declaredVars.get(name) : stored;
402
443
  }
403
444
 
404
- setVar(scope, name, value, id) {
405
- this.db.set(this._varKey(scope, name, id), String(value));
445
+ async setVar(scope, name, value, id) {
446
+ await this.db.set(this._varKey(scope, name, id), String(value));
406
447
  }
407
448
 
408
- resetVar(scope, name, id) {
409
- this.db.delete(this._varKey(scope, name, id));
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
+ }
410
481
  }
411
482
 
412
483
  // ===== Menjalankan =========================================================
@@ -748,6 +819,7 @@ class Bot {
748
819
  throw new BotError('Token bot belum diisi. Contoh: new Bot({ token: process.env.TOKEN })');
749
820
  }
750
821
  this.validate();
822
+ this._started = true;
751
823
  const discord = this._discord();
752
824
  const client = this.options.client ?? new discord.Client({
753
825
  intents: this._resolveIntents(discord),
@@ -757,19 +829,39 @@ class Bot {
757
829
  this._bind(client, discord);
758
830
  if (this.options.handleSignals && !this._signalsBound) {
759
831
  this._signalsBound = true;
760
- const shutdown = () => { try { this.db.flush(); } catch { /* abaikan */ } process.exit(0); };
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
+ };
761
837
  process.once('SIGINT', shutdown);
762
838
  process.once('SIGTERM', shutdown);
763
- process.once('exit', () => { try { this.db.flush(); } catch { /* abaikan */ } });
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;
764
856
  }
765
- await client.login(token);
766
857
  return this;
767
858
  }
768
859
 
769
- /** Matikan bot dan simpan database. */
860
+ /** Matikan bot, simpan database, dan tutup koneksinya. */
770
861
  async destroy() {
771
- this.db.flush();
862
+ await this.db.flush?.();
772
863
  if (this.client && typeof this.client.destroy === 'function') await this.client.destroy();
864
+ await this.db.close?.();
773
865
  }
774
866
  }
775
867
 
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 };
@@ -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,
@@ -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('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 };