Skip to main content

Discord

Message classification does not run on Discord

/async, buttons and everything outbound work. Watching ordinary conversation does not — Discord delivers channel messages only over the Gateway websocket, never by webhook, and this plugin does not open one. See Gateway below for exactly what that would take.

For the full product loop today, use Slack.

Setup​

  1. Developer portal → your application → General Information. Copy the Public Key (64 hex characters). It is public — it verifies signatures, it does not create them — so it may live in your config file.
  2. Bot → Token. Create one, keep it in an environment variable.
  3. General Information → Interactions Endpoint URL: https://<your-host>/ingress/discord (/ingress/discord/interactions works too). Discord will only save the URL after your server answers its signed PING — start Ever Async first.
  4. Register a command named async, either globally or per guild. Give it one optional string option carrying the arguments, or model the arguments as sub-commands; both flatten to the same argument line the pipeline parses.
[channels.discord]
public_key = "0123456789abcdef…" # 64 hex chars; or public_key_env
bot_token_env = "DISCORD_BOT_TOKEN"

Both keys are required. Without the public key nothing can be verified, and the plugin refuses to start rather than expose an unverified endpoint.

Ingress: the Interactions endpoint​

Discord posts everything to one URL and tags it with a numeric type:

typeMeaningOutcome
1PINGIngressOutcome::Challenge carrying exactly {"type":1}
2APPLICATION_COMMAND named asyncIngressOutcome::Command
3MESSAGE_COMPONENT (a button)IngressOutcome::Interaction
4, 5autocomplete, modal submitIgnored

Signature verification is not optional​

Every request must carry X-Signature-Ed25519 and X-Signature-Timestamp, and the signature is checked over timestamp || raw_body with the application's public key before anything is parsed. Failures map to AsyncError::Verification, which the server renders as 401.

This is load-bearing, not ceremony: Discord actively probes the endpoint with deliberately invalid signatures and disables an endpoint that accepts one. The timestamp is additionally required to be within ±300s, the same replay window the Slack plugin enforces.

The PONG has to be exact​

Discord refuses to save an Interactions Endpoint URL whose PING answer is not {"type":1}, so the plugin returns that literal rather than something serialized at runtime.

Egress​

act() maps ChannelAction onto the REST API at https://discord.com/api/v10 with Authorization: Bot <token>:

ActionDiscord call
ThreadReplyPOST /channels/{id}/messages with a message_reference
PostPOST /channels/{id}/messages
DirectMessagePOST /users/@me/channels → message create
Ephemeral / EphemeralWithButtonsDM — see below
ReplacePATCH /webhooks/{app}/{token}/messages/@original

health() is GET /users/@me with the bot token.

Why a private nudge is a DM​

Discord has no "post an ephemeral message into a channel" endpoint. Ephemeral exists only as an interaction response: it needs the id and token of an interaction the user just triggered, and a ChannelAction::Ephemeral carries neither — it names a conversation and a user, which is all Slack needs.

So an unprompted private nudge goes out as a direct message. That keeps the invariant that actually matters — the private-nudge rule: Ever Async never corrects anyone in public. The plugin still builds the canonical flags: 64 (EPHEMERAL) shape, because that is what the same nudge must look like the moment it is an interaction response; the flag is dropped on the DM path, where Discord does not document what a message create does with a flag you are not allowed to set.

Buttons and the 100-character wall​

Slack gives a button an action_id and a 2000-character value. Discord gives it one opaque custom_id capped at 100 characters. So the pair travels joined — "{action_id}:{value}" — and is split back on the first colon at ingest.

A short suggested rewrite fits and keeps its buttons. A long one does not, and the plugin then sends the nudge without buttons rather than a message Discord would reject outright: losing the buttons is much better than losing the suggestion they act on. Nothing is logged about the payload itself — it is the user's own message.

The Gateway gap​

Discord does not deliver ordinary channel messages by webhook. The Interactions endpoint only receives things a user aimed at this bot. Everything else — the conversation Ever Async exists to watch — arrives only over a Gateway WebSocket connection.

That is deliberately not half-built here, because it needs two things a stateless ingress path does not have:

  • The privileged MESSAGE CONTENT intent. Without it Discord blanks out content on message events, which is useless to a classifier. It is enabled per-application in the developer portal, and once a bot reaches 100 or more servers it must be verified and the intent explicitly approved by Discord, with a written justification. A self-hosted install below that threshold can simply switch it on.
  • A persistent, stateful connection — identify, heartbeat, resume after a disconnect, shard past 2500 guilds. With more than one replica, every replica would open its own connection and classify every message N times, so it needs a single-holder election or a dedicated gateway process.

The seam​

A Gateway client belongs behind this same plugin id, as a background task that turns MESSAGE_CREATE / MESSAGE_UPDATE frames into InboundEvents and hands them to the pipeline — the same normalized shape the interactions path already produces. Nothing above the plugin boundary changes, and the egress half is already complete and reused as-is.

Two wiring seams above the plugin​

Listed so they are not rediscovered as bugs:

  1. PONG content type. The plugin returns the exact body Discord wants, but the server sends a Challenge as text/plain (correct for Slack's echo). If Discord's endpoint check ever insists on application/json, that is one line in crates/server/src/ingress.rs.
  2. Interaction acknowledgement. Discord expects a callback within 3 seconds of a button press (POST /interactions/{id}/{token}/callback); the pipeline instead answers the webhook with a bare 200 and works asynchronously. Until something sends that callback, a press shows "This interaction failed" and the follow-up Replace has no acknowledged response to edit. The fix is an ack at ingest time ({"type":6}), which needs the interaction id that IngressOutcome::Interaction has nowhere to carry.

What this channel proves​

Everything above the plugin boundary is platform-agnostic. The pipeline classifies an InboundEvent and emits a ChannelAction; it has never seen a Slack payload and does not see a Discord one. Adding this channel meant writing an ingest and an act — no core change, no pipeline change. Where Discord genuinely differs from Slack (Ed25519 instead of HMAC, one custom_id instead of two fields, no unprompted ephemeral), the difference is absorbed here.

See Architecture → Writing a plugin.