This commit is contained in:
@@ -24,6 +24,22 @@ These rules are mandatory for every automated change in this repository.
|
|||||||
or adaptive restart-stability gates.
|
or adaptive restart-stability gates.
|
||||||
- If a deployment gate fails, verify rollback state before retrying.
|
- 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
|
## Configuration And Access
|
||||||
|
|
||||||
- Real profiles live under `~/.config/jwebot/`, never in the repository.
|
- Real profiles live under `~/.config/jwebot/`, never in the repository.
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
# Changelog
|
# 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
|
## beta 1.0.3.1016 - 2026-10-16
|
||||||
|
|
||||||
- Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping
|
- Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`beta 1.0.3.1016`,更新日期:2026-10-16。
|
当前版本:`beta 1.0.4.1016`,更新日期:2026-10-16。
|
||||||
|
|
||||||
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
|
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
|
||||||
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
|
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
|
||||||
@@ -189,7 +189,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
|
|||||||
```
|
```
|
||||||
|
|
||||||
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
||||||
录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。
|
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
|
||||||
|
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。
|
||||||
|
旧 OCR 服务保留安装,但保持禁用。
|
||||||
|
|
||||||
### 仓库结构
|
### 仓库结构
|
||||||
|
|
||||||
@@ -263,7 +265,7 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
beta 1.0.3.1016
|
beta 1.0.4.1016
|
||||||
```
|
```
|
||||||
|
|
||||||
Updated: 2026-10-16.
|
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,
|
The deployer refuses cutover unless the ready-window, empty-queue, Edge health,
|
||||||
SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
|
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
|
## Repository Map
|
||||||
|
|
||||||
|
|||||||
@@ -421,6 +421,7 @@ test -x "${runtime_python}"
|
|||||||
for path in \
|
for path in \
|
||||||
"${source_dir}/wechat_local_ocr.py" \
|
"${source_dir}/wechat_local_ocr.py" \
|
||||||
"${source_dir}/wechat-gui-bridge" \
|
"${source_dir}/wechat-gui-bridge" \
|
||||||
|
"${source_dir}/jwebot_release_update.py" \
|
||||||
"${source_dir}/deploy/scripts/install-wechat-health-watch.sh" \
|
"${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-recorder.service" \
|
||||||
"${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \
|
"${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \
|
||||||
@@ -430,7 +431,9 @@ do
|
|||||||
done
|
done
|
||||||
bash -n "${source_dir}/wechat-gui-bridge"
|
bash -n "${source_dir}/wechat-gui-bridge"
|
||||||
sh -n "${source_dir}/deploy/scripts/install-wechat-health-watch.sh"
|
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)
|
preflight_rendered_dir=$(mktemp -d)
|
||||||
render_root_configuration "${preflight_rendered_dir}"
|
render_root_configuration "${preflight_rendered_dir}"
|
||||||
systemd-analyze verify \
|
systemd-analyze verify \
|
||||||
@@ -619,6 +622,17 @@ success=1
|
|||||||
armed=0
|
armed=0
|
||||||
trap - EXIT HUP INT TERM
|
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 "deployment ready version=$(cat "${source_dir}/VERSION")"
|
||||||
jwebot_log "backup active path=${backup_dir}"
|
jwebot_log "backup active path=${backup_dir}"
|
||||||
jwebot_log "legacy OCR inactive"
|
jwebot_log "legacy OCR inactive"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ adaptive_script_name=wechat_adaptive_recorder.py
|
|||||||
adaptive_bridge_name=wechat_adaptive_gui_bridge.py
|
adaptive_bridge_name=wechat_adaptive_gui_bridge.py
|
||||||
edge_script_name=jwebot_edge.py
|
edge_script_name=jwebot_edge.py
|
||||||
edge_delivery_name=jwebot_edge_delivery.py
|
edge_delivery_name=jwebot_edge_delivery.py
|
||||||
|
release_update_name=jwebot_release_update.py
|
||||||
service_name=wechat-health-watch.service
|
service_name=wechat-health-watch.service
|
||||||
timer_name=wechat-health-watch.timer
|
timer_name=wechat-health-watch.timer
|
||||||
adaptive_service_name=wechat-ocr-adaptive-recorder.service
|
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 "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700
|
||||||
restore_file "$edge_script_name" "$target_dir/$edge_script_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 "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700
|
||||||
|
restore_file "$release_update_name" "$target_dir/$release_update_name" 0700
|
||||||
for package_name in \
|
for package_name in \
|
||||||
jwebot_config \
|
jwebot_config \
|
||||||
jwebot_sdk \
|
jwebot_sdk \
|
||||||
@@ -246,6 +248,7 @@ for path in \
|
|||||||
"$runtime_source_dir/$adaptive_bridge_name" \
|
"$runtime_source_dir/$adaptive_bridge_name" \
|
||||||
"$runtime_source_dir/$edge_script_name" \
|
"$runtime_source_dir/$edge_script_name" \
|
||||||
"$runtime_source_dir/$edge_delivery_name" \
|
"$runtime_source_dir/$edge_delivery_name" \
|
||||||
|
"$runtime_source_dir/$release_update_name" \
|
||||||
"$user_unit_source_dir/$service_name" \
|
"$user_unit_source_dir/$service_name" \
|
||||||
"$user_unit_source_dir/$timer_name" \
|
"$user_unit_source_dir/$timer_name" \
|
||||||
"$user_unit_source_dir/$adaptive_service_name" \
|
"$user_unit_source_dir/$adaptive_service_name" \
|
||||||
@@ -289,7 +292,8 @@ for name in \
|
|||||||
"$adaptive_script_name" \
|
"$adaptive_script_name" \
|
||||||
"$adaptive_bridge_name" \
|
"$adaptive_bridge_name" \
|
||||||
"$edge_script_name" \
|
"$edge_script_name" \
|
||||||
"$edge_delivery_name"
|
"$edge_delivery_name" \
|
||||||
|
"$release_update_name"
|
||||||
do
|
do
|
||||||
if [ -f "$target_dir/$name" ]; then
|
if [ -f "$target_dir/$name" ]; then
|
||||||
cp -a "$target_dir/$name" "$backup_dir/$name"
|
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/$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_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/$edge_delivery_name" "$target_dir/$edge_delivery_name"
|
||||||
|
install -m 0700 \
|
||||||
|
"$runtime_source_dir/$release_update_name" \
|
||||||
|
"$target_dir/$release_update_name"
|
||||||
rm -rf \
|
rm -rf \
|
||||||
"$target_dir/jwebot_config" \
|
"$target_dir/jwebot_config" \
|
||||||
"$target_dir/jwebot_sdk" \
|
"$target_dir/jwebot_sdk" \
|
||||||
@@ -395,7 +402,8 @@ for name in \
|
|||||||
"$adaptive_script_name" \
|
"$adaptive_script_name" \
|
||||||
"$adaptive_bridge_name" \
|
"$adaptive_bridge_name" \
|
||||||
"$edge_script_name" \
|
"$edge_script_name" \
|
||||||
"$edge_delivery_name"
|
"$edge_delivery_name" \
|
||||||
|
"$release_update_name"
|
||||||
do
|
do
|
||||||
"$runtime_python" -m py_compile "$target_dir/$name"
|
"$runtime_python" -m py_compile "$target_dir/$name"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ exception.
|
|||||||
- every package `__version__`;
|
- every package `__version__`;
|
||||||
- `README.md`;
|
- `README.md`;
|
||||||
- `CHANGELOG.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
|
- Do not mark production complete merely because code was committed. Production
|
||||||
is complete only after staging, cutover, acceptance, observation, and backup
|
is complete only after staging, cutover, acceptance, observation, and backup
|
||||||
restore checks pass.
|
restore checks pass.
|
||||||
@@ -63,6 +66,26 @@ Before touching production runtime files on production host:
|
|||||||
SQLite gates.
|
SQLite gates.
|
||||||
- If any gate fails, confirm rollback state before retrying.
|
- 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
|
## Backup And Restore
|
||||||
|
|
||||||
- Pre-deploy and post-deploy baselines must include runtime files, systemd
|
- Pre-deploy and post-deploy baselines must include runtime files, systemd
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ The deployer prints an eight-stage progress bar and:
|
|||||||
- leaves legacy OCR disabled;
|
- leaves legacy OCR disabled;
|
||||||
- verifies Edge health and adaptive restart stability;
|
- verifies Edge health and adaptive restart stability;
|
||||||
- writes and verifies backup SHA-256 checksums;
|
- 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
|
## Post-Cutover
|
||||||
|
|
||||||
@@ -104,6 +109,28 @@ PY
|
|||||||
Player-visible acceptance must use deliberate synthetic messages in the
|
Player-visible acceptance must use deliberate synthetic messages in the
|
||||||
configured group and confirm exactly one ACK and one final response.
|
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
|
## Rollback
|
||||||
|
|
||||||
If any gate fails:
|
If any gate fails:
|
||||||
|
|||||||
@@ -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<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
|
||||||
|
)
|
||||||
|
CHANGELOG_HEADING = re.compile(
|
||||||
|
r"^##\s+(?P<release>beta \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())
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "1.0.3b1+1016"
|
version = "1.0.4b1+1016"
|
||||||
description = "Profile-based configuration compiler for JWeBot"
|
description = "Profile-based configuration compiler for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ __all__ = [
|
|||||||
"write_default_profile",
|
"write_default_profile",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.3b1+1016"
|
__version__ = "1.0.4b1+1016"
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-extension-host"
|
name = "jwebot-extension-host"
|
||||||
version = "1.0.3b1+1016"
|
version = "1.0.4b1+1016"
|
||||||
description = "Single-release, single-invocation process host for JWeBot extensions"
|
description = "Single-release, single-invocation process host for JWeBot extensions"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==1.0.3b1+1016",
|
"jwebot-sdk==1.0.4b1+1016",
|
||||||
"jwebot-kernel==1.0.3b1+1016",
|
"jwebot-kernel==1.0.4b1+1016",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.3b1+1016"
|
__version__ = "1.0.4b1+1016"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "1.0.3b1+1016"
|
version = "1.0.4b1+1016"
|
||||||
description = "Stable routing and extension authority for JWeBot"
|
description = "Stable routing and extension authority for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-config==1.0.3b1+1016",
|
"jwebot-config==1.0.4b1+1016",
|
||||||
"jwebot-sdk==1.0.3b1+1016",
|
"jwebot-sdk==1.0.4b1+1016",
|
||||||
"jwebot-official-extensions==1.0.3b1+1016",
|
"jwebot-official-extensions==1.0.4b1+1016",
|
||||||
"tomli>=2; python_version < '3.11'",
|
"tomli>=2; python_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -107,4 +107,4 @@ __all__ = [
|
|||||||
"verify_installed_release_signature",
|
"verify_installed_release_signature",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.3b1+1016"
|
__version__ = "1.0.4b1+1016"
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-official-extensions"
|
name = "jwebot-official-extensions"
|
||||||
version = "1.0.3b1+1016"
|
version = "1.0.4b1+1016"
|
||||||
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
description = "Reviewed local command adapters and broker implementation for JWeBot"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jwebot-sdk==1.0.3b1+1016",
|
"jwebot-sdk==1.0.4b1+1016",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
|
|||||||
@@ -90,4 +90,4 @@ __all__ = [
|
|||||||
"render_feature_list",
|
"render_feature_list",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.3b1+1016"
|
__version__ = "1.0.4b1+1016"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "1.0.3b1+1016"
|
version = "1.0.4b1+1016"
|
||||||
description = "Typed, capability-limited extension API for JWeBot plugins"
|
description = "Typed, capability-limited extension API for JWeBot plugins"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ __all__ = [
|
|||||||
"SingleGroupContext",
|
"SingleGroupContext",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.3b1+1016"
|
__version__ = "1.0.4b1+1016"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "beta 1.0.3.1016"
|
release = "beta 1.0.4.1016"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
"wechat_adaptive_gui_bridge.py",
|
"wechat_adaptive_gui_bridge.py",
|
||||||
"jwebot_edge.py",
|
"jwebot_edge.py",
|
||||||
"jwebot_edge_delivery.py",
|
"jwebot_edge_delivery.py",
|
||||||
|
"jwebot_release_update.py",
|
||||||
"jwebot_config",
|
"jwebot_config",
|
||||||
"jwebot_sdk",
|
"jwebot_sdk",
|
||||||
"jwebot_kernel",
|
"jwebot_kernel",
|
||||||
@@ -126,6 +127,9 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
'jwebot_log "runtime install ready"',
|
'jwebot_log "runtime install ready"',
|
||||||
'jwebot_log "edge active"',
|
'jwebot_log "edge active"',
|
||||||
'jwebot_log "adaptive 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=',
|
'jwebot_log "deployment ready version=',
|
||||||
'cd "${target_home}"',
|
'cd "${target_home}"',
|
||||||
):
|
):
|
||||||
@@ -138,6 +142,14 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
source.count("xdotool"),
|
source.count("xdotool"),
|
||||||
source.count("/usr/bin/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):
|
def test_production_reports_bounded_progress(self):
|
||||||
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
|
source = PRODUCTION_INSTALLER.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -884,9 +884,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(version["route"], "local")
|
self.assertEqual(version["route"], "local")
|
||||||
self.assertIn("JWeBot 版本", version["text"])
|
self.assertIn("JWeBot 版本", version["text"])
|
||||||
self.assertIn("kernel", 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("更新时间:2026-10-16", version["text"])
|
||||||
self.assertIn("更新摘要:Rename the virtual currency", version["text"])
|
self.assertIn("更新摘要:新增版本更新补偿流程", version["text"])
|
||||||
self.assertEqual(debug["route"], "local")
|
self.assertEqual(debug["route"], "local")
|
||||||
self.assertIn("调试埋点", debug["text"])
|
self.assertIn("调试埋点", debug["text"])
|
||||||
self.assertEqual(balance["route"], "local")
|
self.assertEqual(balance["route"], "local")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -11,6 +11,7 @@ cd "$(dirname "$0")/.."
|
|||||||
python3 -m py_compile \
|
python3 -m py_compile \
|
||||||
jwebot_edge.py \
|
jwebot_edge.py \
|
||||||
jwebot_edge_delivery.py \
|
jwebot_edge_delivery.py \
|
||||||
|
jwebot_release_update.py \
|
||||||
wechat_local_ocr.py \
|
wechat_local_ocr.py \
|
||||||
wechat_adaptive_recorder.py \
|
wechat_adaptive_recorder.py \
|
||||||
wechat_adaptive_gui_bridge.py \
|
wechat_adaptive_gui_bridge.py \
|
||||||
|
|||||||
Reference in New Issue
Block a user