Author SHA1 Message Date
JWeBot Contributors 127fea8cd0 Release beta 1.0.4 update settlement
check / local-gate (push) Failing after 1m7s
2026-10-16 16:10:19 +08:00
JWeBot Contributors bf6b738baa Release beta 1.0.3 Mora wallet updates 2026-10-16 15:44:34 +08:00
JWeBot Contributors 22edf6ff15 Cache OCR-negative bubbles
check / local-gate (push) Failing after 42s
2026-10-16 11:38:03 +08:00
JWeBot Contributors 572159f571 Fix OCR thread test isolation
check / local-gate (push) Failing after 17m10s
2026-10-16 11:19:55 +08:00
JWeBot Contributors 99a251c19a Limit OCR runtime threads
check / local-gate (push) Failing after 44s
2026-10-16 11:16:23 +08:00
JWeBot Contributors 3fa04755d0 Add multiplayer blackjack table flow
check / local-gate (push) Failing after 51s
2026-10-16 11:03:32 +08:00
69 changed files with 3565 additions and 206 deletions
+16
View File
@@ -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.
+33
View File
@@ -1,5 +1,38 @@
# 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
- Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping
the existing `/金币`, `/金币排行`, and `/Taffy金币` commands as aliases.
- Add recent account movement details to `/摩拉` and `/金币`, covering the
latest transfer, loan, repayment, lottery, draw, and blackjack net changes.
- Suppress short-window semantic duplicate OCR events so one visible command
bubble cannot trigger the same local reply multiple times under new event IDs.
## beta 1.0.2.1016 - 2026-10-16
- Cache OCR-negative visible bubbles and reuse stable visual signatures as the
OCR cache key so unreadable or lightly re-rendered bubbles do not trigger
repeated 10+ second RapidOCR passes during active chat windows.
## beta 1.0.1.1016 - 2026-10-16
- Limit RapidOCR ONNXRuntime intra/inter operation threads by default so the
adaptive OCR recorder no longer saturates CPU after restart and slow recovery
windows do not stretch local command detection into 10+ second scans.
## beta 1.0.0.1016 - 2026-10-16 ## beta 1.0.0.1016 - 2026-10-16
- Promote JWeBot from alpha to beta after the local, staging, privacy, and - Promote JWeBot from alpha to beta after the local, staging, privacy, and
+13 -8
View File
@@ -8,12 +8,12 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.0.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
自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则 自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则
已经外置为包内 JSON,虚拟金币支持转账、贷款、还款和群内排行榜。 已经外置为包内 JSON,虚拟摩拉支持转账、贷款、还款和群内排行榜。
完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见 完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见
[CHANGELOG](CHANGELOG.md)。 [CHANGELOG](CHANGELOG.md)。
@@ -82,12 +82,13 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/功能列表 /功能列表
/上下文 [100|200|300](/context) /上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person) /人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw) /摩拉(兼容 /金币、/wallet);/抽奖 [金额](/draw)
/大富翁榜(/金币排行) /大富翁榜(/摩拉排行,兼容 /金币排行)
/转账 对方 金额(/transfer name amount) /转账 对方 金额(/transfer name amount)
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案 /自走棋开始|报名|开赛|状态|档案
@@ -188,7 +189,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
``` ```
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。
旧 OCR 服务保留安装,但保持禁用。
### 仓库结构 ### 仓库结构
@@ -262,7 +265,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.0.1016 beta 1.0.4.1016
``` ```
Updated: 2026-10-16. Updated: 2026-10-16.
@@ -451,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
+1 -1
View File
@@ -1 +1 @@
beta 1.0.0.1016 beta 1.0.4.1016
+15 -1
View File
@@ -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"
+10 -2
View File
@@ -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
+7 -3
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.0.1016`. Current baseline: `beta 1.0.1.1016`.
This catalog records the reviewed commands shipped with the built-in local This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated extensions. Runtime ownership is frozen at startup from the generated
@@ -41,8 +41,8 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| Command | Alias | Purpose | | Command | Alias | Purpose |
|---|---|---| |---|---|---|
| `/金币` | `/wallet` | Initialize or show the current person's virtual coin account. | | `/摩拉` | `/金币`, `/wallet` | Initialize or show the current person's Mora account. |
| `/大富翁榜 [人数]` | `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped coin leaderboard. | | `/大富翁榜 [人数]` | `/摩拉排行`, `/摩拉排行榜`, `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped Mora leaderboard. |
| `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. | | `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. |
| `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. | | `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. |
| `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. | | `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. |
@@ -57,6 +57,10 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. | | `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. | | `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. | | `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. |
| `/21点 加入` | - | Join the current blackjack table before betting starts. |
| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. |
| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. | | `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. | | `/游戏帮助` | `/games` | Show local game rules. |
+9
View File
@@ -90,6 +90,15 @@ Steam uses `JWEBOT_EDGE_STEAM_ID64` and
- Host timeout and fallback; - Host timeout and fallback;
- GUI sender timeout. - GUI sender timeout.
The OCR runtime also accepts optional environment overrides for constrained
hosts:
- `WECHAT_OCR_ONNX_INTRA_OP_THREADS` defaults to `1`;
- `WECHAT_OCR_ONNX_INTER_OP_THREADS` defaults to `1`.
These values limit RapidOCR's ONNXRuntime CPU thread pools so service restarts
do not temporarily saturate the host while the OCR cache is cold.
### Debug ### Debug
`components/debug.toml` generates: `components/debug.toml` generates:
+9 -9
View File
@@ -48,7 +48,7 @@
每位玩家开局拥有: 每位玩家开局拥有:
- 生命值:`100` - 生命值:`100`
- 金币:`10` - 摩拉:`10`
- 教练:赛前第一轮选秀决定 - 教练:赛前第一轮选秀决定
- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容 - 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场 - 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
@@ -206,7 +206,7 @@
### 7.2 教练获取 ### 7.2 教练获取
教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练 教练由第一轮公共选秀决定,本届赛事内不能用摩拉升级教练。不同教练
自带等级、属性倾向和全队加成。 自带等级、属性倾向和全队加成。
高费选手的商店概率随赛事进程推进,不再绑定教练等级。 高费选手的商店概率随赛事进程推进,不再绑定教练等级。
@@ -270,7 +270,7 @@
- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。 - 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。
- 训练赛和正赛运营阶段默认展示一组 6 人商店。 - 训练赛和正赛运营阶段默认展示一组 6 人商店。
- 玩家可支付金币执行刷新。 - 玩家可支付摩拉执行刷新。
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。 - 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
### 8.4 顺位 ### 8.4 顺位
@@ -294,7 +294,7 @@
## 10. 训练赛与赛事挑战 ## 10. 训练赛与赛事挑战
训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家 训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家
检验阵容、获取金币并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内 检验阵容、获取摩拉并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内
插曲出现,但只提供资源变动,不淘汰玩家。 插曲出现,但只提供资源变动,不淘汰玩家。
### 10.1 活动类型 ### 10.1 活动类型
@@ -308,11 +308,11 @@
训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛, 训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛,
每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。 每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。
训练赛只发放金币,不记录小组胜负,也不扣生命。 训练赛只发放摩拉,不记录小组胜负,也不扣生命。
每轮训练赛结束后进入运营窗口: 每轮训练赛结束后进入运营窗口:
- `/刷新` 即 D 牌,消耗金币换一组 6 人商店; - `/刷新` 即 D 牌,消耗摩拉换一组 6 人商店;
- `/选1~/选6` 买入商店选手,重复卡自动累积星级; - `/选1~/选6` 买入商店选手,重复卡自动累积星级;
- `/锁定` 保留当前商店; - `/锁定` 保留当前商店;
- `/上阵` 调整 5 人首发; - `/上阵` 调整 5 人首发;
@@ -321,10 +321,10 @@
### 10.3 训练赛结算逻辑 ### 10.3 训练赛结算逻辑
训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。 训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。
胜方获得更多金币,负方也有保底金币,保证测试局能继续运营。 胜方获得更多摩拉,负方也有保底摩拉,保证测试局能继续运营。
媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放 媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放
金币,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。 摩拉,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。
## 11. PvP 赛制 ## 11. PvP 赛制
@@ -618,7 +618,7 @@ Jacky 选择了 zonic!
- AI 测试战队加入 - AI 测试战队加入
- 赛制与种子公布 - 赛制与种子公布
- 三轮选秀候选池、选择结果和选秀完成 - 三轮选秀候选池、选择结果和选秀完成
- 每轮训练赛对阵、比分、金币和运营提示 - 每轮训练赛对阵、比分、摩拉和运营提示
- 媒体日或赞助挑战 - 媒体日或赞助挑战
- 每场 BO3/BO5 对阵与结果 - 每场 BO3/BO5 对阵与结果
- 每张加时地图 - 每张加时地图
+2 -2
View File
@@ -4,7 +4,7 @@
1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。 1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。
2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100 2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100
金币。也可简写为 `/彩票347100`;省略金额时默认下注 10。 摩拉。也可简写为 `/彩票347100`;省略金额时默认下注 10。
3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。 3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。
4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字, 4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字,
统一排序后结算。 统一排序后结算。
@@ -45,7 +45,7 @@
| 2 个 | 120% | | 2 个 | 120% |
| 3 个 | 300% | | 3 个 | 300% |
金币只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个 摩拉只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个
返还 120,净变化为 `+20`。 返还 120,净变化为 `+20`。
## 个人局号与记录 ## 个人局号与记录
+21 -15
View File
@@ -7,8 +7,8 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
| 插件 | 中文指令 | 英文指令 | | 插件 | 中文指令 | 英文指令 |
|---|---|---| |---|---|---|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` | | single-player-games | `/摩拉`(兼容 `/金币`) | `/wallet` |
| coin-ranking | `/大富翁榜`、`/金币排行` | `/richlist` | | coin-ranking | `/大富翁榜`、`/摩拉排行`(兼容 `/金币排行`) | `/richlist` |
| coin-transfer | `/转账 对方 金额` | `/transfer name amount` | | coin-transfer | `/转账 对方 金额` | `/transfer name amount` |
| coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` | | coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` | | single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
@@ -23,7 +23,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。 命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。
每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。 每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。
注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额; 注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额;
游戏虚拟余额使用 `/金币`。 游戏虚拟余额使用 `/摩拉`,`/金币` 保留为兼容别名。
## 上下文总结 ## 上下文总结
@@ -43,7 +43,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名 当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名
解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算 解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算
person_key。相同人物的已配置别名共用金币和牌局,其他群使用独立账户。 person_key。相同人物的已配置别名共用摩拉和牌局,其他群使用独立账户。
没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。 没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。
`/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、 `/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、
@@ -53,27 +53,27 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令 应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令
指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币` 指定他人的账户;转账只支持同群虚拟摩拉账户,收款人需先发送 `/摩拉`
初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟金币不能充值、 或 `/金币` 初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟摩拉不能充值、
赠币或兑换真实资产。 赠币或兑换真实资产。
## 虚拟金币与游戏管理 ## 虚拟摩拉与游戏管理
每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、 每个群内人物首次使用账户时一次性获得 1000 虚拟摩拉。再次查询、
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。 21 点下注为 1~500;余额不足或参数非法不扣款。
`/转账 对方 金额` 会把本人的虚拟金币转给同群已初始化账户,英文为 `/转账 对方 金额` 会把本人的虚拟摩拉转给同群已初始化账户,英文为
`/transfer name amount`。金额范围 1~100000;不允许给自己转账。 `/transfer name amount`。金额范围 1~100000;不允许给自己转账。
转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id 转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id
不会重复扣款。 不会重复扣款。
`/贷款 [金额]` 会给本人账户借入 1~500 金币;同一账户同一时间最多 `/贷款 [金额]` 会给本人账户借入 1~500 摩拉;同一账户同一时间最多
一笔未还贷款。`/还款 [金额|全部]` 从当前金币余额中扣除并减少未还 一笔未还贷款。`/还款 [金额|全部]` 从当前摩拉余额中扣除并减少未还
贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。 贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。
`/大富翁榜` 或 `/金币排行 [人数]` 只读取当前群已有金币账户,不会创建 `/大富翁榜` 或 `/摩拉排行 [人数]`(兼容 `/金币排行`)只读取当前群已有摩拉账户,不会创建
调用者账户。榜单按当前金币余额降序排列,人数范围 1~20;账户存在 调用者账户。榜单按当前摩拉余额降序排列,人数范围 1~20;账户存在
未还贷款时会在名字后标注贷款本金。 未还贷款时会在名字后标注贷款本金。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
@@ -81,7 +81,7 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
[多人彩票与游戏记录](group-lottery-0.6.3.md)。 [多人彩票与游戏记录](group-lottery-0.6.3.md)。
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场, 21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场,
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌, 等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在 然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌, 同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
@@ -90,6 +90,12 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, 仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、 例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。 账户、局号和游戏记录。
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前摩拉。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。 抽奖或 21 点开局,多人彩票报名仍可独立进行。
@@ -100,7 +106,7 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存: Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存:
1. `player_accounts` 中的金币、牌局和胜负统计; 1. `player_accounts` 中的摩拉、牌局和胜负统计;
2. `player_actions` 中的事件、命令及响应。 2. `player_actions` 中的事件、命令及响应。
同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更 同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更
+23
View File
@@ -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
+28 -1
View File
@@ -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:
+2 -2
View File
@@ -14,9 +14,9 @@ Rules:
- `X.Y.Z` is the project version. - `X.Y.Z` is the project version.
- `MMDD` is the delivery date. - `MMDD` is the delivery date.
- Python package versions use PEP 440 pre-release form. For beta builds, - Python package versions use PEP 440 pre-release form. For beta builds,
`beta 1.0.0.1016` maps to `1.0.0b1+1016`. `beta 1.0.1.1016` maps to `1.0.1b1+1016`.
- Normal small updates increment only the patch version, for example - Normal small updates increment only the patch version, for example
`beta 1.0.0.1016` to `beta 1.0.1.1016`. `beta 1.0.1.1016` to `beta 1.0.2.1016`.
- Minor, major, or stable-release changes require explicit user review and - Minor, major, or stable-release changes require explicit user review and
milestone approval. milestone approval.
- Local repository migration does not imply a production deployment. - Local repository migration does not imply a production deployment.
+2
View File
@@ -10,6 +10,8 @@ Help:
Registered commands: Registered commands:
- `/摩拉排行`
- `/摩拉排行榜`
- `/金币排行` - `/金币排行`
- `/金币排行榜` - `/金币排行榜`
- `/大富翁榜` - `/大富翁榜`
+1 -1
View File
@@ -8,7 +8,7 @@ entrypoint = "coin_ranking:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/金币排行", "/金币排行榜", "/大富翁榜", "/richlist"] commands = ["/摩拉排行", "/摩拉排行榜", "/金币排行", "/金币排行榜", "/大富翁榜", "/richlist"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["coin-ranking"] service_profiles = ["coin-ranking"]
@@ -7,7 +7,7 @@ PROFILE = 'coin-ranking'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '富榜' DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',) HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -21,8 +21,8 @@ class PluginTests(unittest.TestCase):
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '富榜') self.assertEqual(DESCRIPTION, '富榜')
self.assertEqual(HELP_ENTRIES, ('/大富翁榜',)) self.assertEqual(HELP_ENTRIES, ('/大富翁榜',))
self.assertIn('/金币排行', registered) self.assertIn('/摩拉排行', registered)
self.assertEqual(len(registered), 4) self.assertEqual(len(registered), 6)
if __name__ == "__main__": if __name__ == "__main__":
+19
View File
@@ -0,0 +1,19 @@
# coin-taffy
Author: `jwebot`
Taffy摩拉
Help:
- `/Taffy摩拉(兼容 /Taffy金币)`
Registered commands:
- `/Taffy摩拉`
- `/Taffy金币`
- `/taffycoins`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-taffy` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-taffy"
author = "jwebot"
description = "Taffy摩拉"
help = ["/Taffy摩拉(兼容 /Taffy金币)"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_taffy:plugin"
type = "command"
[capabilities]
commands = ["/Taffy摩拉", "/Taffy金币", "/taffycoins"]
storage = false
network_profiles = []
service_profiles = ["coin-taffy"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy摩拉'
HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',)
COMMANDS = ('/Taffy摩拉', '/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_taffy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'Taffy摩拉')
self.assertEqual(HELP_ENTRIES, ('/Taffy摩拉(兼容 /Taffy金币)',))
self.assertIn('/Taffy摩拉', registered)
self.assertEqual(len(registered), 3)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -2,7 +2,7 @@
Author: `jwebot` Author: `jwebot`
金币转账 摩拉转账
Help: Help:
+1 -1
View File
@@ -1,6 +1,6 @@
id = "coin-transfer" id = "coin-transfer"
author = "jwebot" author = "jwebot"
description = "金币转账" description = "摩拉转账"
help = ["/转账 对方 金额;/transfer"] help = ["/转账 对方 金额;/transfer"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer' PROFILE = 'coin-transfer'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币转账' DESCRIPTION = '摩拉转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',) HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer') COMMANDS = ('/转账', '/transfer')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币转账') self.assertEqual(DESCRIPTION, '摩拉转账')
self.assertEqual(HELP_ENTRIES, ('/转账 对方 金额;/transfer',)) self.assertEqual(HELP_ENTRIES, ('/转账 对方 金额;/transfer',))
self.assertIn('/转账', registered) self.assertIn('/转账', registered)
self.assertEqual(len(registered), 2) self.assertEqual(len(registered), 2)
@@ -2,19 +2,19 @@
Author: `jwebot` Author: `jwebot`
金币游戏 摩拉游戏
Help: Help:
- `/金币 /wallet;/游戏记录 /gamehistory` - `/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory`
- `/抽奖 [金额];/draw` - `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery` - `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 开始 [1~500];/blackjack start` - `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/游戏帮助;/games` - `/游戏帮助;/games`
Registered commands: Registered commands:
- `/摩拉`
- `/金币` - `/金币`
- `/wallet` - `/wallet`
- `/抽奖` - `/抽奖`
@@ -1,14 +1,14 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "金币游戏" description = "摩拉游戏"
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"] help = ["/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "single_player_games:plugin" entrypoint = "single_player_games:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"] commands = ["/摩拉", "/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["single-player-games"] service_profiles = ["single-player-games"]
@@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏' DESCRIPTION = '摩拉游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') HELP_ENTRIES = ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/摩拉', '/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -19,10 +19,10 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币游戏') self.assertEqual(DESCRIPTION, '摩拉游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games'))
self.assertIn('/金币', registered) self.assertIn('/摩拉', registered)
self.assertEqual(len(registered), 15) self.assertEqual(len(registered), 16)
if __name__ == "__main__": if __name__ == "__main__":
@@ -2,7 +2,7 @@
Author: `official` Author: `official`
提取当前群最近100、200或300条本地文字的上下文 上下文总结
Help: Help:
@@ -1,6 +1,6 @@
id = "context-summary" id = "context-summary"
author = "official" author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文" description = "上下文总结"
help = ["/上下文 [100|200|300];/context [100|200|300]"] help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文') self.assertEqual(DESCRIPTION, '上下文总结')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',)) self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered) self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2) self.assertEqual(len(registered), 2)
+599
View File
@@ -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())
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.0b1+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.0b1+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.0b1+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.0b1+1016", "jwebot-sdk==1.0.4b1+1016",
"jwebot-kernel==1.0.0b1+1016", "jwebot-kernel==1.0.4b1+1016",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.0b1+1016" __version__ = "1.0.4b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.0b1+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.0b1+1016", "jwebot-config==1.0.4b1+1016",
"jwebot-sdk==1.0.0b1+1016", "jwebot-sdk==1.0.4b1+1016",
"jwebot-official-extensions==1.0.0b1+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.0b1+1016" __version__ = "1.0.4b1+1016"
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "1.0.0b1+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.0b1+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.0b1+1016" __version__ = "1.0.4b1+1016"
@@ -7,7 +7,7 @@ PROFILE = 'coin-ranking'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '富榜' DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',) HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy摩拉'
HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',)
COMMANDS = ('/Taffy摩拉', '/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-transfer' PROFILE = 'coin-transfer'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币转账' DESCRIPTION = '摩拉转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',) HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer') COMMANDS = ('/转账', '/transfer')
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
@@ -5,9 +5,9 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏' DESCRIPTION = '摩拉游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') HELP_ENTRIES = ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/摩拉', '/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
def _handler(context: SingleGroupContext, command: str): def _handler(context: SingleGroupContext, command: str):
@@ -956,7 +956,7 @@ def _shop_text(state: dict[str, Any], player: dict[str, Any]) -> str:
lines = [ lines = [
( (
f"{_player_prefix(state, player)}第 {state['round_number']} 回合" f"{_player_prefix(state, player)}第 {state['round_number']} 回合"
f" {phase_name};金币 {player['gold']};生命 {player['hp']};" f" {phase_name};摩拉 {player['gold']};生命 {player['hp']};"
f"教练 {player['coach_level']} 级;战术{player['tactic']};" f"教练 {player['coach_level']} 级;战术{player['tactic']};"
f"阵容 {power['power']:.1f};商店{lock}" f"阵容 {power['power']:.1f};商店{lock}"
) )
@@ -1143,7 +1143,7 @@ def _auto_manage_ai(
) )
text = ( text = (
f"{player['display_name']} 自动运营:{'、'.join(actions)};" f"{player['display_name']} 自动运营:{'、'.join(actions)};"
f"金币 {player['gold']},五人首发战力 " f"摩拉 {player['gold']},五人首发战力 "
f"{lineup_power(player)['power']:.1f}。" f"{lineup_power(player)['power']:.1f}。"
) )
stories.append( stories.append(
@@ -1571,7 +1571,7 @@ def _settle_pve(
) )
text = ( text = (
f"{player['display_name']} {'完成' if won else '未完成'}" f"{player['display_name']} {'完成' if won else '未完成'}"
f"{event_name}目标 {adjusted:.1f}/{target},获得 {reward} 金币。" f"{event_name}目标 {adjusted:.1f}/{target},获得 {reward} 摩拉。"
) )
stories.append(_story(state, "pve_result", text, result)) stories.append(_story(state, "pve_result", text, result))
lines.append(_host_text(text)) lines.append(_host_text(text))
@@ -2326,7 +2326,7 @@ def _team_power_line(state: dict[str, Any], key: str) -> str:
else f"{player.get('coach_level', 1)}级" else f"{player.get('coach_level', 1)}级"
) )
return ( return (
f"{player['display_name']} #{state['season_number']}:金币 {player['gold']};" f"{player['display_name']} #{state['season_number']}:摩拉 {player['gold']};"
f"生命 {player['hp']};教练 {coach_text};战术{player['tactic']};" f"生命 {player['hp']};教练 {coach_text};战术{player['tactic']};"
f"阵容 {power['power']:.1f};商店{'已锁' if player['shop_locked'] else '未锁'}" f"阵容 {power['power']:.1f};商店{'已锁' if player['shop_locked'] else '未锁'}"
) )
@@ -2417,7 +2417,7 @@ def _training_match_part(
f"{_team_name(winner)} 以 {item['score']} 拿下本图。" f"{_team_name(winner)} 以 {item['score']} 拿下本图。"
), ),
( (
f"金币:{_team_name(winner)} +{TRAINING_WIN_GOLD}," f"摩拉:{_team_name(winner)} +{TRAINING_WIN_GOLD},"
f"{_team_name(loser)} +{TRAINING_LOSS_GOLD}。" f"{_team_name(loser)} +{TRAINING_LOSS_GOLD}。"
), ),
] ]
@@ -2433,7 +2433,7 @@ def _training_bye_part(state: dict[str, Any], key: str, round_number: int) -> st
) )
return ( return (
f"训练赛第 {round_number} 轮:{_team_name(player)} 与本地陪练队完成热身。\n" f"训练赛第 {round_number} 轮:{_team_name(player)} 与本地陪练队完成热身。\n"
f"{hook}\n金币:{_team_name(player)} +{TRAINING_BYE_GOLD}。" f"{hook}\n摩拉:{_team_name(player)} +{TRAINING_BYE_GOLD}。"
) )
@@ -2461,7 +2461,7 @@ def _settle_training_round(
if cursor == 0: if cursor == 0:
intro = ( intro = (
f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆," f"欢迎来到{state['event_title']}!你们已经到达酒店宾馆,"
f"训练赛共 {len(rounds)} 轮。训练赛不扣生命,只产出运营金币。" f"训练赛共 {len(rounds)} 轮。训练赛不扣生命,只产出运营摩拉。"
) )
parts.append(intro) parts.append(intro)
stories.append(_story(state, "training_open", intro)) stories.append(_story(state, "training_open", intro))
@@ -2670,7 +2670,7 @@ def _series_report_parts(
state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward state["players"][winner_key]["gold"] = int(state["players"][winner_key]["gold"]) + reward
final_text = ( final_text = (
f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 " f"系列赛结束,{_team_name(state['players'][winner_key])} 以 {result['score']} 击败 "
f"{_team_name(state['players'][loser_key])},获得 {reward} 金币。" f"{_team_name(state['players'][loser_key])},获得 {reward} 摩拉。"
"这笔钱可以马上用于 /刷新、/选1~/选6 或锁店等下一轮运营。" "这笔钱可以马上用于 /刷新、/选1~/选6 或锁店等下一轮运营。"
) )
parts[-1] = parts[-1] + "\n" + final_text parts[-1] = parts[-1] + "\n" + final_text
@@ -2745,7 +2745,7 @@ def _special_event_parts(
) )
text = ( text = (
f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:" f"{_team_name(player)} {'完成' if won else '勉强应付'}{event_kind}:"
f"{hook}获得 {reward} 金币。" f"{hook}获得 {reward} 摩拉。"
) )
parts.append(text) parts.append(text)
stories.append(_story(state, "special_event", text, {"person_key": key})) stories.append(_story(state, "special_event", text, {"person_key": key}))
@@ -3141,7 +3141,7 @@ class CsMajorAutochess:
story = _story(state, "tactic_selected", text, {"person_key": person_key, "tactic": tactic}) story = _story(state, "tactic_selected", text, {"person_key": person_key, "tactic": tactic})
return respond(state, text, stories=[story]) return respond(state, text, stories=[story])
if action == "coach": if action == "coach":
return respond(state, "教练现在由第一轮选秀决定,本届不能用金币升级教练。") return respond(state, "教练现在由第一轮选秀决定,本届不能用摩拉升级教练。")
if action == "buy": if action == "buy":
values = _parse_indices(arguments) values = _parse_indices(arguments)
if len(values) != 1 or not 1 <= values[0] <= SHOP_SIZE: if len(values) != 1 or not 1 <= values[0] <= SHOP_SIZE:
@@ -3152,7 +3152,7 @@ class CsMajorAutochess:
return respond(state, "这个商店位置已经购买或没有候选") return respond(state, "这个商店位置已经购买或没有候选")
card = CARD_BY_ID[str(card_id)] card = CARD_BY_ID[str(card_id)]
if int(player["gold"]) < int(card["cost"]): if int(player["gold"]) < int(card["cost"]):
return respond(state, f"金币不足:{card['name']} 需要 {card['cost']},当前 {player['gold']}") return respond(state, f"摩拉不足:{card['name']} 需要 {card['cost']},当前 {player['gold']}")
existing = any( existing = any(
unit["card_id"] == card_id unit["card_id"] == card_id
for unit in [*player["board"], *player["bench"]] for unit in [*player["board"], *player["bench"]]
@@ -3166,7 +3166,7 @@ class CsMajorAutochess:
star, upgraded = _merge_purchase(player, str(card_id)) star, upgraded = _merge_purchase(player, str(card_id))
player["shop"][slot] = None player["shop"][slot] = None
text = ( text = (
f"{display_name} 签下 {card['name']},花费 {card['cost']} 金币;" f"{display_name} 签下 {card['name']},花费 {card['cost']} 摩拉;"
f"当前 {star} 星,余额 {player['gold']}。" f"当前 {star} 星,余额 {player['gold']}。"
) )
if upgraded: if upgraded:
@@ -3175,7 +3175,7 @@ class CsMajorAutochess:
return respond(state, text, stories=[story]) return respond(state, text, stories=[story])
if action == "refresh": if action == "refresh":
if int(player["gold"]) < REFRESH_COST: if int(player["gold"]) < REFRESH_COST:
return respond(state, f"刷新需要 {REFRESH_COST} 金币,当前 {player['gold']}") return respond(state, f"刷新需要 {REFRESH_COST} 摩拉,当前 {player['gold']}")
player["gold"] -= REFRESH_COST player["gold"] -= REFRESH_COST
player["shop"] = _draw_shop( player["shop"] = _draw_shop(
state, state,
@@ -3222,7 +3222,7 @@ class CsMajorAutochess:
refund = int(card["cost"]) * int(unit["copies"]) refund = int(card["cost"]) * int(unit["copies"])
player["gold"] += refund player["gold"] += refund
state["pool"][card["id"]] += int(unit["copies"]) state["pool"][card["id"]] += int(unit["copies"])
return respond(state, f"{display_name} 出售 {card['name']},返还 {refund} 金币。") return respond(state, f"{display_name} 出售 {card['name']},返还 {refund} 摩拉。")
if action == "ready": if action == "ready":
if person_key not in state.get("ready_keys", []): if person_key not in state.get("ready_keys", []):
@@ -11,7 +11,7 @@
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。", "队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
"这次挑战只出现一次,处理完就回到小组赛主线。", "这次挑战只出现一次,处理完就回到小组赛主线。",
"主舞台彩排占用了热身时间,教练的管理能力开始生效。", "主舞台彩排占用了热身时间,教练的管理能力开始生效。",
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。", "赞助商要求展示新战术,选错方向也不会淘汰,只会少拿摩拉。",
"现场采访提到了前一场失利,队员心态需要稳住。", "现场采访提到了前一场失利,队员心态需要稳住。",
"这类挑战不扣生命,目的只是给运营阶段增加变数。", "这类挑战不扣生命,目的只是给运营阶段增加变数。",
"处理好这次插曲的队伍,会在下一场前多一点经济余地。" "处理好这次插曲的队伍,会在下一场前多一点经济余地。"
@@ -141,21 +141,21 @@
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。" "{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。"
], ],
"training_hooks": [ "training_hooks": [
"训练赛不计入小组胜负,但会发金币,方便后续刷新商店。", "训练赛不计入小组胜负,但会发摩拉,方便后续刷新商店。",
"这轮训练更像实战热身,每队都会遇到同场参赛对手。", "这轮训练更像实战热身,每队都会遇到同场参赛对手。",
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。", "训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。", "这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
"AI 战队也会参与训练,对局结果会写入档案。", "AI 战队也会参与训练,对局结果会写入档案。",
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。", "如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
"如果你想追羁绊,现在是最适合刷新商店的窗口。", "如果你想追羁绊,现在是最适合刷新商店的窗口。",
"训练赛的金币很重要,别把每次刷新都当成免费试错。", "训练赛的摩拉很重要,别把每次刷新都当成免费试错。",
"这一阶段没有生命扣除,重点是运营和阵容成型。", "这一阶段没有生命扣除,重点是运营和阵容成型。",
"训练赛的地图会轮换,不会一直用同一个固定比分模板。", "训练赛的地图会轮换,不会一直用同一个固定比分模板。",
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。", "每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
"训练赛会按轮次播报,避免一次性刷出一大串结果。", "训练赛会按轮次播报,避免一次性刷出一大串结果。",
"如果出现轮空,会安排本地陪练队补一张 BO1。", "如果出现轮空,会安排本地陪练队补一张 BO1。",
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。", "训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
"连胜会带来金币,但失败也有保底,保证测试局能继续运营。", "连胜会带来摩拉,但失败也有保底,保证测试局能继续运营。",
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。" "这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。"
] ]
}, },
@@ -40,12 +40,18 @@ LOCAL_COMMAND_ALIASES = {
"steam": "steam_friends", "steam": "steam_friends",
"上下文": "context_summary", "上下文": "context_summary",
"context": "context_summary", "context": "context_summary",
"摩拉": "wallet",
"金币": "wallet", "金币": "wallet",
"wallet": "wallet", "wallet": "wallet",
"摩拉排行": "coin_ranking",
"摩拉排行榜": "coin_ranking",
"金币排行": "coin_ranking", "金币排行": "coin_ranking",
"金币排行榜": "coin_ranking", "金币排行榜": "coin_ranking",
"大富翁榜": "coin_ranking", "大富翁榜": "coin_ranking",
"richlist": "coin_ranking", "richlist": "coin_ranking",
"Taffy摩拉": "taffy_coins",
"Taffy金币": "taffy_coins",
"taffycoins": "taffy_coins",
"转账": "transfer", "转账": "transfer",
"transfer": "transfer", "transfer": "transfer",
"贷款": "loan", "贷款": "loan",
@@ -143,20 +149,19 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="context-summary", extension_id="context-summary",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
description="提取当前群最近100、200或300条本地文字的上下文", description="上下文总结",
help_entries=("/上下文 [100|200|300];/context [100|200|300]",), help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
command_kinds=("context_summary",), command_kinds=("context_summary",),
), ),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="single-player-games", extension_id="single-player-games",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="金币游戏", description="摩拉游戏",
help_entries=( help_entries=(
"/金币 /wallet;/游戏记录 /gamehistory", "/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory",
"/抽奖 [金额];/draw", "/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery", "/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 开始 [1~500];/blackjack start", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/游戏帮助;/games", "/游戏帮助;/games",
), ),
command_kinds=( command_kinds=(
@@ -171,7 +176,7 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="coin-transfer", extension_id="coin-transfer",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="金币转账", description="摩拉转账",
help_entries=("/转账 对方 金额;/transfer",), help_entries=("/转账 对方 金额;/transfer",),
command_kinds=("transfer",), command_kinds=("transfer",),
), ),
@@ -182,6 +187,13 @@ LOCAL_EXTENSIONS = (
help_entries=("/大富翁榜",), help_entries=("/大富翁榜",),
command_kinds=("coin_ranking",), command_kinds=("coin_ranking",),
), ),
LocalExtensionDescriptor(
extension_id="coin-taffy",
author=JWEBOT_AUTHOR,
description="Taffy摩拉",
help_entries=("/Taffy摩拉(兼容 /Taffy金币)",),
command_kinds=("taffy_coins",),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="coin-loan", extension_id="coin-loan",
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
@@ -157,6 +157,7 @@ class LocalCommandEngine:
if kind in { if kind in {
"wallet", "wallet",
"coin_ranking", "coin_ranking",
"taffy_coins",
"transfer", "transfer",
"loan", "loan",
"repay", "repay",
@@ -364,7 +365,7 @@ class LocalCommandEngine:
return (f"人物:{self.person_display_label(person['display_name'])};" return (f"人物:{self.person_display_label(person['display_name'])};"
f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n" f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n"
f"基本属性:{facts or '暂无配置'}。\n" f"基本属性:{facts or '暂无配置'}。\n"
"本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。") "本人摩拉 /摩拉;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。")
@staticmethod @staticmethod
def service_state(service_name: str) -> str: def service_state(service_name: str) -> str:
@@ -16,6 +16,9 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10 DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1 MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500 MAX_BLACKJACK_BET = 500
MIN_BLACKJACK_TABLE_BET = 1
MAX_BLACKJACK_TABLE_BET = 10_000
MAX_BLACKJACK_TABLE_PLAYERS = 8
MIN_TRANSFER_AMOUNT = 1 MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000 MAX_TRANSFER_AMOUNT = 100_000
MIN_LOAN_AMOUNT = 1 MIN_LOAN_AMOUNT = 1
@@ -23,6 +26,8 @@ MAX_LOAN_AMOUNT = 500
GAME_TIMEOUT_SECONDS = 600 GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50 GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5 GAME_HISTORY_PAGE_SIZE = 5
COIN_EVENT_LIMIT = 50
COIN_EVENT_DISPLAY_LIMIT = 10
GAME_LABELS = { GAME_LABELS = {
"prize_draw": "抽奖", "prize_draw": "抽奖",
"lottery": "彩票", "lottery": "彩票",
@@ -45,11 +50,52 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?" r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$" r"(?:要牌|hit|停牌|stand)$"
) )
TRANSFER_MARKED = re.compile( BLACKJACK_TABLE_BET = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*" r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE, re.IGNORECASE,
) )
TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|摩拉|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE,
)
def coin_event_reason(kind: str, outcome: str) -> str:
label = GAME_LABELS.get(kind, kind)
if kind == "blackjack":
if "赢" in outcome or "获胜" in outcome:
return f"{label} 获胜"
if "输" in outcome or "认输" in outcome or "爆" in outcome:
return f"{label} 失败"
if "平" in outcome:
return f"{label} 平局"
return f"{label} {outcome}".strip()
def append_coin_event(
account: dict[str, Any],
delta: int,
reason: str,
) -> None:
if delta == 0:
return
events = account.get("coin_events")
if not isinstance(events, list):
events = []
events = [
event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int
]
events.append(
{
"delta": delta,
"reason": str(reason)[:120],
"balance": int(account.get("balance", 0)),
"created_at": time.time(),
}
)
account["coin_events"] = events[-COIN_EVENT_LIMIT:]
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
@@ -83,6 +129,33 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account["game_history"] = [ account["game_history"] = [
entry for entry in history if isinstance(entry, dict) entry for entry in history if isinstance(entry, dict)
][-GAME_HISTORY_LIMIT:] ][-GAME_HISTORY_LIMIT:]
events = account.get("coin_events")
if not isinstance(events, list):
events = []
for entry in account["game_history"]:
try:
stake = int(entry.get("stake", 0))
payout = int(entry.get("payout", 0))
except (TypeError, ValueError):
continue
delta = payout - stake
if delta == 0:
continue
events.append(
{
"delta": delta,
"reason": coin_event_reason(
str(entry.get("kind", "")),
str(entry.get("outcome", "")),
),
"balance": int(account["balance"]),
"created_at": float(entry.get("completed_at", 0) or 0),
}
)
account["coin_events"] = [
event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int
][-COIN_EVENT_LIMIT:]
return account return account
@@ -126,6 +199,40 @@ def append_game_record(
account["game_history"] = history[-GAME_HISTORY_LIMIT:] account["game_history"] = history[-GAME_HISTORY_LIMIT:]
def append_game_coin_event(
account: dict[str, Any],
*,
kind: str,
outcome: str,
stake: int,
payout: int,
) -> None:
append_coin_event(
account,
payout - stake,
coin_event_reason(kind, outcome),
)
def recent_coin_event_lines(
account: dict[str, Any],
*,
limit: int = COIN_EVENT_DISPLAY_LIMIT,
) -> list[str]:
normalize_player_account(account)
events = sorted(
account["coin_events"],
key=lambda event: float(event.get("created_at", 0) or 0),
reverse=True,
)
lines: list[str] = []
for event in events[:limit]:
delta = int(event["delta"])
reason = str(event.get("reason", "账户变动")).strip() or "账户变动"
lines.append(f"{delta:+d}:{reason}")
return lines
def card_label(card: int) -> str: def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13] return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
@@ -148,10 +255,11 @@ class PlayerGames:
@staticmethod @staticmethod
def rules() -> str: def rules() -> str:
return ( return (
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n" "每人首次使用获 1000 虚拟摩拉;/摩拉 查看余额(兼容 /金币);"
"/游戏记录 查看本人记录。\n"
"转账:/转账 对方 金额,或 /transfer name amount;" "转账:/转账 对方 金额,或 /transfer name amount;"
"对方需先用 /金币 初始化账户。\n" "对方需先用 /摩拉 或 /金币 初始化账户。\n"
"贷款:/贷款 [1~500] 借入金币;/还款 [金额|全部] 归还," "贷款:/贷款 [1~500] 借入摩拉;/还款 [金额|全部] 归还,"
"同一时间最多一笔未还贷款。\n" "同一时间最多一笔未还贷款。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
@@ -161,8 +269,11 @@ class PlayerGames:
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束" "看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);" "(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n" "每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
"主持人 /21点 多人 开始 后按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。" "21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值或兑换。" "以上均含本金;虚拟摩拉不能充值或兑换。"
) )
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str: def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
@@ -177,17 +288,28 @@ class PlayerGames:
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "coin_ranking": if kind == "coin_ranking":
return self.coin_ranking(row, arguments) return self.coin_ranking(row, arguments)
if kind == "taffy_coins":
return self.taffy_coins(row, arguments)
if kind == "transfer": if kind == "transfer":
return self.transfer(row, arguments) return self.transfer(row, arguments)
if kind in {"loan", "repay"}: if kind in {"loan", "repay"}:
return self.loan_or_repay(row, kind, arguments) return self.loan_or_repay(row, kind, arguments)
if kind == "wallet" and arguments: if kind == "wallet" and arguments:
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则" return "用法:/摩拉(兼容 /金币、/wallet),查看本人的虚拟摩拉;/游戏帮助 查看规则"
if kind == "prize_draw": if kind == "prize_draw":
if arguments and not re.fullmatch(r"\d{1,3}", arguments): if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/抽奖 [2~100金币](/draw),默认10金币" return "用法:/抽奖 [2~100摩拉](/draw),默认10摩拉"
bet = int(arguments or 10) bet = int(arguments or 10)
elif kind == "blackjack": elif kind == "blackjack":
table_command = self.parse_blackjack_table_command(arguments)
if table_command is not None:
table_action, table_bet = table_command
return self.group_blackjack(
row,
table_action,
table_bet,
arguments,
)
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态" compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted) start = BLACKJACK_START.fullmatch(compacted)
if start is not None: if start is not None:
@@ -209,16 +331,38 @@ class PlayerGames:
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);" "用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略" "开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
) )
group = str(row["group_fingerprint"])
if (
action in {"hit", "stand", "status"}
and self.ledger.has_active_group_blackjack(group)
):
return self.group_blackjack(row, action, 0, arguments)
if (
action == "start"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前已有Taffy的21点多人牌桌;"
"发送 /21点 状态 查看,或等待本桌结束"
)
if (
action == "end"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前是多人21点牌桌;轮到你时请 /21点 停牌,"
"主持人可 /21点 多人 取消"
)
else: else:
bet = 0 bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET: if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
return "抽奖下注必须为 2~100 的整数金币" return "抽奖下注必须为 2~100 的整数摩拉"
if ( if (
kind == "blackjack" kind == "blackjack"
and action == "start" and action == "start"
and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET
): ):
return "21点下注必须为 1~500 的整数金币" return "21点下注必须为 1~500 的整数摩拉"
person = self.ledger.resolve_person( person = self.ledger.resolve_person(
str(row["group_fingerprint"]), str(row["group_fingerprint"]),
str(row["sender"]), str(row["sender"]),
@@ -248,6 +392,13 @@ class PlayerGames:
payout=0, payout=0,
summary="10分钟无有效操作,按认输结束", summary="10分钟无有效操作,按认输结束",
) )
append_game_coin_event(
account,
kind="blackjack",
outcome="超时认输",
stake=int(game["bet"]),
payout=0,
)
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
expired = "上一局已超时,按认输结束;" expired = "上一局已超时,按认输结束;"
@@ -255,11 +406,20 @@ class PlayerGames:
state = "进行中" if account.get("game") else "无进行中的牌局" state = "进行中" if account.get("game") else "无进行中的牌局"
loan = account.get("loan") loan = account.get("loan")
loan_text = f"未还贷款 {loan['principal']};" if loan else "无贷款;" loan_text = f"未还贷款 {loan['principal']};" if loan else "无贷款;"
return ( total = account["wins"] + account["losses"] + account["ties"]
f"{expired}金币 {account['balance']};{loan_text}21点 {state};" win_rate = (account["wins"] / total * 100) if total else 0.0
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。" lines = [
"首次赠送1000仅发一次;/游戏帮助 查看规则" f"{player_label}:{expired}当前摩拉 {account['balance']};{loan_text}",
) (
f"21点 {state};已结算胜 {account['wins']} / "
f"负 {account['losses']} / 平 {account['ties']},"
f"胜率 {win_rate:.2f}%。"
),
"最近10次账户变动:",
]
lines.extend(recent_coin_event_lines(account) or ["暂无"])
lines.append("/游戏帮助 查看规则")
return "\n".join(lines)
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
active = account.get("game") active = account.get("game")
index = int(active["index"]) if active else None index = int(active["index"]) if active else None
@@ -275,7 +435,7 @@ class PlayerGames:
else message else message
) )
if account["balance"] < bet: if account["balance"] < bet:
message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}" message = expired + f"摩拉不足:余额 {account['balance']},本次需要 {bet}"
return player_reply(message) if kind == "blackjack" else message return player_reply(message) if kind == "blackjack" else message
account["balance"] -= bet account["balance"] -= bet
if kind == "blackjack": if kind == "blackjack":
@@ -307,10 +467,17 @@ class PlayerGames:
payout=payout, payout=payout,
summary=f"抽奖结果 {result}", summary=f"抽奖结果 {result}",
) )
append_game_coin_event(
account,
kind="prize_draw",
outcome=result,
stake=bet,
payout=payout,
)
return player_reply( return player_reply(
expired expired
+ f"{result};下注 {bet},返还 {payout}," + f"{result};下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};金币 {account['balance']}", f"净变化 {payout - bet:+d};摩拉 {account['balance']}",
index, index,
) )
@@ -321,26 +488,471 @@ class PlayerGames:
def coin_ranking(self, row: dict[str, Any], arguments: str) -> str: def coin_ranking(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip() value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value): if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/大富翁榜 或 /金币排行 [人数]" return "用法:/大富翁榜、/摩拉排行 [人数] 或 /金币排行 [人数]"
limit = int(value or 10) limit = int(value or 10)
if not 1 <= limit <= 20: if not 1 <= limit <= 20:
return "金币排行人数必须为 1~20" return "摩拉排行人数必须为 1~20"
records = self.ledger.player_coin_ranking( records = self.ledger.player_coin_ranking(
str(row["group_fingerprint"]), str(row["group_fingerprint"]),
limit=limit, limit=limit,
) )
if not records: if not records:
return "当前群还没有金币账户;发送 /金币 初始化" return "当前群还没有摩拉账户;发送 /摩拉 或 /金币 初始化"
lines = ["大富翁榜:"] lines = ["大富翁榜:"]
for index, record in enumerate(records, start=1): for index, record in enumerate(records, start=1):
loan = int(record.get("loan", 0)) loan = int(record.get("loan", 0))
loan_text = f"(未还贷款 {loan})" if loan else "" loan_text = f"(未还贷款 {loan})" if loan else ""
lines.append( lines.append(
f"{index}. {record['display_name']} {record['balance']} 金币" f"{index}. {record['display_name']} {record['balance']} 摩拉"
f"{loan_text}" f"{loan_text}"
) )
return "\n".join(lines) return "\n".join(lines)
def taffy_coins(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/Taffy摩拉 [人数](兼容 /Taffy金币)"
limit = int(value or 8)
if not 1 <= limit <= 20:
return "Taffy摩拉人数必须为 1~20"
stats = self.ledger.player_taffy_coin_stats(
str(row["group_fingerprint"]),
limit=limit,
)
if int(stats["records"]) == 0:
return "Taffy摩拉:当前群暂无已结算游戏记录"
net = int(stats["taffy_net"])
result = f"Taffy累计赢 {net} 摩拉" if net >= 0 else f"Taffy累计倒贴 {-net} 摩拉"
lines = [
(
f"Taffy摩拉:已结算 {stats['records']} 局;"
f"玩家总下注 {stats['stake']},总返还 {stats['payout']};"
f"{result}"
)
]
if stats["players"]:
lines.append("最大上贡者排行(含负数):")
else:
lines.append("最大上贡者排行:暂无已结算玩家明细")
for index, record in enumerate(stats["players"], start=1):
player_net = int(record["taffy_net"])
if player_net > 0:
verdict = f"上贡 {player_net}"
elif player_net < 0:
verdict = f"从Taffy赢走 {-player_net}"
else:
verdict = "打平 0"
lines.append(
f"{index}. {record['display_name']}:{verdict};"
f"下注 {record['stake']},返还 {record['payout']},"
f"{record['records']} 局"
)
return "\n".join(lines)
@staticmethod
def parse_blackjack_table_command(
arguments: str,
) -> tuple[str, int] | None:
compacted = re.sub(r"\s+", "", arguments.casefold())
if not compacted:
return None
if compacted in {"多人", "牌桌", "table", "multiplayer"}:
return "help", 0
if compacted in {
"多人启动",
"多人开桌",
"多人创建",
"牌桌启动",
"牌桌开桌",
"tablestart",
"multiplayerstart",
}:
return "start", 0
if compacted in {"加入", "join"}:
return "join", 0
if compacted in {
"多人开始",
"多人发牌",
"开始多人",
"开桌",
"发牌",
"tabledeal",
"multiplayerdeal",
}:
return "begin", 0
if compacted in {
"多人取消",
"牌桌取消",
"取消牌桌",
"tablecancel",
"multiplayercancel",
}:
return "cancel", 0
bet = BLACKJACK_TABLE_BET.fullmatch(compacted)
if bet is not None:
return "bet", int(bet["bet"])
return None
@staticmethod
def blackjack_players_text(players: list[dict[str, Any]]) -> str:
names = [str(player["display_name"]) for player in players]
return "、".join(names) if names else "暂无"
@staticmethod
def blackjack_hand_text(hand: list[int]) -> str:
if not hand:
return "未发牌"
return (
f"{' '.join(card_label(card) for card in hand)}"
f"({hand_value(hand)}点)"
)
@staticmethod
def blackjack_prompt(player: dict[str, Any]) -> str:
return (
f"请{player['display_name']} 放自己的筹码\n"
"输入/21点 放200(1~10000)"
)
def group_blackjack(
self,
row: dict[str, Any],
action: str,
amount: int,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = f"blackjack-table:{arguments}"
if action == "help":
return (
"多人21点用法:/21点 多人 启动;/21点 加入;"
"主持人 /21点 多人 开始;按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
)
if action == "start":
return self.ledger.start_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_start,
)
if action == "join":
return self.ledger.join_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_join,
)
if action == "begin":
return self.ledger.begin_group_blackjack_betting(
group,
sender,
event_id,
command,
self.render_blackjack_table_begin,
)
if action == "cancel":
return self.ledger.cancel_group_blackjack(
group,
sender,
event_id,
command,
self.render_blackjack_table_cancel,
)
if action == "status":
return self.ledger.group_blackjack_status(
group,
sender,
event_id,
command,
self.render_blackjack_table_status,
)
if action == "bet":
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
return "21点多人牌桌筹码必须为 1~10000 的整数摩拉"
deck = list(range(52))
self.rng.shuffle(deck)
return self.ledger.place_group_blackjack_bet(
group,
sender,
event_id,
command,
amount,
deck,
self.render_blackjack_table_bet,
)
if action in {"hit", "stand"}:
return self.ledger.play_group_blackjack(
group,
sender,
event_id,
command,
action,
self.render_blackjack_table_action,
)
raise ValueError(f"unsupported blackjack table action: {action}")
def render_blackjack_table_start(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
players = data.get("players", [])
prefix = (
"当前已有Taffy 的黑杰克Blackjack 21点牌桌"
if status == "already_open"
else "欢迎来到Taffy 的黑杰克Blackjack 21点牌桌"
)
return (
f"{prefix}\n"
"输入/21点 加入 加入当前游戏!"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"主持人发送 /21点 多人 开始 后,"
"Taffy会按顺序喊每个人放筹码"
)
def render_blackjack_table_join(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "closed":
return "这桌已经停止加入;发送 /21点 状态 查看当前进度"
if status == "full":
return f"这桌已满,最多 {data['max_players']} 人"
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
if status == "already_joined":
return (
f"{data['display_name']} 已经在这桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
f"{data['display_name']} 已加入Taffy的21点牌桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
def render_blackjack_table_begin(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以结束加入"
if status == "not_joining":
return "这桌已经结束加入;发送 /21点 状态 查看当前进度"
if status == "not_enough":
return (
"至少需要 2 名玩家才能开始多人21点;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
"加入结束,Taffy 按顺序收筹码。\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
def render_blackjack_table_status(self, data: dict[str, Any]) -> str:
if data["status"] == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
state = str(data["state"])
table_number = int(data["table_number"])
players = data["players"]
if state == "joining":
return (
f"21点牌桌第 {table_number} 桌正在加入;"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"输入/21点 加入 加入当前游戏;"
"主持人 /21点 多人 开始"
)
if state == "betting":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
lines = [f"21点牌桌第 {table_number} 桌正在下注:"]
for player in players:
bet = int(player["bet"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{bet if bet else '未放'}"
)
if prompt is not None:
lines.append(self.blackjack_prompt(prompt))
return "\n".join(lines)
if state == "playing":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
f"21点牌桌第 {table_number} 桌正在出牌;Taffy {dealer_text}"
]
for player in players:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']};{player['state']}"
)
if prompt is not None:
lines.append(
f"请{prompt['display_name']} 操作:"
"/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
return "当前没有进行中的21点多人牌桌"
def render_blackjack_table_bet(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_betting":
return "现在不是放筹码阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
return self.blackjack_prompt(data["prompt_player"])
if status == "insufficient":
return (
f"{data['display_name']}:摩拉不足,余额 {data['balance']},"
f"本次需要 {data['bet']}"
)
if status == "bet_accepted":
return (
f"{data['display_name']} 放下 {data['bet']};"
f"摩拉 {data['balance']}\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
if status == "dealt":
return self.render_blackjack_table_dealt(data)
if status == "settled":
return self.render_blackjack_table_settlement(data)
raise ValueError(f"unsupported blackjack bet status: {status}")
def render_blackjack_table_dealt(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
(
f"{data['display_name']} 放下 {data['bet']};"
"全部筹码已确认。Taffy 发牌。"
),
f"Taffy 明牌 {dealer_text}",
]
for player in data["players"]:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']}"
)
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_action(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_playing":
return "现在不是出牌阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
prompt = data["prompt_player"]
return (
f"现在轮到 {prompt['display_name']};"
"/21点 要牌 或 /21点 停牌"
)
if status == "not_actionable":
return "你这一手已经结束;等待本桌结算"
if status == "settled":
return self.render_blackjack_table_settlement(data)
player = data["player"]
lines: list[str] = []
if status == "hit":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"当前 {self.blackjack_hand_text(player['hand'])}"
)
elif status == "busted":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"{self.blackjack_hand_text(player['hand'])} 爆牌"
)
elif status == "stood":
lines.append(
f"{player['display_name']} 停牌;"
f"{self.blackjack_hand_text(player['hand'])}"
)
else:
raise ValueError(f"unsupported blackjack action status: {status}")
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_settlement(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = self.blackjack_hand_text(dealer)
lines = [
(
"Taffy 的黑杰克Blackjack 21点牌桌"
f"第 {data['table_number']} 桌结算:"
),
f"Taffy 庄家 {dealer_text}",
"这一桌每个人的变化:",
]
for player in data["players"]:
bet = int(player["bet"])
payout = int(player["payout"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{player['outcome']};牌 {self.blackjack_hand_text(player['hand'])};"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"摩拉 {player['balance']}"
)
return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以取消"
return (
f"21点牌桌第 {data['table_number']} 桌已取消;"
f"{data['refunded']} 人的下注已退回"
)
@staticmethod @staticmethod
def parse_transfer( def parse_transfer(
arguments: str, arguments: str,
@@ -376,11 +988,11 @@ class PlayerGames:
return ( return (
"用法:/转账 对方 金额,例如 /转账 Bob 100;" "用法:/转账 对方 金额,例如 /转账 Bob 100;"
"英文 /transfer Bob 100。金额范围 1~100000," "英文 /transfer Bob 100。金额范围 1~100000,"
"对方需先发送 /金币 初始化账户" "对方需先发送 /摩拉 或 /金币 初始化账户"
) )
target, amount = parsed target, amount = parsed
if not MIN_TRANSFER_AMOUNT <= amount <= MAX_TRANSFER_AMOUNT: if not MIN_TRANSFER_AMOUNT <= amount <= MAX_TRANSFER_AMOUNT:
return "转账金额必须为 1~100000 的整数金币" return "转账金额必须为 1~100000 的整数摩拉"
def render(data: dict[str, Any]) -> str: def render(data: dict[str, Any]) -> str:
source = data["source_display_name"] source = data["source_display_name"]
@@ -389,18 +1001,18 @@ class PlayerGames:
return f"{source}:不能给自己转账" return f"{source}:不能给自己转账"
if data["status"] == "target_missing": if data["status"] == "target_missing":
return ( return (
f"{source}:{target_name} 还没有金币账户," f"{source}:{target_name} 还没有摩拉账户,"
"请先让对方发送 /金币 初始化" "请先让对方发送 /摩拉 或 /金币 初始化"
) )
if data["status"] == "insufficient": if data["status"] == "insufficient":
return ( return (
f"{source}:金币不足,余额 {data['source_balance']}," f"{source}:摩拉不足,余额 {data['source_balance']},"
f"本次需要 {data['amount']}" f"本次需要 {data['amount']}"
) )
return ( return (
f"{source}:已向 {target_name} 转账 {data['amount']} 金币;" f"{source}:已向 {target_name} 转账 {data['amount']} 摩拉;"
f"本人金币 {data['source_balance']};" f"本人摩拉 {data['source_balance']};"
f"{target_name} 金币 {data['target_balance']}" f"{target_name} 摩拉 {data['target_balance']}"
) )
return self.ledger.transfer_player_coins( return self.ledger.transfer_player_coins(
@@ -420,7 +1032,7 @@ class PlayerGames:
return "用法:/贷款 [1~500];同一时间最多一笔未还贷款" return "用法:/贷款 [1~500];同一时间最多一笔未还贷款"
amount = int(value) amount = int(value)
if not MIN_LOAN_AMOUNT <= amount <= MAX_LOAN_AMOUNT: if not MIN_LOAN_AMOUNT <= amount <= MAX_LOAN_AMOUNT:
return "贷款金额必须为 1~500 的整数金币" return "贷款金额必须为 1~500 的整数摩拉"
else: else:
amount = 0 amount = 0
if value not in {"", "全部", "all"}: if value not in {"", "全部", "all"}:
@@ -428,7 +1040,7 @@ class PlayerGames:
return "用法:/还款 [金额|全部]" return "用法:/还款 [金额|全部]"
amount = int(value) amount = int(value)
if amount <= 0: if amount <= 0:
return "还款金额必须为正整数金币" return "还款金额必须为正整数摩拉"
group = str(row["group_fingerprint"]) group = str(row["group_fingerprint"])
sender = str(row["sender"]) sender = str(row["sender"])
@@ -441,7 +1053,7 @@ class PlayerGames:
if kind == "loan": if kind == "loan":
if loan: if loan:
return ( return (
f"{player_label}:已有未还贷款 {loan['principal']} 金币;" f"{player_label}:已有未还贷款 {loan['principal']} 摩拉;"
"请先 /还款 [金额|全部]" "请先 /还款 [金额|全部]"
) )
account["balance"] += amount account["balance"] += amount
@@ -449,9 +1061,10 @@ class PlayerGames:
"principal": amount, "principal": amount,
"borrowed_at": time.time(), "borrowed_at": time.time(),
} }
append_coin_event(account, amount, "贷款到账")
return ( return (
f"{player_label}:贷款 {amount} 金币已到账;" f"{player_label}:贷款 {amount} 摩拉已到账;"
f"当前金币 {account['balance']};未还贷款 {amount}" f"当前摩拉 {account['balance']};未还贷款 {amount}"
) )
if not loan: if not loan:
return f"{player_label}:当前没有未还贷款" return f"{player_label}:当前没有未还贷款"
@@ -461,13 +1074,14 @@ class PlayerGames:
else: else:
pay = min(amount, debt) pay = min(amount, debt)
if pay <= 0: if pay <= 0:
return f"{player_label}:金币不足,无法还款;未还贷款 {debt}" return f"{player_label}:摩拉不足,无法还款;未还贷款 {debt}"
if pay > int(account["balance"]): if pay > int(account["balance"]):
return ( return (
f"{player_label}:金币不足,余额 {account['balance']}," f"{player_label}:摩拉不足,余额 {account['balance']},"
f"本次还款需要 {pay}" f"本次还款需要 {pay}"
) )
account["balance"] -= pay account["balance"] -= pay
append_coin_event(account, -pay, "还款")
debt -= pay debt -= pay
if debt: if debt:
account["loan"] = { account["loan"] = {
@@ -475,13 +1089,13 @@ class PlayerGames:
"borrowed_at": float(loan.get("borrowed_at", time.time())), "borrowed_at": float(loan.get("borrowed_at", time.time())),
} }
return ( return (
f"{player_label}:已还款 {pay} 金币;" f"{player_label}:已还款 {pay} 摩拉;"
f"剩余贷款 {debt};当前金币 {account['balance']}" f"剩余贷款 {debt};当前摩拉 {account['balance']}"
) )
account["loan"] = None account["loan"] = None
return ( return (
f"{player_label}:已还清贷款 {pay} 金币;" f"{player_label}:已还清贷款 {pay} 摩拉;"
f"当前金币 {account['balance']}" f"当前摩拉 {account['balance']}"
) )
return self.ledger.apply_player_action( return self.ledger.apply_player_action(
@@ -547,7 +1161,7 @@ class PlayerGames:
if len(set(digits)) != 3: if len(set(digits)) != 3:
raise ValueError("彩票的三个数字必须互不重复") raise ValueError("彩票的三个数字必须互不重复")
if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET: if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET:
raise ValueError("彩票下注必须为 1~500 的整数金币") raise ValueError("彩票下注必须为 1~500 的整数摩拉")
return tuple(sorted(digits)), stake return tuple(sorted(digits)), stake
def group_lottery(self, row: dict[str, Any], arguments: str) -> str: def group_lottery(self, row: dict[str, Any], arguments: str) -> str:
@@ -618,7 +1232,7 @@ class PlayerGames:
f"命中 {entry['matches']} 个;下注 {entry['stake']}," f"命中 {entry['matches']} 个;下注 {entry['stake']},"
f"返还 {entry['payout']},净变化 " f"返还 {entry['payout']},净变化 "
f"{entry['payout'] - entry['stake']:+d};" f"{entry['payout'] - entry['stake']:+d};"
f"金币 {entry['balance']}" f"摩拉 {entry['balance']}"
) )
return "\n".join(lines) return "\n".join(lines)
@@ -666,7 +1280,7 @@ class PlayerGames:
return "当前没有进行中的彩票;先发送 /彩票 开始" return "当前没有进行中的彩票;先发送 /彩票 开始"
if data["status"] == "insufficient": if data["status"] == "insufficient":
return ( return (
f"{data['display_name']}:金币不足,余额 {data['balance']}," f"{data['display_name']}:摩拉不足,余额 {data['balance']},"
f"报名需要 {stake}" f"报名需要 {stake}"
) )
action = "报名已更新" if data["status"] == "updated" else "报名成功" action = "报名已更新" if data["status"] == "updated" else "报名成功"
@@ -674,7 +1288,7 @@ class PlayerGames:
f"{data['display_name']} #{data['player_game_index']}:" f"{data['display_name']} #{data['player_game_index']}:"
f"彩票第 {data['round_number']} 把{action};号码 " f"彩票第 {data['round_number']} 把{action};号码 "
f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};" f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};"
f"金币 {data['balance']}" f"摩拉 {data['balance']}"
) )
return self.ledger.register_group_lottery( return self.ledger.register_group_lottery(
@@ -739,9 +1353,16 @@ class PlayerGames:
payout=0, payout=0,
summary="主动结束牌局,不退下注", summary="主动结束牌局,不退下注",
) )
append_game_coin_event(
account,
kind="blackjack",
outcome="主动认输",
stake=bet,
payout=0,
)
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
return f"本人的21点已认输结束,不退下注;金币 {account['balance']}" return f"本人的21点已认输结束,不退下注;摩拉 {account['balance']}"
game["updated_at"] = time.time() game["updated_at"] = time.time()
if action == "hit": if action == "hit":
game["player"].append(game["deck"].pop()) game["player"].append(game["deck"].pop())
@@ -781,7 +1402,14 @@ class PlayerGames:
f"玩家{player}点,庄家{dealer}点" f"玩家{player}点,庄家{dealer}点"
), ),
) )
append_game_coin_event(
account,
kind="blackjack",
outcome=outcome,
stake=int(bet),
payout=int(payout),
)
account["game"] = None account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点)," return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点),"
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);" f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}") f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};摩拉 {account['balance']}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "1.0.0b1+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.0b1+1016" __version__ = "1.0.4b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 1.0.0.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]
+12
View File
@@ -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")
+19 -2
View File
@@ -677,6 +677,23 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.application.deepseek.complete.assert_called_once() self.application.deepseek.complete.assert_called_once()
self.application.deliver.assert_called_once() self.application.deliver.assert_called_once()
def test_semantic_duplicate_with_new_event_id_is_ignored(self):
first = self.application.accept(
{**event("event-1", "@JWeBot /金币"), "source": "rapidocr-local"}
)
duplicate = self.application.accept(
{**event("event-2", "@JWeBot /金币"), "source": "rapidocr-local"}
)
self.assertEqual(first["status"], "reply")
self.assertEqual(duplicate["status"], "ignored")
self.assertTrue(duplicate["duplicate"])
with self.application.ledger.lock:
count = self.application.ledger.connection.execute(
"SELECT COUNT(*) FROM events"
).fetchone()[0]
self.assertEqual(count, 1)
def test_failed_fast_ack_never_delivers_the_generated_reply(self): def test_failed_fast_ack_never_delivers_the_generated_reply(self):
self.application.deepseek.complete = mock.Mock(return_value="最终回复喵") self.application.deepseek.complete = mock.Mock(return_value="最终回复喵")
self.application.deliver = mock.Mock() self.application.deliver = mock.Mock()
@@ -867,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.0.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("更新摘要:Promote JWeBot", 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")
@@ -92,6 +92,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin": JWEBOT_AUTHOR, "coin": JWEBOT_AUTHOR,
"coin-transfer": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR, "coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR, "coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR, "random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR, "draw-lot": JWEBOT_AUTHOR,
+6 -6
View File
@@ -39,7 +39,7 @@ class Release061Tests(unittest.TestCase):
return json.loads(row[0]) return json.loads(row[0])
def test_aliases_are_corresponding_and_case_insensitive(self): def test_aliases_are_corresponding_and_case_insensitive(self):
pairs = [("上下文", "Context"), ("金币", "wallet"), ("抽奖", "draw"), pairs = [("上下文", "Context"), ("摩拉", "wallet"), ("抽奖", "draw"),
("买彩票", "lottery"), ("21点", "blackjack"), ("买彩票", "lottery"), ("21点", "blackjack"),
("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")] ("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")]
for chinese, english in pairs: for chinese, english in pairs:
@@ -82,18 +82,18 @@ class Release061Tests(unittest.TestCase):
self.assertEqual(ledger.search_repeat_history("group-a", "好"), []) self.assertEqual(ledger.search_repeat_history("group-a", "好"), [])
def test_initial_funds_aliases_groups_and_replay(self): def test_initial_funds_aliases_groups_and_replay(self):
self.assertIn("金币 1000", self.command("/wallet", event_id="wallet-1")) self.assertIn("摩拉 1000", self.command("/wallet", event_id="wallet-1"))
with mock.patch.object(self.local.players.rng, "randrange", return_value=99): with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
first = self.command("/draw 10", event_id="draw-1") first = self.command("/draw 10", event_id="draw-1")
replay = self.command("/draw 10", sender="a1", event_id="draw-1") replay = self.command("/draw 10", sender="a1", event_id="draw-1")
self.assertEqual(first, replay) self.assertEqual(first, replay)
self.assertEqual(self.state("a1")["balance"], 990) self.assertEqual(self.state("a1")["balance"], 990)
self.assertIn("金币 1000", self.command("/wallet", sender="Bob")) self.assertIn("摩拉 1000", self.command("/wallet", sender="Bob"))
self.assertIn("金币 1000", self.command("/wallet", group="group-b")) self.assertIn("摩拉 1000", self.command("/wallet", group="group-b"))
self.engine.ledger.connection.close() self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(self.root)) self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local self.local = self.engine.local
self.assertIn("金币 990", self.command("/wallet", event_id="restart")) self.assertIn("摩拉 990", self.command("/wallet", event_id="restart"))
def test_concurrent_replay_debits_once_and_rolls_back_failure(self): def test_concurrent_replay_debits_once_and_rolls_back_failure(self):
replies = [] replies = []
@@ -123,7 +123,7 @@ class Release061Tests(unittest.TestCase):
with mock.patch.object(self.local.players.rng, "randrange", return_value=99): with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
for _ in range(10): for _ in range(10):
self.command("/draw 100") self.command("/draw 100")
self.assertIn("金币不足", self.command("/draw 2")) self.assertIn("摩拉不足", self.command("/draw 2"))
self.assertEqual(self.state()["balance"], 0) self.assertEqual(self.state()["balance"], 0)
def test_blackjack_is_private_persistent_and_settles_exactly_once(self): def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
+2 -2
View File
@@ -119,8 +119,8 @@ class Release062BlackjackTests(unittest.TestCase):
self.assertIn("分两步", reply) self.assertIn("分两步", reply)
self.assertIn("100 是本局下注", reply) self.assertIn("100 是本局下注", reply)
self.assertIn("再单独发送", reply) self.assertIn("再单独发送", reply)
wallet = self.command("/金币") wallet = self.command("/摩拉")
self.assertIn("金币 1000", wallet) self.assertIn("摩拉 1000", wallet)
self.assertIn("无进行中的牌局", wallet) self.assertIn("无进行中的牌局", wallet)
+112 -18
View File
@@ -176,6 +176,61 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("下注 500,返还 500", history) self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501")) self.assertIn("1~500", self.command("/21点开始501"))
def test_multiplayer_blackjack_table_guides_turns_and_settles(self):
self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动"))
self.assertIn("目前玩家:alice", self.command("/21点 状态"))
self.assertIn("bob 已加入", self.command("/21点 加入", sender="Bob"))
self.assertIn("请alice 放自己的筹码", self.command("/21点 多人 开始"))
alice_bet = self.command("/21点 放200", event_id="alice-table-bet")
replay = self.command(
"/21点 放200",
sender="a1",
event_id="alice-table-bet",
)
self.assertEqual(replay, alice_bet)
self.assertIn("请bob 放自己的筹码", alice_bet)
self.assertEqual(self.state()["balance"], 800)
def table_deck(cards):
first = [12, 8, 9, 24, 19, 32, 25]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=table_deck,
):
dealt = self.command("/21点 放100", sender="Bob")
self.assertIn("全部筹码已确认", dealt)
self.assertIn("alice #1:K Q(20点);下注 200", dealt)
self.assertIn("bob #1:9 7(16点);下注 100", dealt)
self.assertIn("请alice 操作", dealt)
self.assertEqual(self.state("Bob")["balance"], 900)
alice_stand = self.command("/21点 停牌")
self.assertIn("alice 停牌", alice_stand)
self.assertIn("请bob 操作", alice_stand)
settled = self.command("/21点 要牌", sender="Bob")
self.assertIn("这一桌每个人的变化", settled)
self.assertIn("alice #1:你赢了", settled)
self.assertIn("下注 200,返还 400,净变化 +200;摩拉 1200", settled)
self.assertIn("bob #1:你输了", settled)
self.assertIn("下注 100,返还 0,净变化 -100;摩拉 900", settled)
self.assertEqual(self.state()["balance"], 1200)
self.assertEqual(self.state("Bob")["balance"], 900)
self.assertIn("alice #1:21点 你赢了", self.command("/游戏记录"))
self.assertIn(
"bob #1:21点 你输了",
self.command("/游戏记录", sender="Bob"),
)
self.assertIn("+200:21点 获胜", self.command("/金币"))
self.assertIn(
"-100:21点 失败",
self.command("/金币", sender="Bob"),
)
def test_prize_draw_has_indexed_history(self): def test_prize_draw_has_indexed_history(self):
with mock.patch.object( with mock.patch.object(
self.engine.local.players.rng, self.engine.local.players.rng,
@@ -192,12 +247,12 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("alice #1:抽奖 未中奖", history) self.assertIn("alice #1:抽奖 未中奖", history)
def test_transfer_requires_initialized_target_and_replays_once(self): def test_transfer_requires_initialized_target_and_replays_once(self):
self.assertIn("金币 1000", self.command("/金币")) self.assertIn("摩拉 1000", self.command("/摩拉"))
missing = self.command("/转账 Bob 100", event_id="missing-target") missing = self.command("/转账 Bob 100", event_id="missing-target")
self.assertIn("bob 还没有金币账户", missing) self.assertIn("bob 还没有摩拉账户", missing)
self.assertEqual(self.state()["balance"], 1000) self.assertEqual(self.state()["balance"], 1000)
self.assertIn("金币 1000", self.command("/金币", sender="Bob")) self.assertIn("摩拉 1000", self.command("/金币", sender="Bob"))
first = self.command("/转账 Bob 250", event_id="transfer-1") first = self.command("/转账 Bob 250", event_id="transfer-1")
replay = self.command( replay = self.command(
"/转账 Bob 250", "/转账 Bob 250",
@@ -206,40 +261,56 @@ class Release063GameTests(unittest.TestCase):
) )
self.assertEqual(replay, first) self.assertEqual(replay, first)
self.assertIn("alice:已向 bob 转账 250 金币", first) self.assertIn("alice:已向 bob 转账 250 摩拉", first)
self.assertEqual(self.state()["balance"], 750) self.assertEqual(self.state()["balance"], 750)
self.assertEqual(self.state("Bob")["balance"], 1250) self.assertEqual(self.state("Bob")["balance"], 1250)
alice_wallet = self.command("/金币", event_id="wallet-after-transfer-a")
bob_wallet = self.command(
"/金币",
sender="Bob",
event_id="wallet-after-transfer-b",
)
self.assertIn("alice:当前摩拉 750;无贷款;", alice_wallet)
self.assertIn("最近10次账户变动:", alice_wallet)
self.assertIn("-250:转账给 bob", alice_wallet)
self.assertIn("+1000:首次初始化", alice_wallet)
self.assertIn("bob:当前摩拉 1250;无贷款;", bob_wallet)
self.assertIn("+250:转账 来自 alice", bob_wallet)
def test_transfer_rejects_invalid_self_and_insufficient_cases(self): def test_transfer_rejects_invalid_self_and_insufficient_cases(self):
self.assertIn("金币 1000", self.command("/金币")) self.assertIn("摩拉 1000", self.command("/金币"))
self.assertIn("转账金额必须为 1~100000", self.command("/转账 Bob 0")) self.assertIn("转账金额必须为 1~100000", self.command("/转账 Bob 0"))
self.assertIn("不能给自己转账", self.command("/转账 a1 100")) self.assertIn("不能给自己转账", self.command("/转账 a1 100"))
self.assertIn("金币 1000", self.command("/金币", sender="Bob")) self.assertIn("摩拉 1000", self.command("/金币", sender="Bob"))
self.assertIn("金币不足", self.command("/transfer 1001 Bob")) self.assertIn("摩拉不足", self.command("/transfer 1001 Bob"))
self.assertEqual(self.state()["balance"], 1000) self.assertEqual(self.state()["balance"], 1000)
self.assertEqual(self.state("Bob")["balance"], 1000) self.assertEqual(self.state("Bob")["balance"], 1000)
def test_loan_repay_and_replay_once(self): def test_loan_repay_and_replay_once(self):
self.assertIn("金币 1000", self.command("/金币")) self.assertIn("摩拉 1000", self.command("/金币"))
first = self.command("/贷款 300", event_id="loan-1") first = self.command("/贷款 300", event_id="loan-1")
replay = self.command("/贷款 300", event_id="loan-1") replay = self.command("/贷款 300", event_id="loan-1")
self.assertEqual(replay, first) self.assertEqual(replay, first)
self.assertIn("贷款 300 金币已到账", first) self.assertIn("贷款 300 摩拉已到账", first)
self.assertEqual(self.state()["balance"], 1300) self.assertEqual(self.state()["balance"], 1300)
self.assertEqual(self.state()["loan"]["principal"], 300) self.assertEqual(self.state()["loan"]["principal"], 300)
self.assertIn("未还贷款 300", self.command("/金币")) self.assertIn("未还贷款 300", self.command("/金币"))
self.assertIn("已有未还贷款 300", self.command("/贷款 100")) self.assertIn("已有未还贷款 300", self.command("/贷款 100"))
partial = self.command("/还款 120") partial = self.command("/还款 120")
self.assertIn("已还款 120 金币", partial) self.assertIn("已还款 120 摩拉", partial)
self.assertEqual(self.state()["balance"], 1180) self.assertEqual(self.state()["balance"], 1180)
self.assertEqual(self.state()["loan"]["principal"], 180) self.assertEqual(self.state()["loan"]["principal"], 180)
done = self.command("/还款 全部") done = self.command("/还款 全部")
self.assertIn("已还清贷款 180 金币", done) self.assertIn("已还清贷款 180 摩拉", done)
self.assertEqual(self.state()["balance"], 1000) self.assertEqual(self.state()["balance"], 1000)
self.assertIsNone(self.state()["loan"]) self.assertIsNone(self.state()["loan"])
wallet = self.command("/金币", event_id="wallet-after-repay")
self.assertIn("+300:贷款到账", wallet)
self.assertIn("-120:还款", wallet)
self.assertIn("-180:还款", wallet)
def test_loan_rejects_invalid_amounts_and_empty_repay(self): def test_loan_rejects_invalid_amounts_and_empty_repay(self):
self.assertIn("贷款金额必须为 1~500", self.command("/贷款 0")) self.assertIn("贷款金额必须为 1~500", self.command("/贷款 0"))
@@ -247,20 +318,43 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("当前没有未还贷款", self.command("/还款 全部")) self.assertIn("当前没有未还贷款", self.command("/还款 全部"))
def test_coin_ranking_lists_accounts_by_balance(self): def test_coin_ranking_lists_accounts_by_balance(self):
self.assertIn("金币 1000", self.command("/金币")) self.assertIn("摩拉 1000", self.command("/金币"))
self.assertIn("金币 1000", self.command("/金币", sender="Bob")) self.assertIn("摩拉 1000", self.command("/金币", sender="Bob"))
self.assertIn("金币 1000", self.command("/金币", sender="Carol")) self.assertIn("摩拉 1000", self.command("/金币", sender="Carol"))
self.command("/转账 Bob 250") self.command("/转账 Bob 250")
self.command("/贷款 100", sender="Carol") self.command("/贷款 100", sender="Carol")
ranking = self.command("/大富翁榜") ranking = self.command("/大富翁榜")
self.assertIn("大富翁榜:", ranking) self.assertIn("大富翁榜:", ranking)
self.assertIn("1. bob 1250 金币", ranking) self.assertIn("1. bob 1250 摩拉", ranking)
self.assertIn("2. carol 1100 金币(未还贷款 100)", ranking) self.assertIn("2. carol 1100 摩拉(未还贷款 100)", ranking)
self.assertIn("3. alice 750 金币", ranking) self.assertIn("3. alice 750 摩拉", ranking)
self.assertNotIn("Dave", ranking) self.assertNotIn("Dave", ranking)
self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0")) self.assertIn("摩拉排行人数必须为 1~20", self.command("/摩拉排行 0"))
def test_taffy_coin_stats_sums_all_game_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
self.command("/抽奖10")
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=0,
):
self.command("/抽奖10", sender="Bob")
stats = self.command("/Taffy摩拉")
self.assertIn("玩家总下注 20,总返还 50", stats)
self.assertIn("Taffy累计倒贴 30 摩拉", stats)
self.assertIn("最大上贡者排行(含负数):", stats)
self.assertIn("alice:上贡 10", stats)
self.assertIn("bob:从Taffy赢走 40", stats)
self.assertIn("Taffy摩拉人数必须为 1~20", self.command("/Taffy摩拉 0"))
def test_open_round_and_history_survive_restart(self): def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name) root = Path(self.temp.name)
+210
View File
@@ -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()
+25
View File
@@ -423,6 +423,31 @@ class LayoutScalingTests(unittest.TestCase):
fast_ack_sender.assert_called_once() fast_ack_sender.assert_called_once()
legacy.assert_not_called() legacy.assert_not_called()
def test_ignored_duplicate_does_not_fall_back(self):
legacy = mock.Mock(return_value="legacy")
response = {
"status": "ignored",
"route": "local",
"execution_path": "local_command",
"duplicate": True,
}
with mock.patch.object(
MODULE.jwebot_edge,
"request_edge",
return_value=response,
):
reply = MODULE.edge_router_reply(
{
"event_id": "event-2",
"group_fingerprint": "group-a",
"text": "@JWeBot /金币",
},
legacy_router=legacy,
)
self.assertEqual(reply, "NO_REPLY")
legacy.assert_not_called()
def test_hash_command_uses_legacy_router_when_edge_is_unavailable(self): def test_hash_command_uses_legacy_router_when_edge_is_unavailable(self):
legacy = mock.Mock(return_value="legacy") legacy = mock.Mock(return_value="legacy")
with mock.patch.object( with mock.patch.object(
+99
View File
@@ -4,6 +4,7 @@ import importlib.util
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import types
import unittest import unittest
from contextlib import nullcontext from contextlib import nullcontext
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -517,6 +518,64 @@ class GuiCoordinationTests(unittest.TestCase):
class OcrFallbackTests(unittest.TestCase): class OcrFallbackTests(unittest.TestCase):
def test_ocr_engine_limits_onnxruntime_threads_by_default(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 1,
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
},
)
def test_ocr_engine_allows_thread_limit_overrides(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "2",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "3",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 2,
"EngineConfig.onnxruntime.inter_op_num_threads": 3,
},
)
def test_low_confidence_line_uses_detection_fallback(self): def test_low_confidence_line_uses_detection_fallback(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 50, 132, 68, 5000) bubble = MODULE.Bubble(50, 50, 132, 68, 5000)
@@ -630,6 +689,46 @@ class OcrCacheTests(unittest.TestCase):
self.assertEqual(engine.last_cache_hits, 0) self.assertEqual(engine.last_cache_hits, 0)
self.assertEqual(engine.last_cache_misses, 1) self.assertEqual(engine.last_cache_misses, 1)
def test_antialias_drift_reuses_visual_signature_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
first = MODULE.Image.new("RGB", (300, 180), (238, 238, 240))
second = MODULE.Image.new("RGB", (300, 180), (242, 242, 244))
first.putpixel((70, 75), (20, 20, 20))
second.putpixel((70, 75), (35, 35, 35))
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=self.message(60),
) as recognize:
engine.recognize_messages(first, [bubble])
messages, _ = engine.recognize_messages(second, [bubble])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(len(messages), 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_unrecognized_bubble_reuses_negative_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white")
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=None,
) as recognize:
first_messages, _ = engine.recognize_messages(image, [bubble])
second_messages, _ = engine.recognize_messages(image, [bubble])
self.assertEqual(first_messages, [])
self.assertEqual(second_messages, [])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_periodic_refresh_revalidates_cached_bubbles(self): def test_periodic_refresh_revalidates_cached_bubbles(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine) engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white") image = MODULE.Image.new("RGB", (300, 180), "white")
+1
View File
@@ -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 \
+12
View File
@@ -761,6 +761,18 @@ def edge_router_reply(
outcome="reply", outcome="reply",
) )
return reply return reply
if response.get("status") == "ignored":
TELEMETRY.elapsed(
"recorder.edge.request",
started,
event_id=event_id,
group_id=group_id,
route=str(response.get("route", "")),
execution_path=str(response.get("execution_path", "")),
outcome="ignored",
duplicate=bool(response.get("duplicate")),
)
return "NO_REPLY"
logging.warning( logging.warning(
"edge router fallback event_hash=%s status=%s", "edge router fallback event_hash=%s status=%s",
hashlib.sha256( hashlib.sha256(
+36 -6
View File
@@ -67,6 +67,8 @@ DAMAGE_HEARTBEAT_SECONDS = 2.0
CAPTURE_BUFFER_FRAMES = 12 CAPTURE_BUFFER_FRAMES = 12
OCR_MESSAGE_CACHE_SIZE = 256 OCR_MESSAGE_CACHE_SIZE = 256
OCR_CACHE_REFRESH_SCANS = 120 OCR_CACHE_REFRESH_SCANS = 120
DEFAULT_OCR_ONNX_INTRA_OP_THREADS = 1
DEFAULT_OCR_ONNX_INTER_OP_THREADS = 1
WINDOW_LAYOUT_SETTLE_SECONDS = 0.25 WINDOW_LAYOUT_SETTLE_SECONDS = 0.25
FOLLOW_LATEST_SETTLE_SECONDS = 0.20 FOLLOW_LATEST_SETTLE_SECONDS = 0.20
FOLLOW_LATEST_MOUSE_X = 1432 FOLLOW_LATEST_MOUSE_X = 1432
@@ -224,6 +226,17 @@ def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value).strip() return re.sub(r"\s+", " ", value).strip()
def env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
logging.warning("invalid integer environment value name=%s", name)
return default
def bubble_sequence_signature( def bubble_sequence_signature(
sender_crop: Image.Image, sender_crop: Image.Image,
body_crop: Image.Image, body_crop: Image.Image,
@@ -1099,10 +1112,21 @@ class OcrEngine:
from rapidocr import RapidOCR from rapidocr import RapidOCR
self._numpy = np self._numpy = np
self._engine = RapidOCR() self._engine = RapidOCR(
params={
"EngineConfig.onnxruntime.intra_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTRA_OP_THREADS",
DEFAULT_OCR_ONNX_INTRA_OP_THREADS,
),
"EngineConfig.onnxruntime.inter_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTER_OP_THREADS",
DEFAULT_OCR_ONNX_INTER_OP_THREADS,
),
}
)
self._message_cache: OrderedDict[ self._message_cache: OrderedDict[
str, str,
tuple[str, str, float, float, float], tuple[str, str, float, float, float] | None,
] = OrderedDict() ] = OrderedDict()
self._scan_count = 0 self._scan_count = 0
self.last_cache_hits = 0 self.last_cache_hits = 0
@@ -1178,7 +1202,7 @@ class OcrEngine:
def _cache(self) -> OrderedDict[ def _cache(self) -> OrderedDict[
str, str,
tuple[str, str, float, float, float], tuple[str, str, float, float, float] | None,
]: ]:
cache = getattr(self, "_message_cache", None) cache = getattr(self, "_message_cache", None)
if cache is None: if cache is None:
@@ -1258,15 +1282,17 @@ class OcrEngine:
bubble_crop = image.crop( bubble_crop = image.crop(
(bubble.x, bubble.y, bubble.right, bubble.bottom) (bubble.x, bubble.y, bubble.right, bubble.bottom)
) )
key = self._cache_key(sender_crop, bubble_crop)
sequence_signature = bubble_sequence_signature( sequence_signature = bubble_sequence_signature(
sender_crop, sender_crop,
bubble_crop, bubble_crop,
) )
cached = cache.get(key) key = sequence_signature
if cached is not None: if key in cache:
cached = cache[key]
hits += 1 hits += 1
cache.move_to_end(key) cache.move_to_end(key)
if cached is None:
continue
messages.append( messages.append(
IncomingMessage( IncomingMessage(
sender=cached[0], sender=cached[0],
@@ -1287,6 +1313,10 @@ class OcrEngine:
visual_signature=sequence_signature, visual_signature=sequence_signature,
) )
if message is None: if message is None:
cache[key] = None
cache.move_to_end(key)
while len(cache) > OCR_MESSAGE_CACHE_SIZE:
cache.popitem(last=False)
continue continue
cache[key] = ( cache[key] = (
message.sender, message.sender,