NapNodes

← Guías de Bots de Discord

Bots de Discord · actualizada el 31 de octubre de 2026

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.

¿Se lo pasas a tu grupo?WhatsApp

Cómo funcionan los botones y los menús

Los botones y los menús van en filas debajo del mensaje: cada fila lleva hasta 5 botones o un solo menú, nunca las dos cosas, y un mensaje normal admite hasta 5 filas. Cada uno lleva un custom_id (de 1 a 100 caracteres) que pones tú: cuando alguien lo pulsa, Discord le manda al bot una interacción con ese custom_id, y así sabe qué se ha pulsado.

Hay cinco estilos de botón que puedes usar: azul (Primary), gris (Secondary), verde (Success), rojo (Danger) y el de enlace (Link), que abre una dirección. El de enlace no tiene custom_id y no le avisa al bot: no hace falta programar nada para él.

El menú de texto (string select) lleva hasta 25 opciones, cada una con su texto (label), su valor (value, lo que recibe el bot) y una descripción opcional. Con min_values y max_values (hasta 25) se deja elegir más de una. Si los botones van en un mensaje con un embed, cómo se monta el embed está en cómo enviar embeds con tu bot; y si aún no tienes comandos de barra, en comandos de barra para tu bot.

¿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.

index.js
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:

app.py
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.

discord.js
// 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: [] });
discord.py
# 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.

app.py
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 publicados

Los 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

Todas las guías de Bots de Discord, ordenadas →