diff --git a/docs/community/discord-hooks.md b/docs/community/discord-hooks.md index 2c0bbb14b..bb2d3b9c1 100644 --- a/docs/community/discord-hooks.md +++ b/docs/community/discord-hooks.md @@ -14,6 +14,7 @@ eight fields, UK time in the footer with UTC in brackets, never a mention, never | 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 | +| Network feed | #network-feed | every hour, when `DISCORD_WEBHOOK_FEED` is set | height and blocks/s over the hour, hash rate and difficulty, keys active and voters, the last lock; the daily hash-origin field once per report date from `/api/live state.hash_origin`; milestones once each (every 100,000 blocks, 10,000 locks, 10,000 paid shards; the first crossing of a vote-key count and of a hash-rate step). Added 7 October 2026; docs/community/discord/structure.md section 4.1 | 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. @@ -35,6 +36,7 @@ The watcher's texts are fixed sentences in the script, each a stated rule, and a ``` node tools/community/discord-hooks.mjs pulse | digest | weekly [--live] [--force] +node tools/community/discord-hooks.mjs feed [--live] [--via updates] the hourly feed; --via updates posts it through DISCORD_WEBHOOK_UPDATES until #network-feed has a webhook 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] @@ -65,7 +67,9 @@ parenthetical dropped, 160 characters at most; a line that trips the guard is dr ## 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. +`DISCORD_WEBHOOK_INCIDENTS`; optional `DISCORD_WEBHOOK_FEED` (no key, no feed, one "feed off" note per tick) and +`DISCORD_WEBHOOK_UPDATES` (the hidden updates channel, the `--via updates` route). 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 diff --git a/docs/community/discord/structure.md b/docs/community/discord/structure.md new file mode 100644 index 000000000..b01e97177 --- /dev/null +++ b/docs/community/discord/structure.md @@ -0,0 +1,317 @@ +# Discord server structure: the build sheet and the changelog + +Server Igneum (id 1557085229330464808, https://discord.gg/igneum). This file is the structure half of the server; the +cosmetics half (banner, roles ladder, emoji, stickers, server guide, pre-join question, server tag, vanity) is in +README.md. Every item below is a row with a "set / read back" state. A row is set only when it has been read back +from the server after the change. Nothing is posted that names a number our files do not hold. + +The founder, 7 October 2026, 12:0x UK: "is discord fully setup and optimised to house an amazing community?" Cosmetics yes; +structure no. This sheet is the structure. + +## Status, 7 October 2026, 11:4x UK + +| Item | State | +|---|---| +| Chrome profile | confirmed: the signed-in Google account on the tab is the igneum.network login, the Igneum profile | +| Discord session | expired. discord.com shows the account chooser with "igneum_network: Please log in again"; the Log in button opens the email and password form. Nothing was typed (rule: never a password, a code or an email code). the founder signs in himself, then every row below is built and read back in one pass | +| Network feed, webhook-only | built and posted once: `feed:2026-10-07:11`, 413 characters, message id 1557339585166581846, through DISCORD_WEBHOOK_UPDATES (`feed --via updates --live`). The hourly schedule starts on the box when `DISCORD_WEBHOOK_FEED` lands in `/srv/discord-hooks/env` (section 4) | +| Every other row | waiting on the sign-in; the words, settings and order are fixed here so the pass is mechanical | + +## 1. Channel map + +Order top to bottom as the sidebar shows it. "Threads" means Create Public Threads allowed for @everyone and the +channel's own threads kept (auto-archive 3 days). Slowmode is 10 s where set. A pinned post is the channel's first +message, posted from the founder's account (the webhook posts only the bot's numbers), then pinned. + +### What changes from the current server + +| Today | Becomes | Why | +|---|---|---| +| #numbers | #network-feed, read-only, under START HERE's bot row moved to its own place below MINE | one bot channel; the pulse, the digest, the weekly, the hash-origin report and the hourly feed all land here. `DISCORD_WEBHOOK_NUMBERS` keeps working (the webhook follows the channel through a rename); `DISCORD_WEBHOOK_FEED` is set to the same channel's URL (a second webhook named "Igneum feed", or the same URL copied) | +| #first-blocks | #first-block | the brief's name; one block per post, so the singular reads right. ladder.md says #first-blocks; this sheet is the later word | +| #ask | removed | the four support channels and #mining take questions; the pinned FAQ answers the common ones; #ask's messages are read once for anything worth pinning before deletion | +| #wallet | removed | wallet questions go to the support channel of the OS; the wallet page is linked from every support pin | +| #incidents | kept, under START HERE after #releases | the watcher already posts there; cause and fix in public is the ledger's rule | +| #ledger | kept, under BUILD last | criticism with a ledger id and a date, as announcement 1 says | +| #discord-updates | kept, hidden (Founder and Core only) | the CI red watcher and the feed's proof post use its webhook | +| #mining, #devnet, #proving, #start-here, #announcements, #rules | kept, topics and pins set below | | + +### START HERE (read-only for @everyone: Send Messages off on the category; Core and Founder post) + +| Channel | Topic | Pinned first post | Settings | Set / read back | +|---|---|---|---|---| +| #start-here | What Igneum is, what to do first, the ladder. | the welcome post (section 3.3) | read-only; the server guide's first to-do | waiting | +| #rules | Three rules. Read once. | the rules post (section 2.6, the same words as the rules screen) | read-only | waiting | +| #announcements | Releases and chain events only. Announcement channel (followable). | announcement 1 is already here (announcement-01.md); edit its "How this channel works" line to: "Releases in #releases, numbers every hour in #network-feed from the labelled bot. Incidents, with cause and fix, in #incidents. Questions in #mining or the support channel for your OS, criticism in #ledger, where it gets a ledger id and a date." | read-only; Announcement channel type | waiting | +| #releases | One post per release: version, downloads, sha256, what changed. The app updates itself. | "Every release lands here from the bot: the version, the three downloads with their sha256, the node commit, up to five lines on what changed. The app updates itself over the air at a safe moment; a fresh install is at igneum.network/miner. A release is posted after it has crossed the staging chain, never before." | read-only; `DISCORD_WEBHOOK_ANNOUNCEMENTS` moves here (Channel settings, Integrations, Webhooks, edit the channel of the "Igneum" webhook); #announcements keeps human posts only | waiting | +| #incidents | What broke, who it affects, what is being done. Then the cause and the fix. | "An incident is posted when it opens and when it resolves: the UTC time, what happened, who is affected per tier, what is being done; then the cause, the fix and how long it lasted. Three are automatic from the public API: finality paused over five minutes, proving over fifteen minutes behind, the live numbers stale over three minutes. Everything else a person wrote." | read-only | waiting | + +### MINE + +| Channel | Topic | Pinned first post | Settings | Set / read back | +|---|---|---|---|---| +| #mining | Mining talk: cards, rates, settings, what your card is doing. | "Mining questions go here. Say the card, the OS and the app version; the Cards page shows all three. Every rate the project publishes has a row in the bench table (igneum.network/miners) with the command and the hardware. Nothing here is estimated earnings. No price talk: there is no market and nothing is for sale." | slowmode 10 s; threads on | waiting | +| #rig-photos | Your rig, your card, your first block card. Photos only, a line of text. | "Post the rig. One photo or the saved block card, one line: the cards, the OS, the rate the app shows. No serial numbers, no machine names, no payout addresses in the shot. Threads for the replies, so the photos stay a wall." | threads on; slowmode 10 s; Attach Files on for @everyone in this channel only | waiting | +| #first-block | Every first block, posted by the bot. Your reply under it. | "When a key finds its first blue block the bot posts it here: the short key id and the block link, and the card if the miner switched 'Make my page public' on in the app. Reply in the thread under it. The next rungs are in #start-here." | threads on; Send Messages off for @everyone, Send Messages in Threads on; the ladder bot posts (section 4.2) | waiting | +| #benchmarks | Your card's rate and watts, the command, the app version. The bench table is built from rows like these. | "A benchmark is a row: card, driver, OS, app version, MH/s, W, and the command or the screen it came from. The project's own rows are at igneum.network/miners with the hardware named. Say the watts; a row without them is not used." | slowmode 10 s; threads on | waiting | +| #support-windows | Windows: install, SmartScreen, the firewall prompt, drivers. | the FAQ post (section 1.5) with the Windows line first: "SmartScreen shows 'Windows protected your PC' on a new build: More info, then Run anyway. The firewall prompt comes 20 to 50 seconds in; it is the app opening its port." | slowmode 10 s; threads on by default | waiting | +| #support-mac | macOS: Gatekeeper, Open Anyway, Apple silicon. | the FAQ post with the Mac line first: "macOS refuses an app it has not seen: System Settings, Privacy and Security, Open Anyway. Apple silicon mines; it proves on the CPU, slowly." | slowmode 10 s; threads on by default | waiting | +| #support-linux-hiveos | Linux and HiveOS: the tarball, the flight sheet, drivers. | the FAQ post with the Linux line first: "The Linux and HiveOS tarball and the flight sheet are at igneum.network/miner. Say the distribution and the driver version with every question." | slowmode 10 s; threads on by default | waiting | +| #pools | Pools: the project's pool-0 when it opens, and every outside pool with a signed statement. | "Until the public testnet every machine mines solo on its own keys, and a card runs several. Pools come with the testnet; the project runs pool-0 at 1 percent, the same as the optional software fee, and an outside pool is listed here once it publishes a signed key list. A pool never holds your vote weight: the member's own vote key is in every header." | slowmode 10 s; threads on | waiting | + +### #network-feed (its own row under MINE; the bot's only voice) + +| Channel | Topic | Pinned first post | Settings | Set / read back | +|---|---|---|---|---| +| #network-feed | Numbers from the public API, every hour. Read-only. | "Every hour: height, hash rate, keys and the last lock, from igneum.network/api/live. Four times a day the fuller pulse, at 09:00 UK the daily digest, Monday the weekly numbers, daily the hash-origin report (who found the blocks). Milestones once each. The bot never replies; questions go to #mining." | Send Messages off for @everyone and every role; webhooks "Igneum" (numbers) and "Igneum feed" (feed) | waiting; the feed's words are proven (status) | + +### BUILD + +| Channel | Topic | Pinned first post | Settings | Set / read back | +|---|---|---|---|---| +| #devnet | The devnet: resets, activations, what the node is doing. | "The devnet has mined since 3 October 2026. Coins on it have no value and the chain may be reset. A consensus change flips when 95 percent of mining weight signals it, with a floor height as the backstop; it is announced here first. The staging chain crosses first, every time." | slowmode 10 s; threads on | waiting | +| #proving | Proving: shards, the proof pool, what your card can prove. | "Every block is proven in shards by miners' cards and paid from the block. The Prove page of the app says in one sentence what your card can do: the full shard needs a 24 GB NVIDIA card, a 32 GB card mines and proves at once, Apple silicon proves on the CPU. Proof lag and shards paid are in #network-feed." | slowmode 10 s; threads on | waiting | +| #node-runners | Running a node: sync, peers, the RPC, the snapshot. | "A node runner's channel. Say the version, the height and the peer count; the node prints all three. A node that pauses finality reports it itself. Snapshots are refused below the node's tip; a reorg never resets execution. Node problems that look like consensus go to #devnet." | slowmode 10 s; threads on; the pre-join "I run a node" lands here | waiting | +| #dev | Building on or with Igneum: the client, the API, the explorer, tools. | "For people writing code. The public API is igneum.network/api/stats, /api/live and /api/supply, every field named. The repository opens with the public testnet. A claim about another chain cites the repo and file or is labelled approximate." | slowmode 10 s; threads on; the pre-join "I build" lands here | waiting | +| #spec | The spec, line by line. An issue on the spec is the way to report a flaw. | "The litepaper is at igneum.network/litepaper and the spec pages under it. Quote the line you mean. A flaw goes to hello@igneum.network or an issue on the spec; it gets a ledger id and a date in #ledger." | slowmode 10 s; threads on | waiting | +| #ledger | Every criticism, with a ledger id and a date, and what was done. | "Criticism lands here and at igneum.network/ledger with an id and a date. Nothing is deleted; a resolved item says how. The founder is one person, pseudonymous, building with AI systems; say so if that is your criticism, it has an entry." | slowmode 10 s; threads on | waiting | + +### COMMUNITY + +| Channel | Topic | Pinned first post | Settings | Set / read back | +|---|---|---|---|---| +| #general | Everything else about Igneum. | "General talk. The three rules: be kind, no seed phrases ever, nobody from Igneum will DM you first. No price talk: there is no market and nothing is for sale. Regional threads open here when a language has ten people asking for one." | slowmode 10 s; threads on | waiting | +| #off-topic | Not Igneum. GPUs, games, the weather. | "Anything but Igneum and anything but prices. Same three rules." | slowmode 10 s | waiting | + +### Hidden (Founder and Core) + +| Channel | Topic | Settings | Set / read back | +|---|---|---|---| +| #mod-log | AutoMod alerts and moderation notes. | private; every AutoMod rule's alert channel | waiting | +| #discord-updates | CI red runs, the feed's proof posts, tooling notes. | private; unchanged; holds the `DISCORD_WEBHOOK_UPDATES` webhook | exists | + +Regional threads: not now. A regional thread opens in #general when ten members ask for one language; it is a thread, +not a channel, until it carries a week of talk (the no-empty-channels rule applies to channels, not threads). + +### 1.5 The support FAQ pin (the same post in the three support channels, the OS line first) + +From the miner page's "Before you start" section, verbatim where it is quoted: + +``` +Before you start. The questions worth asking. + +Does this website use my GPU to mine? +No. Mining happens only in the app you install, and only when you press Start. + +Does my card mine and prove? +Every card mines. Proving the full shard needs a 24 GB NVIDIA card; a 32 GB card does both at once. Apple silicon proves on the CPU, slowly. The Prove page of the app says in one sentence what your card can do. + +Is the devnet paying real money? +No. Devnet coins have no value and the chain may reset. The app's pounds row reads 0.00 on devnet and says why. + +Is there a fee? +Not in the protocol: no dev fund, no fee to any team. The app takes an optional 1% software fee, the norm for GPU miners, and one flag turns it off. + +Can I run it on a rig or in a pool? +The Linux and HiveOS tarball is on the miner page with the flight sheet. Pools are part of the public testnet; until then every machine mines solo on its own keys, and a card runs several. + +Where do the numbers come from? +Every rate has a row in the bench table and an entry in the engineering log with the command and the hardware. Nothing is estimated earnings. + +Ask here with the card, the OS and the app version. Nobody from Igneum will DM you first. igneum.network/miner +``` + +## 2. Moderation + +| Setting | Value | Where | Set / read back | +|---|---|---|---| +| Verification level | Medium (registered on Discord for longer than 5 minutes) | Server Settings, Moderation | waiting | +| Explicit image filter | Filter messages from all members | Server Settings, Moderation | waiting | +| 2FA requirement for moderation | on | Server Settings, Moderation (needs the founder account's 2FA, which it has) | waiting | +| @everyone role | Mention @everyone, @here and All Roles: off. Also off: Manage Messages, Manage Threads, Create Private Threads, Use External Emoji stays on, Create Invite on | Server Settings, Roles, Default Permissions | waiting | +| Bot role | View Channels, Send Messages, Embed Links, Attach Files, Read Message History. Nothing else (no Manage, no Mention Everyone, no Administrator). Webhooks are not members and carry no role; this role is for the ladder bot's user only until that bot gets its own role (section 4.2) | Server Settings, Roles, Bot | waiting | +| #mod-log | private channel, Founder and Core; the alert channel of every AutoMod rule | Channel create | waiting | +| Rules screen | Membership screening on, the rules text in 2.6 | Server Settings, Safety Setup, Rules Screening | waiting | +| DM spam | Server Settings, Safety Setup, "DM and spam protection": Raid protection on, Activity alerts on | | waiting | + +### 2.1 to 2.5 AutoMod rules (Server Settings, AutoMod) + +Discord allows six custom keyword rules plus its own presets. Every rule: Block message, Send alert to #mod-log. +Exempt roles: Founder, Core. Exempt channels: #mod-log, #discord-updates. + +| # | Rule | Trigger (keywords use Discord's wildcards; regex is Rust-flavoured, under 260 characters) | Action | +|---|---|---|---| +| 1 | Seed phrases and wallet scams | keywords: `seed phrase`, `recovery phrase`, `secret phrase`, `12 words`, `24 words`, `private key`, `send to`, `send me`, `dm me`, `message me first`, `validate your wallet`, `sync your wallet`, `connect your wallet`, `claim your`, `airdrop`, `giveaway`, `support ticket`; regex: `(?i)\b(seed|recovery|secret)\s*(phrase|words)\b` | block, alert, time out 10 minutes | +| 2 | Invite links | regex: `(?i)(discord\.gg|discord(app)?\.com/invite|discord\.com/invite)/\S+` | block, alert (Core exempt) | +| 3 | Spam links | regex: `(?i)https?://\S*\.(xyz|top|click|buzz|icu|monster|rest)\b`, `(?i)\b(t\.me|wa\.me|bit\.ly|tinyurl\.com|cutt\.ly)/\S+`; plus Discord's preset "Block suspected spam content" and "Block mention spam" at 5 mentions | block, alert | +| 4 | Price pumping | keywords: `100x`, `1000x`, `to the moon`, `wen moon`, `pump`, `pumping`, `buy now`, `buy the dip`, `price prediction`, `price target`, `listing soon`, `when binance`, `wen binance`, `wen lambo`, `presale`, `ico`, `ido` | alert only, no block (a miner asking is a conversation; a mod answers with the pin: there is no market and nothing is for sale) | +| 5 | Impersonation of the team | keywords in messages: `igneum team`, `igneum support`, `official igneum`, `igneum admin`, `igneum mod`, `igneum staff`, `from igneum`; member-profile rule (AutoMod "Block words in member profiles"): `igneum`, `admin`, `mod`, `support`, `team`, `official` in display names (Core exempt; the founder's account is named by the server, not by these words) | block, alert; profile rule: block the profile update and alert | +| 6 | Explicit content | Discord's preset keyword lists: Profanity, Sexual content, Slurs and hate speech, all on | block, alert | + +Read-back for each rule: the rule listed as Enabled, a test message from a non-Core account blocked (the alert in +#mod-log), and one legitimate message passing (a payout address, a sha256, a hash rate "2x per joule"). + +### 2.6 The rules text (the rules screen and #rules, the same words) + +``` +Three rules. + +1. Be kind. Argue the claim. +2. No seed phrases ever. Not yours, not anyone's, not in a DM, not "to check". +3. Nobody from Igneum will DM you first. Anyone who does is not from Igneum. + +No price talk: there is no market and nothing is for sale. + +Report a flaw: hello@igneum.network or an issue on the spec. Nobody from Igneum will ask for your seed. +``` + +## 3. Onboarding + +### 3.1 Server guide to-dos (Server Settings, Onboarding, Server Guide), aligned with the map + +| # | To-do | Channel | +|---|---|---| +| 1 | Read what Igneum is and the ladder | #start-here | +| 2 | Read the three rules | #rules | +| 3 | Pick your lane: Miner, Node runner, Builder (the question you answered on joining; change it in Channels and Roles) | #mining | +| 4 | Post your first block when it lands | #first-block | +| 5 | Follow releases | #releases | + +Welcome sign stays; the guide banner is the one in README.md. + +### 3.2 The pre-join question (Server Settings, Onboarding, Default Channels and Questions) + +Question "What do you mine with?" stays. Answers and what each hands out: + +| Answer | Role | Channels joined | +|---|---|---| +| NVIDIA | Miner | #mining, #first-block, #rig-photos, #support-windows, #support-linux-hiveos | +| AMD | Miner | #mining, #first-block, #rig-photos, #support-windows, #support-linux-hiveos | +| Apple | Miner | #mining, #first-block, #rig-photos, #support-mac | +| I run a node | Node runner | #node-runners, #devnet | +| I build | Builder | #dev, #spec, #proving | + +Multiple answers allowed, not required. Default channels (everyone, no question): #start-here, #rules, +#announcements, #releases, #network-feed, #general. + +### 3.3 The welcome post in #start-here (pinned, from the founder's account) + +``` +Welcome to Igneum. + +A proof-of-work chain built for graphics cards. The miners also prove every block and are paid for it from the block. No premine, no stake, no fee to any team in the protocol. One founder, pseudonymous, building with AI systems; every criticism we know of is at igneum.network/ledger. + +Start here +1. Install the miner: igneum.network/miner. Windows, macOS, Linux and HiveOS. +2. Press Start. The app says what your card can do. +3. Your first block lands in #first-block. Reply under it. + +The ladder. The chain computes it, nobody hands it out. +First block: one blue block with your key. The bot posts it in #first-block. +Your key has a vote: 100 blocks in the 30-day window (the dust line). The Voter role. +Your signature is in a checkpoint: the first lock carrying your vote. +Full window: 30 of 30 days with blocks. The Window role. +Rank: your place by weight among all keys, on igneum.network/live. +Your card proved a shard: a paid shard record. The Prover role. + +Questions in #mining or the support channel for your OS. Numbers every hour in #network-feed. Rules in #rules; there are three. +Devnet coins have no value and the chain may be reset. Nothing is for sale. +``` + +The rung names and rules are reinvent.md section 3.7 and ladder.md; on the devnet the dust line is the chain's +`params.dust` (5 today), and the bot's rung 1 post says the number it read, never the constant. + +## 4. Bots + +Two bots. Neither replies, neither mentions, both post from the public API only, both go through the forbidden-string +guard in `tools/community/discord-hooks.mjs` (the founder's name, hosts, machine ids, paths, IPs, 32-hex tokens, +webhook URLs, any mention). + +### 4.1 The network feed bot (webhook; BUILT, the hourly schedule waits on one key) + +What it is: `tools/community/discord-hooks.mjs feed`, added 7 October 2026 beside pulse, digest and weekly. A webhook +needs no bot user, no token and no Discord permission beyond the webhook itself (Channel settings, Integrations, +Webhooks, "Igneum feed" on #network-feed, or the existing "Igneum" webhook's URL copied into the key). + +| Post | When | Exact shape | +|---|---|---| +| Network feed | every hour, on the London clock, through `tick` | title "Network feed" linking igneum.network/live; line 1 "igneum-devnet. Devnet coins have no value. https://igneum.network/live"; fields Height (`state.height`, blocks/s over the hour from `block_count`), Hash rate (`state.hashes_per_second_estimate`, difficulty), Keys (`state.miners_10m` "active in 10 min (a card runs several)", `finality.weights.voters` "voters in the 30-day window"), Last lock (the newest locked checkpoint: index, share of weight, age, voters); footer the UK time with UTC in brackets and "every hour from /api/live; this channel is read-only, questions go to #mining" | +| Hash origin, daily | in the first feed of the day that sees a new `state.hash_origin.date` (the launch pack's job writes it at 08:30 UTC) | one extra field "Hash origin, ": "N keys found a block, M above dust, ten largest X% of blocks, project fleet Y%, P attested pools. Full report in #numbers." A field the report lacks is left out, never estimated | +| Milestone | once each, when a feed crosses it | title "Milestone": every 100,000 chain blocks ("Chain block 100,000. igneum.network/block/100000"), every 10,000 locks, every 10,000 paid shards, the first crossing of 100, 250, 500, 1,000, 2,500, 5,000, 10,000 vote keys active at once, the first crossing of 1, 10, 100 GH/s and 1, 10, 100 TH/s. The first feed seeds and posts none | + +Read back today (7 October 2026, 11:30 UK, dry run against the live API, then one live post through the updates +webhook as the proof of the path): "Height 162,099, 1.47 blocks/s last 60 s; Hash rate 444.7 MH/s, difficulty +210,368,365; Keys 17 active in 10 min, 16 voters in the 30-day window; Last lock checkpoint 8,723 at 72.1% of weight, +16 s ago, 16 voters". 413 characters. Tests: 37 pass (`node --test tools/community/discord-hooks.test.mjs`). + +What the numbers mean per tier (the consequences rule): 17 keys and 444.7 MH/s is the project's own machines on the +devnet, so the feed names no outside miner yet; a home miner with one 8 GB card at the measured rates sees a first +block inside the hour at this size, and the Keys line is the one that tells them when that stops being true (the +pool is the answer from about 1 TH/s, reinvent.md 3.5). + +To turn the hour on: add `DISCORD_WEBHOOK_FEED=` to `~/.config/igneum/discord`, then +`IGNEUM_SECRET_ON_BOX_OK=1 infra/build-server/discord-hooks/install.sh` (copies the script and the file to the box; +the tick posts the next hour). Until the key exists the tick logs "feed off (no DISCORD_WEBHOOK_FEED)" once a minute +and posts nothing. The box's copy of the script is the 6 October one until install.sh runs. + +### 4.2 The ladder bot (a bot user; SPECIFIED, not built) + +What it posts, from ladder.md (the shapes are fixed there; the words are repeated here so this file stands alone): + +| Post | Channel | Trigger | Exact shape | +|---|---|---|---| +| First block (rung 0) | #first-block | a key's `blocks` goes 0 to 1 in `finality.weights.keys[]` and a block in `/api/live blocks[]` names it | "First block: key 6ad0e117" / "Block 1,284,117 · igneum.network/block/" / "RTX 4070 (the miner chose to show the card)". Without the opt-in the third line is absent. Embed: ember, title "First block", fields Key and Block, UK footer | +| The vote (rung 1) | #first-block | `keys[].voter` goes false to true | "Your key has a vote: 6ad0e117" / "100 blocks in the window (the line is 100) · the key now signs every checkpoint"; the number is `params.dust` as read | +| The ladder, yesterday | #network-feed | 09:00 UK beside the digest | the seven-line summary in ladder.md (first blocks, votes, signatures, full window, rank 1, signing streak, shards); a line whose field the node does not expose is left out | +| Roles | | daily read of `getFinalityWeights` through `/api/live` | Voter granted when a message signed with the vote key names a key whose `voter` is true; revoked at a daily read where `voter` is false. Window when `keys[].daysMined` spans the window (owed from the node; by hand until then). Prover when a paid shard record names the key; never revoked | + +Opt-in, per miner: the key id and the block link post for every key (a chain fact); the card model and the "(you)" +link only when the miner switched "Make my page public" on in the app (`settings.profile_public`). The Discord role +needs the miner to prove the key: a `/key ` slash command, the signature over a nonce the bot gives, +verified against `getFinalityWeights`; the app's "prove my key" button is owed (ladder.md). + +Permissions the bot user needs, minimal: View Channels; Send Messages and Embed Links in #first-block and #network-feed +only (channel overrides, not server-wide); Read Message History; Manage Roles, with the bot's role placed above Voter, +Window and Prover and below everything else; `applications.commands` scope for the slash command. Not: Administrator, +Mention Everyone, Manage Channels, Manage Webhooks, Moderate Members. + +Rungs 2 to 5 and P are never posted singly; they are counted in the daily summary (one a minute at scale). + +### 4.3 The two webhooks that exist and where they point + +| Key | Channel | Used by | +|---|---|---| +| DISCORD_WEBHOOK_NUMBERS | #numbers, to be #network-feed | pulse, digest, weekly, the hash-origin report | +| DISCORD_WEBHOOK_ANNOUNCEMENTS | #announcements, to move to #releases | release posts | +| DISCORD_WEBHOOK_INCIDENTS | #incidents | incident open and resolve, the watcher | +| DISCORD_WEBHOOK_UPDATES | #discord-updates (hidden) | the CI red watcher; the feed's proof post today (`feed --via updates`) | +| DISCORD_WEBHOOK_FEED | #network-feed, not created yet | the hourly feed and milestones | + +## 5. Events + +One template, "Devnet office hour", weekly, unscheduled until the founder picks a time. + +| Field | Value | +|---|---| +| Name | Devnet office hour | +| Where | a voice channel "office-hour" under COMMUNITY, created with the first event (not before: no empty channels) | +| Description | "An hour on the devnet, every week. Bring the card, the log line or the question. Numbers from the public API only; nothing is for sale and there is no price." | +| Recurrence | weekly, same day and hour, UK time | +| Needs the founder | the day and the hour | + +## 6. What needs the founder + +| Item | Why | +|---|---| +| Sign in to Discord in the Igneum Chrome profile | the session expired; this lane never types a password or a code. After the sign-in every row above is built and read back in one pass and this file's states flip | +| A day and an hour for the office hour | section 5 | +| The hosting word for the ladder bot | a bot token with Manage Roles is a secret the box would hold; the 6 October exception covers webhook URLs only. Options: the box under a second exception, or a separate small host that holds only the bot token and reads the public API | +| The webhook for #network-feed | made in Discord after the sign-in (one minute); then the key and `install.sh` (section 4.1). Until then the hour is off and the proof post sits in the hidden channel | + +## Changelog + +| When (UK) | What | +|---|---| +| 7 Oct 2026, 11:1x | Sheet opened on branch discord-structure. Chrome profile confirmed as the igneum.network login. Discord session found expired; stopped at the log-in form, nothing typed | +| 7 Oct 2026, 11:2x | `feed` subcommand, milestones, the daily hash-origin field, `--via updates`, `DISCORD_WEBHOOK_FEED` (optional) and the tick wiring added to tools/community/discord-hooks.mjs; six tests added, 37 pass | +| 7 Oct 2026, 11:30 | One live feed posted through the updates webhook: key `feed:2026-10-07:11`, message id 1557339585166581846, 413 characters | diff --git a/tools/community/discord-hooks.mjs b/tools/community/discord-hooks.mjs index 4c553a865..5c9facfab 100755 --- a/tools/community/discord-hooks.mjs +++ b/tools/community/discord-hooks.mjs @@ -12,11 +12,13 @@ // 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 feed [--live] [--via updates] hourly network feed → #network-feed (milestones and the daily hash-origin line ride with it) // 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. +// DISCORD_WEBHOOK_NUMBERS, DISCORD_WEBHOOK_ANNOUNCEMENTS, DISCORD_WEBHOOK_INCIDENTS; optional DISCORD_WEBHOOK_FEED (#network-feed; +// until it exists, `feed --via updates` posts the feed through DISCORD_WEBHOOK_UPDATES, the hidden updates channel, as a proof of the path). // 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). @@ -35,6 +37,9 @@ 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 }; +// Milestones the feed posts once each (idempotency key milestone::): every 100,000 chain blocks, every 10,000 locks, +// every 10,000 paid shards; the first crossing of a vote-key count and of a hash-rate step. The first feed seeds and posts none. +export const FEED_MILESTONES = { height: 100000, locks: 10000, shards: 10000, keys: [100, 250, 500, 1000, 2500, 5000, 10000], hashrate: [1e9, 10e9, 100e9, 1e12, 10e12, 100e12] }; const HERE = path.dirname(fileURLToPath(import.meta.url)); export const OUT_DIR = process.env.IGNEUM_DISCORD_OUT || path.join(HERE, 'out'); @@ -507,6 +512,66 @@ export function watchPass(data, state, now = data.now) { return actions; } + +// ---------- 5. the hourly network feed (#network-feed, read-only channel; the bot's only voice there) ---------- +// Height, hash rate, keys and the last lock from /api/live (the observer's copy of the node), one small embed an hour. +export function hashOriginLine(live) { + const h = live && live.state && live.state.hash_origin; + if (!h || !h.date || !h.keys) return null; + const pct = x => (x === null || x === undefined ? 'n/a' : `${(Number(x) * 100).toFixed(1)}%`); + const parts = [`${fmtInt(h.keys.with_block)} keys found a block`, `${fmtInt(h.keys.above_dust_30d)} above dust`, `ten largest ${pct(h.keys.top10_share)} of blocks`]; + if (h.fleet && h.fleet.share !== undefined) parts.push(`project fleet ${pct(h.fleet.share)}`); + if (h.pools && h.pools.attested !== undefined) parts.push(`${plural(h.pools.attested, 'attested pool')}`); + return { date: String(h.date), text: `${parts.join(', ')}. Full report in #numbers.` }; +} +export function shapeFeed(data, prev, { hashOrigin = true } = {}) { + const { stats, live, now } = data; + const cur = snapshot(data); + const st = (live && live.state) || {}; + 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 : st.blocks_per_second_60s ?? null; + const lock = newestLock(live); + const stale = !!(stats.stale || st.stale); + const description = stale + ? `Observer stale for ${fmtDur(stats.age_s ?? st.age_s)}: every number is last known. ${SITE}/live` + : `${stats.network || st.network || 'devnet'}. Devnet coins have no value. ${SITE}/live`; + const fields = [ + { name: 'Height', value: `${fmtInt(st.height ?? stats.height)}\n${bps === null ? 'n/a' : bps.toFixed(2)} blocks/s ${prev ? `over ${fmtDur(windowS)}` : 'last 60 s'}` }, + { name: 'Hash rate', value: `${fmtHash(st.hashes_per_second_estimate ?? stats.hashrate)}\ndifficulty ${fmtNum(st.difficulty ?? stats.difficulty, 0)}` }, + { name: 'Keys', value: `${fmtInt(st.miners_10m ?? stats.miners_10m)} active in 10 min (a card runs several)\n${cur.voters === null ? 'voters n/a' : `${fmtInt(cur.voters)} voters in the 30-day window`}` }, + { 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')}` + : `${cur.finality_active ? 'active' : 'paused'}, latest index ${fmtInt(cur.locked_index)}` }, + ]; + const ho = hashOrigin ? hashOriginLine(live) : null; + if (ho) fields.push({ name: `Hash origin, ${ho.date}`, value: ho.text, inline: false }); + const payload = embed({ title: 'Network feed', url: `${SITE}/live`, description, fields, footerNote: 'every hour from /api/live; this channel is read-only, questions go to #mining', now, thumbnail: false }); + return { payload, snapshot: cur, hashOriginDate: ho ? ho.date : null }; +} +// Milestones crossed between the previous feed snapshot and this one. None on the first feed (the seed). +export function feedMilestones(cur, prev) { + if (!prev) return []; + const out = []; + const step = (kind, a, b, size, text) => { + if (a === null || a === undefined || b === null || b === undefined) return; + const from = Math.floor(Number(a) / size), to = Math.floor(Number(b) / size); + for (let k = from + 1; k <= to; k++) out.push({ key: `milestone:${kind}:${k * size}`, text: text(k * size) }); + }; + step('height', prev.height, cur.height, FEED_MILESTONES.height, v => `Chain block ${fmtInt(v)}. ${SITE}/block/${fmtInt(v).replace(/,/g, '')}`); + step('locks', prev.locked_index, cur.locked_index, FEED_MILESTONES.locks, v => `${fmtInt(v)} checkpoints locked by miner vote weight.`); + step('shards', prev.paid_shards_total, cur.paid_shards_total, FEED_MILESTONES.shards, v => `${fmtInt(v)} proof shards paid to provers from the block.`); + const first = (kind, a, b, levels, text) => { + if (a === null || a === undefined || b === null || b === undefined) return; + for (const v of levels) if (Number(a) < v && Number(b) >= v) out.push({ key: `milestone:${kind}:${v}`, text: text(v) }); + }; + first('keys', prev.miners_10m, cur.miners_10m, FEED_MILESTONES.keys, v => `${fmtInt(v)} vote keys active at once (a card runs several).`); + first('hashrate', prev.hashrate, cur.hashrate, FEED_MILESTONES.hashrate, v => `Network hash rate passed ${fmtHash(v)}.`); + return out; +} +export function shapeMilestone(text, now = new Date()) { + return embed({ title: 'Milestone', url: `${SITE}/live`, description: text, fields: [], footerNote: 'posted once per milestone, from /api/live', now, thumbnail: false }); +} + // ---------- credentials, state, posting ---------- export function readCredentials(file = CRED_FILE) { if (!fs.existsSync(file)) return null; @@ -517,7 +582,10 @@ export function readCredentials(file = CRED_FILE) { } return out; } -export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS' }; +export const CHANNEL_KEY = { numbers: 'DISCORD_WEBHOOK_NUMBERS', announcements: 'DISCORD_WEBHOOK_ANNOUNCEMENTS', incidents: 'DISCORD_WEBHOOK_INCIDENTS', feed: 'DISCORD_WEBHOOK_FEED' }; +export const REQUIRED_CHANNELS = ['numbers', 'announcements', 'incidents']; // feed is optional: no key, no feed, one log line +// --via updates: the feed through the hidden updates channel's webhook until #network-feed has its own +export const VIA_KEY = { updates: 'DISCORD_WEBHOOK_UPDATES' }; export function loadState(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { posts: {}, pulses: [], incidents: {}, watch: {} }; } @@ -551,8 +619,10 @@ export async function postWebhook(url, payload, { fetchImpl = fetch, maxTries = } export class Poster { - constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch } = {}) { + constructor({ live = false, outDir = OUT_DIR, credFile = CRED_FILE, stateFile, force = false, log = msg => process.stderr.write(`${msg}\n`), fetchImpl = fetch, via = {} } = {}) { this.live = live; this.outDir = outDir; this.force = force; this.log = log; this.fetchImpl = fetchImpl; + this.via = {}; // channel → credentials key override, e.g. { feed: 'DISCORD_WEBHOOK_UPDATES' } + for (const [ch, name] of Object.entries(via)) { if (!VIA_KEY[name]) throw new Error(`unknown --via ${name}`); this.via[ch] = VIA_KEY[name]; } 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')); @@ -561,8 +631,9 @@ export class Poster { } already(key) { return !this.force && !!this.state.posts[key]; } // the webhook keys the credentials file lacks (names only); empty in dry run - missingKeys() { return this.live ? Object.values(CHANNEL_KEY).filter(k => !this.creds[k]) : []; } - has(channel) { return !this.live || !!this.creds[CHANNEL_KEY[channel]]; } + missingKeys() { return this.live ? REQUIRED_CHANNELS.map(ch => CHANNEL_KEY[ch]).filter(k => !this.creds[k]) : []; } + keyFor(channel) { return this.via[channel] || CHANNEL_KEY[channel]; } + has(channel) { return !this.live || !!this.creds[this.keyFor(channel)]; } // 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); @@ -576,8 +647,8 @@ export class Poster { 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 url = this.creds[this.keyFor(channel)]; + if (!url) throw new Error(`no ${this.keyFor(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(); @@ -636,7 +707,7 @@ 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 }; + const order = { announcements: 0, numbers: 1, incidents: 2, feed: 3 }; 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)); @@ -644,10 +715,11 @@ export function writePreview(outDir = OUT_DIR) { } // ---------- the scheduler ---------- -export function dueNow(now, state) { +export function dueNow(now, state, { feed = false } = {}) { const l = londonParts(now); const date = londonDateKey(now); const due = []; + if (feed) due.push({ kind: 'feed', key: `feed:${date}:${String(l.hour).padStart(2, '0')}` }); 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}` }); @@ -697,6 +769,22 @@ export async function runWeekly(poster, data, key) { if (!r.skipped) { poster.state.weekly_prev = snap; poster.save(); } return r; } +export async function runFeed(poster, data, key) { + const prev = poster.state.feed_last || null; + const seenDate = poster.state.feed_hash_origin_date || null; + const { payload, snapshot: snap, hashOriginDate } = shapeFeed(data, prev, { hashOrigin: true }); + // the hash-origin line rides in the first feed that sees a new report date, once a day + const shaped = hashOriginDate && hashOriginDate === seenDate ? shapeFeed(data, prev, { hashOrigin: false }) : { payload, hashOriginDate }; + const r = await poster.post('feed', key || `feed:${londonDateKey(data.now)}:${String(londonParts(data.now).hour).padStart(2, '0')}`, shaped.payload); + const results = [r]; + if (!r.skipped) { + for (const m of feedMilestones(snap, prev)) results.push(await poster.post('feed', m.key, shapeMilestone(m.text, data.now))); + poster.state.feed_last = snap; + if (shaped.hashOriginDate) poster.state.feed_hash_origin_date = shaped.hashOriginDate; + poster.save(); + } + return results; +} 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); @@ -739,13 +827,15 @@ export async function main(argv = process.argv.slice(2)) { 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; + for (const [ch, k] of Object.entries(CHANNEL_KEY)) process.stdout.write(`${ch.padEnd(14)} ${c[k] ? 'set' : 'missing'} (${k}${REQUIRED_CHANNELS.includes(ch) ? '' : ', optional'})\n`); + process.stdout.write(`${'updates'.padEnd(14)} ${c[VIA_KEY.updates] ? 'set' : 'missing'} (${VIA_KEY.updates}, the --via updates route for the feed)\n`); + return REQUIRED_CHANNELS.every(ch => c[CHANNEL_KEY[ch]]) ? 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 }); + const poster = new Poster({ live, force: !!flags.force, via: flags.via ? { feed: String(flags.via) } : {} }); if (cmd === 'pulse') { const data = await fetchAll(); await runPulse(poster, data, flags.key); return 0; } + if (cmd === 'feed') { const data = await fetchAll({ fleet: false }); await runFeed(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') { @@ -772,12 +862,14 @@ export async function main(argv = process.argv.slice(2)) { 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); + const feedOn = poster.has('feed'); + const due = dueNow(now, poster.state, { feed: feedOn }); let data = null; if (due.length) { data = await fetchAll(); for (const d of due) { try { + if (d.kind === 'feed') await runFeed(poster, data, d.key); 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); @@ -787,7 +879,7 @@ export async function main(argv = process.argv.slice(2)) { const wdata = data && !data.fetchFailed ? data : await fetchForWatch(); const r = await runWatch(poster, wdata); const missing = poster.missingKeys(); - poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}`); + poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}${feedOn ? '' : `, feed off (no ${poster.keyFor('feed')})`}`); return 0; } throw new Error(`unknown command ${cmd}`); diff --git a/tools/community/discord-hooks.test.mjs b/tools/community/discord-hooks.test.mjs index 8d5d93ed5..b2fa8be07 100644 --- a/tools/community/discord-hooks.test.mjs +++ b/tools/community/discord-hooks.test.mjs @@ -11,6 +11,7 @@ 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, + shapeFeed, feedMilestones, shapeMilestone, hashOriginLine, runFeed, FEED_MILESTONES, } from './discord-hooks.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -370,3 +371,89 @@ test('preview: renders every item as a Discord-like card with the channel and th assert.match(html, /border-left-color:#f2541b/); assert.doesNotMatch(html, /