tools/ci/founder-strings.b64 was the founder check's pattern list base64-encoded: a grep could not read it, any reader of the public host could (git.igneum.network was public from 21:26 UK; read off at 21:36). The list now lives only in a private file (~/.config/igneum/founder-strings on the Mac, /srv/discord-hooks/founder-strings on build-1 for the Discord guard; $IGNEUM_FOUNDER_STRINGS overrides) and site/forbidden-strings.txt carries no encoded copy. Readers: founder-strings-check.sh (skips with a line where the file is absent; the Mac's hook is the guard), site/scrub.mjs and launch-gates-check.mjs (the private file's patterns added where it exists), discord-hooks.mjs (three locations; the test writes a fixture list and loads the module after it), fresh-repo.sh (the private file; drops tools/ci/founder-strings.b64 from every commit; rewrites the base64 of every list regex out of every blob and scans for it). The check's second pass (main's addition): every base64 literal of 24 characters or more, every hex literal of 24 or more and every *.b64 file is decoded and scanned, so no encoding hides a term again; the self-test plants each fixture sample in plain text, in a .b64 file, as a base64 literal and as a hex literal, each caught and named, and a tree without the list skips with its line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
902 lines
61 KiB
JavaScript
Executable file
902 lines
61 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 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; 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).
|
||
|
||
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 };
|
||
// Milestones the feed posts once each (idempotency key milestone:<kind>:<value>): 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');
|
||
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.
|
||
// The founder's name, logins and the earlier businesses come from the PRIVATE list (never a tracked file in any encoding: a base64
|
||
// copy in the tree was a disclosure on the public host, 7 October 2026, 21:3x UK): $IGNEUM_FOUNDER_STRINGS, else
|
||
// ~/.config/igneum/founder-strings (the Mac), else /srv/discord-hooks/founder-strings (build-1, beside the webhook env). One row per
|
||
// pattern: perl regex, a tab, a sample. Absent everywhere: no founder rows (the host that posts carries the file).
|
||
export function founderListPath(env = process.env) {
|
||
for (const f of [env.IGNEUM_FOUNDER_STRINGS, path.join(os.homedir(), '.config', 'igneum', 'founder-strings'), '/srv/discord-hooks/founder-strings']) if (f && fs.existsSync(f)) return f;
|
||
return '';
|
||
}
|
||
export function founderPatterns(file = founderListPath()) {
|
||
if (!file) return [];
|
||
const rows = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim() && !l.startsWith('#')).map((l) => l.split('\t'));
|
||
return rows.map(([re, sample], i) => ({ re: new RegExp(re, 'i'), sample, why: i < 3 ? "the founder's name or login" : i < 8 ? 'an earlier business' : i === 8 ? "the founder's address" : 'the login before its rename' }));
|
||
}
|
||
export const FORBIDDEN = [
|
||
...founderPatterns().map(({ re, why }) => ({ re, why })),
|
||
{ 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;
|
||
}
|
||
|
||
|
||
// ---------- 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;
|
||
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', 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: {} }; }
|
||
}
|
||
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, 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'));
|
||
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 ? 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);
|
||
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[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();
|
||
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, 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));
|
||
return file;
|
||
}
|
||
|
||
// ---------- the scheduler ----------
|
||
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}` });
|
||
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 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);
|
||
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}${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, 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') {
|
||
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 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);
|
||
} 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(', ')}` : ''}${feedOn ? '' : `, feed off (no ${poster.keyFor('feed')})`}`);
|
||
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); });
|
||
}
|