@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/LICENSE +21 -0
- package/README.md +797 -2
- package/examples/advanced/lanjutan.js +60 -0
- package/examples/basic.js +100 -0
- package/examples/commands/ping.js +6 -0
- package/examples/commands/profil.js +28 -0
- package/index.d.ts +129 -0
- package/package.json +32 -4
- package/src/bot.js +776 -0
- package/src/context.js +218 -0
- package/src/database.js +73 -0
- package/src/errors.js +38 -0
- package/src/functions/actions.js +103 -0
- package/src/functions/components.js +108 -0
- package/src/functions/control.js +142 -0
- package/src/functions/embed.js +71 -0
- package/src/functions/eval.js +73 -0
- package/src/functions/helpers.js +86 -0
- package/src/functions/index.js +29 -0
- package/src/functions/info.js +117 -0
- package/src/functions/interact.js +63 -0
- package/src/functions/manage.js +171 -0
- package/src/functions/messages.js +128 -0
- package/src/functions/text.js +118 -0
- package/src/functions/vars.js +37 -0
- package/src/index.js +19 -0
- package/src/interpreter.js +88 -0
- package/src/parser.js +168 -0
- package/src/util.js +188 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('../errors');
|
|
3
|
+
const { f, has, truthy, cleanId, num, limit, fetchChannel, channelOrCurrent, getRole } = require('./helpers');
|
|
4
|
+
const { parseColor } = require('./embed');
|
|
5
|
+
|
|
6
|
+
const CHANNEL_TYPES = { text: 0, voice: 2, category: 4, announcement: 5, stage: 13, forum: 15 };
|
|
7
|
+
const TYPE_NAMES = {
|
|
8
|
+
0: 'text', 1: 'dm', 2: 'voice', 3: 'groupdm', 4: 'category', 5: 'announcement',
|
|
9
|
+
10: 'announcement_thread', 11: 'public_thread', 12: 'private_thread', 13: 'stage', 15: 'forum', 16: 'media',
|
|
10
|
+
};
|
|
11
|
+
const reason = (r) => (has(r) ? r : undefined);
|
|
12
|
+
|
|
13
|
+
// PERHATIAN: semua fungsi di file ini memakai izin BOT dan mengubah server.
|
|
14
|
+
// Selalu lindungi command dengan $onlyPerms[...] / $onlyAdmin / $onlyOwner.
|
|
15
|
+
module.exports = [
|
|
16
|
+
// ---- Ban --------------------------------------------------------------------
|
|
17
|
+
f('unban', { b: 'required', min: 1, max: 2, s: '[userID;alasan?]', d: 'Cabut ban user' },
|
|
18
|
+
async (ctx, [id, r]) => { await ctx.requireGuild().members.unban(cleanId(id), reason(r)); return ''; }),
|
|
19
|
+
f('isBanned', { b: 'required', min: 1, max: 1, s: '[userID]', d: 'true/false: user sedang di-ban di server ini' },
|
|
20
|
+
async (ctx, [id]) => {
|
|
21
|
+
try {
|
|
22
|
+
await ctx.requireGuild().bans.fetch(cleanId(id));
|
|
23
|
+
return 'true';
|
|
24
|
+
} catch (e) {
|
|
25
|
+
if (e.code === 10026 || /unknown ban/i.test(e.message)) return 'false';
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
}),
|
|
29
|
+
f('banCount', { b: 'none', d: 'Jumlah user ter-ban (dibaca maks 1000 teratas)' },
|
|
30
|
+
async (ctx) => (await ctx.requireGuild().bans.fetch()).size),
|
|
31
|
+
|
|
32
|
+
// ---- Channel ----------------------------------------------------------------
|
|
33
|
+
f('createChannel', { b: 'required', min: 1, max: 4, s: '[nama;tipe?;parentID?;topik?]', d: 'Buat channel. Tipe: text voice category announcement stage forum. Mengembalikan ID' },
|
|
34
|
+
async (ctx, [name, type, parent, topic]) => {
|
|
35
|
+
const g = ctx.requireGuild();
|
|
36
|
+
const key = has(type) ? type.trim().toLowerCase() : 'text';
|
|
37
|
+
if (!Object.hasOwn(CHANNEL_TYPES, key)) throw new BotError(`Tipe channel "${type}" tidak valid (${Object.keys(CHANNEL_TYPES).join(', ')})`);
|
|
38
|
+
const n = name.trim();
|
|
39
|
+
if (!n) throw new BotError('Nama channel tidak boleh kosong');
|
|
40
|
+
const opts = { name: limit('nama', n, 100), type: CHANNEL_TYPES[key] };
|
|
41
|
+
if (has(parent)) opts.parent = cleanId(parent);
|
|
42
|
+
if (has(topic) && [0, 5, 15].includes(opts.type)) opts.topic = limit('topik', topic, 1024);
|
|
43
|
+
return (await g.channels.create(opts)).id;
|
|
44
|
+
}),
|
|
45
|
+
f('deleteChannel', { b: 'required', min: 1, max: 2, s: '[channelID;alasan?]', d: 'Hapus channel/thread (menghapus channel saat ini membuat jawaban gagal terkirim)' },
|
|
46
|
+
async (ctx, [id, r]) => { await (await fetchChannel(ctx, id)).delete(reason(r)); return ''; }),
|
|
47
|
+
f('setChannelName', { b: 'required', min: 2, max: 3, s: '[channelID;nama;alasan?]', d: 'Ubah nama channel' },
|
|
48
|
+
async (ctx, [id, name, r]) => {
|
|
49
|
+
if (!name.trim()) throw new BotError('Nama channel tidak boleh kosong');
|
|
50
|
+
await (await fetchChannel(ctx, id)).setName(limit('nama', name.trim(), 100), reason(r));
|
|
51
|
+
return '';
|
|
52
|
+
}),
|
|
53
|
+
f('setChannelTopic', { b: 'required', min: 2, max: 3, s: '[channelID;topik;alasan?]', d: 'Ubah topik channel' },
|
|
54
|
+
async (ctx, [id, topic, r]) => {
|
|
55
|
+
const ch = await fetchChannel(ctx, id);
|
|
56
|
+
if (typeof ch.setTopic !== 'function') throw new BotError('Channel itu tidak punya topik');
|
|
57
|
+
await ch.setTopic(limit('topik', topic, 1024), reason(r));
|
|
58
|
+
return '';
|
|
59
|
+
}),
|
|
60
|
+
f('lockChannel', { max: 2, s: '[channelID?;alasan?]', d: 'Kunci channel: @everyone tidak bisa mengirim pesan' },
|
|
61
|
+
async (ctx, [id, r]) => {
|
|
62
|
+
const ch = await channelOrCurrent(ctx, id);
|
|
63
|
+
if (!ch.permissionOverwrites) throw new BotError('Channel ini tidak bisa dikunci');
|
|
64
|
+
await ch.permissionOverwrites.edit(ch.guild.roles.everyone, { SendMessages: false }, { reason: reason(r) });
|
|
65
|
+
return '';
|
|
66
|
+
}),
|
|
67
|
+
f('unlockChannel', { max: 2, s: '[channelID?;alasan?]', d: 'Buka kunci channel (kembali ke pengaturan semula)' },
|
|
68
|
+
async (ctx, [id, r]) => {
|
|
69
|
+
const ch = await channelOrCurrent(ctx, id);
|
|
70
|
+
if (!ch.permissionOverwrites) throw new BotError('Channel ini tidak bisa dibuka kuncinya');
|
|
71
|
+
await ch.permissionOverwrites.edit(ch.guild.roles.everyone, { SendMessages: null }, { reason: reason(r) });
|
|
72
|
+
return '';
|
|
73
|
+
}),
|
|
74
|
+
f('createInvite', { max: 3, s: '[channelID?;umurDetik?;maxPakai?]', d: 'Buat link undangan. Umur 0-604800 (0 = selamanya), maxPakai 0-100. Mengembalikan URL' },
|
|
75
|
+
async (ctx, [id, age, uses]) => {
|
|
76
|
+
const ch = await channelOrCurrent(ctx, id);
|
|
77
|
+
if (typeof ch.createInvite !== 'function') throw new BotError('Channel itu tidak bisa dibuat undangan');
|
|
78
|
+
const opts = {};
|
|
79
|
+
if (has(age)) opts.maxAge = num(age, 'umur', { min: 0, max: 604800, int: true });
|
|
80
|
+
if (has(uses)) opts.maxUses = num(uses, 'maxPakai', { min: 0, max: 100, int: true });
|
|
81
|
+
return (await ch.createInvite(opts)).url;
|
|
82
|
+
}),
|
|
83
|
+
f('channelType', { max: 1, s: '[channelID?]', d: 'Tipe channel: text voice category announcement stage forum dst.' },
|
|
84
|
+
async (ctx, [id]) => {
|
|
85
|
+
const ch = await channelOrCurrent(ctx, id);
|
|
86
|
+
return TYPE_NAMES[ch.type] ?? String(ch.type);
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
// ---- Role -------------------------------------------------------------------
|
|
90
|
+
f('createRole', { b: 'required', min: 1, max: 5, s: '[nama;warna?;hoist?;mentionable?;alasan?]', d: 'Buat role. Mengembalikan ID' },
|
|
91
|
+
async (ctx, [name, color, hoist, mentionable, r]) => {
|
|
92
|
+
if (!name.trim()) throw new BotError('Nama role tidak boleh kosong');
|
|
93
|
+
const opts = { name: limit('nama', name.trim(), 100), hoist: truthy(hoist), mentionable: truthy(mentionable), reason: reason(r) };
|
|
94
|
+
if (has(color)) opts.color = parseColor(color);
|
|
95
|
+
return (await ctx.requireGuild().roles.create(opts)).id;
|
|
96
|
+
}),
|
|
97
|
+
f('deleteRole', { b: 'required', min: 1, max: 2, s: '[roleID;alasan?]', d: 'Hapus role' },
|
|
98
|
+
async (ctx, [id, r]) => { await getRole(ctx, id).delete(reason(r)); return ''; }),
|
|
99
|
+
f('setRoleName', { b: 'required', min: 2, max: 3, s: '[roleID;nama;alasan?]', d: 'Ubah nama role' },
|
|
100
|
+
async (ctx, [id, name, r]) => {
|
|
101
|
+
if (!name.trim()) throw new BotError('Nama role tidak boleh kosong');
|
|
102
|
+
await getRole(ctx, id).setName(limit('nama', name.trim(), 100), reason(r));
|
|
103
|
+
return '';
|
|
104
|
+
}),
|
|
105
|
+
f('setRoleColor', { b: 'required', min: 2, max: 3, s: '[roleID;warna;alasan?]', d: 'Ubah warna role (#hex / nama / random)' },
|
|
106
|
+
async (ctx, [id, color, r]) => { await getRole(ctx, id).setColor(parseColor(color), reason(r)); return ''; }),
|
|
107
|
+
f('memberRoles', { max: 1, s: '[userID?]', d: 'Daftar ID role member, dipisah koma' },
|
|
108
|
+
async (ctx, [id]) => {
|
|
109
|
+
const m = await ctx.fetchMember(id);
|
|
110
|
+
return [...m.roles.cache.keys()].filter((rid) => rid !== ctx.guild.id).join(',');
|
|
111
|
+
}),
|
|
112
|
+
|
|
113
|
+
// ---- Emoji & event ------------------------------------------------------------
|
|
114
|
+
f('createEmoji', { b: 'required', min: 2, max: 2, s: '[nama;url]', d: 'Tambah emoji dari URL gambar. Mengembalikan emoji siap pakai' },
|
|
115
|
+
async (ctx, [name, u]) => {
|
|
116
|
+
if (!/^\w{2,32}$/.test(name)) throw new BotError('Nama emoji harus 2-32 karakter (huruf, angka, _)');
|
|
117
|
+
if (!/^https?:\/\/\S+$/i.test(u.trim())) throw new BotError('URL gambar emoji tidak valid');
|
|
118
|
+
return String(await ctx.requireGuild().emojis.create({ attachment: u.trim(), name }));
|
|
119
|
+
}),
|
|
120
|
+
f('deleteEmoji', { b: 'required', min: 1, max: 1, s: '[emojiID]', d: 'Hapus emoji server' },
|
|
121
|
+
async (ctx, [id]) => {
|
|
122
|
+
const e = ctx.requireGuild().emojis.cache.get(cleanId(id));
|
|
123
|
+
if (!e) throw new BotError(`Emoji "${id}" tidak ditemukan di server ini`);
|
|
124
|
+
await e.delete();
|
|
125
|
+
return '';
|
|
126
|
+
}),
|
|
127
|
+
f('createEvent', { b: 'required', min: 4, max: 5, s: '[nama;mulaiMs;selesaiMs;lokasi;deskripsi?]', d: 'Buat scheduled event (di luar Discord). Waktu dalam ms (lihat $timestamp). Mengembalikan ID' },
|
|
128
|
+
async (ctx, [name, start, end, location, description]) => {
|
|
129
|
+
const s = num(start, 'mulai', { int: true });
|
|
130
|
+
const e = num(end, 'selesai', { int: true });
|
|
131
|
+
if (s <= Date.now()) throw new BotError('Waktu mulai harus di masa depan');
|
|
132
|
+
if (e <= s) throw new BotError('Waktu selesai harus setelah waktu mulai');
|
|
133
|
+
if (!name.trim() || !location.trim()) throw new BotError('Nama dan lokasi event tidak boleh kosong');
|
|
134
|
+
const opts = {
|
|
135
|
+
name: limit('nama', name.trim(), 100),
|
|
136
|
+
scheduledStartTime: new Date(s),
|
|
137
|
+
scheduledEndTime: new Date(e),
|
|
138
|
+
privacyLevel: 2,
|
|
139
|
+
entityType: 3,
|
|
140
|
+
entityMetadata: { location: limit('lokasi', location.trim(), 100) },
|
|
141
|
+
};
|
|
142
|
+
if (has(description)) opts.description = limit('deskripsi', description, 1000);
|
|
143
|
+
return (await ctx.requireGuild().scheduledEvents.create(opts)).id;
|
|
144
|
+
}),
|
|
145
|
+
|
|
146
|
+
// ---- Voice ------------------------------------------------------------------
|
|
147
|
+
f('moveMember', { b: 'required', min: 2, max: 2, s: '[userID;voiceChannelID]', d: 'Pindahkan member ke voice channel lain' },
|
|
148
|
+
async (ctx, [id, ch]) => {
|
|
149
|
+
const m = await ctx.fetchMember(id);
|
|
150
|
+
if (!m.voice?.channel) throw new BotError('Member itu sedang tidak berada di voice channel');
|
|
151
|
+
await m.voice.setChannel(cleanId(ch));
|
|
152
|
+
return '';
|
|
153
|
+
}),
|
|
154
|
+
f('disconnectMember', { b: 'required', min: 1, max: 1, s: '[userID]', d: 'Keluarkan member dari voice channel' },
|
|
155
|
+
async (ctx, [id]) => {
|
|
156
|
+
const m = await ctx.fetchMember(id);
|
|
157
|
+
if (!m.voice?.channel) throw new BotError('Member itu sedang tidak berada di voice channel');
|
|
158
|
+
await m.voice.setChannel(null);
|
|
159
|
+
return '';
|
|
160
|
+
}),
|
|
161
|
+
|
|
162
|
+
// ---- Bot ----------------------------------------------------------------------
|
|
163
|
+
f('leaveServer', { max: 1, s: '[serverID?]', d: 'Bot keluar dari server. Hanya untuk owner bot' },
|
|
164
|
+
async (ctx, [id]) => {
|
|
165
|
+
if (!ctx.bot.options.owners.includes(ctx.user?.id)) throw new BotError('$leaveServer hanya untuk owner bot');
|
|
166
|
+
const g = has(id) ? ctx.client.guilds.cache.get(cleanId(id)) : ctx.requireGuild();
|
|
167
|
+
if (!g) throw new BotError(`Server "${id}" tidak ditemukan`);
|
|
168
|
+
await g.leave();
|
|
169
|
+
return '';
|
|
170
|
+
}),
|
|
171
|
+
];
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('../errors');
|
|
3
|
+
const {
|
|
4
|
+
f, has, truthy, cleanId, num, url, limit, fetchChannel, channelOrCurrent, fetchMessage,
|
|
5
|
+
} = require('./helpers');
|
|
6
|
+
|
|
7
|
+
const WEBHOOK_RE = /^https:\/\/(?:ptb\.|canary\.)?(?:discord|discordapp)\.com\/api(?:\/v\d+)?\/webhooks\/\d+\/[\w-]+$/;
|
|
8
|
+
const ARCHIVE = [60, 1440, 4320, 10080];
|
|
9
|
+
const MAX_FILE_BYTES = 1_000_000;
|
|
10
|
+
|
|
11
|
+
async function getThread(ctx, id) {
|
|
12
|
+
const th = await channelOrCurrent(ctx, id);
|
|
13
|
+
if (typeof th.isThread !== 'function' || !th.isThread()) throw new BotError('Channel itu bukan thread');
|
|
14
|
+
return th;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// PERHATIAN: fungsi di file ini memakai izin BOT. Lindungi command dengan $onlyPerms / $onlyAdmin / $onlyOwner.
|
|
18
|
+
module.exports = [
|
|
19
|
+
f('editMessage', { b: 'required', min: 2, max: 3, s: '[messageID;teks;channelID?]', d: 'Edit pesan milik bot sendiri' },
|
|
20
|
+
async (ctx, [mid, text, cid]) => {
|
|
21
|
+
if (!text.trim()) throw new BotError('Teks baru tidak boleh kosong');
|
|
22
|
+
const m = await fetchMessage(ctx, mid, cid);
|
|
23
|
+
if (m.author?.id !== ctx.client.user.id) throw new BotError('Bot hanya bisa mengedit pesannya sendiri');
|
|
24
|
+
await m.edit({ content: limit('teks', text, 2000) });
|
|
25
|
+
return '';
|
|
26
|
+
}),
|
|
27
|
+
f('deleteMessage', { b: 'required', min: 1, max: 2, s: '[messageID;channelID?]', d: 'Hapus satu pesan' },
|
|
28
|
+
async (ctx, [mid, cid]) => { await (await fetchMessage(ctx, mid, cid)).delete(); return ''; }),
|
|
29
|
+
f('pinMessage', { b: 'required', min: 1, max: 2, s: '[messageID;channelID?]', d: 'Sematkan (pin) pesan' },
|
|
30
|
+
async (ctx, [mid, cid]) => { await (await fetchMessage(ctx, mid, cid)).pin(); return ''; }),
|
|
31
|
+
f('unpinMessage', { b: 'required', min: 1, max: 2, s: '[messageID;channelID?]', d: 'Lepas pin pesan' },
|
|
32
|
+
async (ctx, [mid, cid]) => { await (await fetchMessage(ctx, mid, cid)).unpin(); return ''; }),
|
|
33
|
+
f('addReaction', { b: 'required', min: 2, max: 3, s: '[messageID;emoji;channelID?]', d: 'Beri reaksi pada pesan tertentu' },
|
|
34
|
+
async (ctx, [mid, emoji, cid]) => {
|
|
35
|
+
if (!emoji.trim()) throw new BotError('Emoji tidak boleh kosong');
|
|
36
|
+
await (await fetchMessage(ctx, mid, cid)).react(emoji.trim());
|
|
37
|
+
return '';
|
|
38
|
+
}),
|
|
39
|
+
f('messageContentOf', { b: 'required', min: 1, max: 2, s: '[messageID;channelID?]', d: 'Isi teks pesan tertentu' },
|
|
40
|
+
async (ctx, [mid, cid]) => (await fetchMessage(ctx, mid, cid)).content ?? ''),
|
|
41
|
+
f('messageAuthorOf', { b: 'required', min: 1, max: 2, s: '[messageID;channelID?]', d: 'ID pengirim pesan tertentu' },
|
|
42
|
+
async (ctx, [mid, cid]) => (await fetchMessage(ctx, mid, cid)).author?.id ?? ''),
|
|
43
|
+
|
|
44
|
+
f('addAttachment', { b: 'required', min: 1, max: 2, s: '[url;nama?]', d: 'Lampirkan file dari URL http(s) ke jawaban (maks 10)' },
|
|
45
|
+
(ctx, [u, name]) => {
|
|
46
|
+
if (ctx.files.length >= 10) throw new BotError('Maksimal 10 lampiran per pesan');
|
|
47
|
+
const file = { attachment: url(u, 'URL lampiran') };
|
|
48
|
+
if (has(name)) file.name = limit('nama file', name.trim(), 100);
|
|
49
|
+
ctx.files.push(file);
|
|
50
|
+
return '';
|
|
51
|
+
}),
|
|
52
|
+
f('addTextFile', { b: 'required', min: 2, max: 2, s: '[namaFile;isi]', d: 'Lampirkan file teks buatan sendiri (maks 1 MB)' },
|
|
53
|
+
(ctx, [name, content]) => {
|
|
54
|
+
if (ctx.files.length >= 10) throw new BotError('Maksimal 10 lampiran per pesan');
|
|
55
|
+
if (!/^[\w .-]{1,100}$/.test(name)) throw new BotError(`Nama file "${name}" tidak valid (huruf, angka, spasi, . - _)`);
|
|
56
|
+
if (Buffer.byteLength(content) > MAX_FILE_BYTES) throw new BotError('Isi file terlalu besar (maks 1 MB)');
|
|
57
|
+
ctx.files.push({ attachment: Buffer.from(content, 'utf8'), name });
|
|
58
|
+
return '';
|
|
59
|
+
}),
|
|
60
|
+
f('poll', { b: 'required', min: 5, max: 13, s: '[pertanyaan;jam;multi;opsi1;opsi2;...]', d: 'Buat polling (jam 1-768, multi = true/false, 2-10 opsi)' },
|
|
61
|
+
(ctx, [question, hours, multi, ...answers]) => {
|
|
62
|
+
if (ctx.poll) throw new BotError('Hanya satu polling per pesan');
|
|
63
|
+
if (!question.trim()) throw new BotError('Pertanyaan polling tidak boleh kosong');
|
|
64
|
+
if (answers.some((a) => !a.trim())) throw new BotError('Opsi polling tidak boleh kosong');
|
|
65
|
+
ctx.poll = {
|
|
66
|
+
question: { text: limit('pertanyaan', question.trim(), 300) },
|
|
67
|
+
answers: answers.map((a) => ({ text: limit('opsi', a.trim(), 55) })),
|
|
68
|
+
duration: num(hours, 'jam', { min: 1, max: 768, int: true }),
|
|
69
|
+
allowMultiselect: truthy(multi),
|
|
70
|
+
};
|
|
71
|
+
return '';
|
|
72
|
+
}),
|
|
73
|
+
f('sendWebhook', { b: 'required', min: 2, max: 4, s: '[webhookURL;teks;username?;avatarURL?]', d: 'Kirim pesan lewat URL webhook Discord' },
|
|
74
|
+
async (ctx, [hook, text, username, avatar]) => {
|
|
75
|
+
const u = hook.trim();
|
|
76
|
+
if (!WEBHOOK_RE.test(u)) throw new BotError('URL webhook tidak valid (harus https://discord.com/api/webhooks/ID/TOKEN)');
|
|
77
|
+
if (!text.trim()) throw new BotError('Teks webhook tidak boleh kosong');
|
|
78
|
+
const body = { content: limit('teks', text, 2000), allowed_mentions: { parse: [] } };
|
|
79
|
+
if (has(username)) body.username = limit('username', username, 80);
|
|
80
|
+
if (has(avatar)) body.avatar_url = url(avatar, 'avatar');
|
|
81
|
+
const res = await fetch(u, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'content-type': 'application/json' },
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: AbortSignal.timeout(10000),
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw new BotError(`Webhook gagal (HTTP ${res.status})`);
|
|
88
|
+
return '';
|
|
89
|
+
}),
|
|
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]) => {
|
|
93
|
+
if (!name.trim()) throw new BotError('Nama thread tidak boleh kosong');
|
|
94
|
+
const opts = { name: limit('nama', name.trim(), 100) };
|
|
95
|
+
if (has(archive)) {
|
|
96
|
+
const a = num(archive, 'arsip', { int: true });
|
|
97
|
+
if (!ARCHIVE.includes(a)) throw new BotError(`Arsip otomatis harus salah satu dari ${ARCHIVE.join(', ')} menit`);
|
|
98
|
+
opts.autoArchiveDuration = a;
|
|
99
|
+
}
|
|
100
|
+
let thread;
|
|
101
|
+
if (has(mid)) {
|
|
102
|
+
thread = await (await fetchMessage(ctx, mid)).startThread(opts);
|
|
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);
|
|
106
|
+
}
|
|
107
|
+
return thread.id;
|
|
108
|
+
}),
|
|
109
|
+
f('archiveThread', { max: 2, s: '[threadID?;kunci?]', d: 'Arsipkan thread (kunci = true untuk sekalian dikunci)' },
|
|
110
|
+
async (ctx, [id, lock]) => {
|
|
111
|
+
const th = await getThread(ctx, id);
|
|
112
|
+
if (truthy(lock)) await th.setLocked(true);
|
|
113
|
+
await th.setArchived(true);
|
|
114
|
+
return '';
|
|
115
|
+
}),
|
|
116
|
+
f('addThreadMember', { b: 'required', min: 1, max: 2, s: '[userID;threadID?]', d: 'Tambahkan user ke thread' },
|
|
117
|
+
async (ctx, [uid, tid]) => { await (await getThread(ctx, tid)).members.add(cleanId(uid)); return ''; }),
|
|
118
|
+
f('removeThreadMember', { b: 'required', min: 1, max: 2, s: '[userID;threadID?]', d: 'Keluarkan user dari thread' },
|
|
119
|
+
async (ctx, [uid, tid]) => { await (await getThread(ctx, tid)).members.remove(cleanId(uid)); return ''; }),
|
|
120
|
+
f('createForumPost', { b: 'required', min: 3, max: 3, s: '[forumID;judul;isi]', d: 'Buat postingan di channel forum. Mengembalikan ID thread' },
|
|
121
|
+
async (ctx, [forumId, title, text]) => {
|
|
122
|
+
const ch = await fetchChannel(ctx, forumId);
|
|
123
|
+
if (!ch.threads || typeof ch.threads.create !== 'function') throw new BotError('Channel itu bukan channel forum');
|
|
124
|
+
if (!title.trim() || !text.trim()) throw new BotError('Judul dan isi postingan tidak boleh kosong');
|
|
125
|
+
const post = await ch.threads.create({ name: limit('judul', title.trim(), 100), message: { content: limit('isi', text, 2000) } });
|
|
126
|
+
return post.id;
|
|
127
|
+
}),
|
|
128
|
+
];
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('../errors');
|
|
3
|
+
const { safeMath, formatNumber, numberSeparator } = require('../util');
|
|
4
|
+
const { f, has, truthy, cleanId, list, num } = require('./helpers');
|
|
5
|
+
|
|
6
|
+
const nums = (args, label = 'angka') => args.map((a, i) => num(a, `${label} ke-${i + 1}`));
|
|
7
|
+
|
|
8
|
+
module.exports = [
|
|
9
|
+
// ---- Matematika -----------------------------------------------------------
|
|
10
|
+
f('math', { b: 'required', min: 1, max: 1, s: '[ekspresi]', d: 'Hitung aman. Dukung + - * / % ^ ( ) sqrt abs round floor ceil min max pow pi e' },
|
|
11
|
+
(ctx, [expr]) => formatNumber(safeMath(expr))),
|
|
12
|
+
f('sum', { b: 'required', min: 1, s: '[a;b;...]', d: 'Jumlah semua angka' },
|
|
13
|
+
(ctx, args) => formatNumber(nums(args).reduce((a, b) => a + b, 0))),
|
|
14
|
+
f('sub', { b: 'required', min: 2, max: 2, s: '[a;b]', d: 'a - b' },
|
|
15
|
+
(ctx, args) => { const [a, b] = nums(args); return formatNumber(a - b); }),
|
|
16
|
+
f('multiply', { b: 'required', min: 1, s: '[a;b;...]', d: 'Kali semua angka' },
|
|
17
|
+
(ctx, args) => formatNumber(nums(args).reduce((a, b) => a * b, 1))),
|
|
18
|
+
f('divide', { b: 'required', min: 2, max: 2, s: '[a;b]', d: 'a / b' },
|
|
19
|
+
(ctx, args) => {
|
|
20
|
+
const [a, b] = nums(args);
|
|
21
|
+
if (b === 0) throw new BotError('Pembagian dengan nol');
|
|
22
|
+
return formatNumber(a / b);
|
|
23
|
+
}),
|
|
24
|
+
f('modulo', { b: 'required', min: 2, max: 2, s: '[a;b]', d: 'Sisa bagi a % b' },
|
|
25
|
+
(ctx, args) => {
|
|
26
|
+
const [a, b] = nums(args);
|
|
27
|
+
if (b === 0) throw new BotError('Pembagian dengan nol');
|
|
28
|
+
return formatNumber(a % b);
|
|
29
|
+
}),
|
|
30
|
+
f('round', { b: 'required', min: 1, max: 2, s: '[angka;desimal?]', d: 'Bulatkan angka' },
|
|
31
|
+
(ctx, [n, dec]) => {
|
|
32
|
+
const d = has(dec) ? num(dec, 'desimal', { min: 0, max: 10, int: true }) : 0;
|
|
33
|
+
const p = 10 ** d;
|
|
34
|
+
return formatNumber(Math.round(num(n, 'angka') * p) / p);
|
|
35
|
+
}),
|
|
36
|
+
f('max', { b: 'required', min: 1, s: '[a;b;...]', d: 'Angka terbesar' },
|
|
37
|
+
(ctx, args) => formatNumber(Math.max(...nums(args)))),
|
|
38
|
+
f('min', { b: 'required', min: 1, s: '[a;b;...]', d: 'Angka terkecil' },
|
|
39
|
+
(ctx, args) => formatNumber(Math.min(...nums(args)))),
|
|
40
|
+
f('random', { b: 'required', min: 2, max: 2, s: '[min;max]', d: 'Bilangan bulat acak (inklusif)' },
|
|
41
|
+
(ctx, [a, b]) => {
|
|
42
|
+
const lo = num(a, 'min', { int: true });
|
|
43
|
+
const hi = num(b, 'max', { int: true });
|
|
44
|
+
if (lo > hi) throw new BotError('min tidak boleh lebih besar dari max');
|
|
45
|
+
return Math.floor(Math.random() * (hi - lo + 1)) + lo;
|
|
46
|
+
}),
|
|
47
|
+
f('randomText', { b: 'required', min: 1, s: '[a;b;...]', d: 'Pilih salah satu teks secara acak' },
|
|
48
|
+
(ctx, args) => args[Math.floor(Math.random() * args.length)]),
|
|
49
|
+
f('numberSeparator', { b: 'required', min: 1, max: 2, s: '[angka;pemisah?]', d: '1234567 -> 1,234,567' },
|
|
50
|
+
(ctx, [n, sep]) => numberSeparator(n, has(sep) ? sep : ',')),
|
|
51
|
+
|
|
52
|
+
// ---- Teks -----------------------------------------------------------------
|
|
53
|
+
f('toUpperCase', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'HURUF BESAR' }, (ctx, [t]) => t.toUpperCase()),
|
|
54
|
+
f('toLowerCase', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'huruf kecil' }, (ctx, [t]) => t.toLowerCase()),
|
|
55
|
+
f('capitalize', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Huruf pertama jadi besar' },
|
|
56
|
+
(ctx, [t]) => t.charAt(0).toUpperCase() + t.slice(1)),
|
|
57
|
+
f('titleCase', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Huruf Pertama Tiap Kata Besar' },
|
|
58
|
+
(ctx, [t]) => t.toLowerCase().replace(/(^|\s)(\S)/g, (m, sp, c) => sp + c.toUpperCase())),
|
|
59
|
+
f('trimSpace', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Hapus spasi di awal/akhir' }, (ctx, [t]) => t.trim()),
|
|
60
|
+
f('length', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Jumlah karakter' }, (ctx, [t]) => [...t].length),
|
|
61
|
+
f('reverse', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Balik urutan teks' }, (ctx, [t]) => [...t].reverse().join('')),
|
|
62
|
+
f('repeat', { b: 'required', min: 2, max: 2, s: '[teks;jumlah]', d: 'Ulang teks (maks 100x)' },
|
|
63
|
+
(ctx, [t, n]) => t.repeat(num(n, 'jumlah', { min: 0, max: 100, int: true }))),
|
|
64
|
+
f('substring', { b: 'required', min: 2, max: 3, s: '[teks;awal;akhir?]', d: 'Potong teks (indeks mulai 0)' },
|
|
65
|
+
(ctx, [t, a, b]) => [...t].slice(num(a, 'awal', { int: true, min: 0 }), has(b) ? num(b, 'akhir', { int: true, min: 0 }) : undefined).join('')),
|
|
66
|
+
f('replaceText', { b: 'required', min: 3, max: 4, s: '[teks;cari;ganti;batas?]', d: 'Ganti teks (semua, atau N pertama)' },
|
|
67
|
+
(ctx, [t, from, to, lim]) => {
|
|
68
|
+
if (from === '') throw new BotError('Teks yang dicari tidak boleh kosong');
|
|
69
|
+
if (!has(lim)) return t.split(from).join(to);
|
|
70
|
+
let left = num(lim, 'batas', { min: 1, int: true });
|
|
71
|
+
let out = '';
|
|
72
|
+
let rest = t;
|
|
73
|
+
while (left > 0) {
|
|
74
|
+
const idx = rest.indexOf(from);
|
|
75
|
+
if (idx === -1) break;
|
|
76
|
+
out += rest.slice(0, idx) + to;
|
|
77
|
+
rest = rest.slice(idx + from.length);
|
|
78
|
+
left--;
|
|
79
|
+
}
|
|
80
|
+
return out + rest;
|
|
81
|
+
}),
|
|
82
|
+
f('split', { b: 'required', min: 3, max: 3, s: '[teks;pemisah;nomor]', d: 'Ambil bagian ke-N setelah memecah teks' },
|
|
83
|
+
(ctx, [t, sep, n]) => {
|
|
84
|
+
if (sep === '') throw new BotError('Pemisah tidak boleh kosong');
|
|
85
|
+
return t.split(sep)[num(n, 'nomor', { min: 1, int: true }) - 1] ?? '';
|
|
86
|
+
}),
|
|
87
|
+
f('urlEncode', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'Encode untuk URL' }, (ctx, [t]) => encodeURIComponent(t)),
|
|
88
|
+
f('semicolon', { b: 'none', d: 'Karakter ;' }, () => ';'),
|
|
89
|
+
f('leftBracket', { b: 'none', d: 'Karakter [' }, () => '['),
|
|
90
|
+
f('rightBracket', { b: 'none', d: 'Karakter ]' }, () => ']'),
|
|
91
|
+
f('dollar', { b: 'none', d: 'Karakter $' }, () => '$'),
|
|
92
|
+
|
|
93
|
+
// ---- Pengecekan -----------------------------------------------------------
|
|
94
|
+
f('isNumber', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'true/false: apakah angka' },
|
|
95
|
+
(ctx, [t]) => String(/^-?\d+(\.\d+)?$/.test(t.trim()))),
|
|
96
|
+
f('checkContains', { b: 'required', min: 2, s: '[teks;cari1;cari2;...]', d: 'true bila teks memuat salah satu kata' },
|
|
97
|
+
(ctx, [t, ...needles]) => String(needles.some((n) => n !== '' && t.includes(n)))),
|
|
98
|
+
f('checkCondition', { b: 'required', min: 1, max: 1, lazy: true, s: '[kondisi]', d: 'true/false. Operator: == != > < >= <= && ||' },
|
|
99
|
+
async (ctx, args, api) => String(await api.cond(args[0]))),
|
|
100
|
+
f('hasRole', { b: 'required', min: 1, max: 2, s: '[roleID;userID?]', d: 'true/false: member punya role' },
|
|
101
|
+
async (ctx, [roleId, userId]) => {
|
|
102
|
+
const m = await ctx.tryMember(userId);
|
|
103
|
+
return String(!!m?.roles?.cache?.has(cleanId(roleId)));
|
|
104
|
+
}),
|
|
105
|
+
f('hasPerms', { b: 'required', min: 1, max: 2, s: '[izin1,izin2;userID?]', d: 'true/false: member punya semua izin (nama izin discord.js)' },
|
|
106
|
+
async (ctx, [perms, userId]) => {
|
|
107
|
+
const m = await ctx.tryMember(userId);
|
|
108
|
+
const p = list(perms);
|
|
109
|
+
if (!p.length) throw new BotError('Izin belum diisi');
|
|
110
|
+
return String(!!m?.permissions?.has(p));
|
|
111
|
+
}),
|
|
112
|
+
f('isAdmin', { max: 1, s: '[userID?]', d: 'true/false: member administrator' },
|
|
113
|
+
async (ctx, [userId]) => String(!!(await ctx.tryMember(userId))?.permissions?.has('Administrator'))),
|
|
114
|
+
f('isOwner', { max: 1, s: '[userID?]', d: 'true/false: user ada di daftar owners bot' },
|
|
115
|
+
async (ctx, [userId]) => String(ctx.bot.options.owners.includes(has(userId) ? cleanId(userId) : ctx.user?.id))),
|
|
116
|
+
f('isTrue', { b: 'required', min: 1, max: 1, s: '[teks]', d: 'true bila teks bernilai true/yes/1/on' },
|
|
117
|
+
(ctx, [t]) => String(truthy(t))),
|
|
118
|
+
];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError } = require('../errors');
|
|
3
|
+
const { f, has, cleanId } = require('./helpers');
|
|
4
|
+
|
|
5
|
+
const SCOPES = [
|
|
6
|
+
['User', 'user', (ctx) => ctx.user?.id, 'user'],
|
|
7
|
+
['Server', 'server', (ctx) => ctx.guild?.id, 'server'],
|
|
8
|
+
['Channel', 'channel', (ctx) => ctx.channel?.id, 'channel'],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const [Scope, scope, defaultId, label] of SCOPES) {
|
|
13
|
+
const idFor = (ctx, id) => {
|
|
14
|
+
const resolved = has(id) ? cleanId(id) : defaultId(ctx);
|
|
15
|
+
if (!resolved) throw new BotError(`Tidak ada ${label} pada konteks ini, isi ID secara manual`);
|
|
16
|
+
return resolved;
|
|
17
|
+
};
|
|
18
|
+
out.push(
|
|
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 ''; }),
|
|
21
|
+
f(`get${Scope}Var`, { b: 'required', min: 1, max: 2, s: `[nama;${label}ID?]`, d: `Baca variabel ${label}` },
|
|
22
|
+
(ctx, [name, id]) => ctx.bot.getVar(scope, name, idFor(ctx, id))),
|
|
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 ''; }),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
out.push(
|
|
29
|
+
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 ''; }),
|
|
31
|
+
f('getGlobalVar', { b: 'required', min: 1, max: 1, s: '[nama]', d: 'Baca variabel global' },
|
|
32
|
+
(ctx, [name]) => ctx.bot.getVar('global', name, null)),
|
|
33
|
+
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 ''; }),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
module.exports = out;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { Bot } = require('./bot');
|
|
3
|
+
const { Database } = require('./database');
|
|
4
|
+
const { Context } = require('./context');
|
|
5
|
+
const { BotError, ParseError, ValidationError } = require('./errors');
|
|
6
|
+
const { parse } = require('./parser');
|
|
7
|
+
const { createRegistry, CATEGORIES } = require('./functions');
|
|
8
|
+
|
|
9
|
+
module.exports = {
|
|
10
|
+
Bot,
|
|
11
|
+
Database,
|
|
12
|
+
Context,
|
|
13
|
+
BotError,
|
|
14
|
+
ParseError,
|
|
15
|
+
ValidationError,
|
|
16
|
+
parse,
|
|
17
|
+
createRegistry,
|
|
18
|
+
CATEGORIES,
|
|
19
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { BotError, Stop } = require('./errors');
|
|
3
|
+
|
|
4
|
+
// --- Evaluasi kondisi yang aman dari "injeksi operator" -----------------------
|
|
5
|
+
// Hasil fungsi dinamis (mis. $message) ditandai: karakter operator di dalamnya
|
|
6
|
+
// diganti karakter private-use supaya tidak bisa dibaca sebagai operator kondisi.
|
|
7
|
+
const OPS = ['&', '|', '=', '!', '<', '>'];
|
|
8
|
+
const PLACEHOLDERS = OPS.map((_, i) => String.fromCharCode(0xe000 + i));
|
|
9
|
+
|
|
10
|
+
const escapeOps = (s) => s.replace(/[&|=!<>]/g, (c) => PLACEHOLDERS[OPS.indexOf(c)]);
|
|
11
|
+
const unescapeOps = (s) => s.replace(/[-]/g, (c) => OPS[c.charCodeAt(0) - 0xe000]);
|
|
12
|
+
const isNum = (s) => /^-?\d+(\.\d+)?$/.test(s);
|
|
13
|
+
|
|
14
|
+
function compare(raw) {
|
|
15
|
+
const s = raw.trim();
|
|
16
|
+
const m = /^(.*?)(==|!=|>=|<=|>|<)(.*)$/s.exec(s);
|
|
17
|
+
if (!m) return unescapeOps(s).trim().toLowerCase() === 'true';
|
|
18
|
+
const l = unescapeOps(m[1]).trim();
|
|
19
|
+
const r = unescapeOps(m[3]).trim();
|
|
20
|
+
const numeric = isNum(l) && isNum(r);
|
|
21
|
+
const a = numeric ? Number(l) : l;
|
|
22
|
+
const b = numeric ? Number(r) : r;
|
|
23
|
+
switch (m[2]) {
|
|
24
|
+
case '==': return a === b;
|
|
25
|
+
case '!=': return a !== b;
|
|
26
|
+
case '>=': return a >= b;
|
|
27
|
+
case '<=': return a <= b;
|
|
28
|
+
case '>': return a > b;
|
|
29
|
+
default: return a < b;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "a==b && c>1 || d!=e" -> boolean. Tanpa kurung. Nilai kosong/lainnya = false. */
|
|
34
|
+
function evalCondition(str) {
|
|
35
|
+
return str.split('||').some((group) => group.split('&&').every(compare));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
class Interpreter {
|
|
39
|
+
/** Jalankan daftar node, kembalikan teks hasil. */
|
|
40
|
+
async run(nodes, ctx, opts = {}) {
|
|
41
|
+
let out = '';
|
|
42
|
+
for (const node of nodes) {
|
|
43
|
+
if (node.type === 'text') { out += node.value; continue; }
|
|
44
|
+
const value = node.type === 'if' ? await this._if(node, ctx) : await this._call(node, ctx);
|
|
45
|
+
out += opts.cond ? escapeOps(value) : value;
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async cond(nodes, ctx) {
|
|
51
|
+
return evalCondition(await this.run(nodes, ctx, { cond: true }));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async _if(node, ctx) {
|
|
55
|
+
for (const branch of node.branches) {
|
|
56
|
+
if (branch.cond === null || (await this.cond(branch.cond, ctx))) {
|
|
57
|
+
return this.run(branch.body, ctx);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async _call(node, ctx) {
|
|
64
|
+
const { def } = node;
|
|
65
|
+
const api = {
|
|
66
|
+
run: (nodes) => this.run(nodes, ctx),
|
|
67
|
+
cond: (nodes) => this.cond(nodes, ctx),
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
let result;
|
|
71
|
+
if (def.lazy) {
|
|
72
|
+
result = await def.run(ctx, node.args ?? [], api);
|
|
73
|
+
} else {
|
|
74
|
+
const args = [];
|
|
75
|
+
for (const arg of node.args ?? []) args.push(await this.run(arg, ctx));
|
|
76
|
+
result = await def.run(ctx, args, api);
|
|
77
|
+
}
|
|
78
|
+
return result === undefined || result === null ? '' : String(result);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (e instanceof Stop || (e instanceof BotError && e.fn)) throw e;
|
|
81
|
+
const err = new BotError(`${def.display}: ${e.message}`, { cause: e });
|
|
82
|
+
err.fn = def.display;
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { Interpreter, evalCondition, escapeOps, unescapeOps };
|