Discord¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
The daemon delivers PU and @Intel notices to a player's Discord channel, named by their
@discord tag (channel, category); the bot resolves the name and posts to it, and every
send is recorded in a delivery log. This page covers sending a message and reading back the
delivery outcomes. Sending reaches the network, so the example uses ?dryRun=true — a pure
preview that POSTs nothing and writes no log record.
Sending needs discord.send; reading the log needs log.read. Every example runs
against a live daemon — see how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| POST | /discord/send |
Send-NerthusDiscordMessage |
discord.send |
— |
| GET | /discord/deliveries |
Get-NerthusDiscordDeliveryLog |
log.read |
— |
| POST | /discord/dispatch |
— | discord.dispatch |
— |
Cap is the required capability (— = public, no token); Write (✓) marks routes that pass the write gate; a — in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.
POST /discord/send is not Write-flagged — its only write is private delivery state, so it stays operable in read-only mode.
Preview a send without reaching Discord¶
POST /v1/api/discord/send posts a message to a channel — targeted by { channel, category }
or resolved from an { entity }'s @discord. With ?dryRun=true it returns
200 { Delivered: false, Result: "WHATIF" }, echoes the entity and message, and makes
no HTTP request — proven by the follow-up: the delivery log stays empty. Here the fixture
Gracz Stefan, whose channel is resolved from his @discord.
Response 200:
The delivery log¶
GET /v1/api/discord/deliveries returns the { count, items } envelope of actual send
outcomes, newest first. Filter with ?operation=, the label the send recorded — message
by default, pu from the monthly PU batch, Intel from session close, and one label per
internal notice (close-reminder, peer-watch, …). The filter runs before the cap: the
envelope carries at most 500 matching records. A dry run never records, so on a fresh
daemon the log is empty.
Response 200:
The worker dispatch — discord.dispatch¶
POST /v1/api/discord/dispatch is the ingest Worker's daemon-first path: the Worker at
conjure.nerthus.pl verifies the interaction's Ed25519 signature, acks Discord, then
offers the interaction here before deciding what a decline costs
(Fleet owns the switch's design). The bearer
named token is the trust boundary — the daemon trusts the forwarded Discord id exactly
as the CI daemon trusts the same value as a trigger variable.
Body: { "interactionId": "<snowflake>", "kind": "close|report|query", "sentAtEpochMs": <epoch ms>, "vars": { … } }
where vars is the exact trigger-variable dictionary the Worker builds. An absent kind
is inferred from the vars, so a Worker deployed before the kinded contract keeps working.
| kind | vars it needs | write gate | what a decline costs |
|---|---|---|---|
close |
NERTHUS_CLOSE_DISCORD_ID |
applies | one CI pipeline — the trigger enacts the same close |
report |
NERTHUS_REPORT_B64, NERTHUS_REPORTER_DISCORD_ID |
applies | one CI pipeline — the trigger renders and opens the same merge request |
query |
NERTHUS_QUERY_INTENT (status/open/pu), NERTHUS_QUERY_DISCORD_ID |
skipped | the answer — there is no CI counterpart, and the Worker says the daemon is unavailable |
A query writes nothing, so it is accepted on a read-only, sync-stale, or schema-drifted
daemon: that state is exactly what someone asks /status about.
| Status | When |
|---|---|
202 |
accepted: { "accepted": true, "kind", "interactionId", "duplicate", "daemonEpochMs" } — the daemon now owns the interaction: its tick enacts it and PATCHes the Discord followup itself, suffixed with the ⚡ path marker |
202 (replay) |
idempotent: the queue record is keyed by interactionId, so a replay returns duplicate: true and enqueues nothing |
400 bad_request |
missing/malformed interactionId, sentAtEpochMs, or vars |
409 too_late_for_report |
a report whose receipt is past near_deadline_ms (3000 ms). A close is idempotent at the far end, so a race with the Worker's timeout costs a redundant pipeline; a report is not, and two enactments would be two merge requests. Past the near-deadline the daemon hands it back deterministically |
410 dispatch_stale |
receipt-relative lateness: the daemon's own receipt is more than window_ms (5000 ms) after sentAtEpochMs (the body echoes ageMs, windowMs, daemonEpochMs); a daemon clock behind the edge never refuses |
422 not_dispatchable |
an unknown kind (the body echoes the three), or a kind whose required vars are missing |
503 daemon_write_gated |
for close and report: the daemon is read-only, schema-drifted, or sync-stale, so it refuses work it cannot land and the Worker falls back to CI — a gated daemon degrades loudly to the trigger, never silently to nothing |
The window and the drain bounds are configuration (integrations.discord.dispatch:
window_ms, near_deadline_ms, skew_alert_ms, max_attempts, max_per_tick,
tick_budget_ms — see the configuration reference). window_ms
must stay 1500 ms under the Worker's DAEMON_DISPATCH_TIMEOUT_MS, so an accepted dispatch
is always one the edge still waits on; a contract test holds the default pair together.
Per-kind counters (accepted, enacted, failed, refused) ride the anonymous
GET /health under dispatch, which is where the fleet-health sweep reads the measured
daemon share for its daily summary.
The anonymous refusal executes:
So does the stale refusal — a fixed timestamp is deterministically outside the 5-second window, which is exactly the receipt-relative rule this route lives by:
await fetch("https://evocation.nerthus.pl/v1/api/discord/dispatch", {
method: "POST",
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${dispatchToken}` },
body: JSON.stringify({
interactionId: "900000000000000001",
sentAtEpochMs: 1600000000000,
vars: { NERTHUS_CLOSE_DISCORD_ID: "555000555", NERTHUS_CLOSE_HINT: "" },
}),
}).then((r) => r.json());
The 202 happy path is shown as a non-executed listing: its sentAtEpochMs must be
within 5 seconds of the daemon's receipt, and a committed example carries a fixed
timestamp by definition — the executable coverage of the accept path is the contract
suite's (tests/contract/Api.Contract.Tests.ps1, the dispatch Describe).