Author SHA1 Message Date
JWeBot Contributors f07ae653c7 feat: add multiplayer lottery and game records
check / local-gate (push) Failing after 1m35s
2026-10-15 13:57:32 +08:00
JWeBot Contributors 0731e5cebb fix: bound chat latency and clarify blackjack flow 2026-10-15 13:37:57 +08:00
JWeBot Contributors f7fedc54e4 release: alpha 0.6.1 local player plugins and capture reconciliation
check / local-gate (push) Failing after 3m24s
2026-10-15 05:01:40 +08:00
43 changed files with 2995 additions and 41 deletions
+53
View File
@@ -1,5 +1,58 @@
# Changelog # Changelog
## alpha 0.6.3.1015
- Replace instant personal lottery tickets with one persistent multiplayer
lottery round per group: start, register/update, status, draw, and cancel.
- Treat each ticket as three distinct digits from 0-9. Sort digits before
storage so `347` and `743` represent the same selection.
- Accept spaced and compact OCR forms. Ticket stakes are 1-500 coins with a
default of 10 when omitted.
- Return 0%, 50%, 120%, or 300% of the stake for matching zero, one, two, or
three drawn digits. All settlement values remain integer coins.
- Commit entry updates, stake differences, all-player payouts, cancellation
refunds, and event replay responses in serialized SQLite transactions.
- Raise the blackjack stake range to 1-500 coins.
- Add per-game personal sequence numbers and `/游戏记录` (`/gamehistory`) for
prize draws, lottery rounds, and blackjack hands.
- Prefix game output with the configured player label and game number, such as
`Jacky #1:...`; aliases share the same counter and account.
## alpha 0.6.2.1015
- Bound direct DeepSeek SSE reads to 30 seconds of total wall time in addition
to the existing 18-second socket inactivity timeout.
- Stop reading as soon as the SSE `[DONE]` marker arrives instead of waiting
for the upstream connection to close.
- Prevent a trickling or unfinished upstream response from occupying both
generation workers for about 15 minutes and delaying later final replies.
- Preserve fast ACK delivery, three-attempt retry behavior, two generation
workers, and strict final-reply ordering.
- Treat `/21点 开始 100` as a 100-coin stake and accept OCR forms with missing
whitespace, such as `/21点开始100` and `/blackjackstart100`.
- Keep blackjack play as explicit steps: start and deal first, then hit or
stand in a separate command. Combined forms return this workflow without
opening a game or deducting funds.
- Prefix blackjack hands, status and settlement replies with the current
group-scoped player's configured display label, such as `jacky:...`.
## alpha 0.6.1.1015
- Add three reviewed plugins: local context summaries for 100/200/300 messages,
person and alias lookup, and single-player virtual coin games.
- Provide corresponding Chinese and English commands, including `/context`,
`/people`, `/person`, `/wallet`, `/draw`, `/lottery`, and `/blackjack`.
- Grant each group-scoped person 1000 virtual coins once; persist account state,
private blackjack decks, timeout/forfeit rules, and win/loss statistics.
- Commit funds, game state and event replay responses in one SQLite transaction;
retries never debit twice, and aliases share the canonical person's account.
- Reconcile the message image after a two-second idle wait so a missed final
XDamage notification does not strand an isolated message; drain buffered Xlib
events before waiting on the socket. Preserve visual deduplication and guards.
- Keep the generated feature list within the sender's 900-character boundary.
- Document local extractive-summary coverage, OCR identity limitations,
game payouts, persistence and rollback behavior.
## alpha 0.6.0.1015 ## alpha 0.6.0.1015
- Replace repository-local and hardcoded deployment values with independent, - Replace repository-local and hardcoded deployment values with independent,
+37 -2
View File
@@ -8,7 +8,18 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.0.1015`。 当前版本:`alpha 0.6.3.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。
0.6.2 为 DeepSeek 流式回复增加 30 秒总时限,避免未结束的上游连接长期
占满生成 worker;21 点开局金额明确作为本局下注,并兼容 OCR 丢失空格。
21 点牌面和结算以当前玩家展示名开头。快速 ACK、重试和严格顺序投递
规则保持不变。
0.6.3 将彩票改为群内多人轮次,加入报名、状态、主持人开奖和取消;
抽奖、彩票、21 点统一使用 `Jacky #N:...` 局号并支持 `/游戏记录`。
完整玩法见[多人彩票与游戏记录](docs/group-lottery-0.6.3.md)。
### 运行流程 ### 运行流程
@@ -72,6 +83,13 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
```text ```text
/功能列表 /功能列表
/上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw)
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/游戏记录 [页码](/gamehistory)
/游戏帮助(/games)
/丢骰子 [NdM] /丢骰子 [NdM]
/猜数字 开始 [上限] /猜数字 开始 [上限]
/石头剪刀布 [开始|加入|出|状态|结算|结束] /石头剪刀布 [开始|加入|出|状态|结算|结束]
@@ -240,7 +258,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.0.1015 alpha 0.6.3.1015
``` ```
## Runtime Flow ## Runtime Flow
@@ -337,6 +355,23 @@ the final separator shown below:
/steam时长Dota2人=玩家名 /steam时长Dota2人=玩家名
``` ```
Version 0.6.1 adds local `/context 100|200|300` extractive summaries,
`/people` and `/person` lookup, and `/wallet`, `/draw`, `/lottery`, `/blackjack`
single-player virtual coin games. Chinese aliases share the same implementations.
Accounts receive 1000 virtual coins once; funds, game state and event replay
results commit together. OCR display aliases identify group-scoped people;
native WeChat account IDs are unavailable. See
[plugin rules and capture reconciliation](docs/local-plugins-0.6.1.md).
Version 0.6.2 adds a 30-second total deadline to direct DeepSeek streams so an
unfinished upstream response cannot occupy both generation workers for about
15 minutes. Blackjack start amounts are the current round's stake; compact OCR
forms are accepted, while hit/stand remain explicit follow-up commands.
Blackjack hand and settlement replies start with the current player's configured
display label. ACKs, retries, and ordered final delivery remain unchanged.
Version 0.6.3 replaces instant lottery tickets with persistent multiplayer
group rounds, adds indexed personal game history, and raises blackjack stakes
to 1-500 coins. See [multiplayer lottery and game records](docs/group-lottery-0.6.3.md).
Steam prefers private per-person SteamID mappings from the active profile and Steam prefers private per-person SteamID mappings from the active profile and
uses the corresponding group display IDs and aliases. Without a private roster, uses the corresponding group display IDs and aliases. Without a private roster,
it discovers the configured account's public friend list at runtime. No real it discovers the configured account's public friend list at runtime. No real
+1 -1
View File
@@ -1 +1 @@
alpha 0.6.0.1015 alpha 0.6.3.1015
+6 -1
View File
@@ -25,7 +25,8 @@ error. They never fall through to DeepSeek or OpenClaw.
The local command path is: The local command path is:
1. XDamage reports a relevant message-region change. 1. XDamage reports a relevant message-region change, or a two-second idle
reconciliation capture detects a missed final drawing.
2. OpenCV segments incoming bubbles without running OCR over the whole screen. 2. OpenCV segments incoming bubbles without running OCR over the whole screen.
3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU. 3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU.
4. Only new or changed sender/body crops use RapidOCR. 4. Only new or changed sender/body crops use RapidOCR.
@@ -45,6 +46,10 @@ because each bubble position still produces its own `IncomingMessage`.
Changed pixels force fresh OCR. The cache is bounded and periodically Changed pixels force fresh OCR. The cache is bounded and periodically
revalidated. revalidated.
For the three local plugins added in `alpha 0.6.1.1015`, including bilingual
commands, person identity limitations and atomic single-player game settlement,
see [local plugin rules](local-plugins-0.6.1.md).
## Bundled Plugin Registration ## Bundled Plugin Registration
Bundled command metadata has one source: Bundled command metadata has one source:
+68
View File
@@ -0,0 +1,68 @@
# alpha 0.6.3.1015 多人彩票与游戏记录
## 一把彩票怎么玩
1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。
2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100
金币。也可简写为 `/彩票347100`;省略金额时默认下注 10。
3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。
4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字,
统一排序后结算。
5. 主持人也可在开奖前发送 `/彩票 取消`,所有报名者全额退款。
命令关键词和数字之间的空格均可省略。`/买彩票` 和 `/lottery` 保留为
对应别名。
## 选号与报名
- 每张票必须是三个互不重复的 0~9 数字。
- 数字顺序不影响结果:`347`、`743` 和 `3 4 7` 都保存为 `3 4 7`。
- 每人每把只有一张票。再次报名会更新号码和下注,不增加个人局号。
- 更新到更高下注时只补扣差额;降低下注时退回差额。
- 下注范围是 1~500,省略时为 10。
- 没有进行中的彩票时不能报名;没有报名者时不能开奖。
- 只有创建本轮的主持人可以开奖或取消,状态查询对群内所有人开放。
支持的报名示例:
```text
/彩票 347
/彩票 743 100
/彩票 报名 3 4 7 100
/彩票报名347金额100
/彩票347100
/lottery join 347 100
```
## 返还规则
报名时先扣除下注。开奖号和玩家号码按集合比较,返还包含本金:
| 命中数 | 返还 |
|---|---:|
| 0 个 | 0% |
| 1 个 | 50% |
| 2 个 | 120% |
| 3 个 | 300% |
金币只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个
返还 120,净变化为 `+20`。
## 个人局号与记录
抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示
`Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时,
共用同一账户、局号和历史。
发送 `/游戏记录 [页码]`、`/个人记录 [页码]` 或
`/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、
结果、下注、返还和净变化,最多保留最近 50 条。
21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100;
发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。
## 持久化与并发
彩票轮次、报名和结算保存在 Edge SQLite。报名/改注、所有玩家的开奖返还、
取消退款和事件重放响应都在 `BEGIN IMMEDIATE` 事务中提交。重复事件不会
重复扣款、发奖或增加局号;进程重启后未开奖轮次和个人记录仍然保留。
+107
View File
@@ -0,0 +1,107 @@
# alpha 0.6.1.1015 本地插件与消息抓取
本版本增加三个插件。全部由本机命令引擎执行,不调用 DeepSeek 或
OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,OCR 丢失
命令与参数间的空格时仍可解析。正常使用时仍需 @ 当前配置的机器人。
| 插件 | 中文指令 | 英文指令 |
|---|---|---|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
| single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
| single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
| single-player-games | `/游戏记录 [页码]` | `/gamehistory [page]` |
| single-player-games | `/游戏帮助` | `/games` |
| person-directory | `/人物列表 [页码]`、`/别名列表 [页码]` | `/people [页码]` |
| person-directory | `/人物 [名称或别名]`、`/个人属性 [名称或别名]` | `/person [名称或别名]` |
命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。
每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。
注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额;
游戏虚拟余额使用 `/金币`。
## 上下文总结
默认选择最近 100 条,也可选择 200 或 300。查询限定当前群,排除当前
请求事件,按观察时间和事件 ID 选出最近记录,再恢复时间顺序。
不足指定数量时明确报告实际数量;没有记录时返回空历史提示。
总结采用本地抽取式方法:统计参与人数、时间范围、重复话题,选出五条
具有代表性的内容并按时间排列。它保留原话,不生成未经原文支持的决定
或待办。覆盖范围是本机已经成功记录的文字消息,不包含图片、语音、
未加载的微信历史或本机没观察到的消息。所有历史只保存在本机私有状态。
独立的 `context_history` 保留短消息,避免复读排行的长度过滤影响最近
100/200/300 条计数;启动时从现有复读历史补入旧记录,查询时从 OCR
历史导入已识别发送人的文字。
## 人物与账户索引
当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名
解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算
person_key。相同人物的已配置别名共用金币和牌局,其他群使用独立账户。
没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。
`/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、
截短的群内索引、别名和经过安全过滤的基本属性,不返回 SteamID 或密钥。
`/人物列表` 每页五人。真实人物资料仍只存放于 `~/.config/jwebot/`,
仓库没有生产人物或映射文件。
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
应优先追加别名而不是重命名。用户不能通过游戏命令指定他人的账户,
不能充值、转账、赠币或兑换真实资产。
## 虚拟金币与游戏管理
每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
[多人彩票与游戏记录](group-lottery-0.6.3.md)。
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场,
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
牌堆、双方手牌、下注和最后操作时间持久化。A 可作为 1 或 11;
庄家软 17 停牌。开始后只公开一张庄家牌;要牌、停牌、认输和查询都
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
独立玩,不能加入或结束你的牌局。旧的群游戏保持独立。
Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存:
1. `player_accounts` 中的金币、牌局和胜负统计;
2. `player_actions` 中的事件、命令及响应。
同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更
人物或命令则拒绝。事务失败全部回滚,避免扣款后丢失结果。新的独立
指令会创建新的游戏操作,即使文字相同也不会被当成重试。
数据库迁移仅新增表,旧版本可以忽略它们;运行文件回滚不会主动删除
游戏数据。使用协调部署器回滚整库时,以部署前已验证备份为恢复基线。
## 单条消息抓取修复
独立观察证据表明,新气泡完成绘制后有时没有对应的有效 XDamage
事件;先前截图仍是旧布局,后续消息的绘制才唤醒生产抓取。
保留 XDamage 快速路径,同时将无有效变化时的复核等待窗口缩短到两秒。
空闲超时后走相同截图、跟随最新消息、视觉哈希和缓冲去重流程。
画面没变化时不进入重复 OCR;最终绘制漏事件时也能补抓。等待 X socket
前先检查 Xlib 已缓冲事件,避免用户态已有事件却阻塞等待另一次通知。
新增 `source=reconcile` 遥测用于识别补抓路径。两秒是等待窗口,
不是端到端响应承诺;截图、OCR、生成和 GUI 投递仍有各自耗时。
短周期复核增加空闲截图开销,上线后需观测实际负载。
群指纹、窗口就绪、GUI 串行、视觉确认和投递去重规则保持有效。
@@ -0,0 +1,35 @@
# single-player-games
Author: `jwebot`
虚拟金币账户、抽奖、群内多人彩票和单人21点
Help:
- `/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory`
- `/彩票 开始|报名|当前状态|开奖|取消;/lottery`
- `/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)`
- `/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end`
- `/游戏帮助;/games`
Registered commands:
- `/金币`
- `/wallet`
- `/抽奖`
- `/draw`
- `/彩票`
- `/买彩票`
- `/lottery`
- `/21点`
- `/二十一点`
- `/blackjack`
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
- `/游戏帮助`
- `/games`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `single-player-games` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "single-player-games"
author = "jwebot"
description = "虚拟金币账户、抽奖、群内多人彩票和单人21点"
help = ["/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory", "/彩票 开始|报名|当前状态|开奖|取消;/lottery", "/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)", "/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end", "/游戏帮助;/games"]
version = "1.0.0"
api_version = "1"
entrypoint = "single_player_games:plugin"
type = "command"
[capabilities]
commands = ["/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"]
storage = false
network_profiles = []
service_profiles = ["single-player-games"]
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for single-player-games."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
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(),
},
)
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 single_player_games 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, '虚拟金币账户、抽奖、群内多人彩票和单人21点')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games'))
self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,18 @@
# context-summary
Author: `official`
提取当前群最近100、200或300条本地文字的上下文
Help:
- `/上下文 [100|200|300];/context [100|200|300]`
Registered commands:
- `/上下文`
- `/context`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `context-summary` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "context-summary"
author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文"
help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0"
api_version = "1"
entrypoint = "context_summary:plugin"
type = "command"
[capabilities]
commands = ["/上下文", "/context"]
storage = false
network_profiles = []
service_profiles = ["context-summary"]
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for context-summary."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
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(),
},
)
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 context_summary 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, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,22 @@
# person-directory
Author: `official`
查询当前群已配置人物、基本属性和别名
Help:
- `/人物列表 [页码] /people;/人物 [名称或别名] /person`
Registered commands:
- `/人物列表`
- `/别名列表`
- `/people`
- `/人物`
- `/个人属性`
- `/person`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `person-directory` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "person-directory"
author = "official"
description = "查询当前群已配置人物、基本属性和别名"
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
version = "1.0.0"
api_version = "1"
entrypoint = "person_directory:plugin"
type = "command"
[capabilities]
commands = ["/人物列表", "/别名列表", "/people", "/人物", "/个人属性", "/person"]
storage = false
network_profiles = []
service_profiles = ["person-directory"]
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for person-directory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'person-directory'
AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
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(),
},
)
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 person_directory 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, 'official')
self.assertEqual(DESCRIPTION, '查询当前群已配置人物、基本属性和别名')
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
self.assertIn('/人物列表', registered)
self.assertEqual(len(registered), 6)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "0.6.0a0+1015" version = "0.6.3a0+1015"
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__ = "0.6.0a0+1015" __version__ = "0.6.3a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "0.6.0a0+1015" version = "0.6.3a0+1015"
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==0.6.0a0+1015", "jwebot-sdk==0.6.3a0+1015",
"jwebot-kernel==0.6.0a0+1015", "jwebot-kernel==0.6.3a0+1015",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.0a0+1015" __version__ = "0.6.3a0+1015"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "0.6.0a0+1015" version = "0.6.3a0+1015"
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==0.6.0a0+1015", "jwebot-config==0.6.3a0+1015",
"jwebot-sdk==0.6.0a0+1015", "jwebot-sdk==0.6.3a0+1015",
"jwebot-official-extensions==0.6.0a0+1015", "jwebot-official-extensions==0.6.3a0+1015",
"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__ = "0.6.0a0+1015" __version__ = "0.6.3a0+1015"
@@ -68,6 +68,11 @@ from jwebot_official_extensions.steam import (
SteamClient, SteamClient,
SteamUnavailable, SteamUnavailable,
) )
from jwebot_official_extensions.player_games import (
append_game_record,
next_game_index,
normalize_player_account,
)
from jwebot_sdk import Event, PlanKind from jwebot_sdk import Event, PlanKind
@@ -92,6 +97,10 @@ MAX_REPLY_CHARS = 900
MAX_CONTEXT_TURNS = 6 MAX_CONTEXT_TURNS = 6
MAX_OUTBOX_ATTEMPTS = 3 MAX_OUTBOX_ATTEMPTS = 3
DIRECT_CHAT_GENERATION_WORKERS = 2 DIRECT_CHAT_GENERATION_WORKERS = 2
DIRECT_API_SOCKET_TIMEOUT_SECONDS = 18.0
DIRECT_API_WALL_TIMEOUT_SECONDS = 30.0
DIRECT_API_MAX_RESPONSE_BYTES = 2_000_000
DIRECT_API_READ_CHUNK_BYTES = 65_536
FAST_ACKNOWLEDGEMENT = "喵......" FAST_ACKNOWLEDGEMENT = "喵......"
PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3 PROFILE_MIN_AUTOMATIC_OBSERVATIONS = 3
PROFILE_MAX_FACTS = 12 PROFILE_MAX_FACTS = 12
@@ -626,6 +635,59 @@ def deepseek_response_content(raw: bytes) -> str:
return str(content) return str(content)
def read_direct_api_response(
response: Any,
*,
wall_timeout_seconds: float = DIRECT_API_WALL_TIMEOUT_SECONDS,
max_bytes: int = DIRECT_API_MAX_RESPONSE_BYTES,
) -> bytes:
"""Read an SSE response with an absolute deadline, not an idle timeout."""
deadline = time.monotonic() + wall_timeout_seconds
chunks: list[bytes] = []
total = 0
stream_tail = b""
done_marker = re.compile(
rb"(?:^|\r?\n)data:\s*\[DONE\]\s*(?:\r?\n|$)"
)
socket_connection = getattr(
getattr(getattr(response, "fp", None), "raw", None),
"_sock",
None,
)
read_chunk = getattr(response, "read1", response.read)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise DeepSeekUnavailable("direct API exceeded total timeout")
if socket_connection is not None:
socket_connection.settimeout(
max(
0.001,
min(DIRECT_API_SOCKET_TIMEOUT_SECONDS, remaining),
)
)
try:
chunk = read_chunk(
min(DIRECT_API_READ_CHUNK_BYTES, max_bytes - total + 1)
)
except (TimeoutError, OSError) as error:
raise DeepSeekUnavailable("direct API transport failure") from error
if not chunk:
break
if not isinstance(chunk, bytes):
raise DeepSeekUnavailable("direct API returned invalid bytes")
total += len(chunk)
if total > max_bytes:
raise DeepSeekUnavailable("direct API response exceeded size limit")
chunks.append(chunk)
stream_scan = stream_tail + chunk
if done_marker.search(stream_scan):
break
stream_tail = stream_scan[-64:]
return b"".join(chunks)
def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None: def load_environment_file(path: Path = ENVIRONMENT_FILE) -> None:
"""Load the active generated profile, then the legacy compatibility file.""" """Load the active generated profile, then the legacy compatibility file."""
@@ -941,6 +1003,73 @@ class Ledger:
observed_at TEXT NOT NULL, observed_at TEXT NOT NULL,
created_at TEXT NOT NULL created_at TEXT NOT NULL
); );
CREATE TABLE IF NOT EXISTS context_history (
event_id TEXT PRIMARY KEY,
group_fingerprint TEXT NOT NULL,
display_name TEXT NOT NULL,
text TEXT NOT NULL,
observed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS context_history_group_time
ON context_history(group_fingerprint, observed_at, event_id);
INSERT OR IGNORE INTO context_history
SELECT event_id, group_fingerprint, display_name, text, observed_at
FROM repeat_history;
CREATE TABLE IF NOT EXISTS player_accounts (
group_fingerprint TEXT NOT NULL,
person_key TEXT NOT NULL,
state_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, person_key)
);
CREATE TABLE IF NOT EXISTS player_actions (
group_fingerprint TEXT NOT NULL,
event_id TEXT NOT NULL,
person_key TEXT NOT NULL,
command TEXT NOT NULL,
response_text TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, event_id)
);
CREATE TABLE IF NOT EXISTS group_lottery_rounds (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
state TEXT NOT NULL,
host_person_key TEXT NOT NULL,
host_display_name TEXT NOT NULL,
drawn_numbers_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (group_fingerprint, round_number)
);
CREATE TABLE IF NOT EXISTS group_lottery_entries (
group_fingerprint TEXT NOT NULL,
round_number INTEGER NOT NULL,
person_key TEXT NOT NULL,
display_name TEXT NOT NULL,
player_game_index INTEGER NOT NULL,
numbers_json TEXT NOT NULL,
stake INTEGER NOT NULL,
matches INTEGER NOT NULL DEFAULT 0,
payout INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (
group_fingerprint, round_number, person_key
),
FOREIGN KEY (group_fingerprint, round_number)
REFERENCES group_lottery_rounds (
group_fingerprint, round_number
)
ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS group_lottery_one_open
ON group_lottery_rounds(group_fingerprint)
WHERE state = 'open';
CREATE INDEX IF NOT EXISTS group_lottery_entries_round
ON group_lottery_entries(
group_fingerprint, round_number, created_at, person_key
);
CREATE INDEX IF NOT EXISTS events_group_created CREATE INDEX IF NOT EXISTS events_group_created
ON events(group_fingerprint, created_at); ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due CREATE INDEX IF NOT EXISTS outbox_due
@@ -2713,14 +2842,20 @@ class Ledger:
) -> bool: ) -> bool:
content = bounded_text(text, limit=1_500) content = bounded_text(text, limit=1_500)
repeat_key = repeat_text_key(content) repeat_key = repeat_text_key(content)
if not event_id or not group_fingerprint or not content:
return False
person = self.resolve_person(group_fingerprint, sender)
with self.lock:
self.connection.execute(
"INSERT OR IGNORE INTO context_history VALUES (?, ?, ?, ?, ?)",
(event_id, group_fingerprint, person_display_label(person["display_name"]),
content, observed_at),
)
if ( if (
not event_id not repeat_key
or not group_fingerprint
or not repeat_key
or len(repeat_key) < 2 or len(repeat_key) < 2
): ):
return False return False
person = self.resolve_person(group_fingerprint, sender)
with self.lock, self.connection: with self.lock, self.connection:
cursor = self.connection.execute( cursor = self.connection.execute(
""" """
@@ -2846,6 +2981,659 @@ class Ledger:
).fetchall() ).fetchall()
return [dict(row) for row in rows] return [dict(row) for row in rows]
def recent_context_messages(
self, group: str, limit: int, *, exclude_event_id: str = "",
) -> list[dict[str, Any]]:
if limit not in {100, 200, 300}:
raise LocalCommandError("上下文数量只支持 100、200、300")
with self.lock:
rows = self.connection.execute(
"""
SELECT event_id, display_name, text, observed_at
FROM context_history
WHERE group_fingerprint = ? AND event_id != ?
ORDER BY observed_at DESC, event_id DESC LIMIT ?
""", (group, exclude_event_id, limit),
).fetchall()
return [dict(row) for row in reversed(rows)]
def person_directory(self, group: str) -> list[dict[str, Any]]:
self.seed_person_aliases(group)
with self.lock:
rows = self.connection.execute(
"""
SELECT canonical_name, alias_key, match_kind FROM person_aliases
WHERE group_fingerprint = ?
ORDER BY canonical_name, match_kind, alias_key
""", (group,),
).fetchall()
people: dict[str, dict[str, Any]] = {}
for row in rows:
name = str(row["canonical_name"])
person = people.setdefault(name, {
"display_name": person_display_label(name),
"person_key": person_scope_key(group, name),
"aliases": [], "prefixes": [],
})
person["prefixes" if row["match_kind"] == "prefix" else "aliases"].append(
str(row["alias_key"]))
# Include profiles with a canonical name even when no alias was configured.
for template in PERSON_ALIAS_TEMPLATES:
name = str(template["canonical_name"])
people.setdefault(name, {
"display_name": person_display_label(name),
"person_key": person_scope_key(group, name),
"aliases": [], "prefixes": [],
})
return list(people.values())
def _player_identity(self, group: str, sender: str) -> dict[str, str]:
if not group or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person["display_name"] = person_display_label(person["display_name"])
return person
def _player_action_replay(
self,
group: str,
event_id: str,
person_key: str,
command: str,
) -> str | None:
previous = self.connection.execute(
"""
SELECT person_key, command, response_text FROM player_actions
WHERE group_fingerprint = ? AND event_id = ?
""",
(group, event_id),
).fetchone()
if previous is None:
return None
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更账户或游戏指令")
return str(previous["response_text"])
def _load_player_account(
self,
group: str,
person_key: str,
) -> dict[str, Any]:
stored = self.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, person_key),
).fetchone()
if stored is None:
return normalize_player_account({})
try:
account = json.loads(str(stored["state_json"]))
except json.JSONDecodeError as error:
raise LocalCommandError("金币账户状态无效") from error
if not isinstance(account, dict):
raise LocalCommandError("金币账户状态无效")
return normalize_player_account(account)
def _save_player_account(
self,
group: str,
person_key: str,
account: dict[str, Any],
timestamp: str,
) -> None:
normalize_player_account(account)
if (
type(account["balance"]) is not int
or not 0 <= account["balance"] <= 1_000_000_000
):
raise LocalCommandError("金币结算超出允许范围")
self.connection.execute(
"""
INSERT INTO player_accounts VALUES (?, ?, ?, ?)
ON CONFLICT(group_fingerprint, person_key) DO UPDATE
SET state_json = excluded.state_json,
updated_at = excluded.updated_at
""",
(
group,
person_key,
json.dumps(account, ensure_ascii=False),
timestamp,
),
)
def _store_player_action(
self,
group: str,
event_id: str,
person_key: str,
command: str,
response: str,
timestamp: str,
) -> None:
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(group, event_id, person_key, command, response, timestamp),
)
def _open_group_lottery(self, group: str) -> sqlite3.Row | None:
return self.connection.execute(
"""
SELECT * FROM group_lottery_rounds
WHERE group_fingerprint = ? AND state = 'open'
LIMIT 1
""",
(group,),
).fetchone()
def _group_lottery_entries(
self,
group: str,
round_number: int,
) -> list[dict[str, Any]]:
rows = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
ORDER BY created_at, person_key
""",
(group, round_number),
).fetchall()
entries: list[dict[str, Any]] = []
for row in rows:
entry = dict(row)
try:
numbers = json.loads(str(entry["numbers_json"]))
except json.JSONDecodeError:
numbers = []
entry["numbers"] = [
int(value)
for value in numbers
if type(value) is int and 0 <= value <= 9
]
entries.append(entry)
return entries
def start_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
row = self._open_group_lottery(group)
status = "already_open"
if row is None:
round_number = int(
self.connection.execute(
"""
SELECT COALESCE(MAX(round_number), 0) + 1
FROM group_lottery_rounds
WHERE group_fingerprint = ?
""",
(group,),
).fetchone()[0]
)
timestamp = utc_now()
self.connection.execute(
"""
INSERT INTO group_lottery_rounds (
group_fingerprint, round_number, state,
host_person_key, host_display_name,
drawn_numbers_json, created_at, updated_at
) VALUES (?, ?, 'open', ?, ?, '[]', ?, ?)
""",
(
group,
round_number,
person_key,
person["display_name"],
timestamp,
timestamp,
),
)
row = self._open_group_lottery(group)
status = "started"
if row is None:
raise LocalCommandError("彩票创建失败")
data = dict(row)
data["status"] = status
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def group_lottery_status(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"entries": [],
}
else:
data = dict(row)
data["status"] = "open"
data["entries"] = self._group_lottery_entries(
group, int(row["round_number"])
)
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def register_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
numbers: tuple[int, int, int],
stake: int,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {
"status": "no_round",
"display_name": person["display_name"],
}
else:
round_number = int(row["round_number"])
existing = self.connection.execute(
"""
SELECT * FROM group_lottery_entries
WHERE group_fingerprint = ? AND round_number = ?
AND person_key = ?
""",
(group, round_number, person_key),
).fetchone()
account = self._load_player_account(group, person_key)
old_stake = int(existing["stake"]) if existing else 0
delta = stake - old_stake
if delta > int(account["balance"]):
data = {
"status": "insufficient",
"display_name": person["display_name"],
"balance": int(account["balance"]),
}
else:
account["balance"] -= delta
index = (
int(existing["player_game_index"])
if existing
else next_game_index(account, "lottery")
)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self.connection.execute(
"""
INSERT INTO group_lottery_entries (
group_fingerprint, round_number, person_key,
display_name, player_game_index, numbers_json,
stake, matches, payout, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
ON CONFLICT (
group_fingerprint, round_number, person_key
) DO UPDATE SET
display_name = excluded.display_name,
numbers_json = excluded.numbers_json,
stake = excluded.stake,
matches = 0,
payout = 0,
updated_at = excluded.updated_at
""",
(
group,
round_number,
person_key,
person["display_name"],
index,
canonical_json(list(numbers)),
stake,
timestamp,
timestamp,
),
)
data = {
"status": "updated" if existing else "registered",
"display_name": person["display_name"],
"player_game_index": index,
"round_number": round_number,
"numbers": list(numbers),
"stake": stake,
"balance": int(account["balance"]),
}
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def draw_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
drawn_numbers: tuple[int, int, int],
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
if (
len(drawn_numbers) != 3
or len(set(drawn_numbers)) != 3
or any(type(value) is not int or not 0 <= value <= 9 for value in drawn_numbers)
):
raise LocalCommandError("彩票开奖号无效")
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
if not entries:
data = {
"status": "empty",
"round_number": round_number,
}
else:
timestamp = utc_now()
settled: list[dict[str, Any]] = []
drawn = set(drawn_numbers)
rates = {0: 0, 1: 50, 2: 120, 3: 300}
for entry in entries:
key = str(entry["person_key"])
account = self._load_player_account(group, key)
matches = len(drawn.intersection(entry["numbers"]))
stake = int(entry["stake"])
payout = stake * rates[matches] // 100
account["balance"] += payout
account[
"wins" if payout > stake else "losses"
] += 1
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome=f"命中{matches}个",
stake=stake,
payout=payout,
summary=(
"选号 "
+ " ".join(map(str, entry["numbers"]))
+ ";开奖号 "
+ " ".join(map(str, drawn_numbers))
),
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_entries
SET matches = ?, payout = ?, updated_at = ?
WHERE group_fingerprint = ?
AND round_number = ? AND person_key = ?
""",
(
matches,
payout,
timestamp,
group,
round_number,
key,
),
)
result = dict(entry)
result.update(
{
"matches": matches,
"payout": payout,
"balance": int(account["balance"]),
}
)
settled.append(result)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'drawn', drawn_numbers_json = ?,
updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(
canonical_json(list(drawn_numbers)),
timestamp,
group,
round_number,
),
)
data = {
"status": "drawn",
"round_number": round_number,
"drawn_numbers": list(drawn_numbers),
"entries": settled,
}
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def cancel_group_lottery(
self,
group: str,
sender: str,
event_id: str,
command: str,
render: Callable[[dict[str, Any]], str],
) -> str:
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
row = self._open_group_lottery(group)
if row is None:
data: dict[str, Any] = {"status": "no_round"}
elif str(row["host_person_key"]) != person_key:
data = {
"status": "not_host",
"host_display_name": str(row["host_display_name"]),
}
else:
round_number = int(row["round_number"])
entries = self._group_lottery_entries(
group, round_number
)
timestamp = utc_now()
for entry in entries:
key = str(entry["person_key"])
stake = int(entry["stake"])
account = self._load_player_account(group, key)
account["balance"] += stake
append_game_record(
account,
kind="lottery",
index=int(entry["player_game_index"]),
outcome="本轮取消",
stake=stake,
payout=stake,
summary="主持人取消,下注已全额退回",
)
self._save_player_account(
group, key, account, timestamp
)
self.connection.execute(
"""
UPDATE group_lottery_rounds
SET state = 'cancelled', updated_at = ?
WHERE group_fingerprint = ? AND round_number = ?
""",
(timestamp, group, round_number),
)
data = {
"status": "cancelled",
"round_number": round_number,
"entries": entries,
}
response = render(data)
timestamp = utc_now()
self._store_player_action(
group, event_id, person_key, command, response, timestamp
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def player_game_history(
self,
group: str,
sender: str,
) -> dict[str, Any]:
person = self._player_identity(group, sender)
with self.lock:
account = self._load_player_account(
group, str(person["person_key"])
)
history = sorted(
account["game_history"],
key=lambda entry: float(entry.get("completed_at", 0)),
reverse=True,
)
return {
"display_name": person["display_name"],
"history": history,
"game_counts": dict(account["game_counts"]),
}
def apply_player_action(
self, group: str, sender: str, event_id: str, command: str,
action: Callable[[dict[str, Any]], str],
) -> str:
"""Commit identity-scoped funds, game state and replay response together."""
person = self._player_identity(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
replay = self._player_action_replay(
group, event_id, person_key, command
)
if replay is not None:
self.connection.execute("COMMIT")
return replay
account = self._load_player_account(group, person_key)
response = action(account)
timestamp = utc_now()
self._save_player_account(
group, person_key, account, timestamp
)
self._store_player_action(
group,
event_id,
person_key,
command,
response,
timestamp,
)
self.connection.execute("COMMIT")
return response
except BaseException:
self.connection.execute("ROLLBACK")
raise
def status(self) -> dict[str, Any]: def status(self) -> dict[str, Any]:
with self.lock: with self.lock:
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0] events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
@@ -3292,8 +4080,11 @@ class DeepSeekClient:
method="POST", method="POST",
) )
try: try:
with urllib.request.urlopen(request, timeout=18) as response: with urllib.request.urlopen(
raw = response.read(2_000_000) request,
timeout=DIRECT_API_SOCKET_TIMEOUT_SECONDS,
) as response:
raw = read_direct_api_response(response)
except urllib.error.HTTPError as error: except urllib.error.HTTPError as error:
raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error raise DeepSeekUnavailable(f"direct API HTTP {error.code}") from error
except (urllib.error.URLError, TimeoutError, OSError) as error: except (urllib.error.URLError, TimeoutError, OSError) as error:
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "0.6.0a0+1015" version = "0.6.3a0+1015"
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==0.6.0a0+1015", "jwebot-sdk==0.6.3a0+1015",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -62,4 +62,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "0.6.0a0+1015" __version__ = "0.6.3a0+1015"
@@ -0,0 +1,37 @@
"""Generated reviewed adapter for context-summary."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
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(),
},
)
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,37 @@
"""Generated reviewed adapter for person-directory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'person-directory'
AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
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(),
},
)
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,37 @@
"""Generated reviewed adapter for single-player-games."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '虚拟金币账户、抽奖、群内多人彩票和单人21点'
HELP_ENTRIES = ('/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory', '/彩票 开始|报名|当前状态|开奖|取消;/lottery', '/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)', '/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
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(),
},
)
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))
@@ -38,6 +38,29 @@ LOCAL_COMMAND_ALIASES = {
"debug": "telemetry_debug", "debug": "telemetry_debug",
"余额": "deepseek_balance", "余额": "deepseek_balance",
"steam": "steam_friends", "steam": "steam_friends",
"上下文": "context_summary",
"context": "context_summary",
"金币": "wallet",
"wallet": "wallet",
"抽奖": "prize_draw",
"draw": "prize_draw",
"彩票": "lottery",
"买彩票": "lottery",
"lottery": "lottery",
"21点": "blackjack",
"二十一点": "blackjack",
"blackjack": "blackjack",
"游戏记录": "game_history",
"个人记录": "game_history",
"gamehistory": "game_history",
"游戏帮助": "game_rules",
"games": "game_rules",
"人物列表": "person_directory",
"别名列表": "person_directory",
"people": "person_directory",
"人物": "person_info",
"个人属性": "person_info",
"person": "person_info",
} }
@@ -91,6 +114,40 @@ DIRECT_KERNEL_COMMAND_KINDS = frozenset(
) )
LOCAL_EXTENSIONS = ( LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="context-summary",
author=OFFICIAL_AUTHOR,
description="提取当前群最近100、200或300条本地文字的上下文",
help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
command_kinds=("context_summary",),
),
LocalExtensionDescriptor(
extension_id="single-player-games",
author=JWEBOT_AUTHOR,
description="虚拟金币账户、抽奖、群内多人彩票和单人21点",
help_entries=(
"/金币 /wallet;/抽奖 [金额] /draw;/游戏记录 /gamehistory",
"/彩票 开始|报名|当前状态|开奖|取消;/lottery",
"/21点 开始 [1~500](金额=本局下注,默认50,空格可省略)",
"/21点 要牌|停牌|状态|结束;/blackjack hit|stand|status|end",
"/游戏帮助;/games",
),
command_kinds=(
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
),
LocalExtensionDescriptor(
extension_id="person-directory",
author=OFFICIAL_AUTHOR,
description="查询当前群已配置人物、基本属性和别名",
help_entries=("/人物列表 [页码] /people;/人物 [名称或别名] /person",),
command_kinds=("person_directory", "person_info"),
),
LocalExtensionDescriptor( LocalExtensionDescriptor(
extension_id="person-memory", extension_id="person-memory",
author=OFFICIAL_AUTHOR, author=OFFICIAL_AUTHOR,
@@ -237,7 +294,6 @@ def render_feature_list() -> str:
) )
for descriptor in LOCAL_EXTENSIONS: for descriptor in LOCAL_EXTENSIONS:
lines.append( lines.append(
f"[{descriptor.author}/{descriptor.extension_id}] "
f"{descriptor.description}:" f"{descriptor.description}:"
+ ";".join(descriptor.help_entries) + ";".join(descriptor.help_entries)
) )
@@ -9,11 +9,13 @@ import json
import re import re
import subprocess import subprocess
import time import time
from collections import Counter
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Callable from typing import Any, Callable
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
from .player_games import PlayerGames
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60 LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
@@ -104,6 +106,7 @@ class LocalCommandEngine:
self.person_display_label = person_display_label self.person_display_label = person_display_label
self.profile_fact_is_safe = profile_fact_is_safe self.profile_fact_is_safe = profile_fact_is_safe
self.profile_expiry = profile_expiry self.profile_expiry = profile_expiry
self.players = PlayerGames(ledger, person_display_label)
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str: def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
question = str(row["question"]) question = str(row["question"])
@@ -112,6 +115,19 @@ class LocalCommandEngine:
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能") raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
if kind == "feature_list": if kind == "feature_list":
return self.reply_cleaner(self.feature_list()) return self.reply_cleaner(self.feature_list())
if kind == "context_summary":
return self.reply_cleaner(self.context_summary(row, arguments))
if kind in {"person_directory", "person_info"}:
return self.reply_cleaner(self.person_index(row, kind, arguments))
if kind in {
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
}:
return self.reply_cleaner(self.players.handle(row, kind, arguments))
if kind == "dice": if kind == "dice":
return self.reply_cleaner(self.dice(row, arguments)) return self.reply_cleaner(self.dice(row, arguments))
if kind == "dice_challenge": if kind == "dice_challenge":
@@ -229,6 +245,82 @@ class LocalCommandEngine:
) )
return f"本地记录命中“{query}”:{listing}" return f"本地记录命中“{query}”:{listing}"
def context_summary(self, row: dict[str, Any], arguments: str) -> str:
arguments = arguments.strip()
if arguments.strip() not in {"", "100", "200", "300"}:
return "用法:/上下文 100|200|300(/context),默认100条"
limit = int(arguments or 100)
self.ledger.bootstrap_repeat_history()
records = self.ledger.recent_context_messages(
str(row["group_fingerprint"]), limit,
exclude_event_id=str(row["event_id"]))
if not records:
return "当前群还没有可总结的本地文字记录"
# Local extractive summary: rank representative messages, retaining chronology.
stop = {"这个", "那个", "就是", "然后", "现在", "我们", "你们", "可以",
"不是", "一个", "什么", "怎么", "感觉", "还是", "已经", "没有",
"the", "and", "you", "that", "this", "jwebot"}
texts = [re.sub(r"^@\S+\s+", "", normalize_text(record["text"])) for record in records]
tokens = [set(re.findall(r"[a-zA-Z][a-zA-Z0-9_-]{2,}|[\u4e00-\u9fff]{2}", text.casefold())) - stop
for text in texts]
frequency = Counter(token for message in tokens for token in message)
topics = [token for token, count in frequency.most_common(6) if count > 1]
scored = sorted(range(len(records)), key=lambda i: (
-sum(frequency[token] for token in tokens[i]),
-int(bool(re.search(r"决定|计划|问题|需要|建议|修复|明天|[??]", texts[i]))),
-i))
selected: list[int] = []
seen: set[str] = set()
for index in scored:
if texts[index] in seen or texts[index].lstrip().startswith("/"):
continue
selected.append(index)
seen.add(texts[index])
if len(selected) == 5:
break
if not selected:
selected = list(range(max(0, len(records) - 3), len(records)))
people = len({record["display_name"] for record in records})
lines = [f"上下文总结(本地提取):请求最近{limit}条,实际{len(records)}条,{people}人。",
f"范围 {records[0]['observed_at'][:16]} ~ {records[-1]['observed_at'][:16]}。",
"主要话题:" + ("、".join(topics) if topics else "话题分散,以下为代表性内容。")]
for index in sorted(selected):
record = records[index]
lines.append(f"• {record['display_name']}:{texts[index][:100]}")
lines.append("仅覆盖本机已记录的当前群文字;问题或计划按原话提取。")
return "\n".join(lines)
def person_index(self, row: dict[str, Any], kind: str, arguments: str) -> str:
group = str(row["group_fingerprint"])
directory = self.ledger.person_directory(group)
if kind == "person_directory":
if arguments and not re.fullmatch(r"[1-9]\d{0,2}", arguments):
return "用法:/人物列表 [页码](/people),每页5人"
page = int(arguments or 1)
pages = max(1, (len(directory) + 4) // 5)
if page > pages:
return f"只有 {pages} 页;/人物列表 1"
lines = [f"当前群人物与别名({page}/{pages},共{len(directory)}人)"]
for person in directory[(page - 1) * 5:page * 5]:
aliases = "、".join(person["aliases"])[:80] or "未配置"
prefixes = "、".join(person["prefixes"])[:40]
lines.append(f"{person['display_name']}:{aliases}" + (f";前缀 {prefixes}*" if prefixes else ""))
lines.append("/人物 [名称或别名](/person)查看基本属性;/人物列表 页码 翻页")
return "\n".join(lines)
requested = normalize_text(arguments) or str(row["sender"])
person = self.ledger.resolve_person(group, requested)
entry = next((item for item in directory if item["person_key"] == person["person_key"]), None)
if arguments and entry is None:
return "当前群没有这个已配置人物或别名;/人物列表 查看索引"
facts = str(person.get("identity_fact", ""))
if facts and not self.profile_fact_is_safe(facts):
facts = ""
aliases = "、".join(entry["aliases"])[:160] if entry else "未配置"
return (f"人物:{self.person_display_label(person['display_name'])};"
f"群内索引 {person['person_key'][:12]};别名:{aliases or '未配置'}。\n"
f"基本属性:{facts or '暂无配置'}。\n"
"本人金币 /金币;个人记忆 /我的记忆。身份由本地昵称映射解析,无法读取微信原生账号ID。")
@staticmethod @staticmethod
def service_state(service_name: str) -> str: def service_state(service_name: str) -> str:
try: try:
@@ -0,0 +1,578 @@
"""Single-player virtual currency rules, invoked by the reviewed Kernel service."""
from __future__ import annotations
import random
import re
import time
from typing import Any
INITIAL_COINS = 1000
MIN_DRAW_BET = 2
MAX_DRAW_BET = 100
MIN_LOTTERY_BET = 1
MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
GAME_TIMEOUT_SECONDS = 600
GAME_HISTORY_LIMIT = 50
GAME_HISTORY_PAGE_SIZE = 5
GAME_LABELS = {
"prize_draw": "抽奖",
"lottery": "彩票",
"blackjack": "21点",
}
BLACKJACK_ACTIONS = {
"要牌": "hit",
"hit": "hit",
"停牌": "stand",
"stand": "stand",
"状态": "status",
"status": "status",
"结束": "end",
"end": "end",
}
BLACKJACK_START = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?$"
)
BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$"
)
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None)
account.setdefault("wins", 0)
account.setdefault("losses", 0)
account.setdefault("ties", 0)
counts = account.get("game_counts")
if not isinstance(counts, dict):
counts = {}
account["game_counts"] = counts
for kind in GAME_LABELS:
value = counts.get(kind, 0)
counts[kind] = value if type(value) is int and value >= 0 else 0
history = account.get("game_history")
if not isinstance(history, list):
history = []
account["game_history"] = [
entry for entry in history if isinstance(entry, dict)
][-GAME_HISTORY_LIMIT:]
return account
def next_game_index(account: dict[str, Any], kind: str) -> int:
normalize_player_account(account)
counts = account["game_counts"]
counts[kind] = int(counts.get(kind, 0)) + 1
return int(counts[kind])
def append_game_record(
account: dict[str, Any],
*,
kind: str,
index: int,
outcome: str,
stake: int,
payout: int,
summary: str,
) -> None:
normalize_player_account(account)
history = [
entry
for entry in account["game_history"]
if not (
entry.get("kind") == kind
and int(entry.get("index", 0)) == index
)
]
history.append(
{
"kind": kind,
"index": index,
"outcome": outcome[:80],
"stake": stake,
"payout": payout,
"summary": summary[:240],
"completed_at": time.time(),
}
)
account["game_history"] = history[-GAME_HISTORY_LIMIT:]
def card_label(card: int) -> str:
return ("A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K")[card % 13]
def hand_value(cards: list[int]) -> int:
total = sum(min(card % 13 + 1, 10) if card % 13 else 11 for card in cards)
aces = sum(card % 13 == 0 for card in cards)
while total > 21 and aces:
total -= 10
aces -= 1
return total
class PlayerGames:
def __init__(self, ledger: Any, display_label: Any) -> None:
self.ledger = ledger
self.display_label = display_label
self.rng = random.SystemRandom()
@staticmethod
def rules() -> str:
return (
"每人首次使用获 1000 虚拟金币;/金币 查看余额;/游戏记录 查看本人记录。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
"命中1/2/3个返下注的50%/120%/300%。\n"
"21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n"
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值、转账或兑换。"
)
def handle(self, row: dict[str, Any], kind: str, arguments: str) -> str:
arguments = arguments.strip()
if kind == "game_rules":
return self.rules()
if arguments.casefold() in {"帮助", "规则", "help", "rules"}:
return self.rules()
if kind == "game_history":
return self.game_history(row, arguments)
if kind == "lottery":
return self.group_lottery(row, arguments)
if kind == "wallet" and arguments:
return "用法:/金币(/wallet),查看本人的虚拟金币;/游戏帮助 查看规则"
if kind == "prize_draw":
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/抽奖 [2~100金币](/draw),默认10金币"
bet = int(arguments or 10)
elif kind == "blackjack":
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted)
if start is not None:
action = "start"
bet = int(start["bet"] or 50)
else:
action = BLACKJACK_ACTIONS.get(compacted, "")
bet = 0
if not action:
combined = BLACKJACK_COMBINED_ACTION.fullmatch(compacted)
if combined is not None:
amount = combined["bet"] or "50"
return (
"21点要分两步:先发送 /21点 开始 "
f"{amount}({amount} 是本局下注);看到牌后再单独发送 "
"/21点 要牌 或 /21点 停牌"
)
return (
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
)
else:
bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
return "抽奖下注必须为 2~100 的整数金币"
if (
kind == "blackjack"
and action == "start"
and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET
):
return "21点下注必须为 1~500 的整数金币"
person = self.ledger.resolve_person(
str(row["group_fingerprint"]),
str(row["sender"]),
)
player_label = self.display_label(person["display_name"])
def player_reply(text: str, index: int | None = None) -> str:
suffix = f" #{index}" if index is not None else ""
return f"{player_label}{suffix}:{text}"
def execute(account: dict[str, Any]) -> str:
normalize_player_account(account)
expired = ""
game = account.get("game")
if game and (
type(game.get("index")) is not int
or int(game["index"]) < 1
):
game["index"] = next_game_index(account, "blackjack")
if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS:
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome="超时认输",
stake=int(game["bet"]),
payout=0,
summary="10分钟无有效操作,按认输结束",
)
account["game"] = None
account["losses"] += 1
expired = "上一局已超时,按认输结束;"
if kind == "wallet":
state = "进行中" if account.get("game") else "无进行中的牌局"
return (f"{expired}金币 {account['balance']};21点 {state};"
f"已结算胜 {account['wins']} / 负 {account['losses']} / 平 {account['ties']}。"
"首次赠送1000仅发一次;/游戏帮助 查看规则")
if kind == "blackjack" and action in {"hit", "stand", "status", "end"}:
active = account.get("game")
index = int(active["index"]) if active else None
return player_reply(
expired + self.play_blackjack(account, action),
index,
)
if account.get("game"):
message = expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
return (
player_reply(message, int(account["game"]["index"]))
if kind == "blackjack"
else message
)
if account["balance"] < bet:
message = expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
return player_reply(message) if kind == "blackjack" else message
account["balance"] -= bet
if kind == "blackjack":
index = next_game_index(account, "blackjack")
deck = list(range(52))
self.rng.shuffle(deck)
game = {"bet": bet, "deck": deck[4:], "player": deck[:2],
"dealer": deck[2:4], "updated_at": time.time(),
"index": index}
account["game"] = game
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
return player_reply(
expired + self.finish_blackjack(account, natural=True),
index,
)
return player_reply(expired + self.show_hand(game), index)
index = next_game_index(account, "prize_draw")
roll = self.rng.randrange(100)
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
account["balance"] += payout
account["wins" if payout > bet else "losses"] += 1
append_game_record(
account,
kind="prize_draw",
index=index,
outcome=result,
stake=bet,
payout=payout,
summary=f"抽奖结果 {result}",
)
return player_reply(
expired
+ f"{result};下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};金币 {account['balance']}",
index,
)
return self.ledger.apply_player_action(
str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute)
@staticmethod
def parse_lottery_entry(
arguments: str,
) -> tuple[tuple[int, int, int], int] | None:
value = arguments.strip().casefold()
for prefix in ("报名", "join"):
if value.startswith(prefix):
value = value[len(prefix):].strip()
break
if not value:
return None
marked = re.fullmatch(
r"([0-9])\s*([0-9])\s*([0-9])"
r"(?:金额|下注|bet)(\d{1,3})",
re.sub(r"[,,、]", "", value),
)
stake: int
if marked is not None:
digits = tuple(int(marked[index]) for index in range(1, 4))
stake = int(marked[4])
else:
tokens = re.sub(r"[,,、]", " ", value).split()
digits: tuple[int, int, int]
if len(tokens) == 1 and tokens[0].isdigit():
compact = tokens[0]
if not 3 <= len(compact) <= 6:
return None
digits = tuple(int(item) for item in compact[:3])
stake = int(compact[3:] or DEFAULT_LOTTERY_BET)
elif (
len(tokens) == 2
and re.fullmatch(r"\d{3}", tokens[0])
and re.fullmatch(r"\d{1,3}", tokens[1])
):
digits = tuple(int(item) for item in tokens[0])
stake = int(tokens[1])
elif (
len(tokens) in {3, 4}
and all(re.fullmatch(r"\d", item) for item in tokens[:3])
and (
len(tokens) == 3
or re.fullmatch(r"\d{1,3}", tokens[3])
)
):
digits = tuple(int(item) for item in tokens[:3])
stake = (
int(tokens[3])
if len(tokens) == 4
else DEFAULT_LOTTERY_BET
)
else:
return None
if len(set(digits)) != 3:
raise ValueError("彩票的三个数字必须互不重复")
if not MIN_LOTTERY_BET <= stake <= MAX_LOTTERY_BET:
raise ValueError("彩票下注必须为 1~500 的整数金币")
return tuple(sorted(digits)), stake
def group_lottery(self, row: dict[str, Any], arguments: str) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = "lottery:" + arguments
compacted = re.sub(r"\s+", "", arguments.casefold())
def status_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;发送 /彩票 开始 创建新一把"
entries = data["entries"]
lines = [
f"彩票第 {data['round_number']} 把正在报名;"
f"主持人 {data['host_display_name']};已报名 {len(entries)} 人"
]
for entry in entries:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"{' '.join(map(str, entry['numbers']))},下注 {entry['stake']}"
)
if not entries:
lines.append("暂无报名;/彩票 报名 3 4 7 [金额]")
return "\n".join(lines)
if compacted in {"开始", "start"}:
return self.ledger.start_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
f"彩票第 {data['round_number']} 把"
+ ("已经在报名中" if data["status"] == "already_open" else "已开始")
+ f";主持人 {data['host_display_name']}。\n"
"报名:/彩票 3 4 7 [金额](金额1~500,默认10,空格可省略);"
"查询:/彩票 当前状态;主持人开奖:/彩票 开奖"
),
)
if compacted in {"当前状态", "状态", "status"}:
return self.ledger.group_lottery_status(
group,
sender,
event_id,
command,
status_text,
)
if compacted in {"开奖", "draw"}:
drawn = tuple(sorted(self.rng.sample(range(10), 3)))
def draw_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有可以开奖的彩票"
if data["status"] == "not_host":
return (
f"只有本轮主持人 {data['host_display_name']} 可以开奖"
)
if data["status"] == "empty":
return "本轮还没有人报名,不能开奖"
lines = [
f"彩票第 {data['round_number']} 把开奖号:"
f"{' '.join(map(str, data['drawn_numbers']))}"
]
for entry in data["entries"]:
lines.append(
f"{entry['display_name']} #{entry['player_game_index']}:"
f"命中 {entry['matches']} 个;下注 {entry['stake']},"
f"返还 {entry['payout']},净变化 "
f"{entry['payout'] - entry['stake']:+d};"
f"金币 {entry['balance']}"
)
return "\n".join(lines)
return self.ledger.draw_group_lottery(
group,
sender,
event_id,
command,
drawn,
draw_text,
)
if compacted in {"取消", "cancel"}:
return self.ledger.cancel_group_lottery(
group,
sender,
event_id,
command,
lambda data: (
"当前没有进行中的彩票"
if data["status"] == "no_round"
else (
f"只有本轮主持人 {data['host_display_name']} 可以取消"
if data["status"] == "not_host"
else (
f"彩票第 {data['round_number']} 把已取消;"
f"{len(data['entries'])} 人的下注已全额退回"
)
)
),
)
try:
parsed = self.parse_lottery_entry(arguments)
except ValueError as error:
return str(error)
if parsed is None:
return (
"用法:先 /彩票 开始;再 /彩票 报名 3 4 7 [金额]"
"(也可 /彩票347,金额1~500、默认10);"
"/彩票 当前状态;主持人 /彩票 开奖"
)
numbers, stake = parsed
def register_text(data: dict[str, Any]) -> str:
if data["status"] == "no_round":
return "当前没有进行中的彩票;先发送 /彩票 开始"
if data["status"] == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"报名需要 {stake}"
)
action = "报名已更新" if data["status"] == "updated" else "报名成功"
return (
f"{data['display_name']} #{data['player_game_index']}:"
f"彩票第 {data['round_number']} 把{action};号码 "
f"{' '.join(map(str, data['numbers']))};下注 {data['stake']};"
f"金币 {data['balance']}"
)
return self.ledger.register_group_lottery(
group,
sender,
event_id,
command,
numbers,
stake,
register_text,
)
def game_history(self, row: dict[str, Any], arguments: str) -> str:
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/游戏记录 [页码](/gamehistory),每页5条"
page = int(arguments or 1)
if page < 1:
return "游戏记录页码必须从 1 开始"
data = self.ledger.player_game_history(
str(row["group_fingerprint"]),
str(row["sender"]),
)
records = data["history"]
pages = max(1, (len(records) + GAME_HISTORY_PAGE_SIZE - 1) // GAME_HISTORY_PAGE_SIZE)
if page > pages:
return f"{data['display_name']} 只有 {pages} 页游戏记录"
start = (page - 1) * GAME_HISTORY_PAGE_SIZE
lines = [f"{data['display_name']} 的游戏记录 {page}/{pages}:"]
for record in records[start:start + GAME_HISTORY_PAGE_SIZE]:
label = GAME_LABELS.get(str(record["kind"]), str(record["kind"]))
lines.append(
f"{data['display_name']} #{record['index']}:{label} {record['outcome']};"
f"下注 {record['stake']},返还 {record['payout']},"
f"净变化 {record['payout'] - record['stake']:+d}"
)
if not records:
lines.append("暂无已结算记录")
return "\n".join(lines)
@staticmethod
def show_hand(game: dict[str, Any]) -> str:
player = " ".join(card_label(card) for card in game["player"])
return (f"21点:你的牌 {player}({hand_value(game['player'])}点);"
f"庄家 {card_label(game['dealer'][0])} + 暗牌;下注 {game['bet']}。\n"
"/21点 要牌(hit)或 /21点 停牌(stand)")
def play_blackjack(self, account: dict[str, Any], action: str) -> str:
game = account.get("game")
if not game:
return "本人没有进行中的21点;/21点 开始 [金额](/blackjack start)"
if action == "status":
return self.show_hand(game)
if action == "end":
index = int(game["index"])
bet = int(game["bet"])
append_game_record(
account,
kind="blackjack",
index=index,
outcome="主动认输",
stake=bet,
payout=0,
summary="主动结束牌局,不退下注",
)
account["game"] = None
account["losses"] += 1
return f"本人的21点已认输结束,不退下注;金币 {account['balance']}"
game["updated_at"] = time.time()
if action == "hit":
game["player"].append(game["deck"].pop())
if hand_value(game["player"]) > 21:
return self.finish_blackjack(account)
return self.show_hand(game)
while hand_value(game["dealer"]) < 17:
game["dealer"].append(game["deck"].pop())
return self.finish_blackjack(account)
@staticmethod
def finish_blackjack(account: dict[str, Any], *, natural: bool = False) -> str:
game = account["game"]
player = hand_value(game["player"])
dealer = hand_value(game["dealer"])
bet = game["bet"]
if player > 21 or natural and dealer == 21 and player != 21:
outcome, payout, stat = "你输了", 0, "losses"
elif player == dealer:
outcome, payout, stat = "平局", bet, "ties"
elif natural and player == 21:
outcome, payout, stat = "自然21点获胜", bet * 5 // 2, "wins"
elif dealer > 21 or player > dealer:
outcome, payout, stat = "你赢了", bet * 2, "wins"
else:
outcome, payout, stat = "你输了", 0, "losses"
account["balance"] += payout
account[stat] += 1
append_game_record(
account,
kind="blackjack",
index=int(game["index"]),
outcome=outcome,
stake=int(bet),
payout=int(payout),
summary=(
f"玩家{player}点,庄家{dealer}点"
),
)
account["game"] = None
return (f"21点结算:{outcome};你的牌 {' '.join(map(card_label, game['player']))}({player}点),"
f"庄家 {' '.join(map(card_label, game['dealer']))}({dealer}点);"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}")
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-sdk" name = "jwebot-sdk"
version = "0.6.0a0+1015" version = "0.6.3a0+1015"
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__ = "0.6.0a0+1015" __version__ = "0.6.3a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.0.1015" release = "alpha 0.6.3.1015"
production_deployments_enabled_by_default = false production_deployments_enabled_by_default = false
[tool.jwebot.workspace] [tool.jwebot.workspace]
+93
View File
@@ -0,0 +1,93 @@
"""Lost final-draw events must not strand an isolated incoming message."""
import unittest
from unittest import mock
from test_wechat_local_ocr import MODULE
class ReconcileTests(unittest.TestCase):
def test_final_drawing_without_damage_is_captured_and_duplicates_are_suppressed(self):
class Monitor:
window_id = 42
def __enter__(self):
return self
def __exit__(self, *_):
pass
def wait_for_message_change(self):
return next(self.changes)
changes = iter((True, False, False))
buffer = MODULE.CaptureFrameBuffer(5)
outcomes = []
offer = buffer.offer
def observe(frame):
outcome = offer(frame)
outcomes.append(outcome)
if len(outcomes) == 3:
buffer.close()
return outcome
images = [MODULE.Image.new("RGB", (2, 2), color) for color in ("white", "white", "black")]
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
mock.patch.object(MODULE, "window_id", return_value="42"), \
mock.patch.object(MODULE, "capture_image", side_effect=images) as captures, \
mock.patch.object(MODULE, "message_view_at_loaded_bottom", return_value=True), \
mock.patch.object(MODULE, "message_image", side_effect=lambda image: image), \
mock.patch.object(MODULE, "incoming_frame_hashes", side_effect=[
("old-layout", "old-visual"), ("old-layout", "old-visual"), ("new-layout", "new-visual")]), \
mock.patch.object(buffer, "offer", side_effect=observe), \
mock.patch.object(MODULE, "telemetry_event") as telemetry:
recorder.capture_damage_frames(buffer)
self.assertEqual(captures.call_count, 3)
self.assertEqual(outcomes, ["enqueued", "duplicate", "enqueued"])
self.assertEqual(buffer.get().visual_hash, "old-visual")
self.assertEqual(buffer.get().visual_hash, "new-visual")
self.assertEqual([call.kwargs["source"] for call in telemetry.call_args_list
if call.args == ("ocr.capture",)], ["damage", "reconcile", "reconcile"])
self.assertLessEqual(MODULE.DAMAGE_HEARTBEAT_SECONDS, 2)
def test_reconciliation_does_not_bypass_window_identity_check(self):
class Monitor:
window_id = 42
def __enter__(self):
return self
def __exit__(self, *_):
pass
def wait_for_message_change(self):
return False
buffer = MODULE.CaptureFrameBuffer(2)
recorder = MODULE.Recorder.__new__(MODULE.Recorder)
with mock.patch.object(MODULE, "XDamageMonitor", return_value=Monitor()), \
mock.patch.object(MODULE, "window_id", return_value="43"), \
mock.patch.object(MODULE, "capture_image") as capture:
recorder.capture_damage_frames(buffer)
capture.assert_not_called()
with self.assertRaises(MODULE.CaptureProducerError):
buffer.get()
def test_xlib_buffered_event_is_drained_before_waiting_for_socket(self):
monitor = MODULE.XDamageMonitor.__new__(MODULE.XDamageMonitor)
monitor._display = object()
monitor._descriptor = 42
monitor._x11 = mock.Mock()
monitor._x11.XPending.side_effect = [1, 0]
with mock.patch.object(monitor, "_drain_events", return_value=True) as drain, \
mock.patch.object(MODULE.select, "select", return_value=([], [], [])) as select:
self.assertTrue(monitor.wait_for_message_change())
drain.assert_called_once()
select.assert_called_once()
if __name__ == "__main__":
unittest.main()
+51 -2
View File
@@ -704,7 +704,7 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(response["route"], "local") self.assertEqual(response["route"], "local")
self.assertEqual(response["execution_path"], "local-command") self.assertEqual(response["execution_path"], "local-command")
self.assertIn("由内核和插件描述自动生成", response["text"]) self.assertIn("由内核和插件描述自动生成", response["text"])
self.assertIn("[jwebot/dice]", response["text"]) self.assertIn("/丢骰子", response["text"])
self.assertIn("骰子和多人骰子挑战", response["text"]) self.assertIn("骰子和多人骰子挑战", response["text"])
self.assertIn("/steam", response["text"]) self.assertIn("/steam", response["text"])
self.assertIn("[DeepSeek]", response["text"]) self.assertIn("[DeepSeek]", response["text"])
@@ -1736,7 +1736,7 @@ class DirectClientTests(unittest.TestCase):
) )
) )
response = mock.MagicMock() response = mock.MagicMock()
response.read.return_value = ( response.read1.return_value = (
'data: {"choices":[{"delta":{"content":"正"}}]}\n\n' 'data: {"choices":[{"delta":{"content":"正"}}]}\n\n'
'data: {"choices":[{"delta":{"content":"常"}}]}\n\n' 'data: {"choices":[{"delta":{"content":"常"}}]}\n\n'
"data: [DONE]\n\n" "data: [DONE]\n\n"
@@ -1782,6 +1782,55 @@ class DirectClientTests(unittest.TestCase):
finally: finally:
temporary.cleanup() temporary.cleanup()
def test_stream_reader_stops_at_done_without_waiting_for_eof(self):
response = mock.MagicMock()
response.read1.side_effect = [
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
b"data: [DO",
b"NE]\n\n",
AssertionError("reader continued after the stream completed"),
]
raw = MODULE.read_direct_api_response(response)
self.assertEqual(
raw,
b'data: {"choices":[{"delta":{"content":"ok"}}]}\n'
b"data: [DONE]\n\n",
)
self.assertEqual(response.read1.call_count, 2)
def test_stream_reader_does_not_treat_content_as_done_marker(self):
response = mock.MagicMock()
response.read1.side_effect = [
b'data: {"choices":[{"delta":{"content":"data: [DONE]"}}]}\n\n',
b"data: [DONE]\n\n",
AssertionError("reader continued after the stream completed"),
]
raw = MODULE.read_direct_api_response(response)
self.assertIn(b'"content":"data: [DONE]"', raw)
self.assertTrue(raw.endswith(b"data: [DONE]\n\n"))
self.assertEqual(response.read1.call_count, 2)
def test_stream_reader_enforces_total_wall_time(self):
response = mock.MagicMock()
response.read1.return_value = b": keep-alive\n\n"
with mock.patch.object(
MODULE.time,
"monotonic",
side_effect=[0.0, 0.0, 31.0],
):
with self.assertRaisesRegex(
MODULE.DeepSeekUnavailable,
"exceeded total timeout",
):
MODULE.read_direct_api_response(response)
response.fp.raw._sock.settimeout.assert_called_once_with(18.0)
response.read1.assert_called_once()
def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self): def test_stream_parser_accepts_a_non_streaming_compatibility_payload(self):
self.assertEqual( self.assertEqual(
MODULE.deepseek_response_content( MODULE.deepseek_response_content(
+12 -1
View File
@@ -69,7 +69,6 @@ class LocalExtensionInventoryTests(unittest.TestCase):
with self.subTest(extension=extension.extension_id): with self.subTest(extension=extension.extension_id):
self.assertTrue(extension.description) self.assertTrue(extension.description)
self.assertTrue(extension.help_entries) self.assertTrue(extension.help_entries)
self.assertIn(extension.extension_id, help_text)
self.assertIn(extension.description, help_text) self.assertIn(extension.description, help_text)
for entry in extension.help_entries: for entry in extension.help_entries:
self.assertIn(entry, help_text) self.assertIn(entry, help_text)
@@ -81,6 +80,9 @@ class LocalExtensionInventoryTests(unittest.TestCase):
for extension in LOCAL_EXTENSIONS for extension in LOCAL_EXTENSIONS
}, },
{ {
"context-summary": OFFICIAL_AUTHOR,
"person-directory": OFFICIAL_AUTHOR,
"single-player-games": JWEBOT_AUTHOR,
"person-memory": OFFICIAL_AUTHOR, "person-memory": OFFICIAL_AUTHOR,
"server-status": OFFICIAL_AUTHOR, "server-status": OFFICIAL_AUTHOR,
"jwebot-version": OFFICIAL_AUTHOR, "jwebot-version": OFFICIAL_AUTHOR,
@@ -108,6 +110,15 @@ class LocalExtensionInventoryTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
combined, combined,
{ {
"single-player-games": (
"wallet",
"prize_draw",
"lottery",
"blackjack",
"game_history",
"game_rules",
),
"person-directory": ("person_directory", "person_info"),
"person-memory": ( "person-memory": (
"remember", "remember",
"my_memory", "my_memory",
+208
View File
@@ -0,0 +1,208 @@
"""Behavioral coverage for local context, identity and single-player funds."""
import json
import random
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
from jwebot_official_extensions.local_commands import local_command_parts
from jwebot_official_extensions.player_games import hand_value
class Release061Tests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local
self.counter = 0
def tearDown(self):
self.engine.ledger.connection.close()
self.temp.cleanup()
def command(self, question, *, sender="Alice", group="group-a", event_id=None):
self.counter += 1
row = {"question": question, "sender": sender, "group_fingerprint": group,
"event_id": event_id or f"test-{self.counter}"}
return self.local.handle(row, {})
def state(self, sender="Alice", group="group-a"):
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
row = self.engine.ledger.connection.execute(
"SELECT state_json FROM player_accounts WHERE group_fingerprint=? AND person_key=?",
(group, key)).fetchone()
return json.loads(row[0])
def test_aliases_are_corresponding_and_case_insensitive(self):
pairs = [("上下文", "Context"), ("金币", "wallet"), ("抽奖", "draw"),
("买彩票", "lottery"), ("21点", "blackjack"),
("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")]
for chinese, english in pairs:
self.assertEqual(local_command_parts("/" + chinese), local_command_parts("/" + english))
self.assertEqual(local_command_parts("/Context200"), ("context_summary", "200"))
def test_context_scope_count_order_and_current_request_exclusion(self):
ledger = self.engine.ledger
for i in range(305):
ledger.record_repeat_history(event_id=f"history-{i:04d}", group_fingerprint="group-a",
sender="Alice", text=f"项目计划讨论编号 {i}",
observed_at=f"2026-10-15T00:{i // 60:02d}:{i % 60:02d}+00:00")
ledger.record_repeat_history(event_id="private-other", group_fingerprint="group-b",
sender="Bob", text="隔离群秘密话题", observed_at="2026-10-15T01:00:00+00:00")
for count in (100, 200, 300):
rows = ledger.recent_context_messages("group-a", count, exclude_event_id="history-0304")
self.assertEqual(len(rows), count)
self.assertEqual(rows[-1]["event_id"], "history-0303")
self.assertLess(rows[0]["observed_at"], rows[-1]["observed_at"])
with mock.patch.object(ledger, "bootstrap_repeat_history"):
result = self.command(f"/context {count}")
self.assertIn(f"实际{count}条", result)
self.assertNotIn("隔离群", result)
self.assertIn("用法", self.command("/context 101"))
def test_person_lookup_uses_alias_but_does_not_expose_steam_ids(self):
self.assertIn("alice", self.command("/people"))
self.assertIn("Alice is a configured test profile", self.command("/person a1"))
self.assertIn("群内索引", self.command("/个人属性"))
self.assertIn("没有", self.command("/person unconfigured"))
self.assertIn("只有", self.command("/people 99"))
def test_context_keeps_short_messages_omitted_from_repeat_statistics(self):
ledger = self.engine.ledger
self.assertFalse(ledger.record_repeat_history(
event_id="short", group_fingerprint="group-a", sender="Alice",
text="好", observed_at="2026-10-15T00:00:00+00:00"))
records = ledger.recent_context_messages("group-a", 100)
self.assertEqual([row["text"] for row in records], ["好"])
self.assertEqual(ledger.search_repeat_history("group-a", "好"), [])
def test_initial_funds_aliases_groups_and_replay(self):
self.assertIn("金币 1000", self.command("/wallet", event_id="wallet-1"))
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
first = self.command("/draw 10", event_id="draw-1")
replay = self.command("/draw 10", sender="a1", event_id="draw-1")
self.assertEqual(first, replay)
self.assertEqual(self.state("a1")["balance"], 990)
self.assertIn("金币 1000", self.command("/wallet", sender="Bob"))
self.assertIn("金币 1000", self.command("/wallet", group="group-b"))
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local
self.assertIn("金币 990", self.command("/wallet", event_id="restart"))
def test_concurrent_replay_debits_once_and_rolls_back_failure(self):
replies = []
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
threads = [threading.Thread(target=lambda: replies.append(
self.command("/draw 100", event_id="concurrent"))) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(len(replies), 8)
self.assertEqual(len(set(replies)), 1)
self.assertEqual(self.state()["balance"], 900)
def broken(state):
state["balance"] = -1
return "bad"
with self.assertRaises(MODULE.LocalCommandError):
self.engine.ledger.apply_player_action("group-a", "Alice", "broken", "test", broken)
self.assertEqual(self.state()["balance"], 900)
with self.assertRaises(MODULE.LocalCommandError):
self.command("/draw 100", sender="Bob", event_id="concurrent")
def test_invalid_bets_insufficient_funds_and_lottery(self):
for question in ("/draw -1", "/draw 0", "/draw 101", "/lottery xyz",
"/blackjack start -1", "/blackjack hit 10"):
self.assertRegex(self.command(question), "用法|下注")
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
for _ in range(10):
self.command("/draw 100")
self.assertIn("金币不足", self.command("/draw 2"))
self.assertEqual(self.state()["balance"], 0)
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
# Player 10+7, dealer 9+8: standing produces a tie.
def deck(cards):
ordered = [9, 6, 8, 7] + [card for card in cards if card not in {9, 6, 8, 7}]
cards[:] = ordered
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=deck):
start = self.command("/21点 开始 50")
self.assertIn("暗牌", start)
self.assertEqual(self.state()["balance"], 950)
self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob"))
self.assertIn("已有一局", self.command("/draw"))
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local
settled = self.command("/blackjack stand", sender="a1", event_id="stand-1")
self.assertIn("平局", settled)
self.assertEqual(self.state()["balance"], 1000)
self.assertEqual(self.command("/blackjack stand", event_id="stand-1"), settled)
self.assertIsNone(self.state()["game"])
self.assertEqual(self.state()["ties"], 1)
def test_aces_natural_bust_timeout_and_end(self):
self.assertEqual(hand_value([0, 13, 9]), 12)
self.assertEqual(hand_value([0, 9]), 21)
def natural(cards):
first = [0, 9, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=natural):
reply = self.command("/blackjack start 50")
self.assertIn("自然21点获胜", reply)
self.assertEqual(self.state()["balance"], 1075)
self.local.players.rng = random.Random(4)
self.command("/blackjack start 50")
if self.state()["game"]:
with mock.patch("jwebot_official_extensions.player_games.time.time", return_value=10**10):
self.assertIn("超时", self.command("/wallet"))
self.assertIsNone(self.state()["game"])
self.local.players.rng = random.Random(4)
self.command("/blackjack start 50")
if self.state()["game"]:
self.assertIn("不退下注", self.command("/blackjack end"))
self.assertIsNone(self.state()["game"])
def test_adapter_and_direct_paths_agree_for_new_plugins(self):
for index, question in enumerate(("/games", "/wallet", "/people", "/person a1", "/context 200")):
kind, _ = local_command_parts(question)
row = {"question": question, "sender": "Alice", "group_fingerprint": "group-a",
"event_id": f"adapter-{index}"}
with mock.patch.object(self.engine.ledger, "bootstrap_repeat_history"):
direct = self.local.handle(row, {})
adapter = self.engine.extensions.handle(row, {})
self.assertEqual(adapter, direct)
def test_blackjack_bust_cannot_be_acted_on_by_another_player(self):
def bust_deck(cards):
first = [9, 22, 8, 7]
cards[:] = first + [card for card in cards if card not in first and card != 35] + [35]
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=bust_deck):
self.command("/blackjack start 100")
self.assertIn("没有进行中", self.command("/blackjack hit", sender="Bob"))
self.assertEqual(len(self.state()["game"]["player"]), 2)
result = self.command("/blackjack hit", event_id="bust")
self.assertIn("你输了", result)
self.assertEqual(self.state()["balance"], 900)
self.assertIsNone(self.state()["game"])
self.assertEqual(self.command("/blackjack hit", event_id="bust"), result)
self.assertEqual(self.state()["losses"], 1)
def test_simultaneous_natural_blackjack_returns_stake(self):
def tied_deck(cards):
first = [0, 9, 13, 22]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(self.local.players.rng, "shuffle", side_effect=tied_deck):
self.assertIn("平局", self.command("/blackjack start 50"))
self.assertEqual(self.state()["balance"], 1000)
self.assertIsNone(self.state()["game"])
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
"""Behavioral coverage for the alpha 0.6.2 latency and blackjack fixes."""
import json
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
class Release062BlackjackTests(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.ledger.connection.close()
self.temp.cleanup()
def command(self, question, *, event_id=None, sender="Alice"):
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": "group-a",
"event_id": event_id or f"test-{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()
return json.loads(row[0])
@staticmethod
def hit_deck(cards):
first = [4, 5, 8, 7]
cards[:] = (
first
+ [card for card in cards if card not in first and card != 1]
+ [1]
)
def test_compact_start_sets_stake_then_separate_hit_replays_once(self):
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=self.hit_deck,
):
start = self.command("/21点开始100", event_id="compact-start")
self.assertTrue(start.startswith("alice #1:"))
self.assertIn("下注 100", start)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 2)
first = self.command("/21点要牌", event_id="separate-hit")
replay = self.command(
"/21点 要牌",
event_id="separate-hit",
sender="a1",
)
self.assertEqual(replay, first)
self.assertTrue(first.startswith("alice #1:"))
self.assertIn("13点", first)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(len(self.state()["game"]["player"]), 3)
def test_spaced_chinese_and_compact_english_start_use_bet(self):
def tied_deck(cards):
first = [9, 6, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
for command, sender in (
("/21点 开始 50", "Alice"),
("/blackjackstart50", "Bob"),
):
with self.subTest(command=command):
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=tied_deck,
):
started = self.command(command, sender=sender)
self.assertTrue(
started.startswith(
"alice #1:" if sender == "Alice" else "bob #1:"
)
)
self.assertIn("下注 50", started)
self.assertEqual(self.state(sender)["balance"], 950)
self.assertIsNotNone(self.state(sender)["game"])
self.command("/21点 结束", sender=sender)
self.assertIsNone(self.state(sender)["game"])
def test_default_bet_can_start_without_spaces(self):
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=self.hit_deck,
):
reply = self.command("/21点开始")
self.assertTrue(reply.startswith("alice #1:"))
self.assertIn("下注 50", reply)
self.assertEqual(self.state()["balance"], 950)
def test_combined_start_and_action_explains_two_step_flow(self):
reply = self.command("/21点开始100要牌")
self.assertIn("分两步", reply)
self.assertIn("100 是本局下注", reply)
self.assertIn("再单独发送", reply)
wallet = self.command("/金币")
self.assertIn("金币 1000", wallet)
self.assertIn("无进行中的牌局", wallet)
if __name__ == "__main__":
unittest.main()
+252
View File
@@ -0,0 +1,252 @@
"""Behavioral coverage for multiplayer lottery and indexed game records."""
import json
import tempfile
import threading
import unittest
from pathlib import Path
from unittest import mock
from test_jwebot_edge import MODULE, config
class Release063GameTests(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.ledger.connection.close()
self.temp.cleanup()
def command(
self,
question,
*,
sender="Alice",
group="group-a",
event_id=None,
):
self.counter += 1
row = {
"question": question,
"sender": sender,
"group_fingerprint": group,
"event_id": event_id or f"test-{self.counter}",
}
return self.engine.local.handle(row, {})
def state(self, sender="Alice", group="group-a"):
key = self.engine.ledger.resolve_person(group, sender)["person_key"]
row = self.engine.ledger.connection.execute(
"""
SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?
""",
(group, key),
).fetchone()
return json.loads(row[0])
def test_lottery_number_forms_sort_and_validate(self):
parse = self.engine.local.players.parse_lottery_entry
self.assertEqual(parse("347"), ((3, 4, 7), 10))
self.assertEqual(parse("743"), ((3, 4, 7), 10))
self.assertEqual(parse("报名 3 4 7 100"), ((3, 4, 7), 100))
self.assertEqual(parse("报名347100"), ((3, 4, 7), 100))
self.assertEqual(parse("743金额500"), ((3, 4, 7), 500))
with self.assertRaisesRegex(ValueError, "互不重复"):
parse("337")
with self.assertRaisesRegex(ValueError, "1~500"):
parse("347501")
def test_multiplayer_lottery_status_draw_and_payouts(self):
self.assertIn("第 1 把已开始", self.command("/彩票开始"))
self.assertIn("alice #1", self.command("/彩票347100"))
self.assertIn(
"bob #1",
self.command("/彩票 报名 9 3 7 200", sender="Bob"),
)
self.assertIn(
"carol #1",
self.command("/彩票 1 2 3 100", sender="Carol"),
)
self.assertIn(
"Dave #1",
self.command("/彩票045100", sender="Dave"),
)
status = self.command("/彩票当前状态", sender="Bob")
self.assertIn("已报名 4 人", status)
self.assertIn("alice #1:3 4 7,下注 100", status)
self.assertIn("bob #1:3 7 9,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票开奖", sender="Bob"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[7, 3, 9],
):
result = self.command("/彩票开奖", event_id="draw-round-1")
self.assertIn("开奖号:3 7 9", result)
self.assertIn("alice #1:命中 2 个", result)
self.assertIn("返还 120", result)
self.assertIn("bob #1:命中 3 个", result)
self.assertIn("返还 600", result)
self.assertIn("carol #1:命中 1 个", result)
self.assertIn("返还 50", result)
self.assertIn("Dave #1:命中 0 个", result)
self.assertEqual(self.state()["balance"], 1020)
self.assertEqual(self.state("Bob")["balance"], 1400)
self.assertEqual(self.state("Carol")["balance"], 950)
self.assertEqual(self.state("Dave")["balance"], 900)
self.assertIn("命中2个", self.command("/游戏记录"))
self.assertIn("命中3个", self.command(
"/个人记录", sender="Bob"))
self.assertIn("没有进行中", self.command("/彩票当前状态"))
def test_registration_update_keeps_index_and_cancel_refunds(self):
self.command("/彩票 开始")
first = self.command("/彩票 347 100")
updated = self.command("/彩票 743 200", sender="a1")
self.assertIn("alice #1", first)
self.assertIn("alice #1", updated)
self.assertIn("报名已更新", updated)
self.assertEqual(self.state()["balance"], 800)
status = self.command("/彩票 状态")
self.assertIn("已报名 1 人", status)
self.assertIn("3 4 7,下注 200", status)
self.assertIn("只有本轮主持人 alice", self.command(
"/彩票 取消", sender="Bob"))
self.assertIn("已全额退回", self.command("/彩票取消"))
self.assertEqual(self.state()["balance"], 1000)
self.assertIn("净变化 +0", self.command("/游戏记录"))
self.command("/彩票开始")
second = self.command("/彩票347")
self.assertIn("alice #2", second)
def test_concurrent_registration_replay_debits_once(self):
self.command("/彩票开始")
replies = []
errors = []
def register():
try:
replies.append(
self.command("/彩票347500", event_id="same-entry")
)
except BaseException as error:
errors.append(error)
threads = [threading.Thread(target=register) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertFalse(errors)
self.assertEqual(len(set(replies)), 1)
self.assertEqual(self.state()["balance"], 500)
self.assertIn("已报名 1 人", self.command("/彩票状态"))
def test_blackjack_uses_1_to_500_and_indexed_history(self):
def tied_deck(cards):
first = [9, 6, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=tied_deck,
):
started = self.command("/21点开始500")
self.assertTrue(started.startswith("alice #1:"))
self.assertEqual(self.state()["balance"], 500)
settled = self.command("/21点停牌")
self.assertTrue(settled.startswith("alice #1:"))
self.assertIn("平局", settled)
self.assertEqual(self.state()["balance"], 1000)
history = self.command("/游戏记录")
self.assertIn("alice #1:21点 平局", history)
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
def test_prize_draw_has_indexed_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
first = self.command("/抽奖10")
second = self.command("/抽奖10")
self.assertTrue(first.startswith("alice #1:"))
self.assertTrue(second.startswith("alice #2:"))
history = self.command("/gamehistory")
self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history)
def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name)
self.command("/彩票开始")
self.command("/彩票347100")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
self.assertIn("alice #1:3 4 7", self.command("/彩票状态"))
with mock.patch.object(
self.engine.local.players.rng,
"sample",
return_value=[3, 4, 7],
):
self.command("/彩票开奖")
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(root))
history = self.command("/游戏记录")
self.assertIn("alice #1:彩票 命中3个", history)
self.assertEqual(self.state()["balance"], 1200)
def test_registration_rolls_back_when_response_building_fails(self):
ledger = self.engine.ledger
ledger.start_group_lottery(
"group-a",
"Alice",
"start",
"lottery:start",
lambda _: "started",
)
def fail(_):
raise RuntimeError("render failed")
with self.assertRaisesRegex(RuntimeError, "render failed"):
ledger.register_group_lottery(
"group-a",
"Alice",
"register",
"lottery:347100",
(3, 4, 7),
100,
fail,
)
account = ledger.connection.execute(
"SELECT COUNT(*) FROM player_accounts"
).fetchone()[0]
entries = ledger.connection.execute(
"SELECT COUNT(*) FROM group_lottery_entries"
).fetchone()[0]
actions = ledger.connection.execute(
"SELECT COUNT(*) FROM player_actions WHERE event_id = 'register'"
).fetchone()[0]
self.assertEqual((account, entries, actions), (0, 0, 0))
if __name__ == "__main__":
unittest.main()
+12 -10
View File
@@ -63,7 +63,7 @@ POLL_SECONDS = 1.0
STABLE_FRAMES = 2 STABLE_FRAMES = 2
DAMAGE_DEBOUNCE_SECONDS = 0.35 DAMAGE_DEBOUNCE_SECONDS = 0.35
DAMAGE_MAX_BURST_SECONDS = 1.0 DAMAGE_MAX_BURST_SECONDS = 1.0
DAMAGE_HEARTBEAT_SECONDS = 60.0 DAMAGE_HEARTBEAT_SECONDS = 2.0
CAPTURE_BUFFER_FRAMES = 12 CAPTURE_BUFFER_FRAMES = 12
OCR_MESSAGE_CACHE_SIZE = 256 OCR_MESSAGE_CACHE_SIZE = 256
OCR_CACHE_REFRESH_SCANS = 120 OCR_CACHE_REFRESH_SCANS = 120
@@ -1060,7 +1060,11 @@ class XDamageMonitor:
timeout = max(0.0, deadline - now) timeout = max(0.0, deadline - now)
if timeout == 0: if timeout == 0:
return first_relevant is not None return first_relevant is not None
readable, _, _ = select.select([self._descriptor], [], [], timeout) # Xlib may already hold events even when its socket is no longer readable.
if self._x11.XPending(self._display):
readable = [self._descriptor]
else:
readable, _, _ = select.select([self._descriptor], [], [], timeout)
if not readable: if not readable:
return first_relevant is not None return first_relevant is not None
if not self._drain_events(): if not self._drain_events():
@@ -1728,10 +1732,8 @@ class Recorder:
buffer.capacity, buffer.capacity,
) )
while not buffer.closed: while not buffer.closed:
if not monitor.wait_for_message_change(): changed = monitor.wait_for_message_change()
if int(window_id()) != monitor.window_id: source = "damage" if changed else "reconcile"
raise RuntimeError("WeChat window changed")
continue
identifier = window_id() identifier = window_id()
if int(identifier) != monitor.window_id: if int(identifier) != monitor.window_id:
raise RuntimeError("WeChat window changed") raise RuntimeError("WeChat window changed")
@@ -1746,7 +1748,7 @@ class Recorder:
* 1000, * 1000,
outcome="captured", outcome="captured",
phase="initial_capture", phase="initial_capture",
source="damage", source=source,
) )
latest_synced = message_view_at_loaded_bottom(image) latest_synced = message_view_at_loaded_bottom(image)
if not latest_synced: if not latest_synced:
@@ -1757,7 +1759,7 @@ class Recorder:
elapsed_ms=(time.monotonic() - follow_started) * 1000, elapsed_ms=(time.monotonic() - follow_started) * 1000,
outcome="followed" if followed else "lock_busy", outcome="followed" if followed else "lock_busy",
phase="follow_latest", phase="follow_latest",
source="damage", source=source,
) )
if followed: if followed:
recapture_started = time.monotonic() recapture_started = time.monotonic()
@@ -1770,7 +1772,7 @@ class Recorder:
* 1000, * 1000,
outcome="captured", outcome="captured",
phase="recapture", phase="recapture",
source="damage", source=source,
) )
latest_synced = message_view_at_loaded_bottom(image) latest_synced = message_view_at_loaded_bottom(image)
crop = message_image(image) crop = message_image(image)
@@ -1801,7 +1803,7 @@ class Recorder:
elapsed_ms=(time.monotonic() - capture_started) * 1000, elapsed_ms=(time.monotonic() - capture_started) * 1000,
outcome=outcome, outcome=outcome,
depth=stats["depth"], depth=stats["depth"],
source="damage", source=source,
) )
if outcome == "closed": if outcome == "closed":
return return