@tryty/easydc-js 0.0.0-stage → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parser.js ADDED
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+ const { ParseError } = require('./errors');
3
+
4
+ // Karakter yang bisa di-escape dengan backslash: \$ \[ \] \; \\
5
+ const ESCAPABLE = '$[];\\';
6
+ const IDENT = /[A-Za-z][A-Za-z0-9_]*/y;
7
+ const BLOCK = new Set(['if', 'elseif', 'else', 'endif']);
8
+
9
+ function lineCol(root, pos) {
10
+ let line = 1;
11
+ let col = 1;
12
+ for (let i = 0; i < pos && i < root.length; i++) {
13
+ if (root[i] === '\n') { line++; col = 1; } else col++;
14
+ }
15
+ return { line, col };
16
+ }
17
+
18
+ function fail(message, pos, state) {
19
+ return new ParseError(message, lineCol(state.root, pos));
20
+ }
21
+
22
+ /** Cari "]" penutup yang cocok untuk "[" di indeks `open` (menghormati escape & nesting). */
23
+ function findClose(src, open) {
24
+ let depth = 0;
25
+ for (let i = open; i < src.length; i++) {
26
+ const c = src[i];
27
+ if (c === '\\') { i++; continue; }
28
+ if (c === '[') depth++;
29
+ else if (c === ']') {
30
+ depth--;
31
+ if (depth === 0) return i;
32
+ }
33
+ }
34
+ return -1;
35
+ }
36
+
37
+ /** Pecah isi kurung dengan ";" level-0 lalu parse tiap argumen. */
38
+ function splitArgs(inner, base, state) {
39
+ if (inner === '') return [];
40
+ const ranges = [];
41
+ let depth = 0;
42
+ let start = 0;
43
+ for (let i = 0; i < inner.length; i++) {
44
+ const c = inner[i];
45
+ if (c === '\\') { i++; continue; }
46
+ if (c === '[') depth++;
47
+ else if (c === ']') depth--;
48
+ else if (c === ';' && depth === 0) { ranges.push([start, i]); start = i + 1; }
49
+ }
50
+ ranges.push([start, inner.length]);
51
+ return ranges.map(([s, e]) => structure(parseFlat(inner.slice(s, e), base + s, state), state));
52
+ }
53
+
54
+ function describeArity(def) {
55
+ if (def.min === def.max) return `tepat ${def.min}`;
56
+ if (def.max === Infinity) return `minimal ${def.min}`;
57
+ return `${def.min}-${def.max}`;
58
+ }
59
+
60
+ /** Tahap 1: teks + pemanggilan fungsi (belum ada struktur $if). */
61
+ function parseFlat(src, base, state) {
62
+ const nodes = [];
63
+ let text = '';
64
+ const flush = () => {
65
+ if (text) { nodes.push({ type: 'text', value: text }); text = ''; }
66
+ };
67
+ let i = 0;
68
+ while (i < src.length) {
69
+ const ch = src[i];
70
+ if (ch === '\\' && i + 1 < src.length && ESCAPABLE.includes(src[i + 1])) {
71
+ text += src[i + 1];
72
+ i += 2;
73
+ continue;
74
+ }
75
+ if (ch !== '$') { text += ch; i++; continue; }
76
+
77
+ IDENT.lastIndex = i + 1;
78
+ const m = IDENT.exec(src);
79
+ if (!m) { text += '$'; i++; continue; } // "$5", "$" sendirian = teks biasa
80
+
81
+ const ident = m[0];
82
+ let len = 0;
83
+ for (let l = ident.length; l >= 1; l--) {
84
+ if (state.registry.has(ident.slice(0, l).toLowerCase())) { len = l; break; }
85
+ }
86
+ if (!len) {
87
+ throw fail(`Fungsi $${ident} tidak dikenal (kalau maksudnya teks biasa, tulis \\$${ident})`, base + i, state);
88
+ }
89
+ const def = state.registry.get(ident.slice(0, len).toLowerCase());
90
+ let j = i + 1 + len;
91
+ let args = null;
92
+ if (src[j] === '[' && def.brackets !== 'none') {
93
+ const close = findClose(src, j);
94
+ if (close === -1) throw fail(`Tanda ] penutup untuk ${def.display} tidak ditemukan`, base + i, state);
95
+ args = splitArgs(src.slice(j + 1, close), base + j + 1, state);
96
+ j = close + 1;
97
+ }
98
+ if (def.brackets === 'required' && args === null) {
99
+ throw fail(`${def.display} wajib memakai tanda kurung, contoh: ${def.display}[...]`, base + i, state);
100
+ }
101
+ const n = args ? args.length : 0;
102
+ if (n < def.min || n > def.max) {
103
+ throw fail(`${def.display} membutuhkan ${describeArity(def)} argumen, tetapi diberi ${n}`, base + i, state);
104
+ }
105
+ flush();
106
+ nodes.push({ type: 'fn', name: def.name, def, args, pos: base + i });
107
+ if (BLOCK.has(def.name)) {
108
+ if (src[j] === '\r' && src[j + 1] === '\n') j += 2;
109
+ else if (src[j] === '\n') j += 1;
110
+ }
111
+ i = j;
112
+ }
113
+ flush();
114
+ return nodes;
115
+ }
116
+
117
+ /** Tahap 2: ubah $if/$elseif/$else/$endif menjadi node blok. */
118
+ function structure(nodes, state) {
119
+ const root = { kind: 'root', body: [] };
120
+ const stack = [root];
121
+ const top = () => stack[stack.length - 1];
122
+
123
+ for (const node of nodes) {
124
+ if (node.type !== 'fn' || !BLOCK.has(node.def.name)) { top().body.push(node); continue; }
125
+ const name = node.def.name;
126
+ if (name === 'if') {
127
+ const frame = { kind: 'if', pos: node.pos, branches: [], body: null, sawElse: false };
128
+ const branch = { cond: node.args[0], body: [] };
129
+ frame.branches.push(branch);
130
+ frame.body = branch.body;
131
+ stack.push(frame);
132
+ continue;
133
+ }
134
+ const frame = top();
135
+ if (frame.kind !== 'if') throw fail(`${node.def.display} tanpa $if di depannya`, node.pos, state);
136
+ if (name === 'elseif') {
137
+ if (frame.sawElse) throw fail('$elseif tidak boleh setelah $else', node.pos, state);
138
+ const branch = { cond: node.args[0], body: [] };
139
+ frame.branches.push(branch);
140
+ frame.body = branch.body;
141
+ } else if (name === 'else') {
142
+ if (frame.sawElse) throw fail('$else tidak boleh dipakai dua kali dalam satu $if', node.pos, state);
143
+ frame.sawElse = true;
144
+ const branch = { cond: null, body: [] };
145
+ frame.branches.push(branch);
146
+ frame.body = branch.body;
147
+ } else {
148
+ stack.pop();
149
+ top().body.push({ type: 'if', branches: frame.branches, pos: frame.pos });
150
+ }
151
+ }
152
+ if (stack.length > 1) {
153
+ throw fail('$if tidak ditutup, tambahkan $endif', top().pos, state);
154
+ }
155
+ return root.body;
156
+ }
157
+
158
+ /**
159
+ * Parse kode menjadi AST. `registry` = Map(namaLowercase -> definisi fungsi).
160
+ * Melempar ParseError (dengan baris/kolom) bila ada kesalahan.
161
+ */
162
+ function parse(code, registry) {
163
+ const src = String(code ?? '');
164
+ const state = { root: src, registry };
165
+ return structure(parseFlat(src, 0, state), state);
166
+ }
167
+
168
+ module.exports = { parse };
@@ -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 };
package/src/util.js ADDED
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+ const { BotError } = require('./errors');
3
+
4
+ const UNITS = { ms: 1, s: 1000, m: 60000, h: 3600000, d: 86400000, w: 604800000 };
5
+
6
+ /** "10s", "5m", "1h30m", "1500" (ms) -> milidetik. */
7
+ function parseTime(input) {
8
+ const s = String(input ?? '').trim().toLowerCase().replace(/\s+/g, '');
9
+ if (!s) throw new BotError('Waktu kosong (contoh: 10s, 5m, 1h30m)');
10
+ if (/^\d+$/.test(s)) return Number(s);
11
+ const re = /(\d+(?:\.\d+)?)(ms|s|m|h|d|w)/y;
12
+ let total = 0;
13
+ let idx = 0;
14
+ while (idx < s.length) {
15
+ re.lastIndex = idx;
16
+ const m = re.exec(s);
17
+ if (!m) throw new BotError(`Format waktu salah: "${input}" (contoh: 10s, 5m, 1h30m)`);
18
+ total += Number(m[1]) * UNITS[m[2]];
19
+ idx = re.lastIndex;
20
+ }
21
+ return Math.round(total);
22
+ }
23
+
24
+ /** ms -> "1d 2h 3m 4s". */
25
+ function formatDuration(ms) {
26
+ let s = Math.floor(Math.max(0, Number(ms) || 0) / 1000);
27
+ const d = Math.floor(s / 86400);
28
+ s -= d * 86400;
29
+ const h = Math.floor(s / 3600);
30
+ s -= h * 3600;
31
+ const m = Math.floor(s / 60);
32
+ s -= m * 60;
33
+ const parts = [];
34
+ if (d) parts.push(`${d}d`);
35
+ if (h) parts.push(`${h}h`);
36
+ if (m) parts.push(`${m}m`);
37
+ if (s || !parts.length) parts.push(`${s}s`);
38
+ return parts.join(' ');
39
+ }
40
+
41
+ /** Format angka hasil hitungan tanpa noise floating point. */
42
+ function formatNumber(n) {
43
+ if (!Number.isFinite(n)) throw new BotError('Hasil hitungan bukan angka yang valid');
44
+ if (Number.isInteger(n)) return String(n);
45
+ return String(parseFloat(n.toPrecision(12)));
46
+ }
47
+
48
+ /** Kalkulator aman (tanpa eval). Mendukung + - * / % ^ ( ) dan fungsi umum. */
49
+ function safeMath(expr) {
50
+ const src = String(expr ?? '').toLowerCase().replace(/\s+/g, '').replace(/\*\*/g, '^');
51
+ if (!src) throw new BotError('Ekspresi matematika kosong');
52
+ const FUNCS = {
53
+ sqrt: Math.sqrt, abs: Math.abs, round: Math.round, floor: Math.floor, ceil: Math.ceil,
54
+ trunc: Math.trunc, sign: Math.sign, log: Math.log, log10: Math.log10, min: Math.min,
55
+ max: Math.max, pow: Math.pow,
56
+ };
57
+ const CONSTS = { pi: Math.PI, e: Math.E };
58
+ let i = 0;
59
+
60
+ const fail = (msg) => { throw new BotError(`${msg} pada "${expr}"`); };
61
+ const peek = () => src[i];
62
+
63
+ function parseExpr() {
64
+ let v = parseTerm();
65
+ while (peek() === '+' || peek() === '-') {
66
+ const op = src[i++];
67
+ const r = parseTerm();
68
+ v = op === '+' ? v + r : v - r;
69
+ }
70
+ return v;
71
+ }
72
+ function parseTerm() {
73
+ let v = parseUnary();
74
+ while (peek() === '*' || peek() === '/' || peek() === '%') {
75
+ const op = src[i++];
76
+ const r = parseUnary();
77
+ if (op === '*') v *= r;
78
+ else {
79
+ if (r === 0) throw new BotError('Pembagian dengan nol');
80
+ v = op === '/' ? v / r : v % r;
81
+ }
82
+ }
83
+ return v;
84
+ }
85
+ function parseUnary() {
86
+ if (peek() === '-') { i++; return -parseUnary(); }
87
+ if (peek() === '+') { i++; return parseUnary(); }
88
+ return parsePow();
89
+ }
90
+ function parsePow() {
91
+ const base = parsePrimary();
92
+ if (peek() === '^') {
93
+ i++;
94
+ return Math.pow(base, parseUnary());
95
+ }
96
+ return base;
97
+ }
98
+ function parsePrimary() {
99
+ if (peek() === '(') {
100
+ i++;
101
+ const v = parseExpr();
102
+ if (peek() !== ')') fail('Kurung tutup ")" hilang');
103
+ i++;
104
+ return v;
105
+ }
106
+ const numRe = /\d+(?:\.\d+)?|\.\d+/y;
107
+ numRe.lastIndex = i;
108
+ const nm = numRe.exec(src);
109
+ if (nm) { i = numRe.lastIndex; return Number(nm[0]); }
110
+ const idRe = /[a-z_][a-z0-9_]*/y;
111
+ idRe.lastIndex = i;
112
+ const im = idRe.exec(src);
113
+ if (im) {
114
+ i = idRe.lastIndex;
115
+ const name = im[0];
116
+ if (peek() === '(') {
117
+ if (!Object.hasOwn(FUNCS, name)) fail(`Fungsi "${name}" tidak dikenal`);
118
+ i++;
119
+ const args = [];
120
+ if (peek() !== ')') {
121
+ args.push(parseExpr());
122
+ while (peek() === ',') { i++; args.push(parseExpr()); }
123
+ }
124
+ if (peek() !== ')') fail('Kurung tutup ")" hilang');
125
+ i++;
126
+ return FUNCS[name](...args);
127
+ }
128
+ if (Object.hasOwn(CONSTS, name)) return CONSTS[name];
129
+ fail(`"${name}" tidak dikenal`);
130
+ }
131
+ return fail(i >= src.length ? 'Ekspresi tidak lengkap' : `Karakter "${src[i]}" tidak valid`);
132
+ }
133
+
134
+ const result = parseExpr();
135
+ if (i < src.length) fail(`Karakter "${src[i]}" tidak valid`);
136
+ return result;
137
+ }
138
+
139
+ /** Pecah teks panjang jadi potongan <= limit (Discord: 2000). */
140
+ function splitMessage(text, limit = 2000) {
141
+ const s = String(text ?? '');
142
+ if (!s.trim()) return [];
143
+ if (s.length <= limit) return [s];
144
+ const chunks = [];
145
+ let current = '';
146
+ const push = () => { if (current) { chunks.push(current); current = ''; } };
147
+ for (const line of s.split('\n')) {
148
+ let rest = line;
149
+ while (rest.length > limit) {
150
+ push();
151
+ chunks.push(rest.slice(0, limit));
152
+ rest = rest.slice(limit);
153
+ }
154
+ const candidate = current ? `${current}\n${rest}` : rest;
155
+ if (candidate.length > limit) { push(); current = rest; } else current = candidate;
156
+ }
157
+ push();
158
+ return chunks;
159
+ }
160
+
161
+ /** Format tanggal sederhana: token YYYY MM DD HH mm ss pada zona waktu tertentu. */
162
+ function formatDate(ms, format, timeZone) {
163
+ let parts;
164
+ try {
165
+ parts = new Intl.DateTimeFormat('en-CA', {
166
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
167
+ hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
168
+ }).formatToParts(new Date(ms));
169
+ } catch (e) {
170
+ throw new BotError(`Zona waktu "${timeZone}" tidak valid atau tanggal salah`, { cause: e });
171
+ }
172
+ const map = {};
173
+ for (const p of parts) map[p.type] = p.value;
174
+ const tokens = { YYYY: map.year, MM: map.month, DD: map.day, HH: map.hour, mm: map.minute, ss: map.second };
175
+ return String(format).replace(/YYYY|MM|DD|HH|mm|ss/g, (t) => tokens[t]);
176
+ }
177
+
178
+ /** 1234567 -> "1.234.567" (pemisah bisa diganti). */
179
+ function numberSeparator(value, sep = ',') {
180
+ const s = String(value).trim();
181
+ if (!/^-?\d+(\.\d+)?$/.test(s)) throw new BotError(`"${s}" bukan angka`);
182
+ const neg = s.startsWith('-');
183
+ const [int, dec] = s.replace('-', '').split('.');
184
+ const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, sep);
185
+ return `${neg ? '-' : ''}${grouped}${dec ? `.${dec}` : ''}`;
186
+ }
187
+
188
+ module.exports = { parseTime, formatDuration, formatNumber, safeMath, splitMessage, formatDate, numberSeparator };