"""Automatic network reports on the community forum. Posts as the verified MoonBite account (forum.OFFICIAL_NAME) into the Network category, and every post is tagged "Automated report". Every number is read from a MoonBite node at posting time; nothing is typed in by hand, and if the node can't be reached the run posts nothing (it never uses the explorer's demo sample data). Posts: daily one report per UTC day covering the previous 24 hours: blocks found, average block time, hashrate, supply, next halving, and how long a single computer can expect to wait for a block milestone a thread when the chain passes each 10,000-block height weekly Monday "Show your rig", Thursday "Hot take" (from forum_prompts.py), Saturday "Predictions" plus the real numbers on last week's thread Run it on a timer (it is idempotent; already-posted keys are skipped): */15 * * * * cd /srv/moonbite-network && .venv/bin/python forum_bot.py >> /var/log/moonbite-forum-bot.log 2>&1 with the same BIGCOIN_RPC_* and MOONBITE_FORUM_DB environment as the web app. `python forum_bot.py --dry-run` prints what it would post and writes nothing. """ from __future__ import annotations import argparse import datetime import hashlib import json import os import sys import time import forum from live_explorer import HALVING_INTERVAL, UNITS_PER_COIN, issued_supply_units MILESTONE_EVERY = 10_000 TARGET_BLOCK_SECONDS = 120 DAY = 86_400 # Example machines for the "how long will I wait" table, in H/s. EXAMPLE_RATES = (1_000, 5_000, 10_000) SITE = "https://moonbite.org" class NodeUnavailable(RuntimeError): pass # ---------------------------------------------------------------- formatting def _num(n) -> str: return f"{n:,}" def _rate(hps: float) -> str: for unit, div in (("GH/s", 1e9), ("MH/s", 1e6), ("kH/s", 1e3)): if hps >= div: return f"{hps / div:.2f} {unit}" return f"{hps:.0f} H/s" def _duration(sec: float) -> str: sec = max(0, sec) if sec < 90: return f"{sec:.0f} seconds" if sec < 90 * 60: return f"{sec / 60:.0f} minutes" if sec < 48 * 3600: return f"{sec / 3600:.1f} hours" if sec < 365 * DAY: return f"{sec / DAY:.0f} days" return f"{sec / (365.25 * DAY):.1f} years" def expected_wait(block_seconds: float, network_hps: float, my_hps: float) -> float: """Average seconds between blocks for one miner once difficulty has retargeted to include them: the network still makes a block every `block_seconds`, and this miner's share of them is my / (network + my).""" return block_seconds * (network_hps + my_hps) / my_hps # ---------------------------------------------------------------- chain reads def _header(rpc, height: int) -> dict: return rpc.call("getblockheader", rpc.getblockhash(height)) def collect(rpc, until: int) -> dict: """Read everything a report needs: the 24 hours of blocks before `until`, plus the current tip, hashrate and supply. Header-only reads (cheap on the seed box: getblock re-verifies RandomX).""" height = int(rpc.getblockcount()) tip = _header(rpc, height) try: hashps = float(rpc.call("getnetworkhashps", 120)) except NodeUnavailable: raise except Exception: # noqa: BLE001 — reported as unknown, never guessed hashps = None since = until - DAY times, h, walked = [], height, 0 # Capped walk: a day is ~720 blocks; stop well past that whatever happens. while h > 0 and walked < 4 * DAY // TARGET_BLOCK_SECONDS: hdr = tip if h == height else _header(rpc, h) walked += 1 h -= 1 t = int(hdr["time"]) if t >= until: continue if t < since: break times.append(t) found = len(times) avg = (max(times) - min(times)) / (found - 1) if found > 1 else None return { "height": height, "tip_time": int(tip["time"]), "hashps": hashps, "blocks_24h": found, "avg_block_seconds": avg, "supply_coins": issued_supply_units(height) / UNITS_PER_COIN, } # ---------------------------------------------------------------- posts def daily_post(stats: dict, day_label: str, max_supply: float) -> tuple[str, str]: avg = stats["avg_block_seconds"] hps = stats["hashps"] next_halving = (stats["height"] // HALVING_INTERVAL + 1) * HALVING_INTERVAL to_halving = next_halving - stats["height"] pace = avg or TARGET_BLOCK_SECONDS lines = [ f"Automatic report for the 24 hours to {day_label}. Every number here was read from a MoonBite node when this was posted, and you can check each one in the explorer: {SITE}/explorer", "", f"Blocks found: {_num(stats['blocks_24h'])} (target {_num(DAY // TARGET_BLOCK_SECONDS)})", f"Average time between blocks: {_duration(avg) if avg else 'not enough blocks to measure'}", f"Network hashrate: {_rate(hps) if hps else 'not reported by the node'} (estimated over the last 120 blocks)", f"Chain height: {_num(stats['height'])}", f"Coins issued so far: {stats['supply_coins']:,.0f} MBITE ({stats['supply_coins'] / max_supply * 100:.3f}% of the {max_supply:,.2f} cap)", f"Next halving: block {_num(next_halving)}, {_num(to_halving)} blocks away (about {_duration(to_halving * pace)} at the current pace)", ] if hps: lines += [ "", "How long would one computer wait for a block?", f"The network aims for one block every {_duration(TARGET_BLOCK_SECONDS)} and retargets difficulty every 60 blocks to get there (over the last day it averaged {_duration(pace)}). A single machine's share depends on its hashrate against everyone else's. Once difficulty has adjusted to include it, the average wait is:", "", ] lines += [f"`{_rate(r)}` about {_duration(expected_wait(TARGET_BLOCK_SECONDS, hps, r))} per block" for r in EXAMPLE_RATES] lines += [ "", "These are averages. Mining is luck, so single waits can be much longer or shorter, and they grow as more people join.", ] lines += ["", f"Start mining: {SITE}/mine"] return f"Network report: {day_label}", "\n".join(lines) def milestone_post(stats: dict, milestone: int, reached_at: int, genesis_time: int) -> tuple[str, str]: avg = (reached_at - genesis_time) / milestone if milestone else None when = time.strftime("%d %b %Y, %H:%M UTC", time.gmtime(reached_at)) issued = issued_supply_units(milestone) / UNITS_PER_COIN body = "\n".join([ f"Block {_num(milestone)} was found on {when}.", "", f"Average time per block since genesis: {_duration(avg) if avg else 'n/a'}", f"Coins issued up to this block: {issued:,.0f} MBITE", f"Next milestone: block {_num(milestone + MILESTONE_EVERY)}", "", f"See it in the explorer: {SITE}/explorer#block/{milestone}", ]) return f"Block {_num(milestone)} reached", body # ---------------------------------------------------------------- run def _publish(key: str, title: str, body: str, dry_run: bool, out, category: str = "network") -> bool: if dry_run: print(f"--- would post [{key}] {title}\n{body}\n", file=out) return True if not forum.bot_claim(key): return False try: t = forum.create_thread(title, None, body, category, official=True, automated=True) except Exception: forum.bot_release(key) raise forum.bot_record(key, t["id"]) print(f"posted [{key}] thread {t['id']}: {title}", file=out) return True def run(rpc, now: int | None = None, *, max_supply: float, dry_run: bool = False, out=sys.stdout) -> list[str]: """One pass: post whatever is due and not yet posted. Returns posted keys.""" now = int(now if now is not None else time.time()) posted = [] day_start = now - now % DAY day_label = time.strftime("00:00 UTC, %d %b %Y", time.gmtime(day_start)) stats = None daily_key = "daily:" + time.strftime("%Y-%m-%d", time.gmtime(day_start)) if dry_run or (not _already(daily_key) and _due(daily_key, now, *DAILY_WINDOW)): stats = collect(rpc, day_start) title, body = daily_post(stats, day_label, max_supply) if _publish(daily_key, title, body, dry_run, out): posted.append(daily_key) height = stats["height"] if stats else int(rpc.getblockcount()) milestone = height - height % MILESTONE_EVERY if milestone > 0: key = f"milestone:{milestone}" if dry_run or not _already(key): reached = int(_header(rpc, milestone)["time"]) # Only announce recent milestones, so a first run doesn't backfill. if now - reached <= 2 * DAY: title, body = milestone_post(stats or {}, milestone, reached, int(_header(rpc, 0)["time"])) if _publish(key, title, body, dry_run, out): posted.append(key) posted += _weekly(rpc, now, stats, dry_run, out) return posted # Posting windows (UTC hours: start, length). Each post gets its own minute # inside the window, fixed by hashing its key, so posts are spread out and # land at a different time each day instead of all at the stroke of midnight. DAILY_WINDOW = (6, 3) WEEKLY_WINDOW = (9, 9) def slot(key: str, start_hour: int, span_hours: int) -> int: """Seconds after 00:00 UTC at which the post with this key becomes due.""" h = int(hashlib.sha256(key.encode()).hexdigest(), 16) return start_hour * 3600 + h % (span_hours * 3600) def _due(key: str, now: int, start_hour: int, span_hours: int) -> bool: return now % DAY >= slot(key, start_hour, span_hours) def _week(ts: int) -> str: """ISO week label in UTC, e.g. '2026-W43'.""" iso = datetime.datetime.fromtimestamp(ts, datetime.timezone.utc).isocalendar() return f"{iso[0]}-W{iso[1]:02d}" def _weekly(rpc, now: int, stats: dict | None, dry_run: bool, out) -> list[str]: """Scheduled conversation starters (UTC): Monday rig thread, Thursday hot take, Saturday predictions plus last Saturday's real numbers.""" import forum_prompts as fp posted = [] wd = time.gmtime(now).tm_wday # Monday = 0 week = _week(now) date = time.strftime("%d %b %Y", time.gmtime(now)) if wd == 0: key = f"weekly:rig:{week}" if dry_run or (not _already(key) and _due(key, now, *WEEKLY_WINDOW)): if _publish(key, f"Show your rig: week of {date}", fp.RIG_PROMPT, dry_run, out, "mining"): posted.append(key) elif wd == 3: key = f"weekly:hottake:{week}" if dry_run or (not _already(key) and _due(key, now, *WEEKLY_WINDOW)): take = fp.HOT_TAKES[int(week.split("W")[1]) % len(fp.HOT_TAKES)] title = "Hot take: " + (take if len(take) <= 100 else take[:99].rstrip() + "…") if _publish(key, title, fp.HOT_TAKE_PROMPT.format(take=take), dry_run, out, "general"): posted.append(key) elif wd == 5: key = f"weekly:predict:{week}" if dry_run or (not _already(key) and _due(key, now, *WEEKLY_WINDOW)): height = stats["height"] if stats else int(rpc.getblockcount()) try: hps = stats["hashps"] if stats else float(rpc.call("getnetworkhashps", 120)) except NodeUnavailable: raise except Exception: # noqa: BLE001 hps = None rate = _rate(hps) if hps else "not reported by the node" # Last week's thread gets the real numbers first. prev = _thread_for(f"weekly:predict:{_week(now - 7 * DAY)}") if prev and not dry_run: forum.add_reply(prev, None, fp.PREDICTION_RESULT.format(height=_num(height), rate=rate), official=True) body = fp.PREDICTION_PROMPT.format(height=_num(height), rate=rate) if _publish(key, f"Predictions: where will the chain be on {time.strftime('%d %b', time.gmtime(now + 7 * DAY))}?", body, dry_run, out, "general"): posted.append(key) return posted def _thread_for(key: str): with forum._connect() as conn: row = conn.execute("SELECT thread_id FROM bot_state WHERE key = ?", (key,)).fetchone() return row["thread_id"] if row and row["thread_id"] else None def _already(key: str) -> bool: with forum._connect() as conn: return conn.execute("SELECT 1 FROM bot_state WHERE key = ?", (key,)).fetchone() is not None # ---------------------------------------------------------------- queue QUEUE_PATH = os.environ.get("MOONBITE_FORUM_QUEUE", "").strip() or "forum_queue.json" _UNRESOLVED = "[NEEDS FOUNDER INPUT" def _parse_time(value: str) -> int: return int(datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()) def publish_queue(now: int | None = None, path: str = QUEUE_PATH, dry_run: bool = False, out=sys.stdout) -> list[str]: """Publish founder-written posts whose time has come. The queue is a JSON list of posts, each published once as the verified MoonBite account (NOT tagged automated: a person wrote them): {"key": "ama", "publish_at": "2026-10-25T10:20:00Z", "category": "general", "title": "...", "body": "...", "pin": true} {"key": "ama-2", "publish_at": "2026-10-26T15:05:00Z", "reply_to": "ama", "body": "..."} A post that still contains an unresolved [NEEDS FOUNDER INPUT ...] marker is never published.""" now = int(now if now is not None else time.time()) try: with open(path, encoding="utf-8") as fh: items = json.load(fh) except FileNotFoundError: return [] posted = [] for item in items: key = "queue:" + str(item["key"]) if _parse_time(item["publish_at"]) > now or (not dry_run and _already(key)): continue text = item.get("title", "") + item.get("body", "") if _UNRESOLVED in text: print(f"held [{key}]: still has {_UNRESOLVED} ...]", file=out) continue if dry_run: print(f"--- would post [{key}] {item.get('title') or 'reply to ' + item['reply_to']}", file=out) posted.append(key) continue parent = None if item.get("reply_to"): parent = _thread_for("queue:" + item["reply_to"]) if parent is None: continue # its thread isn't up yet; try again next run if not forum.bot_claim(key): continue try: if parent: forum.add_reply(parent, None, item["body"], official=True) thread_id = parent else: t = forum.create_thread(item["title"], None, item["body"], item.get("category", "general"), official=True) thread_id = t["id"] if item.get("pin"): forum.set_flag("t", thread_id, "pinned", True) except Exception: forum.bot_release(key) raise forum.bot_record(key, thread_id) print(f"posted [{key}] thread {thread_id}", file=out) posted.append(key) return posted class StrictRpc: """The explorer's RpcClient without its demo fallback: any connection problem raises NodeUnavailable instead of returning sample data, because these numbers go out as public posts.""" def __init__(self, inner): self._inner = inner def call(self, method, *params): from explorer.rpc import RPCConnectionError try: return self._inner._http_call(method, list(params)) except RPCConnectionError as e: raise NodeUnavailable(f"node unreachable: {e}") from e def getblockcount(self): return self.call("getblockcount") def getblockhash(self, height): return self.call("getblockhash", height) def _rpc(): if not (os.environ.get("BIGCOIN_RPC_URL") or os.environ.get("BIGCOIN_RPC_USER") or os.environ.get("BIGCOIN_RPC_PASSWORD")): raise NodeUnavailable("no node configured (set BIGCOIN_RPC_URL or BIGCOIN_RPC_USER/PASSWORD)") here = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(here, "explorer")) from explorer.rpc import RpcClient return StrictRpc(RpcClient()) def main(argv=None) -> int: ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) ap.add_argument("--dry-run", action="store_true", help="print posts, write nothing") ap.add_argument("--slots", action="store_true", help="print today's posting times and exit") args = ap.parse_args(argv) from params import MAX_SUPPLY, CENTS_PER_COIN try: if args.slots: now = int(time.time()) day = time.strftime("%Y-%m-%d", time.gmtime(now)) for key, win in ((f"daily:{day}", DAILY_WINDOW), (f"weekly:rig:{_week(now)}", WEEKLY_WINDOW), (f"weekly:hottake:{_week(now)}", WEEKLY_WINDOW), (f"weekly:predict:{_week(now)}", WEEKLY_WINDOW)): sec = slot(key, *win) print(f"{key:<28} {sec // 3600:02d}:{sec % 3600 // 60:02d} UTC") return 0 publish_queue(dry_run=args.dry_run) # founder posts don't need the node run(_rpc(), max_supply=MAX_SUPPLY / CENTS_PER_COIN, dry_run=args.dry_run) except NodeUnavailable as e: print(f"skipped: {e}", file=sys.stderr) return 2 return 0 if __name__ == "__main__": sys.exit(main())