#!/usr/bin/env python3 """Apply the mandatory post-release Mora grant and group announcement.""" from __future__ import annotations import argparse import hashlib import json import os import re import sqlite3 import subprocess import sys from collections.abc import Callable from datetime import datetime, timezone from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parent sys.path[:0] = [ str(ROOT / "packages" / "jwebot-official-extensions" / "src"), str(ROOT / "packages" / "jwebot-sdk" / "src"), ] from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402 from jwebot_official_extensions.player_games import ( # noqa: E402 append_coin_event, normalize_player_account, ) COMPENSATION_AMOUNT = 200 MAX_BALANCE = 1_000_000_000 MAX_SUMMARY_CHARS = 300 MAX_ANNOUNCEMENT_CHARS = 900 RELEASE_PATTERN = re.compile( r"^beta (?P\d+)\.(?P\d+)\.(?P\d+)\.(?P\d{4})$" ) CHANGELOG_HEADING = re.compile( r"^##\s+(?Pbeta \d+\.\d+\.\d+\.\d{4})" r"(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" ) class ReleaseUpdateError(RuntimeError): """Raised when release settlement cannot complete safely.""" class AnnouncementUncertainError(ReleaseUpdateError): """Raised when an announcement may have been sent without a final receipt.""" def utc_now() -> datetime: return datetime.now(timezone.utc) def isoformat_utc(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat( timespec="milliseconds" ).replace("+00:00", "Z") def release_to_pep440(release: str) -> str: match = RELEASE_PATTERN.fullmatch(release) if match is None: raise ReleaseUpdateError(f"unsupported release format: {release}") return ( f"{match.group('major')}.{match.group('minor')}." f"{match.group('patch')}b1+{match.group('date')}" ) def read_release(path: Path) -> str: try: release = path.read_text(encoding="utf-8").strip() except OSError as error: raise ReleaseUpdateError("cannot read VERSION") from error release_to_pep440(release) return release def read_changelog_summary(path: Path, release: str) -> str: try: lines = path.read_text(encoding="utf-8").splitlines() except OSError as error: raise ReleaseUpdateError("cannot read CHANGELOG.md") from error section_index: int | None = None for index, line in enumerate(lines): match = CHANGELOG_HEADING.fullmatch(line) if match is not None and match.group("release") == release: section_index = index break if section_index is None: raise ReleaseUpdateError("release is missing from CHANGELOG.md") parts: list[str] = [] collecting = False for line in lines[section_index + 1 :]: if line.startswith("## "): break if line.startswith("- "): if collecting: break collecting = True parts.append(line[2:].strip()) continue if collecting: if line.startswith(" "): parts.append(line.strip()) continue if not line.strip(): break summary = " ".join(" ".join(parts).split()) if not summary: raise ReleaseUpdateError("release changelog needs a first summary bullet") if len(summary) > MAX_SUMMARY_CHARS: raise ReleaseUpdateError("release changelog summary is too long") return summary def build_announcement(release: str, summary: str) -> str: announcement = ( "亲爱的群友:\n" f"{release} 更新现已完成。\n" f"更新内容:{summary}\n" f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}\n" "补偿范围:截至本公告发布前已创建摩拉账户的玩家\n" "发放方式:补偿已直接到账,无需领取\n" "感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!" ) if len(announcement) > MAX_ANNOUNCEMENT_CHARS: raise ReleaseUpdateError("release announcement is too long") return announcement def compensation_key(release: str) -> str: return f"release_compensation:{release}" def announcement_key(release: str, group_fingerprint: str) -> str: group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest() return f"release_announcement:{release}:{group_hash}" def release_slug(release: str) -> str: return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower() def checked_marker(raw: str, release: str) -> dict[str, Any]: try: marker = json.loads(raw) except json.JSONDecodeError as error: raise ReleaseUpdateError("release compensation marker is invalid") from error if ( not isinstance(marker, dict) or marker.get("release") != release or marker.get("amount") != COMPENSATION_AMOUNT or not isinstance(marker.get("group_fingerprints"), list) ): raise ReleaseUpdateError("release compensation marker does not match") return marker def backup_database( source: sqlite3.Connection, backup_dir: Path, release: str, now: datetime, ) -> Path: backup_dir.mkdir(mode=0o700, parents=True, exist_ok=True) os.chmod(backup_dir, 0o700) stamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ") destination = backup_dir / ( f"edge-before-{release_slug(release)}-compensation-{stamp}.sqlite3" ) target = sqlite3.connect(destination) try: source.backup(target) if target.execute("PRAGMA quick_check").fetchone()[0] != "ok": raise ReleaseUpdateError("release compensation backup failed integrity check") finally: target.close() destination.chmod(0o600) return destination def load_compensation_marker( connection: sqlite3.Connection, release: str, ) -> dict[str, Any] | None: row = connection.execute( "SELECT value FROM metadata WHERE key = ?", (compensation_key(release),), ).fetchone() if row is None: return None return checked_marker(str(row[0]), release) def apply_compensation( database_path: Path, backup_dir: Path, release: str, *, now: datetime | None = None, ) -> tuple[dict[str, Any], str]: applied_at = now or utc_now() connection = sqlite3.connect(database_path, timeout=30) connection.row_factory = sqlite3.Row try: if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok": raise ReleaseUpdateError("Edge SQLite integrity check failed") existing = load_compensation_marker(connection, release) if existing is not None: return existing, "already_applied" backup_path = backup_database( connection, backup_dir, release, applied_at, ) connection.execute("BEGIN IMMEDIATE") try: existing = load_compensation_marker(connection, release) if existing is not None: connection.execute("COMMIT") return existing, "already_applied" rows = connection.execute( """ SELECT group_fingerprint, person_key, state_json FROM player_accounts ORDER BY group_fingerprint, person_key """ ).fetchall() groups = sorted({str(row["group_fingerprint"]) for row in rows}) old_balance_sum = 0 timestamp = isoformat_utc(applied_at) event_reason = f"{release} 版本更新补偿" for row in rows: try: state = json.loads(str(row["state_json"])) except json.JSONDecodeError as error: raise ReleaseUpdateError("Mora account state is invalid") from error if not isinstance(state, dict): raise ReleaseUpdateError("Mora account state is invalid") normalize_player_account(state) balance = state.get("balance") if ( type(balance) is not int or not 0 <= balance <= MAX_BALANCE - COMPENSATION_AMOUNT ): raise ReleaseUpdateError("Mora account balance is invalid") old_balance_sum += balance state["balance"] = balance + COMPENSATION_AMOUNT append_coin_event(state, COMPENSATION_AMOUNT, event_reason) state["coin_events"][-1]["created_at"] = applied_at.timestamp() connection.execute( """ UPDATE player_accounts SET state_json = ?, updated_at = ? WHERE group_fingerprint = ? AND person_key = ? """, ( json.dumps(state, ensure_ascii=False), timestamp, row["group_fingerprint"], row["person_key"], ), ) marker = { "release": release, "amount": COMPENSATION_AMOUNT, "accounts": len(rows), "group_fingerprints": groups, "old_balance_sum": old_balance_sum, "new_balance_sum": ( old_balance_sum + COMPENSATION_AMOUNT * len(rows) ), "applied_at": timestamp, "backup": backup_path.name, } connection.execute( """ INSERT INTO metadata (key, value, updated_at) VALUES (?, ?, ?) """, ( compensation_key(release), json.dumps(marker, ensure_ascii=False, separators=(",", ":")), timestamp, ), ) connection.execute("COMMIT") return marker, "applied" except Exception: if connection.in_transaction: connection.execute("ROLLBACK") raise finally: connection.close() def metadata_value( connection: sqlite3.Connection, key: str, ) -> dict[str, Any] | None: row = connection.execute( "SELECT value FROM metadata WHERE key = ?", (key,), ).fetchone() if row is None: return None try: value = json.loads(str(row[0])) except json.JSONDecodeError as error: raise ReleaseUpdateError("announcement marker is invalid") from error if not isinstance(value, dict): raise ReleaseUpdateError("announcement marker is invalid") return value def store_metadata( connection: sqlite3.Connection, key: str, value: dict[str, Any], updated_at: str, ) -> None: connection.execute( """ INSERT INTO metadata (key, value, updated_at) VALUES (?, ?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at """, ( key, json.dumps(value, ensure_ascii=False, separators=(",", ":")), updated_at, ), ) def run_delivery_helper( delivery_helper: Path, group_fingerprint: str, announcement: str, event_id: str, ) -> None: payload = { "group_fingerprint": group_fingerprint, "reply": announcement, "event_id": event_id, } try: result = subprocess.run( [sys.executable, str(delivery_helper)], input=json.dumps(payload, ensure_ascii=False), text=True, capture_output=True, timeout=70, check=False, ) except (OSError, subprocess.TimeoutExpired) as error: raise AnnouncementUncertainError("announcement transport failed") from error if result.returncode != 0: raise AnnouncementUncertainError( f"announcement helper failed with return code {result.returncode}" ) def deliver_announcements( database_path: Path, release: str, groups: list[str], announcement: str, delivery_helper: Path, *, retry_uncertain: bool = False, deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper, now: Callable[[], datetime] = utc_now, ) -> dict[str, int]: delivered = 0 skipped = 0 for group in groups: key = announcement_key(release, group) group_hash = hashlib.sha256(group.encode()).hexdigest()[:12] connection = sqlite3.connect(database_path, timeout=30) try: existing = metadata_value(connection, key) state = str(existing.get("state", "")) if existing else "" if state == "delivered": skipped += 1 continue if state in {"sending", "uncertain"} and not retry_uncertain: raise AnnouncementUncertainError( "previous announcement delivery is uncertain; " "visually confirm before using --retry-uncertain-announcement" ) attempt = int(existing.get("attempt", 0)) + 1 if existing else 1 started_at = isoformat_utc(now()) with connection: store_metadata( connection, key, { "release": release, "state": "sending", "attempt": attempt, "group_hash": group_hash, "started_at": started_at, }, started_at, ) finally: connection.close() event_id = f"release-{release_slug(release)}-{group_hash}" try: deliverer( delivery_helper, group, announcement, event_id, ) except Exception as error: failed_at = isoformat_utc(now()) connection = sqlite3.connect(database_path, timeout=30) try: with connection: store_metadata( connection, key, { "release": release, "state": "uncertain", "attempt": attempt, "group_hash": group_hash, "started_at": started_at, "failed_at": failed_at, "error_type": type(error).__name__[:80], }, failed_at, ) finally: connection.close() if isinstance(error, ReleaseUpdateError): raise raise AnnouncementUncertainError( "announcement delivery is uncertain" ) from error completed_at = isoformat_utc(now()) connection = sqlite3.connect(database_path, timeout=30) try: with connection: store_metadata( connection, key, { "release": release, "state": "delivered", "attempt": attempt, "group_hash": group_hash, "started_at": started_at, "delivered_at": completed_at, }, completed_at, ) finally: connection.close() delivered += 1 return {"delivered": delivered, "skipped": skipped} def run_release_update( *, release_file: Path, changelog: Path, database_path: Path, backup_dir: Path, delivery_helper: Path, retry_uncertain: bool = False, deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper, applied_at: datetime | None = None, clock: Callable[[], datetime] = utc_now, ) -> dict[str, Any]: release = read_release(release_file) expected_package_version = release_to_pep440(release) if OFFICIAL_VERSION != expected_package_version: raise ReleaseUpdateError( "installed package version does not match VERSION" ) summary = read_changelog_summary(changelog, release) announcement = build_announcement(release, summary) marker, compensation_state = apply_compensation( database_path, backup_dir, release, now=applied_at, ) groups = [str(value) for value in marker["group_fingerprints"]] announcement_result = deliver_announcements( database_path, release, groups, announcement, delivery_helper, retry_uncertain=retry_uncertain, deliverer=deliverer, now=clock, ) connection = sqlite3.connect( f"file:{database_path}?mode=ro", uri=True, ) try: if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok": raise ReleaseUpdateError("post-release SQLite integrity check failed") finally: connection.close() return { "status": "ok", "release": release, "compensation": compensation_state, "amount_each": COMPENSATION_AMOUNT, "accounts": int(marker["accounts"]), "groups": len(groups), "announcements_delivered": announcement_result["delivered"], "announcements_skipped": announcement_result["skipped"], "backup": str(marker["backup"]), } def parse_args() -> argparse.Namespace: home = Path.home() parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--release-file", type=Path, default=ROOT / "VERSION", ) parser.add_argument( "--changelog", type=Path, default=ROOT / "CHANGELOG.md", ) parser.add_argument( "--database", type=Path, default=home / ".local/state/jwebot-edge/edge.sqlite3", ) parser.add_argument( "--backup-dir", type=Path, default=home / ".local/state/jwebot/admin-backups", ) parser.add_argument( "--delivery-helper", type=Path, default=ROOT / "jwebot_edge_delivery.py", ) parser.add_argument( "--retry-uncertain-announcement", action="store_true", help="retry only after visually confirming that the prior send did not land", ) return parser.parse_args() def main() -> int: args = parse_args() try: result = run_release_update( release_file=args.release_file, changelog=args.changelog, database_path=args.database, backup_dir=args.backup_dir, delivery_helper=args.delivery_helper, retry_uncertain=args.retry_uncertain_announcement, ) except ReleaseUpdateError as error: print( json.dumps( { "status": "error", "error_type": type(error).__name__, "message": str(error), }, ensure_ascii=False, ), file=sys.stderr, ) return 1 print(json.dumps(result, ensure_ascii=False, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())