Tipos de botão
O Spraxium oferece duas variações de botão, e cada uma resolve um problema diferente. Botões estáticos usam um customId fixo e disparam um handler quando o usuário clica. Botões de link apenas abrem uma URL no navegador e não executam nenhuma lógica no bot.
Nos dois casos, a definição nasce em uma classe decorada e a renderização passa pelo ButtonService. Rótulo, estilo, emoji e estado desabilitado ficam descritos no decorator como metadados. Na hora de montar o componente, o renderer lê esses metadados e produz a instância correta de ButtonBuilder do discord.js.
| Tipo | Decorador | Tem handler | Formato do ID |
|---|---|---|---|
| Estático | @Button | Sim, via @ButtonHandler | String fixa em customId |
| Link | @LinkButton | Não, só abre a URL | Não usa customId, usa url |
Botões estáticos
O botão estático é a forma mais direta de criar uma ação clicável. Você decora uma classe com @Button e informa, no mínimo, um customId e um label. Esse customId precisa ser único no bot inteiro, porque o dispatcher faz o roteamento comparando o valor recebido com igualdade exata. O campo style segue os estilos de botão do Discord e assume primary quando você não informa nada.
import { Button } from '@spraxium/components';
@Button({
customId: 'confirm_action',
label: 'Confirm',
style: 'success',
emoji: '✅',
})
export class ConfirmButton {}A classe do handler precisa usar @ButtonHandler e receber a classe do componente como argumento. É isso que cria o vínculo de metadados que o dispatcher resolve durante o bootstrap. No método handle, os parâmetros chegam por decorators: @Ctx() injeta o ButtonInteraction bruto e @FlowContext() injeta o contexto quando ele existir.
Referência de configuração do botão
| Campo | Tipo | Obrigatório | Finalidade |
|---|---|---|---|
| customId | string | Sim | Identificador único usado no roteamento da interação. Precisa ser único em todo o bot. |
| url | string | Sim, em botões de link | URL aberta quando o botão de link é clicado. Nenhum handler é executado. |
| label | string | Sim | Texto exibido no corpo do botão. |
| style | primary | secondary | success | danger | Não | Estilo visual. Não se aplica a botões de link. O padrão é primary. |
| emoji | string ou objeto de emoji customizado | Não | Emoji Unicode ou objeto de emoji customizado exibido ao lado do rótulo. |
| disabled | boolean | Não | Quando está como true, o botão aparece apagado e não pode ser clicado. |
Botões de link
Botões de link abrem uma URL no navegador do usuário sem disparar nenhum handler no bot. Eles são úteis quando você quer levar a pessoa para uma documentação, um painel interno ou qualquer ferramenta externa. Como o Discord exige um estilo próprio para links, o decorator @LinkButton elimina customId e style e trabalha com o campo url.
import { LinkButton } from '@spraxium/components';
@LinkButton({
url: 'https://docs.example.com',
label: 'View Documentation',
emoji: '📘',
})
export class DocsLinkButton {}Você pode misturar botões de link e botões estáticos na mesma action row. Basta passar todas as classes para ButtonService.build, e o renderer escolhe o builder certo para cada uma com base nos metadados.
Renderização com ButtonService
ButtonService é o serviço injetável que transforma classes decoradas em action rows do discord.js. Ele oferece dois métodos para cenários de renderização diferentes. Como tudo é lido por reflection, você não precisa de configuração extra além das classes decoradas.
| Método | Assinatura | Finalidade |
|---|---|---|
| build | build(input: Class | Class[], context?): ActionRowBuilder | Renderiza um ou mais botões estáticos ou de link em uma única action row. Quando o contexto é informado, ele é anexado a todos os botões. |
| rowWithContext | rowWithContext(context, ...Classes): ActionRowBuilder | Atalho que chama build com o contexto como primeiro argumento e espalha as classes em seguida. |
| buildMixedRow | buildMixedRow(entries: {button, items}[], context?): Promise<[ActionRowBuilder, string[]]> | Renderiza múltiplos botões dinâmicos de tipos diferentes em uma única action row em uma única chamada. Retorna a linha e um array plano de todos os refs de payload. Misture livremente botões store-encoded e inline-encoded. Limitado automaticamente a cinco botões. |
O Discord limita cada action row a cinco botões. O serviço lança um erro se você passar mais de cinco classes para build. Se precisar de layouts maiores, monte múltiplas linhas chamando build mais de uma vez e envie cada linha separadamente no 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],
});
}
}Referência de estilos do botão
O campo style aceita um entre quatro valores nomeados que mapeiam para o enum ButtonStyle do Discord. Cada estilo muda a cor e o peso visual da ação. Botões de link sempre usam o estilo de link automaticamente e não aceitam o campo style.
| Nome | Estilo no Discord | Cor | Quando usar |
|---|---|---|---|
| primary | ButtonStyle.Primary | Azul Discord | Ação padrão e interações de uso geral. |
| secondary | ButtonStyle.Secondary | Cinza | Ações com menos destaque, como cancelar ou fechar. |
| success | ButtonStyle.Success | Verde | Confirmações e ações com resultado positivo. |
| danger | ButtonStyle.Danger | Vermelho | Ações destrutivas ou irreversíveis, como banir ou excluir. |
Registro no módulo
Só as classes de handler precisam ser registradas, e elas entram no array handlers do módulo da sua feature. As classes de componente decoradas com @Button ou @LinkButton servem apenas como metadados e nunca são adicionadas a módulo nenhum. O dispatcher lê o customId e a configuração direto da referência de classe armazenada em cada decorator @ButtonHandler, sem precisar instanciar o componente pela 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 {}Botões dinâmicos
Botões estáticos usam um customId fixo, conhecido já na definição da classe. Botões dinâmicos viram essa lógica de cabeça para baixo: o rótulo, o estilo e os dados carregados pelo botão passam a ser calculados a cada renderização, a partir de um payload qualquer. O framework gera o ID automaticamente e embute nele uma referência para o payload armazenado. Quando o usuário clica, o handler recupera esse payload original no store.
O sistema de botões dinâmicos tem três peças: a classe com @DynamicButton, que define o componente e sua lógica de renderização; a classe com @DynamicButtonHandler, que trata o clique; e o store de payload, que faz a ponte entre uma ponta e outra.
Como definir um botão dinâmico
Uma classe de botão dinâmico usa @DynamicButton e precisa expor um método estático render(item) que retorne um ButtonRenderConfig. O baseId da configuração funciona como prefixo no matching das interações recebidas, e o framework acrescenta automaticamente a referência do payload. O payloadTtl é opcional e representa o tempo de vida em segundos. Quando esse tempo expira, cliques posteriores deixam de chegar ao handler. O padrão é 600 segundos, ou 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',
};
}
}Como renderizar um botão dinâmico
Para renderizar um único botão dinâmico em uma action row, chame ButtonService.build passando a classe do componente e o objeto de payload. O serviço chama render(item), grava o payload no store com TTL e devolve um ActionRowBuilder com o custom ID gerado já embutido.
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últiplos botões dinâmicos de tipos diferentes na mesma linha, use ButtonService.buildMixedRow. Ele recebe um array de entradas { button, items } e retorna um único ActionRowBuilder junto a um array plano de todos os refs de payload - um por botão store-encoded, na ordem de declaração. Botões inline-encoded não produzem nenhum 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 os dois botões em uma linha e coleta todos os refs do store.
// AssignTicketButton é store-encoded (payload persistido, ref rastreado para revogação).
// CloseTicketButton é inline-encoded (id do ticket no customId, sem ref).
const [row, refs] = await this.buttons.buildMixedRow([
{ button: AssignTicketButton, items: [ticket] },
{ button: CloseTicketButton, items: [ticket] },
]);
// Persista cada ref para poder revogá-lo quando o ticket for fechado 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 em handlers dinâmicos
Handlers de botão dinâmico aceitam três decorators de parâmetro dentro do método handle().
| Decorador | Injeta | Observações |
|---|---|---|
| @Ctx() | ButtonInteraction | O objeto bruto de interação do discord.js. |
| @ButtonPayload() | O payload armazenado durante a renderização | Tipa o parâmetro com o mesmo tipo usado no método de render. O framework desserializa o valor salvo para você. |
| @PayloadRef() | PayloadHandle | Um handle para controlar o ciclo de vida. Chame o consume do ref com await para apagar o payload de forma definitiva e impedir que cliques seguintes reexecutem o handler. |
@ButtonPayload() e @PayloadRef() funcionam de forma independente. Você pode usar um, o outro, ou os dois juntos. Use @PayloadRef() sozinho quando só precisar consumir o payload. Combine os dois quando precisar dos dados e, ao mesmo tempo, quiser bloquear uma segunda execução.
Referência de configuração do botão dinâmico
| Campo | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| baseId | string | Sim | Prefixo usado na geração dos custom IDs. Precisa ser único entre todos os botões dinâmicos do bot. |
| payloadTtl | number, em segundos | Não | Tempo até o payload expirar no store. Cliques após esse prazo não chegam ao handler. O padrão é 600, ou 10 minutos. |
Registro no módulo
Handlers de botão dinâmico seguem exatamente a mesma regra de registro dos handlers estáticos. Registre só a classe do handler no array handlers do módulo. A classe do componente decorada com @DynamicButton serve apenas como metadado e não precisa aparecer em módulo nenhum.
import { Module } from '@spraxium/common';
import { BuyBookButtonHandler } from './components/book-buy.handler';
@Module({
handlers: [BuyBookButtonHandler],
})
export class ShopModule {}@DynamicStringSelect segue a mesma ideia para menus de seleção: você define uma classe com @DynamicStringSelect, expõe um método estático render(items) que retorna um SelectRenderConfig e cria um handler com @DynamicSelectHandler. Nesse caso, use @SelectPayload() no lugar de @ButtonPayload() para injetar o payload salvo.
Codificação de parâmetros inline
Por padrão, os botões dinâmicos armazenam seu payload em um store do lado do servidor com TTL e embutem apenas uma referência opaca no custom ID. Essa é a escolha certa para objetos grandes ou complexos.
Para dados pequenos e primitivos - como o ID de um único registro de banco de dados - você pode usar encoding: 'inline' em vez disso. O framework codifica os parâmetros diretamente na string do custom ID, eliminando qualquer dependência do store de payload e do TTL completamente. Os dados vivem na mensagem do Discord enquanto essa mensagem existir.
O Discord impõe um limite rígido de 100 caracteres em custom IDs. O
framework mede a string codificada no momento da renderização e lança
DynamicButtonInlinePayloadTooLargeError se o resultado ultrapassar esse
limite. Mantenha os parâmetros inline pequenos: um ou dois IDs curtos ou
flags. Use a codificação baseada em store para qualquer coisa maior.
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 },
};
}
}Note que @ButtonParams() substitui @ButtonPayload() para botões com codificação inline. Usar @ButtonPayload() ou @PayloadRef() em um handler inline causa um erro no bootstrap. O tipo de params é inferido do campo params retornado por render(), então o TypeScript conhece o formato sem nenhum casting manual.
O custom ID codificado para o exemplo acima tem a aparência approve-suggestion~id=42. Os valores são codificados em URL para lidar com caracteres especiais. Números e booleanos são convertidos de volta aos seus tipos originais na decodificação.
Revogando payloads externamente
Quando o payload de um botão dinâmico vive no store, o handler pode consumi-lo com ref.consume() depois de tratar o clique. Mas alguns fluxos precisam revogar um payload antes de qualquer clique ocorrer - por exemplo, fechar um ticket via slash command deveria invalidar imediatamente os botões de aprovar/rejeitar no canal do admin.
Injete ButtonPayloadService para revogar um ou mais refs de payload a partir de qualquer serviço, handler de comando ou listener.
Armazenando os refs no momento da renderização
ButtonService.buildMixedRow retorna uma tupla [ActionRowBuilder, string[]]. O array de refs contém uma entrada por botão store-encoded na ordem de declaração. Botões inline-encoded não produzem nenhum ref. Salve cada ref no banco de dados para poder revogá-lo depois:
interface TicketRecord {
id: string;
subject: string;
openedBy: string;
assignRefs: string[]; // refs de payload para todos os botões 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;
}
}Depois, quando o ticket for fechado de fora de um handler, carregue o registro e revogue todos os refs:
const ticket = await this.tickets.findOpenById(ticketId);
if (ticket?.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}Revogando quando o fluxo 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 não encontrado ou já fechado.', flags: MessageFlags.Ephemeral });
}
// Revogar todos os refs de botões store-encoded - qualquer clique posterior receberá
// a mensagem de expiração configurada em vez de chegar ao handler.
if (ticket.assignRefs.length) {
await this.payloads.revokeMany(ticket.assignRefs);
}
await this.tickets.close(ticketId);
await interaction.reply({ content: `Ticket \`${ticketId}\` fechado.`, flags: MessageFlags.Ephemeral });
}
}Referência da API de ButtonPayloadService
| Método | Assinatura | Finalidade |
|---|---|---|
| revoke | revoke(ref: string): Promise\<void\> | Apaga um único payload do store. Após a revogação, qualquer clique em um botão que carregue esse ref recebe a mensagem de expiração. Resolve em silêncio se o ref não existir mais. |
| revokeMany | revokeMany(refs: string[]): Promise\<void\> | Revogação em lote. Útil para limpar múltiplos botões de uma vez, como todos os botões de ação de um ticket resolvido. Refs ausentes são ignorados em silêncio. |