From 1a6c88631b2d1bd81fb0be7105521c2ed318123c Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:02:05 +0000 Subject: [PATCH] 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 --- .gitignore | 3 + docs/community/discord-hooks.md | 82 ++ docs/plans/build-server.md | 1 + .../igneum-discord-hooks.service | 28 + .../discord-hooks/igneum-discord-hooks.timer | 14 + infra/build-server/discord-hooks/install.sh | 34 + tools/community/discord-hooks.mjs | 788 ++++++++++++++++++ tools/community/discord-hooks.test.mjs | 354 ++++++++ tools/community/fixtures/discord/fleet.json | 14 + tools/community/fixtures/discord/live.json | 648 ++++++++++++++ tools/community/fixtures/discord/stats.json | 58 ++ tools/community/fixtures/discord/supply.json | 75 ++ 12 files changed, 2099 insertions(+) create mode 100644 docs/community/discord-hooks.md create mode 100644 infra/build-server/discord-hooks/igneum-discord-hooks.service create mode 100644 infra/build-server/discord-hooks/igneum-discord-hooks.timer create mode 100755 infra/build-server/discord-hooks/install.sh create mode 100755 tools/community/discord-hooks.mjs create mode 100644 tools/community/discord-hooks.test.mjs create mode 100644 tools/community/fixtures/discord/fleet.json create mode 100644 tools/community/fixtures/discord/live.json create mode 100644 tools/community/fixtures/discord/stats.json create mode 100644 tools/community/fixtures/discord/supply.json diff --git a/.gitignore b/.gitignore index e60bf5fb6..6bbd74b42 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ vendor/igneum-node-ship/ # Trademark instruction packs name the director and the applicant company; never in the repository brand/trademark/pbip-pack/ brand/trademark/*.zip + +# the Discord bot and reddit bot dry-run renders +tools/community/out/ diff --git a/docs/community/discord-hooks.md b/docs/community/discord-hooks.md new file mode 100644 index 000000000..0b1e0cd7f --- /dev/null +++ b/docs/community/discord-hooks.md @@ -0,0 +1,82 @@ +# 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 --windows-sha --mac --mac-sha --hive --hive-sha \ + --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/.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::`, `digest:`, `weekly:`, `release:`, `incident::open|resolve`; a rerun is skipped, `--force` posts again | +| 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. diff --git a/docs/plans/build-server.md b/docs/plans/build-server.md index 5951f79a8..2825ed829 100644 --- a/docs/plans/build-server.md +++ b/docs/plans/build-server.md @@ -81,6 +81,7 @@ Also logged for context: the Mac's Linux cross-build with zig (`infra/cross/buil | R4 | A worktree builds once on the box per commit plus overlay; the next build is incremental in `/srv/builds//.../target`. Nobody deletes another worktree's target dir on the box. | | R5 | `RUST_TOOLCHAIN` in provision.sh is bumped in the same commit as the Mac's `rustup update`; build-remote.sh refuses a mismatch. Add a `rust-toolchain.toml` to the fork and the repo (none exists today) so both sides pin from one file. | | R6 | The box is never a node host for the live devnet and never holds a secret (no `~/.config/igneum` there). The Devnet 2 seed on it runs under its own unit with `--devnet --devnet-suffix=` on 26611 (ufw already open) when that work starts. | +| R6a | ONE recorded exception to R6 (main's ruling, 6 October 2026, 20:0x UK): the three Discord webhook URLs live at `/srv/discord-hooks/env` (mode 600, owner build), installed by `infra/build-server/discord-hooks/install.sh` with `IGNEUM_SECRET_ON_BOX_OK=1`, because the Mac sleeps and the bot's timer must not. The reasoning: a webhook URL signs no release, moves no funds and reaches none of the devnet's hands; whoever holds it can only post as the bot, and a leaked one is deleted in Discord in one click and the file replaced. No other secret joins it; `check` prints key names, never values. | | R7 | CLAUDE.md line "nothing is built on a server" and "Windows builds go to the GitHub runner, Linux binaries come from infra/cross/build-linux.sh" are rewritten when R1 and R2 are adopted; until then the box is the measured option, not the rule. | ## 5. Gotchas met on the first day diff --git a/infra/build-server/discord-hooks/igneum-discord-hooks.service b/infra/build-server/discord-hooks/igneum-discord-hooks.service new file mode 100644 index 000000000..5b0ea6fe3 --- /dev/null +++ b/infra/build-server/discord-hooks/igneum-discord-hooks.service @@ -0,0 +1,28 @@ +# The Discord bot's scheduler on igneum-build-1: one `tick` a minute from igneum-discord-hooks.timer. +# A tick posts what the London clock says is due (pulse 03/09/15/21, digest and weekly at 09:00) and runs the watcher +# (finality paused, proof lag, observer silent). Idempotency keys in the state file make a repeated tick harmless. +# Installed by infra/build-server/discord-hooks/install.sh: the script to /srv/discord-hooks/bin, the webhook file to +# /srv/discord-hooks/env (mode 600, owner build), never in the repository. `journalctl -u igneum-discord-hooks -n 30` for the log. +[Unit] +Description=Igneum Discord webhooks (one scheduler tick) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=build +Group=build +Environment=HOME=/home/build +Environment=PATH=/usr/local/bin:/usr/bin:/bin +Environment=IGNEUM_DISCORD_ENV=/srv/discord-hooks/env +Environment=IGNEUM_DISCORD_STATE=/srv/discord-hooks/state.json +Environment=IGNEUM_DISCORD_OUT=/srv/discord-hooks/out +WorkingDirectory=/srv/discord-hooks +ExecStart=/usr/local/bin/node /srv/discord-hooks/bin/discord-hooks.mjs tick --live +TimeoutStartSec=50 +Nice=10 +# the unit reads one secret file; nothing else on the box may +PrivateTmp=yes +NoNewPrivileges=yes +ProtectSystem=strict +ReadWritePaths=/srv/discord-hooks diff --git a/infra/build-server/discord-hooks/igneum-discord-hooks.timer b/infra/build-server/discord-hooks/igneum-discord-hooks.timer new file mode 100644 index 000000000..126ca39dd --- /dev/null +++ b/infra/build-server/discord-hooks/igneum-discord-hooks.timer @@ -0,0 +1,14 @@ +# Fires the Discord scheduler tick every minute. `systemctl list-timers igneum-discord-hooks.timer` shows the next pass; +# the tick itself decides what is due by the London clock (pulse at 03:00, 09:00, 15:00, 21:00 UK; digest and weekly at 09:00). +[Unit] +Description=Igneum Discord webhooks, a tick every minute + +[Timer] +OnBootSec=45s +OnCalendar=*-*-* *:*:00 +AccuracySec=5s +Persistent=true +Unit=igneum-discord-hooks.service + +[Install] +WantedBy=timers.target diff --git a/infra/build-server/discord-hooks/install.sh b/infra/build-server/discord-hooks/install.sh new file mode 100755 index 000000000..25ae612b4 --- /dev/null +++ b/infra/build-server/discord-hooks/install.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Install or refresh the Discord webhooks scheduler on igneum-build-1 from this Mac. +# IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh +# Copies tools/community/discord-hooks.mjs to /srv/discord-hooks/bin, the webhook file ~/.config/igneum/discord to +# /srv/discord-hooks/env (mode 600, owner build; never into the repository), the two units, and enables the timer. +# Needs root over ssh (root@ with ~/.ssh/igneum_ed25519); the host ip comes from ~/.config/igneum/build-server (build@). +# +# The repository rule says the box never holds a secret. A webhook URL is one: whoever holds it posts as the bot. This +# script therefore refuses unless IGNEUM_SECRET_ON_BOX_OK=1 is set, which records that the coordinator ruled the exception. +# A leaked webhook is revoked in Discord (Channel settings > Integrations > Webhooks > Delete) and the file replaced here. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"; ROOT="$(cd "$HERE/../../.." && pwd)" +KEY="${IGNEUM_BUILD_KEY:-$HOME/.ssh/igneum_ed25519}" +CRED="${IGNEUM_DISCORD_ENV:-$HOME/.config/igneum/discord}" +HOST_LINE="$(head -1 "${IGNEUM_BUILD_HOST_FILE:-$HOME/.config/igneum/build-server}" | tr -d '[:space:]')" +IP="${HOST_LINE#*@}"; [ -n "$IP" ] || { echo "no build server in ~/.config/igneum/build-server" >&2; exit 1; } +[ "${IGNEUM_SECRET_ON_BOX_OK:-}" = "1" ] || { echo "refusing: the box holds no secret by rule; set IGNEUM_SECRET_ON_BOX_OK=1 once the coordinator has ruled the exception" >&2; exit 1; } +[ -f "$CRED" ] || { echo "no credentials file at $CRED (DISCORD_WEBHOOK_NUMBERS, _ANNOUNCEMENTS, _INCIDENTS)" >&2; exit 1; } +for k in DISCORD_WEBHOOK_NUMBERS DISCORD_WEBHOOK_ANNOUNCEMENTS DISCORD_WEBHOOK_INCIDENTS; do + grep -q "^$k=" "$CRED" || { echo "credentials file lacks $k" >&2; exit 1; } +done +MODE="$(stat -f %Lp "$CRED" 2>/dev/null || stat -c %a "$CRED")" +[ "$MODE" = "600" ] || { echo "credentials file must be mode 600 (is $MODE)" >&2; exit 1; } + +SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10) +"${SSH[@]}" "root@$IP" 'install -d -o build -g build -m 750 /srv/discord-hooks /srv/discord-hooks/bin /srv/discord-hooks/out' +scp -q -i "$KEY" "$ROOT/tools/community/discord-hooks.mjs" "build@$IP:/srv/discord-hooks/bin/" +# the secret travels over ssh stdin, never as a command-line argument and never through a world-readable temp file +"${SSH[@]}" "build@$IP" 'umask 077; cat > /srv/discord-hooks/env.tmp && mv -f /srv/discord-hooks/env.tmp /srv/discord-hooks/env && chmod 600 /srv/discord-hooks/env' < "$CRED" +scp -q -i "$KEY" "$HERE/igneum-discord-hooks.service" "$HERE/igneum-discord-hooks.timer" "root@$IP:/etc/systemd/system/" +"${SSH[@]}" "root@$IP" 'systemctl daemon-reload && systemctl enable --now igneum-discord-hooks.timer >/dev/null 2>&1; systemctl is-active igneum-discord-hooks.timer; systemctl list-timers igneum-discord-hooks.timer --no-pager | sed -n 2p' +# one check pass as the unit's user: names only, never values +"${SSH[@]}" "build@$IP" 'IGNEUM_DISCORD_ENV=/srv/discord-hooks/env /usr/local/bin/node /srv/discord-hooks/bin/discord-hooks.mjs check' +echo "installed; the first tick runs within a minute: journalctl -u igneum-discord-hooks -n 20" diff --git a/tools/community/discord-hooks.mjs b/tools/community/discord-hooks.mjs new file mode 100755 index 000000000..1c67936be --- /dev/null +++ b/tools/community/discord-hooks.mjs @@ -0,0 +1,788 @@ +#!/usr/bin/env node +// Igneum Discord webhooks: the labelled bot's posts to #numbers, #announcements and #incidents. +// Node 22, standard library only. Dry run is the default: payloads go to tools/community/out/.json and +// out/preview.html renders them as Discord embeds; --live posts through the webhooks in the credentials file. +// +// node tools/community/discord-hooks.mjs pulse [--live] network pulse → #numbers +// node tools/community/discord-hooks.mjs digest [--live] 24 h digest → #numbers (09:00 UK) +// node tools/community/discord-hooks.mjs weekly [--live] weekly numbers → #numbers (Monday 09:00 UK) +// node tools/community/discord-hooks.mjs release --windows --windows-sha --mac --mac-sha +// --hive --hive-sha --node-commit --digest [--plan --section ] [--changed "line"]... [--live] +// node tools/community/discord-hooks.mjs incident open --what "..." --affected "..." --doing "..." [--at ] [--id ] [--live] +// node tools/community/discord-hooks.mjs incident resolve --id --cause "..." --fix "..." [--at ] [--live] +// node tools/community/discord-hooks.mjs watch [--live] one watcher pass (opens and resolves automatic incidents) +// node tools/community/discord-hooks.mjs tick [--live] one scheduler pass: what is due by the London clock, plus watch +// node tools/community/discord-hooks.mjs preview rebuild out/preview.html from out/*.json +// node tools/community/discord-hooks.mjs check which webhooks are configured (names only, never values) +// +// Credentials: ~/.config/igneum/discord (or $IGNEUM_DISCORD_ENV), KEY=VALUE lines, mode 600, never in the repository, never printed: +// DISCORD_WEBHOOK_NUMBERS, DISCORD_WEBHOOK_ANNOUNCEMENTS, DISCORD_WEBHOOK_INCIDENTS. +// State: ~/.config/igneum/discord-hooks-state.json (or $IGNEUM_DISCORD_STATE) in live mode; out/state.json in dry run. +// Rules (docs/community/reddit/bot.md): the bot never replies, never mentions, posts numbers from the public API and +// incident sentences a person wrote (the watcher's three automatic conditions are the one exception, each a stated rule). + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; + +export const SITE = 'https://igneum.network'; +export const API = { stats: `${SITE}/api/stats`, live: `${SITE}/api/live?window=300`, supply: `${SITE}/api/supply` }; +export const FLEET_URL = 'https://dl.igneum.network/fleet-22adafa34bc2/fleet.json'; // read only; never appears in a post +export const ICON = `${SITE}/icon-512.png`; +export const EMBER = 0xF2541B; +export const BOT_NAME = 'Igneum'; +export const PULSE_HOURS_UK = [3, 9, 15, 21]; +export const LIMITS = { title: 256, description: 4096, fieldName: 256, fieldValue: 1024, footer: 2048, fields: 8, total: 6000, content: 2000 }; +export const WATCH = { finalityPausedS: 300, proofLagS: 900, observerSilentS: 180, clearHoldS: 120 }; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +export const OUT_DIR = process.env.IGNEUM_DISCORD_OUT || path.join(HERE, 'out'); +const CRED_FILE = process.env.IGNEUM_DISCORD_ENV || path.join(os.homedir(), '.config', 'igneum', 'discord'); +const STATE_FILE_LIVE = process.env.IGNEUM_DISCORD_STATE || path.join(os.homedir(), '.config', 'igneum', 'discord-hooks-state.json'); + +// ---------- guards ---------- +// Forbidden in any post: the founder's name and logins, rented-box and build hosts, machine ids, internal paths, IP addresses, +// a standalone 32-hex token (a sha256 is 64 hex and passes), any dl.igneum.network path outside /public/, any mention. +export const FORBIDDEN = [ + { re: /[user]/i, why: "the founder's name or login" }, + { re: /[removed]/i, why: "the founder's name" }, + { re: /hetzner|runpod|vast\.ai|\bvast\b/i, why: 'a hosting provider' }, + { re: /igneum-build-\d|seed\d?\.testnet|\btailscale\b/i, why: 'an internal host name' }, + { re: /DESKTOP-[A-Z0-9]{7}|\bae432dc7\b|\b1ccfe586\b|\bp[12]-\d{4}-\d\b/i, why: 'a machine id' }, + { re: /\b\d{1,3}(\.\d{1,3}){3}\b/, why: 'an IP address' }, + { re: /(^|[\s("'`])(\/Users\/|\/srv\/|\/root\b|\/var\/lib\/|\/home\/|~\/\.config|[A-Z]:\\)/, why: 'an internal path' }, + { re: /(?/, why: 'a mention' }, + { re: /\bBST\b|\+0100|CLAUDE\.md/, why: 'an internal reference' }, + // the rest of tools/ci/forbidden-strings.txt (the public export's committed identity list) + { re: /MacBook|ts\.net|~\/Desktop|log[-_]?intake|intake[_-]?key/i, why: 'a pattern from tools/ci/forbidden-strings.txt' }, +]; + +export function guardText(text, label = 'post') { + for (const { re, why } of FORBIDDEN) { + const m = re.exec(text); + if (m) throw new Error(`refusing ${label}: it contains ${why} (${JSON.stringify(m[0].slice(0, 24))})`); + } +} + +export function embedLength(e) { + let n = (e.title || '').length + (e.description || '').length + ((e.footer && e.footer.text) || '').length + ((e.author && e.author.name) || '').length; + for (const f of e.fields || []) n += (f.name || '').length + (f.value || '').length; + return n; +} + +export function guardPayload(payload) { + if (!payload.embeds || payload.embeds.length !== 1) throw new Error('a post is exactly one embed'); + const e = payload.embeds[0]; + if ((payload.content || '').length > LIMITS.content) throw new Error(`content over ${LIMITS.content}`); + if ((e.title || '').length > LIMITS.title) throw new Error(`title over ${LIMITS.title}`); + if ((e.description || '').length > LIMITS.description) throw new Error(`description over ${LIMITS.description}`); + if ((e.fields || []).length > LIMITS.fields) throw new Error(`more than ${LIMITS.fields} fields`); + for (const f of e.fields || []) { + if (!f.name || !f.value) throw new Error('a field needs a name and a value'); + if (f.name.length > LIMITS.fieldName) throw new Error(`field name over ${LIMITS.fieldName}`); + if (f.value.length > LIMITS.fieldValue) throw new Error(`field value over ${LIMITS.fieldValue}: ${f.name}`); + } + if (((e.footer && e.footer.text) || '').length > LIMITS.footer) throw new Error(`footer over ${LIMITS.footer}`); + const total = embedLength(e); + if (total > LIMITS.total) throw new Error(`embed over ${LIMITS.total} characters (${total})`); + const allText = [payload.content, e.title, e.description, e.footer && e.footer.text, ...(e.fields || []).flatMap(f => [f.name, f.value])].filter(Boolean).join('\n'); + guardText(allText, 'post'); + if (!payload.allowed_mentions || (payload.allowed_mentions.parse || []).length) throw new Error('allowed_mentions must be empty'); + return total; +} + +// ---------- formatting ---------- +export const fmtInt = n => (n === null || n === undefined || Number.isNaN(Number(n)) ? 'n/a' : Math.round(Number(n)).toLocaleString('en-GB')); +export const fmtNum = (n, d = 2) => (n === null || n === undefined || Number.isNaN(Number(n)) ? 'n/a' : Number(n).toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d })); +export function fmtHash(h) { + if (h === null || h === undefined || Number.isNaN(Number(h))) return 'n/a'; + const n = Number(h); + if (n >= 1e12) return `${(n / 1e12).toFixed(2)} TH/s`; + if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GH/s`; + if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MH/s`; + if (n >= 1e3) return `${(n / 1e3).toFixed(1)} kH/s`; + return `${Math.round(n)} H/s`; +} +export function fmtDur(s) { + if (s === null || s === undefined || Number.isNaN(Number(s))) return 'n/a'; + s = Math.max(0, Math.round(Number(s))); + if (s < 60) return `${s} s`; + const m = Math.round(s / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60), r = m % 60; + if (h < 48) return r ? `${h} h ${r} min` : `${h} h`; + return `${Math.round(h / 24)} days`; +} +export function fmtPct(x, d = 1) { return x === null || x === undefined || Number.isNaN(Number(x)) ? 'n/a' : `${(Number(x) * 100).toFixed(d)}%`; } +export function fmtDelta(now, prev, unit = '') { + if (now === null || prev === null || now === undefined || prev === undefined) return 'n/a'; + const d = Number(now) - Number(prev); + const sign = d > 0 ? '+' : d < 0 ? '−' : '±'; + return `${sign}${fmtInt(Math.abs(d))}${unit}`; +} +export function fmtChangePct(now, prev) { + if (!now || !prev) return 'n/a'; + const p = (Number(now) - Number(prev)) / Number(prev) * 100; + const sign = p > 0 ? '+' : p < 0 ? '−' : '±'; + return `${sign}${Math.abs(p).toFixed(1)}%`; +} +export const plural = (n, one, many = `${one}s`) => `${fmtInt(n)} ${Number(n) === 1 ? one : many}`; +export function fmtIgn(x, d = 2) { return x === null || x === undefined || Number.isNaN(Number(x)) ? 'n/a' : `${Number(x).toLocaleString('en-GB', { minimumFractionDigits: d, maximumFractionDigits: d })} IGN`; } + +// London clock. Footer: "6 Oct 2026, 20:12 UK (19:12 UTC)". +const LONDON = new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false, weekday: 'short' }); +export function londonParts(d = new Date()) { + const p = Object.fromEntries(LONDON.formatToParts(d).map(x => [x.type, x.value])); + return { weekday: p.weekday, day: Number(p.day), month: p.month, year: Number(p.year), hour: Number(p.hour) % 24, minute: Number(p.minute) }; +} +export function londonDateKey(d = new Date()) { + const p = Object.fromEntries(new Intl.DateTimeFormat('en-GB', { timeZone: 'Europe/London', year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(d).map(x => [x.type, x.value])); + return `${p.year}-${p.month}-${p.day}`; +} +export function ukStamp(d = new Date()) { + const l = londonParts(d); + const hh = String(l.hour).padStart(2, '0'), mm = String(l.minute).padStart(2, '0'); + const utc = `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + return `${l.day} ${l.month} ${l.year}, ${hh}:${mm} UK (${utc} UTC)`; +} +export const utcStamp = d => `${d.toISOString().slice(0, 16).replace('T', ' ')} UTC`; +export const ageOf = (iso, now = Date.now()) => (iso ? fmtDur((now - new Date(iso).getTime()) / 1000) : 'n/a'); + +// ---------- the embed ---------- +export function embed({ title, url, description, fields = [], footerNote, now = new Date(), thumbnail = true }) { + const e = { + title, url, description, color: EMBER, + fields: fields.filter(Boolean).slice(0, LIMITS.fields).map(f => ({ name: f.name, value: f.value, inline: f.inline !== false })), + footer: { text: `${ukStamp(now)}${footerNote ? ` · ${footerNote}` : ''}` }, + timestamp: now.toISOString(), + }; + if (thumbnail) e.thumbnail = { url: ICON }; + return { username: BOT_NAME, avatar_url: ICON, embeds: [e], allowed_mentions: { parse: [] } }; +} + +// ---------- data ---------- +export async function fetchJson(url, timeoutMs = 15000) { + const r = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), headers: { 'user-agent': 'igneum-discord-hooks/1 (the project; posts numbers, never replies)' } }); + if (!r.ok) throw new Error(`${url} answered ${r.status}`); + return r.json(); +} +export async function fetchAll({ fleet = true } = {}) { + const [stats, live, supply] = await Promise.all([fetchJson(API.stats), fetchJson(API.live), fetchJson(API.supply)]); + let fleetJson = null; + if (fleet) { try { fleetJson = await fetchJson(FLEET_URL, 8000); } catch { fleetJson = null; } } + return { stats, live, supply, fleet: fleetJson, now: new Date() }; +} + +// One line of numbers from the three endpoints, stored per pulse so the next one has its window. +export function snapshot({ stats, live, supply, now }) { + const lock = newestLock(live); + return { + at: now.toISOString(), + height: stats.height, block_count: stats.block_count, daa: stats.daa, difficulty: stats.difficulty, hashrate: stats.hashrate, + miners_10m: stats.miners_10m, peers: stats.peers, node_version: stats.node_version, + block_time_measured_s: stats.block_time_measured_s, blocks_per_day_measured: stats.blocks_per_day_measured, + locked_index: (live.finality && live.finality.latest_locked_index) ?? (stats.finality && stats.finality.latest_locked_index) ?? null, + finality_active: !!(stats.finality && stats.finality.active), + voters: live.finality && live.finality.weights ? live.finality.weights.voters ?? null : null, + last_lock_fraction: lock ? lock.fraction_total : null, + paid_shards_total: live.proving && live.proving.paid_shards_total !== undefined ? live.proving.paid_shards_total : null, + median_proof_lag_s: live.proving ? live.proving.median_proof_lag_s ?? null : null, + provers_10m: live.proving ? live.proving.provers_10m ?? null : null, + proving_active: !!(live.proving && live.proving.active), + circulating_ign: supply.circulating_ign !== undefined ? Number(supply.circulating_ign) : null, + ramp_factor: supply.ramp ? supply.ramp.factor_now : null, + }; +} +export function newestLock(live) { + const cps = (live.finality && live.finality.checkpoints) || []; + return cps.filter(c => c.state === 'locked').slice(-1)[0] || null; +} +export function paidInWindow(live) { + let ign = 0, shards = 0; + for (const b of live.blocks || []) for (const s of b.shards || []) if (s.state === 'paid') { shards++; ign += s.payout || 0; } + return { ign, shards }; +} +export function versionShare(live) { + const engines = (live.miners || []).map(m => m.engine).filter(Boolean); + if (!engines.length) return null; + const count = new Map(); + for (const e of engines) count.set(e, (count.get(e) || 0) + 1); + return [...count.entries()].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([v, n]) => `${v} ${Math.round(n / engines.length * 100)}%`).join(', '); +} +// Devnet 2 from the fleet file: numbers and the gate word only. Never its seed, its state text or its box names. +export function devnet2Line(fleet, now = Date.now()) { + const d = fleet && fleet.devnet2; + if (!d || d.height === undefined) return null; + const gate = typeof d.last_gate === 'string' ? (/^PASS/i.test(d.last_gate) ? 'PASS' : /^FAIL/i.test(d.last_gate) ? 'FAIL' : null) : null; + const parts = [`${fmtInt(d.boxes)} boxes`, `height ${fmtInt(d.height)}`]; + if (gate) parts.push(`last gate ${gate} ${ageOf(d.last_gate_at, now)} ago`); + return parts.join(', '); +} +function closestBefore(snaps, targetMs, toleranceMs) { + let best = null; + for (const s of snaps) { + const t = new Date(s.at).getTime(); + if (t > targetMs + toleranceMs) continue; + if (!best || Math.abs(t - targetMs) < Math.abs(new Date(best.at).getTime() - targetMs)) best = s; + } + return best; +} + +// ---------- 1. the network pulse ---------- +export function shapePulse(data, prev) { + const { stats, live, supply, fleet, now } = data; + const cur = snapshot(data); + const windowS = prev ? (now.getTime() - new Date(prev.at).getTime()) / 1000 : null; + const bps = prev && windowS > 0 && cur.block_count !== null && prev.block_count !== null + ? (cur.block_count - prev.block_count) / windowS + : (live.state && live.state.blocks_per_second_60s) ?? null; + const bpsNote = prev ? `over ${fmtDur(windowS)}` : 'last 60 s'; + const lock = newestLock(live); + const paid = paidInWindow(live); + const pv = live.proving || {}; + const vshare = versionShare(live); + const dn2 = devnet2Line(fleet, now.getTime()); + const stale = !!stats.stale; + + const paused = !(stats.finality && stats.finality.active); + const pausedLine = paused ? `\nFinality paused since ${lock && lock.locked_at ? utcStamp(new Date(lock.locked_at)) : 'the last lock'}: nothing is final until two thirds of the 30-day weight signs again; mining and proving are paid as usual.` : ''; + const description = (stale + ? `Observer stale for ${fmtDur(stats.age_s)}: every number is last known. Live: ${SITE}/live` + : `${stats.network || 'devnet'}, node ${stats.node_version || 'n/a'}. Devnet coins have no value. Live: ${SITE}/live`) + pausedLine; + + const fields = [ + { name: 'Chain block', value: `${fmtInt(stats.height)}\n${bps === null ? 'n/a' : bps.toFixed(2)} blocks/s ${bpsNote}` }, + { name: 'Hash rate', value: `${fmtHash(stats.hashrate)}\n${fmtInt(stats.miners_10m)} vote keys active (a card runs several)` }, + { name: 'Difficulty', value: `${fmtNum(stats.difficulty, 0)}\n${prev ? `${fmtChangePct(stats.difficulty, prev.difficulty)} in ${fmtDur(windowS)}` : 'first pulse, no window yet'}` }, + { name: 'Last lock', value: lock + ? `checkpoint ${fmtInt(lock.index)} at ${fmtPct(lock.fraction_total)} of weight\n${ageOf(lock.locked_at, now.getTime())} ago, ${plural(lock.voters, 'voter')}` + : `${stats.finality && stats.finality.active ? 'active' : 'paused'}, latest index ${fmtInt(cur.locked_index)}` }, + { name: 'Proving', value: `${prev && prev.paid_shards_total !== null ? `${fmtDelta(cur.paid_shards_total, prev.paid_shards_total)} shards paid in ${fmtDur(windowS)}` : `${fmtInt(cur.paid_shards_total)} shards paid in total`}\n${fmtIgn(paid.ign, 2)} to provers in the last 5 min` }, + { name: 'Proof lag', value: `median ${pv.median_proof_lag_s === null || pv.median_proof_lag_s === undefined ? 'n/a' : fmtDur(pv.median_proof_lag_s)}\n${plural(pv.provers_10m, 'prover')} in 10 min${pv.verifier ? `, verifier ${pv.verifier}` : ''}` }, + { name: 'Reward', value: `${fmtIgn(stats.block_reward && stats.block_reward.ign, 4)} per block\nramp ${fmtPct(cur.ramp_factor, 1)}, ${fmtIgn(cur.circulating_ign, 0)} minted` }, + ]; + if (vshare) fields.push({ name: 'Miner versions', value: vshare }); + if (dn2 && fields.length < LIMITS.fields) fields.push({ name: 'Devnet 2 (staging)', value: dn2 }); + + const payload = embed({ title: 'Network pulse', url: `${SITE}/live`, description, fields, footerNote: 'every 6 hours from /api/stats, /api/live, /api/supply', now }); + return { payload, snapshot: cur }; +} + +// ---------- the 24 h digest ---------- +export function shapeDigest(data, snaps) { + const { now } = data; + const cur = snapshot(data); + const prev = closestBefore(snaps, now.getTime() - 24 * 3600e3, 3 * 3600e3); + const row = (label, a, b, fmt = fmtInt) => `${label.padEnd(11)} ${String(fmt(a)).padStart(11)} ${String(fmt(b)).padStart(11)}`; + const lines = [ + `${'24 h'.padEnd(11)} ${'yesterday'.padStart(11)} ${'now'.padStart(11)}`, + row('chain block', prev && prev.height, cur.height), + row('DAG blocks', prev && prev.block_count, cur.block_count), + row('hash rate', prev && prev.hashrate, cur.hashrate, fmtHash), + row('difficulty', prev && prev.difficulty, cur.difficulty, v => (v === null || v === undefined ? 'n/a' : fmtNum(v / 1e6, 1) + 'M')), + row('vote keys', prev && prev.miners_10m, cur.miners_10m), + row('locks', prev && prev.locked_index, cur.locked_index), + row('shards paid', prev && prev.paid_shards_total, cur.paid_shards_total), + row('minted IGN', prev && prev.circulating_ign, cur.circulating_ign, v => (v === null || v === undefined ? 'n/a' : fmtInt(v))), + ]; + const deltas = prev ? [ + `Blocks ${fmtDelta(cur.block_count, prev.block_count)}, locks ${fmtDelta(cur.locked_index, prev.locked_index)}, shards paid ${fmtDelta(cur.paid_shards_total, prev.paid_shards_total)}, minted ${fmtDelta(cur.circulating_ign, prev.circulating_ign, ' IGN')}.`, + `Hash rate ${fmtChangePct(cur.hashrate, prev.hashrate)}, difficulty ${fmtChangePct(cur.difficulty, prev.difficulty)}.`, + ].join('\n') : 'No snapshot from 24 h ago yet; the table fills from tomorrow.'; + const description = `\`\`\`\n${lines.join('\n')}\n\`\`\`\n${deltas}`; + const payload = embed({ title: 'Daily digest', url: `${SITE}/live`, description, fields: [], footerNote: 'yesterday is the 09:00 pulse 24 h ago', now, thumbnail: false }); + return { payload, snapshot: cur }; +} + +// ---------- 4. the weekly numbers (the reddit kit's template as an embed) ---------- +export function shapeWeekly(data, prev, incidents = []) { + const { stats, live, supply, now } = data; + const cur = snapshot(data); + const lw = (v, fmt = fmtInt) => (prev ? ` (last week ${fmt(v)})` : ''); + const lock = newestLock(live); + const pv = live.proving || {}; + const chk = supply.check || {}; + const monday = new Date(now); const dow = (monday.getUTCDay() + 6) % 7; monday.setUTCDate(monday.getUTCDate() - dow); + const weekOf = `${monday.getUTCDate()} ${monday.toLocaleString('en-GB', { month: 'short', timeZone: 'UTC' })} ${monday.getUTCFullYear()}`; + const staleLine = stats.stale ? `Observer stale for ${fmtDur(stats.age_s)}: every number is last known.\n` : ''; + const description = `${staleLine}Weekly numbers from the public API. Every line names its field; raw JSON at ${SITE}/api/stats, /api/live and /api/supply. Devnet coins have no value and the chain may be reset. ${prev ? '' : 'First weekly post: no last-week column yet. '}Questions go to #ask; this bot never replies.\nLedger: ${SITE}/ledger`; + const fields = [ + { name: 'Chain (/api/stats)', inline: false, value: [ + `height ${fmtInt(stats.height)}${lw(prev && prev.height)}`, + `block_count ${fmtInt(stats.block_count)}${lw(prev && prev.block_count)}`, + `hashrate ${fmtHash(stats.hashrate)}${lw(prev && prev.hashrate, fmtHash)}`, + `difficulty ${fmtNum(stats.difficulty, 0)}`, + `block_time_measured_s ${fmtNum(stats.block_time_measured_s, 3)} against 1 s target`, + `blocks_per_day_measured ${fmtInt(stats.blocks_per_day_measured)} against 86,400`, + `miners_10m ${fmtInt(stats.miners_10m)} vote keys (a card runs several)${lw(prev && prev.miners_10m)}, peers ${fmtInt(stats.peers)}`, + ].join('\n') }, + { name: 'Reward and supply (/api/stats block_reward, /api/supply)', inline: false, value: [ + `block_reward.ign ${fmtIgn(stats.block_reward && stats.block_reward.ign, 8)} per blue block, 80% miner / 20% proving pool`, + `ramp_factor ${fmtPct(cur.ramp_factor, 1)} of the schedule (30-day launch ramp)`, + `circulating_ign ${fmtIgn(cur.circulating_ign, 2)} of 4,000,000,000`, + `check.rule_match ${fmtInt(chk.rule_match)} / ${fmtInt(chk.sampled)} blocks match the emission rule`, + ].join('\n') }, + { name: 'Finality (/api/stats finality, /api/live finality)', inline: false, value: [ + `active ${cur.finality_active ? 'yes' : 'no'}${prev ? ` (last week ${prev.finality_active ? 'yes' : 'no'})` : ''}`, + `latest_locked_index ${fmtInt(cur.locked_index)}${lw(prev && prev.locked_index)}, locks this week ${prev ? fmtDelta(cur.locked_index, prev.locked_index) : 'n/a'}`, + `voters in the window ${fmtInt(cur.voters)}${lw(prev && prev.voters)}`, + `last lock ${lock ? `checkpoint ${fmtInt(lock.index)} at ${fmtPct(lock.fraction_total)} of weight` : 'n/a'}`, + ].join('\n') }, + { name: 'Proving (/api/live proving)', inline: false, value: [ + `active ${cur.proving_active ? 'yes' : 'no'}, verifier ${pv.verifier || 'n/a'}`, + `paid_shards_total ${fmtInt(cur.paid_shards_total)}${lw(prev && prev.paid_shards_total)}, shards this week ${prev ? fmtDelta(cur.paid_shards_total, prev.paid_shards_total) : 'n/a'}`, + `shards_paid_10m ${fmtInt(pv.shards_paid_10m)} / blocks_10m ${fmtInt(pv.blocks_10m)}, blocks_fully_proven_10m ${fmtInt(pv.blocks_fully_proven_10m)}`, + `median_proof_lag_s ${pv.median_proof_lag_s === null || pv.median_proof_lag_s === undefined ? 'n/a' : fmtDur(pv.median_proof_lag_s)} (target under 60 s)${lw(prev && prev.median_proof_lag_s, v => (v === null || v === undefined ? 'n/a' : fmtDur(v)))}`, + `provers_10m ${fmtInt(pv.provers_10m)}${lw(prev && prev.provers_10m)}`, + ].join('\n') }, + { name: 'Incidents this week', inline: false, value: incidents.length ? incidents.slice(0, 6).map(s => `• ${s}`).join('\n').slice(0, LIMITS.fieldValue) : 'None recorded by the project this week.' }, + { name: 'What these mean', inline: false, value: [ + '• Divide the network hash rate by a card\'s MH/s for the share one card holds this week.', + '• A ramp under 100% means every block pays that fraction of the schedule.', + '• A proof lag over 60 s means the phase 3 gate is open.', + '• Vote keys are not machines; the machine count is owed (ledger X5).', + ].join('\n') }, + ]; + const payload = embed({ title: `Devnet numbers, week of ${weekOf}`, url: `${SITE}/live`, description, fields, footerNote: 'every Monday 09:00 UK', now }); + return { payload, snapshot: cur }; +} + +// ---------- 2. the release ---------- +// "What changed": up to five plain lines. From --changed flags, or read from the named section of the release plan: +// bullet lines as they are; table rows by their first cell, cut at the first semicolon, code ticks removed, cut to 160 characters +// at a clause boundary. A line that trips the guard is dropped with a note on stderr; the plan is an internal document. +export function changedLinesFromPlan(markdown, sectionHeading, max = 5, warn = () => {}) { + const lines = markdown.split('\n'); + const start = lines.findIndex(l => /^#{1,6}\s/.test(l) && l.replace(/^#+\s*/, '').toLowerCase().startsWith(sectionHeading.toLowerCase())); + if (start < 0) throw new Error(`section not found in the plan: ${sectionHeading}`); + const out = []; + for (let i = start + 1; i < lines.length && out.length < max; i++) { + const l = lines[i]; + if (/^#{1,6}\s/.test(l)) break; + let text = null; + if (/^\s*[-*]\s+/.test(l)) text = l.replace(/^\s*[-*]\s+/, ''); + else if (/^\|/.test(l) && !/^\|\s*-{2,}/.test(l)) { + const cells = l.split('|').map(c => c.trim()).filter((c, k, a) => k > 0 && k < a.length - 1); + if (cells.length && !/^(change|what|item|step)$/i.test(cells[0])) text = cells[0]; + } + if (!text) continue; + text = plainLine(text); + if (!text) continue; + try { guardText(text, 'a changed line'); out.push(text); } catch (e) { warn(`dropped a changed line: ${e.message}`); } + } + return out; +} +export function plainLine(text, max = 160) { + let t = text.replace(/`/g, '').replace(/\*\*/g, '').replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim(); + const semi = t.indexOf(';'); if (semi > 20) t = t.slice(0, semi); + t = t.trim().replace(/[,:]$/, ''); + if (t.length > max) { + // a parenthetical is the first detail to drop, then the last clause before the limit, then the last word + const paren = t.indexOf(' (', 30); + if (paren > 30) t = t.slice(0, paren) + (t.indexOf(')', paren) > 0 && t.indexOf(')', paren) < t.length - 1 ? t.slice(t.indexOf(')', paren) + 1) : ''); + } + if (t.length > max) { + const cut = Math.max(t.lastIndexOf(', ', max), t.lastIndexOf(' and ', max)); + t = (cut > 40 ? t.slice(0, cut) : t.slice(0, t.lastIndexOf(' ', max))).trim(); + } + return t.replace(/[,:(]$/, '').trim(); +} +export function shapeRelease({ version, windows, windowsSha, mac, macSha, hive, hiveSha, nodeCommit, digest, changed = [], now = new Date() }) { + for (const [k, v] of Object.entries({ version, windows, windowsSha, mac, macSha, hive, hiveSha, nodeCommit, digest })) if (!v) throw new Error(`release needs ${k}`); + for (const [k, v] of Object.entries({ windowsSha, macSha, hiveSha })) if (!/^[0-9a-f]{64}$/i.test(v)) throw new Error(`${k} is not a sha256`); + const lines = changed.slice(0, 5).map(l => plainLine(l)).filter(Boolean); + const description = `Igneum Miner ${version} is live for Windows, macOS and HiveOS. The app updates itself; a fresh install is at ${SITE}/miner#get.${lines.length ? `\n\n**What changed**\n${lines.map(l => `• ${l}`).join('\n')}` : ''}`; + const dl = (url, sha) => `[Download](${url})\n\`${sha}\``; + const fields = [ + { name: 'Windows', value: dl(windows, windowsSha) }, + { name: 'macOS', value: dl(mac, macSha) }, + { name: 'HiveOS', value: dl(hive, hiveSha) }, + { name: 'Node commit', value: `\`${nodeCommit}\`` }, + { name: 'Consensus digest', value: `\`${digest}\`` }, + { name: 'Verify', value: '`shasum -a 256 ` on Mac and Linux, `certutil -hashfile SHA256` on Windows; the hash must match the line above.', inline: false }, + ]; + return { payload: embed({ title: `Igneum Miner ${version}`, url: `${SITE}/miner`, description, fields, footerNote: 'signed release notes on igneum.network/miner', now }) }; +} + +// ---------- 3. incidents ---------- +export function shapeIncidentOpen({ id, at = new Date(), what, affected, doing, now = new Date() }) { + for (const [k, v] of Object.entries({ id, what, affected, doing })) if (!v) throw new Error(`incident open needs ${k}`); + const fields = [ + { name: 'Opened', value: utcStamp(at) }, + { name: 'Incident', value: `\`${id}\`` }, + { name: 'Who is affected', value: affected, inline: false }, + { name: 'What is being done', value: doing, inline: false }, + ]; + return { payload: embed({ title: 'Incident open', url: `${SITE}/live`, description: what, fields, footerNote: 'resolved here when it clears', now }) }; +} +export function shapeIncidentResolve({ id, openedAt, at = new Date(), cause, fix, what, now = new Date() }) { + for (const [k, v] of Object.entries({ id, cause, fix })) if (!v) throw new Error(`incident resolve needs ${k}`); + const dur = openedAt ? fmtDur((new Date(at).getTime() - new Date(openedAt).getTime()) / 1000) : 'n/a'; + const fields = [ + { name: 'Resolved', value: utcStamp(at) }, + { name: 'Duration', value: dur }, + { name: 'Incident', value: `\`${id}\`` }, + { name: 'Cause', value: cause, inline: false }, + { name: 'The rule or fix', value: fix, inline: false }, + ]; + return { payload: embed({ title: 'Incident resolved', url: `${SITE}/live`, description: what || 'Resolved.', fields, footerNote: 'UTC times; the project\'s own record', now }) }; +} + +// The watcher: three conditions from the public API. One open incident per condition; a condition opens after it has held +// for its threshold and resolves after it has been clear for WATCH.clearHoldS. State carries since/clear_since per condition. +export const WATCH_CONDITIONS = { + finality_paused: { + holdS: WATCH.finalityPausedS, + test: ({ stats, live }, st, now) => { + const active = !!(stats.finality && stats.finality.active); + const idx = (live.finality && live.finality.latest_locked_index) ?? (stats.finality && stats.finality.latest_locked_index) ?? null; + // the index not moving for the threshold counts as paused too + if (idx !== null && st.lastIdx !== idx) { st.lastIdx = idx; st.lastIdxAt = now.toISOString(); } + const stuck = st.lastIdxAt ? (now.getTime() - new Date(st.lastIdxAt).getTime()) / 1000 > WATCH.finalityPausedS : false; + return !active || stuck; + }, + what: ({ stats }) => `Finality paused: the node reports ${stats.finality && stats.finality.active ? 'no new lock' : 'finality inactive'} for over ${fmtDur(WATCH.finalityPausedS)}. Blocks keep being produced; the chain is not locking checkpoints.`, + affected: 'Everyone equally: no transaction is final until locks resume. Miners keep mining and are paid; provers keep proving; wallets and the explorer show unlocked blocks.', + doing: 'The project is reading the vote weight in the window (finality needs two thirds of all 30-day weight connected and signing). If miners left the network, the lock resumes as they return or as the window rolls.', + cause: 'Under two thirds of the 30-day vote weight was connected and signing, or the observer saw no lock advance.', + fix: 'Locks resumed by themselves once enough weight was back. Rule unchanged: finality pauses rather than locks on a minority.', + }, + proof_lag: { + holdS: 0, + test: ({ live }) => !!(live.proving && live.proving.median_proof_lag_s !== null && live.proving.median_proof_lag_s !== undefined && live.proving.median_proof_lag_s > WATCH.proofLagS), + what: ({ live }) => `Proving behind: the median proof lag is ${fmtDur(live.proving.median_proof_lag_s)} against a target under 60 s.`, + affected: 'Blocks are produced and final as usual; their proofs arrive late. Provers earn the same per shard. Anyone reading proven state (bridges, the explorer\'s proven badge) sees it minutes behind the tip.', + doing: 'The project is checking prover count and shard sizes. More provers or smaller shards close the gap; the 8 to 16 GB prover is the open item.', + cause: 'Fewer provers than the block rate needs, or large shards.', + fix: 'The median lag is back under the threshold. The phase 3 target stays under 60 s.', + }, + observer_silent: { + holdS: WATCH.observerSilentS, + test: ({ stats, fetchFailed }) => !!fetchFailed || (!!stats.stale && (stats.age_s ?? Infinity) > WATCH.observerSilentS), + what: ({ stats, fetchFailed }) => fetchFailed ? 'The public API did not answer for over 3 minutes.' : `The observer has not written for ${fmtDur(stats.age_s)}: /api/stats and /api/live show last-known numbers.`, + affected: 'Nobody on the chain: blocks, mining and proving continue. The live page, the explorer and this bot show stale numbers until the observer returns.', + doing: 'The observer is being restarted or its node reconnected. Numbers resume by themselves.', + cause: 'The observer process or its node stopped writing.', + fix: 'The observer is writing again; the numbers are current.', + }, +}; + +export function watchPass(data, state, now = data.now) { + state.watch = state.watch || {}; + const actions = []; + for (const [key, c] of Object.entries(WATCH_CONDITIONS)) { + const st = state.watch[key] = state.watch[key] || {}; + let firing = false; + try { firing = c.test(data, st, now); } catch { firing = false; } + // A condition already firing when the watcher first looks is pre-existing: it was not observed to start, so it is + // recorded and never opened (the resolve would have no open to match). It clears silently; the next start is a + // transition the watcher saw and opens as normal. The pulse carries "finality paused since" in the meantime. + if (!st.seen) { st.seen = now.toISOString(); if (firing) { st.preexisting = { since: now.toISOString() }; continue; } } + if (st.preexisting) { if (firing) continue; st.preexisting = null; st.since = null; continue; } + if (firing) { + st.clear_since = null; + if (!st.since) st.since = now.toISOString(); + const heldS = (now.getTime() - new Date(st.since).getTime()) / 1000; + if (!st.open && heldS >= c.holdS) { + const id = `auto-${key}-${now.toISOString().slice(0, 16).replace(/[-:T]/g, '')}`; + st.open = { id, at: now.toISOString() }; + actions.push({ kind: 'open', key, id, what: c.what(data), affected: c.affected, doing: c.doing, at: now }); + } + } else { + st.since = null; + if (st.open) { + if (!st.clear_since) st.clear_since = now.toISOString(); + const clearS = (now.getTime() - new Date(st.clear_since).getTime()) / 1000; + if (clearS >= WATCH.clearHoldS) { + actions.push({ kind: 'resolve', key, id: st.open.id, openedAt: st.open.at, cause: c.cause, fix: c.fix, at: now }); + st.open = null; st.clear_since = null; + } + } + } + } + return actions; +} + +// ---------- credentials, state, posting ---------- +export function readCredentials(file = CRED_FILE) { + if (!fs.existsSync(file)) return null; + const out = {}; + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const m = /^\s*([A-Z_]+)\s*=\s*(.*?)\s*$/.exec(line); + if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, ''); + } + return out; +} +export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS' }; + +export function loadState(file) { + try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { posts: {}, pulses: [], incidents: {}, watch: {} }; } +} +export function saveState(file, state) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const tmp = `${file}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 1), { mode: 0o600 }); + fs.renameSync(tmp, file); +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); +// POST with ?wait=true so the message id comes back; exponential backoff on 429 (retry_after honoured) and on 5xx. +export async function postWebhook(url, payload, { fetchImpl = fetch, maxTries = 6, sleepImpl = sleep } = {}) { + let wait = 1000; + for (let i = 1; i <= maxTries; i++) { + const r = await fetchImpl(`${url}${url.includes('?') ? '&' : '?'}wait=true`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) }); + if (r.status === 429 || r.status >= 500) { + let retryAfter = Number(r.headers.get('retry-after')) || 0; + try { const j = await r.json(); if (j && j.retry_after) retryAfter = Math.max(retryAfter, Number(j.retry_after)); } catch { /* no body */ } + if (i === maxTries) throw new Error(`webhook answered ${r.status} after ${maxTries} tries`); + await sleepImpl(Math.max(wait, retryAfter * 1000)); + wait = Math.min(wait * 2, 60000); + continue; + } + if (!r.ok) throw new Error(`webhook answered ${r.status}`); // the body may echo the URL; never printed + const j = await r.json().catch(() => ({})); + return { id: j.id || null, channel_id: j.channel_id || null }; + } + throw new Error('unreachable'); +} + +export class Poster { + constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch } = {}) { + this.live = live; this.outDir = outDir; this.force = force; this.log = log; this.fetchImpl = fetchImpl; + this.creds = live ? readCredentials(credFile) : null; + if (live && !this.creds) throw new Error(`no credentials file at ${credFile}; create it with the three webhook keys (mode 600) or run without --live`); + this.stateFile = stateFile || (live ? STATE_FILE_LIVE : path.join(outDir, 'state.json')); + this.state = loadState(this.stateFile); + this.state.posts = this.state.posts || {}; this.state.pulses = this.state.pulses || []; this.state.incidents = this.state.incidents || {}; this.state.watch = this.state.watch || {}; + } + already(key) { return !this.force && !!this.state.posts[key]; } + // Posts one payload under an idempotency key; returns {posted, skipped, id}. Writes the dry-run JSON and preview in dry-run mode. + async post(channel, key, payload) { + const total = guardPayload(payload); + if (this.already(key)) { this.log(`skip ${key}: already posted ${this.state.posts[key].at}`); return { posted: false, skipped: true, id: this.state.posts[key].id }; } + if (!this.live) { + fs.mkdirSync(this.outDir, { recursive: true }); + const file = path.join(this.outDir, `${key.replace(/[^a-zA-Z0-9_.-]+/g, '_')}.json`); + fs.writeFileSync(file, JSON.stringify({ channel, key, at: new Date().toISOString(), payload }, null, 2)); + this.state.posts[key] = { id: null, at: new Date().toISOString(), dry: true, channel }; + this.log(`dry-run ${channel} ${key}: ${total} characters → ${file}`); + writePreview(this.outDir); + return { posted: false, skipped: false, id: null, file }; + } + const url = this.creds[CHANNEL_KEY[channel]]; + if (!url) throw new Error(`no ${CHANNEL_KEY[channel]} in the credentials file`); + const r = await postWebhook(url, payload, { fetchImpl: this.fetchImpl }); + this.state.posts[key] = { id: r.id, at: new Date().toISOString(), channel }; + this.save(); + this.log(`posted ${channel} ${key}: ${total} characters, message ${r.id || 'id unknown'}`); + return { posted: true, skipped: false, id: r.id }; + } + save() { saveState(this.stateFile, this.state); } +} + +// ---------- the preview (Discord-like rendering of out/*.json) ---------- +function esc(s) { return String(s).replace(/&/g, '&').replace(//g, '>'); } +function md(s) { + if (!s) return ''; + let t = esc(s); + t = t.replace(/```\n?([\s\S]*?)```/g, (_, c) => `
${c.replace(/\n$/, '')}
`); + t = t.replace(/`([^`]+)`/g, '$1'); + t = t.replace(/\*\*([^*]+)\*\*/g, '$1'); + t = t.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '$1'); + t = t.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1$2'); + return t.replace(/\n/g, '
'); +} +export function renderPreview(items) { + const cards = items.map(({ channel, key, payload }) => { + const e = payload.embeds[0]; + const fields = (e.fields || []).map(f => `
${md(f.name)}
${md(f.value)}
`).join(''); + return `
#${esc(channel)} ${esc(key)} ${embedLength(e)} / ${LIMITS.total} chars
+
${esc(payload.username)}APPToday at ${esc(ukStamp(new Date(e.timestamp)).replace(/^.*?, /, '').replace(/ UK.*$/, ''))}
+
+${e.title ? `
${e.url ? `${md(e.title)}` : md(e.title)}
` : ''} +${e.description ? `
${md(e.description)}
` : ''} +
${fields}
+${e.footer ? `` : ''} +
${e.thumbnail ? `` : ''}
`; + }).join('\n'); + return `Igneum Discord posts +

Igneum Discord posts, dry-run render (${items.length})

${cards}`; +} +export function writePreview(outDir = OUT_DIR) { + if (!fs.existsSync(outDir)) return null; + const items = fs.readdirSync(outDir).filter(f => f.endsWith('.json') && f !== 'state.json').sort() + .map(f => { try { return JSON.parse(fs.readFileSync(path.join(outDir, f), 'utf8')); } catch { return null; } }).filter(x => x && x.payload); + const order = { announcements: 0, numbers: 1, incidents: 2 }; + items.sort((a, b) => (order[a.channel] ?? 9) - (order[b.channel] ?? 9) || a.key.localeCompare(b.key)); + const file = path.join(outDir, 'preview.html'); + fs.writeFileSync(file, renderPreview(items)); + return file; +} + +// ---------- the scheduler ---------- +export function dueNow(now, state) { + const l = londonParts(now); + const date = londonDateKey(now); + const due = []; + if (PULSE_HOURS_UK.includes(l.hour)) due.push({ kind: 'pulse', key: `pulse:${date}:${String(l.hour).padStart(2, '0')}` }); + if (l.hour === 9) due.push({ kind: 'digest', key: `digest:${date}` }); + if (l.hour === 9 && l.weekday === 'Mon') due.push({ kind: 'weekly', key: `weekly:${date}` }); + return due.filter(d => !(state.posts && state.posts[d.key])); +} + +// ---------- commands ---------- +function argsOf(argv) { + const flags = {}; const rest = []; const multi = {}; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a.startsWith('--')) { + const name = a.slice(2); + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + if (name === 'changed') (multi.changed = multi.changed || []).push(next); else flags[name] = next; + i++; + } else flags[name] = true; + } else rest.push(a); + } + return { flags, rest, multi }; +} + +function keepPulses(state, snap) { + state.pulses.push(snap); + state.pulses = state.pulses.slice(-120); // 30 days at four a day +} + +export async function runPulse(poster, data, key) { + const prev = poster.state.pulses.slice(-1)[0] || null; + const { payload, snapshot: snap } = shapePulse(data, prev); + const r = await poster.post('numbers', key || `pulse:${londonDateKey(data.now)}:${String(londonParts(data.now).hour).padStart(2, '0')}`, payload); + if (!r.skipped) { keepPulses(poster.state, snap); poster.save(); } + return r; +} +export async function runDigest(poster, data, key) { + const { payload } = shapeDigest(data, poster.state.pulses); + const r = await poster.post('numbers', key || `digest:${londonDateKey(data.now)}`, payload); + if (!r.skipped) poster.save(); + return r; +} +export async function runWeekly(poster, data, key) { + const weekIncidents = Object.values(poster.state.incidents || {}).filter(i => data.now.getTime() - new Date(i.at).getTime() < 7 * 86400e3) + .map(i => `${utcStamp(new Date(i.at))}: ${i.what}${i.resolved ? ` Resolved after ${fmtDur((new Date(i.resolved.at).getTime() - new Date(i.at).getTime()) / 1000)}: ${i.resolved.fix}` : ' Open.'}`); + const { payload, snapshot: snap } = shapeWeekly(data, poster.state.weekly_prev || null, weekIncidents); + const r = await poster.post('numbers', key || `weekly:${londonDateKey(data.now)}`, payload); + if (!r.skipped) { poster.state.weekly_prev = snap; poster.save(); } + return r; +} +export async function openIncident(poster, { id, at, what, affected, doing, auto = false }) { + const { payload } = shapeIncidentOpen({ id, at, what, affected, doing, now: new Date() }); + const r = await poster.post('incidents', `incident:${id}:open`, payload); + if (!r.skipped) { poster.state.incidents[id] = { id, at: new Date(at).toISOString(), what, affected, doing, auto, message_id: r.id }; poster.save(); } + return r; +} +export async function resolveIncident(poster, { id, at, cause, fix }) { + const inc = poster.state.incidents[id]; + const { payload } = shapeIncidentResolve({ id, openedAt: inc && inc.at, at, cause, fix, what: inc && inc.what, now: new Date() }); + const r = await poster.post('incidents', `incident:${id}:resolve`, payload); + if (!r.skipped) { poster.state.incidents[id] = { ...(inc || { id, at: new Date(at).toISOString(), what: '' }), resolved: { at: new Date(at).toISOString(), cause, fix, message_id: r.id } }; poster.save(); } + return r; +} +export async function runWatch(poster, data) { + const actions = watchPass(data, poster.state); + const results = []; + for (const a of actions) { + if (a.kind === 'open') results.push(await openIncident(poster, { id: a.id, at: a.at, what: a.what, affected: a.affected, doing: a.doing, auto: true })); + else results.push(await resolveIncident(poster, { id: a.id, at: a.at, cause: a.cause, fix: a.fix })); + } + poster.save(); + return results; +} + +async function fetchForWatch() { + try { return await fetchAll({ fleet: false }); } catch (e) { return { stats: { stale: true, age_s: null, finality: null }, live: {}, supply: {}, fleet: null, now: new Date(), fetchFailed: true, error: String(e.message || e) }; } +} + +export async function main(argv = process.argv.slice(2)) { + const { flags, rest, multi } = argsOf(argv); + const cmd = rest[0]; + const live = !!flags.live; + if (!cmd || flags.help) { process.stdout.write(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n').slice(1, 22).map(l => l.replace(/^\/\/ ?/, '')).join('\n') + '\n'); return 0; } + if (cmd === 'check') { + const c = readCredentials(); + if (!c) { process.stdout.write(`no credentials file at ${CRED_FILE}\n`); return 1; } + for (const [ch, k] of Object.entries(CHANNEL_KEY)) process.stdout.write(`${ch.padEnd(14)} ${c[k] ? 'set' : 'missing'} (${k})\n`); + return Object.values(CHANNEL_KEY).every(k => c[k]) ? 0 : 1; + } + if (cmd === 'preview') { const f = writePreview(); process.stdout.write(`${f || 'nothing in out/ yet'}\n`); return 0; } + + const poster = new Poster({ live, force: !!flags.force }); + if (cmd === 'pulse') { const data = await fetchAll(); await runPulse(poster, data, flags.key); return 0; } + if (cmd === 'digest') { const data = await fetchAll({ fleet: false }); await runDigest(poster, data, flags.key); return 0; } + if (cmd === 'weekly') { const data = await fetchAll({ fleet: false }); await runWeekly(poster, data, flags.key); return 0; } + if (cmd === 'release') { + const version = rest[1]; if (!version) throw new Error('release needs a version'); + let changed = multi.changed || []; + if (flags.plan) changed = changed.concat(changedLinesFromPlan(fs.readFileSync(flags.plan, 'utf8'), flags.section || 'What changed', 5 - changed.length, poster.log)); + const { payload } = shapeRelease({ version, windows: flags.windows, windowsSha: flags['windows-sha'], mac: flags.mac, macSha: flags['mac-sha'], hive: flags.hive, hiveSha: flags['hive-sha'], nodeCommit: flags['node-commit'], digest: flags.digest, changed }); + await poster.post('announcements', `release:${version}`, payload); + return 0; + } + if (cmd === 'incident') { + const sub = rest[1]; + const at = flags.at ? new Date(flags.at) : new Date(); + if (Number.isNaN(at.getTime())) throw new Error('--at must be an ISO UTC time'); + if (sub === 'open') { + const id = flags.id || `inc-${at.toISOString().slice(0, 16).replace(/[-:T]/g, '')}`; + await openIncident(poster, { id, at, what: flags.what, affected: flags.affected, doing: flags.doing }); + process.stdout.write(`${id}\n`); + return 0; + } + if (sub === 'resolve') { if (!flags.id) throw new Error('incident resolve needs --id'); await resolveIncident(poster, { id: flags.id, at, cause: flags.cause, fix: flags.fix }); return 0; } + throw new Error('incident open | incident resolve'); + } + if (cmd === 'watch') { const data = await fetchForWatch(); const r = await runWatch(poster, data); poster.log(`watch: ${r.length} action(s)${data.fetchFailed ? `, api unreachable (${data.error})` : ''}`); return 0; } + if (cmd === 'tick') { + const now = new Date(); + const due = dueNow(now, poster.state); + let data = null; + if (due.length) { + data = await fetchAll(); + for (const d of due) { + try { + if (d.kind === 'pulse') await runPulse(poster, data, d.key); + if (d.kind === 'digest') await runDigest(poster, data, d.key); + if (d.kind === 'weekly') await runWeekly(poster, data, d.key); + } catch (e) { poster.log(`tick ${d.key} failed: ${e.message}`); } + } + } + const wdata = data && !data.fetchFailed ? data : await fetchForWatch(); + const r = await runWatch(poster, wdata); + poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)`); + return 0; + } + throw new Error(`unknown command ${cmd}`); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().then(code => process.exit(code), e => { process.stderr.write(`${String(e.message || e).replace(/https:\/\/discord(app)?\.com\/api\/webhooks\/\S+/g, '')}\n`); process.exit(1); }); +} diff --git a/tools/community/discord-hooks.test.mjs b/tools/community/discord-hooks.test.mjs new file mode 100644 index 000000000..0be48114c --- /dev/null +++ b/tools/community/discord-hooks.test.mjs @@ -0,0 +1,354 @@ +// node --test tools/community/discord-hooks.test.mjs +// Shaping against fixtures cut from the live API on 6 October 2026 (fixtures/discord), the guards, the limits, +// idempotency, the 429 backoff, the watcher's state machine and the London scheduler. Nothing here touches the network. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + shapePulse, shapeDigest, shapeWeekly, shapeRelease, shapeIncidentOpen, shapeIncidentResolve, changedLinesFromPlan, plainLine, + guardText, guardPayload, embed, embedLength, LIMITS, snapshot, devnet2Line, versionShare, watchPass, WATCH, WATCH_CONDITIONS, + Poster, postWebhook, dueNow, ukStamp, londonParts, fmtHash, fmtDur, fmtDelta, fmtChangePct, renderPreview, ICON, EMBER, +} from './discord-hooks.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const fx = name => JSON.parse(fs.readFileSync(path.join(HERE, 'fixtures', 'discord', `${name}.json`), 'utf8')); +const NOW = new Date('2026-10-06T18:44:50Z'); // the fixtures' moment, 19:44 UK +const data = () => ({ stats: fx('stats'), live: fx('live'), supply: fx('supply'), fleet: fx('fleet'), now: NOW }); +const tmpDir = () => fs.mkdtempSync(path.join(os.tmpdir(), 'discord-hooks-')); + +// ---------- the pulse ---------- +test('pulse: one embed, ember, icon, at most eight fields, UK footer with UTC in brackets', () => { + const { payload } = shapePulse(data(), null); + guardPayload(payload); + const e = payload.embeds[0]; + assert.equal(payload.embeds.length, 1); + assert.equal(e.color, EMBER); + assert.equal(e.thumbnail.url, ICON); + assert.ok(e.fields.length <= 8); + assert.match(e.footer.text, /^6 Oct 2026, 19:44 UK \(18:44 UTC\)/); + assert.deepEqual(payload.allowed_mentions, { parse: [] }); + assert.equal(payload.username, 'Igneum'); +}); + +test('pulse: first pulse has no window; the second computes blocks per second and the difficulty change from the snapshot', () => { + const first = shapePulse(data(), null); + assert.match(first.payload.embeds[0].fields[0].value, /blocks\/s last 60 s/); + assert.match(first.payload.embeds[0].fields[2].value, /first pulse/); + const prev = { ...first.snapshot, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString(), block_count: first.snapshot.block_count - 21600, difficulty: first.snapshot.difficulty / 1.1, paid_shards_total: first.snapshot.paid_shards_total - 40 }; + const second = shapePulse(data(), prev); + const f = second.payload.embeds[0].fields; + assert.match(f[0].value, /1\.00 blocks\/s over 6 h/); + assert.match(f[2].value, /\+10\.0% in 6 h/); + assert.match(f[4].value, /\+40 shards paid in 6 h/); +}); + +test('pulse: the fields carry the asked numbers in plain words', () => { + const { payload } = shapePulse(data(), null); + const text = payload.embeds[0].fields.map(f => `${f.name}: ${f.value}`).join('\n'); + assert.match(text, /Chain block: 141,642/); + assert.match(text, /2\.56 GH\/s/); + assert.match(text, /56 vote keys active \(a card runs several\)/); + assert.match(text, /checkpoint 6,842 at \d+\.\d% of weight/); + assert.match(text, /1\.09 IGN to provers in the last 5 min/); + assert.match(text, /median n\/a/); // the fixture's median_proof_lag_s is null + assert.match(text, /0 provers in 10 min/); + assert.match(text, /ramp 17\.3%/); +}); + +test('pulse: Devnet 2 is numbers and the gate word only; the seed address and the state text never pass', () => { + const line = devnet2Line(fx('fleet'), NOW.getTime()); + assert.equal(line, '4 boxes, height 3,848, last gate FAIL 37 min ago'); + assert.equal(devnet2Line(null), null); + assert.equal(devnet2Line({ devnet2: { boxes: 2, height: 10, last_gate: 'PASS: abc', last_gate_at: NOW.toISOString() } }, NOW.getTime()), '2 boxes, height 10, last gate PASS 0 s ago'); + assert.throws(() => guardText(fx('fleet').devnet2.seed), /IP address/); +}); + +test('pulse: finality paused puts a since line in the description, from the newest lock', () => { + const { payload } = shapePulse(paused(), null); + assert.match(payload.embeds[0].description, /\nFinality paused since 2026-10-06 18:4\d UTC: nothing is final until two thirds/); + assert.doesNotMatch(shapePulse(data(), null).payload.embeds[0].description, /Finality paused/); +}); +test('pulse: a stale observer changes the first line and keeps the numbers', () => { + const d = data(); d.stats.stale = true; d.stats.age_s = 95; + const { payload } = shapePulse(d, null); + assert.match(payload.embeds[0].description, /^Observer stale for 2 min: every number is last known/); +}); + +test('pulse: miner version share appears only when engines are exposed', () => { + const d = data(); + assert.equal(versionShare(d.live), null); + d.live.miners[0].engine = '0.3.14'; d.live.miners[1].engine = '0.3.14'; d.live.miners[2].engine = '0.3.13'; + assert.equal(versionShare(d.live), '0.3.14 67%, 0.3.13 33%'); + const { payload } = shapePulse(d, null); + assert.ok(payload.embeds[0].fields.some(f => f.name === 'Miner versions')); + assert.ok(payload.embeds[0].fields.length <= 8); +}); + +// ---------- digest and weekly ---------- +test('digest: a monospace table with yesterday from the snapshot closest to 24 h ago', () => { + const cur = snapshot(data()); + const yesterday = { ...cur, at: new Date(NOW.getTime() - 24 * 3600e3 - 600e3).toISOString(), height: cur.height - 80000, locked_index: cur.locked_index - 2800, paid_shards_total: cur.paid_shards_total - 400, circulating_ign: cur.circulating_ign - 450000 }; + const { payload } = shapeDigest(data(), [yesterday, { ...cur, at: new Date(NOW.getTime() - 6 * 3600e3).toISOString() }]); + guardPayload(payload); + const d = payload.embeds[0].description; + assert.match(d, /```\n24 h/); + assert.match(d, /chain block\s+61,642\s+141,642/); + assert.match(d, /locks \+2,800/); + assert.match(d, /minted \+450,000 IGN/); + assert.equal(payload.embeds[0].thumbnail, undefined); + const none = shapeDigest(data(), []); + assert.match(none.payload.embeds[0].description, /No snapshot from 24 h ago yet/); +}); + +test('weekly: the reddit template as an embed with the ledger link and a last-week column', () => { + const first = shapeWeekly(data(), null, []); + guardPayload(first.payload); + const e = first.payload.embeds[0]; + assert.equal(e.title, 'Devnet numbers, week of 5 Oct 2026'); + assert.match(e.description, /Ledger: https:\/\/igneum\.network\/ledger/); + assert.match(e.description, /First weekly post/); + assert.equal(e.fields.length, 6); + const prev = { ...first.snapshot, height: 100000, locked_index: 4000, paid_shards_total: 1000 }; + const second = shapeWeekly(data(), prev, ['Example incident sentence.']); + const text = second.payload.embeds[0].fields.map(f => f.value).join('\n'); + assert.match(text, /height 141,642 \(last week 100,000\)/); + assert.match(text, /locks this week \+2,842/); + assert.match(text, /shards this week \+1,258/); + assert.match(text, /• Example incident sentence\./); + assert.match(text, /check\.rule_match 500 \/ 500/); +}); + +// ---------- the release ---------- +const REL = { + version: '0.3.14', + windows: 'https://dl.igneum.network/public/igneum-miner-windows.exe', windowsSha: '08e75effc732f88bca11006b3b7cdf2bc533576f56819f79509fee47a8f5dd59', + mac: 'https://dl.igneum.network/public/igneum-miner-mac.dmg', macSha: 'e9e4c24db81a262a36b6bac11609ce6d24ed03220973da4142df935d41fc4e40', + hive: 'https://dl.igneum.network/public/igneum-miner-hive.tar.gz', hiveSha: 'c4699f2487580dcf06b5c22a36a2eb8383a3307b6137699c76c45eefebdd4c88', + nodeCommit: '4c6b129d', digest: 'b18ed271 (thirteen fields, unchanged)', now: NOW, +}; +test('release: three downloads with full sha256, node commit, digest, up to five changed lines; a sha256 passes the 32-hex guard', () => { + const { payload } = shapeRelease({ ...REL, changed: ['One.', 'Two.', 'Three.', 'Four.', 'Five.', 'Six.'] }); + guardPayload(payload); + const e = payload.embeds[0]; + assert.equal(e.title, 'Igneum Miner 0.3.14'); + assert.equal(e.fields.length, 6); + assert.match(e.fields[0].value, /\[Download\]\(https:\/\/dl\.igneum\.network\/public\/igneum-miner-windows\.exe\)\n`08e75eff[0-9a-f]{56}`/); + assert.equal((e.description.match(/^• /gm) || []).length, 5); + assert.doesNotMatch(e.description, /Six/); +}); +test('release: refuses a missing value or a hash that is not sha256', () => { + assert.throws(() => shapeRelease({ ...REL, hive: '' }), /release needs hive/); + assert.throws(() => shapeRelease({ ...REL, macSha: 'abc' }), /macSha is not a sha256/); +}); +test('release: changed lines from a plan section, table rows by first cell, bullets as they are, guarded lines dropped', () => { + const plan = `# Plan\n\n## 1. Why\n\nIntro.\n\n| Change | Where |\n|---|---|\n| A deep reorg reloads the newest generation; never a genesis replay | there |\n| \`exec_restart_state_root\`, the fourteenth field (block 27,276's root 0x${'e'.repeat(64)}, read on node 1 and confirmed by the fleet on both node kinds; set) | there |\n| A flag under /root was ignored silently | there |\n\n- A bullet line.\n\n## 2. Next\n\n| Not | this |\n`; + const warned = []; + const lines = changedLinesFromPlan(plan, '1. Why', 5, m => warned.push(m)); + assert.deepEqual(lines, ['A deep reorg reloads the newest generation', 'exec_restart_state_root, the fourteenth field', 'A bullet line.']); + assert.equal(warned.length, 1); + assert.match(warned[0], /internal path/); + assert.throws(() => changedLinesFromPlan(plan, 'Missing'), /section not found/); +}); +test('plainLine: strips code ticks, cuts at the first semicolon, drops a long parenthetical, never cuts mid-word', () => { + assert.equal(plainLine('`A thing` that **reloads** the generation; never a replay'), 'A thing that reloads the generation'); + const long = `Start of a long line that goes on (${'x'.repeat(200)}) and ends here with more words`; + assert.equal(plainLine(long), 'Start of a long line that goes on and ends here with more words'); + const words = Array.from({ length: 60 }, (_, i) => `word${i}`).join(' '); + const cut = plainLine(words); + assert.ok(cut.length <= 160); + assert.ok(words.startsWith(cut)); + assert.ok(!cut.endsWith(' ')); +}); + +// ---------- incidents ---------- +test('incident open and resolve: UTC times, the three answers, the duration', () => { + const open = shapeIncidentOpen({ id: 'inc-1', at: new Date('2026-10-06T15:44:00Z'), what: 'What happened.', affected: 'Miners: fine. Provers: paused.', doing: 'Replaying.', now: NOW }); + guardPayload(open.payload); + assert.equal(open.payload.embeds[0].title, 'Incident open'); + assert.equal(open.payload.embeds[0].fields[0].value, '2026-10-06 15:44 UTC'); + const res = shapeIncidentResolve({ id: 'inc-1', openedAt: '2026-10-06T15:44:00Z', at: new Date('2026-10-06T17:10:00Z'), cause: 'A cause.', fix: 'A rule.', what: 'What happened.', now: NOW }); + guardPayload(res.payload); + assert.equal(res.payload.embeds[0].title, 'Incident resolved'); + assert.equal(res.payload.embeds[0].fields[1].value, '1 h 26 min'); + assert.throws(() => shapeIncidentOpen({ id: 'x', what: 'w', affected: '', doing: 'd' }), /needs affected/); +}); + +// ---------- the watcher ---------- +const at = (base, s) => new Date(base.getTime() + s * 1000); +const paused = () => { const d = data(); d.stats.finality.active = false; return d; }; +// clear data at +s seconds: finality active and the lock index advancing one checkpoint per 30 s (an unchanged index reads as paused) +const advancing = s => { const d = data(); const idx = 6842 + Math.floor(s / 30); d.stats.finality.latest_locked_index = idx; d.live.finality.latest_locked_index = idx; return d; }; +test('watcher: a condition already firing at the first look is pre-existing: never opened, cleared silently; the next start opens', () => { + const state = { watch: {} }; + assert.deepEqual(watchPass(paused(), state, NOW), [], 'first look: paused already, nothing observed to start'); + assert.ok(state.watch.finality_paused.preexisting); + assert.deepEqual(watchPass(paused(), state, at(NOW, 900)), [], 'held 15 min, still nothing: no open to match a resolve'); + assert.deepEqual(watchPass(advancing(1200), state, at(NOW, 1200)), [], 'clears silently'); + assert.equal(state.watch.finality_paused.preexisting, null); + assert.deepEqual(watchPass(paused(), state, at(NOW, 1300)), [], 'a start the watcher saw: the hold begins'); + const a = watchPass(paused(), state, at(NOW, 1300 + WATCH.finalityPausedS + 1)); + assert.equal(a.length, 1); assert.equal(a[0].kind, 'open'); +}); +test('watcher: finality paused opens after five minutes, once, and resolves after two clear minutes', () => { + const state = { watch: {} }; + assert.deepEqual(watchPass(data(), state, at(NOW, -60)), [], 'the first look is clear'); + assert.deepEqual(watchPass(paused(), state, NOW), []); + assert.deepEqual(watchPass(paused(), state, at(NOW, 240)), []); + const a = watchPass(paused(), state, at(NOW, 301)); + assert.equal(a.length, 1); assert.equal(a[0].kind, 'open'); assert.equal(a[0].key, 'finality_paused'); + assert.match(a[0].what, /Finality paused/); + assert.match(a[0].affected, /Miners keep mining/); + assert.deepEqual(watchPass(paused(), state, at(NOW, 600)), [], 'no second open while one is open'); + assert.deepEqual(watchPass(advancing(700), state, at(NOW, 700)), [], 'clear must hold'); + const r = watchPass(advancing(700 + WATCH.clearHoldS), state, at(NOW, 700 + WATCH.clearHoldS)); + assert.equal(r.length, 1); assert.equal(r[0].kind, 'resolve'); assert.equal(r[0].id, a[0].id); + assert.equal(state.watch.finality_paused.open, null); +}); +test('watcher: a lock index that does not move for five minutes counts as paused even with active true', () => { + const state = { watch: {} }; + watchPass(data(), state, NOW); + assert.deepEqual(watchPass(data(), state, at(NOW, 299)), []); + const a = watchPass(data(), state, at(NOW, 301)); + // the hold starts when the stuck test first fires (at 301 s), so the open comes one threshold later + assert.deepEqual(a, []); + const b = watchPass(data(), state, at(NOW, 301 + WATCH.finalityPausedS)); + assert.equal(b.length, 1); assert.match(b[0].what, /no new lock/); +}); +test('watcher: proof lag over 15 minutes opens at once; observer silence needs three minutes; a failed fetch counts as silence', () => { + const state = { watch: {} }; + watchPass(data(), state, at(NOW, -60)); + const lag = data(); lag.live.proving.median_proof_lag_s = 1000; + const a = watchPass(lag, state, NOW); + assert.equal(a.length, 1); assert.equal(a[0].key, 'proof_lag'); assert.match(a[0].what, /17 min against a target under 60 s/); + const s2 = { watch: {} }; + watchPass(data(), s2, at(NOW, -60)); + const failed = { stats: { stale: true, age_s: null, finality: null }, live: {}, supply: {}, now: NOW, fetchFailed: true }; + assert.deepEqual(watchPass(failed, s2, NOW), []); + const b = watchPass(failed, s2, at(NOW, WATCH.observerSilentS + 1)); + assert.equal(b.length, 1); assert.equal(b[0].key, 'observer_silent'); assert.match(b[0].what, /did not answer/); +}); +test('watcher: every automatic text passes the guard', () => { + for (const c of Object.values(WATCH_CONDITIONS)) { + const d = data(); d.stats.age_s = 200; d.live.proving.median_proof_lag_s = 1000; + guardText([c.what(d), c.affected, c.doing, c.cause, c.fix].join('\n')); + } +}); + +// ---------- guards and limits ---------- +test('guard: refuses the founder, hosts, machine ids, paths, IPs, 32-hex tokens, token download paths, webhook URLs, mentions', () => { + const bad = { + 'the project lead fixed it': /founder/, 'by [user] at night': /founder/, 'on Hetzner': /hosting/, 'RunPod box': /hosting/, 'host igneum-build-1': /host name/, + 'from DESKTOP-ABC1234': /machine id/, 'target ae432dc7': /machine id/, 'box p2-3090-4': /machine id/, + 'seed 213.173.110.229:11927': /IP address/, 'see /Users/x/file': /internal path/, 'under /srv/discord-hooks/env': /internal path/, 'in C:\\Users': /internal path/, + [`token ${'a1'.repeat(16)}`]: /32-hex/, 'https://dl.igneum.network/fleet-22adafa34bc2/fleet.json': /outside \/public/, 'https://dl.igneum.network/dl/abc/x.exe': /outside \/public/, + 'https://discord.com/api/webhooks/1/x': /webhook URL/, 'hello @everyone': /mention/, 'ping <@123>': /mention/, 'at 19:00 BST': /internal reference/, 'on the MacBook': /forbidden-strings/, 'box.ts.net': /forbidden-strings/, 'LOG_INTAKE_KEY=x': /forbidden-strings/, + }; + for (const [text, why] of Object.entries(bad)) assert.throws(() => guardText(text), why, text); +}); +test('guard: passes a sha256, a public download URL, an igneum.network link, a 16-hex checkpoint hash and plain words', () => { + guardText(`sha ${'ab'.repeat(32)} public https://dl.igneum.network/public/igneum-miner-mac.dmg live https://igneum.network/live hash a56a00dfb657036d 4 boxes, height 3,848`); +}); +test('limits: more than eight fields, a value over 1,024, a total over 6,000, a stray mention setting are refused', () => { + const big = embed({ title: 'T', fields: Array.from({ length: 9 }, (_, i) => ({ name: `f${i}`, value: 'v' })), now: NOW }); + assert.equal(big.embeds[0].fields.length, 8, 'embed() itself caps at eight'); + big.embeds[0].fields.push({ name: 'f9', value: 'v' }); + assert.throws(() => guardPayload(big), /more than 8 fields/); + const long = embed({ title: 'T', fields: [{ name: 'f', value: 'x'.repeat(1025) }], now: NOW }); + assert.throws(() => guardPayload(long), /field value over 1024/); + const total = embed({ title: 'T', description: 'd'.repeat(4000), fields: [{ name: 'a', value: 'y'.repeat(1024) }, { name: 'b', value: 'y'.repeat(1024) }], now: NOW }); + assert.ok(embedLength(total.embeds[0]) > LIMITS.total); + assert.throws(() => guardPayload(total), /embed over 6000/); + const mention = embed({ title: 'T', fields: [{ name: 'a', value: 'b' }], now: NOW }); mention.allowed_mentions = { parse: ['everyone'] }; + assert.throws(() => guardPayload(mention), /allowed_mentions/); +}); + +// ---------- idempotency, dry run, backoff ---------- +test('poster: dry run writes the payload and the preview, a rerun under the same key is skipped, --force posts again', async () => { + const out = tmpDir(); + const p = new Poster({ live: false, outDir: out, log: () => {} }); + const { payload } = shapePulse(data(), null); + const r1 = await p.post('numbers', 'pulse:test', payload); + assert.equal(r1.skipped, false); + assert.ok(fs.existsSync(path.join(out, 'pulse_test.json'))); + assert.ok(fs.existsSync(path.join(out, 'preview.html'))); + const r2 = await p.post('numbers', 'pulse:test', payload); + assert.equal(r2.skipped, true); + const f = new Poster({ live: false, outDir: out, force: true, log: () => {} }); + const r3 = await f.post('numbers', 'pulse:test', payload); + assert.equal(r3.skipped, false); + const html = fs.readFileSync(path.join(out, 'preview.html'), 'utf8'); + assert.match(html, /Network pulse/); + assert.match(html, /#numbers/); +}); +test('poster: live mode without a credentials file refuses to start, and never prints a URL', () => { + assert.throws(() => new Poster({ live: true, credFile: path.join(tmpDir(), 'none'), outDir: tmpDir() }), /no credentials file/); +}); +test('poster: live mode posts once, stores the message id, skips the rerun; the state is written only after success', async () => { + const dir = tmpDir(); + const cred = path.join(dir, 'discord'); fs.writeFileSync(cred, 'DISCORD_WEBHOOK_NUMBERS=https://discord.com/api/webhooks/1/secret\n', { mode: 0o600 }); + const calls = []; + const fetchImpl = async (url, init) => { calls.push({ url, body: JSON.parse(init.body) }); return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '42', channel_id: '7' }) }; }; + const p = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl }); + const { payload } = shapePulse(data(), null); + const r = await p.post('numbers', 'pulse:live', payload); + assert.equal(r.posted, true); assert.equal(r.id, '42'); + assert.equal(calls.length, 1); assert.match(calls[0].url, /\?wait=true$/); + assert.deepEqual(calls[0].body.allowed_mentions, { parse: [] }); + const saved = JSON.parse(fs.readFileSync(path.join(dir, 'state.json'), 'utf8')); + assert.equal(saved.posts['pulse:live'].id, '42'); + const again = new Poster({ live: true, credFile: cred, stateFile: path.join(dir, 'state.json'), outDir: dir, log: () => {}, fetchImpl }); + assert.equal((await again.post('numbers', 'pulse:live', payload)).skipped, true); + assert.equal(calls.length, 1); + await assert.rejects(() => p.post('incidents', 'inc:x', payload), /no DISCORD_WEBHOOK_INCIDENTS/); +}); +test('backoff: a 429 waits retry_after then doubles; success returns the id; six failures give up', async () => { + const sleeps = []; + let n = 0; + const fetchImpl = async () => { n++; if (n <= 2) return { ok: false, status: 429, headers: new Map([['retry-after', '2']]), json: async () => ({ retry_after: 2.5 }) }; return { ok: true, status: 200, headers: new Map(), json: async () => ({ id: '9' }) }; }; + const r = await postWebhook('https://example.invalid/hook', { embeds: [] }, { fetchImpl, sleepImpl: async ms => sleeps.push(ms) }); + assert.equal(r.id, '9'); assert.equal(n, 3); + assert.deepEqual(sleeps, [2500, 2500]); + const always = async () => ({ ok: false, status: 429, headers: new Map(), json: async () => ({}) }); + const s2 = []; + await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: always, sleepImpl: async ms => s2.push(ms) }), /after 6 tries/); + assert.deepEqual(s2, [1000, 2000, 4000, 8000, 16000]); + const bad = async () => ({ ok: false, status: 400, headers: new Map(), json: async () => ({}) }); + await assert.rejects(() => postWebhook('https://example.invalid/hook', {}, { fetchImpl: bad }), /answered 400/); +}); + +// ---------- the scheduler and the clock ---------- +test('scheduler: Monday 09:xx UK is pulse + digest + weekly, each once; other hours only the pulse slots; nothing twice', () => { + const monday9 = new Date('2026-10-12T08:05:00Z'); // 09:05 UK in BST + assert.equal(londonParts(monday9).weekday, 'Mon'); assert.equal(londonParts(monday9).hour, 9); + const due = dueNow(monday9, { posts: {} }); + assert.deepEqual(due.map(d => d.kind), ['pulse', 'digest', 'weekly']); + assert.equal(due[0].key, 'pulse:2026-10-12:09'); + const posted = { posts: Object.fromEntries(due.map(d => [d.key, { id: '1' }])) }; + assert.deepEqual(dueNow(monday9, posted), []); + assert.deepEqual(dueNow(new Date('2026-10-13T09:30:00Z'), { posts: {} }), [], '10:30 UK is not a slot'); + assert.deepEqual(dueNow(new Date('2026-10-13T20:59:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:21']); + assert.deepEqual(dueNow(new Date('2026-10-13T02:00:00Z'), { posts: {} }).map(d => d.key), ['pulse:2026-10-13:03']); +}); +test('clock: the footer carries UK time with UTC in brackets, in summer and in winter', () => { + assert.equal(ukStamp(new Date('2026-10-06T18:51:00Z')), '6 Oct 2026, 19:51 UK (18:51 UTC)'); + assert.equal(ukStamp(new Date('2026-11-06T18:51:00Z')), '6 Nov 2026, 18:51 UK (18:51 UTC)'); + assert.equal(ukStamp(new Date('2026-10-25T00:30:00Z')), '25 Oct 2026, 01:30 UK (00:30 UTC)', 'the clocks go back at 01:00 UTC on 25 Oct 2026'); +}); +test('formatting: hash units, durations, deltas with signs', () => { + assert.equal(fmtHash(2562646398), '2.56 GH/s'); assert.equal(fmtHash(169e6), '169.0 MH/s'); assert.equal(fmtHash(null), 'n/a'); + assert.equal(fmtDur(45), '45 s'); assert.equal(fmtDur(406.5), '7 min'); assert.equal(fmtDur(5160), '1 h 26 min'); assert.equal(fmtDur(3 * 86400), '3 days'); + assert.equal(fmtDelta(10, 4), '+6'); assert.equal(fmtDelta(4, 10), '−6'); assert.equal(fmtDelta(4, 4), '±0'); assert.equal(fmtDelta(null, 4), 'n/a'); + assert.equal(fmtChangePct(110, 100), '+10.0%'); assert.equal(fmtChangePct(90, 100), '−10.0%'); +}); +test('preview: renders every item as a Discord-like card with the channel and the length', () => { + const items = [{ channel: 'numbers', key: 'pulse:x', payload: shapePulse(data(), null).payload }, { channel: 'announcements', key: 'release:0.3.14', payload: shapeRelease({ ...REL, changed: ['One.'] }).payload }]; + const html = renderPreview(items); + assert.equal((html.match(/class="msg"/g) || []).length, 2); + assert.match(html, /#announcements/); + assert.match(html, /Download<\/a>/); + assert.match(html, /border-left-color:#f2541b/); + assert.doesNotMatch(html, /