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
## 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
- Replace repository-local and hardcoded deployment values with independent,
+20 -2
View File
@@ -8,7 +8,11 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 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
/功能列表
/上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw)
/买彩票 [000~999] [金额](/lottery)
/21点 开始|要牌|停牌|状态|结束(/blackjack start|hit|stand|status|end)
/游戏帮助(/games)
/丢骰子 [NdM]
/猜数字 开始 [上限]
/石头剪刀布 [开始|加入|出|状态|结算|结束]
@@ -240,7 +250,7 @@ visually confirmed GUI bridge.
Current version:
```text
alpha 0.6.0.1015
alpha 0.6.1.1015
```
## Runtime Flow
@@ -337,6 +347,14 @@ the final separator shown below:
/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
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
+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:
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.
3. The OCR engine reuses exact visual-fingerprint matches from a bounded LRU.
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
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 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]
name = "jwebot-config"
version = "0.6.0a0+1015"
version = "0.6.1a0+1015"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "0.6.0a0+1015"
__version__ = "0.6.1a0+1015"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "0.6.0a0+1015"
version = "0.6.1a0+1015"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.0a0+1015",
"jwebot-kernel==0.6.0a0+1015",
"jwebot-sdk==0.6.1a0+1015",
"jwebot-kernel==0.6.1a0+1015",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"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]
name = "jwebot-kernel"
version = "0.6.0a0+1015"
version = "0.6.1a0+1015"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==0.6.0a0+1015",
"jwebot-sdk==0.6.0a0+1015",
"jwebot-official-extensions==0.6.0a0+1015",
"jwebot-config==0.6.1a0+1015",
"jwebot-sdk==0.6.1a0+1015",
"jwebot-official-extensions==0.6.1a0+1015",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"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,
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
ON events(group_fingerprint, created_at);
CREATE INDEX IF NOT EXISTS outbox_due
@@ -2713,14 +2741,20 @@ class Ledger:
) -> bool:
content = bounded_text(text, limit=1_500)
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 (
not event_id
or not group_fingerprint
or not repeat_key
not repeat_key
or len(repeat_key) < 2
):
return False
person = self.resolve_person(group_fingerprint, sender)
with self.lock, self.connection:
cursor = self.connection.execute(
"""
@@ -2846,6 +2880,102 @@ class Ledger:
).fetchall()
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]:
with self.lock:
events = self.connection.execute("SELECT COUNT(*) FROM events").fetchone()[0]
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "0.6.0a0+1015"
version = "0.6.1a0+1015"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==0.6.0a0+1015",
"jwebot-sdk==0.6.1a0+1015",
]
[tool.setuptools.packages.find]
@@ -62,4 +62,4 @@ __all__ = [
"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",
"余额": "deepseek_balance",
"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 = (
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(
extension_id="person-memory",
author=OFFICIAL_AUTHOR,
@@ -237,7 +281,6 @@ def render_feature_list() -> str:
)
for descriptor in LOCAL_EXTENSIONS:
lines.append(
f"[{descriptor.author}/{descriptor.extension_id}] "
f"{descriptor.description}:"
+ ";".join(descriptor.help_entries)
)
@@ -9,11 +9,13 @@ import json
import re
import subprocess
import time
from collections import Counter
from datetime import datetime, timezone
from typing import Any, Callable
from zoneinfo import ZoneInfo
from .descriptors import LOCAL_COMMAND_ALIASES, render_feature_list
from .player_games import PlayerGames
LOCAL_GAME_TIMEOUT_SECONDS = 10 * 60
@@ -104,6 +106,7 @@ class LocalCommandEngine:
self.person_display_label = person_display_label
self.profile_fact_is_safe = profile_fact_is_safe
self.profile_expiry = profile_expiry
self.players = PlayerGames(ledger, person_display_label)
def handle(self, row: dict[str, Any], event: dict[str, Any]) -> str:
question = str(row["question"])
@@ -112,6 +115,12 @@ class LocalCommandEngine:
raise LocalCommandError("本地命令不存在,发 /功能列表 查看可用本地功能")
if kind == "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":
return self.reply_cleaner(self.dice(row, arguments))
if kind == "dice_challenge":
@@ -229,6 +238,82 @@ class LocalCommandEngine:
)
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
def service_state(service_name: str) -> str:
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]
name = "jwebot-sdk"
version = "0.6.0a0+1015"
version = "0.6.1a0+1015"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "0.6.0a0+1015"
__version__ = "0.6.1a0+1015"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "alpha 0.6.0.1015"
release = "alpha 0.6.1.1015"
production_deployments_enabled_by_default = false
[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["execution_path"], "local-command")
self.assertIn("由内核和插件描述自动生成", response["text"])
self.assertIn("[jwebot/dice]", response["text"])
self.assertIn("/丢骰子", response["text"])
self.assertIn("骰子和多人骰子挑战", response["text"])
self.assertIn("/steam", 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):
self.assertTrue(extension.description)
self.assertTrue(extension.help_entries)
self.assertIn(extension.extension_id, help_text)
self.assertIn(extension.description, help_text)
for entry in extension.help_entries:
self.assertIn(entry, help_text)
@@ -81,6 +80,9 @@ class LocalExtensionInventoryTests(unittest.TestCase):
for extension in LOCAL_EXTENSIONS
},
{
"context-summary": OFFICIAL_AUTHOR,
"person-directory": OFFICIAL_AUTHOR,
"single-player-games": JWEBOT_AUTHOR,
"person-memory": OFFICIAL_AUTHOR,
"server-status": OFFICIAL_AUTHOR,
"jwebot-version": OFFICIAL_AUTHOR,
@@ -108,6 +110,8 @@ class LocalExtensionInventoryTests(unittest.TestCase):
self.assertEqual(
combined,
{
"single-player-games": ("wallet", "prize_draw", "lottery", "blackjack", "game_rules"),
"person-directory": ("person_directory", "person_info"),
"person-memory": (
"remember",
"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
DAMAGE_DEBOUNCE_SECONDS = 0.35
DAMAGE_MAX_BURST_SECONDS = 1.0
DAMAGE_HEARTBEAT_SECONDS = 60.0
DAMAGE_HEARTBEAT_SECONDS = 2.0
CAPTURE_BUFFER_FRAMES = 12
OCR_MESSAGE_CACHE_SIZE = 256
OCR_CACHE_REFRESH_SCANS = 120
@@ -1060,7 +1060,11 @@ class XDamageMonitor:
timeout = max(0.0, deadline - now)
if timeout == 0:
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:
return first_relevant is not None
if not self._drain_events():
@@ -1728,10 +1732,8 @@ class Recorder:
buffer.capacity,
)
while not buffer.closed:
if not monitor.wait_for_message_change():
if int(window_id()) != monitor.window_id:
raise RuntimeError("WeChat window changed")
continue
changed = monitor.wait_for_message_change()
source = "damage" if changed else "reconcile"
identifier = window_id()
if int(identifier) != monitor.window_id:
raise RuntimeError("WeChat window changed")
@@ -1746,7 +1748,7 @@ class Recorder:
* 1000,
outcome="captured",
phase="initial_capture",
source="damage",
source=source,
)
latest_synced = message_view_at_loaded_bottom(image)
if not latest_synced:
@@ -1757,7 +1759,7 @@ class Recorder:
elapsed_ms=(time.monotonic() - follow_started) * 1000,
outcome="followed" if followed else "lock_busy",
phase="follow_latest",
source="damage",
source=source,
)
if followed:
recapture_started = time.monotonic()
@@ -1770,7 +1772,7 @@ class Recorder:
* 1000,
outcome="captured",
phase="recapture",
source="damage",
source=source,
)
latest_synced = message_view_at_loaded_bottom(image)
crop = message_image(image)
@@ -1801,7 +1803,7 @@ class Recorder:
elapsed_ms=(time.monotonic() - capture_started) * 1000,
outcome=outcome,
depth=stats["depth"],
source="damage",
source=source,
)
if outcome == "closed":
return