Qué son los contenedores V2
La API Components V2 de Discord sustituye el modelo tradicional de diseño de mensajes por un sistema de bloques componibles. En lugar de tratar el mensaje como una combinación plana de contenido, embeds y filas de componentes, V2 permite unir bloques de texto, secciones, galerías de medios, separadores y action rows dentro de un único contenedor. El contenedor pasa a ser la unidad principal del diseño del mensaje.
El sistema V2 de Spraxium encapsula esta API usando la misma arquitectura de decorators y handlers presente en el resto de @spraxium/components. Declaras el esquema del contenedor como una clase decorada, anotas cada elemento estructural con un decorator de propiedad y renderizas todo con V2Service, pasando los datos de runtime. El resultado es un diseño fuertemente tipado y componible, en el que la estructura queda separada de los datos que circulan por ella.
El paquete ofrece dos estilos de autoría para V2. El patrón basado en clase usa @V2Container con decorators de propiedad y funciona mejor en diseños reutilizables guiados por datos. La API fluida es un builder imperativo, encadenado en una instancia de V2ContainerFluentBuilder, y suele ser mejor para mensajes puntuales o armados dinámicamente. Ambos estilos producen el mismo formato final y pueden coexistir en el mismo bot.
Cuándo usar contenedores V2
Los contenedores V2 tienen sentido siempre que el mensaje necesite un diseño más estructurado de lo que puede ofrecer un embed simple. Los casos comunes incluyen leaderboards con listas de tamaño variable, paneles de dashboard con thumbnail y cuadrícula de estadísticas, perfiles con avatar junto a varios bloques de texto y galerías de imágenes dentro de la respuesta del comando. Siempre que el mensaje se beneficie de una separación visual más clara entre secciones, o cuando notes que tendrías que apilar varios embeds para acercarte al resultado, probablemente un contenedor V2 sea la opción correcta.
Los contenedores V2 requieren la flag MessageFlags.IsComponentsV2 al enviar el mensaje. Cuando esta flag está presente, el mensaje no puede llevar al mismo tiempo embeds tradicionales ni contenido de texto simple junto a los contenedores. En otras palabras, trata los mensajes V2 como diseños completos y autocontenidos, no como un adorno encima de contenido ya existente.
Configurando V2Service
V2Service es el servicio responsable de renderizar y construir todo lo relacionado con V2. Está disponible para inyección automáticamente en cuanto ComponentsModule entra en el grafo de módulos. Fuera de la configuración estándar del plugin defineComponents, no necesitas ningún ajuste extra.
Inyecta V2Service en cualquier handler o servicio que necesite renderizar contenedores V2.
import { V2Service } from '@spraxium/components';
@SlashCommandHandler(MyCommand)
export class MyHandler {
constructor(private readonly v2: V2Service) {}
async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
// ...
}
}Esquemas de contenedor basados en clase
En el patrón basado en clase, el contenedor se declara como una clase anotada. Aplicas @V2Container a la clase para marcarla como contenedor y configurar opciones como color de acento y spoiler. Luego usas decorators de propiedad para registrar los hijos en el orden de declaración. En runtime, V2Service.build lee estos metadatos, evalúa las factory functions con los datos recibidos y monta el contenedor final.
Los nombres de las propiedades en la clase no tienen relevancia en runtime. Lo que realmente importa es el metadato registrado por los decorators. Por eso, es común declarar el tipo de la propiedad como never, indicando que ese campo existe solo como slot de metadatos.
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 fluida
La API fluida es un builder encadenable ligado a V2Service. Llama a v2.container() para abrir el builder, encadena las llamadas de los hijos y finaliza con .toReply() para producir el payload listo para interaction.reply(). Cada método devuelve el propio builder, así que todo puede quedar inline si eso tiene sentido para el mensaje.
La API fluida no ofrece soporte para decorators de propiedad ni para condiciones con @V2When, porque no existe metadato de clase para anotar. Siempre que necesites renderizado condicional o quieras reutilizar el mismo diseño en varios handlers, prefiere el patrón basado en clase.
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,
});
}
}Referencia de la API de V2Service
| Método | Devuelve | Finalidad |
|---|---|---|
| build(ContainerClass, data?) | Promise<ContainerBuilder> | Renderiza de forma asíncrona un esquema basado en clase en un ContainerBuilder de discord.js. |
| buildReply(ContainerClass, data?) | Promise<V2ReplyPayload> | Renderiza de forma asíncrona y ya empaqueta el resultado en un payload listo para reply con la flag IsComponentsV2. |
| buildSync(ContainerClass, data?) | ContainerBuilder | Ruta síncrona para esquemas que no requieren resolución asíncrona de hijos. |
| buildReplySync(ContainerClass, data?) | V2ReplyPayload | Builder síncrono de payload de respuesta, útil para contenedores V2 totalmente estáticos. |
| container(opts?) | V2ContainerFluentBuilder | Abre un builder fluido para construcción imperativa del contenedor. |
| text(content) | TextDisplayBuilder | Crea un bloque de texto independiente. Acepta string o DescriptionBuilder. |
| separator(config?) | SeparatorBuilder | Crea un separador. Usa la opción divider como false cuando quieras solo espaciado invisible. |
| thumbnail(url, opts?) | ThumbnailBuilder | Crea un thumbnail listo para usarse como accesorio en una section. |
| section(text, accessory?) | SectionBuilder | Crea una section con texto y, opcionalmente, un accesorio de thumbnail o botón. |
| gallery(items) | MediaGalleryBuilder | Crea una galería de medios a partir de un array de ítems con URL, descripción opcional y estado opcional de spoiler. |
Decoradores de propiedad
| Decorador | Tipo de hijo | Descripción |
|---|---|---|
| @V2Text(content) | Bloque de texto | Renderiza un TextDisplayBuilder con la string informada o con la salida de un DescriptionBuilder. También acepta factory con datos de runtime. |
| @V2Section(config) | Section | Diseño lado a lado con un bloque de texto y un accesorio que puede ser thumbnail o botón. El texto y la URL del thumbnail pueden venir de factory. |
| @V2Separator(config?) | Separador | Línea horizontal. Pasa divider como false para crear un espaciador invisible. Usa spacing 1 o 2 para controlar el tamaño del espacio. |
| @V2Row(config) | Action row | Incrusta una fila de botones o un select menu dentro del contenedor. Pasa clases de componente en el array components. Una clase de select se detecta automáticamente. |
| @V2MediaGallery(items) | Galería de medios | Renderiza una galería de medios. Los ítems pueden venir de un array estático o de una factory que devuelve objetos de medios. |
| @V2File(config) | Adjunto de archivo | Adjunta una URL de archivo dentro del contenedor. La URL puede venir de factory. Usa spoiler como true para ocultar la vista previa hasta el clic del usuario. |
| @V2Dynamic(factory) | Hijos dinámicos | Llama una factory con los datos de runtime para producir una lista variable de specs de hijos. La siguiente sección cubre esto en detalle. |
| @V2When(predicate) | Condición | Se aplica después del decorator del hijo. Cuando el predicado devuelve false, el hijo se omite del contenedor renderizado. |
Hijos dinámicos con @V2Dynamic
@V2Dynamic resuelve los casos en los que la cantidad de hijos depende de los datos de runtime. En lugar de proporcionar una configuración estática, pasas una factory que recibe el objeto de datos y devuelve un array de child specs creados con helpers como v2text, v2sep, v2section, v2gallery y v2row. El motor recorre ese array y renderiza cada spec en orden.
Un patrón común es generar una o más líneas de texto por ítem e insertar separadores entre ellas, teniendo cuidado de no dejar un separador sobrante después del último ítem.
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;
}El handler pasa la lista de entradas como dato y llama a buildReply sin necesitar saber cuántas líneas se generarán al final.
@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);
}
}Renderizado condicional con @V2When
@V2When incluye un hijo de forma condicional en función de los datos de runtime. Se aplica como un decorator adicional debajo del decorator del hijo, es decir, se ejecuta al final y agrega una guarda a la definición existente. Cuando el predicado devuelve false para ese conjunto de datos, el hijo se elimina del contenedor por completo, no solo se renderiza vacío.
@V2When es apilable. Puedes tener varios hijos diferentes en el mismo contenedor, cada uno con su propia condición. El orden registrado para los elementos se mantiene, independientemente de cuáles hijos terminen filtrados.
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;
}Cuando isAdmin es false, la línea de la insignia de administrador simplemente no se renderiza. Los demás hijos se reorganizan de forma natural, sin dejar espacio sobrante.
Insertando action rows con @V2Row
@V2Row coloca una fila de botones o un select menu directamente dentro de un contenedor V2. Pasa las clases de componente en el array components, exactamente como lo harías con ButtonService o SelectService. El servicio detecta por sí solo si la primera clase es un select menu y enruta la construcción al builder correcto.
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 divide automáticamente en runtime una lista de ítems o clases de botón en filas de hasta cinco elementos dentro del contenedor. Es el equivalente en V2 a llamar ButtonService.build varias veces, pero guiado por datos, no por una lista estática fija.
Existen dos modos:
Modo dinámico: combina una clase @DynamicButton con un accessor de datos que produzca la lista de ítems. Spraxium llama al método estático render(item) del botón para cada ítem, almacena cada ítem como payload y agrupa los botones generados en filas de cinco. Este es el caso principal: una clase de botón, N ítems, N botones gestionados por interacción.
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: proporciona una factory que devuelva un array de clases de botón estáticas. Spraxium las renderiza todas en filas de cinco, sin payloads involucrados. Usa este modo cuando el conjunto de botones varía, pero cada botón todavía tiene customId fijo.
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 con dynamic: solo funciona con clases @DynamicButton, no
con botones estáticos. Para dividir botones estáticos en bloques, usa la
forma de factory en components:.
DescriptionBuilder
La función helper desc() devuelve un DescriptionBuilder que encadena helpers de Markdown al estilo de Discord. Puede usarse tanto en el patrón basado en clase como en la API fluida, en cualquier lugar donde se acepte un valor de texto. El builder funciona como un acumulador simple de string. Al llamar .toString(), obtienes el contenido final, aunque el propio motor de V2 ya hace esto automáticamente antes de pasar el texto a los builders de discord.js.
| Método | Salida |
|---|---|
| .h2(text) | Genera un heading interno en formato de heading nivel 2 de Markdown. |
| .subtext(text) | Genera un subtexto más pequeño en formato de subtexto corto de Markdown. |
| .bold(text) | Produce texto en negrita en Markdown. |
| .italic(text) | Produce texto en cursiva en Markdown. |
| .code(text) | Produce texto en código inline. |
| .line(text?) | Agrega texto seguido por salto de línea. |
Configuración del contenedor
El decorator @V2Container acepta un objeto V2ContainerMeta.
| Opción | Tipo | Predeterminado | Descripción |
|---|---|---|---|
| accentColor | number | string | Ninguno | Color de la franja lateral de destaque. Acepta un número hexadecimal como 0x5865f2 o una string hexadecimal como #5865F2. |
| spoiler | boolean | false | Marca todo el contenedor como spoiler, exigiendo clic del usuario para revelar el contenido. |
Construyendo el payload de respuesta
Tanto buildReply como el método fluido .toReply() devuelven un objeto { components, flags }. Cuando expandes ese payload en interaction.reply(), la flag IsComponentsV2 ya se configura automáticamente. Si necesitas flags adicionales, solo combínalas con OR bit a bit.
const payload = await this.v2.buildReply(MyContainer, data);
await interaction.reply({
...payload,
flags: payload.flags | MessageFlags.Ephemeral,
});Si necesitas actualizar un mensaje V2 ya existente, usa interaction.editReply() o pasa el payload directamente a message.edit(). La flag es necesaria tanto en el envío inicial como en las ediciones posteriores, porque Discord identifica un mensaje V2 justamente por la presencia de esa flag.
Sistema dinámico de la 0.2.0 y guía de migración
En la 0.2.0, el ecosistema V2 fue más allá de la composición visual y ganó un modelo completo para interacciones dinámicas con botones y selects. La idea central es que los datos de runtime pasan a viajar con cada elemento interactivo por referencia de payload. Con eso, el handler recibe objetos tipados de dominio directamente, sin depender de serialización manual en custom IDs.
Este modelo es especialmente útil en catálogos, paneles operativos, colas de moderación y cualquier flujo en el que renderizas listas variables de acciones y necesitas preservar contexto de negocio por ítem.
DynamicButton con payload tipado
Usa @DynamicButton para declarar el renderizado por ítem y @DynamicButtonHandler para tratar la
interacción. Inyecta el payload original con @ButtonPayload() y, cuando el flujo sea one-shot, inyecta
@PayloadRef() para llamar a consume().
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 con datos acoplados
Con @DynamicStringSelect y @DynamicSelectHandler, el select puede montarse con datos de runtime
y el handler recibe tanto los valores seleccionados (@SelectedValues()) como el payload original
(@SelectPayload()). Este patrón mantiene presentación y dominio sincronizados sin parsing extra.
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: [],
});
}
}Qué son exactamente payload y payload ref
En el sistema dinámico de la 0.2.0, payload es el dato de dominio original usado para renderizar el
componente interactivo. En lugar de serializar todo en el customId, Spraxium guarda ese dato en el
store de contexto y coloca en el customId solo una referencia corta en formato ~p:<id>.
En otras palabras:
- Renderizas botones/selects dinámicos con datos reales (producto, ticket, usuario, etc.).
- El framework guarda cada payload con TTL y adjunta
~p:<id>en elcustomIdfinal. - En la interacción, el dispatcher resuelve ese ID e inyecta el objeto tipado en el handler.
Esto reduce el parsing manual de strings y evita customId inflado.
Diferencia práctica entre @ButtonPayload, @SelectPayload y @PayloadRef
@ButtonPayload() y @SelectPayload() inyectan el objeto de datos asociado a esa interacción.
@PayloadRef() inyecta un handle de ciclo de vida (PayloadHandle) con:
id: el identificador bruto del payload.consume(): elimina el payload del store para impedir la reutilización.
Usa @PayloadRef() cuando la acción deba ser one-shot (compra, confirmación irreversible,
consumo de cupón, etc.).
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, expiración y cuándo el handler deja de ejecutarse
Cada payload dinámico tiene un tiempo de vida (TTL). Puedes definirlo en payloadTtl en la clase
dinámica. Si no lo informas, el servicio usa un TTL estándar de 600 segundos.
Cuando el payload expira (o cuando llamas consume()), las interacciones posteriores con el mismo
customId no llegan al handler. El usuario recibe error de acción expirada, que puede ser
personalizado en los mensajes de error de la configuración de components.
Payload (~p) versus contexto de flujo (~ctx)
Las dos referencias pueden coexistir en el mismo customId, pero tienen papeles diferentes:
~p:<id>: transporta el dato de dominio del componente dinámico.~ctx:<id>: transporta estado de flujo (wizard, etapas, restricción por usuario).
Regla práctica: usa payload para identificar el ítem de la acción y contexto para identificar el estado de la conversación/interacción.
Cuándo usar build asíncrono o síncrono
En la 0.2.0, build() y buildReply() son asíncronos por defecto porque un esquema V2 puede contener
hijos que requieren resolución asíncrona, como:
@V2DynamicRowusando expansión pordynamic:.@V2Rowcon selects dinámicos que resuelven opciones en tiempo de ejecución.- Cualquier hijo que dependa de factories asíncronas.
Usa buildSync() o buildReplySync() solo cuando tu contenedor sea totalmente síncrono. Si el
esquema tiene hijos que requieren async, la ruta sync lanza error de forma intencional para evitar
diseño inconsistente en runtime.
Checklist rápido de migración (pre-0.2.0 -> 0.2.0)
- Añade
awaitenthis.v2.build(...)ythis.v2.buildReply(...). - Mantén
buildReplySync()solo en contenedores estáticos sin dynamic rows/selects. - Cambia el chunking manual de botones por
@V2DynamicRowen listas de acción. - Cambia el parsing manual de customId por
@ButtonPayload()/@SelectPayload(). - En acciones one-shot (compra, confirmación, consumo), inyecta
@PayloadRef()y llamaconsume().