Tipos de botón
Spraxium ofrece dos variaciones de botón, y cada una resuelve un problema distinto. Botones estáticos usan un customId fijo y disparan un handler cuando el usuario hace clic. Botones de enlace solo abren una URL en el navegador y no ejecutan ninguna lógica en el bot.
En ambos casos, la definición nace en una clase decorada y el renderizado pasa por ButtonService. Etiqueta, estilo, emoji y estado deshabilitado quedan descritos en el decorator como metadatos. Al construir el componente, el renderer lee esos metadatos y produce la instancia correcta de ButtonBuilder de discord.js.
| Tipo | Decorador | Tiene handler | Formato del ID |
|---|---|---|---|
| Estático | @Button | Sí, vía @ButtonHandler | String fija en customId |
| Enlace | @LinkButton | No, solo abre URL | No usa customId, usa url |
Botones estáticos
El botón estático es la forma más directa de crear una acción clicable. Decoras una clase con @Button e informas, como mínimo, un customId y un label. Ese customId debe ser único en todo el bot, porque el dispatcher enruta comparando el valor recibido con igualdad exacta. El campo style sigue los estilos de botón de Discord y asume primary cuando no informas nada.
import { Button } from '@spraxium/components';
@Button({
customId: 'confirm_action',
label: 'Confirm',
style: 'success',
emoji: '✅',
})
export class ConfirmButton {}La clase del handler necesita usar @ButtonHandler y recibir la clase del componente como argumento. Eso crea el vínculo de metadatos que el dispatcher resuelve durante el bootstrap. En el método handle, los parámetros llegan por decorators: @Ctx() inyecta el ButtonInteraction bruto y @FlowContext() inyecta el contexto cuando existe.
Referencia de configuración del botón
| Campo | Tipo | Obligatorio | Finalidad |
|---|---|---|---|
| customId | string | Sí | Identificador único usado en el enrutamiento de la interacción. Debe ser único en todo el bot. |
| url | string | Sí, en botones de enlace | URL abierta cuando se hace clic en el botón de enlace. No se ejecuta ningún handler. |
| label | string | Sí | Texto mostrado en el cuerpo del botón. |
| style | primary | secondary | success | danger | No | Estilo visual. No aplica a botones de enlace. El predeterminado es primary. |
| emoji | string u objeto de emoji personalizado | No | Emoji Unicode u objeto de emoji personalizado mostrado junto a la etiqueta. |
| disabled | boolean | No | Cuando está en true, el botón aparece atenuado y no puede clicarse. |
Botones de enlace
Botones de enlace abren una URL en el navegador del usuario sin disparar ningún handler en el bot. Son útiles cuando quieres llevar a la persona a documentación, un panel interno o cualquier herramienta externa. Como Discord exige un estilo propio para enlaces, el decorator @LinkButton elimina customId y style y trabaja con el campo url.
import { LinkButton } from '@spraxium/components';
@LinkButton({
url: 'https://docs.example.com',
label: 'View Documentation',
emoji: '📖',
})
export class DocsLinkButton {}Puedes mezclar botones de enlace y botones estáticos en la misma action row. Basta pasar todas las clases a ButtonService.build, y el renderer elige el builder correcto para cada una con base en metadatos.
Renderizado con ButtonService
ButtonService es el servicio inyectable que transforma clases decoradas en action rows de discord.js. Ofrece dos métodos para escenarios de renderizado distintos. Como todo se lee por reflection, no necesitas configuración extra además de las clases decoradas.
| Método | Firma | Finalidad |
|---|---|---|
| build | build(input: Class | Class[], context?): ActionRowBuilder | Renderiza uno o más botones estáticos o de enlace en una sola action row. Cuando se informa contexto, se anexa a todos los botones. |
| rowWithContext | rowWithContext(context, ...Classes): ActionRowBuilder | Atajo que llama build con el contexto como primer argumento y expande las clases después. |
| buildMixedRow | buildMixedRow(entries: {button, items}[], context?): Promise<[ActionRowBuilder, string[]]> | Renderiza múltiples botones dinámicos de distintos tipos en una sola action row en una sola llamada. Retorna la fila y un array plano de todos los refs de payload. Mezcla libremente botones store-encoded e inline-encoded. Limitado automáticamente a cinco botones. |
Discord limita cada action row a cinco botones. El servicio lanza error si pasas más de cinco clases a build. Si necesitas layouts mayores, arma múltiples filas llamando build más de una vez y envía cada fila por separado en el array components.
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ButtonService, ContextService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { ConfirmButton } from '../components/buttons/confirm.button';
import { CancelButton } from '../components/buttons/cancel.button';
import { DocsLinkButton } from '../components/buttons/docs-link.button';
import { ModerationCommand } from './moderation.command';
@SlashCommandHandler(ModerationCommand)
export class ModerationCommandHandler {
constructor(
private readonly buttons: ButtonService,
private readonly context: ContextService,
) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const ctx = await this.context.create({ targetUserId: '123456' });
const actionRow = this.buttons.rowWithContext(
ctx,
ConfirmButton,
CancelButton,
);
const linkRow = this.buttons.build(DocsLinkButton);
await interaction.reply({
content: 'Review the moderation action below.',
components: [actionRow, linkRow],
});
}
}Referencia de estilos del botón
El campo style acepta uno de cuatro valores nombrados que mapean al enum ButtonStyle de Discord. Cada estilo cambia color y peso visual de la acción. Botones de enlace siempre usan el estilo de enlace automáticamente y no aceptan style.
| Nombre | Estilo en Discord | Color | Cuándo usar |
|---|---|---|---|
| primary | ButtonStyle.Primary | Azul Discord | Acción estándar e interacciones de uso general. |
| secondary | ButtonStyle.Secondary | Gris | Acciones con menor destaque, como cancelar o cerrar. |
| success | ButtonStyle.Success | Verde | Confirmaciones y acciones con resultado positivo. |
| danger | ButtonStyle.Danger | Rojo | Acciones destructivas o irreversibles, como banear o eliminar. |
Registro en el módulo
Solo las clases de handler deben registrarse, y van en el array handlers del módulo de tu feature. Las clases de componente decoradas con @Button o @LinkButton sirven solo como metadatos y nunca se agregan a ningún módulo. El dispatcher lee customId y configuración directamente de la referencia de clase almacenada en cada decorator @ButtonHandler, sin instanciar el componente por DI.
import { Module } from '@spraxium/common';
import { ConfirmButtonHandler } from './components/confirm.handler';
import { CancelButtonHandler } from './components/cancel.handler';
import { UserBanButtonHandler } from './components/user-ban.handler';
@Module({
handlers: [ConfirmButtonHandler, CancelButtonHandler, UserBanButtonHandler],
})
export class ModerationModule {}Botones dinámicos
Botones estáticos usan un customId fijo, conocido desde la definición de la clase. Botones dinámicos invierten esa lógica: etiqueta, estilo y datos cargados por el botón pasan a calcularse en cada renderizado a partir de cualquier payload. El framework genera el ID automáticamente y embebe en él una referencia al payload almacenado. Cuando el usuario hace clic, el handler recupera ese payload original del store.
El sistema de botones dinámicos tiene tres piezas: la clase con @DynamicButton, que define el componente y su lógica de renderizado; la clase con @DynamicButtonHandler, que trata el clic; y el store de payload, que hace de puente entre ambos extremos.
Cómo definir un botón dinámico
Una clase de botón dinámico usa @DynamicButton y necesita exponer un método estático render(item) que retorne un ButtonRenderConfig. El baseId de la configuración funciona como prefijo en el matching de interacciones recibidas, y el framework agrega automáticamente la referencia del payload. payloadTtl es opcional y representa el tiempo de vida en segundos. Cuando ese tiempo expira, clics posteriores dejan de llegar al handler. El predeterminado es 600 segundos, o 10 minutos.
import { DynamicButton } from '@spraxium/components';
import type { ButtonRenderConfig } from '@spraxium/components';
interface Book {
id: string;
title: string;
}
@DynamicButton({ baseId: 'buy-book', payloadTtl: 300 })
export class BuyBookButton {
static render(book: Book): ButtonRenderConfig {
return {
label: `Buy "${book.title}"`,
style: 'success',
};
}
}Cómo renderizar un botón dinámico
Para renderizar un único botón dinámico en una action row, llama ButtonService.build pasando la clase del componente y el objeto payload. El servicio llama render(item), guarda el payload en el store con TTL y devuelve un ActionRowBuilder con el custom ID generado ya embebido.
import { SlashCommandHandler, Ctx } from '@spraxium/common';
import { ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { BuyBookButton } from '../components/buttons/book-buy.button';
import { ShopCommand } from './shop.command';
@SlashCommandHandler(ShopCommand)
export class ShopCommandHandler {
constructor(private readonly buttons: ButtonService) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
const book = await this.catalog.getBook('978-0-7432-7356-5');
const row = this.buttons.build(BuyBookButton, book);
await interaction.reply({
content: `Found **${book.title}**`,
components: [row],
});
}
}Para combinar múltiples botones dinámicos de distintos tipos en la misma fila, usa ButtonService.buildMixedRow. Recibe un array de entradas { button, items } y retorna un único ActionRowBuilder junto a un array plano de todos los refs de payload - uno por botón store-encoded, en orden de declaración. Los botones inline-encoded no producen ningún ref.
import { Ctx, SlashCommandHandler, SlashStringOption } from '@spraxium/common';
import { ButtonService } from '@spraxium/components';
import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js';
import { OpenTicketCommand } from '../commands/open-ticket.command';
import { AssignTicketButton } from '../components/assign-ticket-button.component';
import { CloseTicketButton } from '../components/close-ticket-button.component';
import { TicketsRepository } from '../tickets.repository';
@SlashCommandHandler(OpenTicketCommand)
export class OpenTicketHandler {
constructor(
private readonly buttons: ButtonService,
private readonly tickets: TicketsRepository,
) {}
async handle(
@Ctx() interaction: ChatInputCommandInteraction,
@SlashStringOption('subject') subject: string,
): Promise<void> {
const ticket = await this.tickets.create({
subject,
openedBy: interaction.user.id,
});
// buildMixedRow renderiza ambos botones en una fila y recolecta todos los refs del store.
// AssignTicketButton es store-encoded (payload persistido, ref guardado para revocación).
// CloseTicketButton es inline-encoded (id del ticket en customId, sin ref).
const [row, refs] = await this.buttons.buildMixedRow([
{ button: AssignTicketButton, items: [ticket] },
{ button: CloseTicketButton, items: [ticket] },
]);
// Persistir cada ref para poder revocarlo cuando el ticket se cierre externamente.
await Promise.all(refs.map((ref) => this.tickets.addAssignRef(ticket.id, ref)));
await interaction.reply({
content: [
`## 🎫 Ticket \`${ticket.id}\``,
`**Subject:** ${ticket.subject}`,
`**Opened by:** <@${ticket.openedBy}>`,
].join('\n'),
components: [row],
flags: MessageFlags.Ephemeral,
});
}
}Decorators de parámetro en handlers dinámicos
Handlers de botón dinámico aceptan tres decorators de parámetro dentro del método handle().
| Decorador | Inyecta | Observaciones |
|---|---|---|
| @Ctx() | ButtonInteraction | El objeto bruto de interacción de discord.js. |
| @ButtonPayload() | El payload almacenado durante el renderizado | Tipa el parámetro con el mismo tipo usado en el método de render. El framework deserializa el valor guardado por ti. |
| @PayloadRef() | PayloadHandle | Un handle para controlar el ciclo de vida. Llama consume en el ref con await para eliminar el payload de forma definitiva e impedir que clics siguientes reejecuten el handler. |
@ButtonPayload() y @PayloadRef() funcionan de forma independiente. Puedes
usar uno, el otro, o ambos juntos. Usa @PayloadRef() solo cuando necesites
únicamente consumir el payload. Combina ambos cuando necesites los datos y, al
mismo tiempo, quieras bloquear una segunda ejecución.
Referencia de configuración del botón dinámico
| Campo | Tipo | Obligatorio | Descripción |
|---|---|---|---|
| baseId | string | Sí | Prefijo usado en la generación de custom IDs. Debe ser único entre todos los botones dinámicos del bot. |
| payloadTtl | number, en segundos | No | Tiempo hasta que expire el payload en el store. Clics después de ese plazo no llegan al handler. El predeterminado es 600, o 10 minutos. |
Registro en el módulo
Handlers de botón dinámico siguen exactamente la misma regla de registro que los handlers estáticos. Registra solo la clase del handler en el array handlers del módulo. La clase del componente decorada con @DynamicButton sirve solo como metadato y no necesita aparecer en ningún módulo.
import { Module } from '@spraxium/common';
import { BuyBookButtonHandler } from './components/book-buy.handler';
@Module({
handlers: [BuyBookButtonHandler],
})
export class ShopModule {}@DynamicStringSelect sigue la misma idea para menús de selección: defines
una clase con @DynamicStringSelect, expones un método estático render(items)
que retorna un SelectRenderConfig y creas un handler con @DynamicSelectHandler.
En ese caso, usa @SelectPayload() en lugar de @ButtonPayload() para inyectar
el payload guardado.
Codificación de parámetros inline
Por defecto, los botones dinámicos guardan su payload en un store del lado del servidor con TTL y embeben solo una referencia opaca en el custom ID. Esa es la opción correcta cuando el objeto es grande o complejo.
Para datos pequeños y primitivos - como el ID de un único registro de base de datos - puedes usar encoding: 'inline' en su lugar. El framework codifica los parámetros directamente en la cadena del custom ID, eliminando cualquier dependencia del store de payload y del TTL por completo. Los datos viven en el mensaje de Discord mientras ese mensaje exista.
Discord impone un límite estricto de 100 caracteres en los custom IDs. El
framework mide la cadena codificada en el momento del renderizado y lanza
DynamicButtonInlinePayloadTooLargeError si el resultado superaría ese límite.
Manten los parámetros inline pequeños: uno o dos IDs cortos o flags. Usa la
codificación basada en store para cualquier cosa más grande.
import { DynamicButton } from '@spraxium/components';
import type { ButtonRenderConfig } from '@spraxium/components';
interface Suggestion {
id: number;
userId: string;
}
@DynamicButton({ baseId: 'approve-suggestion', encoding: 'inline' })
export class ApproveSuggestionButton {
static render(s: Suggestion): ButtonRenderConfig {
return {
label: 'Approve',
style: 'success',
params: { id: s.id },
};
}
}Nota que @ButtonParams() reemplaza a @ButtonPayload() para botones con codificación inline. Usar @ButtonPayload() o @PayloadRef() en un handler inline provoca un error durante el bootstrap. El tipo de params se infiere del campo params retornado por render(), por lo que TypeScript conoce la forma sin necesidad de ningún casting manual.
El custom ID codificado para el ejemplo anterior tiene el aspecto approve-suggestion~id=42. Los valores están codificados en formato URL para manejar caracteres especiales. Los números y booleanos se convierten de vuelta a sus tipos originales en la decodificación.
Revocar payloads externamente
Cuando el payload de un botón dinámico vive en el store, el handler puede consumirlo con ref.consume() después de manejar el clic. Pero algunos flujos requieren revocar un payload antes de que ocurra cualquier clic - por ejemplo, cerrar un ticket desde un slash command debería invalidar de inmediato los botones de aprobar/rechazar en el canal de administración.
Inyecta ButtonPayloadService para revocar uno o más refs de payload desde cualquier servicio, handler de comando o listener.
Almacenar los refs al momento del renderizado
ButtonService.buildMixedRow retorna una tupla [ActionRowBuilder, string[]]. El array de refs contiene una entrada por botón store-encoded en orden de declaración. Los botones inline-encoded no producen ningún ref. Guarda cada ref en tu base de datos para poder revocarlo después:
interface TicketRecord {
id: string;
subject: string;
openedBy: string;
assignRefs: string[]; // refs de payload para todos los botones store-encoded
status: 'open' | 'closed';
}
export class TicketsRepository {
async create(data: Pick<TicketRecord, 'subject' | 'openedBy'>): Promise<TicketRecord> {
// INSERT INTO tickets (...)
return { id: 'ticket-uuid', assignRefs: [], status: 'open', ...data };
}
async addAssignRef(ticketId: string, ref: string): Promise<void> {
// UPDATE tickets SET assign_refs = JSON_ARRAY_APPEND(assign_refs, '$', ?) WHERE id = ?
}
async findOpenById(ticketId: string): Promise<TicketRecord | null> {
// SELECT ... WHERE id = ? AND status = 'open'
return null;
}
}Después, cuando el ticket se cierre desde fuera de un handler, carga el registro y revoca todos los refs:
const ticket = await this.tickets.findOpenById(ticketId);
if (ticket?.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}Revocar cuando el flujo termina externamente
import { SlashCommandHandler, Ctx, SlashStringOption } from '@spraxium/common';
import { ButtonPayloadService } from '@spraxium/components';
import { type ChatInputCommandInteraction, MessageFlags } from 'discord.js';
import { CloseTicketCommand } from '../commands/close-ticket.command';
import { TicketsRepository } from '../tickets.repository';
@SlashCommandHandler(CloseTicketCommand)
export class CloseTicketHandler {
constructor(
private readonly payloads: ButtonPayloadService,
private readonly tickets: TicketsRepository,
) {}
async handle(
@Ctx() interaction: ChatInputCommandInteraction,
@SlashStringOption('ticket-id') ticketId: string,
): Promise<void> {
const ticket = await this.tickets.findOpenById(ticketId);
if (!ticket) {
return interaction.reply({ content: 'Ticket no encontrado o ya cerrado.', flags: MessageFlags.Ephemeral });
}
// Revocar todos los refs de botones store-encoded - cualquier clic posterior recibirá
// el mensaje de expiración configurado en lugar de llegar al handler.
if (ticket.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}
await this.tickets.close(ticketId);
await interaction.reply({ content: `Ticket \`${ticketId}\` cerrado.`, flags: MessageFlags.Ephemeral });
}
}Referencia de API de ButtonPayloadService
| Método | Firma | Finalidad |
|---|---|---|
| revoke | revoke(ref: string): Promise\<void\> | Elimina un payload individual del store. Tras la revocación, cualquier clic en un botón que lleve este ref recibe el mensaje de expiración. Resuelve en silencio si el ref ya no existe. |
| revokeMany | revokeMany(refs: string[]): Promise\<void\> | Revocación en lote. Útil para limpiar múltiples botones a la vez, por ejemplo todos los botones de acción de un ticket resuelto. Los refs faltantes se ignoran en silencio. |