Spraxium logoSpraxium

Botões

Monte botões interativos com IDs estáticos e links de URL para interações confiáveis e orientadas por eventos. Entenda os dois tipos de botão, a ligação com handlers, as opções de estilo, a configuração de emoji e a renderização com ButtonService.

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.

TipoDecoradorTem handlerFormato do ID
Estático@ButtonSim, via @ButtonHandlerString fixa em customId
Link@LinkButtonNão, só abre a URLNã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

CampoTipoObrigatórioFinalidade
customIdstringSimIdentificador único usado no roteamento da interação. Precisa ser único em todo o bot.
urlstringSim, em botões de linkURL aberta quando o botão de link é clicado. Nenhum handler é executado.
labelstringSimTexto exibido no corpo do botão.
styleprimary | secondary | success | dangerNãoEstilo visual. Não se aplica a botões de link. O padrão é primary.
emojistring ou objeto de emoji customizadoNãoEmoji Unicode ou objeto de emoji customizado exibido ao lado do rótulo.
disabledbooleanNãoQuando está como true, o botão aparece apagado e não pode ser clicado.

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.

src/components/buttons/docs-link.button.ts
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étodoAssinaturaFinalidade
buildbuild(input: Class | Class[], context?): ActionRowBuilderRenderiza 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.
rowWithContextrowWithContext(context, ...Classes): ActionRowBuilderAtalho que chama build com o contexto como primeiro argumento e espalha as classes em seguida.
buildMixedRowbuildMixedRow(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.

src/commands/moderation.command-handler.ts
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.

NomeEstilo no DiscordCorQuando usar
primaryButtonStyle.PrimaryAzul DiscordAção padrão e interações de uso geral.
secondaryButtonStyle.SecondaryCinzaAções com menos destaque, como cancelar ou fechar.
successButtonStyle.SuccessVerdeConfirmações e ações com resultado positivo.
dangerButtonStyle.DangerVermelho

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.

src/modules/moderation/moderation.module.ts
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.

src/commands/shop.command-handler.ts
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.

src/commands/open-ticket.command-handler.ts
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().

DecoradorInjetaObservações
@Ctx()ButtonInteractionO objeto bruto de interação do discord.js.
@ButtonPayload()O payload armazenado durante a renderizaçãoTipa o parâmetro com o mesmo tipo usado no método de render. O framework desserializa o valor salvo para você.
@PayloadRef()PayloadHandleUm 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

CampoTipoObrigatórioDescrição
baseIdstringSimPrefixo usado na geração dos custom IDs. Precisa ser único entre todos os botões dinâmicos do bot.
payloadTtlnumber, em segundosNãoTempo 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.

src/modules/shop/shop.module.ts
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:

src/repositories/tickets.repository.ts
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:

src/commands/close-ticket.command-handler.ts
const ticket = await this.tickets.findOpenById(ticketId);

if (ticket?.assignRefs.length) {
  await this.payloads.revokeMany(ticket.assignRefs);
}

Revogando quando o fluxo termina externamente

src/commands/close-ticket.command-handler.ts
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étodoAssinaturaFinalidade
revokerevoke(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.
revokeManyrevokeMany(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.