Main's ruling (6 October 2026, 20:1x UK): the box installs with the NUMBERS key alone so the 21:00 UK pulse comes from it. install.sh accepts at least one key and names the missing ones; every tick's log line ends with "missing <keys>"; the watcher without an incidents webhook logs its open or resolve and still advances its state, so the key landing later does not flood the channel. Test added (31 passing). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
798 lines
52 KiB
JavaScript
Executable file
798 lines
52 KiB
JavaScript
Executable file
#!/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/<key>.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 <version> --windows <url> --windows-sha <hex> --mac <url> --mac-sha <hex>
|
||
// --hive <url> --hive-sha <hex> --node-commit <sha> --digest <text> [--plan <file> --section <heading>] [--changed "line"]... [--live]
|
||
// node tools/community/discord-hooks.mjs incident open --what "..." --affected "..." --doing "..." [--at <UTC ISO>] [--id <id>] [--live]
|
||
// node tools/community/discord-hooks.mjs incident resolve --id <id> --cause "..." --fix "..." [--at <UTC ISO>] [--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: /(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/i, why: 'a 32-hex token' },
|
||
{ re: /dl\.igneum\.network\/(?!public\/)/i, why: 'a downloads path outside /public/' },
|
||
{ re: /discord(app)?\.com\/api\/webhooks/i, why: 'a webhook URL' },
|
||
{ re: /@everyone|@here|<@[!&]?\d+>/, 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 <file>` on Mac and Linux, `certutil -hashfile <file> 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]; }
|
||
// 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]]; }
|
||
// 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, '<').replace(/>/g, '>'); }
|
||
function md(s) {
|
||
if (!s) return '';
|
||
let t = esc(s);
|
||
t = t.replace(/```\n?([\s\S]*?)```/g, (_, c) => `<pre>${c.replace(/\n$/, '')}</pre>`);
|
||
t = t.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||
t = t.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||
t = t.replace(/\[([^\]]+)\]\((https?:[^)]+)\)/g, '<a href="$2">$1</a>');
|
||
t = t.replace(/(^|\s)(https?:\/\/[^\s<]+)/g, '$1<a href="$2">$2</a>');
|
||
return t.replace(/\n/g, '<br>');
|
||
}
|
||
export function renderPreview(items) {
|
||
const cards = items.map(({ channel, key, payload }) => {
|
||
const e = payload.embeds[0];
|
||
const fields = (e.fields || []).map(f => `<div class="f ${f.inline ? 'in' : 'full'}"><div class="fn">${md(f.name)}</div><div class="fv">${md(f.value)}</div></div>`).join('');
|
||
return `<section class="msg"><div class="meta">#${esc(channel)} <span class="key">${esc(key)}</span> <span class="len">${embedLength(e)} / ${LIMITS.total} chars</span></div>
|
||
<div class="row"><img class="av" src="${esc(payload.avatar_url)}" alt=""><div class="body"><div class="hdr"><span class="name">${esc(payload.username)}</span><span class="bot">APP</span><span class="time">Today at ${esc(ukStamp(new Date(e.timestamp)).replace(/^.*?, /, '').replace(/ UK.*$/, ''))}</span></div>
|
||
<div class="embed" style="border-left-color:#${e.color.toString(16).padStart(6, '0')}"><div class="grid"><div class="main">
|
||
${e.title ? `<div class="title">${e.url ? `<a href="${esc(e.url)}">${md(e.title)}</a>` : md(e.title)}</div>` : ''}
|
||
${e.description ? `<div class="desc">${md(e.description)}</div>` : ''}
|
||
<div class="fields">${fields}</div>
|
||
${e.footer ? `<div class="footer">${md(e.footer.text)}</div>` : ''}
|
||
</div>${e.thumbnail ? `<img class="thumb" src="${esc(e.thumbnail.url)}" alt="">` : ''}</div></div></div></div></section>`;
|
||
}).join('\n');
|
||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Igneum Discord posts</title>
|
||
<style>
|
||
:root{--bg:#313338;--embed:#2b2d31;--text:#dbdee1;--muted:#949ba4;--link:#00a8fc;--ember:#F2541B;--code:#1e1f22}
|
||
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.375 "gg sans","Noto Sans",system-ui,-apple-system,"Segoe UI",sans-serif;padding:24px 16px}
|
||
h1{font-size:16px;font-weight:600;color:var(--muted);margin:0 0 16px;letter-spacing:.02em} h1 b{color:var(--ember)}
|
||
.msg{max-width:720px;margin:0 auto 28px} .meta{font-size:12px;color:var(--muted);margin:0 0 6px 56px} .key{opacity:.7;margin-left:8px} .len{float:right}
|
||
.row{display:flex;gap:16px} .av{width:40px;height:40px;border-radius:50%;flex:none;background:#000}
|
||
.hdr{display:flex;align-items:center;gap:6px;margin-bottom:4px} .name{font-weight:500;color:#f2f3f5} .bot{background:#5865f2;color:#fff;font-size:10px;font-weight:600;border-radius:3px;padding:0 4px;line-height:15px} .time{font-size:12px;color:var(--muted)}
|
||
.embed{background:var(--embed);border-left:4px solid var(--ember);border-radius:4px;padding:8px 16px 16px 12px;max-width:520px}
|
||
.grid{display:grid;grid-template-columns:1fr auto;gap:16px} .thumb{width:80px;height:80px;border-radius:4px;margin-top:8px}
|
||
.title{font-weight:600;color:#f2f3f5;margin-top:8px} .title a{color:var(--link);text-decoration:none} .title a:hover{text-decoration:underline}
|
||
.desc{font-size:14px;margin-top:8px;white-space:normal} .desc a,.fv a{color:var(--link);text-decoration:none}
|
||
.fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:8px} .f.full{grid-column:1/-1}
|
||
.fn{font-size:14px;font-weight:600;color:#f2f3f5;margin-bottom:2px} .fv{font-size:14px;overflow-wrap:anywhere}
|
||
code{background:var(--code);border-radius:3px;padding:1px 4px;font:12px ui-monospace,Menlo,Consolas,monospace;overflow-wrap:anywhere}
|
||
pre{background:var(--code);border-radius:4px;padding:8px;font:12px/1.35 ui-monospace,Menlo,Consolas,monospace;overflow-x:auto;margin:4px 0}
|
||
.footer{font-size:12px;color:var(--muted);margin-top:8px}
|
||
@media (max-width:600px){.fields{grid-template-columns:1fr 1fr}.grid{grid-template-columns:1fr}.thumb{display:none}.meta{margin-left:0}}
|
||
</style></head><body><h1><b>Igneum</b> Discord posts, dry-run render (${items.length})</h1>${cards}</body></html>`;
|
||
}
|
||
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 = [];
|
||
if (actions.length && !poster.has('incidents')) {
|
||
// the state still advances (no backlog flood when the key lands); the action is logged, not sent
|
||
for (const a of actions) poster.log(`watch: ${a.kind} ${a.id} not posted, no ${CHANNEL_KEY.incidents} in the credentials file`);
|
||
poster.save();
|
||
return [];
|
||
}
|
||
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);
|
||
const missing = poster.missingKeys();
|
||
poster.log(`tick ${ukStamp(now)}: ${due.length} due, watch ${r.length} action(s)${missing.length ? `, missing ${missing.join(', ')}` : ''}`);
|
||
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, '<webhook>')}\n`); process.exit(1); });
|
||
}
|