release: alpha 0.6.1 local player plugins and capture reconciliation
check / local-gate (push) Failing after 3m24s

This commit is contained in:
JWeBot Contributors
2026-10-15 05:01:40 +08:00
parent 84ba2193df
commit f7fedc54e4
40 changed files with 1346 additions and 38 deletions
+17
View File
@@ -1,5 +1,22 @@
# Changelog # Changelog
## 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,
+20 -2
View File
@@ -8,7 +8,11 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`alpha 0.6.0.1015`。 当前版本:`alpha 0.6.1.1015`。
0.6.1 新增本地上下文总结、人物与别名索引、单人虚拟金币游戏,并增加
两秒画面复核以补抓遗漏的消息变化。命令和游戏管理规则见
[0.6.1 插件说明](docs/local-plugins-0.6.1.md)。
### 运行流程 ### 运行流程
@@ -72,6 +76,12 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
```text ```text
/功能列表 /功能列表
/上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw)
/买彩票 [000~999] [金额](/lottery)
/21点 开始|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)
/游戏帮助(/games)
/丢骰子 [NdM] /丢骰子 [NdM]
/猜数字 开始 [上限] /猜数字 开始 [上限]
/石头剪刀布 [开始|加入|出|状态|结算|结束] /石头剪刀布 [开始|加入|出|状态|结算|结束]
@@ -240,7 +250,7 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
alpha 0.6.0.1015 alpha 0.6.1.1015
``` ```
## Runtime Flow ## Runtime Flow
@@ -337,6 +347,14 @@ 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).
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.1.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:
+99
View File
@@ -0,0 +1,99 @@
# 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 | `/买彩票 [000~999] [金额]` | `/lottery [000~999] [金额]` |
| single-player-games | `/21点 开始 [金额]` | `/blackjack start [金额]` |
| single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
| 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 的整数,
余额不足或参数非法不扣款。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
彩票默认随机三位号码、下注 10;可指定 000~999,命中返还五百倍。
所有返还都包含本金,回复同时报告下注、返还、净变化和余额。
21 点默认下注 50。牌局使用洗牌后的单副 52 张牌,牌堆、双方手牌、
下注和最后操作时间持久化。A 可作为 1 或 11;庄家软 17 停牌。
开始后只公开一张庄家牌;要牌、停牌、认输和查询都仅针对本人牌局。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖、彩票或开局。
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
独立玩,不能加入或结束你的牌局。旧的群游戏保持独立。
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,29 @@
# single-player-games
Author: `jwebot`
本人虚拟金币账户、抽奖、彩票和单人21点
Help:
- `/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [号码] [金额] /lottery`
- `/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end`
- `/游戏帮助;/games`
Registered commands:
- `/金币`
- `/wallet`
- `/抽奖`
- `/draw`
- `/买彩票`
- `/lottery`
- `/21点`
- `/二十一点`
- `/blackjack`
- `/游戏帮助`
- `/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;/买彩票 [号码] [金额] /lottery", "/21点 开始|要牌|停牌|状态|结束;/blackjack start|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", "/游戏帮助", "/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;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/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;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games'))
self.assertIn('/金币', registered)
self.assertEqual(len(registered), 11)
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.1a0+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.1a0+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.1a0+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.1a0+1015",
"jwebot-kernel==0.6.0a0+1015", "jwebot-kernel==0.6.1a0+1015",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "0.6.0a0+1015" __version__ = "0.6.1a0+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.1a0+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.1a0+1015",
"jwebot-sdk==0.6.0a0+1015", "jwebot-sdk==0.6.1a0+1015",
"jwebot-official-extensions==0.6.0a0+1015", "jwebot-official-extensions==0.6.1a0+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.1a0+1015"
@@ -941,6 +941,34 @@ 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 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 +2741,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 +2880,102 @@ 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 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."""
if not group or not event_id or not sender or sender == "unknown":
raise LocalCommandError("无法确认本人身份,不能操作金币账户")
person = self.resolve_person(group, sender)
person_key = str(person["person_key"])
with self.lock:
self.connection.execute("BEGIN IMMEDIATE")
try:
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 not None:
if previous["person_key"] != person_key or previous["command"] != command:
raise LocalCommandError("同一事件不能变更账户或游戏指令")
self.connection.execute("COMMIT")
return str(previous["response_text"])
stored = self.connection.execute(
"""SELECT state_json FROM player_accounts
WHERE group_fingerprint = ? AND person_key = ?""",
(group, person_key),
).fetchone()
account = json.loads(stored["state_json"]) if stored else {
"balance": 1000, "game": None, "wins": 0, "losses": 0, "ties": 0,
}
response = action(account)
if type(account["balance"]) is not int or not 0 <= account["balance"] <= 1_000_000_000:
raise LocalCommandError("金币结算超出允许范围")
timestamp = utc_now()
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),
)
self.connection.execute(
"INSERT INTO player_actions VALUES (?, ?, ?, ?, ?, ?)",
(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]
@@ -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.1a0+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.1a0+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.1a0+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;/买彩票 [号码] [金额] /lottery', '/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/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,25 @@ 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",
"21点": "blackjack",
"二十一点": "blackjack",
"blackjack": "blackjack",
"游戏帮助": "game_rules",
"games": "game_rules",
"人物列表": "person_directory",
"别名列表": "person_directory",
"people": "person_directory",
"人物": "person_info",
"个人属性": "person_info",
"person": "person_info",
} }
@@ -91,6 +110,31 @@ 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;/买彩票 [号码] [金额] /lottery",
"/21点 开始|要牌|停牌|状态|结束;/blackjack start|hit|stand|status|end",
"/游戏帮助;/games",
),
command_kinds=("wallet", "prize_draw", "lottery", "blackjack", "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 +281,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,12 @@ 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_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 +238,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,180 @@
"""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
MAX_BET = 100
GAME_TIMEOUT_SECONDS = 600
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 虚拟金币;每次下注 2~100。\n"
"/金币 /wallet;/抽奖 [金额] /draw;/买彩票 [000~999] [金额] /lottery;"
"/21点 开始 [金额] /blackjack start。\n"
"21点:/21点 要牌|停牌|状态|结束(hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"抽奖:5%返5倍、20%返2倍,其余无返还;彩票命中返500倍;"
"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 == "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 == "lottery":
match = re.fullmatch(r"(?:(\d{3})(?:\s+(\d{1,3}))?)?", arguments)
if match is None:
return "用法:/买彩票 [三位号码000~999] [2~100金币](/lottery),默认随机号码、10金币"
number = int(match[1]) if match[1] is not None else None
bet = int(match[2] or 10)
elif kind == "blackjack":
parts = arguments.casefold().split()
action = parts[0] if parts else "状态"
actions = {"开始": "start", "start": "start", "要牌": "hit", "hit": "hit",
"停牌": "stand", "stand": "stand", "状态": "status", "status": "status",
"结束": "end", "end": "end"}
action = actions.get(action, "")
if not action or (action != "start" and len(parts) > 1) or len(parts) > 2:
return "用法:/21点 开始 [金额]|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)"
if action == "start":
if len(parts) == 2 and not re.fullmatch(r"\d{1,3}", parts[1]):
return "21点下注必须为 2~100 的整数金币"
bet = int(parts[1]) if len(parts) == 2 else 50
else:
bet = 0
else:
bet = 0
if kind in {"prize_draw", "lottery"} or kind == "blackjack" and action == "start":
if not 2 <= bet <= MAX_BET:
return "下注必须为 2~100 的整数金币"
def execute(account: dict[str, Any]) -> str:
expired = ""
game = account.get("game")
if game and time.time() - float(game["updated_at"]) >= GAME_TIMEOUT_SECONDS:
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"}:
return expired + self.play_blackjack(account, action)
if account.get("game"):
return expired + "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
if account["balance"] < bet:
return expired + f"金币不足:余额 {account['balance']},本次需要 {bet}"
account["balance"] -= bet
if kind == "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()}
account["game"] = game
if hand_value(game["player"]) == 21 or hand_value(game["dealer"]) == 21:
return expired + self.finish_blackjack(account, natural=True)
return expired + self.show_hand(game)
if kind == "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 "未中奖"
else:
selected = self.rng.randrange(1000) if number is None else number
winning = self.rng.randrange(1000)
payout = bet * 500 if selected == winning else 0
result = f"号码 {selected:03d};开奖号 {winning:03d};" + ("中奖" if payout else "未中奖")
account["balance"] += payout
account["wins" if payout > bet else "losses"] += 1
return expired + f"{result};下注 {bet},返还 {payout},净变化 {payout - bet:+d};金币 {account['balance']}"
return self.ledger.apply_player_action(
str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute)
@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":
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
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.1a0+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.1a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot] [tool.jwebot]
release = "alpha 0.6.0.1015" release = "alpha 0.6.1.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()
+1 -1
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"])
+5 -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,8 @@ class LocalExtensionInventoryTests(unittest.TestCase):
self.assertEqual( self.assertEqual(
combined, combined,
{ {
"single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
"person-directory": ("person_directory", "person_info"),
"person-memory": ( "person-memory": (
"remember", "remember",
"my_memory", "my_memory",
+212
View File
@@ -0,0 +1,212 @@
"""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)
with mock.patch.object(self.local.players.rng, "randrange", return_value=123):
reply = self.command("/买彩票 123 10", sender="Bob")
self.assertIn("返还 5000", reply)
self.assertEqual(self.state("Bob")["balance"], 5990)
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()
+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