@tryty/easydc-js 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/context.js ADDED
@@ -0,0 +1,218 @@
1
+ 'use strict';
2
+ const { BotError } = require('./errors');
3
+ const { splitMessage } = require('./util');
4
+
5
+ const EPHEMERAL = 64; // MessageFlags.Ephemeral
6
+
7
+ /**
8
+ * Konteks satu eksekusi kode. Diberikan ke setiap fungsi `$...` sebagai argumen pertama.
9
+ * Fungsi custom (bot.addFunction) juga menerima objek ini.
10
+ */
11
+ class Context {
12
+ constructor(bot, init = {}) {
13
+ this.bot = bot;
14
+ this.client = bot.client;
15
+ this.kind = 'message'; // 'message' | 'slash' | 'component' | 'event'
16
+ this.eventName = null;
17
+ this.message = null;
18
+ this.interaction = null;
19
+ this.guild = null;
20
+ this.channel = null;
21
+ this.member = null;
22
+ this.user = null;
23
+ this.args = [];
24
+ this.rawArgs = '';
25
+ this.command = null;
26
+ this.options = new Map();
27
+ this.values = [];
28
+ this.customId = null;
29
+ this.reaction = null;
30
+ this.mentionSkipId = null;
31
+ this.fields = new Map(); // nilai input modal yang dikirim
32
+ this.target = null; // { id, name, content? } untuk context menu & event
33
+ this.focused = null; // { name, value, type } untuk autocomplete
34
+ Object.assign(this, init);
35
+
36
+ // state yang diisi oleh fungsi
37
+ this.temp = new Map();
38
+ this.embeds = [];
39
+ this.rows = [];
40
+ this.files = [];
41
+ this.poll = null;
42
+ this.modal = null;
43
+ this.choices = [];
44
+ this.ephemeral = false;
45
+ this.update = false;
46
+ this.useReply = false;
47
+ this.deleteCommand = false;
48
+ this.deleteIn = null;
49
+ this.sent = [];
50
+ }
51
+
52
+ // --- helper untuk fungsi ---------------------------------------------------
53
+ static cleanId(v) {
54
+ return String(v ?? '').trim().replace(/^<(?:@!?|@&|#)(\d+)>$/, '$1');
55
+ }
56
+
57
+ requireGuild() {
58
+ if (!this.guild) throw new BotError('Fungsi ini hanya bisa dipakai di dalam server (bukan DM)');
59
+ return this.guild;
60
+ }
61
+
62
+ async fetchUser(id) {
63
+ if (id === undefined || id === null || id === '') {
64
+ if (!this.user) throw new BotError('Tidak ada user pada konteks ini');
65
+ return this.user;
66
+ }
67
+ const clean = Context.cleanId(id);
68
+ const users = this.client.users;
69
+ const cached = users.cache.get(clean);
70
+ if (cached) return cached;
71
+ try {
72
+ return await users.fetch(clean);
73
+ } catch (e) {
74
+ throw new BotError(`User "${clean}" tidak ditemukan`, { cause: e });
75
+ }
76
+ }
77
+
78
+ async tryMember(id) {
79
+ if (!this.guild) return null;
80
+ if (id === undefined || id === null || id === '') return this.member ?? null;
81
+ const clean = Context.cleanId(id);
82
+ const cached = this.guild.members.cache.get(clean);
83
+ if (cached) return cached;
84
+ try {
85
+ return await this.guild.members.fetch(clean);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ async fetchMember(id) {
92
+ this.requireGuild();
93
+ const m = await this.tryMember(id);
94
+ if (!m) throw new BotError(`Member "${Context.cleanId(id) || '(kamu)'}" tidak ditemukan di server ini`);
95
+ return m;
96
+ }
97
+
98
+ mentionedUsers() {
99
+ const col = this.message?.mentions?.users;
100
+ const list = col && typeof col.values === 'function' ? [...col.values()] : [];
101
+ return this.mentionSkipId ? list.filter((u) => u.id !== this.mentionSkipId) : list;
102
+ }
103
+
104
+ currentEmbed() {
105
+ if (!this.embeds.length) this.embeds.push({});
106
+ return this.embeds[this.embeds.length - 1];
107
+ }
108
+
109
+ newEmbed() {
110
+ if (this.embeds.length >= 10) throw new BotError('Maksimal 10 embed per pesan');
111
+ if (this.embeds.length && !Object.keys(this.embeds[this.embeds.length - 1]).length) return;
112
+ this.embeds.push({});
113
+ }
114
+
115
+ // --- mengirim jawaban --------------------------------------------------------
116
+ _buildComponents() {
117
+ const out = [];
118
+ for (const row of this.rows) {
119
+ if (!row.components.length) continue;
120
+ if (row.kind === 'select' && row.components[0].type === 3) {
121
+ const menu = row.components[0];
122
+ if (!menu.options.length) {
123
+ throw new BotError(`Select menu "${menu.custom_id}" belum punya opsi (pakai $addSelectOption)`);
124
+ }
125
+ if (menu.max_values !== undefined && menu.max_values > menu.options.length) {
126
+ throw new BotError(`Select menu "${menu.custom_id}": max (${menu.max_values}) melebihi jumlah opsi (${menu.options.length})`);
127
+ }
128
+ if (menu.min_values !== undefined && menu.max_values !== undefined && menu.min_values > menu.max_values) {
129
+ throw new BotError(`Select menu "${menu.custom_id}": min lebih besar dari max`);
130
+ }
131
+ }
132
+ out.push({ type: 1, components: row.components });
133
+ }
134
+ return out;
135
+ }
136
+
137
+ async _send(payload, first) {
138
+ const it = this.interaction;
139
+ if (it) {
140
+ const withFlags = this.ephemeral ? { ...payload, flags: EPHEMERAL } : payload;
141
+ if (this.update && first && typeof it.update === 'function') return it.update(payload);
142
+ if (it.deferred && !it.replied) return it.editReply(payload);
143
+ if (it.replied || it.deferred) return it.followUp(withFlags);
144
+ return it.reply(withFlags);
145
+ }
146
+ if (this.kind === 'message' && this.useReply && this.message && typeof this.message.reply === 'function') {
147
+ return this.message.reply({ ...payload, failIfNotExists: false });
148
+ }
149
+ if (!this.channel) return null;
150
+ return this.channel.send(payload);
151
+ }
152
+
153
+ /** Kirim hasil: teks (dipecah bila > 2000 karakter) + embed + tombol. */
154
+ async _showModal() {
155
+ const it = this.interaction;
156
+ if (!it || typeof it.showModal !== 'function') {
157
+ throw new BotError('Modal hanya bisa dipakai dari slash command, tombol, atau select menu');
158
+ }
159
+ if (it.deferred || it.replied) {
160
+ throw new BotError('Modal harus jadi respons pertama (jangan pakai $defer atau kirim pesan sebelum modal)');
161
+ }
162
+ const m = this.modal;
163
+ if (!m.components.length) throw new BotError('Modal belum punya input (pakai $addTextInput)');
164
+ await it.showModal({ custom_id: m.custom_id, title: m.title, components: m.components });
165
+ }
166
+
167
+ async respond(text) {
168
+ if (this.modal) return this._showModal();
169
+ const chunks = splitMessage(text);
170
+ const embeds = this.embeds.filter((e) => Object.keys(e).length > 0);
171
+ const components = this._buildComponents();
172
+ const hasExtra = this.files.length > 0 || this.poll !== null;
173
+ if (!chunks.length && !embeds.length && !components.length && !hasExtra) return;
174
+ if (!chunks.length && !embeds.length && !hasExtra) {
175
+ throw new BotError('Tombol/select menu butuh teks atau embed di pesan yang sama');
176
+ }
177
+ if (!chunks.length) chunks.push(undefined);
178
+ for (let i = 0; i < chunks.length; i++) {
179
+ const last = i === chunks.length - 1;
180
+ const payload = { allowedMentions: this.bot.options.allowedMentions };
181
+ if (chunks[i]) payload.content = chunks[i];
182
+ if (last && embeds.length) payload.embeds = embeds;
183
+ if (last && components.length) payload.components = components;
184
+ if (last && this.files.length) payload.files = this.files;
185
+ if (last && this.poll) payload.poll = this.poll;
186
+ const sent = await this._send(payload, i === 0);
187
+ this.sent.push(sent);
188
+ }
189
+ }
190
+
191
+ /** Kirim teks polos saja (dipakai untuk pesan $stop/$onlyIf/error). */
192
+ async sendPlain(text) {
193
+ const chunks = splitMessage(text);
194
+ for (let i = 0; i < chunks.length; i++) {
195
+ await this._send({ content: chunks[i], allowedMentions: this.bot.options.allowedMentions }, i === 0);
196
+ }
197
+ }
198
+
199
+ /** Pekerjaan setelah pesan terkirim: $deleteIn dan $deleteCommand. */
200
+ async finish() {
201
+ const swallow = () => {};
202
+ if (this.deleteIn !== null) {
203
+ const ms = this.deleteIn;
204
+ if (this.interaction) {
205
+ setTimeout(() => this.interaction.deleteReply?.().catch(swallow), ms);
206
+ } else {
207
+ for (const m of this.sent) {
208
+ if (m && typeof m.delete === 'function') setTimeout(() => m.delete().catch(swallow), ms);
209
+ }
210
+ }
211
+ }
212
+ if (this.deleteCommand && this.message && typeof this.message.delete === 'function') {
213
+ await this.message.delete().catch(swallow);
214
+ }
215
+ }
216
+ }
217
+
218
+ module.exports = { Context };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ /**
6
+ * Database key-value sederhana (disimpan sebagai file JSON).
7
+ * - `memory: true` -> hanya di RAM (hilang saat bot mati).
8
+ * - Penulisan ditunda sebentar lalu ditulis atomik (file sementara + rename).
9
+ * - File rusak tidak dihapus: dipindah ke *.corrupt-<waktu> lalu mulai kosong.
10
+ */
11
+ class Database {
12
+ constructor({ path: file = './easydc-data.json', memory = false, saveDelay = 200 } = {}) {
13
+ this.file = memory ? null : path.resolve(file);
14
+ this.saveDelay = saveDelay;
15
+ this.data = new Map();
16
+ this._timer = null;
17
+ if (this.file) this._load();
18
+ }
19
+
20
+ _load() {
21
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
22
+ if (!fs.existsSync(this.file)) return;
23
+ const raw = fs.readFileSync(this.file, 'utf8');
24
+ if (!raw.trim()) return;
25
+ try {
26
+ const obj = JSON.parse(raw);
27
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('isi file bukan object');
28
+ for (const [k, v] of Object.entries(obj)) this.data.set(k, v);
29
+ } catch (e) {
30
+ const backup = `${this.file}.corrupt-${Date.now()}`;
31
+ fs.renameSync(this.file, backup);
32
+ console.warn(`[easydc] File database rusak (${e.message}). Disalin ke ${backup}, memulai database kosong.`);
33
+ }
34
+ }
35
+
36
+ get(key) { return this.data.get(key); }
37
+ has(key) { return this.data.has(key); }
38
+
39
+ set(key, value) {
40
+ this.data.set(key, value);
41
+ this._schedule();
42
+ }
43
+
44
+ delete(key) {
45
+ const existed = this.data.delete(key);
46
+ this._schedule();
47
+ return existed;
48
+ }
49
+
50
+ keys(prefix = '') {
51
+ return [...this.data.keys()].filter((k) => k.startsWith(prefix));
52
+ }
53
+
54
+ _schedule() {
55
+ if (!this.file || this._timer) return;
56
+ this._timer = setTimeout(() => {
57
+ this._timer = null;
58
+ try { this.flush(); } catch (e) { console.error('[easydc] Gagal menyimpan database:', e.message); }
59
+ }, this.saveDelay);
60
+ if (this._timer.unref) this._timer.unref();
61
+ }
62
+
63
+ /** Tulis ke disk sekarang juga (sinkron). */
64
+ flush() {
65
+ if (!this.file) return;
66
+ if (this._timer) { clearTimeout(this._timer); this._timer = null; }
67
+ const tmp = `${this.file}.tmp`;
68
+ fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(this.data)));
69
+ fs.renameSync(tmp, this.file);
70
+ }
71
+ }
72
+
73
+ module.exports = { Database };
package/src/errors.js ADDED
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ /** Error umum milik package ini. */
4
+ class BotError extends Error {
5
+ constructor(message, options) {
6
+ super(message, options);
7
+ this.name = 'EasyDCError';
8
+ }
9
+ }
10
+
11
+ /** Error saat membaca kode `$fungsi[...]` (ada posisi baris/kolom). */
12
+ class ParseError extends BotError {
13
+ constructor(message, { line, col } = {}) {
14
+ super(line ? `${message} (baris ${line}, kolom ${col})` : message);
15
+ this.name = 'EasyDCParseError';
16
+ this.line = line;
17
+ this.col = col;
18
+ }
19
+ }
20
+
21
+ /** Dikumpulkan dari semua command yang kodenya salah (dilempar sebelum bot login). */
22
+ class ValidationError extends BotError {
23
+ constructor(problems) {
24
+ super(`Ditemukan ${problems.length} masalah pada kode:\n - ${problems.join('\n - ')}`);
25
+ this.name = 'EasyDCValidationError';
26
+ this.problems = problems;
27
+ }
28
+ }
29
+
30
+ /** Sinyal internal untuk menghentikan eksekusi ($stop, $onlyIf, $cooldown, ...). */
31
+ class Stop extends Error {
32
+ constructor(message = '') {
33
+ super(message);
34
+ this.name = 'EasyDCStop';
35
+ }
36
+ }
37
+
38
+ module.exports = { BotError, ParseError, ValidationError, Stop };
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+ const { BotError } = require('../errors');
3
+ const { parseTime, splitMessage } = require('../util');
4
+ const { f, has, cleanId, num, fetchChannel: getChannel } = require('./helpers');
5
+
6
+ const MAX_TIMEOUT = 28 * 24 * 60 * 60 * 1000;
7
+
8
+ // PERHATIAN: fungsi di file ini melakukan aksi memakai izin BOT, bukan izin pengirim.
9
+ // Selalu lindungi command dengan $onlyPerms[...] / $onlyAdmin / $onlyOwner.
10
+ module.exports = [
11
+ f('sendMessage', { b: 'required', min: 2, max: 2, s: '[channelID;teks]', d: 'Kirim pesan ke channel lain' },
12
+ async (ctx, [id, text]) => {
13
+ const ch = await getChannel(ctx, id);
14
+ if (typeof ch.send !== 'function') throw new BotError('Channel itu bukan channel teks');
15
+ for (const chunk of splitMessage(text)) {
16
+ await ch.send({ content: chunk, allowedMentions: ctx.bot.options.allowedMentions });
17
+ }
18
+ return '';
19
+ }),
20
+ f('sendDM', { b: 'required', min: 2, max: 2, s: '[userID;teks]', d: 'Kirim DM ke user' },
21
+ async (ctx, [id, text]) => {
22
+ const user = await ctx.fetchUser(id);
23
+ try {
24
+ for (const chunk of splitMessage(text)) {
25
+ await user.send({ content: chunk, allowedMentions: ctx.bot.options.allowedMentions });
26
+ }
27
+ } catch (e) {
28
+ throw new BotError('Tidak bisa mengirim DM (kemungkinan DM user tertutup)', { cause: e });
29
+ }
30
+ return '';
31
+ }),
32
+ f('react', { b: 'required', min: 1, s: '[emoji1;emoji2;...]', d: 'Beri reaksi pada pesan perintah' },
33
+ async (ctx, emojis) => {
34
+ if (!ctx.message || typeof ctx.message.react !== 'function') throw new BotError('$react hanya bisa dipakai pada command pesan');
35
+ for (const e of emojis) await ctx.message.react(e.trim());
36
+ return '';
37
+ }),
38
+ f('ban', { b: 'required', min: 1, max: 3, s: '[userID;alasan?;hariHapusPesan?]', d: 'Ban user (hapus pesan 0-7 hari)' },
39
+ async (ctx, [id, reason, days]) => {
40
+ const guild = ctx.requireGuild();
41
+ const d = has(days) ? num(days, 'hari', { min: 0, max: 7, int: true }) : 0;
42
+ await guild.members.ban(cleanId(id), { reason: has(reason) ? reason : undefined, deleteMessageSeconds: d * 86400 });
43
+ return '';
44
+ }),
45
+ f('kick', { b: 'required', min: 1, max: 2, s: '[userID;alasan?]', d: 'Kick member' },
46
+ async (ctx, [id, reason]) => {
47
+ const m = await ctx.fetchMember(id);
48
+ await m.kick(has(reason) ? reason : undefined);
49
+ return '';
50
+ }),
51
+ f('timeout', { b: 'required', min: 2, max: 3, s: '[userID;waktu;alasan?]', d: 'Timeout member (maks 28 hari)' },
52
+ async (ctx, [id, time, reason]) => {
53
+ const ms = parseTime(time);
54
+ if (ms < 1000 || ms > MAX_TIMEOUT) throw new BotError('Durasi timeout harus antara 1 detik dan 28 hari');
55
+ const m = await ctx.fetchMember(id);
56
+ await m.timeout(ms, has(reason) ? reason : undefined);
57
+ return '';
58
+ }),
59
+ f('untimeout', { b: 'required', min: 1, max: 2, s: '[userID;alasan?]', d: 'Cabut timeout member' },
60
+ async (ctx, [id, reason]) => {
61
+ const m = await ctx.fetchMember(id);
62
+ await m.timeout(null, has(reason) ? reason : undefined);
63
+ return '';
64
+ }),
65
+ f('addRole', { b: 'required', min: 2, max: 3, s: '[userID;roleID;alasan?]', d: 'Beri role' },
66
+ async (ctx, [id, roleId, reason]) => {
67
+ const guild = ctx.requireGuild();
68
+ const rid = cleanId(roleId);
69
+ if (!guild.roles.cache.get(rid)) throw new BotError(`Role "${rid}" tidak ditemukan`);
70
+ const m = await ctx.fetchMember(id);
71
+ await m.roles.add(rid, has(reason) ? reason : undefined);
72
+ return '';
73
+ }),
74
+ f('removeRole', { b: 'required', min: 2, max: 3, s: '[userID;roleID;alasan?]', d: 'Cabut role' },
75
+ async (ctx, [id, roleId, reason]) => {
76
+ const guild = ctx.requireGuild();
77
+ const rid = cleanId(roleId);
78
+ if (!guild.roles.cache.get(rid)) throw new BotError(`Role "${rid}" tidak ditemukan`);
79
+ const m = await ctx.fetchMember(id);
80
+ await m.roles.remove(rid, has(reason) ? reason : undefined);
81
+ return '';
82
+ }),
83
+ f('setNickname', { b: 'required', min: 2, max: 2, s: '[userID;nickname]', d: 'Ubah nickname (kosongkan untuk reset)' },
84
+ async (ctx, [id, nick]) => {
85
+ const m = await ctx.fetchMember(id);
86
+ await m.setNickname(nick.trim() === '' ? null : nick);
87
+ return '';
88
+ }),
89
+ f('clear', { b: 'required', min: 1, max: 1, s: '[jumlah]', d: 'Hapus 1-100 pesan terakhir (maks umur 14 hari). Mengembalikan jumlah terhapus' },
90
+ async (ctx, [n]) => {
91
+ const amount = num(n, 'jumlah', { min: 1, max: 100, int: true });
92
+ if (!ctx.channel || typeof ctx.channel.bulkDelete !== 'function') throw new BotError('$clear hanya bisa dipakai di channel teks server');
93
+ const deleted = await ctx.channel.bulkDelete(amount, true);
94
+ return String(deleted.size ?? 0);
95
+ }),
96
+ f('slowmode', { b: 'required', min: 1, max: 1, s: '[detik]', d: 'Atur slowmode channel (0 = mati, maks 21600)' },
97
+ async (ctx, [n]) => {
98
+ const secs = num(n, 'detik', { min: 0, max: 21600, int: true });
99
+ if (!ctx.channel || typeof ctx.channel.setRateLimitPerUser !== 'function') throw new BotError('$slowmode hanya bisa dipakai di channel teks server');
100
+ await ctx.channel.setRateLimitPerUser(secs);
101
+ return '';
102
+ }),
103
+ ];
@@ -0,0 +1,108 @@
1
+ 'use strict';
2
+ const { BotError } = require('../errors');
3
+ const { f, has, truthy, num, url, limit } = require('./helpers');
4
+
5
+ const STYLES = { primary: 1, secondary: 2, success: 3, danger: 4, link: 5 };
6
+
7
+ function parseEmoji(v) {
8
+ const m = /^<(a?):(\w+):(\d+)>$/.exec(v.trim());
9
+ return m ? { id: m[3], name: m[2], animated: m[1] === 'a' } : { name: v.trim() };
10
+ }
11
+
12
+ function newRow(ctx, kind) {
13
+ if (ctx.rows.length >= 5) throw new BotError('Maksimal 5 baris tombol/select per pesan');
14
+ const row = { kind, components: [] };
15
+ ctx.rows.push(row);
16
+ return row;
17
+ }
18
+
19
+ /** Pakai baris terakhir bila masih kosong, kalau tidak buat baris baru. */
20
+ function freshRow(ctx, kind) {
21
+ const last = ctx.rows[ctx.rows.length - 1];
22
+ if (last && last.components.length === 0) { last.kind = kind; return last; }
23
+ return newRow(ctx, kind);
24
+ }
25
+
26
+ function buttonRow(ctx) {
27
+ const last = ctx.rows[ctx.rows.length - 1];
28
+ if (last && last.kind === 'buttons' && last.components.length < 5) return last;
29
+ return newRow(ctx, 'buttons');
30
+ }
31
+
32
+ const CHANNEL_TYPES = { text: 0, voice: 2, category: 4, announcement: 5, stage: 13, forum: 15 };
33
+
34
+ function entitySelect(name, type, label, extra) {
35
+ return f(name, {
36
+ b: 'required', min: 1, max: extra ? 5 : 4,
37
+ s: `[customID;placeholder?;min?;max?${extra ? ';tipe?' : ''}]`,
38
+ d: `Select menu pilih ${label} (nilai terpilih lewat $interactionValues)${extra ? '. Tipe: text voice category announcement stage forum, pisah koma' : ''}`,
39
+ }, (ctx, [id, placeholder, min, max, types]) => {
40
+ if (!id.trim()) throw new BotError('customID select menu tidak boleh kosong');
41
+ const menu = { type, custom_id: limit('customID', id.trim(), 100) };
42
+ if (has(placeholder)) menu.placeholder = limit('placeholder', placeholder, 150);
43
+ if (has(min)) menu.min_values = num(min, 'min', { min: 0, max: 25, int: true });
44
+ if (has(max)) menu.max_values = num(max, 'max', { min: 1, max: 25, int: true });
45
+ if (menu.min_values !== undefined && menu.max_values !== undefined && menu.min_values > menu.max_values) {
46
+ throw new BotError('min tidak boleh lebih besar dari max');
47
+ }
48
+ if (extra && has(types)) {
49
+ menu.channel_types = types.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean).map((t) => {
50
+ if (!Object.hasOwn(CHANNEL_TYPES, t)) throw new BotError(`Tipe channel "${t}" tidak valid (${Object.keys(CHANNEL_TYPES).join(', ')})`);
51
+ return CHANNEL_TYPES[t];
52
+ });
53
+ }
54
+ freshRow(ctx, 'select').components.push(menu);
55
+ return '';
56
+ });
57
+ }
58
+
59
+ module.exports = [
60
+ entitySelect('addUserSelect', 5, 'user', false),
61
+ entitySelect('addRoleSelect', 6, 'role', false),
62
+ entitySelect('addMentionableSelect', 7, 'user atau role', false),
63
+ entitySelect('addChannelSelect', 8, 'channel', true),
64
+ f('newRow', { b: 'none', d: 'Mulai baris tombol baru (otomatis tiap 5 tombol)' },
65
+ (ctx) => { freshRow(ctx, 'buttons'); return ''; }),
66
+ f('addButton', { b: 'required', min: 3, max: 5, s: '[label;gaya;customID/URL;nonaktif?;emoji?]', d: 'Tambah tombol. Gaya: primary secondary success danger link' },
67
+ (ctx, [label, style, target, disabled, emoji]) => {
68
+ const key = style.trim().toLowerCase();
69
+ if (!Object.hasOwn(STYLES, key)) throw new BotError(`Gaya tombol "${style}" tidak valid (primary, secondary, success, danger, link)`);
70
+ if (!label.trim() && !has(emoji)) throw new BotError('Tombol butuh label atau emoji');
71
+ const btn = { type: 2, style: STYLES[key] };
72
+ if (label.trim()) btn.label = limit('label tombol', label.trim(), 80);
73
+ if (key === 'link') btn.url = url(target, 'URL tombol');
74
+ else {
75
+ if (!target.trim()) throw new BotError('customID tombol tidak boleh kosong');
76
+ btn.custom_id = limit('customID', target.trim(), 100);
77
+ }
78
+ if (has(disabled) && truthy(disabled)) btn.disabled = true;
79
+ if (has(emoji)) btn.emoji = parseEmoji(emoji);
80
+ buttonRow(ctx).components.push(btn);
81
+ return '';
82
+ }),
83
+ f('addSelectMenu', { b: 'required', min: 1, max: 4, s: '[customID;placeholder?;min?;max?]', d: 'Buat select menu (lanjutkan dengan $addSelectOption)' },
84
+ (ctx, [id, placeholder, min, max]) => {
85
+ if (!id.trim()) throw new BotError('customID select menu tidak boleh kosong');
86
+ const menu = { type: 3, custom_id: limit('customID', id.trim(), 100), options: [] };
87
+ if (has(placeholder)) menu.placeholder = limit('placeholder', placeholder, 150);
88
+ if (has(min)) menu.min_values = num(min, 'min', { min: 0, max: 25, int: true });
89
+ if (has(max)) menu.max_values = num(max, 'max', { min: 1, max: 25, int: true });
90
+ freshRow(ctx, 'select').components.push(menu);
91
+ return '';
92
+ }),
93
+ f('addSelectOption', { b: 'required', min: 2, max: 5, s: '[label;nilai;deskripsi?;emoji?;default?]', d: 'Tambah opsi ke select menu terakhir (maks 25)' },
94
+ (ctx, [label, value, description, emoji, def]) => {
95
+ const row = ctx.rows[ctx.rows.length - 1];
96
+ if (!row || row.kind !== 'select') throw new BotError('Pakai $addSelectMenu dulu sebelum $addSelectOption');
97
+ const menu = row.components[0];
98
+ if (menu.type !== 3) throw new BotError('$addSelectOption hanya untuk select menu teks ($addSelectMenu)');
99
+ if (menu.options.length >= 25) throw new BotError('Maksimal 25 opsi per select menu');
100
+ if (!label.trim() || !value.trim()) throw new BotError('Label dan nilai opsi tidak boleh kosong');
101
+ const opt = { label: limit('label opsi', label.trim(), 100), value: limit('nilai opsi', value.trim(), 100) };
102
+ if (has(description)) opt.description = limit('deskripsi opsi', description, 100);
103
+ if (has(emoji)) opt.emoji = parseEmoji(emoji);
104
+ if (has(def) && truthy(def)) opt.default = true;
105
+ menu.options.push(opt);
106
+ return '';
107
+ }),
108
+ ];