igneum/tools/community/reddit-bot.mjs
igneum-labs 5987908477 Community kit for r/igneum, the Reddit profile, Discord and the stats bot (6 October 2026)
docs/community/reddit/: settings.md (name, title, description, type, content options, topics, flair set, post requirements), automoderator.yaml (price and sale talk removed, referral links removed, flair required, accounts under 2 days queued, the bot's comments removed), rules.md (eight rules under Reddit's limits), sidebar.md (four widgets and the welcome message, five dated facts from the bench log), wiki/ (index, faq with twenty sourced answers, mining with the eleven-card table, proving tiers and pay, ledger process, glossary), posts/ (start-here, the launch post adapted, the weekly numbers template on /api/stats, /api/live and /api/supply fields, the AMA plan with fifteen questions), bot.md, outreach.md, outreach-week-1.md (the 30-day account rule), profile.md, discord.md, assets/ (Reddit avatar and banner, Discord icon and two banners, rendered from site/icon-512.png and the site's palette and fonts, no slogan).

tools/community/reddit-bot.mjs: the labelled stats bot (OAuth script app, credentials in ~/.config/igneum/reddit, dry-run mode, forbidden-string guard, no comment endpoint). Dry run against the live API at 17:34 UTC rendered; the guard refused a known-bad text and passed a clean one.

Subreddit rules in outreach.md are from memory and marked approximate: reddit.com refused every fetch route (WebFetch, the browser pane, curl 403). Every other number names its file or API field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:37:48 +00:00

227 lines
15 KiB
JavaScript

#!/usr/bin/env node
// Igneum stats bot for Reddit (u/igneum_bot) and, with --discord, the Discord #numbers channel.
//
// What it does: renders the weekly numbers post from the public API (igneum.network/api/stats, /api/live,
// /api/supply) and submits it as a text post with the Devnet flair, or posts a devnet incident the operator
// hands it. What it never does: reply to anyone. There is no comment call in this file, by rule (bot.md); the
// subreddit's AutoModerator rule 8 removes any comment from the bot account and pages the moderators.
//
// Commands
// node tools/community/reddit-bot.mjs weekly [--dry-run] [--out <file>] [--discord]
// node tools/community/reddit-bot.mjs incident --text "<one sentence, UTC time and cause>" [--dry-run] [--discord]
// node tools/community/reddit-bot.mjs check (reads the credentials file, prints which keys are present, never their values)
//
// Credentials: ~/.config/igneum/reddit, KEY=VALUE lines, mode 600, never in the repository and never printed:
// REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET the OAuth "script" app at reddit.com/prefs/apps, owned by u/igneum_bot
// REDDIT_USERNAME, REDDIT_PASSWORD u/igneum_bot
// REDDIT_SUBREDDIT igneum
// REDDIT_FLAIR_TEXT Devnet (optional; the bot looks the flair id up by text)
// DISCORD_WEBHOOK_URL optional; the #numbers channel's webhook (bot.md, discord.md)
//
// Dry run writes the rendered title and body to --out (default docs/community/reddit/out/weekly-<date>.md) and
// touches nothing on Reddit or Discord. State (the previous snapshot for the "last week" column) lives in
// ~/.config/igneum/reddit-bot-state.json and is only written after a successful live post.
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { homedir } from 'node:os';
import path from 'node:path';
const SITE = 'https://igneum.network';
const UA = 'igneum-stats-bot/0.1 (by u/igneum_bot; posts weekly numbers; never replies)';
const CRED_PATH = path.join(homedir(), '.config', 'igneum', 'reddit');
const STATE_PATH = path.join(homedir(), '.config', 'igneum', 'reddit-bot-state.json');
const FORBIDDEN_IN_POST = [/\bJosh\b/, /Hetzner/i, /dl\.igneum/, /\bBST\b/, /DESKTOP-[A-Z0-9]{7}/, /192\.168\./, /tailscale/i, /CLAUDE\.md/];
const args = process.argv.slice(2);
const cmd = args[0];
const flag = (name) => args.includes(name);
const opt = (name, dflt) => { const i = args.indexOf(name); return i >= 0 && args[i + 1] ? args[i + 1] : dflt; };
function fmtInt(n) { return n === null || n === undefined || Number.isNaN(Number(n)) ? 'n/a' : Number(n).toLocaleString('en-GB'); }
function fmtHash(h) {
if (h === null || h === undefined) return 'n/a';
const n = Number(h);
if (n >= 1e9) return `${(n / 1e9).toFixed(2)} GH/s`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)} MH/s`;
return `${fmtInt(n)} H/s`;
}
function na(v, stale) { return v === null || v === undefined ? (stale ? 'n/a (stale)' : 'n/a') : v; }
function utc(d = new Date()) { return d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC'; }
function weekStart(d = new Date()) { const x = new Date(d); x.setUTCDate(x.getUTCDate() - ((x.getUTCDay() + 6) % 7)); return x.toISOString().slice(0, 10); }
async function getJson(p) {
const r = await fetch(SITE + p, { headers: { 'user-agent': UA } });
if (!r.ok) throw new Error(`${p} answered ${r.status}`);
return r.json();
}
async function readCreds() {
if (!existsSync(CRED_PATH)) throw new Error(`no credentials file at ${CRED_PATH}`);
const text = await readFile(CRED_PATH, 'utf8');
const out = {};
for (const line of text.split('\n')) {
const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*?)\s*$/);
if (m) out[m[1]] = m[2];
}
return out;
}
async function readState() { try { return JSON.parse(await readFile(STATE_PATH, 'utf8')); } catch { return null; } }
function snapshot(stats, live, supply) {
const keys = (live.finality && live.finality.weights && live.finality.weights.keys) || [];
const lastLock = (live.events || []).find((e) => e.kind === 'checkpoint_locked');
const pct = lastLock ? (lastLock.text.match(/\(([0-9.]+)% of weight/) || [])[1] : null;
return {
taken: new Date().toISOString(),
stale: Boolean(stats.stale),
network: stats.network, node_version: stats.node_version,
height: stats.height, block_count: stats.block_count, daa: stats.daa, hashrate: stats.hashrate,
difficulty: stats.difficulty, block_time_measured_s: stats.block_time_measured_s,
blocks_per_day_measured: stats.blocks_per_day_measured, miners_10m: stats.miners_10m, peers: stats.peers,
block_reward: stats.block_reward, finality: stats.finality,
voters: keys.filter((k) => k.voter).length, last_lock_pct: pct ? `${pct}%` : null,
proving: live.proving || {},
circulating_ign: supply.circulating_ign, rule_match: supply.check && supply.check.rule_match, sampled: supply.check && supply.check.sampled,
};
}
function render(now, prev, incidents) {
const s = now.stale;
const p = prev || {};
const d = (a, b) => (a !== null && a !== undefined && b !== null && b !== undefined ? fmtInt(Number(a) - Number(b)) : 'n/a');
const pr = now.proving || {}; const ppr = p.proving || {};
const br = now.block_reward || {};
const row = (label, a, b) => `| ${label} | ${na(a, s)} | ${na(b, false)} |`;
const blocksWord = prev ? `${d(now.block_count, p.block_count)} blocks this week` : `${fmtInt(now.block_count)} DAG blocks`;
const title = `Devnet numbers, week of ${weekStart()}: ${fmtHash(now.hashrate)} hash, ${blocksWord}, ${fmtInt(pr.paid_shards_total)} shards paid`;
const body = [
`Weekly numbers from the public API, taken ${utc()}. Every line names its field; the raw JSON is at igneum.network/api/stats, /api/live and /api/supply. Devnet coins have no value and the chain may be reset. Posted by a bot that never replies; questions go to u/igneum_network in a new thread.`,
s ? '\n**The observer was stale at render time: treat every number as last known.**' : '',
prev ? '' : '\nNo snapshot from last week yet: the "Last week" column is empty on this first post.',
'\n**Chain** (/api/stats)\n', '| Field | Now | Last week |', '|---|---|---|',
row('network, node_version', `${now.network} ${now.node_version}`, p.node_version),
row('height (chain block number)', fmtInt(now.height), fmtInt(p.height)),
row('block_count (every DAG block)', fmtInt(now.block_count), fmtInt(p.block_count)),
row('daa (the chain\'s clock, DAA seconds)', fmtInt(now.daa), fmtInt(p.daa)),
row('hashrate (node estimate over 1,000 blocks)', fmtHash(now.hashrate), fmtHash(p.hashrate)),
row('difficulty', fmtInt(now.difficulty), fmtInt(p.difficulty)),
row('block_time_measured_s (60 s window) against a 1 s target', now.block_time_measured_s, p.block_time_measured_s),
row('blocks_per_day_measured against 86,400 target', fmtInt(now.blocks_per_day_measured), fmtInt(p.blocks_per_day_measured)),
row('miners_10m (vote keys active in 10 min; a card runs several)', now.miners_10m, p.miners_10m),
row('peers (the observer\'s node)', now.peers, p.peers),
'\n**Reward** (/api/stats block_reward, /api/supply)\n', '| Field | Now |', '|---|---|',
`| block_reward.ign (per blue block at this DAA score) | ${na(br.ign, s)} IGN |`,
`| block_reward.miner_ign (80%) / proving_pool_ign (20%) | ${na(br.miner_ign, s)} / ${na(br.proving_pool_ign, s)} IGN |`,
`| block_reward.ramp_factor (the 30-day launch ramp, 10% to 100%) | ${br.ramp_factor !== undefined ? (br.ramp_factor * 100).toFixed(1) + '%' : 'n/a'} |`,
`| supply.circulating_ign (the schedule at this DAA score) | ${now.circulating_ign ? fmtInt(Math.round(Number(now.circulating_ign))) : na(null, s)} IGN of 4,000,000,000 |`,
`| supply.check.rule_match / sampled (coinbases matching the emission rule, observer's hourly check) | ${na(now.rule_match, s)} / ${na(now.sampled, s)} |`,
'\n**Finality** (/api/stats finality, /api/live finality)\n', '| Field | Now | Last week |', '|---|---|---|',
row('finality.active', now.finality && now.finality.active, p.finality && p.finality.active),
row('finality.latest_locked_index', fmtInt(now.finality && now.finality.latest_locked_index), fmtInt(p.finality && p.finality.latest_locked_index)),
`| locks this week (index delta) | ${d(now.finality && now.finality.latest_locked_index, p.finality && p.finality.latest_locked_index)} | |`,
row('voters in the window (/api/live finality.weights.keys, voter true)', now.voters, p.voters),
`| last lock's share of weight (/api/live events, newest checkpoint_locked) | ${na(now.last_lock_pct, s)} | |`,
'\n**Proving** (/api/live proving)\n', '| Field | Now | Last week |', '|---|---|---|',
row('proving.active', pr.active, ppr.active),
row('paid_shards_total', fmtInt(pr.paid_shards_total), fmtInt(ppr.paid_shards_total)),
`| shards paid this week (delta) | ${d(pr.paid_shards_total, ppr.paid_shards_total)} | |`,
`| shards_paid_10m / blocks_10m | ${na(pr.shards_paid_10m, s)} / ${na(pr.blocks_10m, s)} | |`,
`| blocks_fully_proven_10m | ${na(pr.blocks_fully_proven_10m, s)} | |`,
row('median_proof_lag_s (target under 60 s at launch)', pr.median_proof_lag_s, ppr.median_proof_lag_s),
row('provers_10m', pr.provers_10m, ppr.provers_10m),
`| verifier (the observer's node) | ${na(pr.verifier, s)} | |`,
'\n**Incidents this week**\n', incidents.length ? incidents.map((t) => `- ${t}`).join('\n') : 'None recorded by the project this week.',
'\n**What these mean**\n',
'- Hashrate per card tier is on the wiki Mining page; divide the network figure 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; /api/supply gives the date it completes.',
'- A proof lag over 60 s means the phase 3 gate is open; the wiki Proving page says what closes it.',
'- Vote keys are not machines; the machine count is owed (ledger X5).',
].filter((l) => l !== '').join('\n');
return { title: title.slice(0, 100), body };
}
function guardText(text) {
for (const re of FORBIDDEN_IN_POST) if (re.test(text)) throw new Error(`refusing to post: the text matches a forbidden pattern ${re}`);
}
async function redditToken(c) {
const basic = Buffer.from(`${c.REDDIT_CLIENT_ID}:${c.REDDIT_CLIENT_SECRET}`).toString('base64');
const body = new URLSearchParams({ grant_type: 'password', username: c.REDDIT_USERNAME, password: c.REDDIT_PASSWORD });
const r = await fetch('https://www.reddit.com/api/v1/access_token', { method: 'POST', headers: { authorization: `Basic ${basic}`, 'user-agent': UA, 'content-type': 'application/x-www-form-urlencoded' }, body });
if (!r.ok) throw new Error(`token request answered ${r.status}`);
const j = await r.json();
if (!j.access_token) throw new Error('token request returned no access_token');
return j.access_token;
}
async function flairId(token, sub, text) {
const r = await fetch(`https://oauth.reddit.com/r/${sub}/api/link_flair_v2`, { headers: { authorization: `Bearer ${token}`, 'user-agent': UA } });
if (!r.ok) return null;
const list = await r.json();
const f = list.find((x) => x.text === text);
return f ? f.id : null;
}
async function submit(token, sub, title, body, flair) {
const form = new URLSearchParams({ sr: sub, kind: 'self', title, text: body, api_type: 'json', sendreplies: 'false' });
if (flair) form.set('flair_id', flair);
const r = await fetch('https://oauth.reddit.com/api/submit', { method: 'POST', headers: { authorization: `Bearer ${token}`, 'user-agent': UA, 'content-type': 'application/x-www-form-urlencoded' }, body: form });
const j = await r.json();
const errs = j && j.json && j.json.errors;
if (!r.ok || (errs && errs.length)) throw new Error(`submit failed: ${r.status} ${JSON.stringify(errs || {})}`);
return j.json.data && j.json.data.url;
}
async function discordPost(url, title, body) {
// Discord caps a message at 2,000 characters; the weekly post goes as the title plus a link line, the tables stay on Reddit.
const content = `**${title}**\n${body.split('\n').slice(0, 1).join('\n')}\nFull tables: the r/igneum post. Raw JSON: ${SITE}/api/stats`;
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', 'user-agent': UA }, body: JSON.stringify({ content: content.slice(0, 1990), username: 'igneum_bot' }) });
if (!r.ok) throw new Error(`discord webhook answered ${r.status}`);
}
async function main() {
if (!cmd || !['weekly', 'incident', 'check'].includes(cmd)) {
console.error('usage: reddit-bot.mjs weekly|incident|check [--dry-run] [--out file] [--text "..."] [--discord]');
process.exit(2);
}
if (cmd === 'check') {
const c = await readCreds();
for (const k of ['REDDIT_CLIENT_ID', 'REDDIT_CLIENT_SECRET', 'REDDIT_USERNAME', 'REDDIT_PASSWORD', 'REDDIT_SUBREDDIT', 'REDDIT_FLAIR_TEXT', 'DISCORD_WEBHOOK_URL']) console.log(`${k}: ${c[k] ? 'present' : 'missing'}`);
return;
}
const dry = flag('--dry-run');
let title, body;
let now = null;
if (cmd === 'weekly') {
const [stats, live, supply] = await Promise.all([getJson('/api/stats'), getJson('/api/live'), getJson('/api/supply')]);
now = snapshot(stats, live, supply);
const prev = await readState();
const incidents = opt('--incidents', '') ? (await readFile(opt('--incidents'), 'utf8')).split('\n').filter(Boolean) : [];
({ title, body } = render(now, prev, incidents));
} else {
const text = opt('--text', '');
if (!text) throw new Error('incident needs --text "<UTC time, what happened, the cause, the rule or fix>"');
title = `Devnet incident, ${utc()}`.slice(0, 100);
body = `${text}\n\nPosted by a bot that never replies. The project account answers questions in a new thread. Live state: ${SITE}/live.`;
}
guardText(title + '\n' + body);
if (dry) {
const out = opt('--out', path.join('docs', 'community', 'reddit', 'out', `${cmd}-${new Date().toISOString().slice(0, 10)}.md`));
await mkdir(path.dirname(out), { recursive: true });
await writeFile(out, `# DRY RUN ${utc()}\n\nTitle: ${title}\n\n${body}\n`);
console.log(`dry run: wrote ${out} (${body.length} characters); nothing posted`);
return;
}
const c = await readCreds();
const sub = c.REDDIT_SUBREDDIT || 'igneum';
const token = await redditToken(c);
const flair = await flairId(token, sub, c.REDDIT_FLAIR_TEXT || 'Devnet');
const url = await submit(token, sub, title, body, flair);
console.log(`posted: ${url || '(no url returned)'}`);
if (flag('--discord') && c.DISCORD_WEBHOOK_URL) await discordPost(c.DISCORD_WEBHOOK_URL, title, body);
if (cmd === 'weekly' && now) await writeFile(STATE_PATH, JSON.stringify(now, null, 2), { mode: 0o600 });
}
main().catch((e) => { console.error(`reddit-bot: ${e.message}`); process.exit(1); });