From 127fea8cd019cfffaa3813131de56eca4b0172a5 Mon Sep 17 00:00:00 2001 From: JWeBot Contributors Date: Fri, 16 Oct 2026 16:10:19 +0800 Subject: [PATCH] Release beta 1.0.4 update settlement --- AGENTS.md | 16 + CHANGELOG.md | 12 + README.md | 12 +- VERSION | 2 +- deploy/scripts/deploy-jwebot-production.sh | 16 +- deploy/scripts/install-wechat-health-watch.sh | 12 +- docs/project-rules.md | 23 + docs/runbooks/production-deployment.md | 29 +- jwebot_release_update.py | 599 ++++++++++++++++++ packages/jwebot-config/pyproject.toml | 2 +- .../src/jwebot_config/__init__.py | 2 +- packages/jwebot-extension-host/pyproject.toml | 6 +- .../src/jwebot_extension_host/__init__.py | 2 +- packages/jwebot-kernel/pyproject.toml | 8 +- .../src/jwebot_kernel/__init__.py | 2 +- .../jwebot-official-extensions/pyproject.toml | 4 +- .../jwebot_official_extensions/__init__.py | 2 +- packages/jwebot-sdk/pyproject.toml | 2 +- .../jwebot-sdk/src/jwebot_sdk/__init__.py | 2 +- pyproject.toml | 2 +- tests/test_deployment_scripts.py | 12 + tests/test_jwebot_edge.py | 4 +- tests/test_release_update.py | 210 ++++++ tools/check-local.sh | 1 + 24 files changed, 954 insertions(+), 28 deletions(-) create mode 100644 jwebot_release_update.py create mode 100644 tests/test_release_update.py diff --git a/AGENTS.md b/AGENTS.md index 8cc3d54..1af4952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,22 @@ These rules are mandatory for every automated change in this repository. or adaptive restart-stability gates. - If a deployment gate fails, verify rollback state before retrying. +## Mandatory Release Settlement + +- Every successful production update must run `jwebot_release_update.py` + after cutover gates pass and before the release is declared complete. +- Each release grants exactly 200 Mora to every account that exists when the + settlement starts. The grant and account movement are idempotent by version. +- The first bullet in each release's `CHANGELOG.md` section must be one concise, + user-facing sentence suitable for the group update announcement. +- After crediting accounts, send the version, first changelog bullet, and + `Mora x200` compensation notice through the guarded group sender. +- Never blindly retry an uncertain announcement. Verify the group visually, + then use `--retry-uncertain-announcement` only when the prior message did not + land. +- A production release is incomplete until compensation, announcement, + SQLite integrity, empty queues, Edge health, and service stability pass. + ## Configuration And Access - Real profiles live under `~/.config/jwebot/`, never in the repository. diff --git a/CHANGELOG.md b/CHANGELOG.md index 1234a3a..729385a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## beta 1.0.4.1016 - 2026-10-16 + +- 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。 +- Add a version-idempotent post-release settlement tool that creates an online + SQLite backup, grants Mora through the production account schema, records a + version-labelled movement, and tracks announcement delivery independently. +- Run release settlement only after coordinated cutover and rollback gates are + disarmed, keeping a healthy runtime active when GUI delivery is uncertain. +- Require the first changelog bullet to be a concise user-facing announcement + summary and require visual confirmation before explicitly retrying an + uncertain announcement. + ## beta 1.0.3.1016 - 2026-10-16 - Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping diff --git a/README.md b/README.md index 6d8ce8f..590e727 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`beta 1.0.3.1016`,更新日期:2026-10-16。 +当前版本:`beta 1.0.4.1016`,更新日期:2026-10-16。 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major @@ -189,7 +189,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot ``` 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 -录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。 +录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户 +发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。 +旧 OCR 服务保留安装,但保持禁用。 ### 仓库结构 @@ -263,7 +265,7 @@ visually confirmed GUI bridge. Current version: ```text -beta 1.0.3.1016 +beta 1.0.4.1016 ``` Updated: 2026-10-16. @@ -452,7 +454,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot The deployer refuses cutover unless the ready-window, empty-queue, Edge health, SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units -remain installed but disabled. +remain installed but disabled. After cutover, version-idempotent release +settlement grants 200 Mora to every existing account, records the movement, +and sends the first changelog bullet as the guarded group announcement. ## Repository Map diff --git a/VERSION b/VERSION index 3643e99..35ee1fd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -beta 1.0.3.1016 +beta 1.0.4.1016 diff --git a/deploy/scripts/deploy-jwebot-production.sh b/deploy/scripts/deploy-jwebot-production.sh index 1847974..1a275ec 100755 --- a/deploy/scripts/deploy-jwebot-production.sh +++ b/deploy/scripts/deploy-jwebot-production.sh @@ -421,6 +421,7 @@ test -x "${runtime_python}" for path in \ "${source_dir}/wechat_local_ocr.py" \ "${source_dir}/wechat-gui-bridge" \ + "${source_dir}/jwebot_release_update.py" \ "${source_dir}/deploy/scripts/install-wechat-health-watch.sh" \ "${source_dir}/deploy/systemd/system/wechat-local-ocr-recorder.service" \ "${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \ @@ -430,7 +431,9 @@ do done bash -n "${source_dir}/wechat-gui-bridge" sh -n "${source_dir}/deploy/scripts/install-wechat-health-watch.sh" -"${runtime_python}" -m py_compile "${source_dir}/wechat_local_ocr.py" +"${runtime_python}" -m py_compile \ + "${source_dir}/wechat_local_ocr.py" \ + "${source_dir}/jwebot_release_update.py" preflight_rendered_dir=$(mktemp -d) render_root_configuration "${preflight_rendered_dir}" systemd-analyze verify \ @@ -619,6 +622,17 @@ success=1 armed=0 trap - EXIT HUP INT TERM +jwebot_log "release settlement starting" +release_update_output=$( + /usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \ + HOME="${target_home}" \ + XDG_RUNTIME_DIR="${user_runtime}" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \ + "${runtime_python}" "${target_runtime}/jwebot_release_update.py" +) +printf '%s\n' "${release_update_output}" +jwebot_log "release settlement ready" + jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")" jwebot_log "backup active path=${backup_dir}" jwebot_log "legacy OCR inactive" diff --git a/deploy/scripts/install-wechat-health-watch.sh b/deploy/scripts/install-wechat-health-watch.sh index 89f2e0f..3293f73 100755 --- a/deploy/scripts/install-wechat-health-watch.sh +++ b/deploy/scripts/install-wechat-health-watch.sh @@ -27,6 +27,7 @@ adaptive_script_name=wechat_adaptive_recorder.py adaptive_bridge_name=wechat_adaptive_gui_bridge.py edge_script_name=jwebot_edge.py edge_delivery_name=jwebot_edge_delivery.py +release_update_name=jwebot_release_update.py service_name=wechat-health-watch.service timer_name=wechat-health-watch.timer adaptive_service_name=wechat-ocr-adaptive-recorder.service @@ -209,6 +210,7 @@ rollback() { restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700 restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700 restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700 + restore_file "$release_update_name" "$target_dir/$release_update_name" 0700 for package_name in \ jwebot_config \ jwebot_sdk \ @@ -246,6 +248,7 @@ for path in \ "$runtime_source_dir/$adaptive_bridge_name" \ "$runtime_source_dir/$edge_script_name" \ "$runtime_source_dir/$edge_delivery_name" \ + "$runtime_source_dir/$release_update_name" \ "$user_unit_source_dir/$service_name" \ "$user_unit_source_dir/$timer_name" \ "$user_unit_source_dir/$adaptive_service_name" \ @@ -289,7 +292,8 @@ for name in \ "$adaptive_script_name" \ "$adaptive_bridge_name" \ "$edge_script_name" \ - "$edge_delivery_name" + "$edge_delivery_name" \ + "$release_update_name" do if [ -f "$target_dir/$name" ]; then cp -a "$target_dir/$name" "$backup_dir/$name" @@ -342,6 +346,9 @@ install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adapti install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name" install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name" +install -m 0700 \ + "$runtime_source_dir/$release_update_name" \ + "$target_dir/$release_update_name" rm -rf \ "$target_dir/jwebot_config" \ "$target_dir/jwebot_sdk" \ @@ -395,7 +402,8 @@ for name in \ "$adaptive_script_name" \ "$adaptive_bridge_name" \ "$edge_script_name" \ - "$edge_delivery_name" + "$edge_delivery_name" \ + "$release_update_name" do "$runtime_python" -m py_compile "$target_dir/$name" done diff --git a/docs/project-rules.md b/docs/project-rules.md index 3798f24..6b17d34 100644 --- a/docs/project-rules.md +++ b/docs/project-rules.md @@ -18,6 +18,9 @@ exception. - every package `__version__`; - `README.md`; - `CHANGELOG.md`. +- The first bullet under each release heading in `CHANGELOG.md` must be one + concise, user-facing sentence. It is the canonical group announcement + summary. - Do not mark production complete merely because code was committed. Production is complete only after staging, cutover, acceptance, observation, and backup restore checks pass. @@ -63,6 +66,26 @@ Before touching production runtime files on production host: SQLite gates. - If any gate fails, confirm rollback state before retrying. +## Mandatory Post-Release Settlement + +- After cutover and rollback gates pass, run the installed + `jwebot_release_update.py`. +- The settlement grants exactly 200 Mora to every account that exists at the + start of that release settlement and appends a version-labelled account + movement. +- Compensation uses the full release string as its idempotency key. Rerunning + the same release must not grant Mora twice. +- The tool sends one guarded announcement containing the version, the first + changelog bullet, and the `Mora x200` compensation notice. +- Announcement state is tracked separately from compensation. A missing or + uncertain GUI receipt must never roll back a healthy runtime or trigger + another grant. +- If delivery is uncertain, inspect the configured group before using + `--retry-uncertain-announcement`; never retry blindly. +- Production is not complete until the settlement reports success, SQLite + `quick_check` is `ok`, queues are empty, and service restart counts remain + stable. + ## Backup And Restore - Pre-deploy and post-deploy baselines must include runtime files, systemd diff --git a/docs/runbooks/production-deployment.md b/docs/runbooks/production-deployment.md index 222c36c..d67730a 100644 --- a/docs/runbooks/production-deployment.md +++ b/docs/runbooks/production-deployment.md @@ -73,7 +73,12 @@ The deployer prints an eight-stage progress bar and: - leaves legacy OCR disabled; - verifies Edge health and adaptive restart stability; - writes and verifies backup SHA-256 checksums; -- rolls back files, state, and unit topology on failure. +- rolls back files, state, and unit topology on cutover failure; +- after rollback is disarmed, grants each existing account 200 Mora, records a + version-labelled movement, and sends the guarded group update announcement. + +The first bullet in the current `CHANGELOG.md` release section is the canonical +one-sentence announcement summary. Keep it concise and user-facing. ## Post-Cutover @@ -104,6 +109,28 @@ PY Player-visible acceptance must use deliberate synthetic messages in the configured group and confirm exactly one ACK and one final response. +The deployer also runs the installed release settlement tool. Its successful +JSON result must report the expected account count and announcement delivery. +The operation is idempotent by the full release string, so rerunning the same +command never grants Mora twice: + +```sh +/opt/jwebot/venv/bin/python \ + ~/.local/lib/wechat-health-watch/jwebot_release_update.py +``` + +If it reports `AnnouncementUncertainError`, inspect the configured group first. +Only when the announcement is confirmed absent may the operator retry: + +```sh +/opt/jwebot/venv/bin/python \ + ~/.local/lib/wechat-health-watch/jwebot_release_update.py \ + --retry-uncertain-announcement +``` + +Do not repeat the full deployment to repair an uncertain announcement. The +runtime cutover and version-keyed compensation may already be complete. + ## Rollback If any gate fails: diff --git a/jwebot_release_update.py b/jwebot_release_update.py new file mode 100644 index 0000000..3a5db3a --- /dev/null +++ b/jwebot_release_update.py @@ -0,0 +1,599 @@ +#!/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()) diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 59034f0..4025680 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "1.0.3b1+1016" +version = "1.0.4b1+1016" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index 286c7f1..cbef4cb 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "1.0.3b1+1016" +__version__ = "1.0.4b1+1016" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 3a9cbd7..04e7636 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "1.0.3b1+1016" +version = "1.0.4b1+1016" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==1.0.3b1+1016", - "jwebot-kernel==1.0.3b1+1016", + "jwebot-sdk==1.0.4b1+1016", + "jwebot-kernel==1.0.4b1+1016", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index c1ed230..e51be5e 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "1.0.3b1+1016" +__version__ = "1.0.4b1+1016" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 835dcfc..f7183bb 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "1.0.3b1+1016" +version = "1.0.4b1+1016" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==1.0.3b1+1016", - "jwebot-sdk==1.0.3b1+1016", - "jwebot-official-extensions==1.0.3b1+1016", + "jwebot-config==1.0.4b1+1016", + "jwebot-sdk==1.0.4b1+1016", + "jwebot-official-extensions==1.0.4b1+1016", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index 82e6da6..e22c24e 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "1.0.3b1+1016" +__version__ = "1.0.4b1+1016" diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index 601db22..53219fe 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "1.0.3b1+1016" +version = "1.0.4b1+1016" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==1.0.3b1+1016", + "jwebot-sdk==1.0.4b1+1016", ] [tool.setuptools.packages.find] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index 3602327..5e3bc5e 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "1.0.3b1+1016" +__version__ = "1.0.4b1+1016" diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index ab18857..f2f5d42 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "1.0.3b1+1016" +version = "1.0.4b1+1016" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index a10f4bb..b93116e 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -38,4 +38,4 @@ __all__ = [ "SingleGroupContext", ] -__version__ = "1.0.3b1+1016" +__version__ = "1.0.4b1+1016" diff --git a/pyproject.toml b/pyproject.toml index 88101d6..5a0c95e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "beta 1.0.3.1016" +release = "beta 1.0.4.1016" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_deployment_scripts.py b/tests/test_deployment_scripts.py index c5d9943..f671908 100644 --- a/tests/test_deployment_scripts.py +++ b/tests/test_deployment_scripts.py @@ -58,6 +58,7 @@ class DeploymentScriptTests(unittest.TestCase): "wechat_adaptive_gui_bridge.py", "jwebot_edge.py", "jwebot_edge_delivery.py", + "jwebot_release_update.py", "jwebot_config", "jwebot_sdk", "jwebot_kernel", @@ -126,6 +127,9 @@ class DeploymentScriptTests(unittest.TestCase): 'jwebot_log "runtime install ready"', 'jwebot_log "edge active"', 'jwebot_log "adaptive active"', + 'jwebot_log "release settlement starting"', + '"${target_runtime}/jwebot_release_update.py"', + 'jwebot_log "release settlement ready"', 'jwebot_log "deployment ready version=', 'cd "${target_home}"', ): @@ -138,6 +142,14 @@ class DeploymentScriptTests(unittest.TestCase): source.count("xdotool"), source.count("/usr/bin/xdotool"), ) + self.assertLess( + source.index("trap - EXIT HUP INT TERM"), + source.index('jwebot_log "release settlement starting"'), + ) + self.assertLess( + source.index('jwebot_log "release settlement ready"'), + source.index('jwebot_log "deployment ready version='), + ) def test_production_reports_bounded_progress(self): source = PRODUCTION_INSTALLER.read_text(encoding="utf-8") diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index 272a9e3..6f85495 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -884,9 +884,9 @@ class LedgerAndApplicationTests(unittest.TestCase): self.assertEqual(version["route"], "local") self.assertIn("JWeBot 版本", version["text"]) self.assertIn("kernel", version["text"]) - self.assertIn("最新版本:beta 1.0.3.1016", version["text"]) + self.assertIn("最新版本:beta 1.0.4.1016", version["text"]) self.assertIn("更新时间:2026-10-16", version["text"]) - self.assertIn("更新摘要:Rename the virtual currency", version["text"]) + self.assertIn("更新摘要:新增版本更新补偿流程", version["text"]) self.assertEqual(debug["route"], "local") self.assertIn("调试埋点", debug["text"]) self.assertEqual(balance["route"], "local") diff --git a/tests/test_release_update.py b/tests/test_release_update.py new file mode 100644 index 0000000..f7c78a6 --- /dev/null +++ b/tests/test_release_update.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 + +import importlib.util +import json +import sqlite3 +import sys +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = PROJECT_ROOT / "jwebot_release_update.py" +SPEC = importlib.util.spec_from_file_location("jwebot_release_update", MODULE_PATH) +MODULE = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + + +class ReleaseUpdateTests(unittest.TestCase): + release = "beta 1.0.4.1016" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.database = self.root / "edge.sqlite3" + self.backups = self.root / "backups" + self.version = self.root / "VERSION" + self.changelog = self.root / "CHANGELOG.md" + self.delivery_helper = self.root / "jwebot_edge_delivery.py" + self.version.write_text(self.release + "\n", encoding="utf-8") + self.changelog.write_text( + ( + "# Changelog\n\n" + f"## {self.release} - 2026-10-16\n\n" + "- 更新摩拉账户并修复重复回复问题。\n" + "- Internal implementation detail.\n" + ), + encoding="utf-8", + ) + self.delivery_helper.write_text("# test helper\n", encoding="utf-8") + connection = sqlite3.connect(self.database) + connection.executescript( + """ + CREATE TABLE metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE player_accounts ( + group_fingerprint TEXT NOT NULL, + person_key TEXT NOT NULL, + state_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (group_fingerprint, person_key) + ); + """ + ) + for person_key, balance in (("person-a", 1000), ("person-b", 50)): + connection.execute( + "INSERT INTO player_accounts VALUES (?, ?, ?, ?)", + ( + "group-a", + person_key, + json.dumps({"balance": balance}), + "2026-10-16T00:00:00Z", + ), + ) + connection.commit() + connection.close() + + def tearDown(self): + self.temporary.cleanup() + + def account_states(self): + connection = sqlite3.connect(self.database) + try: + rows = connection.execute( + "SELECT state_json FROM player_accounts ORDER BY person_key" + ).fetchall() + finally: + connection.close() + return [json.loads(row[0]) for row in rows] + + def announcement_marker(self): + key = MODULE.announcement_key(self.release, "group-a") + connection = sqlite3.connect(self.database) + try: + row = connection.execute( + "SELECT value FROM metadata WHERE key = ?", + (key,), + ).fetchone() + finally: + connection.close() + return json.loads(row[0]) if row else None + + def test_changelog_first_bullet_is_the_announcement_summary(self): + self.changelog.write_text( + ( + "# Changelog\n\n" + f"## {self.release} - 2026-10-16\n\n" + "- 第一行更新摘要,\n" + " 同一条摘要可以换行。\n" + "- 第二条不进入群公告。\n" + ), + encoding="utf-8", + ) + summary = MODULE.read_changelog_summary(self.changelog, self.release) + self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。") + announcement = MODULE.build_announcement(self.release, summary) + self.assertIn(f"{self.release} 更新现已完成", announcement) + self.assertIn("更新补偿:摩拉 x200", announcement) + self.assertNotIn("第二条", announcement) + + def test_release_update_grants_once_and_announces_once(self): + delivered = [] + + def deliverer(path, group, announcement, event_id): + delivered.append((path, group, announcement, event_id)) + + applied_at = datetime(2026, 10, 16, 8, 0, tzinfo=timezone.utc) + first = MODULE.run_release_update( + release_file=self.version, + changelog=self.changelog, + database_path=self.database, + backup_dir=self.backups, + delivery_helper=self.delivery_helper, + deliverer=deliverer, + applied_at=applied_at, + ) + second = MODULE.run_release_update( + release_file=self.version, + changelog=self.changelog, + database_path=self.database, + backup_dir=self.backups, + delivery_helper=self.delivery_helper, + deliverer=deliverer, + applied_at=applied_at, + ) + + self.assertEqual(first["compensation"], "applied") + self.assertEqual(second["compensation"], "already_applied") + self.assertEqual(first["accounts"], 2) + self.assertEqual(len(delivered), 1) + self.assertEqual(delivered[0][1], "group-a") + self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2]) + self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) + for state in self.account_states(): + matches = [ + event + for event in state["coin_events"] + if event["delta"] == 200 + and event["reason"] == f"{self.release} 版本更新补偿" + ] + self.assertEqual(len(matches), 1) + self.assertEqual(matches[0]["created_at"], applied_at.timestamp()) + self.assertEqual(self.announcement_marker()["state"], "delivered") + self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1) + + def test_uncertain_announcement_never_duplicates_compensation(self): + def uncertain(*_args): + raise MODULE.AnnouncementUncertainError("uncertain") + + with self.assertRaises(MODULE.AnnouncementUncertainError): + MODULE.run_release_update( + release_file=self.version, + changelog=self.changelog, + database_path=self.database, + backup_dir=self.backups, + delivery_helper=self.delivery_helper, + deliverer=uncertain, + ) + self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) + self.assertEqual(self.announcement_marker()["state"], "uncertain") + + called = [] + + def delivered(*args): + called.append(args) + + with self.assertRaises(MODULE.AnnouncementUncertainError): + MODULE.run_release_update( + release_file=self.version, + changelog=self.changelog, + database_path=self.database, + backup_dir=self.backups, + delivery_helper=self.delivery_helper, + deliverer=delivered, + ) + self.assertFalse(called) + + result = MODULE.run_release_update( + release_file=self.version, + changelog=self.changelog, + database_path=self.database, + backup_dir=self.backups, + delivery_helper=self.delivery_helper, + deliverer=delivered, + retry_uncertain=True, + ) + self.assertEqual(result["compensation"], "already_applied") + self.assertEqual(len(called), 1) + self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) + self.assertEqual(self.announcement_marker()["state"], "delivered") + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/check-local.sh b/tools/check-local.sh index 796857c..c2ff9d5 100755 --- a/tools/check-local.sh +++ b/tools/check-local.sh @@ -11,6 +11,7 @@ cd "$(dirname "$0")/.." python3 -m py_compile \ jwebot_edge.py \ jwebot_edge_delivery.py \ + jwebot_release_update.py \ wechat_local_ocr.py \ wechat_adaptive_recorder.py \ wechat_adaptive_gui_bridge.py \