@molecule/api-channel-discord

Provider bond · channel · API (Node) · v1.0.2 · Apache-2.0

Discord channel bond — implements @molecule/api-channel for sendMessage, ed25519 webhook signature verification, and inbound interaction parsing.

npm install @molecule/api-channel-discord

npm · Source on GitHub · Implements @molecule/api-channel

How it works

@molecule/api-channel-discord is a provider bond on the API (Node) side: it implements the channel core interface (@molecule/api-channel) with a concrete vendor or library behind it.

Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.

import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-discord'

setProvider('discord', provider)

Works with: @molecule/api-channel, @molecule/api-secrets

Secrets: CHANNEL_DISCORD_BOT_TOKEN, CHANNEL_DISCORD_PUBLIC_KEY

Reference

Discord channel bond for molecule.dev.

Implements the ChannelProvider interface defined by @molecule/api-channel, posting outbound messages via Discord's REST API and verifying inbound interaction webhooks against the application's ed25519 public key.

Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit src/index.ts JSDoc, not this file.

Quick Start

import { setProvider } from '@molecule/api-channel'
import { provider } from '@molecule/api-channel-discord'

setProvider('discord', provider)

Type

provider

Installation

npm install @molecule/api-channel-discord @molecule/api-channel @molecule/api-secrets discord.js

API

Interfaces

DiscordConfig

Configuration for the Discord channel provider.

Discord uses two distinct credentials:

  • botToken — used as the Authorization: Bot <token> header for REST calls (sending messages, fetching channels, etc.). Required to send messages.
  • publicKey — the application's hex-encoded ed25519 public key, used to verify the signature on inbound interaction webhook requests. Required to verify webhooks.

Either credential may be omitted at construction time and supplied via environment variables (CHANNEL_DISCORD_BOT_TOKEN, CHANNEL_DISCORD_PUBLIC_KEY).

interface DiscordConfig {
  /**
   * Bot token for the Discord application. Used as the `Bot <token>`
   * authentication header on REST calls. Defaults to the
   * `CHANNEL_DISCORD_BOT_TOKEN` environment variable.
   */
  botToken?: string

  /**
   * Application public key (hex-encoded ed25519) used to verify inbound
   * interaction webhook signatures. Defaults to the
   * `CHANNEL_DISCORD_PUBLIC_KEY` environment variable.
   */
  publicKey?: string

  /**
   * Optional override for the Discord REST API base URL. Defaults to
   * `https://discord.com/api/v10`. Primarily useful for testing.
   */
  apiBaseUrl?: string

  /**
   * Optional REST client. If provided, the bond delegates message sends to
   * it (e.g. an instance of `discord.js`'s `REST`). When omitted, the bond
   * falls back to a built-in `fetch` implementation. Primarily useful for
   * tests that need to mock the `discord.js` client without having a live
   * dependency.
   */
  rest?: DiscordRestLike

  /**
   * Optional request timeout for built-in REST calls in milliseconds.
   * Defaults to `10000`. Ignored when `rest` is supplied.
   */
  timeoutMs?: number
}

DiscordRestLike

Minimal REST-client shape the provider depends on. Compatible with the REST class from discord.js (new REST().setToken(token).post(...)).

interface DiscordRestLike {
  /**
   * Issues a POST request against the Discord REST API at the given route.
   *
   * @param route - REST route relative to the Discord API base (e.g.
   *   `'/channels/123/messages'`).
   * @param options - Request options including a JSON-serialisable body.
   * @returns The parsed JSON response.
   */
  post(route: string, options: { body: unknown }): Promise<unknown>
}

ProcessEnv

Environment variables consumed by the Discord channel provider.

interface ProcessEnv {
  /** Bot token used for REST authentication. */
  CHANNEL_DISCORD_BOT_TOKEN: string
  /** Application public key (hex-encoded) used for webhook verification. */
  CHANNEL_DISCORD_PUBLIC_KEY: string
}

Types

DiscordInteractionType

Discord interaction types relevant to inbound webhook parsing.

Mirrors the subset of Discord's InteractionType enum used by this bond:

  • 1 — PING (handshake; not converted to an InboundMessage).
  • 2 — APPLICATION_COMMAND (slash command).
  • 3 — MESSAGE_COMPONENT (button click, select-menu, etc.).

Any other types (modal submit, autocomplete, …) are passed through as the original payload and treated as an empty inbound message.

type DiscordInteractionType = 1 | 2 | 3 | number

Classes

DiscordChannelProvider

Concrete Discord implementation of ChannelProvider.

class DiscordChannelProvider implements ChannelProvider {
  /**
   * Stable channel name. Always `'discord'`.
   */
  readonly name = 'discord'

  private readonly botToken: string | undefined
  private readonly publicKeyHex: string | undefined
  private readonly apiBaseUrl: string
  private readonly rest: DiscordRestLike | undefined
  private readonly timeoutMs: number

  /**
   * Constructs a Discord channel provider.
   *
   * @param config - Optional configuration. Any field omitted falls back
   *   to the corresponding environment variable.
   */
  constructor(config: DiscordConfig = {}) {
    this.botToken = config.botToken ?? process.env.CHANNEL_DISCORD_BOT_TOKEN
    this.publicKeyHex = config.publicKey ?? process.env.CHANNEL_DISCORD_PUBLIC_KEY
    this.apiBaseUrl = config.apiBaseUrl ?? DEFAULT_API_BASE
    this.rest = config.rest
    this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS
  }

  /**
   * Posts a message to a Discord channel via the REST API.
   *
   * The recipient `to` is treated as a Discord channel snowflake ID (the
   * same identifier yielded by the inbound interaction `channel.id`).
   * Threaded replies are posted to the thread channel directly; pass the
   * thread's channel ID as `to` (Discord threads are channels themselves).
   *
   * @param to - Discord channel snowflake to post into.
   * @param message - Normalized `OutboundMessage`.
   * @returns Result describing the delivered message.
   * @throws {Error} When the bot token is missing, the recipient is empty,
   *   or the REST call fails.
   */
  async sendMessage(to: ChannelRecipient, message: OutboundMessage): Promise<SendResult> {
    if (!to) {
      throw new Error('Discord channel ID is required.')
    }
    if (!this.botToken && !this.rest) {
      throw new Error(
        'Discord bot token not configured. Set CHANNEL_DISCORD_BOT_TOKEN or provide a REST client.',
      )
    }

    const body = buildOutboundBody(message)
    const route = `/channels/${encodeURIComponent(to)}/messages`

    const responseJson = this.rest
      ? ((await this.rest.post(route, { body })) as DiscordMessageResponse)
      : await this.fetchPost(route, body)

    const messageId = responseJson?.id ?? ''
    const timestamp = responseJson?.timestamp ? new Date(responseJson.timestamp) : new Date()

    return { messageId, deliveredAt: timestamp }
  }

  /**
   * Verifies the ed25519 signature on an inbound Discord interaction
   * webhook.
   *
   * Discord signs each request with the application's ed25519 private
   * key. The signature covers `timestamp + body` and is delivered in the
   * `X-Signature-Ed25519` header (hex-encoded). The `X-Signature-Timestamp`
   * header carries the timestamp that was signed.
   *
   * @param headers - Lowercased request header map.
   * @param body - Raw, unparsed request body.
   * @returns `true` if the signature is valid for the configured public key.
   */
  verifyWebhookSignature(headers: Record<string, string>, body: string | Uint8Array): boolean {
    if (!this.publicKeyHex) return false

    const signatureHex = pickHeader(headers, 'x-signature-ed25519')
    const timestamp = pickHeader(headers, 'x-signature-timestamp')
    if (!signatureHex || !timestamp) return false

    let signatureBytes: Buffer
    try {
      signatureBytes = Buffer.from(signatureHex, 'hex')
    } catch (_error) {
      // Malformed hex in the signature header — treat as invalid, not a server error.
      return false
    }
    if (signatureBytes.length !== 64) return false

    const bodyBytes = typeof body === 'string' ? Buffer.from(body, 'utf8') : Buffer.from(body)
    const signedPayload = Buffer.concat([Buffer.from(timestamp, 'utf8'), bodyBytes])

    let publicKey
    try {
      const keyDer = Buffer.concat([ED25519_DER_PREFIX, Buffer.from(this.publicKeyHex, 'hex')])
      publicKey = createPublicKey({ key: keyDer, format: 'der', type: 'spki' })
    } catch (_error) {
      // Malformed public key config — treat as unverifiable rather than crashing per-request.
      return false
    }

    try {
      return cryptoVerify(null, signedPayload, publicKey, signatureBytes)
    } catch (_error) {
      // cryptoVerify throws on invalid input (wrong key type, etc.) — treat as failed verification.
      return false
    }
  }

  /**
   * Parses a Discord interaction payload into a normalised
   * `InboundMessage`.
   *
   * Handles two interaction types:
   *
   * - `2` (`APPLICATION_COMMAND`) — slash command. The `text` field is
   *   set to the command name (`'/<command>'`).
   * - `3` (`MESSAGE_COMPONENT`) — button click / select-menu. The `text`
   *   field is set to the component's `custom_id`.
   *
   * Other interaction types (e.g. modal submit, autocomplete) are
   * surfaced as an `InboundMessage` with no `text` and the raw payload
   * preserved on `payload`.
   *
   * @param payload - The parsed Discord interaction body.
   * @returns Normalized `InboundMessage`.
   * @throws {Error} If `payload` is null/undefined or not an object.
   */
  parseInbound(payload: unknown): InboundMessage {
    if (!payload || typeof payload !== 'object') {
      throw new Error('Discord inbound payload must be an object.')
    }

    const interaction = payload as DiscordInteractionPayload
    const channelId = interaction.channel?.id ?? interaction.channel_id ?? ''
    const userId =
      interaction.member?.user?.id ?? interaction.user?.id ?? interaction.author?.id ?? ''

    const attachments = normaliseAttachments(interaction.message?.attachments)

    let text: string | undefined
    if (interaction.type === INTERACTION_APPLICATION_COMMAND) {
      text = interaction.data?.name ? `/${interaction.data.name}` : undefined
    } else if (interaction.type === INTERACTION_MESSAGE_COMPONENT) {
      text = interaction.data?.custom_id ?? undefined
    } else if (typeof interaction.content === 'string') {
      // Pass-through for non-interaction inbound shapes (e.g. raw
      // gateway message events forwarded through a relay).
      text = interaction.content
    }

    return {
      from: userId,
      channel: 'discord',
      text,
      attachments: attachments.length > 0 ? attachments : undefined,
      thread_id: channelId || undefined,
      payload,
      receivedAt: new Date(),
    }
  }

  /**
   * Returns the static feature matrix for the Discord channel.
   *
   * @returns A frozen `ChannelFeatures` record.
   */
  listSupportedFeatures(): ChannelFeatures {
    return FEATURES
  }

  /**
   * Built-in REST `POST` used when no `discord.js` REST instance is
   * configured. Issues a JSON request with the bot token and applies the
   * configured timeout.
   *
   * @param route - Path beneath the Discord API base.
   * @param body - JSON-serialisable body.
   * @returns The parsed JSON response.
   * @throws {Error} If the response is non-2xx or the request times out.
   */
  private async fetchPost(route: string, body: unknown): Promise<DiscordMessageResponse> {
    const controller = new AbortController()
    const timer = setTimeout(() => controller.abort(), this.timeoutMs)

    try {
      const response = await fetch(`${this.apiBaseUrl}${route}`, {
        method: 'POST',
        headers: {
          Authorization: `Bot ${this.botToken!}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(body),
        signal: controller.signal,
      })

      if (!response.ok) {
        throw new Error(`Discord REST returned HTTP ${response.status}`)
      }
      return (await response.json()) as DiscordMessageResponse
    } finally {
      clearTimeout(timer)
    }
  }
}

Functions

createProvider(config)

Convenience factory for DiscordChannelProvider.

function createProvider(config?: DiscordConfig): DiscordChannelProvider
  • config — Optional configuration.

Returns: A configured Discord channel provider.

Constants

provider

Lazily-instantiated, default-configured Discord channel provider.

The proxy defers construction until first use so importing this module has no side effects (e.g. consuming env vars at import time).

const provider: ChannelProvider

channelDiscordSecretDefinitions

Secret definitions required by the Discord channel bond.

const channelDiscordSecretDefinitions: SecretDefinition[]

Core Interface

Implements @molecule/api-channel interface.

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-channel ^1.0.1
  • discord.js ^14.0.0
  • @molecule/api-secrets ^1.0.1

Environment Variables

Runtime Dependencies

  • @molecule/api-channel
  • @molecule/api-secrets
  • discord.js

E2E Tests

Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:

  • Each channel-notifying flow the app defines (a Slack/Discord alert on a new order, a status-change message) actually produces a message. The sandbox CAPTURES channel messages instead of sending — read them with the read_activity tool (filter type 'channel'); never mock the flow or modify production code to expose the message.
  • The captured message targets the configured channel/provider name and carries the app's real content (readable text, no undefined placeholders, no secrets).
  • A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.