Compare commits

..
4 Commits
Author SHA1 Message Date
JWeBot Contributors ddebe075d7 Release beta 1.1.2.1019
check / local-gate (push) Failing after 53s
2026-10-19 16:56:59 +08:00
JWeBot Contributors 522e349491 Release beta 1.1.1 stacked scan boosts 2026-10-16 17:31:45 +08:00
JWeBot Contributors 35ef82f14f Release beta 1.1.0 scan and duplicate fixes 2026-10-16 17:14:41 +08:00
JWeBot Contributors b1d8e4e8e7 Release beta 1.0.5 primogem economy 2026-10-16 16:46:44 +08:00
57 changed files with 3204 additions and 103 deletions
+45
View File
@@ -1,5 +1,50 @@
# 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 ## beta 1.0.4.1016 - 2026-10-16
- 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。 - 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。
+10 -4
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.4.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
@@ -89,6 +89,8 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200 /21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容
/游戏记录 [页码](/gamehistory)
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案 /自走棋开始|报名|开赛|状态|档案
@@ -190,7 +192,9 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。 发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告;
`beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石;
后续普通小版本不额外发放原石。
旧 OCR 服务保留安装,但保持禁用。 旧 OCR 服务保留安装,但保持禁用。
### 仓库结构 ### 仓库结构
@@ -265,10 +269,10 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.4.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,
@@ -457,6 +461,8 @@ SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
remain installed but disabled. After cutover, version-idempotent release remain installed but disabled. After cutover, version-idempotent release
settlement grants 200 Mora to every existing account, records the movement, settlement grants 200 Mora to every existing account, records the movement,
and sends the first changelog bullet as the guarded group announcement. 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
+1 -1
View File
@@ -1 +1 @@
beta 1.0.4.1016 beta 1.1.2.1019
@@ -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 \
@@ -471,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 \
@@ -609,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}"
+10 -4
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.1.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 Mora account. | | `/摩拉` | `/金币`, `/原石`, `/wallet` | Initialize or show the current person's Mora and primogem account. |
| `/大富翁榜 [人数]` | `/摩拉排行`, `/摩拉排行榜`, `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped Mora 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. |
+19
View File
@@ -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()
@@ -6,17 +6,14 @@ Author: `jwebot`
Help: Help:
- `/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory` - `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助`
- `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/游戏帮助;/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点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/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"]
@@ -6,8 +6,8 @@ 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点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/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):
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '摩拉游戏') self.assertEqual(DESCRIPTION, '摩拉游戏')
self.assertEqual(HELP_ENTRIES, ('/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',))
self.assertIn('/摩拉', registered) self.assertIn('/摩拉', registered)
self.assertEqual(len(registered), 16) self.assertEqual(len(registered), 14)
if __name__ == "__main__": if __name__ == "__main__":
+18
View File
@@ -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`.
+14
View File
@@ -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 -1
View File
@@ -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)
+124 -11
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Apply the mandatory post-release Mora grant and group announcement.""" """Apply the mandatory release lifecycle notices, grant, and announcement."""
from __future__ import annotations from __future__ import annotations
@@ -26,20 +26,27 @@ sys.path[:0] = [
from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402 from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402
from jwebot_official_extensions.player_games import ( # noqa: E402 from jwebot_official_extensions.player_games import ( # noqa: E402
append_coin_event, append_coin_event,
append_primogem_event,
normalize_player_account, normalize_player_account,
) )
COMPENSATION_AMOUNT = 200 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_BALANCE = 1_000_000_000
MAX_SUMMARY_CHARS = 300 MAX_SUMMARY_CHARS = 300
MAX_ANNOUNCEMENT_CHARS = 900 MAX_ANNOUNCEMENT_CHARS = 900
MAX_LIFECYCLE_CHARS = 300
LIFECYCLE_STAGES = frozenset({"shutdown", "startup"})
RELEASE_PATTERN = re.compile( RELEASE_PATTERN = re.compile(
r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$" r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
) )
CHANGELOG_HEADING = re.compile( CHANGELOG_HEADING = re.compile(
r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})" r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})"
r"(?:\s+-\s+\d{4}-\d{2}-\d{2})?\s*$" r"(?:\s+-\s+\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?)?\s*$"
) )
@@ -119,11 +126,13 @@ def read_changelog_summary(path: Path, release: str) -> str:
def build_announcement(release: str, summary: str) -> str: def build_announcement(release: str, summary: str) -> str:
primogems = primogem_compensation_amount(release)
extra = f",原石 x{primogems}" if primogems else ""
announcement = ( announcement = (
"亲爱的群友:\n" "亲爱的群友:\n"
f"{release} 更新现已完成。\n" f"{release} 更新现已完成。\n"
f"更新内容:{summary}\n" f"更新内容:{summary}\n"
f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}\n" f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}{extra}\n"
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n" "补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
"发放方式:补偿已直接到账,无需领取\n" "发放方式:补偿已直接到账,无需领取\n"
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!" "感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
@@ -133,10 +142,32 @@ def build_announcement(release: str, summary: str) -> str:
return announcement 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: def compensation_key(release: str) -> str:
return f"release_compensation:{release}" 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: def announcement_key(release: str, group_fingerprint: str) -> str:
group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest() group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest()
return f"release_announcement:{release}:{group_hash}" return f"release_announcement:{release}:{group_hash}"
@@ -146,7 +177,25 @@ def release_slug(release: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower() 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]: def checked_marker(raw: str, release: str) -> dict[str, Any]:
expected_primogems = primogem_compensation_amount(release)
try: try:
marker = json.loads(raw) marker = json.loads(raw)
except json.JSONDecodeError as error: except json.JSONDecodeError as error:
@@ -155,6 +204,8 @@ def checked_marker(raw: str, release: str) -> dict[str, Any]:
not isinstance(marker, dict) not isinstance(marker, dict)
or marker.get("release") != release or marker.get("release") != release
or marker.get("amount") != COMPENSATION_AMOUNT 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) or not isinstance(marker.get("group_fingerprints"), list)
): ):
raise ReleaseUpdateError("release compensation marker does not match") raise ReleaseUpdateError("release compensation marker does not match")
@@ -234,8 +285,11 @@ def apply_compensation(
).fetchall() ).fetchall()
groups = sorted({str(row["group_fingerprint"]) for row in rows}) groups = sorted({str(row["group_fingerprint"]) for row in rows})
old_balance_sum = 0 old_balance_sum = 0
old_primogem_sum = 0
primogem_amount = primogem_compensation_amount(release)
timestamp = isoformat_utc(applied_at) timestamp = isoformat_utc(applied_at)
event_reason = f"{release} 版本更新补偿" event_reason = f"{release} 版本更新补偿"
primogem_reason = f"{release} 版本更新补偿"
for row in rows: for row in rows:
try: try:
state = json.loads(str(row["state_json"])) state = json.loads(str(row["state_json"]))
@@ -251,9 +305,20 @@ def apply_compensation(
): ):
raise ReleaseUpdateError("Mora account balance is invalid") raise ReleaseUpdateError("Mora account balance is invalid")
old_balance_sum += balance old_balance_sum += balance
old_primogem_sum += int(state["primogems"])
state["balance"] = balance + COMPENSATION_AMOUNT state["balance"] = balance + COMPENSATION_AMOUNT
state["primogems"] += primogem_amount
append_coin_event(state, COMPENSATION_AMOUNT, event_reason) append_coin_event(state, COMPENSATION_AMOUNT, event_reason)
state["coin_events"][-1]["created_at"] = applied_at.timestamp() 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( connection.execute(
""" """
UPDATE player_accounts UPDATE player_accounts
@@ -270,12 +335,18 @@ def apply_compensation(
marker = { marker = {
"release": release, "release": release,
"amount": COMPENSATION_AMOUNT, "amount": COMPENSATION_AMOUNT,
"mora_amount": COMPENSATION_AMOUNT,
"primogem_amount": primogem_amount,
"accounts": len(rows), "accounts": len(rows),
"group_fingerprints": groups, "group_fingerprints": groups,
"old_balance_sum": old_balance_sum, "old_balance_sum": old_balance_sum,
"new_balance_sum": ( "new_balance_sum": (
old_balance_sum + COMPENSATION_AMOUNT * len(rows) 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, "applied_at": timestamp,
"backup": backup_path.name, "backup": backup_path.name,
} }
@@ -523,6 +594,7 @@ def run_release_update(
"release": release, "release": release,
"compensation": compensation_state, "compensation": compensation_state,
"amount_each": COMPENSATION_AMOUNT, "amount_each": COMPENSATION_AMOUNT,
"primogems_each": primogem_compensation_amount(release),
"accounts": int(marker["accounts"]), "accounts": int(marker["accounts"]),
"groups": len(groups), "groups": len(groups),
"announcements_delivered": announcement_result["delivered"], "announcements_delivered": announcement_result["delivered"],
@@ -531,6 +603,34 @@ def run_release_update(
} }
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: def parse_args() -> argparse.Namespace:
home = Path.home() home = Path.home()
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
@@ -564,20 +664,33 @@ def parse_args() -> argparse.Namespace:
action="store_true", action="store_true",
help="retry only after visually confirming that the prior send did not land", 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() return parser.parse_args()
def main() -> int: def main() -> int:
args = parse_args() args = parse_args()
try: try:
result = run_release_update( if args.lifecycle_stage:
release_file=args.release_file, result = run_release_lifecycle(
changelog=args.changelog, release_file=args.release_file,
database_path=args.database, database_path=args.database,
backup_dir=args.backup_dir, delivery_helper=args.delivery_helper,
delivery_helper=args.delivery_helper, stage=args.lifecycle_stage,
retry_uncertain=args.retry_uncertain_announcement, )
) 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: except ReleaseUpdateError as error:
print( print(
json.dumps( json.dumps(
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.4b1+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.4b1+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.4b1+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.4b1+1016", "jwebot-sdk==1.1.2b1+1019",
"jwebot-kernel==1.0.4b1+1016", "jwebot-kernel==1.1.2b1+1019",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.4b1+1016" __version__ = "1.1.2b1+1019"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.4b1+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.4b1+1016", "jwebot-config==1.1.2b1+1019",
"jwebot-sdk==1.0.4b1+1016", "jwebot-sdk==1.1.2b1+1019",
"jwebot-official-extensions==1.0.4b1+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.4b1+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.4b1+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.4b1+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.4b1+1016" __version__ = "1.1.2b1+1019"
@@ -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))
@@ -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):
@@ -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')
@@ -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))
@@ -6,8 +6,8 @@ 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点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/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):
@@ -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))
@@ -34,6 +34,7 @@ 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",
@@ -52,6 +53,18 @@ LOCAL_COMMAND_ALIASES = {
"Taffy摩拉": "taffy_coins", "Taffy摩拉": "taffy_coins",
"Taffy金币": "taffy_coins", "Taffy金币": "taffy_coins",
"taffycoins": "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",
@@ -158,21 +171,23 @@ LOCAL_EXTENSIONS = (
author=JWEBOT_AUTHOR, author=JWEBOT_AUTHOR,
description="摩拉游戏", description="摩拉游戏",
help_entries=( help_entries=(
"/摩拉(兼容 /金币)/wallet;/游戏记录 /gamehistory", "/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助",
"/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/游戏帮助;/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,
@@ -194,6 +209,20 @@ LOCAL_EXTENSIONS = (
help_entries=("/Taffy摩拉(兼容 /Taffy金币)",), help_entries=("/Taffy摩拉(兼容 /Taffy金币)",),
command_kinds=("taffy_coins",), 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,
@@ -201,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,
@@ -213,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"),
), ),
@@ -244,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(
@@ -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):
@@ -166,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":
@@ -216,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
@@ -240,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()
@@ -433,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 :]:
@@ -604,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"]),
@@ -28,6 +28,28 @@ GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5 GAME_HISTORY_PAGE_SIZE = 5
COIN_EVENT_LIMIT = 50 COIN_EVENT_LIMIT = 50
COIN_EVENT_DISPLAY_LIMIT = 10 COIN_EVENT_DISPLAY_LIMIT = 10
PRIMOGEM_MORA_VALUE = 3600
PRIMOGEM_EVENT_LIMIT = 50
PRIMOGEM_EVENT_DISPLAY_LIMIT = 5
WISH_HISTORY_LIMIT = 30
SCAN_BOOST_HOURS = 24
SCAN_BOOST_SECONDS = SCAN_BOOST_HOURS * 60 * 60
SCAN_BOOST_WEIGHT = 5
SCAN_BOOST_LIMIT = 200
TAFFY_SAY_CREDITS_PER_PRIMOGEM = 2
TAFFY_SAY_MAX_CHARS = 160
WISH_MORA_TABLE = (
(20, 5),
(100, 12),
(500, 11),
(1000, 16),
(1800, 8),
(3600, 13),
(6000, 12),
(8000, 13),
(10000, 10),
)
WISH_SCAN_TICKET_PERCENT = 10
GAME_LABELS = { GAME_LABELS = {
"prize_draw": "抽奖", "prize_draw": "抽奖",
"lottery": "彩票", "lottery": "彩票",
@@ -59,6 +81,12 @@ TRANSFER_MARKED = re.compile(
r"(?:金额|摩拉|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$", r"(?:金额|摩拉|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
re.IGNORECASE, re.IGNORECASE,
) )
TAFFY_SAY_SCHEDULE = re.compile(
r"^(?:定时|预约|schedule)\s+"
r"(?P<time>(?:今天|明天)?\s*\d{1,2}:\d{2})\s+"
r"(?P<content>.+)$",
re.IGNORECASE,
)
def coin_event_reason(kind: str, outcome: str) -> str: def coin_event_reason(kind: str, outcome: str) -> str:
@@ -98,9 +126,141 @@ def append_coin_event(
account["coin_events"] = events[-COIN_EVENT_LIMIT:] account["coin_events"] = events[-COIN_EVENT_LIMIT:]
def append_primogem_event(
account: dict[str, Any],
delta: int,
reason: str,
) -> None:
if delta == 0:
return
events = account.get("primogem_events")
if not isinstance(events, list):
events = []
events = [
event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int
]
events.append(
{
"delta": delta,
"reason": str(reason)[:120],
"balance": int(account.get("primogems", 0)),
"created_at": time.time(),
}
)
account["primogem_events"] = events[-PRIMOGEM_EVENT_LIMIT:]
def grant_primogems(
account: dict[str, Any],
amount: int,
reason: str,
) -> str:
if amount <= 0:
return ""
normalize_player_account(account)
account["primogems"] += amount
append_primogem_event(account, amount, reason)
return (
f"达成成就:{reason}!+{amount} 原石"
f"(价值 {amount * PRIMOGEM_MORA_VALUE} 摩拉);"
f"当前原石 {account['primogems']} 个。"
)
def spend_primogems(
account: dict[str, Any],
amount: int,
reason: str,
) -> None:
normalize_player_account(account)
if amount <= 0:
raise ValueError("原石消耗必须为正数")
if int(account["primogems"]) < amount:
raise ValueError("原石不足")
account["primogems"] -= amount
append_primogem_event(account, -amount, reason)
def append_wish_record(
account: dict[str, Any],
*,
mora: int,
scan_ticket: bool,
) -> None:
records = account.get("wish_history")
if not isinstance(records, list):
records = []
records = [record for record in records if isinstance(record, dict)]
records.append(
{
"mora": mora,
"scan_ticket": scan_ticket,
"created_at": time.time(),
}
)
account["wish_history"] = records[-WISH_HISTORY_LIMIT:]
def weighted_wish_mora(rng: random.Random) -> int:
total = sum(weight for _, weight in WISH_MORA_TABLE)
roll = rng.randrange(total)
cursor = 0
for value, weight in WISH_MORA_TABLE:
cursor += weight
if roll < cursor:
return value
return WISH_MORA_TABLE[-1][0]
def recent_primogem_event_lines(
account: dict[str, Any],
*,
limit: int = PRIMOGEM_EVENT_DISPLAY_LIMIT,
) -> list[str]:
normalize_player_account(account)
events = sorted(
account["primogem_events"],
key=lambda event: float(event.get("created_at", 0) or 0),
reverse=True,
)
lines: list[str] = []
for event in events[:limit]:
delta = int(event["delta"])
reason = str(event.get("reason", "原石变动")).strip() or "原石变动"
lines.append(f"{delta:+d}:{reason}")
return lines
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]: def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS) account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None) account.setdefault("game", None)
primogems = account.get("primogems", 0)
account["primogems"] = primogems if type(primogems) is int and primogems >= 0 else 0
taffy_credits = account.get("taffy_say_credits", 0)
account["taffy_say_credits"] = (
taffy_credits if type(taffy_credits) is int and taffy_credits >= 0 else 0
)
scan_tickets = account.get("scan_boost_tickets", 0)
account["scan_boost_tickets"] = (
scan_tickets if type(scan_tickets) is int and scan_tickets >= 0 else 0
)
blackjack_streak = account.get("blackjack_taffy_win_streak", 0)
account["blackjack_taffy_win_streak"] = (
blackjack_streak
if type(blackjack_streak) is int and blackjack_streak >= 0
else 0
)
lottery_streak = account.get("lottery_three_hit_streak", 0)
account["lottery_three_hit_streak"] = (
lottery_streak if type(lottery_streak) is int and lottery_streak >= 0 else 0
)
previous_lottery_matches = account.get("lottery_previous_matches", -1)
account["lottery_previous_matches"] = (
previous_lottery_matches
if type(previous_lottery_matches) is int and -1 <= previous_lottery_matches <= 3
else -1
)
loan = account.get("loan") loan = account.get("loan")
try: try:
loan_principal = int(loan.get("principal", 0) or 0) if isinstance(loan, dict) else 0 loan_principal = int(loan.get("principal", 0) or 0) if isinstance(loan, dict) else 0
@@ -156,6 +316,51 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
event for event in events event for event in events
if isinstance(event, dict) and type(event.get("delta")) is int if isinstance(event, dict) and type(event.get("delta")) is int
][-COIN_EVENT_LIMIT:] ][-COIN_EVENT_LIMIT:]
primogem_events = account.get("primogem_events")
if not isinstance(primogem_events, list):
primogem_events = []
account["primogem_events"] = [
event for event in primogem_events
if isinstance(event, dict) and type(event.get("delta")) is int
][-PRIMOGEM_EVENT_LIMIT:]
wish_history = account.get("wish_history")
if not isinstance(wish_history, list):
wish_history = []
account["wish_history"] = [
record for record in wish_history if isinstance(record, dict)
][-WISH_HISTORY_LIMIT:]
boosts = account.get("scan_boosts")
if not isinstance(boosts, list):
boosts = []
normalized_boosts = []
current_time = time.time()
for boost in boosts:
if not isinstance(boost, dict):
continue
target_key = str(boost.get("target_person_key", "")).strip()
target_name = str(boost.get("target_display_name", "")).strip()
weight = boost.get("weight", 0)
created_at = float(boost.get("created_at", current_time) or current_time)
expires_at = boost.get("expires_at")
if not isinstance(expires_at, (int, float)):
expires_at = created_at + SCAN_BOOST_SECONDS
if (
target_key
and target_name
and type(weight) is int
and weight > 0
and float(expires_at) > current_time
):
normalized_boosts.append(
{
"target_person_key": target_key[:128],
"target_display_name": target_name[:120],
"weight": min(max(weight, SCAN_BOOST_WEIGHT), 100),
"created_at": created_at,
"expires_at": float(expires_at),
}
)
account["scan_boosts"] = normalized_boosts[-SCAN_BOOST_LIMIT:]
return account return account
@@ -233,6 +438,74 @@ def recent_coin_event_lines(
return lines return lines
def recent_account_event_lines(
account: dict[str, Any],
*,
limit: int = COIN_EVENT_DISPLAY_LIMIT,
) -> list[str]:
normalize_player_account(account)
events: list[dict[str, Any]] = []
for event in account["coin_events"]:
events.append({**event, "unit": "mora"})
for event in account["primogem_events"]:
events.append({**event, "unit": "primogem"})
events.sort(
key=lambda event: float(event.get("created_at", 0) or 0),
reverse=True,
)
lines: list[str] = []
for event in events[:limit]:
delta = int(event["delta"])
reason = str(event.get("reason", "账户变动")).strip() or "账户变动"
if event.get("unit") == "primogem":
lines.append(f"{delta:+d}原石:{reason}")
else:
lines.append(f"{delta:+d}:{reason}")
return lines
def blackjack_achievement_lines(
account: dict[str, Any],
*,
outcome: str,
natural_player_21: bool = False,
extreme_21: bool = False,
) -> list[str]:
normalize_player_account(account)
lines: list[str] = []
if natural_player_21:
lines.append(grant_primogems(account, 1, "21点自然抽到21"))
if extreme_21:
lines.append(grant_primogems(account, 2, "21点极限挑战"))
if "赢" in outcome or "获胜" in outcome:
account["blackjack_taffy_win_streak"] += 1
streak = int(account["blackjack_taffy_win_streak"])
if streak == 3:
lines.append(grant_primogems(account, 1, "对战Taffy 21点3连胜"))
elif streak == 5:
lines.append(grant_primogems(account, 2, "对战Taffy 21点5连胜"))
else:
account["blackjack_taffy_win_streak"] = 0
return [line for line in lines if line]
def lottery_achievement_lines(
account: dict[str, Any],
*,
matches: int,
) -> list[str]:
normalize_player_account(account)
previous = int(account.get("lottery_previous_matches", -1))
lines: list[str] = []
if previous >= 0 and previous + matches >= 5:
account["lottery_previous_matches"] = -1
account["lottery_three_hit_streak"] += 1
lines.append(grant_primogems(account, 2, "彩票三中挑战"))
else:
account["lottery_previous_matches"] = matches
return [line for line in lines if line]
def card_label(card: int) -> str: def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13] return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
@@ -256,11 +529,15 @@ class PlayerGames:
def rules() -> str: def rules() -> str:
return ( return (
"每人首次使用获 1000 虚拟摩拉;/摩拉 查看余额(兼容 /金币);" "每人首次使用获 1000 虚拟摩拉;/摩拉 查看余额(兼容 /金币);"
"/游戏记录 查看本人记录。\n" "/原石 查看稀有资源;/游戏记录 查看本人记录。\n"
"转账:/转账 对方 金额,或 /transfer name amount;" "转账:/转账 对方 金额,或 /transfer name amount;"
"对方需先用 /摩拉 或 /金币 初始化账户。\n" "对方需先用 /摩拉 或 /金币 初始化账户。\n"
"贷款:/贷款 [1~500] 借入摩拉;/还款 [金额|全部] 归还," "贷款:/贷款 [1~500] 借入摩拉;/还款 [金额|全部] 归还,"
"同一时间最多一笔未还贷款。\n" "同一时间最多一笔未还贷款。\n"
"原石:达成特定成就获得;/原石祈愿 消耗1原石抽20~10000摩拉"
"(理论期望3600摩拉)并可能获得扫码概率券;"
"/扫码加权 人名 在24小时内给TA整点扫码+5权重;"
"/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n" "抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];" "多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;" "/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
@@ -284,6 +561,14 @@ class PlayerGames:
return self.rules() return self.rules()
if kind == "game_history": if kind == "game_history":
return self.game_history(row, arguments) return self.game_history(row, arguments)
if kind in {
"primogem",
"primogem_wish",
"primogem_ranking",
"scan_boost",
"taffy_say",
}:
return self.primogem_command(row, kind, arguments)
if kind == "lottery": if kind == "lottery":
return self.group_lottery(row, arguments) return self.group_lottery(row, arguments)
if kind == "coin_ranking": if kind == "coin_ranking":
@@ -401,6 +686,7 @@ class PlayerGames:
) )
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
expired = "上一局已超时,按认输结束;" expired = "上一局已超时,按认输结束;"
if kind == "wallet": if kind == "wallet":
state = "进行中" if account.get("game") else "无进行中的牌局" state = "进行中" if account.get("game") else "无进行中的牌局"
@@ -409,7 +695,12 @@ class PlayerGames:
total = account["wins"] + account["losses"] + account["ties"] total = account["wins"] + account["losses"] + account["ties"]
win_rate = (account["wins"] / total * 100) if total else 0.0 win_rate = (account["wins"] / total * 100) if total else 0.0
lines = [ lines = [
f"{player_label}:{expired}当前摩拉 {account['balance']};{loan_text}", (
f"{player_label}:{expired}当前摩拉 {account['balance']};"
f"原石 {account['primogems']} 个;"
f"扫码概率券 {account['scan_boost_tickets']} 张;"
f"{loan_text}"
),
( (
f"21点 {state};已结算胜 {account['wins']} / " f"21点 {state};已结算胜 {account['wins']} / "
f"负 {account['losses']} / 平 {account['ties']}," f"负 {account['losses']} / 平 {account['ties']},"
@@ -417,7 +708,7 @@ class PlayerGames:
), ),
"最近10次账户变动:", "最近10次账户变动:",
] ]
lines.extend(recent_coin_event_lines(account) or ["暂无"]) lines.extend(recent_account_event_lines(account) or ["暂无"])
lines.append("/游戏帮助 查看规则") lines.append("/游戏帮助 查看规则")
return "\n".join(lines) return "\n".join(lines)
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}: if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
@@ -485,6 +776,221 @@ class PlayerGames:
str(row["group_fingerprint"]), str(row["sender"]), str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute) str(row["event_id"]), kind + ":" + arguments, execute)
def primogem_command(
self,
row: dict[str, Any],
kind: str,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
arguments = arguments.strip()
person = self.ledger.resolve_person(group, sender)
player_label = self.display_label(person["display_name"])
if kind == "primogem_ranking":
if arguments and not re.fullmatch(r"\d{1,2}", arguments):
return "用法:/原石榜 [人数]"
limit = int(arguments or 10)
if not 1 <= limit <= 20:
return "原石榜人数必须为 1~20"
records = self.ledger.player_primogem_ranking(group, limit=limit)
if not records:
return "当前群还没有原石记录"
lines = ["原石榜:"]
for index, record in enumerate(records, start=1):
lines.append(
f"{index}. {record['display_name']} {record['primogems']} 个;"
f"来源:{record['sources']}"
)
return "\n".join(lines)
if kind == "primogem":
if arguments:
return (
"用法:/原石;/原石祈愿;/原石榜;"
"/扫码加权 人名;/Taffy发话 内容"
)
def show(account: dict[str, Any]) -> str:
normalize_player_account(account)
lines = [
(
f"{player_label}:原石 {account['primogems']} 个"
f"(折合 {account['primogems'] * PRIMOGEM_MORA_VALUE} 摩拉);"
f"扫码概率券 {account['scan_boost_tickets']} 张;"
f"Taffy发话额度 {account['taffy_say_credits']} 次。"
),
"最近原石变动:",
]
lines.extend(recent_primogem_event_lines(account) or ["暂无"])
if account["wish_history"]:
recent = account["wish_history"][-3:]
wish_text = ";".join(
(
f"+{int(record.get('mora', 0))}摩拉"
+ (",扫码券+1" if record.get("scan_ticket") else "")
)
for record in reversed(recent)
)
lines.append(f"最近祈愿:{wish_text}")
return "\n".join(lines)
return self.ledger.apply_player_action(
group, sender, event_id, "primogem:" + arguments, show
)
if kind == "primogem_wish":
if arguments:
return "用法:/原石祈愿,消耗1原石抽20~10000摩拉,可能获得扫码概率券"
def wish(account: dict[str, Any]) -> str:
normalize_player_account(account)
if account["primogems"] < 1:
return f"{player_label}:原石不足;当前原石 {account['primogems']} 个"
spend_primogems(account, 1, "原石祈愿")
mora = weighted_wish_mora(self.rng)
scan_ticket = self.rng.randrange(100) < WISH_SCAN_TICKET_PERCENT
account["balance"] += mora
if scan_ticket:
account["scan_boost_tickets"] += 1
append_coin_event(account, mora, "原石祈愿")
append_wish_record(account, mora=mora, scan_ticket=scan_ticket)
ticket_text = ";额外获得扫码概率券 1 张" if scan_ticket else ""
return (
f"{player_label}:原石祈愿消耗 1 原石,抽到 {mora} 摩拉"
f"{ticket_text};当前摩拉 {account['balance']},"
f"原石 {account['primogems']} 个,"
f"扫码概率券 {account['scan_boost_tickets']} 张"
)
return self.ledger.apply_player_action(
group, sender, event_id, "primogem_wish:" + arguments, wish
)
if kind == "scan_boost":
if not arguments:
return "用法:/扫码加权 人名;优先消耗扫码概率券,没有券时消耗1原石"
target = self.ledger.resolve_person(group, arguments)
target_label = self.display_label(target["display_name"])
def boost(account: dict[str, Any]) -> str:
normalize_player_account(account)
if account["scan_boost_tickets"] > 0:
account["scan_boost_tickets"] -= 1
cost_text = "消耗扫码概率券 1 张"
elif account["primogems"] > 0:
spend_primogems(account, 1, f"扫码加权给 {target_label}")
cost_text = "消耗原石 1 个"
else:
return (
f"{player_label}:没有扫码概率券,原石也不足;"
f"当前原石 {account['primogems']} 个"
)
now = time.time()
boosts = list(account["scan_boosts"])
boosts.append(
{
"target_person_key": str(target["person_key"]),
"target_display_name": target_label,
"weight": SCAN_BOOST_WEIGHT,
"created_at": now,
"expires_at": now + SCAN_BOOST_SECONDS,
}
)
account["scan_boosts"] = boosts[-SCAN_BOOST_LIMIT:]
return (
f"{player_label}:已给 {target_label} 增加整点扫码权重;"
f"{cost_text};未来 {SCAN_BOOST_HOURS} 小时内,"
f"{target_label} 额外 +{SCAN_BOOST_WEIGHT} 权重"
)
return self.ledger.apply_player_action(
group,
sender,
event_id,
"scan_boost:" + arguments,
boost,
)
if kind == "taffy_say":
parsed = self.parse_taffy_say(arguments)
if parsed is None:
return (
"用法:/Taffy发话 内容;"
"/Taffy发话 定时 HH:MM 内容。1原石兑换2次发话额度"
)
content, scheduled_at = parsed
return self.ledger.player_taffy_say(
group,
sender,
event_id,
"taffy_say:" + arguments,
content,
scheduled_at,
lambda data: self.render_taffy_say(data, player_label),
)
raise ValueError(f"unsupported primogem command: {kind}")
@staticmethod
def parse_taffy_say(arguments: str) -> tuple[str, float | None] | None:
value = re.sub(r"\s+", " ", arguments).strip()
if not value:
return None
scheduled_at: float | None = None
match = TAFFY_SAY_SCHEDULE.fullmatch(value)
if match is not None:
content = match["content"].strip()
try:
scheduled_at = PlayerGames.parse_taffy_time(match["time"])
except ValueError:
return None
else:
content = value
if not content or len(content) > TAFFY_SAY_MAX_CHARS:
return None
return content, scheduled_at
@staticmethod
def parse_taffy_time(value: str) -> float:
cleaned = re.sub(r"\s+", "", value)
day_offset = 0
if cleaned.startswith("明天"):
day_offset = 1
cleaned = cleaned[2:]
elif cleaned.startswith("今天"):
cleaned = cleaned[2:]
hour_text, minute_text = cleaned.split(":", 1)
hour = int(hour_text)
minute = int(minute_text)
if not 0 <= hour <= 23 or not 0 <= minute <= 59:
raise ValueError("Taffy发话定时必须是 HH:MM")
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
timezone = ZoneInfo("Asia/Shanghai")
now = datetime.now(timezone)
scheduled = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if day_offset:
scheduled += timedelta(days=day_offset)
elif scheduled <= now:
scheduled += timedelta(days=1)
return scheduled.timestamp()
@staticmethod
def render_taffy_say(data: dict[str, Any], player_label: str) -> str:
if data["status"] == "insufficient":
return f"{player_label}:原石不足,无法兑换Taffy发话额度"
if data["status"] == "scheduled":
return (
f"{player_label}:已安排Taffy在 {data['scheduled_text']} 发话;"
f"剩余额度 {data['taffy_say_credits']} 次,"
f"原石 {data['primogems']} 个"
)
return f"Taffy:{data['content']}"
def coin_ranking(self, row: dict[str, Any], arguments: str) -> str: def coin_ranking(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip() value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value): if value and not re.fullmatch(r"\d{1,2}", value):
@@ -940,6 +1446,7 @@ class PlayerGames:
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};" f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"摩拉 {player['balance']}" f"摩拉 {player['balance']}"
) )
lines.extend(player.get("achievements") or [])
return "\n".join(lines) return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str: def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
@@ -1234,6 +1741,7 @@ class PlayerGames:
f"{entry['payout'] - entry['stake']:+d};" f"{entry['payout'] - entry['stake']:+d};"
f"摩拉 {entry['balance']}" f"摩拉 {entry['balance']}"
) )
lines.extend(entry.get("achievements") or [])
return "\n".join(lines) return "\n".join(lines)
return self.ledger.draw_group_lottery( return self.ledger.draw_group_lottery(
@@ -1362,10 +1870,15 @@ class PlayerGames:
) )
account["game"] = None account["game"] = None
account["losses"] += 1 account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
return f"本人的21点已认输结束,不退下注;摩拉 {account['balance']}" return f"本人的21点已认输结束,不退下注;摩拉 {account['balance']}"
game["updated_at"] = time.time() game["updated_at"] = time.time()
if action == "hit": if action == "hit":
game["player"].append(game["deck"].pop()) before = hand_value(game["player"])
drawn = game["deck"].pop()
game["player"].append(drawn)
if before == 20 and hand_value(game["player"]) == 21:
game["extreme_21"] = True
if hand_value(game["player"]) > 21: if hand_value(game["player"]) > 21:
return self.finish_blackjack(account) return self.finish_blackjack(account)
return self.show_hand(game) return self.show_hand(game)
@@ -1409,7 +1922,19 @@ class PlayerGames:
stake=int(bet), stake=int(bet),
payout=int(payout), payout=int(payout),
) )
achievements = blackjack_achievement_lines(
account,
outcome=outcome,
natural_player_21=bool(natural and player == 21),
extreme_21=bool(game.get("extreme_21") and player == 21),
)
account["game"] = None account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点)," result = (
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);" f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};摩拉 {account['balance']}") f"({player}点),庄家 {' '.join(map(card_label, game['dealer']))}"
f"({dealer}点);下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};摩拉 {account['balance']}"
)
if achievements:
result += "\n" + "\n".join(achievements)
return result
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "1.0.4b1+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.4b1+1016" __version__ = "1.1.2b1+1019"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "beta 1.0.4.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]
+14
View File
@@ -127,6 +127,10 @@ 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"', 'jwebot_log "release settlement starting"',
'"${target_runtime}/jwebot_release_update.py"', '"${target_runtime}/jwebot_release_update.py"',
'jwebot_log "release settlement ready"', 'jwebot_log "release settlement ready"',
@@ -142,10 +146,20 @@ 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( self.assertLess(
source.index("trap - EXIT HUP INT TERM"), source.index("trap - EXIT HUP INT TERM"),
source.index('jwebot_log "release settlement starting"'), 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( self.assertLess(
source.index('jwebot_log "release settlement ready"'), source.index('jwebot_log "release settlement ready"'),
source.index('jwebot_log "deployment ready version='), source.index('jwebot_log "deployment ready version='),
+395 -3
View File
@@ -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(
@@ -694,6 +699,42 @@ class LedgerAndApplicationTests(unittest.TestCase):
).fetchone()[0] ).fetchone()[0]
self.assertEqual(count, 1) 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()
@@ -884,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.4.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("更新摘要:新增版本更新补偿流程", 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")
@@ -1092,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")):
@@ -1122,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()
@@ -1151,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
+9 -1
View File
@@ -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,
@@ -93,7 +94,9 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin-transfer": JWEBOT_AUTHOR, "coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR, "coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": 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,
@@ -120,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",
+8 -2
View File
@@ -270,11 +270,17 @@ class Release063GameTests(unittest.TestCase):
sender="Bob", sender="Bob",
event_id="wallet-after-transfer-b", event_id="wallet-after-transfer-b",
) )
self.assertIn("alice:当前摩拉 750;无贷款;", alice_wallet) self.assertIn(
"alice:当前摩拉 750;原石 0 个;扫码概率券 0 张;无贷款;",
alice_wallet,
)
self.assertIn("最近10次账户变动:", alice_wallet) self.assertIn("最近10次账户变动:", alice_wallet)
self.assertIn("-250:转账给 bob", alice_wallet) self.assertIn("-250:转账给 bob", alice_wallet)
self.assertIn("+1000:首次初始化", alice_wallet) self.assertIn("+1000:首次初始化", alice_wallet)
self.assertIn("bob:当前摩拉 1250;无贷款;", bob_wallet) self.assertIn(
"bob:当前摩拉 1250;原石 0 个;扫码概率券 0 张;无贷款;",
bob_wallet,
)
self.assertIn("+250:转账 来自 alice", 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):
+192
View File
@@ -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()
+46 -1
View File
@@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase): class ReleaseUpdateTests(unittest.TestCase):
release = "beta 1.0.4.1016" release = "beta 1.1.2.1019"
def setUp(self): def setUp(self):
self.temporary = tempfile.TemporaryDirectory() self.temporary = tempfile.TemporaryDirectory()
@@ -112,8 +112,44 @@ class ReleaseUpdateTests(unittest.TestCase):
announcement = MODULE.build_announcement(self.release, summary) announcement = MODULE.build_announcement(self.release, summary)
self.assertIn(f"{self.release} 更新现已完成", announcement) self.assertIn(f"{self.release} 更新现已完成", announcement)
self.assertIn("更新补偿:摩拉 x200", announcement) self.assertIn("更新补偿:摩拉 x200", announcement)
self.assertNotIn("原石 x1", announcement)
self.assertNotIn("第二条", 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): def test_release_update_grants_once_and_announces_once(self):
delivered = [] delivered = []
@@ -148,6 +184,7 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2]) self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2])
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250])
for state in self.account_states(): for state in self.account_states():
self.assertEqual(state["primogems"], 0)
matches = [ matches = [
event event
for event in state["coin_events"] for event in state["coin_events"]
@@ -156,6 +193,12 @@ class ReleaseUpdateTests(unittest.TestCase):
] ]
self.assertEqual(len(matches), 1) self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["created_at"], applied_at.timestamp()) 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(self.announcement_marker()["state"], "delivered")
self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1) self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1)
@@ -173,6 +216,7 @@ class ReleaseUpdateTests(unittest.TestCase):
deliverer=uncertain, deliverer=uncertain,
) )
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) 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") self.assertEqual(self.announcement_marker()["state"], "uncertain")
called = [] called = []
@@ -203,6 +247,7 @@ class ReleaseUpdateTests(unittest.TestCase):
self.assertEqual(result["compensation"], "already_applied") self.assertEqual(result["compensation"], "already_applied")
self.assertEqual(len(called), 1) self.assertEqual(len(called), 1)
self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) 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") self.assertEqual(self.announcement_marker()["state"], "delivered")