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
- 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.
- Bot → Token. Create one, keep it in an environment variable.
- General Information → Interactions Endpoint URL:
https://<your-host>/ingress/discord(/ingress/discord/interactionsworks too). Discord will only save the URL after your server answers its signedPING— start Ever Async first. - 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:
type | Meaning | Outcome |
|---|---|---|
| 1 | PING | IngressOutcome::Challenge carrying exactly {"type":1} |
| 2 | APPLICATION_COMMAND named async | IngressOutcome::Command |
| 3 | MESSAGE_COMPONENT (a button) | IngressOutcome::Interaction |
| 4, 5 | autocomplete, modal submit | Ignored |
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>:
| Action | Discord call |
|---|---|
ThreadReply | POST /channels/{id}/messages with a message_reference |
Post | POST /channels/{id}/messages |
DirectMessage | POST /users/@me/channels → message create |
Ephemeral / EphemeralWithButtons | DM — see below |
Replace | PATCH /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
contenton 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:
- PONG content type. The plugin returns the exact body Discord wants, but
the server sends a
Challengeastext/plain(correct for Slack's echo). If Discord's endpoint check ever insists onapplication/json, that is one line incrates/server/src/ingress.rs. - 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-upReplacehas no acknowledged response to edit. The fix is an ack at ingest time ({"type":6}), which needs the interaction id thatIngressOutcome::Interactionhas 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.