Version Estable

This commit is contained in:
2025-09-17 13:33:10 -05:00
commit bf6a7e3024
39 changed files with 2537 additions and 0 deletions

View File

@@ -0,0 +1,292 @@
import { CommandMessage } from "../../../core/types/commands";
// @ts-ignore
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, TextChannel, ChannelType } from "discord.js";
//@ts-ignore
import { ButtonStyle, ComponentType } from "discord.js";
import { replaceVars } from "../../../core/lib/vars";
export const command: CommandMessage = {
name: "embedcreate",
type: "message",
aliases: ["crearembed", "newembed"],
cooldown: 20,
// @ts-ignore
run: async (message, args, client) => {
if (!message.member?.permissions.has("Administrator")) {
return message.reply("❌ No tienes permisos de Administrador.");
}
const embedName: string | null = args[0] ?? null;
if (!embedName) {
return message.reply(
"Debes proporcionar un nombre para el embed. Uso: `!embedcreate <nombre>`"
);
}
const nameIsValid = await client.prisma.embedConfig.findFirst({ where: {
//@ts-ignore
guildId: message.guild.id,
name: embedName
}})
if(nameIsValid) return message.reply("❌ Nombre del embed ya fue tomado!")
// 📌 Estado independiente
let embedState: {
title?: string;
description?: string;
color?: number;
footer?: string;
} = {
title: `Editor de Embed: ${embedName}`,
description:
"Usa los botones de abajo para configurar este embed.\n\n_Ejemplo de variable: `{user.name}`_",
color: 0x5865f2,
footer: "Haz clic en Guardar cuando termines.",
};
// 📌 Función para construir un embed a partir del estado
const renderPreview = async () => {
const preview = new EmbedBuilder()
.setColor(embedState.color ?? 0x5865f2);
if (embedState.title)
preview.setTitle(
//@ts-ignore
await replaceVars(embedState.title, message.member)
);
if (embedState.description)
preview.setDescription(
//@ts-ignore
await replaceVars(embedState.description, message.member)
);
if (embedState.footer)
preview.setFooter({
//@ts-ignore
text: await replaceVars(embedState.footer, message.member),
});
return preview;
};
// 📌 Botones
const generateButtonRows = (disabled = false) => {
const primaryRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("edit_title")
.setLabel("Título")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("edit_description")
.setLabel("Descripción")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("edit_color")
.setLabel("Color")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled)
);
const secondaryRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("edit_footer")
.setLabel("Footer")
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled)
);
const controlRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("save_embed")
.setLabel("Guardar")
.setStyle(ButtonStyle.Success)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("cancel_embed")
.setLabel("Cancelar")
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled)
);
return [primaryRow, secondaryRow, controlRow];
};
if (message.channel.type === ChannelType.GuildText) {
const channel = message.channel as TextChannel;
const editorMessage = await channel.send({
embeds: [await renderPreview()],
components: generateButtonRows(),
});
const collector = editorMessage.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 300000,
});
collector.on("collect", async (i) => {
if (i.user.id !== message.author.id) {
await i.reply({
content: "No puedes usar este menú.",
ephemeral: true,
});
return;
}
await i.deferUpdate();
await editorMessage.edit({ components: generateButtonRows(true) });
// Guardar
if (i.customId === "save_embed") {
try {
const dataForDb = {
title: embedState.title,
description: embedState.description,
color: embedState.color ? `#${embedState.color.toString(16).padStart(6, '0')}` : null,
footerText: embedState.footer,
};
await client.prisma.embedConfig.upsert({
where: {
guildId_name: {
guildId: message.guildId!,
name: embedName,
},
},
update: dataForDb,
create: {
name: embedName,
...dataForDb,
// ✅ ESTA ES LA SOLUCIÓN:
// Le decimos a Prisma que se conecte al Guild o lo cree si no existe.
guild: {
connectOrCreate: {
where: { id: message.guildId! },
create: {
id: message.guildId!,
name: message.guild!.name, // Asegura que el nombre del servidor se guarde
},
},
},
},
});
const saved = new EmbedBuilder()
.setColor(0x00ff00)
.setTitle(`✅ Guardado: ${embedName}`)
.setDescription("La configuración se guardó en la base de datos.");
await editorMessage.edit({
embeds: [saved],
components: [],
});
} catch (e) {
const errorEmbed = new EmbedBuilder()
.setColor(0xff0000)
.setTitle("❌ Error al Guardar")
.setDescription("No se pudo guardar en la base de datos. Revisa la consola.");
await editorMessage.edit({
embeds: [errorEmbed],
components: [],
});
console.error("Error de Prisma al guardar el embed:", e);
}
collector.stop();
return;
}
// Cancelar
if (i.customId === "cancel_embed") {
await editorMessage.delete();
collector.stop();
return;
}
// Edición
let promptContent = "";
let fieldToEdit: "title" | "description" | "color" | "footer" | null =
null;
switch (i.customId) {
case "edit_title":
promptContent =
"Escribe el nuevo **título** (puedes usar variables como `{user.name}`).";
fieldToEdit = "title";
break;
case "edit_description":
promptContent =
"Escribe la nueva **descripción** (puedes usar variables).";
fieldToEdit = "description";
break;
case "edit_color":
promptContent =
"Escribe el nuevo **color** en formato hexadecimal (ej: `#FF0000`).";
fieldToEdit = "color";
break;
case "edit_footer":
promptContent =
"Escribe el nuevo **texto del footer** (puedes usar variables).";
fieldToEdit = "footer";
break;
}
//@ts-ignore
const promptMessage = await i.channel.send(promptContent);
//@ts-ignore
const messageCollector = i.channel!.createMessageCollector({
//@ts-ignore
filter: (m: Message) => m.author.id === i.user.id,
max: 1,
time: 60000,
});
//@ts-ignore
messageCollector.on("collect", async (collectedMessage) => {
const newValue = collectedMessage.content;
if (fieldToEdit === "title") embedState.title = newValue;
if (fieldToEdit === "description") embedState.description = newValue;
if (fieldToEdit === "footer") embedState.footer = newValue;
if (fieldToEdit === "color") {
try {
const hex = newValue.replace("#", "");
embedState.color = parseInt(hex, 16);
} catch {
embedState.color = 0x5865f2;
}
}
await collectedMessage.delete();
await promptMessage.delete();
await editorMessage.edit({
embeds: [await renderPreview()],
components: generateButtonRows(false),
});
});
//@ts-ignore
messageCollector.on("end", async (collected) => {
if (collected.size === 0) {
await promptMessage.delete();
await editorMessage.edit({
components: generateButtonRows(false),
});
}
});
});
collector.on("end", async (_, reason) => {
if (reason === "time") {
const timeoutEmbed = new EmbedBuilder()
.setColor(0xff0000)
.setTitle("Editor finalizado por inactividad.");
await editorMessage.edit({
embeds: [timeoutEmbed],
components: [],
});
}
});
}
},
};

View File

@@ -0,0 +1,276 @@
import { CommandMessage } from "../../../core/types/commands";
// @ts-ignore
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, TextChannel, ChannelType } from "discord.js";
//@ts-ignore
import { ButtonStyle, ComponentType } from "discord.js";
import { replaceVars } from "../../../core/lib/vars";
export const command: CommandMessage = {
name: "editembed",
type: "message",
aliases: ["modembed", "updateembed"],
cooldown: 20,
// @ts-ignore
run: async (message, args, client) => {
if (!message.member?.permissions.has("Administrator")) {
return message.reply("❌ No tienes permisos de Administrador.");
}
const embedName: string | null = args[0] ?? null;
if (!embedName) {
return message.reply(
"Debes proporcionar un nombre para el embed. Uso: `!editembed <nombre>`"
);
}
// 📌 Buscar en la base de datos
const existing = await client.prisma.embedConfig.findUnique({
where: {
guildId_name: {
guildId: message.guildId!,
name: embedName,
},
},
});
if (!existing) {
return message.reply("❌ No encontré un embed con ese nombre.");
}
// 📌 Estado inicial desde DB
let embedState: {
title?: string;
description?: string;
color?: number;
footer?: string;
} = {
title: existing.title ?? undefined,
description: existing.description ?? undefined,
color: existing.color ? parseInt(existing.color.replace("#", ""), 16) : 0x5865f2,
footer: existing.footerText ?? undefined,
};
// 📌 Función para renderizar preview
const renderPreview = async () => {
const preview = new EmbedBuilder().setColor(embedState.color ?? 0x5865f2);
if (embedState.title)
//@ts-ignore
preview.setTitle(await replaceVars(embedState.title, message.member));
if (embedState.description)
//@ts-ignore
preview.setDescription(await replaceVars(embedState.description, message.member));
if (embedState.footer)
preview.setFooter({
//@ts-ignore
text: await replaceVars(embedState.footer, message.member),
});
return preview;
};
const generateButtonRows = (disabled = false) => {
const primaryRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("edit_title")
.setLabel("Título")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("edit_description")
.setLabel("Descripción")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("edit_color")
.setLabel("Color")
.setStyle(ButtonStyle.Primary)
.setDisabled(disabled)
);
const secondaryRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("edit_footer")
.setLabel("Footer")
.setStyle(ButtonStyle.Secondary)
.setDisabled(disabled)
);
const controlRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder()
.setCustomId("save_embed")
.setLabel("Guardar cambios")
.setStyle(ButtonStyle.Success)
.setDisabled(disabled),
new ButtonBuilder()
.setCustomId("cancel_embed")
.setLabel("Cancelar")
.setStyle(ButtonStyle.Danger)
.setDisabled(disabled)
);
return [primaryRow, secondaryRow, controlRow];
};
if (message.channel.type === ChannelType.GuildText) {
const channel = message.channel as TextChannel;
const editorMessage = await channel.send({
embeds: [await renderPreview()],
components: generateButtonRows(),
});
const collector = editorMessage.createMessageComponentCollector({
componentType: ComponentType.Button,
time: 300000,
});
collector.on("collect", async (i) => {
if (i.user.id !== message.author.id) {
await i.reply({
content: "No puedes usar este menú.",
ephemeral: true,
});
return;
}
await i.deferUpdate();
await editorMessage.edit({ components: generateButtonRows(true) });
// Guardar cambios
if (i.customId === "save_embed") {
try {
const dataForDb = {
title: embedState.title,
description: embedState.description,
color: embedState.color ? `#${embedState.color.toString(16).padStart(6, '0')}` : null,
footerText: embedState.footer,
};
await client.prisma.embedConfig.update({
where: {
guildId_name: {
guildId: message.guildId!,
name: embedName,
},
},
data: dataForDb,
});
const saved = new EmbedBuilder()
.setColor(0x00ff00)
.setTitle(`✅ Actualizado: ${embedName}`)
.setDescription("Los cambios fueron guardados en la base de datos.");
await editorMessage.edit({
embeds: [saved],
components: [],
});
} catch (e) {
const errorEmbed = new EmbedBuilder()
.setColor(0xff0000)
.setTitle("❌ Error al Guardar")
.setDescription("No se pudo guardar en la base de datos. Revisa la consola.");
await editorMessage.edit({
embeds: [errorEmbed],
components: [],
});
console.error("Error de Prisma al actualizar el embed:", e);
}
collector.stop();
return;
}
// Cancelar
if (i.customId === "cancel_embed") {
await editorMessage.delete();
collector.stop();
return;
}
// Edición
let promptContent = "";
let fieldToEdit: "title" | "description" | "color" | "footer" | null =
null;
switch (i.customId) {
case "edit_title":
promptContent = "Escribe el nuevo **título** (puedes usar variables).";
fieldToEdit = "title";
break;
case "edit_description":
promptContent = "Escribe la nueva **descripción**.";
fieldToEdit = "description";
break;
case "edit_color":
promptContent = "Escribe el nuevo **color** en formato hexadecimal (ej: `#FF0000`).";
fieldToEdit = "color";
break;
case "edit_footer":
promptContent = "Escribe el nuevo **texto del footer**.";
fieldToEdit = "footer";
break;
}
//@ts-ignore
const promptMessage = await i.channel.send(promptContent);
//@ts-ignore
const messageCollector = i.channel!.createMessageCollector({
//@ts-ignore
filter: (m: Message) => m.author.id === i.user.id,
max: 1,
time: 60000,
});
//@ts-ignore
messageCollector.on("collect", async (collectedMessage) => {
const newValue = collectedMessage.content;
if (fieldToEdit === "title") embedState.title = newValue;
if (fieldToEdit === "description") embedState.description = newValue;
if (fieldToEdit === "footer") embedState.footer = newValue;
if (fieldToEdit === "color") {
try {
const hex = newValue.replace("#", "");
embedState.color = parseInt(hex, 16);
} catch {
embedState.color = 0x5865f2;
}
}
await collectedMessage.delete();
await promptMessage.delete();
await editorMessage.edit({
embeds: [await renderPreview()],
components: generateButtonRows(false),
});
});
//@ts-ignore
messageCollector.on("end", async (collected) => {
if (collected.size === 0) {
await promptMessage.delete();
await editorMessage.edit({
components: generateButtonRows(false),
});
}
});
});
collector.on("end", async (_, reason) => {
if (reason === "time") {
const timeoutEmbed = new EmbedBuilder()
.setColor(0xff0000)
.setTitle("Editor finalizado por inactividad.");
await editorMessage.edit({
embeds: [timeoutEmbed],
components: [],
});
}
});
}
},
};

View File

@@ -0,0 +1,34 @@
import { CommandMessage } from "../../../core/types/commands";
export const command: CommandMessage = {
name: "embeddelete",
type: "message",
aliases: ["delembed", "removeembed"],
cooldown: 10,
//@ts-ignore
run: async (message, args, client) => {
if (!message.member?.permissions.has("Administrator")) {
return message.reply("❌ No tienes permisos de Administrador.");
}
const embedName = args[0];
if (!embedName) {
return message.reply("Debes proporcionar el nombre del embed a eliminar. Uso: `!embeddelete <nombre>`");
}
try {
await client.prisma.embedConfig.delete({
where: {
guildId_name: {
guildId: message.guildId!,
name: embedName,
},
},
});
return message.reply(`✅ El embed **${embedName}** fue eliminado con éxito.`);
} catch {
return message.reply("❌ No encontré un embed con ese nombre.");
}
},
};

View File

@@ -0,0 +1,75 @@
import {CommandMessage} from "../../../core/types/commands";
import {
//@ts-ignore
ChannelType,
ContainerBuilder,
//@ts-ignore
MessageFlags,
SectionBuilder,
SeparatorBuilder,
//@ts-ignore
SeparatorSpacingSize,
TextChannel,
TextDisplayBuilder
} from "discord.js";
export const command: CommandMessage = {
name: "embedlist",
type: "message",
aliases: ["listembeds", "embeds"],
cooldown: 10,
//@ts-ignore
run: async (message, args, client) => {
if (!message.member?.permissions.has("Administrator")) {
return message.reply("❌ No tienes permisos de Administrador.");
}
const embeds = await client.prisma.embedConfig.findMany({
where: { guildId: message.guildId! },
});
if (embeds.length === 0) {
return message.reply("📭 No hay ningún embed guardado en este servidor.");
}
const title = new TextDisplayBuilder()
.setContent('﹒⌒    Embed List    ╰୧﹒');
// Combina la lista de embeds en la misma sección que la miniatura
// para un mejor diseño.
//@ts-ignore
const embedListContent = embeds.map((e, i) => `**${i + 1}.** ${e.name}`).join("\n");
// Obtenemos la URL del icono de forma segura
const guildIconURL = message.guild?.iconURL({ forceStatic: false });
// Creamos la sección que contendrá el texto Y la miniatura
const mainSection = new SectionBuilder()
.addTextDisplayComponents(text => text.setContent(embedListContent)); // <--- Componente principal requerido
// Solo añadimos la miniatura si la URL existe
if (guildIconURL) {
//@ts-ignore
mainSection.setThumbnailAccessory(thumbnail => thumbnail
.setURL(guildIconURL)
.setDescription('Icono del servidor')
);
}
const separator = new SeparatorBuilder()
.setSpacing(SeparatorSpacingSize.Large)
.setDivider(false);
const container = new ContainerBuilder()
.setAccentColor(0x49225B)
.addTextDisplayComponents(title)
.addSeparatorComponents(separator)
.addSectionComponents(mainSection); // <--- Añadimos la sección ya completa
if (message.channel.type === ChannelType.GuildText) {
const channel = message.channel as TextChannel;
await channel.send({ components: [container], flags: MessageFlags.IsComponentsV2});
}
},
};

View File

@@ -0,0 +1,11 @@
import {CommandMessage} from "../../../core/types/commands";
export const command: CommandMessage = {
name: 'ping',
type: "message",
aliases: ['latency', 'pong'],
cooldown: 5,
run: async (message, args) => {
await message.reply('pong!')
}
}

View File

@@ -0,0 +1,45 @@
import {CommandMessage} from "../../../core/types/commands";
export const command: CommandMessage = {
name: 'test1',
type: "message",
cooldown: 5,
run: async (message, args) => {
//@ts-ignore
await message.channel.send({
"flags": 32768,
"components": [
{
"type": 17,
"components": [
{
"type": 10,
"content": "## ﹒⌒    🌹 Navegacion 🌹    ╰୧﹒"
},
{
"type": 14,
"spacing": 2,
"divider": false
},
{
"type": 9,
"components": [
{
"type": 10,
"content": "### Reglas dentro del Servidor"
}
],
"accessory": {
"style": 2,
"type": 5,
"label": "Ver",
"url": "https://discord.com/channels/1316592320954630144/1417682278762676264/1417901305434734656",
}
}
],
"accent_color": 4393549
}
]
})
}
}

View File

@@ -0,0 +1,51 @@
import {CommandMessage} from "../../../core/types/commands";
//@ts-ignore
import {
ButtonStyle, ChannelType,
ContainerBuilder,
MessageFlags,
SectionBuilder, SeparatorBuilder, SeparatorSpacingSize, TextChannel,
TextDisplayBuilder,
UserSelectMenuBuilder
} from "discord.js";
export const command: CommandMessage = {
name: 'settings',
type: "message",
aliases: ['options', 'stts'],
cooldown: 5,
run: async (message, args, client) => {
const server = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } });
const title = new TextDisplayBuilder()
.setContent("## ﹒⌒    Settings Seɾveɾ    ╰୧﹒")
const description = new TextDisplayBuilder()
.setContent("Panel de Administracion del bot dentro del servidor.")
const sect = new TextDisplayBuilder()
.setContent("**Prefix del bot:** " + ` \`\`\`${server.prefix}\`\`\``)
const section = new SectionBuilder()
.addTextDisplayComponents(sect)
//@ts-ignore
.setButtonAccessory(button => button
.setCustomId('prefixsettings')
.setLabel('Prefix')
.setStyle(ButtonStyle.Primary),
)
const separator = new SeparatorBuilder()
.setSpacing(SeparatorSpacingSize.Large)
.setDivider(false);
const main = new ContainerBuilder()
.addTextDisplayComponents(title, description)
.addSeparatorComponents(separator)
.addSectionComponents(section)
//@ts-ignore
if (message.channel.type === ChannelType.GuildText) {
const channel = message.channel as TextChannel;
await channel.send({ components: [main], flags: MessageFlags.IsComponentsV2});
}
}
}

View File

@@ -0,0 +1,12 @@
import {CommandSlash} from "../../../core/types/commands";
export const command: CommandSlash = {
name: 'ping',
description: 'Ping',
type: "slash",
cooldown: 10,
run: async (interaction, client) => {
await interaction.reply('pong!')
}
}

View File

@@ -0,0 +1,22 @@
import type {ButtonInteraction} from "discord.js";
//@ts-ignore
import { ActionRowBuilder, Events, ModalBuilder, TextInputBuilder, TextInputStyle } from 'discord.js'
export default {
customId: "prefixsettings",
run: async(interaction: ButtonInteraction) => {
const modal = new ModalBuilder()
.setCustomId('prefixsettingsmodal')
.setTitle('Prefix');
const prefixInput = new TextInputBuilder()
.setCustomId('prefixInput')
.setLabel("Change Prefix")
.setStyle(TextInputStyle.Short);
const secondActionRow = new ActionRowBuilder().addComponents(prefixInput);
modal.addComponents(secondActionRow);
await interaction.showModal(modal);
}
}

View File

@@ -0,0 +1,10 @@
import {ModalSubmitInteraction} from "discord.js";
export default {
customId: "prefixsettingsmodal",
run: async (interaction: ModalSubmitInteraction) => {
const newPrefix = interaction.fields.getTextInputValue("prefixInput")
}
}

View File

@@ -0,0 +1,41 @@
import { REST } from "discord.js";
// @ts-ignore
import { Routes } from "discord-api-types/v10";
import { commands } from "../loader";
export async function registeringCommands(): Promise<void> {
const commandsToRegister: any[] = [];
// Recorremos la Collection que ya cargó loadCommands()
for (const [name, cmd] of commands) {
if (cmd.type === "slash") {
commandsToRegister.push({
name: cmd.name,
description: cmd.description ?? "Sin descripción",
type: 1, // CHAT_INPUT
options: cmd.options ?? []
});
console.log(`✅ Preparado para registrar: ${cmd.name}`);
}
}
const rest = new REST().setToken(process.env.TOKEN ?? "");
try {
console.log(`🚀 Registrando ${commandsToRegister.length} comandos slash...`);
const data: any = await rest.put(
Routes.applicationGuildCommands(
process.env.CLIENT!,
process.env.guildTest!
),
{ body: commandsToRegister }
);
console.log(`${data.length} comandos registrados correctamente.`);
} catch (error) {
console.error("❌ Error registrando comandos:", error);
}
}

49
src/core/client.ts Normal file
View File

@@ -0,0 +1,49 @@
// @ts-ignore
import { Client, GatewayIntentBits } from 'discord.js';
// 1. Importa PrismaClient
// @ts-ignore
import { PrismaClient } from '@prisma/client';
process.loadEnvFile();
class Amayo extends Client {
public key: string;
// 2. Declara la propiedad prisma
public prisma: PrismaClient;
constructor() {
super({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMessageTyping
],
rest: {
retries: 10
}
});
this.key = process.env.TOKEN ?? '';
// 3. Instancia PrismaClient en el constructor
this.prisma = new PrismaClient();
}
async play () {
if(!this.key) {
return console.error('No key provided');
} else {
// Ejemplo de cómo usarías prisma antes de iniciar sesión
try {
await this.prisma.$connect();
console.log('Successfully connected to the database.');
await this.login(this.key);
} catch (error) {
console.error('Failed to connect to the database:', error);
}
}
}
}
export default Amayo;

49
src/core/components.ts Normal file
View File

@@ -0,0 +1,49 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { Collection } from "discord.js";
export const buttons: Collection<string, any> = new Collection<string, any>();
export const modals = new Collection<string, any>();
export const selectmenus = new Collection<string, any>();
export const contextmenus = new Collection<string, any>();
export function loadComponents(dir: string = path.join(__dirname, "..", "components")) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
loadComponents(fullPath); // recursivo
continue;
}
if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
const imported = require(fullPath);
const component = imported.default ?? imported;
if (!component?.customId) {
console.warn(`⚠️ Archivo ignorado: ${file} (no tiene "customId")`);
continue;
}
// Detectamos el tipo según la carpeta en la que está
if (fullPath.includes("buttons")) {
buttons.set(component.customId, component);
console.log(`🔘 Botón cargado: ${component.customId}`);
} else if (fullPath.includes("modals")) {
modals.set(component.customId, component);
console.log(`📄 Modal cargado: ${component.customId}`);
} else if (fullPath.includes("selectmenus")) {
selectmenus.set(component.customId, component);
console.log(`📜 SelectMenu cargado: ${component.customId}`);
} else if (fullPath.includes("contextmenu")) {
contextmenus.set(component.customId, component);
console.log(`📑 ContextMenu cargado: ${component.customId}`);
} else {
console.log(`⚠️ Componente desconocido: ${component.customId}`);
}
}
}

12
src/core/lib/vars.ts Normal file
View File

@@ -0,0 +1,12 @@
import {Guild, User} from "discord.js";
export async function replaceVars(text: string, user: User | undefined, guild:
Guild | undefined, stats: any) {
if(!text) return;
return text
.replace(/(user.name)/g, user!.username ?? '')
.replace(/(user.id)/g, user!.id ?? '')
.replace(/(user.mention)/g, `<@${user!.id}>`)
.replace(/(user.avatar)/g, user!.displayAvatarURL({ forceStatic: false }))
}

43
src/core/loader.ts Normal file
View File

@@ -0,0 +1,43 @@
import * as fs from "node:fs";
import path from "node:path";
import { Collection } from "discord.js";
export const commands = new Collection<string, any>();
export function loadCommands(dir: string = path.join(__dirname, '..', 'commands')) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
loadCommands(fullPath); // recursivo
continue;
}
if (!file.endsWith('.ts')) continue;
const imported = require(fullPath);
const command = imported.command ?? imported.default ?? imported;
if (!command?.data?.name && !command?.name) {
console.warn(`⚠️ Archivo ignorado: ${file} (no es un comando válido)`);
continue;
}
const name = command.data?.name ?? command.name;
console.log(`📦 Loading command: ${name}`);
// @ts-ignore
commands.set(name, command);
if (command.aliases?.length) {
for (const alias of command.aliases) {
commands.set(alias, command);
}
}
console.log(`✅ Cargado comando: ${name}`);
}
}

32
src/core/loaderEvents.ts Normal file
View File

@@ -0,0 +1,32 @@
import { bot } from "../main";
import path from "node:path";
import * as fs from "node:fs";
export function loadEvents(dir: string = path.join(__dirname, "../events")) {
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
loadEvents(fullPath); // recursión para subcarpetas
continue;
}
if (!file.endsWith(".ts") && !file.endsWith(".js")) continue;
const imported = require(fullPath);
const event = imported.default ?? imported;
if (!event?.name || !event?.execute) continue;
if (event.once) {
bot.once(event.name, (...args: any[]) => event.execute(...args));
} else {
bot.on(event.name, (...args: any[]) => event.execute(...args));
}
console.log(`Evento cargado: ${event.name}`);
}
}

13
src/core/redis.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createClient } from "redis";
export const redis = createClient({
url: process.env.REDIS_URL,
})
redis.on("error", (err: any) => console.error("Redis error:", err));
redis.on("connect", () => console.log("✅ Conectado a Redis"));
redis.on("reconnecting", () => console.warn("♻️ Reintentando conexión Redis"));
export async function redisConnect () {
if (!redis.isOpen) await redis.connect();
}

View File

@@ -0,0 +1,19 @@
import type {ChatInputCommandInteraction, Client, Message} from "discord.js";
import Amayo from "../client";
export interface CommandMessage {
name: string;
type: 'message';
aliases?: string[];
cooldown?: number;
run: (message: Message, args: string[], client: Amayo) => Promise<void>;
}
export interface CommandSlash {
name: string;
description: string;
type: 'slash';
options?: string[];
cooldown?: number;
run: (i: ChatInputCommandInteraction, client: Client) => Promise<void>;
}

View File

@@ -0,0 +1,7 @@
import type {ButtonInteraction} from "discord.js";
export interface button {
customId: string;
run: (interaction: ButtonInteraction) => Promise<void>;
}

View File

@@ -0,0 +1,53 @@
import { bot } from "../main";
import type { BaseInteraction } from "discord.js";
import { Events } from "discord.js";
import { redis } from "../core/redis";
import { commands } from "../core/loader";
import { buttons, modals, selectmenus } from "../core/components";
bot.on(Events.InteractionCreate, async (interaction: BaseInteraction) => {
try {
// 🔹 Slash commands
if (interaction.isChatInputCommand()) {
const cmd = commands.get(interaction.commandName);
if (!cmd) return;
const cooldown = Math.floor(Number(cmd.cooldown) || 0);
if (cooldown > 0) {
const key = `cooldown:${cmd.name}:${interaction.user.id}`;
const ttl = await redis.ttl(key);
if (ttl > 0) {
return interaction.reply(`⏳ Espera ${ttl}s antes de volver a usar **${cmd.name}**.`);
}
await redis.set(key, "1", { EX: cooldown });
}
await cmd.run(interaction, bot);
}
// 🔹 Botones
if (interaction.isButton()) {
//@ts-ignore
const btn = buttons.get(interaction.customId);
if (btn) await btn.run(interaction, bot);
}
// 🔹 Select menus
if (interaction.isStringSelectMenu()) {
const menu = selectmenus.get(interaction.customId);
if (menu) await menu.run(interaction, bot);
}
// 🔹 Modales
if (interaction.isModalSubmit()) {
const modal = modals.get(interaction.customId);
if (modal) await modal.run(interaction, bot);
}
} catch (error) {
console.error(error);
if (interaction.isRepliable()) {
await interaction.reply({ content: "❌ Hubo un error ejecutando la interacción.", ephemeral: true });
}
}
});

View File

@@ -0,0 +1,40 @@
import {bot} from "../main";
import {Events} from "discord.js";
import {redis} from "../core/redis";
import {commands} from "../core/loader";
bot.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
const server = await bot.prisma.guild.findFirst({ where: { id: message.guild!.id } }) || "!";
const PREFIX = server.prefix
if (!message.content.startsWith(PREFIX)) return;
const [cmdName, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/);
console.log(cmdName);
const command = commands.get(cmdName);
if (!command) return;
const cooldown = Math.floor(Number(command.cooldown) || 0);
if (cooldown > 0) {
const key = `cooldown:${command.name}:${message.author.id}`;
const ttl = await redis.ttl(key);
console.log(`Key: ${key}, TTL: ${ttl}`);
if (ttl > 0) {
return message.reply(`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`);
}
// SET con expiración correcta para redis v4+
await redis.set(key, "1", { EX: cooldown });
}
try {
await command.run(message, args, message.client);
} catch (error) {
console.error(error);
await message.reply("❌ Hubo un error ejecutando el comando.");
}
})

6
src/events/ready.ts Normal file
View File

@@ -0,0 +1,6 @@
import {bot} from "../main";
import {Events} from "discord.js";
bot.on(Events.ClientReady, () => {
console.log("Ready!");
})

28
src/main.ts Normal file
View File

@@ -0,0 +1,28 @@
import Amayo from "./core/client";
import { loadCommands } from "./core/loader";
import { loadEvents } from "./core/loaderEvents";
import { redisConnect } from "./core/redis";
import { registeringCommands } from "./core/api/discordAPI";
import {loadComponents} from "./core/components";
export const bot = new Amayo();
async function bootstrap() {
console.log("🚀 Iniciando bot...");
loadCommands(); // 1⃣ Cargar comandos en la Collection
loadComponents()
loadEvents(); // 2⃣ Cargar eventos
await registeringCommands(); // 3⃣ Registrar los slash en Discord
await redisConnect(); // 4⃣ Conectar Redis
await bot.play();
console.log("✅ Bot conectado a Discord");
}
bootstrap().catch((err) => {
console.error("❌ Error en el arranque:", err);
process.exit(1);
});