Spraxium logoSpraxium

Webhooks

Envía mensajes, embeds y notificaciones a webhooks de Discord desde el bot con el paquete @spraxium/webhook. Aprende a configurar webhooks nombrados, usar el decorator declarativo @Send y llamar la API imperativa de WebhookService.

Qué hace el paquete de webhook

El paquete @spraxium/webhook ofrece a cualquier provider del bot una forma limpia de enviar mensajes y embeds a webhooks de Discord. En vez de montar requests HTTP manualmente o administrar clients de webhook por cuenta propia, registras webhooks nombrados una sola vez en la configuración del plugin y luego los referencias por nombre desde cualquier service de la aplicación.

El paquete ofrece dos estilos de entrega. El estilo declarativo gira alrededor del decorator @Send aplicado a un método de service; Spraxium intercepta el valor retornado y hace el dispatch automáticamente según el tipo. El estilo imperativo inyecta WebhookService en el constructor y permite llamar métodos como send, sendEmbed, sendAll y formatAndSend directamente. Ambos estilos comparten el mismo motor de entrega y el mismo registro de webhooks nombrados, por lo que puedes mezclar los dos sin problemas en la misma aplicación.

Cuándo elegir entrega declarativa o imperativa

Prefiere el estilo declarativo cuando la función completa del método sea producir un mensaje de webhook. Esto mantiene el service compacto y deja la intención muy clara durante lectura y revisión.

El estilo imperativo tiene más sentido cuando la entrega depende de lógica condicional, múltiples destinos, broadcast selectivo o decisiones tomadas en runtime. En esos escenarios, llamadas explícitas a WebhookService suelen ser más fáciles de entender y depurar.

Setup

Instala el paquete:

snippet.sh
pnpm add @spraxium/webhook

Luego importa WebhookModule en el módulo raíz para que WebhookService sea inyectable en toda la aplicación:

src/app.module.ts
import { Module } from '@spraxium/common';
import { WebhookModule } from '@spraxium/webhook';

@Module({
  imports: [WebhookModule],
})
export class AppModule {}

Configuración

La configuración de webhook debe quedar en un archivo dedicado, como config/webhook.config.ts, exportado como constante nombrada y consumido por spraxium.config.ts mediante el array plugins.

config/webhook.config.ts
import { defineWebhook } from '@spraxium/webhook';

export const webhookConfig = defineWebhook({
  webhooks: {
    alerts: process.env.WEBHOOK_ALERTS ?? '',
    logs: process.env.WEBHOOK_LOGS ?? '',
    reports: process.env.WEBHOOK_REPORTS ?? '',
  },
  globalUsername: 'MyBot',
  globalAvatarUrl: 'https://example.com/avatar.png',
  onError: (name, error) => {
    console.error(`Webhook "${name}" failed: ${error.message}`);
  },
});
spraxium.config.ts
import { defineConfig } from '@spraxium/core';
import { webhookConfig } from './config/webhook.config';

export default defineConfig((env) => ({
  plugins: [webhookConfig],
}));

Las claves del mapa webhooks son los nombres usados en el resto de la aplicación, como en send('alerts', ...), @Send('reports'), sendAll() y así sucesivamente. Cada valor es la URL del webhook de Discord correspondiente a ese destino. globalUsername y globalAvatarUrl definen la identidad predeterminada para todos los mensajes de salida, pero ambos pueden sobrescribirse por llamada con SendOptions.

CampoTipoObligatorioDescripción
webhooksRecord<string, string>Mapa nombre del webhook → URL del webhook de Discord. Los nombres son los identificadores usados en todas las llamadas de envío.
globalUsernamestringNoSobrescribe el nombre mostrado en todo mensaje enviado. Se impone al nombre predeterminado del propio webhook.
globalAvatarUrlstringNoSobrescribe el avatar usado en todo mensaje enviado.
onError(name: string, err: Error) => voidNoSe llama cuando falla el envío de un webhook, en vez de lanzar error. Si se omite, los errores suben como promises rechazadas.

Guarda URLs de webhook en variables de entorno con @spraxium/env y valida con @IsDiscordWebhookUrl(). Nunca dejes esas URLs hardcoded en el código fuente.

API declarativa: @Send

El decorator @Send es la forma más directa de disparar un mensaje. Aplícalo a un método en una clase decorada con @WebhookSender y retorna uno de tres tipos: string para contenido simple, EmbedBuilder para un embed único o MessageCreateOptions para un payload bruto completo. Cuando el método termina, Spraxium intercepta ese retorno y hace dispatch al webhook nombrado.

@WebhookSender es obligatorio en la clase. Aplica @Injectable automáticamente y registra la clase en el pipeline de interceptación de webhooks, por lo que no necesitas añadir @Injectable por separado.

src/modules/reports/reports.service.ts
import { Send, WebhookSender } from '@spraxium/webhook';
import { EmbedBuilder } from 'discord.js';

@WebhookSender()
export class ReportsService {
  // A plain string → sent as message content
  @Send('logs')
  async buildActivityLog(action: string, userId: string): Promise<string> {
    return `Activity: \`${action}\` by <@${userId}> at ${new Date().toISOString()}`;
  }

  // An EmbedBuilder → dispatched as a single embed
  @Send('alerts')
  async buildErrorReport(error: Error): Promise<EmbedBuilder> {
    return new EmbedBuilder()
      .setTitle('Error Report')
      .setDescription(`\`\`\`\n${error.message}\n\`\`\``)
      .setColor(0xed4245)
      .setTimestamp();
  }

  // A MessageCreateOptions object → sent as a raw payload
  @Send('reports')
  async buildDailySummary(): Promise<{ content: string }> {
    return { content: `Daily summary for ${new Date().toDateString()}: all systems nominal.` };
  }
}

El tipo de retorno determina el comportamiento de dispatch:

Tipo de retornoComportamiento de envío
stringEnviado como campo de contenido del mensaje.
EmbedBuilderEnviado como embed único dentro del array de embeds.
MessageCreateOptionsEnviado como payload bruto de Discord, con control total sobre cada campo.
undefined o nullNo se envía nada. El método se ejecuta normalmente, pero el interceptor omite el dispatch.

El estilo declarativo funciona mejor cuando un método corresponde claramente a un único mensaje de salida. Si el método empieza a bifurcar, tomar decisiones y construir varias llamadas de webhook, ese suele ser el punto donde la API imperativa se vuelve más sostenible.

Registra la clase sender en el array providers de cualquier módulo de feature:

src/modules/reports/reports.module.ts
import { Module } from '@spraxium/common';
import { ReportsService } from './reports.service';

@Module({
  providers: [ReportsService],
})
export class ReportsModule {}

API imperativa: WebhookService

Inyecta WebhookService por constructor en cualquier provider cuando necesites control programático total. Este estilo es ideal cuando el contenido depende de branching, cuando hay broadcast condicional o cuando el dispatch debe ocurrir en hooks de lifecycle como onReady.

src/modules/notifications/notifications.service.ts
import { Injectable } from '@spraxium/common';
import type { SpraxiumOnReady } from '@spraxium/common';
import { WebhookService } from '@spraxium/webhook';
import { EmbedBuilder } from 'discord.js';

@Injectable()
export class NotificationsService implements SpraxiumOnReady {
  constructor(private readonly webhook: WebhookService) {}

  async onReady(): Promise<void> {
    await this.webhook.send('logs', 'Bot is now online and ready.');

    const embed = new EmbedBuilder()
      .setTitle('Bot Online')
      .setDescription('All systems operational.')
      .setColor(0x57f287)
      .setTimestamp();

    await this.webhook.sendEmbed('alerts', embed);
    await this.webhook.sendAll('Startup complete.');
  }

  async notifyGuildJoin(guildName: string, memberCount: number): Promise<void> {
    await this.webhook.formatAndSend(
      'logs',
      'Bot joined guild **{{guildName}}** ({{memberCount}} members).',
      { guildName, memberCount: String(memberCount) },
    );
  }

  async broadcastAlert(message: string): Promise<void> {
    await this.webhook.sendMany(['alerts', 'logs'], message);
  }

  async sendStatusReport(stats: { guilds: number; users: number; ping: number }): Promise<void> {
    const guildsEmbed = new EmbedBuilder()
      .setTitle('Guild Stats')
      .addFields({ name: 'Total Guilds', value: String(stats.guilds), inline: true })
      .setColor(0x5865f2);

    const pingEmbed = new EmbedBuilder()
      .setTitle('Performance')
      .addFields(
        { name: 'Users', value: String(stats.users), inline: true },
        { name: 'WS Ping', value: `${stats.ping} ms`, inline: true },
      )
      .setColor(0xfee75c);

    await this.webhook.sendEmbeds('reports', [guildsEmbed, pingEmbed]);
  }
}

Referencia de métodos

MétodoDescripción
send(name, content, options?)Envía un mensaje de texto al webhook nombrado.
sendEmbed(name, embed, options?)Envía un único EmbedBuilder al webhook nombrado.
sendEmbeds(name, embeds[], options?)Envía hasta 10 embeds en un solo mensaje al webhook nombrado.
sendMessage(name, message, options?)Envía un payload bruto de MessageCreateOptions al webhook nombrado.
sendMany(names[], content, options?)Hace broadcast de texto plano a un subconjunto específico de webhooks nombrados en paralelo.
sendAll(content, options?)Hace broadcast de texto plano a todos los webhooks registrados al mismo tiempo.
formatAndSend(name, template, vars, options?)Reemplaza placeholders usando el objeto vars y luego envía la string resultante.
format(template, vars)Reemplaza placeholders y devuelve la string interpolada sin enviar nada.
get(name)Retorna la WebhookEntry del webhook nombrado, o undefined si no está registrado.
isRegistered(name)Retorna true si existe un webhook registrado con ese nombre.
registered()Retorna un array con todos los nombres de webhook registrados.

Opciones por llamada

Todo método de envío acepta un objeto SendOptions opcional como último argumento. Los valores definidos allí sobrescriben globalUsername y globalAvatarUrl de la configuración del plugin solo para esa llamada específica.

OpciónTipoDescripción
usernamestringSobrescribe el nombre mostrado por el webhook solo en este envío.
avatarURLstringSobrescribe el avatar del webhook solo en este envío.
threadIdstringEnvía el mensaje a un thread específico de foro o texto dentro del canal del webhook.

Usa estos overrides por llamada con moderación. Si prácticamente todo mensaje necesita username o avatar customizado, normalmente es señal de que deberías separar ese destino en varios webhooks nombrados, en vez de sobrescribir siempre el mismo webhook.

snippet.ts
await this.webhook.send('alerts', 'Maintenance window starting in 5 minutes.', {
  username: 'Maintenance Bot',
  avatarURL: 'https://example.com/maintenance.png',
  threadId: '1234567890123456789',
});

Verificando registro en runtime

Usa isRegistered para proteger una llamada cuando un webhook es opcional en algunos entornos, y registered para inspeccionar la lista completa de nombres configurados en boot o en una ruta de health check.

snippet.ts
if (this.webhook.isRegistered('alerts')) {
  await this.webhook.send('alerts', 'Service is up.');
}

// List all configured webhook names
console.log(this.webhook.registered()); // ['alerts', 'logs', 'reports']

Modelo de confiabilidad en 0.2.0

En 0.2.0, el comportamiento de entrega de webhook quedó intencionalmente explícito. Algunas llamadas fallan de forma directa, mientras llamadas de broadcast priorizan éxito parcial y continúan enviando a los destinos restantes.

La recomendación es modelar tu flujo usando esa semántica, en vez de tratar todos los métodos como si tuvieran el mismo comportamiento de error.

MétodoComportamiento de falloUso típico
send / sendEmbed / sendEmbeds / sendMessageRechaza en caso de fallo (excepto cuando onError del plugin maneja el error).Destino único y crítico, donde el caller decide retry/fallback.
sendManyHace fan-out paralelo, registra fallos individuales y no interrumpe los demás envíos.Notificaciones best-effort para múltiples destinos.
sendAllDelega a sendMany; emite warning cuando no hay webhooks registrados.Mensajes operativas estilo broadcast.

onError global versus try/catch en el punto de llamada

El callback onError del plugin es excelente para telemetría centralizada, alertas y logs estructurados. Aun así, try/catch en el punto de llamada sigue siendo importante para decisiones de negocio, por ejemplo: fallar el comando actual, cambiar el canal destino o encolar un retry.

config/webhook.config.ts
import { defineWebhook } from '@spraxium/webhook';
import { logger } from '@spraxium/logger';

const log = logger.child('WebhookErrors');

export const webhookConfig = defineWebhook({
  webhooks: {
    alerts: process.env.WEBHOOK_ALERTS ?? '',
    audit: process.env.WEBHOOK_AUDIT ?? '',
  },
  onError: (name, error) => {
    log.error(`Webhook \"${name}\" failed: ${error.message}`);
  },
});

Patrón de arquitectura recomendado

En bots medianos y grandes, un enfoque robusto es separar el tráfico por responsabilidad y no por módulo de feature. Ejemplo de división:

  1. alerts: incidentes y fallos críticos.
  2. audit: trazabilidad de auditoría de seguridad/moderación.
  3. ops: eventos de lifecycle y snapshots operativos.

Esta separación deja los canales downstream más limpios y facilita políticas distintas de retención y permiso.

src/modules/ops/ops-webhook.service.ts
import { Injectable } from '@spraxium/common';
import { WebhookService } from '@spraxium/webhook';

@Injectable()
export class OpsWebhookService {
  constructor(private readonly webhook: WebhookService) {}

  async reportIncident(summary: string): Promise<void> {
    await this.webhook.send('alerts', `INCIDENT: ${summary}`);
  }

  async appendAudit(entry: string): Promise<void> {
    await this.webhook.send('audit', entry);
  }

  async announceLifecycle(eventName: string): Promise<void> {
    await this.webhook.send('ops', `Lifecycle event: ${eventName}`);
  }
}

Checklist de seguridad y operación

Para proyectos en 0.2.0, valida esta checklist antes de producción:

  1. Guarda URLs de webhook en variables de entorno y nunca hagas commit de esas URLs.
  2. Valida los valores en boot (@spraxium/env) para fallar temprano en caso de URL inválida.
  3. Mantén nombres de destino estables (alerts, audit, ops) para evitar drift de strings.
  4. Define claramente dónde best-effort es aceptable (sendMany/sendAll) y dónde no.
  5. Envía errores de onError a tu pipeline central de observabilidad.
  6. Evita volcar markdown de usuario sin tratamiento en canales privilegiados de auditoría.