Spraxium logoSpraxium

Contexto de Fluxo

Transporte dados com estado entre interações de componentes em várias etapas usando SpraxiumContext. Aprenda criação de contexto, adapters de armazenamento, expiração por TTL, restrição por usuário e injeção no handler.

O que é contexto de fluxo

Contexto de fluxo é um contêiner de dados com estado que carrega informações ao longo de interações sequenciais com componentes. Quando uma pessoa clica em um botão, escolhe uma opção e depois envia um modal, cada handler dessa sequência pode precisar de dados que nasceram em etapas anteriores. Sem esse contexto, você teria de codificar estado em strings de customId, que são limitadas, jogar dados em um banco externo ou manter mapas globais que tendem a vazar memória.

SpraxiumContext resolve esse problema oferecendo um contêiner tipado, com TTL e, opcionalmente, restrito a um usuário específico. Ele fica armazenado em um adapter plugável e é referenciado por um UUID anexado ao customId do componente. O ContextService cria os contextos, os serviços de componente acoplam esse ID aos elementos renderizados e o dispatcher resolve tudo automaticamente antes de chamar o handler.

O sistema de contexto de fluxo é compartilhado por todos os tipos de componente. Um contexto criado em uma linha de botões pode ser lido mais tarde por um handler de modal no mesmo fluxo. É isso que torna experiências interativas em múltiplas etapas viáveis sem precisar controlar estado na mão.

Como criar um contexto

Injete ContextService no seu comando ou serviço e chame create com o objeto de dados que você quer carregar ao longo do fluxo. O método devolve um SpraxiumContext com UUID gerado, dados, timestamps e TTL. Depois disso, você passa esse contexto para o método de build do serviço 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],
    });
  }
}

Interface SpraxiumContext

O objeto SpraxiumContext carrega tanto os seus dados quanto os metadados operacionais do fluxo. O campo id é o UUID anexado às strings de customId por meio do separador til. O campo data guarda o payload tipado. Já ttl e expiresAt controlam a expiração automática, enquanto restrictedTo limita quais usuários podem interagir com componentes vinculados a esse contexto.

CampoTipoFinalidade
idstring

UUID gerado pela função de UUID em runtime. É anexado ao customId por meio do separador til.

dataT

Seu payload tipado. Pode ser qualquer objeto serializável e pode ser alterado via update.

createdAtnumber

Timestamp Unix em milissegundos indicando quando o contexto foi criado.

ttlnumber

Tempo de vida em segundos. É usado para calcular expiresAt.

expiresAtnumber

Timestamp Unix em milissegundos que marca a expiração. O cálculo é feito como createdAt + ttl * 1000.

restrictedTostring | undefined

Quando definido, só esse ID de usuário pode interagir com os componentes que carregam esse contexto.

Como ler o contexto no handler

Use o decorator de parâmetro @FlowContext() no método handle para receber o contexto já resolvido. O dispatcher extrai o ID do contexto a partir do customId, busca o valor no adapter de armazenamento, valida expiração e restrições de usuário e injeta o objeto resultante. Se o contexto tiver expirado, ou se a interação vier de um usuário não autorizado, o dispatcher responde com a mensagem de erro configurada e nem chega a chamar o 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)}`,
      flags: 'Ephemeral',
    });
  }
}

Como atualizar o contexto

O método ContextService.update aplica um patch nos dados do contexto sem substituir o objeto inteiro. Isso ajuda muito em fluxos de várias etapas, onde cada handler só acrescenta ou ajusta uma parte do estado. O retorno é um booleano informando se a atualização deu certo, ou seja, se o contexto foi encontrado e ainda não tinha 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.',
      flags: 'Ephemeral',
    });
  }
}

Como apagar o contexto

Chame ContextService.delete quando o fluxo tiver sido concluído ou cancelado. Isso remove o contexto imediatamente do adapter de armazenamento. Mesmo que a expiração automática já exista por TTL, a exclusão explícita continua sendo uma boa prática em fluxos encerrados normalmente, porque libera espaço e evita a leitura de dados obsoletos em cenários de borda.

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!', flags: 'Ephemeral' });
  }
}

Excluindo contexto de fora de um handler

ContextService.delete recebe o ID do contexto como uma string simples, então pode ser chamado a partir de qualquer serviço, handler de comando ou listener - não apenas de dentro de um handler de botão ou modal que receba uma injeção de @FlowContext().

O segredo é persistir o ID do contexto junto aos seus próprios dados de aplicação quando o contexto é criado:

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 },
    );

    // Salve ctx.id no banco de dados para que possa ser cancelado de fora
    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] });
  }
}

Persistindo um registro de sessão recuperável

Para fluxos de longa duração, salve uma linha de sessão dedicada para conseguir recuperar contextId depois:

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;
  }
}

Em seguida, cancele o fluxo a partir de um comando admin ou qualquer outro serviço sem precisar do contexto em escopo:

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) {
      // Apaga o contexto - qualquer botão pendente no fluxo
      // receberá agora a mensagem de expiração ao ser clicado.
      await this.context.delete(session.contextId);
    }

    await this.db.removeSession(session.userId);
    await interaction.reply({ content: 'Sessão de onboarding cancelada.', flags: 'Ephemeral' });
  }
}

Adapters de armazenamento

O sistema de contexto usa um adapter de armazenamento plugável configurado por meio do plugin defineComponents. Quatro adapters nativos cobrem os cenários mais comuns de deploy. Todos implementam a interface ContextStorageAdapter, com métodos assíncronos get, set, delete e entries.

AdapterValor na configPersistênciaMelhor uso
MemorymemoryVolátil, perdido ao reiniciar

Desenvolvimento e testes. Não exige configuração adicional.

FilesystemfilesystemArquivo JSON dentro de .spraxium

Bots de instância única que precisam manter o contexto após reinícios.

RedisredisChaves Redis com prefixo spraxium:ctx:

Deploys com múltiplas instâncias e necessidade de estado compartilhado.

SQLitesqliteBanco SQLite em .spraxium/contexts.db

Persistência leve sem depender de infraestrutura externa.

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

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

Referência da API de ContextService

MétodoAssinaturaFinalidade
createcreate<T>(data: T, options?): Promise<SpraxiumContext<T>>Cria um novo contexto com UUID gerado, persiste o valor e devolve o objeto completo.
getget<T>(id: string): Promise<SpraxiumContext<T> | null>Busca um contexto pelo ID. Retorna null se ele não existir ou já tiver expirado.
updateupdate<T>(id: string, patch: Partial<T>): Promise<boolean>Mescla um objeto parcial nos dados já existentes. Retorna false se o contexto não for encontrado.
deletedelete(id: string): Promise<void>Remove o contexto do armazenamento imediatamente. Resolve em silêncio se ele não existir.

Comportamento de TTL e expiração

Todo contexto tem um TTL medido em segundos. Quando alguém interage com um componente cujo contexto já expirou, o dispatcher responde com a mensagem configurada em errorMessages.expired e não chama o handler. Contextos expirados não são removidos imediatamente do armazenamento. A limpeza costuma acontecer de forma preguiçosa, no acesso, ou em varreduras periódicas realizadas pelo adapter.

O TTL padrão é definido na configuração de defineComponents. Contextos individuais podem sobrescrever esse valor pelo parâmetro options.ttl em ContextService.create.

Para interações curtas, como caixas de confirmação, um TTL entre 60 e 120 segundos costuma ser suficiente. Em assistentes de múltiplas etapas, algo entre 600 e 1800 segundos costuma dar uma margem confortável para a pessoa concluir o fluxo sem bater numa expiração no meio do caminho.

Restrições por usuário

A opção restrictedTo limita o acesso ao contexto a um ID de usuário específico. Quando ela está definida, só interações vindas desse usuário são despachadas para o handler. Se outra pessoa clicar no componente, recebe a mensagem configurada em errorMessages.restricted. Isso é essencial em fluxos nos quais o componente está visível para todo o canal, mas só deveria poder ser acionado por quem iniciou o 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],
    });
  }
}