chrxmaticc-framework 1.2.0 → 1.3.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.
@@ -1,13 +1,10 @@
1
1
  /**
2
2
  * core/ChrxCommandBuilder.js
3
3
  * Simple command builder — write a command in 10 lines.
4
- * Handles SlashCommandBuilder, option types, and plugin injection automatically.
5
4
  */
6
5
 
7
6
  const { SlashCommandBuilder, PermissionFlagsBits } = require("discord.js");
8
7
 
9
- // ── Option type map ───────────────────────────────────────────────────────
10
- // Write "user" instead of ApplicationCommandOptionType.User etc.
11
8
  const TYPE_MAP = {
12
9
  string: "addStringOption",
13
10
  number: "addNumberOption",
@@ -21,23 +18,6 @@ const TYPE_MAP = {
21
18
  };
22
19
 
23
20
  class ChrxCommandBuilder {
24
- /**
25
- * @param {object} options
26
- * @param {string} options.name Command name (lowercase, no spaces)
27
- * @param {string} options.description Command description
28
- * @param {object[]} [options.options] Command options/arguments
29
- * @param {string} options.options[].name Option name
30
- * @param {string} options.options[].description Option description
31
- * @param {string} options.options[].type Option type — "string" | "number" | "integer" | "boolean" | "user" | "channel" | "role" | "mentionable" | "attachment"
32
- * @param {boolean} [options.options[].required] Whether option is required (default: false)
33
- * @param {Array} [options.options[].choices] Choices array [{ name, value }]
34
- * @param {string} [options.category] Category label for help menus
35
- * @param {string} [options.permission] Required permission e.g. "BanMembers"
36
- * @param {boolean} [options.ownerOnly] Restrict to bot owner
37
- * @param {boolean} [options.guildOnly] Restrict to guilds only (default: true)
38
- * @param {number} [options.cooldown] Cooldown in seconds
39
- * @param {Function} options.run Command function (interaction, plugins) => void
40
- */
41
21
  constructor(options = {}) {
42
22
  if (!options.name) throw new Error("[ChrxCommand] name is required.");
43
23
  if (!options.description) throw new Error("[ChrxCommand] description is required.");
@@ -46,7 +26,6 @@ class ChrxCommandBuilder {
46
26
  this._options = options;
47
27
  this._cooldowns = new Map();
48
28
 
49
- // ── Build SlashCommandBuilder ─────────────────────────────────────────
50
29
  const builder = new SlashCommandBuilder()
51
30
  .setName(options.name)
52
31
  .setDescription(options.description);
@@ -59,7 +38,6 @@ class ChrxCommandBuilder {
59
38
  builder.setDMPermission(false);
60
39
  }
61
40
 
62
- // ── Add options ───────────────────────────────────────────────────────
63
41
  for (const opt of options.options ?? []) {
64
42
  const method = TYPE_MAP[opt.type ?? "string"];
65
43
  if (!method) throw new Error(`[ChrxCommand] Unknown option type: "${opt.type}"`);
@@ -67,43 +45,36 @@ class ChrxCommandBuilder {
67
45
  builder[method]((o) => {
68
46
  o.setName(opt.name).setDescription(opt.description);
69
47
  if (opt.required) o.setRequired(true);
70
- if (opt.choices && (opt.type === "string" || opt.type === "integer" || opt.type === "number")) {
48
+ if (opt.choices && ["string", "integer", "number"].includes(opt.type)) {
71
49
  o.addChoices(...opt.choices);
72
50
  }
73
51
  return o;
74
52
  });
75
53
  }
76
54
 
77
- this.data = builder;
55
+ // ── Critical: expose data and execute so CommandLoader + deploy-commands picks it up ──
56
+ this.data = builder;
57
+ this.execute = this._execute.bind(this);
78
58
  }
79
59
 
80
- /**
81
- * Called by CommandLoader automatically.
82
- * Handles cooldowns, permissions, ownerOnly, then calls run().
83
- */
84
- async execute(interaction) {
60
+ async _execute(interaction) {
85
61
  const client = interaction.client;
86
62
  const opts = this._options;
87
63
 
88
- // ── Guild only ────────────────────────────────────────────────────────
89
64
  if (opts.guildOnly !== false && !interaction.guild) {
90
65
  return interaction.reply({ content: "❌ This command can only be used in a server.", ephemeral: true });
91
66
  }
92
67
 
93
- // ── Owner only ────────────────────────────────────────────────────────
94
68
  if (opts.ownerOnly && interaction.user.id !== process.env.OWNER_ID) {
95
69
  return interaction.reply({ content: "❌ This command is restricted to the bot owner.", ephemeral: true });
96
70
  }
97
71
 
98
- // ── Permission check ──────────────────────────────────────────────────
99
72
  if (opts.permission) {
100
- const member = interaction.member;
101
- if (!member?.permissions.has(PermissionFlagsBits[opts.permission])) {
73
+ if (!interaction.member?.permissions.has(PermissionFlagsBits[opts.permission])) {
102
74
  return interaction.reply({ content: `❌ You need the **${opts.permission}** permission to use this.`, ephemeral: true });
103
75
  }
104
76
  }
105
77
 
106
- // ── Cooldown ──────────────────────────────────────────────────────────
107
78
  if (opts.cooldown) {
108
79
  const key = `${interaction.user.id}-${opts.name}`;
109
80
  const now = Date.now();
@@ -120,8 +91,6 @@ class ChrxCommandBuilder {
120
91
  setTimeout(() => this._cooldowns.delete(key), opts.cooldown * 1000);
121
92
  }
122
93
 
123
- // ── Inject plugins from client ────────────────────────────────────────
124
- // Plugins are attached to client.chrx.* if using ChrxClient
125
94
  const plugins = {
126
95
  economy: client.chrx?.economy,
127
96
  moderation: client.chrx?.moderation,
@@ -137,12 +106,11 @@ class ChrxCommandBuilder {
137
106
  db: client.db,
138
107
  };
139
108
 
140
- // ── Run ───────────────────────────────────────────────────────────────
141
109
  try {
142
110
  await opts.run(interaction, plugins);
143
111
  } catch (err) {
144
112
  console.error(`[ChrxCommand] Error in /${opts.name}:`, err);
145
- const msg = { content: "❌ Something went wrong running this command.", ephemeral: true };
113
+ const msg = { content: err.message || "❌ Something went wrong running this command.", ephemeral: true };
146
114
  if (interaction.replied || interaction.deferred) {
147
115
  interaction.followUp(msg).catch(() => {});
148
116
  } else {
@@ -0,0 +1,652 @@
1
+ /**
2
+ * core/ChrxComponentsV3.js
3
+ * Components V3 — every Discord component simplified into clean Chrx builders.
4
+ *
5
+ * Includes:
6
+ * - ChrxButton
7
+ * - ChrxSelectMenu
8
+ * - ChrxPagination
9
+ * - ChrxContextMenu
10
+ * - ChrxMenu
11
+ * - ChrxForm
12
+ * - ChrxConfirm
13
+ */
14
+
15
+ const {
16
+ ButtonBuilder,
17
+ ButtonStyle,
18
+ ActionRowBuilder,
19
+ StringSelectMenuBuilder,
20
+ StringSelectMenuOptionBuilder,
21
+ ContextMenuCommandBuilder,
22
+ ApplicationCommandType,
23
+ EmbedBuilder,
24
+ ComponentType,
25
+ } = require("discord.js");
26
+
27
+ // ── Style map ─────────────────────────────────────────────────────────────
28
+ const BUTTON_STYLE_MAP = {
29
+ primary: ButtonStyle.Primary,
30
+ secondary: ButtonStyle.Secondary,
31
+ success: ButtonStyle.Success,
32
+ danger: ButtonStyle.Danger,
33
+ link: ButtonStyle.Link,
34
+ blurple: ButtonStyle.Primary,
35
+ grey: ButtonStyle.Secondary,
36
+ gray: ButtonStyle.Secondary,
37
+ green: ButtonStyle.Success,
38
+ red: ButtonStyle.Danger,
39
+ };
40
+
41
+ // ═════════════════════════════════════════════════════════════════════════════
42
+ // ChrxButton
43
+ // ═════════════════════════════════════════════════════════════════════════════
44
+
45
+ class ChrxButton {
46
+ /**
47
+ * @param {object} options
48
+ * @param {string} options.label Button label
49
+ * @param {string} [options.style] "primary"|"secondary"|"success"|"danger"|"link" (default: primary)
50
+ * @param {string} [options.id] Custom ID (not needed for link buttons)
51
+ * @param {string} [options.url] URL for link buttons
52
+ * @param {string} [options.emoji] Emoji for button
53
+ * @param {boolean} [options.disabled] Disable the button
54
+ * @param {Function} [options.onClick] Handler (interaction) => void
55
+ */
56
+ constructor(options = {}) {
57
+ if (!options.label) throw new Error("[ChrxButton] label is required.");
58
+
59
+ this._options = options;
60
+ this._handler = options.onClick ?? null;
61
+
62
+ const btn = new ButtonBuilder().setLabel(options.label);
63
+
64
+ if (options.style === "link" || options.url) {
65
+ btn.setStyle(ButtonStyle.Link).setURL(options.url);
66
+ } else {
67
+ btn.setStyle(BUTTON_STYLE_MAP[options.style ?? "primary"] ?? ButtonStyle.Primary);
68
+ btn.setCustomId(options.id ?? `chrx-btn-${Math.random().toString(36).slice(2, 9)}`);
69
+ }
70
+
71
+ if (options.emoji) btn.setEmoji(options.emoji);
72
+ if (options.disabled) btn.setDisabled(true);
73
+
74
+ this._button = btn;
75
+ this.customId = options.id;
76
+ }
77
+
78
+ /**
79
+ * Build into an ActionRow ready to send.
80
+ */
81
+ toRow() {
82
+ return new ActionRowBuilder().addComponents(this._button);
83
+ }
84
+
85
+ /**
86
+ * Send the button with a message and listen for clicks.
87
+ * Auto cleans up the collector after timeout.
88
+ *
89
+ * @param {import("discord.js").CommandInteraction} interaction
90
+ * @param {object} [options]
91
+ * @param {string} [options.content] Message content
92
+ * @param {object} [options.embed] EmbedBuilder to attach
93
+ * @param {boolean} [options.ephemeral]
94
+ * @param {number} [options.timeout] Collector timeout in ms (default: 60000)
95
+ */
96
+ async send(interaction, options = {}) {
97
+ const payload = {
98
+ components: [this.toRow()],
99
+ ephemeral: options.ephemeral ?? false,
100
+ };
101
+ if (options.content) payload.content = options.content;
102
+ if (options.embed) payload.embeds = [options.embed];
103
+
104
+ const reply = await interaction.reply({ ...payload, fetchReply: true });
105
+
106
+ if (!this._handler) return;
107
+
108
+ const collector = reply.createMessageComponentCollector({
109
+ componentType: ComponentType.Button,
110
+ time: options.timeout ?? 60000,
111
+ });
112
+
113
+ collector.on("collect", async (i) => {
114
+ if (i.customId !== (this._options.id ?? this._button.data.custom_id)) return;
115
+ try {
116
+ await this._handler(i);
117
+ } catch (err) {
118
+ console.error("[ChrxButton] onClick error:", err);
119
+ i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
120
+ }
121
+ });
122
+
123
+ collector.on("end", () => {
124
+ reply.edit({ components: [] }).catch(() => {});
125
+ });
126
+ }
127
+ }
128
+
129
+ // ═════════════════════════════════════════════════════════════════════════════
130
+ // ChrxSelectMenu
131
+ // ═════════════════════════════════════════════════════════════════════════════
132
+
133
+ class ChrxSelectMenu {
134
+ /**
135
+ * @param {object} options
136
+ * @param {string} options.id Custom ID
137
+ * @param {string} options.placeholder Placeholder text
138
+ * @param {object[]} options.options Menu options
139
+ * @param {string} options.options[].label
140
+ * @param {string} options.options[].value
141
+ * @param {string} [options.options[].description]
142
+ * @param {string} [options.options[].emoji]
143
+ * @param {boolean} [options.options[].default]
144
+ * @param {number} [options.min] Min selections (default: 1)
145
+ * @param {number} [options.max] Max selections (default: 1)
146
+ * @param {Function} [options.onSelect] Handler (interaction, values) => void
147
+ */
148
+ constructor(options = {}) {
149
+ if (!options.id) throw new Error("[ChrxSelectMenu] id is required.");
150
+ if (!options.placeholder) throw new Error("[ChrxSelectMenu] placeholder is required.");
151
+ if (!options.options?.length) throw new Error("[ChrxSelectMenu] options are required.");
152
+
153
+ this._options = options;
154
+ this._handler = options.onSelect ?? null;
155
+
156
+ const menu = new StringSelectMenuBuilder()
157
+ .setCustomId(options.id)
158
+ .setPlaceholder(options.placeholder)
159
+ .setMinValues(options.min ?? 1)
160
+ .setMaxValues(options.max ?? 1)
161
+ .addOptions(
162
+ options.options.map((opt) => {
163
+ const o = new StringSelectMenuOptionBuilder()
164
+ .setLabel(opt.label)
165
+ .setValue(opt.value);
166
+ if (opt.description) o.setDescription(opt.description);
167
+ if (opt.emoji) o.setEmoji(opt.emoji);
168
+ if (opt.default) o.setDefault(true);
169
+ return o;
170
+ })
171
+ );
172
+
173
+ this._menu = menu;
174
+ }
175
+
176
+ toRow() {
177
+ return new ActionRowBuilder().addComponents(this._menu);
178
+ }
179
+
180
+ /**
181
+ * Send the select menu and listen for selections.
182
+ */
183
+ async send(interaction, options = {}) {
184
+ const payload = {
185
+ components: [this.toRow()],
186
+ ephemeral: options.ephemeral ?? false,
187
+ };
188
+ if (options.content) payload.content = options.content;
189
+ if (options.embed) payload.embeds = [options.embed];
190
+
191
+ const reply = await interaction.reply({ ...payload, fetchReply: true });
192
+
193
+ if (!this._handler) return;
194
+
195
+ const collector = reply.createMessageComponentCollector({
196
+ componentType: ComponentType.StringSelect,
197
+ time: options.timeout ?? 60000,
198
+ });
199
+
200
+ collector.on("collect", async (i) => {
201
+ if (i.customId !== this._options.id) return;
202
+ try {
203
+ await this._handler(i, i.values);
204
+ } catch (err) {
205
+ console.error("[ChrxSelectMenu] onSelect error:", err);
206
+ i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
207
+ }
208
+ });
209
+
210
+ collector.on("end", () => {
211
+ reply.edit({ components: [] }).catch(() => {});
212
+ });
213
+ }
214
+ }
215
+
216
+ // ═════════════════════════════════════════════════════════════════════════════
217
+ // ChrxPagination
218
+ // ═════════════════════════════════════════════════════════════════════════════
219
+
220
+ class ChrxPagination {
221
+ /**
222
+ * Auto paginated embed system with next/prev buttons.
223
+ *
224
+ * @param {object} options
225
+ * @param {EmbedBuilder[]} options.pages Array of EmbedBuilders
226
+ * @param {number} [options.timeout] Collector timeout ms (default: 120000)
227
+ * @param {boolean} [options.ephemeral]
228
+ * @param {boolean} [options.showPageNumber] Show "Page X/Y" in footer (default: true)
229
+ */
230
+ constructor(options = {}) {
231
+ if (!options.pages?.length) throw new Error("[ChrxPagination] pages array is required.");
232
+ this._options = options;
233
+ this._page = 0;
234
+ }
235
+
236
+ _getButtons(page, total) {
237
+ const prev = new ButtonBuilder()
238
+ .setCustomId("chrx-page-prev")
239
+ .setLabel("◀")
240
+ .setStyle(ButtonStyle.Secondary)
241
+ .setDisabled(page === 0);
242
+
243
+ const next = new ButtonBuilder()
244
+ .setCustomId("chrx-page-next")
245
+ .setLabel("▶")
246
+ .setStyle(ButtonStyle.Secondary)
247
+ .setDisabled(page === total - 1);
248
+
249
+ const counter = new ButtonBuilder()
250
+ .setCustomId("chrx-page-counter")
251
+ .setLabel(`${page + 1} / ${total}`)
252
+ .setStyle(ButtonStyle.Primary)
253
+ .setDisabled(true);
254
+
255
+ return new ActionRowBuilder().addComponents(prev, counter, next);
256
+ }
257
+
258
+ _getPage(index) {
259
+ const page = this._options.pages[index];
260
+ const total = this._options.pages.length;
261
+ if (this._options.showPageNumber !== false) {
262
+ page.setFooter({ text: `Page ${index + 1} of ${total}` });
263
+ }
264
+ return page;
265
+ }
266
+
267
+ /**
268
+ * Send the paginated embed.
269
+ */
270
+ async send(interaction) {
271
+ const total = this._options.pages.length;
272
+ const reply = await interaction.reply({
273
+ embeds: [this._getPage(0)],
274
+ components: total > 1 ? [this._getButtons(0, total)] : [],
275
+ ephemeral: this._options.ephemeral ?? false,
276
+ fetchReply: true,
277
+ });
278
+
279
+ if (total <= 1) return;
280
+
281
+ const collector = reply.createMessageComponentCollector({
282
+ componentType: ComponentType.Button,
283
+ time: this._options.timeout ?? 120000,
284
+ });
285
+
286
+ collector.on("collect", async (i) => {
287
+ if (i.user.id !== interaction.user.id) {
288
+ return i.reply({ content: "❌ These controls aren't for you.", ephemeral: true });
289
+ }
290
+
291
+ if (i.customId === "chrx-page-prev") this._page = Math.max(0, this._page - 1);
292
+ if (i.customId === "chrx-page-next") this._page = Math.min(total - 1, this._page + 1);
293
+
294
+ await i.update({
295
+ embeds: [this._getPage(this._page)],
296
+ components: [this._getButtons(this._page, total)],
297
+ });
298
+ });
299
+
300
+ collector.on("end", () => {
301
+ reply.edit({ components: [] }).catch(() => {});
302
+ });
303
+ }
304
+ }
305
+
306
+ // ═════════════════════════════════════════════════════════════════════════════
307
+ // ChrxContextMenu
308
+ // ═════════════════════════════════════════════════════════════════════════════
309
+
310
+ class ChrxContextMenu {
311
+ /**
312
+ * Right click context menu command.
313
+ *
314
+ * @param {object} options
315
+ * @param {string} options.name Context menu name
316
+ * @param {string} options.type "user" | "message"
317
+ * @param {Function} options.run Handler (interaction) => void
318
+ */
319
+ constructor(options = {}) {
320
+ if (!options.name) throw new Error("[ChrxContextMenu] name is required.");
321
+ if (!options.type) throw new Error("[ChrxContextMenu] type is required. Use 'user' or 'message'.");
322
+ if (!options.run) throw new Error("[ChrxContextMenu] run function is required.");
323
+
324
+ const builder = new ContextMenuCommandBuilder()
325
+ .setName(options.name)
326
+ .setType(
327
+ options.type === "user"
328
+ ? ApplicationCommandType.User
329
+ : ApplicationCommandType.Message
330
+ );
331
+
332
+ this.data = builder;
333
+ this.execute = async (interaction) => {
334
+ try {
335
+ await options.run(interaction);
336
+ } catch (err) {
337
+ console.error(`[ChrxContextMenu] Error in "${options.name}":`, err);
338
+ const msg = { content: err.message || "❌ Something went wrong.", ephemeral: true };
339
+ if (interaction.replied || interaction.deferred) {
340
+ interaction.followUp(msg).catch(() => {});
341
+ } else {
342
+ interaction.reply(msg).catch(() => {});
343
+ }
344
+ }
345
+ };
346
+ }
347
+ }
348
+
349
+ // ═════════════════════════════════════════════════════════════════════════════
350
+ // ChrxMenu
351
+ // ═════════════════════════════════════════════════════════════════════════════
352
+
353
+ class ChrxMenu {
354
+ /**
355
+ * Full interactive menu — combines embed, buttons and select menus together.
356
+ *
357
+ * @param {object} options
358
+ * @param {object} options.embed Embed options for ChrxEmbedBuilder.build()
359
+ * @param {object[]} [options.buttons] Array of ChrxButton-like option objects
360
+ * @param {object} [options.select] ChrxSelectMenu-like option object
361
+ * @param {number} [options.timeout] Collector timeout ms (default: 60000)
362
+ * @param {boolean} [options.ephemeral]
363
+ */
364
+ constructor(options = {}) {
365
+ if (!options.embed) throw new Error("[ChrxMenu] embed is required.");
366
+ this._options = options;
367
+ }
368
+
369
+ async send(interaction) {
370
+ const { EmbedBuilder } = require("discord.js");
371
+ const embed = new EmbedBuilder();
372
+
373
+ if (this._options.embed.title) embed.setTitle(this._options.embed.title);
374
+ if (this._options.embed.desc) embed.setDescription(this._options.embed.desc);
375
+ if (this._options.embed.color) embed.setColor(this._options.embed.color);
376
+ if (this._options.embed.footer) embed.setFooter({ text: this._options.embed.footer });
377
+ if (this._options.embed.thumbnail) embed.setThumbnail(this._options.embed.thumbnail);
378
+ if (this._options.embed.timestamp) embed.setTimestamp();
379
+
380
+ const rows = [];
381
+
382
+ // ── Buttons ───────────────────────────────────────────────────────────
383
+ if (this._options.buttons?.length) {
384
+ const btnRow = new ActionRowBuilder();
385
+ const handlers = {};
386
+
387
+ for (const btnOpt of this._options.buttons) {
388
+ const btn = new ButtonBuilder()
389
+ .setLabel(btnOpt.label)
390
+ .setStyle(BUTTON_STYLE_MAP[btnOpt.style ?? "primary"] ?? ButtonStyle.Primary)
391
+ .setCustomId(btnOpt.id ?? `chrx-menu-btn-${Math.random().toString(36).slice(2, 7)}`);
392
+
393
+ if (btnOpt.emoji) btn.setEmoji(btnOpt.emoji);
394
+ if (btnOpt.disabled) btn.setDisabled(true);
395
+
396
+ handlers[btnOpt.id] = btnOpt.onClick;
397
+ btnRow.addComponents(btn);
398
+ }
399
+
400
+ rows.push(btnRow);
401
+
402
+ const reply = await interaction.reply({
403
+ embeds: [embed],
404
+ components: rows,
405
+ ephemeral: this._options.ephemeral ?? false,
406
+ fetchReply: true,
407
+ });
408
+
409
+ const collector = reply.createMessageComponentCollector({
410
+ time: this._options.timeout ?? 60000,
411
+ });
412
+
413
+ collector.on("collect", async (i) => {
414
+ const handler = handlers[i.customId];
415
+ if (handler) {
416
+ try {
417
+ await handler(i);
418
+ } catch (err) {
419
+ i.reply({ content: err.message || "❌ Error.", ephemeral: true }).catch(() => {});
420
+ }
421
+ }
422
+ });
423
+
424
+ collector.on("end", () => {
425
+ reply.edit({ components: [] }).catch(() => {});
426
+ });
427
+
428
+ return;
429
+ }
430
+
431
+ // ── Select only ───────────────────────────────────────────────────────
432
+ if (this._options.select) {
433
+ const menu = new ChrxSelectMenu(this._options.select);
434
+ rows.push(menu.toRow());
435
+ await interaction.reply({
436
+ embeds: [embed],
437
+ components: rows,
438
+ ephemeral: this._options.ephemeral ?? false,
439
+ });
440
+ return;
441
+ }
442
+
443
+ // ── Embed only ────────────────────────────────────────────────────────
444
+ await interaction.reply({
445
+ embeds: [embed],
446
+ ephemeral: this._options.ephemeral ?? false,
447
+ });
448
+ }
449
+ }
450
+
451
+ // ═════════════════════════════════════════════════════════════════════════════
452
+ // ChrxForm
453
+ // ═════════════════════════════════════════════════════════════════════════════
454
+
455
+ class ChrxForm {
456
+ /**
457
+ * Combines a modal AND processes the response in one block.
458
+ * No need to set up onModalSubmit separately.
459
+ *
460
+ * @param {object} options
461
+ * @param {string} options.id Modal ID
462
+ * @param {string} options.title Modal title
463
+ * @param {object[]} options.inputs Same as ChrxModalBuilder inputs
464
+ * @param {Function} options.onSubmit (interaction, values) => void
465
+ * values is an object keyed by input id e.g. { answer: "yes" }
466
+ */
467
+ constructor(options = {}) {
468
+ if (!options.id) throw new Error("[ChrxForm] id is required.");
469
+ if (!options.title) throw new Error("[ChrxForm] title is required.");
470
+ if (!options.inputs?.length) throw new Error("[ChrxForm] inputs are required.");
471
+ if (!options.onSubmit) throw new Error("[ChrxForm] onSubmit is required.");
472
+
473
+ this._options = options;
474
+ this._modal = null;
475
+
476
+ // Build the modal
477
+ const {
478
+ ModalBuilder,
479
+ TextInputBuilder,
480
+ TextInputStyle,
481
+ ActionRowBuilder,
482
+ } = require("discord.js");
483
+
484
+ const STYLE_MAP = {
485
+ short: TextInputStyle.Short,
486
+ paragraph: TextInputStyle.Paragraph,
487
+ long: TextInputStyle.Paragraph,
488
+ };
489
+
490
+ const modal = new ModalBuilder()
491
+ .setCustomId(options.id)
492
+ .setTitle(options.title);
493
+
494
+ const rows = options.inputs.map((input) => {
495
+ const textInput = new TextInputBuilder()
496
+ .setCustomId(input.id)
497
+ .setLabel(input.label)
498
+ .setStyle(STYLE_MAP[input.type ?? "short"] ?? TextInputStyle.Short);
499
+
500
+ if (input.required) textInput.setRequired(input.required);
501
+ if (input.placeholder) textInput.setPlaceholder(input.placeholder);
502
+ if (input.value) textInput.setValue(input.value);
503
+ if (input.minLength) textInput.setMinLength(input.minLength);
504
+ if (input.maxLength) textInput.setMaxLength(input.maxLength);
505
+
506
+ return new ActionRowBuilder().addComponents(textInput);
507
+ });
508
+
509
+ modal.addComponents(...rows);
510
+ this._modal = modal;
511
+ }
512
+
513
+ /**
514
+ * Show the form and handle submission automatically.
515
+ * @param {import("discord.js").CommandInteraction} interaction
516
+ */
517
+ async show(interaction) {
518
+ await interaction.showModal(this._modal);
519
+
520
+ try {
521
+ const submitted = await interaction.awaitModalSubmit({
522
+ time: 300000, // 5 minutes to fill out the form
523
+ filter: (i) => i.customId === this._options.id && i.user.id === interaction.user.id,
524
+ });
525
+
526
+ // Build values object keyed by input id
527
+ const values = {};
528
+ for (const input of this._options.inputs) {
529
+ values[input.id] = submitted.fields.getTextInputValue(input.id);
530
+ }
531
+
532
+ try {
533
+ await this._options.onSubmit(submitted, values);
534
+ } catch (err) {
535
+ console.error(`[ChrxForm] onSubmit error:`, err);
536
+ const msg = { content: err.message || "❌ Something went wrong.", ephemeral: true };
537
+ if (submitted.replied || submitted.deferred) {
538
+ submitted.followUp(msg).catch(() => {});
539
+ } else {
540
+ submitted.reply(msg).catch(() => {});
541
+ }
542
+ }
543
+ } catch {
544
+ // User didn't submit in time — silently ignore
545
+ }
546
+ }
547
+ }
548
+
549
+ // ═════════════════════════════════════════════════════════════════════════════
550
+ // ChrxConfirm
551
+ // ═════════════════════════════════════════════════════════════════════════════
552
+
553
+ class ChrxConfirm {
554
+ /**
555
+ * Yes/No confirmation prompt in two lines.
556
+ *
557
+ * @param {object} options
558
+ * @param {string} options.question Question to ask
559
+ * @param {Function} options.onConfirm Called when user clicks Yes
560
+ * @param {Function} [options.onCancel] Called when user clicks No
561
+ * @param {string} [options.confirmLabel] Yes button label (default: "Confirm")
562
+ * @param {string} [options.cancelLabel] No button label (default: "Cancel")
563
+ * @param {string} [options.color] Embed color (default: #5865F2)
564
+ * @param {boolean} [options.ephemeral]
565
+ * @param {number} [options.timeout] ms before auto cancel (default: 30000)
566
+ */
567
+ constructor(options = {}) {
568
+ if (!options.question) throw new Error("[ChrxConfirm] question is required.");
569
+ if (!options.onConfirm) throw new Error("[ChrxConfirm] onConfirm is required.");
570
+ this._options = options;
571
+ }
572
+
573
+ /**
574
+ * Send the confirmation prompt.
575
+ */
576
+ async send(interaction) {
577
+ const confirmId = `chrx-confirm-yes-${Math.random().toString(36).slice(2, 7)}`;
578
+ const cancelId = `chrx-confirm-no-${Math.random().toString(36).slice(2, 7)}`;
579
+
580
+ const embed = new EmbedBuilder()
581
+ .setColor(this._options.color ?? "#5865F2")
582
+ .setDescription(`❓ ${this._options.question}`);
583
+
584
+ const row = new ActionRowBuilder().addComponents(
585
+ new ButtonBuilder()
586
+ .setCustomId(confirmId)
587
+ .setLabel(this._options.confirmLabel ?? "Confirm")
588
+ .setStyle(ButtonStyle.Success),
589
+ new ButtonBuilder()
590
+ .setCustomId(cancelId)
591
+ .setLabel(this._options.cancelLabel ?? "Cancel")
592
+ .setStyle(ButtonStyle.Danger)
593
+ );
594
+
595
+ const reply = await interaction.reply({
596
+ embeds: [embed],
597
+ components: [row],
598
+ ephemeral: this._options.ephemeral ?? false,
599
+ fetchReply: true,
600
+ });
601
+
602
+ const collector = reply.createMessageComponentCollector({
603
+ componentType: ComponentType.Button,
604
+ time: this._options.timeout ?? 30000,
605
+ max: 1,
606
+ });
607
+
608
+ collector.on("collect", async (i) => {
609
+ if (i.user.id !== interaction.user.id) {
610
+ return i.reply({ content: "❌ This isn't your confirmation.", ephemeral: true });
611
+ }
612
+
613
+ try {
614
+ if (i.customId === confirmId) {
615
+ await this._options.onConfirm(i);
616
+ } else {
617
+ if (this._options.onCancel) {
618
+ await this._options.onCancel(i);
619
+ } else {
620
+ await i.update({ embeds: [embed.setDescription("❌ Cancelled.").setColor("#ED4245")], components: [] });
621
+ }
622
+ }
623
+ } catch (err) {
624
+ console.error("[ChrxConfirm] Error:", err);
625
+ i.reply({ content: err.message || "❌ Something went wrong.", ephemeral: true }).catch(() => {});
626
+ }
627
+ });
628
+
629
+ collector.on("end", (collected) => {
630
+ if (collected.size === 0) {
631
+ reply.edit({
632
+ embeds: [embed.setDescription("⏱ Confirmation timed out.").setColor("#ED4245")],
633
+ components: [],
634
+ }).catch(() => {});
635
+ }
636
+ });
637
+ }
638
+ }
639
+
640
+ // ═════════════════════════════════════════════════════════════════════════════
641
+ // Exports
642
+ // ═════════════════════════════════════════════════════════════════════════════
643
+
644
+ module.exports = {
645
+ ChrxButton,
646
+ ChrxSelectMenu,
647
+ ChrxPagination,
648
+ ChrxContextMenu,
649
+ ChrxMenu,
650
+ ChrxForm,
651
+ ChrxConfirm,
652
+ };
@@ -0,0 +1,127 @@
1
+ /**
2
+ * core/ChrxEmbedBuilder.js
3
+ * Simple and Advanced embed builder.
4
+ *
5
+ * Simple mode → key/value syntax (title, desc, color)
6
+ * AdvancedEmbed → parses raw JSON or HTML-like string into a full embed
7
+ */
8
+
9
+ const { EmbedBuilder } = require("discord.js");
10
+
11
+ class ChrxEmbedBuilder {
12
+ /**
13
+ * Simple mode — just pass title, desc, color etc.
14
+ * @param {object} options
15
+ * @param {string} [options.title]
16
+ * @param {string} [options.desc]
17
+ * @param {string} [options.color]
18
+ * @param {string} [options.thumbnail]
19
+ * @param {string} [options.image]
20
+ * @param {string} [options.footer]
21
+ * @param {boolean} [options.timestamp]
22
+ * @param {object[]} [options.fields] [{ name, value, inline? }]
23
+ */
24
+ static build(options = {}) {
25
+ const embed = new EmbedBuilder();
26
+
27
+ if (options.title) embed.setTitle(options.title);
28
+ if (options.desc) embed.setDescription(options.desc);
29
+ if (options.color) embed.setColor(options.color);
30
+ if (options.thumbnail) embed.setThumbnail(options.thumbnail);
31
+ if (options.image) embed.setImage(options.image);
32
+ if (options.footer) embed.setFooter({ text: options.footer });
33
+ if (options.timestamp) embed.setTimestamp();
34
+ if (options.fields) embed.addFields(options.fields);
35
+
36
+ return embed;
37
+ }
38
+
39
+ /**
40
+ * AdvancedEmbed — reads raw JSON string or HTML-like string and parses it.
41
+ * Supports both:
42
+ * - JSON: { "title": "Hi", "desc": "ok bro", "color": "#5865F2", ... }
43
+ * - HTML-like: <title>Hi</title><desc>ok bro</desc><color>#5865F2</color>
44
+ *
45
+ * @param {string|object} input Raw JSON string, HTML-like string, or plain object
46
+ * @returns {EmbedBuilder}
47
+ */
48
+ static advanced(input) {
49
+ let data = {};
50
+
51
+ if (typeof input === "object") {
52
+ // Already a plain object
53
+ data = input;
54
+
55
+ } else if (typeof input === "string") {
56
+ // Try JSON first
57
+ try {
58
+ data = JSON.parse(input);
59
+ } catch {
60
+ // Fall back to HTML-like tag parser
61
+ data = ChrxEmbedBuilder._parseHTML(input);
62
+ }
63
+ }
64
+
65
+ return ChrxEmbedBuilder.build(data);
66
+ }
67
+
68
+ /**
69
+ * Parse HTML-like tag syntax into a plain object.
70
+ * Supports: <title> <desc> <color> <thumbnail> <image> <footer> <timestamp> <field>
71
+ *
72
+ * Example input:
73
+ * <title>Hello!</title>
74
+ * <desc>This is my embed</desc>
75
+ * <color>#5865F2</color>
76
+ * <field name="Field 1" value="Value 1" inline="true"/>
77
+ */
78
+ static _parseHTML(html) {
79
+ const data = {};
80
+
81
+ const extract = (tag) => {
82
+ const match = html.match(new RegExp(`<${tag}[^>]*>(.*?)<\\/${tag}>`, "si"));
83
+ return match ? match[1].trim() : null;
84
+ };
85
+
86
+ data.title = extract("title") || undefined;
87
+ data.desc = extract("desc") || extract("description") || undefined;
88
+ data.color = extract("color") || undefined;
89
+ data.thumbnail = extract("thumbnail") || undefined;
90
+ data.image = extract("image") || undefined;
91
+ data.footer = extract("footer") || undefined;
92
+ data.timestamp = html.includes("<timestamp") ? true : undefined;
93
+
94
+ // Parse <field name="x" value="y" inline="true"/> tags
95
+ const fieldRegex = /<field\s+name="([^"]+)"\s+value="([^"]+)"(?:\s+inline="(true|false)")?[^/]*\/>/gi;
96
+ const fields = [];
97
+ let fieldMatch;
98
+ while ((fieldMatch = fieldRegex.exec(html)) !== null) {
99
+ fields.push({
100
+ name: fieldMatch[1],
101
+ value: fieldMatch[2],
102
+ inline: fieldMatch[3] === "true",
103
+ });
104
+ }
105
+ if (fields.length > 0) data.fields = fields;
106
+
107
+ return data;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * EmbedTrigger — fires a ChrxEmbedBuilder embed in a reply/send.
113
+ * Drop this anywhere in a ChrxCommandBuilder run() function.
114
+ *
115
+ * @param {import("discord.js").CommandInteraction} interaction
116
+ * @param {EmbedBuilder} embed
117
+ * @param {object} [options]
118
+ * @param {boolean} [options.ephemeral]
119
+ */
120
+ async function EmbedTrigger(interaction, embed, options = {}) {
121
+ if (interaction.replied || interaction.deferred) {
122
+ return interaction.followUp({ embeds: [embed], ephemeral: options.ephemeral ?? false });
123
+ }
124
+ return interaction.reply({ embeds: [embed], ephemeral: options.ephemeral ?? false });
125
+ }
126
+
127
+ module.exports = { ChrxEmbedBuilder, EmbedTrigger };
@@ -0,0 +1,151 @@
1
+ /**
2
+ * core/ChrxModalBuilder.js
3
+ * Simple modal builder with built in validation and rate limiting.
4
+ */
5
+
6
+ const {
7
+ ModalBuilder,
8
+ TextInputBuilder,
9
+ TextInputStyle,
10
+ ActionRowBuilder,
11
+ } = require("discord.js");
12
+
13
+ // ── Rate limiter ──────────────────────────────────────────────────────────
14
+ // Tracks how many modals a user has triggered in the last minute
15
+ const modalRateLimit = new Map(); // userId -> { count, resetAt }
16
+ const RATE_LIMIT = 50; // max modals per minute per user
17
+
18
+ function checkRateLimit(userId) {
19
+ const now = Date.now();
20
+ const data = modalRateLimit.get(userId);
21
+
22
+ if (!data || now > data.resetAt) {
23
+ modalRateLimit.set(userId, { count: 1, resetAt: now + 60000 });
24
+ return true;
25
+ }
26
+
27
+ if (data.count >= RATE_LIMIT) return false;
28
+
29
+ data.count++;
30
+ return true;
31
+ }
32
+
33
+ // ── Type map ──────────────────────────────────────────────────────────────
34
+ const STYLE_MAP = {
35
+ short: TextInputStyle.Short,
36
+ paragraph: TextInputStyle.Paragraph,
37
+ long: TextInputStyle.Paragraph, // alias
38
+ };
39
+
40
+ class ChrxModalBuilder {
41
+ /**
42
+ * @param {object} options
43
+ * @param {string} options.name Internal name (for reference)
44
+ * @param {string} options.id Modal custom ID
45
+ * @param {string} options.title Modal title (max 45 chars)
46
+ * @param {object[]} options.inputs Input fields (max 5)
47
+ * @param {string} options.inputs[].id Input custom ID
48
+ * @param {string} options.inputs[].label Input label (max 100 chars)
49
+ * @param {string} [options.inputs[].type] "short" | "paragraph" (default: short)
50
+ * @param {boolean} [options.inputs[].required] Required? (default: false)
51
+ * @param {string} [options.inputs[].placeholder] Placeholder text
52
+ * @param {string} [options.inputs[].value] Pre-filled value
53
+ * @param {number} [options.inputs[].minLength]
54
+ * @param {number} [options.inputs[].maxLength]
55
+ */
56
+ constructor(options = {}) {
57
+ if (!options.id) throw new Error("[ChrxModal] id is required.");
58
+ if (!options.title) throw new Error("[ChrxModal] title is required.");
59
+ if (!options.inputs || options.inputs.length === 0) throw new Error("[ChrxModal] At least one input is required.");
60
+
61
+ // ── Validation ────────────────────────────────────────────────────────
62
+ if (options.title.length > 45) {
63
+ throw new Error(`[ChrxModal] Title exceeds 45 characters (got ${options.title.length}).`);
64
+ }
65
+ if (options.inputs.length > 5) {
66
+ throw new Error(`[ChrxModal] Maximum 5 inputs allowed (got ${options.inputs.length}).`);
67
+ }
68
+ for (const input of options.inputs) {
69
+ if (!input.id) throw new Error("[ChrxModal] Every input must have an id.");
70
+ if (!input.label) throw new Error("[ChrxModal] Every input must have a label.");
71
+ if (input.label.length > 100) {
72
+ throw new Error(`[ChrxModal] Input label "${input.label}" exceeds 100 characters.`);
73
+ }
74
+ }
75
+
76
+ this._options = options;
77
+ this.name = options.name ?? options.id;
78
+ }
79
+
80
+ /**
81
+ * Build and return the raw discord.js Modal.
82
+ * @returns {ModalBuilder}
83
+ */
84
+ build() {
85
+ const modal = new ModalBuilder()
86
+ .setCustomId(this._options.id)
87
+ .setTitle(this._options.title);
88
+
89
+ const rows = this._options.inputs.map((input) => {
90
+ const textInput = new TextInputBuilder()
91
+ .setCustomId(input.id)
92
+ .setLabel(input.label)
93
+ .setStyle(STYLE_MAP[input.type ?? "short"] ?? TextInputStyle.Short);
94
+
95
+ if (input.required) textInput.setRequired(input.required);
96
+ if (input.placeholder) textInput.setPlaceholder(input.placeholder);
97
+ if (input.value) textInput.setValue(input.value);
98
+ if (input.minLength) textInput.setMinLength(input.minLength);
99
+ if (input.maxLength) textInput.setMaxLength(input.maxLength);
100
+
101
+ return new ActionRowBuilder().addComponents(textInput);
102
+ });
103
+
104
+ modal.addComponents(...rows);
105
+ return modal;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * ModalTrigger — shows a ChrxModalBuilder modal to the user.
111
+ * Handles rate limiting automatically.
112
+ *
113
+ * @param {import("discord.js").CommandInteraction} interaction
114
+ * @param {ChrxModalBuilder} modal
115
+ */
116
+ async function ModalTrigger(interaction, modal) {
117
+ if (!checkRateLimit(interaction.user.id)) {
118
+ return interaction.reply({
119
+ content: "⏱ You're triggering too many modals! Slow down.",
120
+ ephemeral: true,
121
+ });
122
+ }
123
+
124
+ await interaction.showModal(modal.build());
125
+ }
126
+
127
+ /**
128
+ * onModalSubmit — listen for a modal submission by ID.
129
+ * @param {import("discord.js").Client} client
130
+ * @param {string} modalId
131
+ * @param {Function} handler (interaction) => void
132
+ */
133
+ function onModalSubmit(client, modalId, handler) {
134
+ client.on("interactionCreate", async (interaction) => {
135
+ if (!interaction.isModalSubmit()) return;
136
+ if (interaction.customId !== modalId) return;
137
+ try {
138
+ await handler(interaction);
139
+ } catch (err) {
140
+ console.error(`[ChrxModal] Error in modal "${modalId}":`, err);
141
+ const msg = { content: err.message || "❌ Something went wrong.", ephemeral: true };
142
+ if (interaction.replied || interaction.deferred) {
143
+ interaction.followUp(msg).catch(() => {});
144
+ } else {
145
+ interaction.reply(msg).catch(() => {});
146
+ }
147
+ }
148
+ });
149
+ }
150
+
151
+ module.exports = { ChrxModalBuilder, ModalTrigger, onModalSubmit };
package/index.js CHANGED
@@ -1,15 +1,25 @@
1
1
  /**
2
- * index.js (not for a discord bot)
3
- * Main entry point, will ALWAYS export everything.
4
- * Listen to Project4play / SVJ at SoundCloud NOW!
2
+ * index.js
3
+ * Main entry point exports everything.
5
4
  */
6
5
 
7
- const { ChrxClient } = require("./core/Client");
8
- const Database = require("./core/Database");
9
- const XPSystem = require("./core/XPSystem");
10
- const AIWrapper = require("./core/AIWrapper");
11
- const MusicManager = require("./core/MusicManager");
12
- const ChrxCommandBuilder = require("./core/ChrxCommandBuilder");
6
+ const { ChrxClient } = require("./core/Client");
7
+ const Database = require("./core/Database");
8
+ const XPSystem = require("./core/XPSystem");
9
+ const AIWrapper = require("./core/AIWrapper");
10
+ const MusicManager = require("./core/MusicManager");
11
+ const ChrxCommandBuilder = require("./core/ChrxCommandBuilder");
12
+ const { ChrxEmbedBuilder, EmbedTrigger } = require("./core/ChrxEmbedBuilder");
13
+ const { ChrxModalBuilder, ModalTrigger, onModalSubmit } = require("./core/ChrxModalBuilder");
14
+ const {
15
+ ChrxButton,
16
+ ChrxSelectMenu,
17
+ ChrxPagination,
18
+ ChrxContextMenu,
19
+ ChrxMenu,
20
+ ChrxForm,
21
+ ChrxConfirm,
22
+ } = require("./core/ChrxComponentsV3");
13
23
  const {
14
24
  parseTime,
15
25
  formatTime,
@@ -37,6 +47,22 @@ module.exports = {
37
47
  ChrxClient,
38
48
  ChrxCommandBuilder,
39
49
 
50
+ // Embed & Modal
51
+ ChrxEmbedBuilder,
52
+ EmbedTrigger,
53
+ ChrxModalBuilder,
54
+ ModalTrigger,
55
+ onModalSubmit,
56
+
57
+ // Components V3
58
+ ChrxButton,
59
+ ChrxSelectMenu,
60
+ ChrxPagination,
61
+ ChrxContextMenu,
62
+ ChrxMenu,
63
+ ChrxForm,
64
+ ChrxConfirm,
65
+
40
66
  // Modules
41
67
  Database,
42
68
  XPSystem,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrxmaticc-framework",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "A batteries-included Discord bot framework with music, AI, XP and database support.",
5
5
  "main": "index.js",
6
6
  "files": [