Botones y menús desplegables en tu bot de Discord (discord.js y discord.py)
Los botones y los menús desplegables son lo que hace que un bot se use sin escribir comandos: pasar páginas, elegir un tema, confirmar algo. Aquí tienes un bot completo en discord.js y en discord.py con un /normas de varias páginas y un /ayuda con menú, cómo contestar antes de que Discord diga «Esta interacción ha fallado», cómo hacer que los botones sigan funcionando después de reiniciar el bot y los límites de cada cosa.
¿Prefieres no montarlo tú? Hosting de bots de Discord desde 1,50 € al mes, sin permanencia y con devolución íntegra en 72 horas. O crea un bot de Discord gratis, sin tarjeta.
En discord.js
Con ButtonBuilder y StringSelectMenuBuilder, metidos en un ActionRowBuilder (la fila), y se mandan en la lista components. Al pulsarlos llega el evento InteractionCreate: i.isButton() o i.isStringSelectMenu() dicen qué es, i.customId cuál, e i.values lo que se eligió en el menú.
Este bot publica /normas, con las normas en páginas y los botones «Anterior» y «Siguiente», y /ayuda, con un menú de temas que solo ve quien lo pide. El número de página va dentro del customId (normas:2): así no hay que guardar nada y los botones siguen funcionando aunque reinicies el bot. i.update cambia el mismo mensaje en vez de mandar otro. No necesita ningún intent privilegiado.
require("dotenv").config(); // lee el token del fichero .env
const { ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, EmbedBuilder, Events, GatewayIntentBits, MessageFlags, SlashCommandBuilder, StringSelectMenuBuilder } = require("discord.js");
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Las páginas de /normas: cámbialas por las tuyas
const NORMAS = [
"1. Respeto a todo el mundo.\n2. Nada de spam ni publicidad.",
"3. Sin trampas ni hacks en el servidor de juego.\n4. Los moderadores tienen la última palabra.",
"5. Para reportar algo, abre un ticket.",
];
// Los temas de /ayuda: el value de cada opción es su clave aquí
const TEMAS = {
ip: { label: "Cómo entrar al servidor", texto: "La dirección es play.example.com" },
eventos: { label: "Horario de eventos", texto: "Los eventos son los sábados a las 18:00." },
staff: { label: "Hablar con el staff", texto: "Abre un ticket en #soporte." },
};
const comandos = [
new SlashCommandBuilder().setName("normas").setDescription("Las normas, página a página"),
new SlashCommandBuilder().setName("ayuda").setDescription("Elige un tema de ayuda"),
];
function paginaNormas(n) {
const embed = new EmbedBuilder()
.setTitle("Normas del servidor")
.setDescription(NORMAS[n])
.setFooter({ text: `Página ${n + 1} de ${NORMAS.length}` });
// la página a la que lleva cada botón va en su customId: no hay que guardarla en ningún sitio
const fila = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId(`normas:${n - 1}`).setLabel("Anterior").setStyle(ButtonStyle.Secondary).setDisabled(n === 0),
new ButtonBuilder().setCustomId(`normas:${n + 1}`).setLabel("Siguiente").setStyle(ButtonStyle.Primary).setDisabled(n === NORMAS.length - 1),
);
return { embeds: [embed], components: [fila] };
}
const menuAyuda = new ActionRowBuilder().addComponents(
new StringSelectMenuBuilder()
.setCustomId("ayuda")
.setPlaceholder("¿En qué te ayudo?")
.addOptions(Object.entries(TEMAS).map(([value, t]) => ({ label: t.label, value }))),
);
client.on(Events.InteractionCreate, async (i) => {
if (i.isChatInputCommand() && i.commandName === "normas") {
await i.reply(paginaNormas(0));
} else if (i.isChatInputCommand() && i.commandName === "ayuda") {
await i.reply({ content: "Elige un tema:", components: [menuAyuda], flags: MessageFlags.Ephemeral });
} else if (i.isButton() && i.customId.startsWith("normas:")) {
const n = Math.min(Math.max(Number(i.customId.split(":")[1]) || 0, 0), NORMAS.length - 1);
await i.update(paginaNormas(n)); // cambia el mismo mensaje en vez de mandar otro
} else if (i.isStringSelectMenu() && i.customId === "ayuda") {
const tema = TEMAS[i.values[0]]; // lo elegido: los value de las opciones marcadas
await i.update({ content: `**${tema.label}**\n${tema.texto}`, components: [menuAyuda] });
}
});
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set(comandos.map((x) => x.toJSON())); // publica /normas y /ayuda
console.log(`Conectado como ${c.user.tag}`);
});
client.login(process.env.DISCORD_TOKEN);En discord.py
Con una discord.ui.View: cada botón es un método con @discord.ui.button y cada menú, con @discord.ui.select; el método recibe la interacción y el propio botón o menú (select.values es lo elegido). La vista se manda con view= en send_message, y interaction.response.edit_message cambia el mismo mensaje.
Aquí la página se guarda en la propia vista (self.n), así que la vista caduca: a los 5 minutos sin tocarla deja de escuchar (de serie son 180 segundos) y on_timeout desactiva los botones para que nadie pulse uno que ya no contesta. Para botones que no caduquen nunca, mira la sección de reiniciar el bot. El mismo bot de /normas y /ayuda:
import os
import discord
from discord import app_commands
from dotenv import load_dotenv
load_dotenv() # lee el token del fichero .env
# Las páginas de /normas: cámbialas por las tuyas
NORMAS = [
"1. Respeto a todo el mundo.\n2. Nada de spam ni publicidad.",
"3. Sin trampas ni hacks en el servidor de juego.\n4. Los moderadores tienen la última palabra.",
"5. Para reportar algo, abre un ticket.",
]
# Los temas de /ayuda: el value de cada opción es su clave aquí
TEMAS = {
"ip": ("Cómo entrar al servidor", "La dirección es play.example.com"),
"eventos": ("Horario de eventos", "Los eventos son los sábados a las 18:00."),
"staff": ("Hablar con el staff", "Abre un ticket en #soporte."),
}
client = discord.Client(intents=discord.Intents.default())
tree = app_commands.CommandTree(client)
def pagina_normas(n: int) -> discord.Embed:
embed = discord.Embed(title="Normas del servidor", description=NORMAS[n])
embed.set_footer(text=f"Página {n + 1} de {len(NORMAS)}")
return embed
class Paginas(discord.ui.View):
def __init__(self):
super().__init__(timeout=300) # a los 5 minutos sin tocarlo, deja de escuchar
self.n = 0
self.mensaje = None
self.colocar()
def colocar(self):
self.anterior.disabled = self.n == 0
self.siguiente.disabled = self.n == len(NORMAS) - 1
@discord.ui.button(label="Anterior", style=discord.ButtonStyle.secondary)
async def anterior(self, interaction: discord.Interaction, button: discord.ui.Button):
self.n -= 1
self.colocar()
# cambia el mismo mensaje en vez de mandar otro
await interaction.response.edit_message(embed=pagina_normas(self.n), view=self)
@discord.ui.button(label="Siguiente", style=discord.ButtonStyle.primary)
async def siguiente(self, interaction: discord.Interaction, button: discord.ui.Button):
self.n += 1
self.colocar()
await interaction.response.edit_message(embed=pagina_normas(self.n), view=self)
async def on_timeout(self):
# desactiva los botones para que nadie pulse uno que ya no contesta
for item in self.children:
item.disabled = True
if self.mensaje:
await self.mensaje.edit(view=self)
class Ayuda(discord.ui.View):
@discord.ui.select(
placeholder="¿En qué te ayudo?",
options=[discord.SelectOption(label=titulo, value=clave) for clave, (titulo, _) in TEMAS.items()],
)
async def tema(self, interaction: discord.Interaction, select: discord.ui.Select):
titulo, texto = TEMAS[select.values[0]] # lo elegido: los value de las opciones marcadas
await interaction.response.edit_message(content=f"**{titulo}**\n{texto}", view=self)
@tree.command(name="normas", description="Las normas, página a página")
async def normas(interaction: discord.Interaction):
vista = Paginas()
await interaction.response.send_message(embed=pagina_normas(0), view=vista)
vista.mensaje = await interaction.original_response() # para desactivarlo al caducar
@tree.command(name="ayuda", description="Elige un tema de ayuda")
async def ayuda(interaction: discord.Interaction):
await interaction.response.send_message("Elige un tema:", view=Ayuda(), ephemeral=True)
@client.event
async def on_ready():
await tree.sync() # publica /normas y /ayuda
print(f"Conectado como {client.user}")
client.run(os.environ["DISCORD_TOKEN"])Contestar a tiempo: «Esta interacción ha fallado»
Discord da 3 segundos para contestar a cada pulsación. Si el bot no contesta en ese tiempo, quien ha pulsado ve «Esta interacción ha fallado» y el bot recibe después el error 10062 («Unknown interaction») si intenta contestar tarde. Pasa también si el bot está apagado, si ningún código recoge ese custom_id o si la vista de discord.py ya ha caducado.
Hay tres maneras de contestar: con un mensaje nuevo (i.reply en discord.js, interaction.response.send_message en discord.py; con ephemeral solo lo ve quien ha pulsado), cambiando el mensaje del botón (i.update, interaction.response.edit_message) o diciendo «ya voy» y contestando después (i.deferUpdate(), interaction.response.defer()). Después de avisar quedan 15 minutos para cambiar el mensaje o mandar más.
Solo se contesta una vez: una segunda respuesta da el error 40060 («Interaction has already been acknowledged»). Para mandar algo más, i.followUp en discord.js o interaction.followup.send en discord.py.
// algo que tarda (una base de datos, una web): primero se avisa a Discord…
await i.deferUpdate();
const datos = await algoQueTarda();
// …y después se cambia el mensaje del botón (hay 15 minutos)
await i.editReply({ content: datos, components: [] });# algo que tarda (una base de datos, una web): primero se avisa a Discord…
await interaction.response.defer()
datos = await algo_que_tarda()
# …y después se cambia el mensaje del botón (hay 15 minutos)
await interaction.edit_original_response(content=datos, view=None)Que los botones sigan funcionando al reiniciar el bot
Un mensaje con botones se queda en el canal para siempre, pero el bot solo sabe qué hacer con ellos mientras su código los recoja. En discord.js, si los recoges en InteractionCreate por su customId (como en el ejemplo), funcionan también con mensajes de antes de reiniciar. Los que se recogen con un createMessageComponentCollector o un awaitMessageComponent, no: el colector vive en la memoria del bot y se pierde al reiniciar.
En discord.py, para que una vista no caduque: timeout=None, un custom_id fijo en cada botón y menú, y registrarla al arrancar con add_view. Así el bot vuelve a escuchar los botones de los mensajes que ya estaban publicados (un botón de normas, uno de abrir ticket). Si el custom_id tiene que llevar datos que cambian (un número, un ID), discord.py tiene discord.ui.DynamicItem, que lo lee con una expresión regular.
class Normas(discord.ui.View):
def __init__(self):
super().__init__(timeout=None) # no caduca nunca
# con custom_id fijo, el bot lo reconoce aunque el mensaje sea de antes de reiniciar
@discord.ui.button(label="Acepto las normas", style=discord.ButtonStyle.success, custom_id="normas:acepto")
async def acepto(self, interaction: discord.Interaction, button: discord.ui.Button):
await interaction.response.send_message("¡Gracias!", ephemeral=True)
class Bot(discord.Client):
async def setup_hook(self):
self.add_view(Normas()) # vuelve a escuchar los botones de los mensajes ya publicadosLos límites y los fallos de siempre
Según la documentación de Discord: hasta 5 botones por fila, o un menú solo en su fila; hasta 5 filas en un mensaje normal; el custom_id, de 1 a 100 caracteres; el texto de un botón, hasta 80; un menú, hasta 25 opciones, con texto, valor y descripción de hasta 100 cada uno; el texto de ayuda del menú (placeholder), hasta 150. Pasarse, o repetir el mismo custom_id en dos botones del mismo mensaje, da el error 50035 («Invalid Form Body») y no se manda nada. Un botón de enlace con custom_id, o uno normal sin él, también.
El error 50013 («Missing Permissions») al pulsar un botón no es del botón: es lo que el bot intenta hacer al contestar (dar un rol, borrar un mensaje) sin tener el permiso. Para dar roles, el bot necesita «Gestionar roles» y que su rol esté por encima del que reparte. Los permisos que necesita, con su enlace de invitación, salen en la calculadora de permisos de bot.
Si el botón funciona para uno y para otro no, recuerda que todos pulsan el mismo mensaje: en las páginas de /normas, el que pasa de página la pasa para todos. Si quieres que cada uno vaya a su ritmo, manda el mensaje con ephemeral (solo lo ve quien lo pide), como /ayuda.
Tenerlo encendido siempre
Un botón solo contesta si el bot está encendido: con el bot apagado, cada pulsación es un «Esta interacción ha fallado». Si lo tienes en tu ordenador, se apaga contigo. Opciones gratis y de pago, comparadas, en dónde alojar tu bot de Discord.
En NapNodes hay un bot gratis de 256 MB que no se apaga por estar vacío; se renueva cada 7 días con «Encender» (sus pegas, en esta guía). Créalo, elige Node.js o Python en «Configuración» → «Lenguaje» y sube tus ficheros en «Archivos» (o pon tu repositorio en «Configuración» → «Repositorio de GitHub»); lo que escriba el bot lo ves en «Consola».
Preguntas frecuentes
¿Cómo se pone un botón en un bot de discord.js?
Con new ButtonBuilder().setCustomId("algo").setLabel("Texto").setStyle(ButtonStyle.Primary), dentro de un ActionRowBuilder, y se manda en components. Al pulsarlo llega InteractionCreate con i.isButton() y su i.customId.
¿Cómo se pone un botón en discord.py?
Con una clase que hereda de discord.ui.View y un método con @discord.ui.button(label=…, style=…), y se manda con send_message(…, view=MiVista()).
¿Por qué sale «Esta interacción ha fallado» al pulsar un botón?
Porque el bot no ha contestado en 3 segundos: está apagado, ningún código recoge ese botón, la vista de discord.py ha caducado o tarda demasiado. Si va a tardar, contesta primero con deferUpdate() o defer().
¿Cuántos botones caben en un mensaje de Discord?
5 por fila y hasta 5 filas en un mensaje normal: 25. Un menú desplegable ocupa una fila entera.
¿Los botones siguen funcionando si reinicio el bot?
En discord.js, si los recoges por su customId en InteractionCreate, sí. En discord.py, solo si la vista tiene timeout=None, custom_id fijos y la registras con add_view al arrancar.
¿Te ha servido esta guía?
¿Lo montamos?
Hosting de bots de Discord desde 1,50 € al mes, sin sobreventa: tu RAM es solo tuya. Y si quieres empezar sin pagar, tienes un bot de Discord gratis, sin tarjeta.
Herramienta gratis: Permisos de un bot de Discord. Marca los permisos de tu bot en castellano y sal con el número y el enlace de invitación; o pega un número y mira qué permisos da.
Sigue leyendo
- Cómo enviar embeds con tu bot de Discord (discord.js y discord.py)
Envía embeds con tu bot de Discord: título, color, campos, imágenes y pie en discord.js y discord.py, imágenes adjuntas, editar y los límites de Discord.
- Comandos con barra en tu bot de Discord: opciones, subcomandos y permisos
Comandos con barra (slash commands) en discord.js y discord.py: opciones, subcomandos, permisos, respuestas que tardan, autocompletado y por qué no salen.
- Bot de sorteos para Discord en Python: botón para participar, ganadores al azar y repetir
Haz tu bot de sorteos de Discord en Python: /sorteo con botón para participar, cuenta atrás, ganadores al azar y /resortear. Código completo, sin anuncios.