@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-discordnpm · 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.tsJSDoc, 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 theAuthorization: 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.1discord.js^14.0.0@molecule/api-secrets^1.0.1
Environment Variables
CHANNEL_DISCORD_BOT_TOKEN(required) — Discord bot token- Setup: Create an application → Bot → Reset Token, then copy the bot token.
- Get it here: https://discord.com/developers/applications
CHANNEL_DISCORD_PUBLIC_KEY(required) — Discord public key- Setup: Your application → General Information → Public Key (verifies interaction signatures).
- Get it here: https://discord.com/developers/applications
Runtime Dependencies
@molecule/api-channel@molecule/api-secretsdiscord.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_activitytool (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
undefinedplaceholders, no secrets). - A failed send (unbonded or misconfigured provider) is visible in logs/UI — never silently swallowed.