Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddebe075d7 | ||
|
|
522e349491 | ||
|
|
35ef82f14f | ||
|
|
b1d8e4e8e7 | ||
|
|
127fea8cd0 | ||
|
|
bf6b738baa | ||
|
|
22edf6ff15 | ||
|
|
572159f571 | ||
|
|
99a251c19a | ||
|
|
3fa04755d0 |
@@ -24,6 +24,22 @@ These rules are mandatory for every automated change in this repository.
|
|||||||
or adaptive restart-stability gates.
|
or adaptive restart-stability gates.
|
||||||
- If a deployment gate fails, verify rollback state before retrying.
|
- If a deployment gate fails, verify rollback state before retrying.
|
||||||
|
|
||||||
|
## Mandatory Release Settlement
|
||||||
|
|
||||||
|
- Every successful production update must run `jwebot_release_update.py`
|
||||||
|
after cutover gates pass and before the release is declared complete.
|
||||||
|
- Each release grants exactly 200 Mora to every account that exists when the
|
||||||
|
settlement starts. The grant and account movement are idempotent by version.
|
||||||
|
- The first bullet in each release's `CHANGELOG.md` section must be one concise,
|
||||||
|
user-facing sentence suitable for the group update announcement.
|
||||||
|
- After crediting accounts, send the version, first changelog bullet, and
|
||||||
|
`Mora x200` compensation notice through the guarded group sender.
|
||||||
|
- Never blindly retry an uncertain announcement. Verify the group visually,
|
||||||
|
then use `--retry-uncertain-announcement` only when the prior message did not
|
||||||
|
land.
|
||||||
|
- A production release is incomplete until compensation, announcement,
|
||||||
|
SQLite integrity, empty queues, Edge health, and service stability pass.
|
||||||
|
|
||||||
## Configuration And Access
|
## Configuration And Access
|
||||||
|
|
||||||
- Real profiles live under `~/.config/jwebot/`, never in the repository.
|
- Real profiles live under `~/.config/jwebot/`, never in the repository.
|
||||||
|
|||||||
@@ -1,5 +1,83 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## beta 1.1.2.1019 - 2026-10-19 16:38:10
|
||||||
|
|
||||||
|
- 新增低频主动插话、随机冷门知识问答和更新生命周期提示:Taffy 会偶尔自然冒泡,抛出限时答题奖励摩拉,并在停机、上线和更新就绪时主动说明状态。
|
||||||
|
- Use recent safe group context to replace some hourly scan slots with an
|
||||||
|
ambient chat message during active daytime windows.
|
||||||
|
- Add a 200+ question CS, Apex, Overwatch, and LoL trivia bank with
|
||||||
|
five-minute `/回答 A/B/C/D` answers, recent-question avoidance, and
|
||||||
|
rarity-based Mora rewards.
|
||||||
|
- Limit ambient chat with deterministic probability, multi-hour cooldown, and
|
||||||
|
minimum recent-speaker activity so proactive replies feel occasional.
|
||||||
|
- Send guarded shutdown and startup lifecycle notices during production
|
||||||
|
cutover before the normal ready-stage update announcement and compensation.
|
||||||
|
|
||||||
|
## beta 1.1.1.1016 - 2026-10-16 17:19:45
|
||||||
|
|
||||||
|
- 修复扫码加权重复购买只刷新不叠加的问题。
|
||||||
|
- Change scan boost semantics to stack each successful purchase as an
|
||||||
|
independent 24-hour +5 weight entry.
|
||||||
|
- Preserve legacy boost entries by deriving their 24-hour expiry from their
|
||||||
|
creation time.
|
||||||
|
|
||||||
|
## beta 1.1.0.1016 - 2026-10-16 17:04:25
|
||||||
|
|
||||||
|
- 修复 OCR 偶发重复识别消耗型命令导致重复回复的问题,并完善摩拉/扫码榜展示。
|
||||||
|
- Extend OCR semantic duplicate suppression for resource-mutating local
|
||||||
|
commands so a visible command bubble re-detected after the short 8 second
|
||||||
|
window is still ignored.
|
||||||
|
- Keep ordinary local commands on the short duplicate window to avoid blocking
|
||||||
|
intentional repeated game/query commands.
|
||||||
|
- Show scan-boost coupon count in `/摩拉`, and make `/扫码榜` list every
|
||||||
|
configured scan candidate plus next-hour scan weights.
|
||||||
|
- This major settlement grants each existing account both 200 Mora and
|
||||||
|
1 primogem.
|
||||||
|
|
||||||
|
## beta 1.0.5.1016 - 2026-10-16 16:30:44
|
||||||
|
|
||||||
|
- 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。
|
||||||
|
- Add primogem account state, primogem movement history, wish records, scan
|
||||||
|
boost coupons, Taffy speech credits, scheduled speech delivery, and
|
||||||
|
primogem-source ranking without a SQLite schema migration.
|
||||||
|
- Award primogems from blackjack natural 21, blackjack 20-to-21 hit challenges,
|
||||||
|
Taffy blackjack win streaks, and personal consecutive lottery high-hit pairs.
|
||||||
|
- Extend post-release settlement so this version grants each existing account
|
||||||
|
both 200 Mora and 1 primogem, and show `/version` update time down to seconds.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
|
|||||||
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
|
||||||
并通过带视觉确认的 GUI 发送链路回复。
|
并通过带视觉确认的 GUI 发送链路回复。
|
||||||
|
|
||||||
当前版本:`beta 1.0.0.1016`,更新日期:2026-10-16。
|
当前版本:`beta 1.1.2.1019`,更新时间:2026-10-19 16:38:10。
|
||||||
|
|
||||||
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,15 @@ 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
|
||||||
|
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容
|
||||||
|
/游戏记录 [页码](/gamehistory)
|
||||||
/游戏记录 [页码](/gamehistory)
|
/游戏记录 [页码](/gamehistory)
|
||||||
/游戏帮助(/games)
|
/游戏帮助(/games)
|
||||||
/自走棋开始|报名|开赛|状态|档案
|
/自走棋开始|报名|开赛|状态|档案
|
||||||
@@ -188,7 +191,11 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
|
|||||||
```
|
```
|
||||||
|
|
||||||
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
|
||||||
录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。
|
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
|
||||||
|
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
|
||||||
|
`beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石;
|
||||||
|
后续普通小版本不额外发放原石。
|
||||||
|
旧 OCR 服务保留安装,但保持禁用。
|
||||||
|
|
||||||
### 仓库结构
|
### 仓库结构
|
||||||
|
|
||||||
@@ -262,10 +269,10 @@ visually confirmed GUI bridge.
|
|||||||
Current version:
|
Current version:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
beta 1.0.0.1016
|
beta 1.1.2.1019
|
||||||
```
|
```
|
||||||
|
|
||||||
Updated: 2026-10-16.
|
Updated: 2026-10-19 16:38:10.
|
||||||
|
|
||||||
The 1.0 Beta baseline consolidates the stable local command set, person and
|
The 1.0 Beta baseline consolidates the stable local command set, person and
|
||||||
context indexes, Steam presence/playtime queries, virtual coin games,
|
context indexes, Steam presence/playtime queries, virtual coin games,
|
||||||
@@ -451,7 +458,11 @@ 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.
|
||||||
|
For `beta 1.0.5.1016` and `beta 1.1.0.1016`, the settlement also grants
|
||||||
|
1 primogem once; ordinary later patch releases do not grant extra primogems.
|
||||||
|
|
||||||
## Repository Map
|
## Repository Map
|
||||||
|
|
||||||
|
|||||||
@@ -341,6 +341,19 @@ verify_edge_health() {
|
|||||||
'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)'
|
'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
send_release_lifecycle_notice() {
|
||||||
|
local stage=$1
|
||||||
|
/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}" "${source_dir}/jwebot_release_update.py" \
|
||||||
|
--release-file "${source_dir}/VERSION" \
|
||||||
|
--database "${target_edge_state}/edge.sqlite3" \
|
||||||
|
--delivery-helper "${target_runtime}/jwebot_edge_delivery.py" \
|
||||||
|
--lifecycle-stage "${stage}"
|
||||||
|
}
|
||||||
|
|
||||||
render_root_configuration() {
|
render_root_configuration() {
|
||||||
local output_dir="$1"
|
local output_dir="$1"
|
||||||
sed \
|
sed \
|
||||||
@@ -421,6 +434,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 +444,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 \
|
||||||
@@ -468,6 +484,9 @@ jwebot_log "backup ready path=${backup_dir}"
|
|||||||
armed=1
|
armed=1
|
||||||
jwebot_progress 3 8 "stop services"
|
jwebot_progress 3 8 "stop services"
|
||||||
jwebot_log "cutover starting"
|
jwebot_log "cutover starting"
|
||||||
|
jwebot_log "release lifecycle shutdown notice starting"
|
||||||
|
send_release_lifecycle_notice shutdown
|
||||||
|
jwebot_log "release lifecycle shutdown notice ready"
|
||||||
user_systemctl stop \
|
user_systemctl stop \
|
||||||
wechat-ocr-adaptive-recorder.service \
|
wechat-ocr-adaptive-recorder.service \
|
||||||
jwebot-edge.service \
|
jwebot-edge.service \
|
||||||
@@ -606,6 +625,10 @@ if [[ -f "${target_edge_state}/edge.sqlite3" ]]; then
|
|||||||
fi
|
fi
|
||||||
jwebot_log "database ready quick_check=${db_check:-absent}"
|
jwebot_log "database ready quick_check=${db_check:-absent}"
|
||||||
|
|
||||||
|
jwebot_log "release lifecycle startup notice starting"
|
||||||
|
send_release_lifecycle_notice startup
|
||||||
|
jwebot_log "release lifecycle startup notice ready"
|
||||||
|
|
||||||
jwebot_progress 7 8 "verify backup"
|
jwebot_progress 7 8 "verify backup"
|
||||||
(
|
(
|
||||||
cd "${backup_dir}"
|
cd "${backup_dir}"
|
||||||
@@ -619,6 +642,17 @@ success=1
|
|||||||
armed=0
|
armed=0
|
||||||
trap - EXIT HUP INT TERM
|
trap - EXIT HUP INT TERM
|
||||||
|
|
||||||
|
jwebot_log "release settlement starting"
|
||||||
|
release_update_output=$(
|
||||||
|
/usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \
|
||||||
|
HOME="${target_home}" \
|
||||||
|
XDG_RUNTIME_DIR="${user_runtime}" \
|
||||||
|
DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \
|
||||||
|
"${runtime_python}" "${target_runtime}/jwebot_release_update.py"
|
||||||
|
)
|
||||||
|
printf '%s\n' "${release_update_output}"
|
||||||
|
jwebot_log "release settlement ready"
|
||||||
|
|
||||||
jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")"
|
jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")"
|
||||||
jwebot_log "backup active path=${backup_dir}"
|
jwebot_log "backup active path=${backup_dir}"
|
||||||
jwebot_log "legacy OCR inactive"
|
jwebot_log "legacy OCR inactive"
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ adaptive_script_name=wechat_adaptive_recorder.py
|
|||||||
adaptive_bridge_name=wechat_adaptive_gui_bridge.py
|
adaptive_bridge_name=wechat_adaptive_gui_bridge.py
|
||||||
edge_script_name=jwebot_edge.py
|
edge_script_name=jwebot_edge.py
|
||||||
edge_delivery_name=jwebot_edge_delivery.py
|
edge_delivery_name=jwebot_edge_delivery.py
|
||||||
|
release_update_name=jwebot_release_update.py
|
||||||
service_name=wechat-health-watch.service
|
service_name=wechat-health-watch.service
|
||||||
timer_name=wechat-health-watch.timer
|
timer_name=wechat-health-watch.timer
|
||||||
adaptive_service_name=wechat-ocr-adaptive-recorder.service
|
adaptive_service_name=wechat-ocr-adaptive-recorder.service
|
||||||
@@ -209,6 +210,7 @@ rollback() {
|
|||||||
restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700
|
restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700
|
||||||
restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700
|
restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700
|
||||||
restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700
|
restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700
|
||||||
|
restore_file "$release_update_name" "$target_dir/$release_update_name" 0700
|
||||||
for package_name in \
|
for package_name in \
|
||||||
jwebot_config \
|
jwebot_config \
|
||||||
jwebot_sdk \
|
jwebot_sdk \
|
||||||
@@ -246,6 +248,7 @@ for path in \
|
|||||||
"$runtime_source_dir/$adaptive_bridge_name" \
|
"$runtime_source_dir/$adaptive_bridge_name" \
|
||||||
"$runtime_source_dir/$edge_script_name" \
|
"$runtime_source_dir/$edge_script_name" \
|
||||||
"$runtime_source_dir/$edge_delivery_name" \
|
"$runtime_source_dir/$edge_delivery_name" \
|
||||||
|
"$runtime_source_dir/$release_update_name" \
|
||||||
"$user_unit_source_dir/$service_name" \
|
"$user_unit_source_dir/$service_name" \
|
||||||
"$user_unit_source_dir/$timer_name" \
|
"$user_unit_source_dir/$timer_name" \
|
||||||
"$user_unit_source_dir/$adaptive_service_name" \
|
"$user_unit_source_dir/$adaptive_service_name" \
|
||||||
@@ -289,7 +292,8 @@ for name in \
|
|||||||
"$adaptive_script_name" \
|
"$adaptive_script_name" \
|
||||||
"$adaptive_bridge_name" \
|
"$adaptive_bridge_name" \
|
||||||
"$edge_script_name" \
|
"$edge_script_name" \
|
||||||
"$edge_delivery_name"
|
"$edge_delivery_name" \
|
||||||
|
"$release_update_name"
|
||||||
do
|
do
|
||||||
if [ -f "$target_dir/$name" ]; then
|
if [ -f "$target_dir/$name" ]; then
|
||||||
cp -a "$target_dir/$name" "$backup_dir/$name"
|
cp -a "$target_dir/$name" "$backup_dir/$name"
|
||||||
@@ -342,6 +346,9 @@ install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adapti
|
|||||||
install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name"
|
install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name"
|
||||||
install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name"
|
install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name"
|
||||||
install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name"
|
install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name"
|
||||||
|
install -m 0700 \
|
||||||
|
"$runtime_source_dir/$release_update_name" \
|
||||||
|
"$target_dir/$release_update_name"
|
||||||
rm -rf \
|
rm -rf \
|
||||||
"$target_dir/jwebot_config" \
|
"$target_dir/jwebot_config" \
|
||||||
"$target_dir/jwebot_sdk" \
|
"$target_dir/jwebot_sdk" \
|
||||||
@@ -395,7 +402,8 @@ for name in \
|
|||||||
"$adaptive_script_name" \
|
"$adaptive_script_name" \
|
||||||
"$adaptive_bridge_name" \
|
"$adaptive_bridge_name" \
|
||||||
"$edge_script_name" \
|
"$edge_script_name" \
|
||||||
"$edge_delivery_name"
|
"$edge_delivery_name" \
|
||||||
|
"$release_update_name"
|
||||||
do
|
do
|
||||||
"$runtime_python" -m py_compile "$target_dir/$name"
|
"$runtime_python" -m py_compile "$target_dir/$name"
|
||||||
done
|
done
|
||||||
|
|||||||
+15
-5
@@ -1,6 +1,6 @@
|
|||||||
# JWeBot Command Catalog
|
# JWeBot Command Catalog
|
||||||
|
|
||||||
Current baseline: `beta 1.0.0.1016`.
|
Current baseline: `beta 1.1.2.1019`.
|
||||||
|
|
||||||
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
|
||||||
@@ -12,7 +12,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/功能列表` | `/help` | Show the command list. |
|
| `/功能列表` | `/help` | Show the command list. |
|
||||||
| `/状态` | `/status` | Show local runtime status. |
|
| `/状态` | `/status` | Show local runtime status. |
|
||||||
| `/version` | - | Show installed package versions plus the latest changelog date and summary. |
|
| `/version` | `/版本` | Show installed package versions plus the latest changelog time and summary. |
|
||||||
| `/debug` | - | Show privacy-safe telemetry diagnostics. |
|
| `/debug` | - | Show privacy-safe telemetry diagnostics. |
|
||||||
| `/余额` | - | Show configured direct-provider balance status. |
|
| `/余额` | - | Show configured direct-provider balance status. |
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
|
|||||||
|
|
||||||
| Command | Alias | Purpose |
|
| Command | Alias | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/扫码排行` | - | Show scan ranking for the current group. |
|
| `/扫码排行` | `/扫码榜`, `/今日扫码` | Show all scan candidates, today's counts, and next-hour scan weights for the current group. |
|
||||||
| `/复读榜` | - | Show repeat ranking for the current group. |
|
| `/复读榜` | - | Show repeat ranking for the current group. |
|
||||||
| `/查记录 关键词` | - | Search local group history by keyword. |
|
| `/查记录 关键词` | - | Search local group history by keyword. |
|
||||||
|
|
||||||
@@ -41,11 +41,17 @@ 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 and primogem 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. |
|
||||||
|
| `/原石祈愿` | `/祈愿`, `/wish` | Spend 1 primogem to draw 20-10000 Mora with expected value 3600 Mora, with a chance to gain a scan-boost ticket. |
|
||||||
|
| `/原石榜 [人数]` | `/primogem榜` | Show the group primogem leaderboard with source details. |
|
||||||
|
| `/扫码加权 人名` | `/扫码概率 人名` | Spend a scan-boost ticket or 1 primogem to give that person +5 hourly scan weight for 24 hours. |
|
||||||
|
| `/Taffy发话 内容` | `/taffysay content` | Spend Taffy speech credit; 1 primogem converts to 2 credits when needed. |
|
||||||
|
| `/Taffy发话 定时 HH:MM 内容` | - | Schedule a Taffy speech through the guarded clock delivery queue. |
|
||||||
|
| `/回答 A` | `/answer A` | Answer the active five-minute cold-knowledge quiz for a rarity-based Mora reward. |
|
||||||
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. |
|
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. |
|
||||||
| `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. |
|
| `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. |
|
||||||
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
|
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
|
||||||
@@ -57,6 +63,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. |
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 对阵与结果
|
||||||
- 每张加时地图
|
- 每张加时地图
|
||||||
|
|||||||
@@ -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
@@ -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 只结算一次,重试返回原响应。相同事件若变更
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ exception.
|
|||||||
- every package `__version__`;
|
- every package `__version__`;
|
||||||
- `README.md`;
|
- `README.md`;
|
||||||
- `CHANGELOG.md`.
|
- `CHANGELOG.md`.
|
||||||
|
- The first bullet under each release heading in `CHANGELOG.md` must be one
|
||||||
|
concise, user-facing sentence. It is the canonical group announcement
|
||||||
|
summary.
|
||||||
- Do not mark production complete merely because code was committed. Production
|
- Do not mark production complete merely because code was committed. Production
|
||||||
is complete only after staging, cutover, acceptance, observation, and backup
|
is complete only after staging, cutover, acceptance, observation, and backup
|
||||||
restore checks pass.
|
restore checks pass.
|
||||||
@@ -63,6 +66,26 @@ Before touching production runtime files on production host:
|
|||||||
SQLite gates.
|
SQLite gates.
|
||||||
- If any gate fails, confirm rollback state before retrying.
|
- If any gate fails, confirm rollback state before retrying.
|
||||||
|
|
||||||
|
## Mandatory Post-Release Settlement
|
||||||
|
|
||||||
|
- After cutover and rollback gates pass, run the installed
|
||||||
|
`jwebot_release_update.py`.
|
||||||
|
- The settlement grants exactly 200 Mora to every account that exists at the
|
||||||
|
start of that release settlement and appends a version-labelled account
|
||||||
|
movement.
|
||||||
|
- Compensation uses the full release string as its idempotency key. Rerunning
|
||||||
|
the same release must not grant Mora twice.
|
||||||
|
- The tool sends one guarded announcement containing the version, the first
|
||||||
|
changelog bullet, and the `Mora x200` compensation notice.
|
||||||
|
- Announcement state is tracked separately from compensation. A missing or
|
||||||
|
uncertain GUI receipt must never roll back a healthy runtime or trigger
|
||||||
|
another grant.
|
||||||
|
- If delivery is uncertain, inspect the configured group before using
|
||||||
|
`--retry-uncertain-announcement`; never retry blindly.
|
||||||
|
- Production is not complete until the settlement reports success, SQLite
|
||||||
|
`quick_check` is `ok`, queues are empty, and service restart counts remain
|
||||||
|
stable.
|
||||||
|
|
||||||
## Backup And Restore
|
## Backup And Restore
|
||||||
|
|
||||||
- Pre-deploy and post-deploy baselines must include runtime files, systemd
|
- Pre-deploy and post-deploy baselines must include runtime files, systemd
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ The deployer prints an eight-stage progress bar and:
|
|||||||
- leaves legacy OCR disabled;
|
- leaves legacy OCR disabled;
|
||||||
- verifies Edge health and adaptive restart stability;
|
- verifies Edge health and adaptive restart stability;
|
||||||
- writes and verifies backup SHA-256 checksums;
|
- writes and verifies backup SHA-256 checksums;
|
||||||
- rolls back files, state, and unit topology on failure.
|
- rolls back files, state, and unit topology on cutover failure;
|
||||||
|
- after rollback is disarmed, grants each existing account 200 Mora, records a
|
||||||
|
version-labelled movement, and sends the guarded group update announcement.
|
||||||
|
|
||||||
|
The first bullet in the current `CHANGELOG.md` release section is the canonical
|
||||||
|
one-sentence announcement summary. Keep it concise and user-facing.
|
||||||
|
|
||||||
## Post-Cutover
|
## Post-Cutover
|
||||||
|
|
||||||
@@ -104,6 +109,28 @@ PY
|
|||||||
Player-visible acceptance must use deliberate synthetic messages in the
|
Player-visible acceptance must use deliberate synthetic messages in the
|
||||||
configured group and confirm exactly one ACK and one final response.
|
configured group and confirm exactly one ACK and one final response.
|
||||||
|
|
||||||
|
The deployer also runs the installed release settlement tool. Its successful
|
||||||
|
JSON result must report the expected account count and announcement delivery.
|
||||||
|
The operation is idempotent by the full release string, so rerunning the same
|
||||||
|
command never grants Mora twice:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/opt/jwebot/venv/bin/python \
|
||||||
|
~/.local/lib/wechat-health-watch/jwebot_release_update.py
|
||||||
|
```
|
||||||
|
|
||||||
|
If it reports `AnnouncementUncertainError`, inspect the configured group first.
|
||||||
|
Only when the announcement is confirmed absent may the operator retry:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/opt/jwebot/venv/bin/python \
|
||||||
|
~/.local/lib/wechat-health-watch/jwebot_release_update.py \
|
||||||
|
--retry-uncertain-announcement
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not repeat the full deployment to repair an uncertain announcement. The
|
||||||
|
runtime cutover and version-keyed compensation may already be complete.
|
||||||
|
|
||||||
## Rollback
|
## Rollback
|
||||||
|
|
||||||
If any gate fails:
|
If any gate fails:
|
||||||
|
|||||||
+2
-2
@@ -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.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ Help:
|
|||||||
|
|
||||||
Registered commands:
|
Registered commands:
|
||||||
|
|
||||||
|
- `/摩拉排行`
|
||||||
|
- `/摩拉排行榜`
|
||||||
- `/金币排行`
|
- `/金币排行`
|
||||||
- `/金币排行榜`
|
- `/金币排行榜`
|
||||||
- `/大富翁榜`
|
- `/大富翁榜`
|
||||||
|
|||||||
@@ -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__":
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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()
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Author: `jwebot`
|
Author: `jwebot`
|
||||||
|
|
||||||
金币转账
|
摩拉转账
|
||||||
|
|
||||||
Help:
|
Help:
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# game-history
|
||||||
|
|
||||||
|
Author: `jwebot`
|
||||||
|
|
||||||
|
游戏记录
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/游戏记录 /个人记录 /gamehistory`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/游戏记录`
|
||||||
|
- `/个人记录`
|
||||||
|
- `/gamehistory`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `game-history` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "game-history"
|
||||||
|
author = "jwebot"
|
||||||
|
description = "游戏记录"
|
||||||
|
help = ["/游戏记录 /个人记录 /gamehistory"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "game_history:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/游戏记录", "/个人记录", "/gamehistory"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["game-history"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for game-history."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'game-history'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '游戏记录'
|
||||||
|
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
|
||||||
|
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
|
||||||
|
|
||||||
|
|
||||||
|
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 game_history 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, '游戏记录')
|
||||||
|
self.assertEqual(HELP_ENTRIES, ('/游戏记录 /个人记录 /gamehistory',))
|
||||||
|
self.assertIn('/游戏记录', registered)
|
||||||
|
self.assertEqual(len(registered), 3)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# primogem-economy
|
||||||
|
|
||||||
|
Author: `jwebot`
|
||||||
|
|
||||||
|
原石系统
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/原石祈愿`
|
||||||
|
- `/祈愿`
|
||||||
|
- `/wish`
|
||||||
|
- `/原石榜`
|
||||||
|
- `/primogem榜`
|
||||||
|
- `/扫码加权`
|
||||||
|
- `/扫码概率`
|
||||||
|
- `/Taffy发话`
|
||||||
|
- `/taffysay`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `primogem-economy` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "primogem-economy"
|
||||||
|
author = "jwebot"
|
||||||
|
description = "原石系统"
|
||||||
|
help = ["/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "primogem_economy:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/原石祈愿", "/祈愿", "/wish", "/原石榜", "/primogem榜", "/扫码加权", "/扫码概率", "/Taffy发话", "/taffysay"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["primogem-economy"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for primogem-economy."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'primogem-economy'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '原石系统'
|
||||||
|
HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',)
|
||||||
|
COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay')
|
||||||
|
|
||||||
|
|
||||||
|
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 primogem_economy 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, '原石系统')
|
||||||
|
self.assertEqual(HELP_ENTRIES, ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',))
|
||||||
|
self.assertIn('/原石祈愿', registered)
|
||||||
|
self.assertEqual(len(registered), 9)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -2,21 +2,18 @@
|
|||||||
|
|
||||||
Author: `jwebot`
|
Author: `jwebot`
|
||||||
|
|
||||||
金币游戏
|
摩拉游戏
|
||||||
|
|
||||||
Help:
|
Help:
|
||||||
|
|
||||||
- `/金币 /wallet;/游戏记录 /gamehistory`
|
- `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助`
|
||||||
- `/抽奖 [金额];/draw`
|
|
||||||
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
|
|
||||||
- `/21点 开始 [1~500];/blackjack start`
|
|
||||||
- `/21点 要牌|停牌|状态|结束;/blackjack`
|
|
||||||
- `/游戏帮助;/games`
|
|
||||||
|
|
||||||
Registered commands:
|
Registered commands:
|
||||||
|
|
||||||
|
- `/摩拉`
|
||||||
- `/金币`
|
- `/金币`
|
||||||
- `/wallet`
|
- `/wallet`
|
||||||
|
- `/原石`
|
||||||
- `/抽奖`
|
- `/抽奖`
|
||||||
- `/draw`
|
- `/draw`
|
||||||
- `/彩票`
|
- `/彩票`
|
||||||
@@ -25,9 +22,6 @@ Registered commands:
|
|||||||
- `/21点`
|
- `/21点`
|
||||||
- `/二十一点`
|
- `/二十一点`
|
||||||
- `/blackjack`
|
- `/blackjack`
|
||||||
- `/游戏记录`
|
|
||||||
- `/个人记录`
|
|
||||||
- `/gamehistory`
|
|
||||||
- `/游戏帮助`
|
- `/游戏帮助`
|
||||||
- `/games`
|
- `/games`
|
||||||
|
|
||||||
|
|||||||
@@ -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 = ["/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助"]
|
||||||
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", "/游戏帮助", "/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 = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)
|
||||||
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
|
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/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, ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',))
|
||||||
self.assertIn('/金币', registered)
|
self.assertIn('/摩拉', registered)
|
||||||
self.assertEqual(len(registered), 15)
|
self.assertEqual(len(registered), 14)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# trivia-quiz
|
||||||
|
|
||||||
|
Author: `jwebot`
|
||||||
|
|
||||||
|
冷门知识问答
|
||||||
|
|
||||||
|
Help:
|
||||||
|
|
||||||
|
- `/回答 A;/answer A`
|
||||||
|
|
||||||
|
Registered commands:
|
||||||
|
|
||||||
|
- `/回答`
|
||||||
|
- `/answer`
|
||||||
|
|
||||||
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
It delegates to the reviewed `trivia-quiz` Kernel service profile
|
||||||
|
and returns a structured `ReplyPlan`.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
id = "trivia-quiz"
|
||||||
|
author = "jwebot"
|
||||||
|
description = "冷门知识问答"
|
||||||
|
help = ["/回答 A;/answer A"]
|
||||||
|
version = "1.0.0"
|
||||||
|
api_version = "1"
|
||||||
|
entrypoint = "trivia_quiz:plugin"
|
||||||
|
type = "command"
|
||||||
|
|
||||||
|
[capabilities]
|
||||||
|
commands = ["/回答", "/answer"]
|
||||||
|
storage = false
|
||||||
|
network_profiles = []
|
||||||
|
service_profiles = ["trivia-quiz"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for trivia-quiz."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'trivia-quiz'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '冷门知识问答'
|
||||||
|
HELP_ENTRIES = ('/回答 A;/answer A',)
|
||||||
|
COMMANDS = ('/回答', '/answer')
|
||||||
|
|
||||||
|
|
||||||
|
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 trivia_quiz 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, '冷门知识问答')
|
||||||
|
self.assertEqual(HELP_ENTRIES, ('/回答 A;/answer A',))
|
||||||
|
self.assertIn('/回答', registered)
|
||||||
|
self.assertEqual(len(registered), 2)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.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)
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ JWeBot 版本
|
|||||||
|
|
||||||
Help:
|
Help:
|
||||||
|
|
||||||
- `/version`
|
- `/version /版本`
|
||||||
|
|
||||||
Registered commands:
|
Registered commands:
|
||||||
|
|
||||||
|
- `/版本`
|
||||||
- `/version`
|
- `/version`
|
||||||
|
|
||||||
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
The adapter has no direct Ledger, GUI, environment, shell, or network access.
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
id = "jwebot-version"
|
id = "jwebot-version"
|
||||||
author = "official"
|
author = "official"
|
||||||
description = "JWeBot 版本"
|
description = "JWeBot 版本"
|
||||||
help = ["/version"]
|
help = ["/version /版本"]
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
api_version = "1"
|
api_version = "1"
|
||||||
entrypoint = "jwebot_version:plugin"
|
entrypoint = "jwebot_version:plugin"
|
||||||
type = "command"
|
type = "command"
|
||||||
|
|
||||||
[capabilities]
|
[capabilities]
|
||||||
commands = ["/version"]
|
commands = ["/版本", "/version"]
|
||||||
storage = false
|
storage = false
|
||||||
network_profiles = []
|
network_profiles = []
|
||||||
service_profiles = ["jwebot-version"]
|
service_profiles = ["jwebot-version"]
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
|||||||
PROFILE = 'jwebot-version'
|
PROFILE = 'jwebot-version'
|
||||||
AUTHOR = 'official'
|
AUTHOR = 'official'
|
||||||
DESCRIPTION = 'JWeBot 版本'
|
DESCRIPTION = 'JWeBot 版本'
|
||||||
HELP_ENTRIES = ('/version',)
|
HELP_ENTRIES = ('/version /版本',)
|
||||||
COMMANDS = ('/version',)
|
COMMANDS = ('/版本', '/version')
|
||||||
|
|
||||||
|
|
||||||
def _handler(context: SingleGroupContext, command: str):
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
|
|||||||
]
|
]
|
||||||
self.assertEqual(AUTHOR, 'official')
|
self.assertEqual(AUTHOR, 'official')
|
||||||
self.assertEqual(DESCRIPTION, 'JWeBot 版本')
|
self.assertEqual(DESCRIPTION, 'JWeBot 版本')
|
||||||
self.assertEqual(HELP_ENTRIES, ('/version',))
|
self.assertEqual(HELP_ENTRIES, ('/version /版本',))
|
||||||
self.assertIn('/version', registered)
|
self.assertIn('/版本', registered)
|
||||||
self.assertEqual(len(registered), 1)
|
self.assertEqual(len(registered), 2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Author: `official`
|
Author: `official`
|
||||||
|
|
||||||
查询当前群已配置人物、基本属性和别名
|
人物索引
|
||||||
|
|
||||||
Help:
|
Help:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id = "person-directory"
|
id = "person-directory"
|
||||||
author = "official"
|
author = "official"
|
||||||
description = "查询当前群已配置人物、基本属性和别名"
|
description = "人物索引"
|
||||||
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
|
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
|
||||||
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 = 'person-directory'
|
PROFILE = 'person-directory'
|
||||||
AUTHOR = 'official'
|
AUTHOR = 'official'
|
||||||
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
|
DESCRIPTION = '人物索引'
|
||||||
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
||||||
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
||||||
|
|
||||||
|
|||||||
@@ -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, '查询当前群已配置人物、基本属性和别名')
|
self.assertEqual(DESCRIPTION, '人物索引')
|
||||||
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
|
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
|
||||||
self.assertIn('/人物列表', registered)
|
self.assertIn('/人物列表', registered)
|
||||||
self.assertEqual(len(registered), 6)
|
self.assertEqual(len(registered), 6)
|
||||||
|
|||||||
@@ -0,0 +1,712 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Apply the mandatory release lifecycle notices, grant, and 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,
|
||||||
|
append_primogem_event,
|
||||||
|
normalize_player_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
COMPENSATION_AMOUNT = 200
|
||||||
|
PRIMOGEM_COMPENSATION_RELEASES = {
|
||||||
|
"beta 1.0.5.1016": 1,
|
||||||
|
"beta 1.1.0.1016": 1,
|
||||||
|
}
|
||||||
|
MAX_BALANCE = 1_000_000_000
|
||||||
|
MAX_SUMMARY_CHARS = 300
|
||||||
|
MAX_ANNOUNCEMENT_CHARS = 900
|
||||||
|
MAX_LIFECYCLE_CHARS = 300
|
||||||
|
LIFECYCLE_STAGES = frozenset({"shutdown", "startup"})
|
||||||
|
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+\d{2}:\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:
|
||||||
|
primogems = primogem_compensation_amount(release)
|
||||||
|
extra = f",原石 x{primogems}" if primogems else ""
|
||||||
|
announcement = (
|
||||||
|
"亲爱的群友:\n"
|
||||||
|
f"{release} 更新现已完成。\n"
|
||||||
|
f"更新内容:{summary}\n"
|
||||||
|
f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}{extra}\n"
|
||||||
|
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
|
||||||
|
"发放方式:补偿已直接到账,无需领取\n"
|
||||||
|
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
|
||||||
|
)
|
||||||
|
if len(announcement) > MAX_ANNOUNCEMENT_CHARS:
|
||||||
|
raise ReleaseUpdateError("release announcement is too long")
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
|
||||||
|
def build_lifecycle_message(release: str, stage: str) -> str:
|
||||||
|
if stage == "shutdown":
|
||||||
|
message = (
|
||||||
|
f"Taffy 准备停机更新到 {release},会短暂离线一下;"
|
||||||
|
"别慌,我很快回来喵"
|
||||||
|
)
|
||||||
|
elif stage == "startup":
|
||||||
|
message = (
|
||||||
|
f"Taffy 刚上线,正在做 {release} 的最后自检;"
|
||||||
|
"确认就绪后会发送更新内容和补偿说明喵"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
|
||||||
|
if len(message) > MAX_LIFECYCLE_CHARS:
|
||||||
|
raise ReleaseUpdateError("release lifecycle message is too long")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def compensation_key(release: str) -> str:
|
||||||
|
return f"release_compensation:{release}"
|
||||||
|
|
||||||
|
|
||||||
|
def primogem_compensation_amount(release: str) -> int:
|
||||||
|
return int(PRIMOGEM_COMPENSATION_RELEASES.get(release, 0))
|
||||||
|
|
||||||
|
|
||||||
|
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 release_groups(database_path: Path) -> list[str]:
|
||||||
|
connection = sqlite3.connect(f"file:{database_path}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
||||||
|
raise ReleaseUpdateError("Edge SQLite integrity check failed")
|
||||||
|
rows = connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT group_fingerprint
|
||||||
|
FROM player_accounts
|
||||||
|
ORDER BY group_fingerprint
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
return [str(row[0]) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def checked_marker(raw: str, release: str) -> dict[str, Any]:
|
||||||
|
expected_primogems = primogem_compensation_amount(release)
|
||||||
|
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 marker.get("mora_amount") not in {None, COMPENSATION_AMOUNT}
|
||||||
|
or marker.get("primogem_amount") not in {None, expected_primogems}
|
||||||
|
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
|
||||||
|
old_primogem_sum = 0
|
||||||
|
primogem_amount = primogem_compensation_amount(release)
|
||||||
|
timestamp = isoformat_utc(applied_at)
|
||||||
|
event_reason = f"{release} 版本更新补偿"
|
||||||
|
primogem_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
|
||||||
|
old_primogem_sum += int(state["primogems"])
|
||||||
|
state["balance"] = balance + COMPENSATION_AMOUNT
|
||||||
|
state["primogems"] += primogem_amount
|
||||||
|
append_coin_event(state, COMPENSATION_AMOUNT, event_reason)
|
||||||
|
state["coin_events"][-1]["created_at"] = applied_at.timestamp()
|
||||||
|
if primogem_amount:
|
||||||
|
append_primogem_event(
|
||||||
|
state,
|
||||||
|
primogem_amount,
|
||||||
|
primogem_reason,
|
||||||
|
)
|
||||||
|
state["primogem_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,
|
||||||
|
"mora_amount": COMPENSATION_AMOUNT,
|
||||||
|
"primogem_amount": primogem_amount,
|
||||||
|
"accounts": len(rows),
|
||||||
|
"group_fingerprints": groups,
|
||||||
|
"old_balance_sum": old_balance_sum,
|
||||||
|
"new_balance_sum": (
|
||||||
|
old_balance_sum + COMPENSATION_AMOUNT * len(rows)
|
||||||
|
),
|
||||||
|
"old_primogem_sum": old_primogem_sum,
|
||||||
|
"new_primogem_sum": (
|
||||||
|
old_primogem_sum + primogem_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,
|
||||||
|
"primogems_each": primogem_compensation_amount(release),
|
||||||
|
"accounts": int(marker["accounts"]),
|
||||||
|
"groups": len(groups),
|
||||||
|
"announcements_delivered": announcement_result["delivered"],
|
||||||
|
"announcements_skipped": announcement_result["skipped"],
|
||||||
|
"backup": str(marker["backup"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_release_lifecycle(
|
||||||
|
*,
|
||||||
|
release_file: Path,
|
||||||
|
database_path: Path,
|
||||||
|
delivery_helper: Path,
|
||||||
|
stage: str,
|
||||||
|
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if stage not in LIFECYCLE_STAGES:
|
||||||
|
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
|
||||||
|
release = read_release(release_file)
|
||||||
|
message = build_lifecycle_message(release, stage)
|
||||||
|
groups = release_groups(database_path)
|
||||||
|
delivered = 0
|
||||||
|
for group in groups:
|
||||||
|
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
|
||||||
|
event_id = f"release-{release_slug(release)}-{stage}-{group_hash}"
|
||||||
|
deliverer(delivery_helper, group, message, event_id)
|
||||||
|
delivered += 1
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"release": release,
|
||||||
|
"stage": stage,
|
||||||
|
"groups": len(groups),
|
||||||
|
"delivered": delivered,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--lifecycle-stage",
|
||||||
|
choices=sorted(LIFECYCLE_STAGES),
|
||||||
|
help="send a guarded lifecycle notice instead of release settlement",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
if args.lifecycle_stage:
|
||||||
|
result = run_release_lifecycle(
|
||||||
|
release_file=args.release_file,
|
||||||
|
database_path=args.database,
|
||||||
|
delivery_helper=args.delivery_helper,
|
||||||
|
stage=args.lifecycle_stage,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
result = run_release_update(
|
||||||
|
release_file=args.release_file,
|
||||||
|
changelog=args.changelog,
|
||||||
|
database_path=args.database,
|
||||||
|
backup_dir=args.backup_dir,
|
||||||
|
delivery_helper=args.delivery_helper,
|
||||||
|
retry_uncertain=args.retry_uncertain_announcement,
|
||||||
|
)
|
||||||
|
except ReleaseUpdateError as error:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error_type": type(error).__name__,
|
||||||
|
"message": str(error),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-config"
|
name = "jwebot-config"
|
||||||
version = "1.0.0b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019"
|
||||||
|
|||||||
@@ -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.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
"jwebot-kernel==1.0.0b1+1016",
|
"jwebot-kernel==1.1.2b1+1019",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ __all__ = [
|
|||||||
"process_request",
|
"process_request",
|
||||||
]
|
]
|
||||||
|
|
||||||
__version__ = "1.0.0b1+1016"
|
__version__ = "1.1.2b1+1019"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-kernel"
|
name = "jwebot-kernel"
|
||||||
version = "1.0.0b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
"jwebot-sdk==1.0.0b1+1016",
|
"jwebot-sdk==1.1.2b1+1019",
|
||||||
"jwebot-official-extensions==1.0.0b1+1016",
|
"jwebot-official-extensions==1.1.2b1+1019",
|
||||||
"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.1.2b1+1019"
|
||||||
|
|||||||
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.1.2b1+1019"
|
||||||
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.1.2b1+1019",
|
||||||
]
|
]
|
||||||
|
|
||||||
[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.1.2b1+1019"
|
||||||
|
|||||||
+1
-1
@@ -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):
|
||||||
|
|||||||
+45
@@ -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))
|
||||||
+1
-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')
|
||||||
|
|
||||||
|
|||||||
+1
-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')
|
||||||
|
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for game-history."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'game-history'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '游戏记录'
|
||||||
|
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
|
||||||
|
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
+2
-2
@@ -6,8 +6,8 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
|||||||
PROFILE = 'jwebot-version'
|
PROFILE = 'jwebot-version'
|
||||||
AUTHOR = 'official'
|
AUTHOR = 'official'
|
||||||
DESCRIPTION = 'JWeBot 版本'
|
DESCRIPTION = 'JWeBot 版本'
|
||||||
HELP_ENTRIES = ('/version',)
|
HELP_ENTRIES = ('/version /版本',)
|
||||||
COMMANDS = ('/version',)
|
COMMANDS = ('/版本', '/version')
|
||||||
|
|
||||||
|
|
||||||
def _handler(context: SingleGroupContext, command: str):
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
|||||||
|
|
||||||
PROFILE = 'person-directory'
|
PROFILE = 'person-directory'
|
||||||
AUTHOR = 'official'
|
AUTHOR = 'official'
|
||||||
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
|
DESCRIPTION = '人物索引'
|
||||||
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
|
||||||
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
|
||||||
|
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for primogem-economy."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'primogem-economy'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '原石系统'
|
||||||
|
HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',)
|
||||||
|
COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay')
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
+3
-3
@@ -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 = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',)
|
||||||
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
|
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
|
||||||
|
|
||||||
|
|
||||||
def _handler(context: SingleGroupContext, command: str):
|
def _handler(context: SingleGroupContext, command: str):
|
||||||
|
|||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
"""Generated reviewed adapter for trivia-quiz."""
|
||||||
|
|
||||||
|
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
|
||||||
|
|
||||||
|
|
||||||
|
PROFILE = 'trivia-quiz'
|
||||||
|
AUTHOR = 'jwebot'
|
||||||
|
DESCRIPTION = '冷门知识问答'
|
||||||
|
HELP_ENTRIES = ('/回答 A;/answer A',)
|
||||||
|
COMMANDS = ('/回答', '/answer')
|
||||||
|
|
||||||
|
|
||||||
|
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))
|
||||||
+14
-14
@@ -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", []):
|
||||||
|
|||||||
+4
-4
@@ -11,7 +11,7 @@
|
|||||||
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
|
"队伍如果阵容完整,就更容易把这次插曲处理成奖金。",
|
||||||
"这次挑战只出现一次,处理完就回到小组赛主线。",
|
"这次挑战只出现一次,处理完就回到小组赛主线。",
|
||||||
"主舞台彩排占用了热身时间,教练的管理能力开始生效。",
|
"主舞台彩排占用了热身时间,教练的管理能力开始生效。",
|
||||||
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿金币。",
|
"赞助商要求展示新战术,选错方向也不会淘汰,只会少拿摩拉。",
|
||||||
"现场采访提到了前一场失利,队员心态需要稳住。",
|
"现场采访提到了前一场失利,队员心态需要稳住。",
|
||||||
"这类挑战不扣生命,目的只是给运营阶段增加变数。",
|
"这类挑战不扣生命,目的只是给运营阶段增加变数。",
|
||||||
"处理好这次插曲的队伍,会在下一场前多一点经济余地。"
|
"处理好这次插曲的队伍,会在下一场前多一点经济余地。"
|
||||||
@@ -141,21 +141,21 @@
|
|||||||
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。"
|
"{team} 这波 {count} 连分让地图气质变了,所有枪位都开始往前压。"
|
||||||
],
|
],
|
||||||
"training_hooks": [
|
"training_hooks": [
|
||||||
"训练赛不计入小组胜负,但会发金币,方便后续刷新商店。",
|
"训练赛不计入小组胜负,但会发摩拉,方便后续刷新商店。",
|
||||||
"这轮训练更像实战热身,每队都会遇到同场参赛对手。",
|
"这轮训练更像实战热身,每队都会遇到同场参赛对手。",
|
||||||
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
|
"训练赛重点不是淘汰谁,而是检验当前阵容能不能打完整张图。",
|
||||||
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
|
"这轮结束后可以 D 牌、锁店或调首发,下一轮再检验成果。",
|
||||||
"AI 战队也会参与训练,对局结果会写入档案。",
|
"AI 战队也会参与训练,对局结果会写入档案。",
|
||||||
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
|
"如果阵容缺少指挥,训练赛的中期回合会更容易失控。",
|
||||||
"如果你想追羁绊,现在是最适合刷新商店的窗口。",
|
"如果你想追羁绊,现在是最适合刷新商店的窗口。",
|
||||||
"训练赛的金币很重要,别把每次刷新都当成免费试错。",
|
"训练赛的摩拉很重要,别把每次刷新都当成免费试错。",
|
||||||
"这一阶段没有生命扣除,重点是运营和阵容成型。",
|
"这一阶段没有生命扣除,重点是运营和阵容成型。",
|
||||||
"训练赛的地图会轮换,不会一直用同一个固定比分模板。",
|
"训练赛的地图会轮换,不会一直用同一个固定比分模板。",
|
||||||
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
|
"每轮训练结束后,真人都需要发送 /结束回合 才会进入下一轮。",
|
||||||
"训练赛会按轮次播报,避免一次性刷出一大串结果。",
|
"训练赛会按轮次播报,避免一次性刷出一大串结果。",
|
||||||
"如果出现轮空,会安排本地陪练队补一张 BO1。",
|
"如果出现轮空,会安排本地陪练队补一张 BO1。",
|
||||||
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
|
"训练阶段适合观察战术克制,火力、稳定、残局都会影响回合走向。",
|
||||||
"连胜会带来金币,但失败也有保底,保证测试局能继续运营。",
|
"连胜会带来摩拉,但失败也有保底,保证测试局能继续运营。",
|
||||||
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。"
|
"这部分是纯文字自走棋的节奏层,不再是单条目标数值判定。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,18 +34,37 @@ LOCAL_COMMAND_ALIASES = {
|
|||||||
"历史搜索": "history_search",
|
"历史搜索": "history_search",
|
||||||
"状态": "local_status",
|
"状态": "local_status",
|
||||||
"status": "server_status",
|
"status": "server_status",
|
||||||
|
"版本": "jwebot_version",
|
||||||
"version": "jwebot_version",
|
"version": "jwebot_version",
|
||||||
"debug": "telemetry_debug",
|
"debug": "telemetry_debug",
|
||||||
"余额": "deepseek_balance",
|
"余额": "deepseek_balance",
|
||||||
"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",
|
||||||
|
"原石": "wallet",
|
||||||
|
"原石祈愿": "primogem_wish",
|
||||||
|
"祈愿": "primogem_wish",
|
||||||
|
"wish": "primogem_wish",
|
||||||
|
"原石榜": "primogem_ranking",
|
||||||
|
"primogem榜": "primogem_ranking",
|
||||||
|
"扫码加权": "scan_boost",
|
||||||
|
"扫码概率": "scan_boost",
|
||||||
|
"Taffy发话": "taffy_say",
|
||||||
|
"taffysay": "taffy_say",
|
||||||
|
"回答": "trivia_answer",
|
||||||
|
"answer": "trivia_answer",
|
||||||
"转账": "transfer",
|
"转账": "transfer",
|
||||||
"transfer": "transfer",
|
"transfer": "transfer",
|
||||||
"贷款": "loan",
|
"贷款": "loan",
|
||||||
@@ -143,35 +162,36 @@ 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",
|
"/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助",
|
||||||
"/抽奖 [金额];/draw",
|
|
||||||
"/彩票 开始|报名|状态|开奖|取消;/lottery",
|
|
||||||
"/21点 开始 [1~500];/blackjack start",
|
|
||||||
"/21点 要牌|停牌|状态|结束;/blackjack",
|
|
||||||
"/游戏帮助;/games",
|
|
||||||
),
|
),
|
||||||
command_kinds=(
|
command_kinds=(
|
||||||
"wallet",
|
"wallet",
|
||||||
"prize_draw",
|
"prize_draw",
|
||||||
"lottery",
|
"lottery",
|
||||||
"blackjack",
|
"blackjack",
|
||||||
"game_history",
|
|
||||||
"game_rules",
|
"game_rules",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="game-history",
|
||||||
|
author=JWEBOT_AUTHOR,
|
||||||
|
description="游戏记录",
|
||||||
|
help_entries=("/游戏记录 /个人记录 /gamehistory",),
|
||||||
|
command_kinds=("game_history",),
|
||||||
|
),
|
||||||
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 +202,27 @@ 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(
|
||||||
|
extension_id="primogem-economy",
|
||||||
|
author=JWEBOT_AUTHOR,
|
||||||
|
description="原石系统",
|
||||||
|
help_entries=(
|
||||||
|
"/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容",
|
||||||
|
),
|
||||||
|
command_kinds=(
|
||||||
|
"primogem_wish",
|
||||||
|
"primogem_ranking",
|
||||||
|
"scan_boost",
|
||||||
|
"taffy_say",
|
||||||
|
),
|
||||||
|
),
|
||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
extension_id="coin-loan",
|
extension_id="coin-loan",
|
||||||
author=JWEBOT_AUTHOR,
|
author=JWEBOT_AUTHOR,
|
||||||
@@ -189,6 +230,13 @@ LOCAL_EXTENSIONS = (
|
|||||||
help_entries=("/贷款;/还款",),
|
help_entries=("/贷款;/还款",),
|
||||||
command_kinds=("loan", "repay"),
|
command_kinds=("loan", "repay"),
|
||||||
),
|
),
|
||||||
|
LocalExtensionDescriptor(
|
||||||
|
extension_id="trivia-quiz",
|
||||||
|
author=JWEBOT_AUTHOR,
|
||||||
|
description="冷门知识问答",
|
||||||
|
help_entries=("/回答 A;/answer A",),
|
||||||
|
command_kinds=("trivia_answer",),
|
||||||
|
),
|
||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
extension_id="cs-major-autochess",
|
extension_id="cs-major-autochess",
|
||||||
author=JWEBOT_AUTHOR,
|
author=JWEBOT_AUTHOR,
|
||||||
@@ -201,7 +249,7 @@ LOCAL_EXTENSIONS = (
|
|||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
extension_id="person-directory",
|
extension_id="person-directory",
|
||||||
author=OFFICIAL_AUTHOR,
|
author=OFFICIAL_AUTHOR,
|
||||||
description="查询当前群已配置人物、基本属性和别名",
|
description="人物索引",
|
||||||
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
|
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
|
||||||
command_kinds=("person_directory", "person_info"),
|
command_kinds=("person_directory", "person_info"),
|
||||||
),
|
),
|
||||||
@@ -232,7 +280,7 @@ LOCAL_EXTENSIONS = (
|
|||||||
extension_id="jwebot-version",
|
extension_id="jwebot-version",
|
||||||
author=OFFICIAL_AUTHOR,
|
author=OFFICIAL_AUTHOR,
|
||||||
description="JWeBot 版本",
|
description="JWeBot 版本",
|
||||||
help_entries=("/version",),
|
help_entries=("/version /版本",),
|
||||||
command_kinds=("jwebot_version",),
|
command_kinds=("jwebot_version",),
|
||||||
),
|
),
|
||||||
LocalExtensionDescriptor(
|
LocalExtensionDescriptor(
|
||||||
|
|||||||
+39
-6
@@ -27,7 +27,9 @@ BEIJING_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
|||||||
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
DICE_PATTERN = re.compile(r"^(?:(\d{1,2})[dD](\d{1,3}))?$")
|
||||||
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
RANDOM_NUMBER_PATTERN = re.compile(r"^(-?\d{1,6})\s+(-?\d{1,6})$")
|
||||||
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
|
MULTI_REPLY_MARKER = "\x1eJWEBOT_MULTI_REPLY_V1\x1e"
|
||||||
CHANGELOG_HEADING = re.compile(r"^##\s+(.+?)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$")
|
CHANGELOG_HEADING = re.compile(
|
||||||
|
r"^##\s+(.+?)(?:\s+-\s+(\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?))?\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LocalCommandError(RuntimeError):
|
class LocalCommandError(RuntimeError):
|
||||||
@@ -157,6 +159,7 @@ class LocalCommandEngine:
|
|||||||
if kind in {
|
if kind in {
|
||||||
"wallet",
|
"wallet",
|
||||||
"coin_ranking",
|
"coin_ranking",
|
||||||
|
"taffy_coins",
|
||||||
"transfer",
|
"transfer",
|
||||||
"loan",
|
"loan",
|
||||||
"repay",
|
"repay",
|
||||||
@@ -165,6 +168,10 @@ class LocalCommandEngine:
|
|||||||
"blackjack",
|
"blackjack",
|
||||||
"game_history",
|
"game_history",
|
||||||
"game_rules",
|
"game_rules",
|
||||||
|
"primogem_wish",
|
||||||
|
"primogem_ranking",
|
||||||
|
"scan_boost",
|
||||||
|
"taffy_say",
|
||||||
}:
|
}:
|
||||||
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
return self.reply_cleaner(self.players.handle(row, kind, arguments))
|
||||||
if kind == "cs_autochess":
|
if kind == "cs_autochess":
|
||||||
@@ -215,6 +222,8 @@ class LocalCommandEngine:
|
|||||||
return self.reply_cleaner(self.deepseek_balance())
|
return self.reply_cleaner(self.deepseek_balance())
|
||||||
if kind == "steam_friends":
|
if kind == "steam_friends":
|
||||||
return self.reply_cleaner(self.steam_friends(arguments))
|
return self.reply_cleaner(self.steam_friends(arguments))
|
||||||
|
if kind == "trivia_answer":
|
||||||
|
return self.reply_cleaner(self.trivia_answer(row, arguments))
|
||||||
raise LocalCommandError("unsupported local command")
|
raise LocalCommandError("unsupported local command")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -239,17 +248,28 @@ class LocalCommandEngine:
|
|||||||
|
|
||||||
def scan_ranking(self, row: dict[str, Any]) -> str:
|
def scan_ranking(self, row: dict[str, Any]) -> str:
|
||||||
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
local_date = datetime.now(BEIJING_TIMEZONE).date().isoformat()
|
||||||
ranking = self.ledger.clock_scan_ranking(
|
group = str(row["group_fingerprint"])
|
||||||
str(row["group_fingerprint"]),
|
ranking = self.ledger.clock_scan_ranking_all(
|
||||||
|
group,
|
||||||
local_date,
|
local_date,
|
||||||
)
|
)
|
||||||
|
weights = self.ledger.next_clock_scan_weights(
|
||||||
|
str(row["group_fingerprint"]),
|
||||||
|
)
|
||||||
if not ranking:
|
if not ranking:
|
||||||
return "北京时间今天还没有已确认送达的扫码记录"
|
return "北京时间今天还没有可展示的扫码候选"
|
||||||
listing = ";".join(
|
listing = ";".join(
|
||||||
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
f"{index}. {entry['display_name']} {entry['scan_count']} 次"
|
||||||
for index, entry in enumerate(ranking, start=1)
|
for index, entry in enumerate(ranking, start=1)
|
||||||
)
|
)
|
||||||
return f"北京时间今日扫码排行:{listing}"
|
weight_listing = ";".join(
|
||||||
|
f"{entry['display_name']} {entry['weight']}({entry['share']:.1f}%)"
|
||||||
|
for entry in weights
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"北京时间今日扫码排行:{listing}\n"
|
||||||
|
f"下一小时扫码权重:{weight_listing}"
|
||||||
|
)
|
||||||
|
|
||||||
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
def repeat_ranking(self, row: dict[str, Any]) -> str:
|
||||||
self.ledger.bootstrap_repeat_history()
|
self.ledger.bootstrap_repeat_history()
|
||||||
@@ -364,7 +384,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:
|
||||||
@@ -432,6 +452,8 @@ class LocalCommandEngine:
|
|||||||
continue
|
continue
|
||||||
version = match.group(1).strip()
|
version = match.group(1).strip()
|
||||||
updated_at = (match.group(2) or "").strip()
|
updated_at = (match.group(2) or "").strip()
|
||||||
|
if re.fullmatch(r"\d{4}-\d{2}-\d{2}", updated_at):
|
||||||
|
updated_at = f"{updated_at} 00:00:00"
|
||||||
bullet_parts: list[str] = []
|
bullet_parts: list[str] = []
|
||||||
collecting = False
|
collecting = False
|
||||||
for item in lines[index + 1 :]:
|
for item in lines[index + 1 :]:
|
||||||
@@ -603,6 +625,17 @@ class LocalCommandEngine:
|
|||||||
return "Steam 查询当前不可用"
|
return "Steam 查询当前不可用"
|
||||||
return self.steam.render(arguments)
|
return self.steam.render(arguments)
|
||||||
|
|
||||||
|
def trivia_answer(self, row: dict[str, Any], arguments: str) -> str:
|
||||||
|
answer = normalize_text(arguments)
|
||||||
|
if not answer:
|
||||||
|
return "用法:/回答 A、/回答 B、/回答 C 或 /回答 D"
|
||||||
|
return self.ledger.answer_trivia(
|
||||||
|
str(row["group_fingerprint"]),
|
||||||
|
str(row["sender"]),
|
||||||
|
str(row["event_id"]),
|
||||||
|
answer,
|
||||||
|
)
|
||||||
|
|
||||||
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
def profile_for_row(self, row: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
person = self.ledger.resolve_person(
|
person = self.ledger.resolve_person(
|
||||||
str(row["group_fingerprint"]),
|
str(row["group_fingerprint"]),
|
||||||
|
|||||||
+1203
-50
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "jwebot-sdk"
|
name = "jwebot-sdk"
|
||||||
version = "1.0.0b1+1016"
|
version = "1.1.2b1+1019"
|
||||||
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.1.2b1+1019"
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[tool.jwebot]
|
[tool.jwebot]
|
||||||
release = "beta 1.0.0.1016"
|
release = "beta 1.1.2.1019"
|
||||||
production_deployments_enabled_by_default = false
|
production_deployments_enabled_by_default = false
|
||||||
|
|
||||||
[tool.jwebot.workspace]
|
[tool.jwebot.workspace]
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
"wechat_adaptive_gui_bridge.py",
|
"wechat_adaptive_gui_bridge.py",
|
||||||
"jwebot_edge.py",
|
"jwebot_edge.py",
|
||||||
"jwebot_edge_delivery.py",
|
"jwebot_edge_delivery.py",
|
||||||
|
"jwebot_release_update.py",
|
||||||
"jwebot_config",
|
"jwebot_config",
|
||||||
"jwebot_sdk",
|
"jwebot_sdk",
|
||||||
"jwebot_kernel",
|
"jwebot_kernel",
|
||||||
@@ -126,6 +127,13 @@ 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"',
|
||||||
|
'send_release_lifecycle_notice shutdown',
|
||||||
|
'send_release_lifecycle_notice startup',
|
||||||
|
'jwebot_log "release lifecycle shutdown notice ready"',
|
||||||
|
'jwebot_log "release lifecycle startup notice ready"',
|
||||||
|
'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 +146,24 @@ class DeploymentScriptTests(unittest.TestCase):
|
|||||||
source.count("xdotool"),
|
source.count("xdotool"),
|
||||||
source.count("/usr/bin/xdotool"),
|
source.count("/usr/bin/xdotool"),
|
||||||
)
|
)
|
||||||
|
cutover_index = source.index('jwebot_log "cutover starting"')
|
||||||
|
actual_stop_index = source.index("user_systemctl stop", cutover_index)
|
||||||
|
self.assertLess(
|
||||||
|
source.index('jwebot_log "release lifecycle shutdown notice ready"'),
|
||||||
|
actual_stop_index,
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
source.index("trap - EXIT HUP INT TERM"),
|
||||||
|
source.index('jwebot_log "release settlement starting"'),
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
source.index('jwebot_log "release lifecycle startup notice ready"'),
|
||||||
|
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")
|
||||||
|
|||||||
+412
-3
@@ -8,6 +8,7 @@ import sqlite3
|
|||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -179,6 +180,10 @@ class RoutingTests(unittest.TestCase):
|
|||||||
MODULE.classify_question("/version"),
|
MODULE.classify_question("/version"),
|
||||||
("local", "local_jwebot_version"),
|
("local", "local_jwebot_version"),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
MODULE.classify_question("/版本"),
|
||||||
|
("local", "local_jwebot_version"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_unknown_slash_command_stays_local(self):
|
def test_unknown_slash_command_stays_local(self):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -677,6 +682,59 @@ 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_resource_mutation_semantic_duplicate_uses_longer_window(self):
|
||||||
|
first = self.application.accept(
|
||||||
|
{
|
||||||
|
**event("event-1", "@JWeBot /原石祈愿"),
|
||||||
|
"source": "rapidocr-local",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
old_timestamp = (
|
||||||
|
MODULE.datetime.now(MODULE.timezone.utc)
|
||||||
|
- MODULE.timedelta(seconds=30)
|
||||||
|
).isoformat(timespec="milliseconds")
|
||||||
|
with self.application.ledger.lock, self.application.ledger.connection:
|
||||||
|
self.application.ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
UPDATE events
|
||||||
|
SET created_at = ?, updated_at = ?
|
||||||
|
WHERE event_id = ?
|
||||||
|
""",
|
||||||
|
(old_timestamp, old_timestamp, "event-1"),
|
||||||
|
)
|
||||||
|
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 +925,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.1.2.1019", version["text"])
|
||||||
self.assertIn("更新时间:2026-10-16", version["text"])
|
self.assertIn("更新时间:2026-10-19 16:38:10", 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")
|
||||||
@@ -1075,6 +1133,223 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertIn(ranking[0]["display_name"], daily_delivery["result_text"])
|
self.assertIn(ranking[0]["display_name"], daily_delivery["result_text"])
|
||||||
self.application.deepseek.complete.assert_not_called()
|
self.application.deepseek.complete.assert_not_called()
|
||||||
|
|
||||||
|
def test_low_frequency_ambient_chat_uses_recent_context(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for index, (sender, text) in enumerate(
|
||||||
|
(
|
||||||
|
("Alice", "今天这个加班安排是不是有点离谱"),
|
||||||
|
("Bob", "我也觉得还要继续修 bug"),
|
||||||
|
("Alice", "先把问题处理完再说"),
|
||||||
|
("Bob", "感觉咖啡已经救不了我了"),
|
||||||
|
),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"ambient-observation-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
ledger.record_repeat_history(
|
||||||
|
event_id=f"ambient-context-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 0), \
|
||||||
|
mock.patch.object(MODULE, "AMBIENT_CHAT_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("加班味", delivery["result_text"])
|
||||||
|
row = ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT kind, state FROM clock_broadcasts
|
||||||
|
WHERE group_fingerprint = ? AND slot_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", "hourly:2026-10-13T13"),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(row["kind"], "ambient_chat")
|
||||||
|
self.assertEqual(row["state"], "delivered")
|
||||||
|
self.assertFalse(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_trivia_quiz_broadcast_and_answer_rewards_mora(self):
|
||||||
|
self.assertGreater(len(MODULE.trivia_question_bank()), 200)
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
42,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for sender in ("Alice", "Bob"):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"trivia-member-{sender}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="冷门题准备一下",
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("冷门知识问答", delivery["result_text"])
|
||||||
|
self.assertIn("限时5分钟", delivery["result_text"])
|
||||||
|
self.assertIn("/回答 A", delivery["result_text"])
|
||||||
|
state = ledger.local_game("group-a", MODULE.TRIVIA_GAME_KEY)
|
||||||
|
self.assertIsNotNone(state)
|
||||||
|
assert state is not None
|
||||||
|
state["expires_at"] = time.time() + 300
|
||||||
|
ledger.save_local_game("group-a", MODULE.TRIVIA_GAME_KEY, state)
|
||||||
|
answer = str(state["answer"])
|
||||||
|
reward = int(state["reward"])
|
||||||
|
result = self.application.accept(
|
||||||
|
event(
|
||||||
|
"trivia-answer",
|
||||||
|
f"@JWeBot /回答 {answer}",
|
||||||
|
sender="Alice",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["route"], "local")
|
||||||
|
self.assertIn("答对了", result["text"])
|
||||||
|
self.assertIn(f"奖励摩拉 +{reward}", result["text"])
|
||||||
|
self.assertIsNone(ledger.local_game("group-a", MODULE.TRIVIA_GAME_KEY))
|
||||||
|
person = ledger.resolve_person("group-a", "Alice")
|
||||||
|
account = ledger._load_player_account("group-a", str(person["person_key"]))
|
||||||
|
self.assertEqual(account["balance"], 1000 + reward)
|
||||||
|
|
||||||
|
def test_ambient_chat_cooldown_keeps_next_hour_scan_broadcast(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
observed_at = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
12,
|
||||||
|
45,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).isoformat()
|
||||||
|
for index, (sender, text) in enumerate(
|
||||||
|
(
|
||||||
|
("Alice", "今天加班真的有点顶不住"),
|
||||||
|
("Bob", "先把这个线上问题看完"),
|
||||||
|
("Alice", "咖啡续上"),
|
||||||
|
("Bob", "修完再开一把游戏"),
|
||||||
|
),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=f"ambient-cooldown-observation-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
ledger.record_repeat_history(
|
||||||
|
event_id=f"ambient-cooldown-context-{index}",
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text=text,
|
||||||
|
observed_at=observed_at,
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
first = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
13,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
second = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch.object(MODULE, "TRIVIA_QUIZ_PROBABILITY_PERCENT", 0), \
|
||||||
|
mock.patch.object(MODULE, "AMBIENT_CHAT_PROBABILITY_PERCENT", 100):
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=first,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=second,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
second_delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("扫码了", second_delivery["result_text"])
|
||||||
|
row = ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT kind, state FROM clock_broadcasts
|
||||||
|
WHERE group_fingerprint = ? AND slot_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", "hourly:2026-10-13T14"),
|
||||||
|
).fetchone()
|
||||||
|
self.assertEqual(row["kind"], "hourly")
|
||||||
|
self.assertEqual(row["state"], "delivered")
|
||||||
|
|
||||||
def test_failed_hourly_broadcast_does_not_increment_scan_ranking(self):
|
def test_failed_hourly_broadcast_does_not_increment_scan_ranking(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
for event_id, sender in (("clock-c", "Alice"), ("clock-d", "Bob")):
|
for event_id, sender in (("clock-c", "Alice"), ("clock-d", "Bob")):
|
||||||
@@ -1105,6 +1380,134 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(ledger.clock_scan_ranking("group-a", "2026-10-13"), [])
|
self.assertEqual(ledger.clock_scan_ranking("group-a", "2026-10-13"), [])
|
||||||
self.assertEqual(ledger.status()["clock_broadcasts_pending"], 0)
|
self.assertEqual(ledger.status()["clock_broadcasts_pending"], 0)
|
||||||
|
|
||||||
|
def test_existing_pending_hourly_broadcast_is_delivered(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
for event_id, sender in (("clock-pending-a", "Alice"), ("clock-pending-b", "Bob")):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=event_id,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="群内聊天记录",
|
||||||
|
observed_at=MODULE.utc_now(),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
person = ledger.resolve_person("group-a", "Bob")
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.reserve_clock_broadcast(
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
slot_key="hourly:2026-10-13T05",
|
||||||
|
local_date="2026-10-13",
|
||||||
|
kind="hourly",
|
||||||
|
message="现在是北京时间 5:00,bob 扫码了。",
|
||||||
|
scan_person_key=str(person["person_key"]),
|
||||||
|
scan_label="bob",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.application.deliver = mock.Mock()
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
5,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertTrue(
|
||||||
|
self.application.process_clock_once(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
delivery = self.application.deliver.call_args.args[0]
|
||||||
|
self.assertIn("bob 扫码了", delivery["result_text"])
|
||||||
|
self.assertEqual(
|
||||||
|
ledger.clock_scan_ranking("group-a", "2026-10-13")[0]["display_name"],
|
||||||
|
"bob",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scan_boost_pity_forces_target_after_two_misses(self):
|
||||||
|
ledger = self.application.ledger
|
||||||
|
for event_id, sender in (("pity-member-a", "Alice"), ("pity-member-b", "Bob")):
|
||||||
|
ledger.record_person_observation(
|
||||||
|
event_id=event_id,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
sender=sender,
|
||||||
|
text="群内聊天记录",
|
||||||
|
observed_at=MODULE.utc_now(),
|
||||||
|
source="test",
|
||||||
|
)
|
||||||
|
alice = ledger.resolve_person("group-a", "Alice")
|
||||||
|
bob = ledger.resolve_person("group-a", "Bob")
|
||||||
|
carol = ledger.resolve_person("group-a", "Carol")
|
||||||
|
account = MODULE.normalize_player_account({})
|
||||||
|
account["scan_boosts"] = [
|
||||||
|
{
|
||||||
|
"target_person_key": str(bob["person_key"]),
|
||||||
|
"target_display_name": "bob",
|
||||||
|
"weight": 5,
|
||||||
|
"created_at": MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
3,
|
||||||
|
30,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).timestamp(),
|
||||||
|
"expires_at": MODULE.datetime(
|
||||||
|
2099,
|
||||||
|
10,
|
||||||
|
14,
|
||||||
|
3,
|
||||||
|
30,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
).timestamp(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
ledger._save_player_account(
|
||||||
|
"group-a",
|
||||||
|
str(alice["person_key"]),
|
||||||
|
account,
|
||||||
|
MODULE.utc_now(),
|
||||||
|
)
|
||||||
|
for hour, person, label in (
|
||||||
|
(4, alice, "alice"),
|
||||||
|
(5, carol, "carol"),
|
||||||
|
):
|
||||||
|
slot_key = f"hourly:2026-10-13T{hour:02d}"
|
||||||
|
self.assertTrue(
|
||||||
|
ledger.reserve_clock_broadcast(
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
slot_key=slot_key,
|
||||||
|
local_date="2026-10-13",
|
||||||
|
kind="hourly",
|
||||||
|
message="test",
|
||||||
|
scan_person_key=str(person["person_key"]),
|
||||||
|
scan_label=label,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertTrue(ledger.complete_clock_broadcast("group-a", slot_key))
|
||||||
|
now = MODULE.datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
6,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
status = ledger.scan_boost_pity_status("group-a", local_now=now)
|
||||||
|
self.assertTrue(status[0]["guaranteed"])
|
||||||
|
self.assertEqual(status[0]["display_name"], "bob")
|
||||||
|
broadcast = self.application.clock.due_broadcast(
|
||||||
|
now=now,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNotNone(broadcast)
|
||||||
|
self.assertEqual(broadcast.scan_label, "bob")
|
||||||
|
|
||||||
def test_scan_ranking_command_uses_current_beijing_date(self):
|
def test_scan_ranking_command_uses_current_beijing_date(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
local_date = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).date().isoformat()
|
local_date = MODULE.datetime.now(MODULE.BEIJING_TIMEZONE).date().isoformat()
|
||||||
@@ -1134,6 +1537,12 @@ class LedgerAndApplicationTests(unittest.TestCase):
|
|||||||
self.assertEqual(response["route"], "local")
|
self.assertEqual(response["route"], "local")
|
||||||
self.assertIn("北京时间今日扫码排行", response["text"])
|
self.assertIn("北京时间今日扫码排行", response["text"])
|
||||||
self.assertIn("bob 1 次", response["text"])
|
self.assertIn("bob 1 次", response["text"])
|
||||||
|
self.assertIn("alice 0 次", response["text"])
|
||||||
|
self.assertIn("carol 0 次", response["text"])
|
||||||
|
self.assertIn("下一小时扫码权重:", response["text"])
|
||||||
|
self.assertIn("alice 1", response["text"])
|
||||||
|
self.assertIn("bob 1", response["text"])
|
||||||
|
self.assertIn("carol 1", response["text"])
|
||||||
|
|
||||||
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
|
def test_repeat_ranking_tracks_origin_and_later_repeats_by_display_id(self):
|
||||||
ledger = self.application.ledger
|
ledger = self.application.ledger
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
|||||||
"context-summary": OFFICIAL_AUTHOR,
|
"context-summary": OFFICIAL_AUTHOR,
|
||||||
"person-directory": OFFICIAL_AUTHOR,
|
"person-directory": OFFICIAL_AUTHOR,
|
||||||
"single-player-games": JWEBOT_AUTHOR,
|
"single-player-games": JWEBOT_AUTHOR,
|
||||||
|
"game-history": JWEBOT_AUTHOR,
|
||||||
"cs-major-autochess": JWEBOT_AUTHOR,
|
"cs-major-autochess": JWEBOT_AUTHOR,
|
||||||
"person-memory": OFFICIAL_AUTHOR,
|
"person-memory": OFFICIAL_AUTHOR,
|
||||||
"server-status": OFFICIAL_AUTHOR,
|
"server-status": OFFICIAL_AUTHOR,
|
||||||
@@ -92,7 +93,10 @@ 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,
|
||||||
|
"primogem-economy": JWEBOT_AUTHOR,
|
||||||
"coin-loan": JWEBOT_AUTHOR,
|
"coin-loan": JWEBOT_AUTHOR,
|
||||||
|
"trivia-quiz": JWEBOT_AUTHOR,
|
||||||
"random-number": JWEBOT_AUTHOR,
|
"random-number": JWEBOT_AUTHOR,
|
||||||
"draw-lot": JWEBOT_AUTHOR,
|
"draw-lot": JWEBOT_AUTHOR,
|
||||||
"daily-fortune": JWEBOT_AUTHOR,
|
"daily-fortune": JWEBOT_AUTHOR,
|
||||||
@@ -119,9 +123,14 @@ class LocalExtensionInventoryTests(unittest.TestCase):
|
|||||||
"prize_draw",
|
"prize_draw",
|
||||||
"lottery",
|
"lottery",
|
||||||
"blackjack",
|
"blackjack",
|
||||||
"game_history",
|
|
||||||
"game_rules",
|
"game_rules",
|
||||||
),
|
),
|
||||||
|
"primogem-economy": (
|
||||||
|
"primogem_wish",
|
||||||
|
"primogem_ranking",
|
||||||
|
"scan_boost",
|
||||||
|
"taffy_say",
|
||||||
|
),
|
||||||
"person-directory": ("person_directory", "person_info"),
|
"person-directory": ("person_directory", "person_info"),
|
||||||
"person-memory": (
|
"person-memory": (
|
||||||
"remember",
|
"remember",
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+118
-18
@@ -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,62 @@ 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;原石 0 个;扫码概率券 0 张;无贷款;",
|
||||||
|
alice_wallet,
|
||||||
|
)
|
||||||
|
self.assertIn("最近10次账户变动:", alice_wallet)
|
||||||
|
self.assertIn("-250:转账给 bob", alice_wallet)
|
||||||
|
self.assertIn("+1000:首次初始化", alice_wallet)
|
||||||
|
self.assertIn(
|
||||||
|
"bob:当前摩拉 1250;原石 0 个;扫码概率券 0 张;无贷款;",
|
||||||
|
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 +324,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)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Behavioral coverage for beta 1.0.5 primogem economy."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from test_jwebot_edge import MODULE, config
|
||||||
|
|
||||||
|
|
||||||
|
class Release105PrimogemTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.engine = MODULE.EdgeApplication(config(Path(self.temp.name)))
|
||||||
|
self.counter = 0
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.engine.close()
|
||||||
|
self.temp.cleanup()
|
||||||
|
|
||||||
|
def command(self, question, *, sender="Alice", event_id=None):
|
||||||
|
self.counter += 1
|
||||||
|
row = {
|
||||||
|
"question": question,
|
||||||
|
"sender": sender,
|
||||||
|
"group_fingerprint": "group-a",
|
||||||
|
"event_id": event_id or f"primogem-{self.counter}",
|
||||||
|
}
|
||||||
|
return self.engine.local.handle(row, {})
|
||||||
|
|
||||||
|
def state(self, sender="Alice"):
|
||||||
|
key = self.engine.ledger.resolve_person("group-a", sender)["person_key"]
|
||||||
|
row = self.engine.ledger.connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT state_json FROM player_accounts
|
||||||
|
WHERE group_fingerprint = ? AND person_key = ?
|
||||||
|
""",
|
||||||
|
("group-a", key),
|
||||||
|
).fetchone()
|
||||||
|
self.assertIsNotNone(row)
|
||||||
|
return json.loads(row[0])
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def blackjack_deck(*prefix):
|
||||||
|
used = set(prefix)
|
||||||
|
return list(prefix) + [card for card in range(52) if card not in used]
|
||||||
|
|
||||||
|
def test_natural_blackjack_grants_primogem_and_wish_can_award_scan_ticket(self):
|
||||||
|
deck = self.blackjack_deck(0, 12, 1, 2)
|
||||||
|
|
||||||
|
def shuffle(cards):
|
||||||
|
cards[:] = deck
|
||||||
|
|
||||||
|
with mock.patch.object(self.engine.local.players.rng, "shuffle", shuffle):
|
||||||
|
result = self.command("/21点 开始 10")
|
||||||
|
|
||||||
|
self.assertIn("+1 原石", result)
|
||||||
|
self.assertEqual(self.state()["primogems"], 1)
|
||||||
|
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"randrange",
|
||||||
|
side_effect=[0, 0],
|
||||||
|
):
|
||||||
|
wish = self.command("/原石祈愿")
|
||||||
|
|
||||||
|
self.assertIn("抽到 20 摩拉", wish)
|
||||||
|
self.assertIn("扫码概率券 1 张", wish)
|
||||||
|
state = self.state()
|
||||||
|
self.assertEqual(state["primogems"], 0)
|
||||||
|
self.assertEqual(state["scan_boost_tickets"], 1)
|
||||||
|
|
||||||
|
def test_extreme_blackjack_hit_from_20_to_21_grants_two_primogems(self):
|
||||||
|
prefix = [9, 12, 1, 2]
|
||||||
|
deck = self.blackjack_deck(*prefix, 0)
|
||||||
|
deck = deck[:4] + [card for card in deck[4:] if card != 0] + [0]
|
||||||
|
|
||||||
|
def shuffle(cards):
|
||||||
|
cards[:] = deck
|
||||||
|
|
||||||
|
with mock.patch.object(self.engine.local.players.rng, "shuffle", shuffle):
|
||||||
|
self.command("/21点 开始 10")
|
||||||
|
self.command("/21点 要牌")
|
||||||
|
result = self.command("/21点 停牌")
|
||||||
|
|
||||||
|
self.assertIn("21点极限挑战", result)
|
||||||
|
self.assertIn("+2 原石", result)
|
||||||
|
self.assertEqual(self.state()["primogems"], 2)
|
||||||
|
|
||||||
|
def test_lottery_two_consecutive_personal_draws_with_five_total_hits(self):
|
||||||
|
self.command("/彩票开始")
|
||||||
|
self.command("/彩票34710")
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"sample",
|
||||||
|
return_value=[3, 7, 9],
|
||||||
|
):
|
||||||
|
self.command("/彩票开奖")
|
||||||
|
|
||||||
|
self.command("/彩票开始", event_id="lottery-start-two")
|
||||||
|
self.command("/彩票34710", event_id="lottery-entry-two")
|
||||||
|
with mock.patch.object(
|
||||||
|
self.engine.local.players.rng,
|
||||||
|
"sample",
|
||||||
|
return_value=[3, 4, 7],
|
||||||
|
):
|
||||||
|
result = self.command("/彩票开奖", event_id="lottery-draw-two")
|
||||||
|
|
||||||
|
self.assertIn("彩票三中挑战", result)
|
||||||
|
self.assertEqual(self.state()["primogems"], 2)
|
||||||
|
|
||||||
|
def test_scan_boost_and_taffy_say_spend_primogem_resources(self):
|
||||||
|
self.command("/原石")
|
||||||
|
account = self.state()
|
||||||
|
account["primogems"] = 3
|
||||||
|
self.engine.ledger._save_player_account(
|
||||||
|
"group-a",
|
||||||
|
self.engine.ledger.resolve_person("group-a", "Alice")["person_key"],
|
||||||
|
account,
|
||||||
|
MODULE.utc_now(),
|
||||||
|
)
|
||||||
|
self.engine.ledger.connection.commit()
|
||||||
|
|
||||||
|
boost = self.command("/扫码加权 Bob")
|
||||||
|
self.assertIn("已给 bob 增加整点扫码权重", boost)
|
||||||
|
boost_targets = self.engine.ledger.active_scan_boost_targets("group-a")
|
||||||
|
bob_key = self.engine.ledger.resolve_person("group-a", "Bob")["person_key"]
|
||||||
|
self.assertIn(bob_key, boost_targets)
|
||||||
|
ranking = self.command("/扫码榜", event_id="scan-ranking-after-boost")
|
||||||
|
self.assertIn("下一小时扫码权重:", ranking)
|
||||||
|
self.assertIn("bob 6", ranking)
|
||||||
|
|
||||||
|
second_boost = self.command("/扫码加权 Bob", event_id="scan-boost-second")
|
||||||
|
self.assertIn("已给 bob 增加整点扫码权重", second_boost)
|
||||||
|
stacked = self.command("/扫码榜", event_id="scan-ranking-stacked")
|
||||||
|
self.assertIn("bob 11", stacked)
|
||||||
|
|
||||||
|
speech = self.command("/Taffy发话 今天也要加班吗")
|
||||||
|
self.assertEqual(speech, "Taffy:今天也要加班吗喵")
|
||||||
|
state = self.state()
|
||||||
|
self.assertEqual(state["primogems"], 0)
|
||||||
|
self.assertEqual(state["taffy_say_credits"], 1)
|
||||||
|
|
||||||
|
def test_scheduled_taffy_speech_uses_clock_delivery_queue(self):
|
||||||
|
self.command("/原石")
|
||||||
|
account = self.state()
|
||||||
|
account["primogems"] = 1
|
||||||
|
person_key = self.engine.ledger.resolve_person("group-a", "Alice")["person_key"]
|
||||||
|
self.engine.ledger._save_player_account(
|
||||||
|
"group-a",
|
||||||
|
person_key,
|
||||||
|
account,
|
||||||
|
MODULE.utc_now(),
|
||||||
|
)
|
||||||
|
self.engine.ledger.connection.commit()
|
||||||
|
scheduled_at = datetime(
|
||||||
|
2026,
|
||||||
|
10,
|
||||||
|
16,
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
tzinfo=MODULE.BEIJING_TIMEZONE,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.engine.ledger.player_taffy_say(
|
||||||
|
"group-a",
|
||||||
|
"Alice",
|
||||||
|
"scheduled-taffy",
|
||||||
|
"taffy_say:test",
|
||||||
|
"准点提醒",
|
||||||
|
scheduled_at.timestamp(),
|
||||||
|
lambda data: self.engine.local.players.render_taffy_say(data, "alice"),
|
||||||
|
)
|
||||||
|
self.assertIn("已安排Taffy", response)
|
||||||
|
|
||||||
|
delivered = []
|
||||||
|
self.engine.deliver = lambda row: delivered.append(row["result_text"])
|
||||||
|
self.assertTrue(
|
||||||
|
self.engine.process_clock_once(
|
||||||
|
now=scheduled_at,
|
||||||
|
group_fingerprint="group-a",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.assertEqual(delivered, ["Taffy:准点提醒"])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
#!/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.1.2.1019"
|
||||||
|
|
||||||
|
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("原石 x1", announcement)
|
||||||
|
self.assertNotIn("第二条", announcement)
|
||||||
|
|
||||||
|
def test_policy_release_announces_primogem(self):
|
||||||
|
for release in ("beta 1.0.5.1016", "beta 1.1.0.1016"):
|
||||||
|
self.assertEqual(MODULE.primogem_compensation_amount(release), 1)
|
||||||
|
announcement = MODULE.build_announcement(release, "原石系统上线。")
|
||||||
|
self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement)
|
||||||
|
|
||||||
|
def test_patch_release_without_policy_does_not_announce_primogem(self):
|
||||||
|
release = "beta 1.1.2.1019"
|
||||||
|
self.assertEqual(MODULE.primogem_compensation_amount(release), 0)
|
||||||
|
announcement = MODULE.build_announcement(release, "普通小版本更新。")
|
||||||
|
self.assertIn("更新补偿:摩拉 x200", announcement)
|
||||||
|
self.assertNotIn("原石 x1", announcement)
|
||||||
|
|
||||||
|
def test_lifecycle_stage_sends_guarded_notice_without_compensation(self):
|
||||||
|
delivered = []
|
||||||
|
|
||||||
|
def deliverer(path, group, message, event_id):
|
||||||
|
delivered.append((path, group, message, event_id))
|
||||||
|
|
||||||
|
result = MODULE.run_release_lifecycle(
|
||||||
|
release_file=self.version,
|
||||||
|
database_path=self.database,
|
||||||
|
delivery_helper=self.delivery_helper,
|
||||||
|
stage="shutdown",
|
||||||
|
deliverer=deliverer,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(result["stage"], "shutdown")
|
||||||
|
self.assertEqual(result["delivered"], 1)
|
||||||
|
self.assertEqual(delivered[0][1], "group-a")
|
||||||
|
self.assertIn("准备停机更新", delivered[0][2])
|
||||||
|
self.assertIn(self.release, delivered[0][2])
|
||||||
|
self.assertIn("shutdown", delivered[0][3])
|
||||||
|
self.assertEqual([state["balance"] for state in self.account_states()], [1000, 50])
|
||||||
|
|
||||||
|
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():
|
||||||
|
self.assertEqual(state["primogems"], 0)
|
||||||
|
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())
|
||||||
|
primogem_matches = [
|
||||||
|
event
|
||||||
|
for event in state["primogem_events"]
|
||||||
|
if event["reason"] == f"{self.release} 版本更新补偿"
|
||||||
|
]
|
||||||
|
self.assertEqual(primogem_matches, [])
|
||||||
|
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([state["primogems"] for state in self.account_states()], [0, 0])
|
||||||
|
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([state["primogems"] for state in self.account_states()], [0, 0])
|
||||||
|
self.assertEqual(self.announcement_marker()["state"], "delivered")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user