Spraxium logoSpraxium

Contexto de flujo

Transporta datos con estado entre interacciones de componentes en múltiples etapas usando SpraxiumContext. Aprende creación de contexto, adapters de almacenamiento, expiración por TTL, restricción por usuario e inyección en el handler.

Qué es el contexto de flujo

El contexto de flujo es un contenedor de datos con estado que transporta información a lo largo de interacciones secuenciales con componentes. Cuando una persona hace clic en un botón, elige una opción y después envía un modal, cada handler de esa secuencia puede necesitar datos creados en etapas anteriores. Sin este contexto, tendrías que codificar estado en strings de customId, que son limitadas, guardar datos en una base externa o mantener mapas globales que tienden a filtrar memoria.

SpraxiumContext resuelve este problema ofreciendo un contenedor tipado, con TTL y, opcionalmente, restringido a un usuario específico. Se almacena en un adapter conectable y se referencia por un UUID anexado al customId del componente. ContextService crea los contextos, los servicios de componente acoplan ese ID a los elementos renderizados y el dispatcher resuelve todo automáticamente antes de llamar al handler.

El sistema de contexto de flujo es compartido por todos los tipos de componente. Un contexto creado en una fila de botones puede leerse después por un handler de modal en el mismo flujo. Eso es lo que hace viables experiencias interactivas de múltiples etapas sin tener que controlar estado manualmente.

Cómo crear un contexto

Inyecta ContextService en tu comando o service y llama create con el objeto de datos que quieres transportar durante el flujo. El método devuelve un SpraxiumContext con UUID generado, datos, timestamps y TTL. Después, pasas ese contexto al método de build del servicio de componente.

src/commands/setup-wizard.command-handler.ts
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ContextService, ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { NextStepButton } from '../components/next-step.button';
import { SetupWizardCommand } from './setup-wizard.command';

@SlashCommandHandler(SetupWizardCommand)
export class SetupWizardCommandHandler {
  constructor(
    private readonly context: ContextService,
    private readonly buttons: ButtonService,
  ) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const ctx = await this.context.create(
      { step: 1, guildId: interaction.guildId, settings: {} },
      { ttl: 600, restrictedTo: interaction.user.id },
    );

    const row = this.buttons.rowWithContext(ctx, NextStepButton);

    await interaction.reply({
      content: 'Welcome to the setup wizard. Click Next to begin.',
      components: [row],
    });
  }
}

Interfaz SpraxiumContext

El objeto SpraxiumContext transporta tanto tus datos como metadatos operativos del flujo. El campo id es el UUID anexado a strings de customId por medio del separador til. El campo data guarda el payload tipado. ttl y expiresAt controlan la expiración automática, mientras restrictedTo limita qué usuarios pueden interactuar con componentes vinculados a ese contexto.

CampoTipoFinalidad
idstring

UUID generado por la función de UUID en runtime. Se anexa al customId por medio del separador til.

dataT

Tu payload tipado. Puede ser cualquier objeto serializable y puede modificarse vía update.

createdAtnumber

Timestamp Unix en milisegundos indicando cuándo se creó el contexto.

ttlnumber

Tiempo de vida en segundos. Se usa para calcular expiresAt.

expiresAtnumber

Timestamp Unix en milisegundos que marca la expiración. El cálculo se hace como createdAt + ttl * 1000.

restrictedTostring | undefined

Cuando se define, solo ese ID de usuario puede interactuar con los componentes que llevan este contexto.

Cómo leer el contexto en el handler

Usa el decorator de parámetro @FlowContext() en el método handle para recibir el contexto ya resuelto. El dispatcher extrae el ID de contexto desde el customId, busca el valor en el adapter de almacenamiento, valida expiración y restricciones de usuario e inyecta el objeto resultante. Si el contexto ya expiró o la interacción viene de un usuario no autorizado, el dispatcher responde con el mensaje de error configurado y ni siquiera llega a llamar al handler.

src/components/next-step.handler.ts
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { NextStepButton } from './next-step.button';

interface WizardData {
  step: number;
  guildId: string;
  settings: Record<string, unknown>;
}

@ButtonHandler(NextStepButton)
export class NextStepButtonHandler {
  async handle(
    @Ctx() interaction: ButtonInteraction,
    @FlowContext() ctx: SpraxiumContext<WizardData>,
  ): Promise<void> {
    const { step, settings } = ctx.data;
    await interaction.reply({
      content: `You are on step ${step}. Current settings: ${JSON.stringify(settings)}`,
      ephemeral: true,
    });
  }
}

Cómo actualizar el contexto

El método ContextService.update aplica un patch a los datos del contexto sin reemplazar el objeto completo. Eso ayuda mucho en flujos de múltiples etapas, donde cada handler solo agrega o ajusta una parte del estado. El retorno es un booleano que indica si la actualización funcionó, es decir, si el contexto fue encontrado y todavía no había expirado.

src/components/step-two.handler.ts
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import { ContextService } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { StepTwoButton } from './step-two.button';

interface WizardData {
  step: number;
  guildId: string;
  settings: Record<string, unknown>;
}

@ButtonHandler(StepTwoButton)
export class StepTwoButtonHandler {
  constructor(private readonly context: ContextService) {}

  async handle(
    @Ctx() interaction: ButtonInteraction,
    @FlowContext() ctx: SpraxiumContext<WizardData>,
  ): Promise<void> {
    await this.context.update<WizardData>(ctx.id, {
      step: 2,
      settings: { ...ctx.data.settings, language: 'en' },
    });

    await interaction.reply({
      content: 'Step 2 complete. Proceeding to step 3.',
      ephemeral: true,
    });
  }
}

Cómo eliminar el contexto

Llama ContextService.delete cuando el flujo haya sido completado o cancelado. Esto elimina el contexto de inmediato del adapter de almacenamiento. Aunque la expiración automática por TTL ya existe, la eliminación explícita sigue siendo una buena práctica en flujos cerrados de forma normal, porque libera espacio y evita lecturas de datos obsoletos en escenarios límite.

src/components/finish-wizard.handler.ts
import { ButtonHandler, Ctx, FlowContext } from '@spraxium/components';
import { ContextService } from '@spraxium/components';
import type { ButtonInteraction } from 'discord.js';
import type { SpraxiumContext } from '@spraxium/components';
import { FinishButton } from './finish.button';

@ButtonHandler(FinishButton)
export class FinishWizardHandler {
  constructor(private readonly context: ContextService) {}

  async handle(
    @Ctx() interaction: ButtonInteraction,
    @FlowContext() ctx: SpraxiumContext<unknown>,
  ): Promise<void> {
    await this.context.delete(ctx.id);
    await interaction.reply({ content: 'Setup complete!', ephemeral: true });
  }
}

Eliminar contexto desde fuera de un handler

ContextService.delete recibe el ID del contexto como string simple, por lo que puede llamarse desde cualquier servicio, handler de comando o listener - no solo desde dentro de un handler de botón o modal que reciba una inyección de @FlowContext().

La clave es persistir el ID del contexto junto a tus propios datos de aplicación cuando se crea el contexto:

src/commands/start-onboarding.command-handler.ts
@SlashCommandHandler(StartOnboardingCommand)
export class StartOnboardingCommandHandler {
  constructor(
    private readonly context: ContextService,
    private readonly buttons: ButtonService,
    private readonly db: OnboardingRepository,
  ) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const ctx = await this.context.create(
      { guildId: interaction.guildId!, step: 1 },
      { ttl: 86400, restrictedTo: interaction.user.id },
    );

    // Guarda ctx.id en tu base de datos para que pueda cancelarse desde fuera
    await this.db.createSession(interaction.user.id, { contextId: ctx.id });

    const row = this.buttons.rowWithContext(ctx, NextStepButton);
    await interaction.reply({ content: '¡Onboarding iniciado!', components: [row] });
  }
}

Persistir un registro de sesión recuperable

Para flujos de larga duración, guarda una fila de sesión dedicada para poder recuperar contextId más tarde:

src/repositories/onboarding.repository.ts
interface OnboardingSessionRecord {
  userId: string;
  contextId: string;
  onboardingMessageId: string;
  state: 'active' | 'cancelled' | 'completed';
}

export class OnboardingRepository {
  async createSession(
    userId: string,
    data: Omit<OnboardingSessionRecord, 'userId'>,
  ): Promise<void> {
    // INSERT INTO onboarding_sessions (...)
  }

  async findActiveSession(userId: string): Promise<OnboardingSessionRecord | null> {
    // SELECT ... WHERE user_id = ? AND state = 'active'
    return null;
  }
}

Después cancela el flujo desde un comando de administración o cualquier otro servicio sin necesitar el contexto en scope:

src/commands/cancel-onboarding.command-handler.ts
@SlashCommandHandler(CancelOnboardingCommand)
export class CancelOnboardingCommandHandler {
  constructor(
    private readonly context: ContextService,
    private readonly db: OnboardingRepository,
  ) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const session = await this.db.findActiveSession(interaction.options.getUser('user', true).id);

    if (session?.contextId) {
      // Elimina el contexto - cualquier botón pendiente en el flujo
      // recibirá ahora el mensaje de expiración al ser clicado.
      await this.context.delete(session.contextId);
    }

    await this.db.removeSession(session.userId);
    await interaction.reply({ content: 'Sesión de onboarding cancelada.', flags: 'Ephemeral' });
  }
}

Adapters de almacenamiento

El sistema de contexto usa un adapter de almacenamiento conectable configurado por medio del plugin defineComponents. Cuatro adapters nativos cubren los escenarios de deploy más comunes. Todos implementan la interfaz ContextStorageAdapter, con métodos asíncronos get, set, delete y entries.

AdapterValor en configPersistenciaMejor uso
MemorymemoryVolátil, se pierde al reiniciar

Desarrollo y pruebas. No requiere configuración adicional.

FilesystemfilesystemArchivo JSON dentro de .spraxium

Bots de instancia única que necesitan mantener contexto tras reinicios.

RedisredisClaves Redis con prefijo spraxium:ctx:

Deploys con múltiples instancias y necesidad de estado compartido.

SQLitesqliteBase SQLite en .spraxium/contexts.db

Persistencia ligera sin depender de infraestructura externa.

config/components.config.ts
import { defineComponents } from '@spraxium/components';

export const componentsPlugin = defineComponents({
  context: {
    storage: 'redis',
    defaultTtl: 600,
  },
});

Referencia de API de ContextService

MétodoFirmaFinalidad
createcreate<T>(data: T, options?): Promise<SpraxiumContext<T>>Crea un nuevo contexto con UUID generado, persiste el valor y devuelve el objeto completo.
getget<T>(id: string): Promise<SpraxiumContext<T> | null>Busca un contexto por ID. Retorna null si no existe o ya expiró.
updateupdate<T>(id: string, patch: Partial<T>): Promise<boolean>Mezcla un objeto parcial en los datos existentes. Retorna false si no encuentra el contexto.
deletedelete(id: string): Promise<void>Elimina el contexto del almacenamiento inmediatamente. Resuelve en silencio si no existe.

Comportamiento de TTL y expiración

Todo contexto tiene un TTL medido en segundos. Cuando alguien interactúa con un componente cuyo contexto ya expiró, el dispatcher responde con el mensaje configurado en errorMessages.expired y no llama al handler. Contextos expirados no se eliminan inmediatamente del almacenamiento. La limpieza suele ocurrir de forma perezosa, al acceder, o en barridos periódicos realizados por el adapter.

El TTL predeterminado se define en la configuración de defineComponents. Contextos individuales pueden sobrescribir ese valor con el parámetro options.ttl en ContextService.create.

Para interacciones cortas, como cajas de confirmación, un TTL entre 60 y 120 segundos suele ser suficiente. En asistentes de varias etapas, algo entre 600 y 1800 segundos suele dar un margen cómodo para que la persona complete el flujo sin toparse con una expiración a mitad de camino.

Restricciones por usuario

La opción restrictedTo limita el acceso al contexto a un ID de usuario específico. Cuando está definida, solo interacciones de ese usuario se despachan al handler. Si otra persona hace clic en el componente, recibe el mensaje configurado en errorMessages.restricted. Esto es esencial en flujos donde el componente está visible para todo el canal, pero solo debería poder accionarlo quien inició el comando.

src/commands/verification.command-handler.ts
import { Ctx, SlashCommandHandler } from '@spraxium/common';
import { ContextService, ButtonService } from '@spraxium/components';
import type { ChatInputCommandInteraction } from 'discord.js';
import { VerifyButton } from '../components/verify.button';
import { VerificationCommand } from './verification.command';

@SlashCommandHandler(VerificationCommand)
export class VerificationCommandHandler {
  constructor(
    private readonly context: ContextService,
    private readonly buttons: ButtonService,
  ) {}

  async handle(@Ctx() interaction: ChatInputCommandInteraction): Promise<void> {
    const ctx = await this.context.create(
      { userId: interaction.user.id },
      { ttl: 300, restrictedTo: interaction.user.id },
    );

    const row = this.buttons.rowWithContext(ctx, VerifyButton);

    await interaction.reply({
      content: 'Click the button to verify your identity.',
      components: [row],
    });
  }
}