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>
227 lines
15 KiB
JavaScript
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); });
|