Spraxium logoSpraxium

Containers V2

Monte mensagens ricas no Discord usando o sistema de containers V2. Aprenda o padrão de schema com classes, a API fluente, filhos dinâmicos, renderização condicional e o uso de V2Service.

O que são containers V2

A API Components V2 do Discord substitui o modelo tradicional de layout de mensagem por um sistema de blocos componíveis. Em vez de tratar a mensagem como uma combinação plana de conteúdo, embeds e linhas de componente, o V2 permite juntar blocos de texto, seções, galerias de mídia, separadores e action rows dentro de um único container. O container passa a ser a unidade principal de layout da mensagem.

O sistema V2 do Spraxium encapsula essa API usando a mesma arquitetura de decorator e handler presente no restante de @spraxium/components. Você declara o schema do container como uma classe decorada, anota cada elemento estrutural com um decorator de propriedade e renderiza tudo pelo V2Service, passando os dados de runtime. O resultado é um layout fortemente tipado e componível, em que a estrutura fica separada dos dados que fluem por ela.

O pacote oferece dois estilos de autoria para V2. O padrão baseado em classe usa @V2Container com decorators de propriedade e funciona melhor em layouts reaproveitáveis guiados por dados. A API fluente é um builder imperativo, encadeado em uma instância de V2ContainerFluentBuilder, e costuma ser melhor para mensagens pontuais ou montadas dinamicamente. Os dois estilos produzem o mesmo formato final e podem coexistir no mesmo bot.

Quando usar containers V2

Containers V2 fazem sentido sempre que a mensagem precisa de um layout mais estruturado do que um embed simples consegue oferecer. Casos comuns incluem leaderboards com listas de tamanho variável, painéis de dashboard com thumbnail e grade de estatísticas, perfis com avatar ao lado de vários blocos de texto e galerias de imagem dentro da resposta do comando. Sempre que a mensagem se beneficia de uma separação visual mais clara entre seções, ou quando você percebe que precisaria empilhar vários embeds para chegar perto do resultado, provavelmente um container V2 é a escolha certa.

Containers V2 exigem a flag MessageFlags.IsComponentsV2 no envio da mensagem. Quando essa flag está presente, a mensagem não pode carregar ao mesmo tempo embeds tradicionais nem um conteúdo de texto simples ao lado dos containers. Em outras palavras, trate mensagens V2 como layouts completos e autocontidos, não como um enfeite em cima de conteúdo já existente.

Configurando o V2Service

V2Service é o serviço responsável por renderizar e construir tudo que envolve V2. Ele fica disponível para injeção automaticamente assim que ComponentsModule entra no grafo de módulos. Fora a configuração padrão do plugin defineComponents, você não precisa de nenhum ajuste extra.

Injete V2Service em qualquer handler ou serviço que precise renderizar containers V2.

src/handlers/my.handler.ts
import { V2Service } from '@spraxium/components';

@SlashCommandHandler(MyCommand)
export class MyHandler {
  constructor(private readonly v2: V2Service) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    // ...
  }
}

Schemas de container baseados em classe

No padrão baseado em classe, o container é declarado como uma classe anotada. Você aplica @V2Container à classe para marcá-la como container e configurar opções como cor de destaque e spoiler. Em seguida, usa decorators de propriedade para registrar os filhos na ordem de declaração. Em runtime, V2Service.build lê esses metadados, avalia as factory functions com os dados recebidos e monta o container final.

Os nomes das propriedades na classe não têm relevância em runtime. O que realmente importa é o metadado registrado pelos decorators. Por isso, é comum declarar o tipo da propriedade como never, sinalizando que aquele campo existe apenas como slot de metadado.

import {
  V2Container,
  V2Separator,
  V2Section,
  V2Text,
  desc,
} from '@spraxium/components';

export interface UserProfileData {
  username: string;
  userId: string;
  joinedAt: number;
  avatarUrl: string;
  bio: string;
}

@V2Container({ accentColor: 0x5865f2 })
export class UserProfileContainer {
  @V2Text((data: UserProfileData) => desc().h2(`👤 ${data.username}`))
  title!: never;

  @V2Separator()
  divider!: never;

  @V2Section({
    text: (data: UserProfileData) =>
      [
        `**ID:** \`${data.userId}\``,
        `**Joined:** <t:${Math.floor(data.joinedAt / 1000)}:R>`,
        `**Bio:** ${data.bio}`,
      ].join('\n'),
    thumbnail: {
      url: (data: UserProfileData) => data.avatarUrl,
      description: 'User avatar',
    },
  })
  stats!: never;

  @V2Separator({ divider: false })
  spacer!: never;

  @V2Text((data: UserProfileData) => desc().subtext(`Profile for ${data.username}`))
  footer!: never;
}

API fluente

A API fluente é um builder encadeável preso ao V2Service. Chame v2.container() para abrir o builder, encadeie as chamadas dos filhos e finalize com .toReply() para produzir o payload pronto para interaction.reply(). Cada método devolve o próprio builder, então tudo pode ficar inline se isso fizer sentido para a mensagem.

A API fluente não oferece suporte a decorators de propriedade nem a condições com @V2When, porque não existe metadado de classe para anotar. Sempre que você precisar de renderização condicional ou quiser reaproveitar o mesmo layout em vários handlers, prefira o padrão baseado em classe.

src/handlers/greet.handler.ts
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { V2Service, desc } from '@spraxium/components';
import { MessageFlags, type ChatInputCommandInteraction } from 'discord.js';
import { GreetCommand } from '../commands/greet.command';

@SlashCommandHandler(GreetCommand)
export class GreetHandler {
  constructor(private readonly v2: V2Service) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const user = interaction.user;

    const payload = this.v2
      .container({ accentColor: 0x57f287 })
      .text(desc().h2(`👋 Hello, ${user.username}!`))
      .sep()
      .section(
        [
          `**ID:** \`${user.id}\``,
          `**Account age:** <t:${Math.floor(user.createdTimestamp / 1000)}:R>`,
        ].join("\n"),
        this.v2.thumbnail(user.displayAvatarURL({ size: 128 }), {
          description: `${user.username} avatar`,
        }),
      )
      .sep({ divider: false })
      .text(desc().subtext('Powered by Spraxium V2'))
      .toReply();

    await interaction.reply({
      ...payload,
      flags: payload.flags | MessageFlags.Ephemeral,
    });
  }
}

Referência da API de V2Service

MétodoRetornaFinalidade
build(ContainerClass, data?)Promise<ContainerBuilder>Renderiza de forma assíncrona um schema baseado em classe em um ContainerBuilder do discord.js.
buildReply(ContainerClass, data?)Promise<V2ReplyPayload>Renderiza de forma assíncrona e já empacota o resultado em um payload pronto para reply com a flag IsComponentsV2.
buildSync(ContainerClass, data?)ContainerBuilderCaminho síncrono para schemas que não exigem resolução assíncrona de filhos.
buildReplySync(ContainerClass, data?)V2ReplyPayloadBuilder síncrono de payload de resposta, útil para containers V2 totalmente estáticos.
container(opts?)V2ContainerFluentBuilderAbre um builder fluente para construção imperativa do container.
text(content)TextDisplayBuilderCria um bloco de texto independente. Aceita string ou DescriptionBuilder.
separator(config?)SeparatorBuilderCria um separador. Use a opção divider como false quando quiser apenas espaçamento invisível.
thumbnail(url, opts?)ThumbnailBuilderCria um thumbnail pronto para ser usado como acessório em uma section.
section(text, accessory?)SectionBuilderCria uma section com texto e, opcionalmente, um acessório de thumbnail ou botão.
gallery(items)MediaGalleryBuilderCria uma galeria de mídia a partir de um array de itens com URL, descrição opcional e estado opcional de spoiler.

Decorators de propriedade

DecoradorTipo de filhoDescrição
@V2Text(content)Bloco de textoRenderiza um TextDisplayBuilder com a string informada ou com a saída de um DescriptionBuilder. Também aceita factory com dados de runtime.
@V2Section(config)SectionLayout lado a lado com um bloco de texto e um acessório que pode ser thumbnail ou botão. Texto e URL do thumbnail podem vir de factory.
@V2Separator(config?)SeparadorLinha horizontal. Passe divider como false para criar um espaçador invisível. Use spacing 1 ou 2 para controlar o tamanho do espaço.
@V2Row(config)Action rowEmbute uma linha de botões ou um select menu dentro do container. Passe classes de componente no array components. Uma classe de select é detectada automaticamente.
@V2MediaGallery(items)Galeria de mídiaRenderiza uma galeria de mídia. Os itens podem vir de um array estático ou de uma factory que retorna objetos de mídia.
@V2File(config)Anexo de arquivoAnexa uma URL de arquivo dentro do container. A URL pode vir de factory. Use spoiler como true para esconder a prévia até o clique do usuário.
@V2Dynamic(factory)Filhos dinâmicosChama uma factory com os dados de runtime para produzir uma lista variável de specs de filhos. A próxima seção cobre isso em detalhe.
@V2When(predicate)CondiçãoÉ aplicado depois do decorator do filho. Quando o predicado retorna false, o filho é omitido do container renderizado.

Filhos dinâmicos com @V2Dynamic

@V2Dynamic resolve os casos em que a quantidade de filhos depende dos dados de runtime. Em vez de fornecer uma configuração estática, você passa uma factory que recebe o objeto de dados e retorna um array de child specs criados com helpers como v2text, v2sep, v2section, v2gallery e v2row. O motor percorre esse array e renderiza cada spec na ordem.

Um padrão comum é gerar uma ou mais linhas de texto por item e inserir separadores entre elas, tomando cuidado para não deixar um separador sobrando depois do último item.

src/schemas/leaderboard.container.ts
import {
  V2Container,
  V2Dynamic,
  V2Separator,
  V2Text,
  desc,
  v2sep,
  v2text,
} from '@spraxium/components';

export interface LeaderboardEntry {
  username: string;
  score: number;
  rank: number;
}

export interface LeaderboardData {
  title: string;
  entries: Array<LeaderboardEntry>;
}

const MEDALS = ['🥇', '🥈', '🥉'];

@V2Container({ accentColor: 0xf1c40f })
export class LeaderboardContainer {
  @V2Text((data: LeaderboardData) => desc().h2(`🏆 ${data.title}`))
  title!: never;

  @V2Separator()
  divider!: never;

  @V2Dynamic((data: LeaderboardData) => {
    if (data.entries.length === 0) {
      return [v2text(desc().subtext('No entries yet.'))];
    }

    return data.entries.flatMap((entry, index) => {
      const medal = MEDALS[index] ?? `**${entry.rank}.**`;
      return [
        v2text(
          `${medal} **${entry.username}**, ${entry.score.toLocaleString()} pts`,
        ),
        ...(index < data.entries.length - 1 ? [v2sep({ divider: false })] : []),
      ];
    });
  })
  entries!: never;

  @V2Separator({ divider: false })
  footer!: never;

  @V2Text((data: LeaderboardData) =>
    desc().subtext(`${data.entries.length} players ranked`),
  )
  summary!: never;
}

O handler passa a lista de entradas como dado e chama buildReply sem precisar saber quantas linhas serão geradas no final.

src/handlers/leaderboard.handler.ts
@SlashCommandHandler(LeaderboardCommand)
export class LeaderboardHandler {
  constructor(private readonly v2: V2Service) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const entries: Array<LeaderboardEntry> = await fetchTopPlayers(10);

    const payload = await this.v2.buildReply(LeaderboardContainer, {
      title: 'Weekly Top Players',
      entries,
    });

    await interaction.reply(payload);
  }
}

Renderização condicional com @V2When

@V2When inclui um filho de forma condicional com base nos dados de runtime. Ele é aplicado como um decorator adicional abaixo do decorator do filho, ou seja, roda por último e adiciona uma guarda à definição já existente. Quando o predicado retorna false para aquele conjunto de dados, o filho é removido do container por completo, não apenas renderizado vazio.

@V2When é empilhável. Você pode ter vários filhos diferentes no mesmo container, cada um com a própria condição. A ordem registrada para os elementos continua preservada, independentemente de quais filhos acabem filtrados.

src/schemas/member-card.container.ts
import {
  V2Container,
  V2Separator,
  V2Text,
  V2When,
  desc,
} from '@spraxium/components';

export interface MemberCardData {
  username: string;
  boostLevel: number;
  isAdmin: boolean;
  bio: string;
}

@V2Container({ accentColor: 0x5865f2 })
export class MemberCardContainer {
  @V2Text((data: MemberCardData) => desc().h2(data.username))
  title!: never;

  @V2Separator()
  divider!: never;

  @V2When((data: MemberCardData) => data.isAdmin)
  @V2Text('🛡️ **Server Administrator**')
  adminBadge!: never;

  @V2When((data: MemberCardData) => data.boostLevel > 0)
  @V2Text(
    (data: MemberCardData) => `⭐ **Server Booster**, Level ${data.boostLevel}`,
  )
  boostBadge!: never;

  @V2Text((data: MemberCardData) => data.bio)
  bio!: never;
}

Quando isAdmin é false, a linha do selo de administrador simplesmente não é renderizada. Os demais filhos se reorganizam naturalmente, sem deixar espaço sobrando.

Embutindo action rows com @V2Row

@V2Row coloca uma linha de botões ou um select menu diretamente dentro de um container V2. Passe as classes de componente no array components, exatamente como faria com ButtonService ou SelectService. O serviço detecta sozinho se a primeira classe é um select menu e encaminha a construção para o builder correto.

import {
  Button,
  LinkButton,
  V2Container,
  V2Row,
  V2Separator,
  V2Text,
  desc,
} from '@spraxium/components';

@Button({ customId: 'panel_confirm', label: 'Confirm', style: 'success', emoji: '✅' })
export class ConfirmButton {}

@Button({ customId: 'panel_cancel', label: 'Cancel', style: 'danger', emoji: '❌' })
export class CancelButton {}

@V2Container({ accentColor: 0xed4245 })
export class ConfirmPanelContainer {
  @V2Text((data: { action: string }) => desc().h2(`Confirm: ${data.action}`))
  title!: never;

  @V2Text('This action cannot be undone. Are you sure?')
  body!: never;

  @V2Separator({ divider: false })
  spacer!: never;

  @V2Row({ components: [ConfirmButton, CancelButton] })
  actions!: never;
}

@V2DynamicRow

@V2DynamicRow quebra automaticamente uma lista de itens ou classes de botão em runtime em linhas de até cinco itens dentro do container. É o equivalente em V2 a chamar ButtonService.build várias vezes, mas guiado pelos dados, não por uma lista estática fixa.

Existem dois modos:

Modo dinâmico: combine uma classe @DynamicButton com um accessor de dados que produza a lista de itens. O Spraxium chama o método estático render(item) do botão para cada item, armazena cada item como payload e agrupa os botões gerados em linhas de cinco. Esse é o caso principal: uma classe de botão, N itens, N botões tratados por interação.

src/modules/leaderboard/components/leaderboard.container.ts
import { V2Container, V2Separator, V2Text, V2DynamicRow, desc } from '@spraxium/components';
import type { LeaderboardData } from '../leaderboard.data';
import { PlayerButton } from './player-button.component';

@V2Container({ accentColor: 0x5865f2 })
export class LeaderboardContainer {
  @V2Text((data: LeaderboardData) => desc().h2(`🏆 ${data.title}`))
  title!: never;

  @V2Separator()
  divider!: never;

  @V2DynamicRow({ dynamic: PlayerButton, items: (data: LeaderboardData) => data.players })
  playerButtons!: never;
}

Modo components: forneça uma factory que devolva um array de classes de botão estáticas. O Spraxium renderiza todas elas em linhas de cinco, sem payloads envolvidos. Use esse modo quando o conjunto de botões varia, mas cada botão ainda tem customId fixo.

src/modules/quick-actions/components/quick-actions.container.ts
import { V2Container, V2DynamicRow, V2Row, V2Text, desc } from '@spraxium/components';
import type { QuickActionsData } from '../quick-actions.data';
import {
  ArchiveButton,
  DeleteButton,
  ExportButton,
  MuteButton,
  PinButton,
  RefreshButton,
  ShareButton,
} from './quick-actions.components';
import { ModeSelect } from './quick-actions.components';

@V2Container({ accentColor: 0x57f287 })
export class QuickActionsContainer {
  @V2Text((data: QuickActionsData) => desc().h2(`⚡ Quick actions for ${data.resource}`))
  title!: never;

  @V2DynamicRow({
    components: () => [
      RefreshButton,
      ExportButton,
      ShareButton,
      ArchiveButton,
      DeleteButton,
      PinButton,
      MuteButton,
    ],
  })
  actions!: never;

  @V2Row({ components: [ModeSelect] })
  mode!: never;
}

@V2DynamicRow com dynamic: só funciona com classes @DynamicButton, não com botões estáticos. Para quebrar botões estáticos em blocos, use a forma de factory em components:.

DescriptionBuilder

A função helper desc() devolve um DescriptionBuilder que encadeia helpers de Markdown no estilo do Discord. Ele pode ser usado tanto no padrão baseado em classe quanto na API fluente, em qualquer lugar em que um valor de texto seja aceito. O builder funciona como um acumulador simples de string. Ao chamar .toString(), você recebe o conteúdo final, embora o próprio motor de V2 já faça isso automaticamente antes de passar o texto para os builders do discord.js.

MétodoSaída
.h2(text)Gera um heading interno no formato de heading nível 2 do Markdown.
.subtext(text)Gera um subtexto menor no formato de subtexto curto do Markdown.
.bold(text)Produz texto em negrito no Markdown.
.italic(text)Produz texto em itálico no Markdown.
.code(text)Produz texto em código inline.
.line(text?)Acrescenta texto seguido por quebra de linha.

Configuração do container

O decorator @V2Container aceita um objeto V2ContainerMeta.

OpçãoTipoPadrãoDescrição
accentColornumber | stringNenhum

Cor da faixa lateral de destaque. Aceita um número hexadecimal como 0x5865f2 ou uma string hexadecimal como #5865F2.

spoilerbooleanfalse

Marca o container inteiro como spoiler, exigindo clique do usuário para revelar o conteúdo.

Montando o payload de resposta

Tanto buildReply quanto o método fluente .toReply() devolvem um objeto { components, flags }. Quando você espalha esse payload em interaction.reply(), a flag IsComponentsV2 já é configurada automaticamente. Se precisar de flags adicionais, basta combiná-las com OR bit a bit.

sending a V2 reply
const payload = await this.v2.buildReply(MyContainer, data);

await interaction.reply({
  ...payload,
  flags: payload.flags | MessageFlags.Ephemeral,
});

Se você precisar atualizar uma mensagem V2 já existente, use interaction.editReply() ou passe o payload direto para message.edit(). A flag é necessária tanto no envio inicial quanto nas edições seguintes, porque o Discord identifica uma mensagem V2 justamente pela presença dessa flag.

Sistema dinâmico e guia de migração

No modelo atual, o ecossistema V2 foi além da composição visual e ganhou um modelo completo para interações dinâmicas com botões e selects. A ideia central é que os dados de runtime passam a viajar com cada elemento interativo por referência de payload. Com isso, o handler recebe objetos tipados de domínio diretamente, sem depender de serialização manual em custom IDs.

Esse modelo é especialmente útil em catálogos, painéis operacionais, filas de moderação e qualquer fluxo em que você renderiza listas variáveis de ações e precisa preservar contexto de negócio por item.

DynamicButton com payload tipado

Use @DynamicButton para declarar a renderização por item e @DynamicButtonHandler para tratar a interação. Injete o payload original com @ButtonPayload() e, quando o fluxo for one-shot, injete @PayloadRef() para chamar consume().

src/modules/shop/buttons/buy-button.component.ts
import {
  ButtonPayload,
  DynamicButton,
  DynamicButtonHandler,
  PayloadRef,
  type PayloadHandle,
} from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { ButtonInteraction } from 'discord.js';

interface Product {
  id: string;
  name: string;
  price: number;
}

@DynamicButton({ baseId: 'shop-buy', payloadTtl: 300 })
export class BuyButton {
  static render(product: Product) {
    return {
      label: `Buy ${product.name}`,
      style: 'success' as const,
      emoji: '🛒',
    };
  }
}

@DynamicButtonHandler(BuyButton)
export class BuyButtonHandler {
  async handle(
    @Ctx() interaction: ButtonInteraction,
    @ButtonPayload() product: Product,
    @PayloadRef() payload: PayloadHandle,
  ): Promise<void> {
    await payload.consume();

    await interaction.reply({
      content: `Purchase confirmed: ${product.name} ($${product.price}).`,
      flags: 'Ephemeral',
    });
  }
}

DynamicStringSelect com dados acoplados

Com @DynamicStringSelect e @DynamicSelectHandler, o select pode ser montado com dados de runtime e o handler recebe tanto os valores selecionados (@SelectedValues()) quanto o payload original (@SelectPayload()). Esse padrão mantém apresentação e domínio sincronizados sem parsing extra.

src/modules/tickets/selects/category-select.component.ts
import {
  DynamicStringSelect,
  DynamicSelectHandler,
  SelectPayload,
  SelectedValues,
} from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { StringSelectMenuInteraction } from 'discord.js';

interface TicketCategory {
  id: string;
  name: string;
}

@DynamicStringSelect({ baseId: 'ticket-category', placeholder: 'Choose a category', payloadTtl: 300 })
export class TicketCategorySelect {
  static render(categories: TicketCategory[]) {
    return {
      options: categories.map((category) => ({
        label: category.name,
        value: category.id,
      })),
      minValues: 1,
      maxValues: 1,
    };
  }
}

@DynamicSelectHandler(TicketCategorySelect)
export class TicketCategorySelectHandler {
  async handle(
    @Ctx() interaction: StringSelectMenuInteraction,
    @SelectedValues() values: string[],
    @SelectPayload() categories: TicketCategory[],
  ): Promise<void> {
    const selected = categories.find((category) => category.id === values[0]);

    await interaction.update({
      content: `Ticket category selected: ${selected?.name ?? 'Unknown'}`,
      components: [],
    });
  }
}

O que exatamente são payload e payload ref

No sistema dinâmico atual, payload é o dado de domínio original usado para renderizar o componente interativo. Em vez de serializar tudo no customId, o Spraxium salva esse dado no store de contexto e coloca no customId apenas uma referência curta no formato ~p:<id>.

Em outras palavras:

  1. Você renderiza botões/selects dinâmicos com dados reais (produto, ticket, usuário, etc.).
  2. O framework salva cada payload com TTL e anexa ~p:<id> no customId final.
  3. Na interação, o dispatcher resolve esse ID e injeta o objeto tipado no handler.

Isso reduz parsing manual de string e evita customId inchado.

Diferença prática entre @ButtonPayload, @SelectPayload e @PayloadRef

@ButtonPayload() e @SelectPayload() injetam o objeto de dados associado àquela interação. @PayloadRef() injeta um handle de ciclo de vida (PayloadHandle) com:

  • id: o identificador bruto do payload.
  • consume(): remove o payload do store para impedir reuso.

Use @PayloadRef() quando a ação deve ser one-shot (compra, confirmação irreversível, consumo de cupom, etc.).

src/modules/shop/buttons/buy-button.handler.ts
import { ButtonPayload, DynamicButtonHandler, PayloadRef, type PayloadHandle } from '@spraxium/components';
import { Ctx } from '@spraxium/common';
import type { ButtonInteraction } from 'discord.js';
import { BuyButton } from './buy-button.component';

interface Product {
  id: string;
  name: string;
}

@DynamicButtonHandler(BuyButton)
export class BuyButtonHandler {
  async handle(
    @Ctx() interaction: ButtonInteraction,
    @ButtonPayload() product: Product,
    @PayloadRef() ref: PayloadHandle,
  ): Promise<void> {
    await this.auditPurchaseAttempt(ref.id, product.id);

    // One-shot: cliques futuros com o mesmo payload voltam como expirado.
    await ref.consume();

    await interaction.reply({
      content: `Purchase confirmed: ${product.name}`,
      flags: 'Ephemeral',
    });
  }

  private async auditPurchaseAttempt(payloadId: string, productId: string): Promise<void> {
    void payloadId;
    void productId;
  }
}

TTL, expiração e quando o handler deixa de rodar

Cada payload dinâmico tem tempo de vida (TTL). Você pode definir isso em payloadTtl na classe dinâmica. Se não informar, o serviço usa TTL padrão de 600 segundos.

Quando o payload expira (ou quando você chama consume()), interações posteriores com o mesmo customId não chegam no handler. O usuário recebe erro de ação expirada, que pode ser personalizado nas mensagens de erro da configuração de components.

Payload (~p) versus contexto de fluxo (~ctx)

As duas referências podem coexistir no mesmo customId, mas têm papéis diferentes:

  • ~p:<id>: carrega o dado de domínio do componente dinâmico.
  • ~ctx:<id>: carrega estado de fluxo (wizard, etapas, restrição por usuário).

Regra prática: use payload para identificar o item da ação e contexto para identificar o estado da conversa/interação.

Quando usar build assíncrono ou síncrono

No modelo atual, build() e buildReply() são assíncronos por padrão porque um schema V2 pode conter filhos que exigem resolução assíncrona, como:

  • @V2DynamicRow usando expansão por dynamic:.
  • @V2Row com selects dinâmicos que resolvem opções em tempo de execução.
  • Qualquer filho que dependa de factories assíncronas.

Use buildSync() ou buildReplySync() somente quando seu container for totalmente síncrono. Se o schema tiver filhos que exigem async, o caminho sync lança erro de forma intencional para evitar layout inconsistente em runtime.

Checklist rápido de migração do modelo anterior para o modelo atual

  1. Adicione await em this.v2.build(...) e this.v2.buildReply(...).
  2. Mantenha buildReplySync() apenas em containers estáticos sem dynamic rows/selects.
  3. Troque chunking manual de botões por @V2DynamicRow em listas de ação.
  4. Troque parsing manual de customId por @ButtonPayload() / @SelectPayload().
  5. Em ações one-shot (compra, confirmação, consumo), injete @PayloadRef() e chame consume().