igneum/docs/community/discord-hooks.md
igneum-labs 1a6c88631b Discord webhooks: the bot's pulse, digest, weekly, release and incident embeds, a watcher, a timer on the box
tools/community/discord-hooks.mjs (Node 22, standard library): one ember embed per post to #numbers, #announcements and
#incidents. Network pulse every 6 h (03/09/15/21 UK) from /api/stats, /api/live, /api/supply with the 6 h window from its
own snapshot and a Devnet 2 line that uses the fleet file's numbers and gate word only; a 24 h digest and the reddit kit's
weekly template at 09:00 UK; a release subcommand for the shipper (three downloads with sha256, node commit, digest, up to
five plain "what changed" lines read from the release plan); incident open and resolve by hand; a watcher that opens one
incident per condition (finality paused 5 min, median proof lag 15 min, observer silent 3 min) and resolves after 2 clear
minutes, and never opens on a condition already firing when it first looks (the pulse says "finality paused since" instead).

Guards before every post: the founder's name and logins, hosts, machine ids, paths, IP addresses, standalone 32-hex
tokens, dl.igneum.network outside /public/, webhook URLs, mentions, the tools/ci/forbidden-strings.txt patterns; Discord's
limits refused rather than cut; idempotency keys per slot in a state file; exponential backoff on 429; dry run by default
with a Discord-like preview in tools/community/out/preview.html. 30 tests on fixtures cut from the live API.

infra/build-server/discord-hooks: a tick every minute on igneum-build-1 (the Mac sleeps). install.sh copies the webhook file
to /srv/discord-hooks/env (mode 600, over ssh stdin) and refuses without IGNEUM_SECRET_ON_BOX_OK=1; the recorded exception
to rule R6 is in docs/plans/build-server.md (main's ruling, 6 October 2026).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 19:02:05 +00:00

6.8 KiB

Discord webhooks: the bot's posts to #numbers, #announcements and #incidents

6 October 2026. Three channel webhooks named "Igneum" (the Bot role in the server kit, docs/community/reddit/discord.md) carry the project's numbers to the Discord server. One script, tools/community/discord-hooks.mjs (Node 22, standard library only), shapes every post as a single embed: ember accent (#F2541B), the site icon as thumbnail, plain words, at most eight fields, UK time in the footer with UTC in brackets, never a mention, never a reply. Dry run is the default.

The posts

Post Channel When (UK) Content
Network pulse #numbers 03:00, 09:00, 15:00, 21:00 chain block and blocks/s over the 6 h window, hash rate and active vote keys ("a card runs several"), difficulty and its 6 h change, last lock (index, share of weight, age, voters), shards paid in the window and IGN paid to provers in the last 5 min, median proof lag and provers, reward and ramp, miner version share when /api/live exposes engines, Devnet 2 in one line (boxes, height, last gate PASS or FAIL)
Daily digest #numbers 09:00 a small monospace table, yesterday against now, with the 24 h deltas
Weekly numbers #numbers Monday 09:00 the reddit kit's template (docs/community/reddit/posts/02-weekly-numbers-template.md) as six fields with the last-week column, a link to the ledger
Release #announcements on a publish, by the shipper version, three download links with full sha256, node commit, consensus digest, up to five "what changed" lines read from the release plan
Incident open / resolve #incidents by hand, or by the watcher UTC time, what happened, who is affected per tier, what is being done; then cause, the rule or fix, duration

Every number comes from the public API (/api/stats, /api/live?window=300, /api/supply; docs/api/public-stats.md). The Devnet 2 line reads the fleet file and uses its numbers and the gate word only; the seed address and the state text never pass.

The watcher

watch (and every tick) opens one incident per condition and resolves it when the condition has been clear for 2 minutes. One open incident per condition at a time; the state file carries since / clear_since / open.

Condition Opens when Text
finality_paused finality.active false, or the locked index unchanged, for over 5 min finality paused; miners and provers paid as usual; nothing final until weight returns
proof_lag proving.median_proof_lag_s over 900 s proving behind; blocks final as usual, proofs late
observer_silent stale with age_s over 180 s, or the API unreachable for over 3 min the live numbers are stale; the chain is unaffected

The watcher's texts are fixed sentences in the script, each a stated rule, and are the only incidents nobody typed.

Commands

node tools/community/discord-hooks.mjs pulse | digest | weekly [--live] [--force]
node tools/community/discord-hooks.mjs release 0.3.14 --windows <url> --windows-sha <hex> --mac <url> --mac-sha <hex> --hive <url> --hive-sha <hex> \
     --node-commit 4c6b129d --digest "b18ed271 (thirteen fields, unchanged)" --plan docs/plans/release-0.3.14.md --section "1. Why" [--changed "line"]... [--live]
node tools/community/discord-hooks.mjs incident open --what "..." --affected "..." --doing "..." [--at 2026-10-06T15:44:00Z] [--id inc-...] [--live]
node tools/community/discord-hooks.mjs incident resolve --id inc-... --cause "..." --fix "..." [--at ...] [--live]
node tools/community/discord-hooks.mjs watch [--live]        one watcher pass
node tools/community/discord-hooks.mjs tick  [--live]        one scheduler pass (what the London clock says is due, then watch)
node tools/community/discord-hooks.mjs preview               rebuild tools/community/out/preview.html
node tools/community/discord-hooks.mjs check                 which webhooks are configured (names, never values)
node --test tools/community/discord-hooks.test.mjs

Dry run writes tools/community/out/<key>.json (git-ignored) and renders out/preview.html, a Discord-like dark page of every payload in the folder. The release subcommand takes the "what changed" lines from --changed flags or from the named section of the release plan: bullet lines as they are, table rows by their first cell, cut at the first semicolon, a long parenthetical dropped, 160 characters at most; a line that trips the guard is dropped with a note on stderr.

Guard rails

Rail How
Forbidden strings before every post: the founder's name and logins, hosting providers, internal host names, machine ids, internal paths, IP addresses, a standalone 32-hex token (a sha256 passes), any dl.igneum.network path outside /public/, a webhook URL, any mention; the post is refused with the pattern named
Limits title 256, description 4,096, field value 1,024, eight fields, 6,000 in total, content 2,000; refused, never cut silently
Idempotency a post id file (posts in the state) keyed pulse:<date>:<hour>, digest:<date>, weekly:<date>, release:<version>, `incident::open
429 exponential backoff from 1 s, doubling to 60 s, retry_after honoured, six tries, then exit 1
State written only after a successful live post; a dry run keeps its own state in out/state.json
Mentions allowed_mentions: {parse: []} on every payload and a guard that refuses @everyone, @here and user mentions in the text

Credentials and deployment

~/.config/igneum/discord, mode 600, KEY=VALUE lines: DISCORD_WEBHOOK_NUMBERS, DISCORD_WEBHOOK_ANNOUNCEMENTS, DISCORD_WEBHOOK_INCIDENTS. Never in the repository, never printed; check prints which keys are set.

The scheduler runs on igneum-build-1 because the Mac sleeps: infra/build-server/discord-hooks/{igneum-discord-hooks.service, igneum-discord-hooks.timer, install.sh}, a tick every minute. install.sh copies the script to /srv/discord-hooks/bin, the credentials file to /srv/discord-hooks/env (mode 600, owner build, over ssh stdin) and enables the timer. The repository rule says the box never holds a secret; a webhook URL is one, so the script refuses unless IGNEUM_SECRET_ON_BOX_OK=1 is set, which records the coordinator's ruling. A leaked webhook is deleted in Discord and the file replaced.

Tested, 6 October 2026

Dry run against the live API at 18:51 UTC rendered all six posts (pulse 641 characters, digest 477, weekly 1,699, release 1,501, incident open 716 and resolve 659). The guard refused the fleet file's seed address and a line with an internal path from the release plan. Finality was paused at render time (finality.active false, last lock 6842 at about 18:42Z, the newest checkpoints at 55% of total weight): the watcher's first condition, seen in dry run. Not yet run: a live post (no credentials file yet), the install on the box.