Author SHA1 Message Date
JWeBot Contributors 22edf6ff15 Cache OCR-negative bubbles
check / local-gate (push) Failing after 42s
2026-10-16 11:38:03 +08:00
JWeBot Contributors 572159f571 Fix OCR thread test isolation
check / local-gate (push) Failing after 17m10s
2026-10-16 11:19:55 +08:00
JWeBot Contributors 99a251c19a Limit OCR runtime threads
check / local-gate (push) Failing after 44s
2026-10-16 11:16:23 +08:00
JWeBot Contributors 3fa04755d0 Add multiplayer blackjack table flow
check / local-gate (push) Failing after 51s
2026-10-16 11:03:32 +08:00
42 changed files with 2140 additions and 45 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog
## beta 1.0.2.1016 - 2026-10-16
- Cache OCR-negative visible bubbles and reuse stable visual signatures as the
OCR cache key so unreadable or lightly re-rendered bubbles do not trigger
repeated 10+ second RapidOCR passes during active chat windows.
## beta 1.0.1.1016 - 2026-10-16
- Limit RapidOCR ONNXRuntime intra/inter operation threads by default so the
adaptive OCR recorder no longer saturates CPU after restart and slow recovery
windows do not stretch local command detection into 10+ second scans.
## beta 1.0.0.1016 - 2026-10-16
- Promote JWeBot from alpha to beta after the local, staging, privacy, and
+3 -2
View File
@@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.0.1016`,更新日期:2026-10-16。
当前版本:`beta 1.0.2.1016`,更新日期:2026-10-16。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟金币游戏、多人彩票、21 点、CS Major
@@ -88,6 +88,7 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/游戏记录 [页码](/gamehistory)
/游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案
@@ -262,7 +263,7 @@ visually confirmed GUI bridge.
Current version:
```text
beta 1.0.0.1016
beta 1.0.2.1016
```
Updated: 2026-10-16.
+1 -1
View File
@@ -1 +1 @@
beta 1.0.0.1016
beta 1.0.2.1016
+5 -1
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog
Current baseline: `beta 1.0.0.1016`.
Current baseline: `beta 1.0.1.1016`.
This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated
@@ -57,6 +57,10 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. |
| `/21点 加入` | - | Join the current blackjack table before betting starts. |
| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. |
| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. |
+9
View File
@@ -90,6 +90,15 @@ Steam uses `JWEBOT_EDGE_STEAM_ID64` and
- Host timeout and fallback;
- GUI sender timeout.
The OCR runtime also accepts optional environment overrides for constrained
hosts:
- `WECHAT_OCR_ONNX_INTRA_OP_THREADS` defaults to `1`;
- `WECHAT_OCR_ONNX_INTER_OP_THREADS` defaults to `1`.
These values limit RapidOCR's ONNXRuntime CPU thread pools so service restarts
do not temporarily saturate the host while the OCR cache is cold.
### Debug
`components/debug.toml` generates:
+6
View File
@@ -90,6 +90,12 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前金币。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。
+2 -2
View File
@@ -14,9 +14,9 @@ Rules:
- `X.Y.Z` is the project version.
- `MMDD` is the delivery date.
- Python package versions use PEP 440 pre-release form. For beta builds,
`beta 1.0.0.1016` maps to `1.0.0b1+1016`.
`beta 1.0.1.1016` maps to `1.0.1b1+1016`.
- Normal small updates increment only the patch version, for example
`beta 1.0.0.1016` to `beta 1.0.1.1016`.
`beta 1.0.1.1016` to `beta 1.0.2.1016`.
- Minor, major, or stable-release changes require explicit user review and
milestone approval.
- Local repository migration does not imply a production deployment.
+18
View File
@@ -0,0 +1,18 @@
# coin-taffy
Author: `jwebot`
Taffy金币
Help:
- `/Taffy金币`
Registered commands:
- `/Taffy金币`
- `/taffycoins`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-taffy` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-taffy"
author = "jwebot"
description = "Taffy金币"
help = ["/Taffy金币"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_taffy:plugin"
type = "command"
[capabilities]
commands = ["/Taffy金币", "/taffycoins"]
storage = false
network_profiles = []
service_profiles = ["coin-taffy"]
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_taffy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'Taffy金币')
self.assertEqual(HELP_ENTRIES, ('/Taffy金币',))
self.assertIn('/Taffy金币', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -9,8 +9,7 @@ Help:
- `/金币 /wallet;/游戏记录 /gamehistory`
- `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 开始 [1~500];/blackjack start`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200`
- `/游戏帮助;/games`
Registered commands:
@@ -1,7 +1,7 @@
id = "single-player-games"
author = "jwebot"
description = "金币游戏"
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"]
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200", "/游戏帮助;/games"]
version = "1.0.0"
api_version = "1"
entrypoint = "single_player_games:plugin"
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -20,7 +20,7 @@ class PluginTests(unittest.TestCase):
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games'))
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games'))
self.assertIn('/金币', registered)
self.assertEqual(len(registered), 15)
@@ -2,7 +2,7 @@
Author: `official`
提取当前群最近100、200或300条本地文字的上下文
上下文总结
Help:
@@ -1,6 +1,6 @@
id = "context-summary"
author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文"
description = "上下文总结"
help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0"
api_version = "1"
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文')
self.assertEqual(DESCRIPTION, '上下文总结')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "1.0.0b1+1016"
version = "1.0.2b1+1016"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "1.0.0b1+1016"
__version__ = "1.0.2b1+1016"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "1.0.0b1+1016"
version = "1.0.2b1+1016"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.0.0b1+1016",
"jwebot-kernel==1.0.0b1+1016",
"jwebot-sdk==1.0.2b1+1016",
"jwebot-kernel==1.0.2b1+1016",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "1.0.0b1+1016"
__version__ = "1.0.2b1+1016"
+4 -4
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "1.0.0b1+1016"
version = "1.0.2b1+1016"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==1.0.0b1+1016",
"jwebot-sdk==1.0.0b1+1016",
"jwebot-official-extensions==1.0.0b1+1016",
"jwebot-config==1.0.2b1+1016",
"jwebot-sdk==1.0.2b1+1016",
"jwebot-official-extensions==1.0.2b1+1016",
"tomli>=2; python_version < '3.11'",
]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "1.0.0b1+1016"
__version__ = "1.0.2b1+1016"
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "1.0.0b1+1016"
version = "1.0.2b1+1016"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.0.0b1+1016",
"jwebot-sdk==1.0.2b1+1016",
]
[tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "1.0.0b1+1016"
__version__ = "1.0.2b1+1016"
@@ -0,0 +1,45 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy金币'
HELP_ENTRIES = ('/Taffy金币',)
COMMANDS = ('/Taffy金币', '/taffycoins')
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE,
operation="execute",
payload={
"command": command,
"arguments": arguments,
"event": event.to_mapping(),
},
)
texts = response.get("texts")
if isinstance(texts, list) and texts and all(
isinstance(item, str) and item.strip() for item in texts
):
return ExtensionResult(
reply=ReplyPlan.multi_text(texts),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS:
context.commands.register(command, _handler(context, command))
@@ -5,7 +5,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'context-summary'
AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文'
DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context')
@@ -6,7 +6,7 @@ from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext
PROFILE = 'single-player-games'
AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200', '/游戏帮助;/games')
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games')
@@ -46,6 +46,8 @@ LOCAL_COMMAND_ALIASES = {
"金币排行榜": "coin_ranking",
"大富翁榜": "coin_ranking",
"richlist": "coin_ranking",
"Taffy金币": "taffy_coins",
"taffycoins": "taffy_coins",
"转账": "transfer",
"transfer": "transfer",
"贷款": "loan",
@@ -143,7 +145,7 @@ LOCAL_EXTENSIONS = (
LocalExtensionDescriptor(
extension_id="context-summary",
author=OFFICIAL_AUTHOR,
description="提取当前群最近100、200或300条本地文字的上下文",
description="上下文总结",
help_entries=("/上下文 [100|200|300];/context [100|200|300]",),
command_kinds=("context_summary",),
),
@@ -155,8 +157,7 @@ LOCAL_EXTENSIONS = (
"/金币 /wallet;/游戏记录 /gamehistory",
"/抽奖 [金额];/draw",
"/彩票 开始|报名|状态|开奖|取消;/lottery",
"/21点 开始 [1~500];/blackjack start",
"/21点 要牌|停牌|状态|结束;/blackjack",
"/21点 单人开始|要牌|停牌|状态;多人启动|加入|开始|放200",
"/游戏帮助;/games",
),
command_kinds=(
@@ -182,6 +183,13 @@ LOCAL_EXTENSIONS = (
help_entries=("/大富翁榜",),
command_kinds=("coin_ranking",),
),
LocalExtensionDescriptor(
extension_id="coin-taffy",
author=JWEBOT_AUTHOR,
description="Taffy金币",
help_entries=("/Taffy金币",),
command_kinds=("taffy_coins",),
),
LocalExtensionDescriptor(
extension_id="coin-loan",
author=JWEBOT_AUTHOR,
@@ -157,6 +157,7 @@ class LocalCommandEngine:
if kind in {
"wallet",
"coin_ranking",
"taffy_coins",
"transfer",
"loan",
"repay",
@@ -16,6 +16,9 @@ MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
MIN_BLACKJACK_TABLE_BET = 1
MAX_BLACKJACK_TABLE_BET = 10_000
MAX_BLACKJACK_TABLE_PLAYERS = 8
MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000
MIN_LOAN_AMOUNT = 1
@@ -45,6 +48,10 @@ BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"(?:要牌|hit|停牌|stand)$"
)
BLACKJACK_TABLE_BET = re.compile(
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
re.IGNORECASE,
)
TRANSFER_MARKED = re.compile(
r"^(?:给|to)?\s*(?P<target>.+?)\s*"
r"(?:金额|金币|coin|coins|:|:)\s*(?P<amount>\d{1,6})$",
@@ -161,6 +168,9 @@ class PlayerGames:
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
"主持人 /21点 多人 开始 后按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟金币不能充值或兑换。"
)
@@ -177,6 +187,8 @@ class PlayerGames:
return self.group_lottery(row, arguments)
if kind == "coin_ranking":
return self.coin_ranking(row, arguments)
if kind == "taffy_coins":
return self.taffy_coins(row, arguments)
if kind == "transfer":
return self.transfer(row, arguments)
if kind in {"loan", "repay"}:
@@ -188,6 +200,15 @@ class PlayerGames:
return "用法:/抽奖 [2~100金币](/draw),默认10金币"
bet = int(arguments or 10)
elif kind == "blackjack":
table_command = self.parse_blackjack_table_command(arguments)
if table_command is not None:
table_action, table_bet = table_command
return self.group_blackjack(
row,
table_action,
table_bet,
arguments,
)
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
start = BLACKJACK_START.fullmatch(compacted)
if start is not None:
@@ -209,6 +230,28 @@ class PlayerGames:
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
)
group = str(row["group_fingerprint"])
if (
action in {"hit", "stand", "status"}
and self.ledger.has_active_group_blackjack(group)
):
return self.group_blackjack(row, action, 0, arguments)
if (
action == "start"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前已有Taffy的21点多人牌桌;"
"发送 /21点 状态 查看,或等待本桌结束"
)
if (
action == "end"
and self.ledger.has_active_group_blackjack(group)
):
return (
"当前是多人21点牌桌;轮到你时请 /21点 停牌,"
"主持人可 /21点 多人 取消"
)
else:
bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
@@ -341,6 +384,451 @@ class PlayerGames:
)
return "\n".join(lines)
def taffy_coins(self, row: dict[str, Any], arguments: str) -> str:
value = arguments.strip()
if value and not re.fullmatch(r"\d{1,2}", value):
return "用法:/Taffy金币 [人数]"
limit = int(value or 8)
if not 1 <= limit <= 20:
return "Taffy金币人数必须为 1~20"
stats = self.ledger.player_taffy_coin_stats(
str(row["group_fingerprint"]),
limit=limit,
)
if int(stats["records"]) == 0:
return "Taffy金币:当前群暂无已结算游戏记录"
net = int(stats["taffy_net"])
result = f"Taffy累计赢 {net} 金币" if net >= 0 else f"Taffy累计倒贴 {-net} 金币"
lines = [
(
f"Taffy金币:已结算 {stats['records']} 局;"
f"玩家总下注 {stats['stake']},总返还 {stats['payout']};"
f"{result}"
)
]
if stats["players"]:
lines.append("最大上贡者排行(含负数):")
else:
lines.append("最大上贡者排行:暂无已结算玩家明细")
for index, record in enumerate(stats["players"], start=1):
player_net = int(record["taffy_net"])
if player_net > 0:
verdict = f"上贡 {player_net}"
elif player_net < 0:
verdict = f"从Taffy赢走 {-player_net}"
else:
verdict = "打平 0"
lines.append(
f"{index}. {record['display_name']}:{verdict};"
f"下注 {record['stake']},返还 {record['payout']},"
f"{record['records']} 局"
)
return "\n".join(lines)
@staticmethod
def parse_blackjack_table_command(
arguments: str,
) -> tuple[str, int] | None:
compacted = re.sub(r"\s+", "", arguments.casefold())
if not compacted:
return None
if compacted in {"多人", "牌桌", "table", "multiplayer"}:
return "help", 0
if compacted in {
"多人启动",
"多人开桌",
"多人创建",
"牌桌启动",
"牌桌开桌",
"tablestart",
"multiplayerstart",
}:
return "start", 0
if compacted in {"加入", "join"}:
return "join", 0
if compacted in {
"多人开始",
"多人发牌",
"开始多人",
"开桌",
"发牌",
"tabledeal",
"multiplayerdeal",
}:
return "begin", 0
if compacted in {
"多人取消",
"牌桌取消",
"取消牌桌",
"tablecancel",
"multiplayercancel",
}:
return "cancel", 0
bet = BLACKJACK_TABLE_BET.fullmatch(compacted)
if bet is not None:
return "bet", int(bet["bet"])
return None
@staticmethod
def blackjack_players_text(players: list[dict[str, Any]]) -> str:
names = [str(player["display_name"]) for player in players]
return "、".join(names) if names else "暂无"
@staticmethod
def blackjack_hand_text(hand: list[int]) -> str:
if not hand:
return "未发牌"
return (
f"{' '.join(card_label(card) for card in hand)}"
f"({hand_value(hand)}点)"
)
@staticmethod
def blackjack_prompt(player: dict[str, Any]) -> str:
return (
f"请{player['display_name']} 放自己的筹码\n"
"输入/21点 放200(1~10000)"
)
def group_blackjack(
self,
row: dict[str, Any],
action: str,
amount: int,
arguments: str,
) -> str:
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
command = f"blackjack-table:{arguments}"
if action == "help":
return (
"多人21点用法:/21点 多人 启动;/21点 加入;"
"主持人 /21点 多人 开始;按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
)
if action == "start":
return self.ledger.start_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_start,
)
if action == "join":
return self.ledger.join_group_blackjack(
group,
sender,
event_id,
command,
max_players=MAX_BLACKJACK_TABLE_PLAYERS,
render=self.render_blackjack_table_join,
)
if action == "begin":
return self.ledger.begin_group_blackjack_betting(
group,
sender,
event_id,
command,
self.render_blackjack_table_begin,
)
if action == "cancel":
return self.ledger.cancel_group_blackjack(
group,
sender,
event_id,
command,
self.render_blackjack_table_cancel,
)
if action == "status":
return self.ledger.group_blackjack_status(
group,
sender,
event_id,
command,
self.render_blackjack_table_status,
)
if action == "bet":
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
return "21点多人牌桌筹码必须为 1~10000 的整数金币"
deck = list(range(52))
self.rng.shuffle(deck)
return self.ledger.place_group_blackjack_bet(
group,
sender,
event_id,
command,
amount,
deck,
self.render_blackjack_table_bet,
)
if action in {"hit", "stand"}:
return self.ledger.play_group_blackjack(
group,
sender,
event_id,
command,
action,
self.render_blackjack_table_action,
)
raise ValueError(f"unsupported blackjack table action: {action}")
def render_blackjack_table_start(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
players = data.get("players", [])
prefix = (
"当前已有Taffy 的黑杰克Blackjack 21点牌桌"
if status == "already_open"
else "欢迎来到Taffy 的黑杰克Blackjack 21点牌桌"
)
return (
f"{prefix}\n"
"输入/21点 加入 加入当前游戏!"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"主持人发送 /21点 多人 开始 后,"
"Taffy会按顺序喊每个人放筹码"
)
def render_blackjack_table_join(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "closed":
return "这桌已经停止加入;发送 /21点 状态 查看当前进度"
if status == "full":
return f"这桌已满,最多 {data['max_players']} 人"
if status == "private_game":
return (
f"{data['display_name']}:你已有单人21点,"
"请先 /21点 停牌 或 /21点 结束"
)
if status == "already_joined":
return (
f"{data['display_name']} 已经在这桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
f"{data['display_name']} 已加入Taffy的21点牌桌;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
def render_blackjack_table_begin(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以结束加入"
if status == "not_joining":
return "这桌已经结束加入;发送 /21点 状态 查看当前进度"
if status == "not_enough":
return (
"至少需要 2 名玩家才能开始多人21点;"
f"目前玩家:{self.blackjack_players_text(data['players'])}"
)
return (
"加入结束,Taffy 按顺序收筹码。\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
def render_blackjack_table_status(self, data: dict[str, Any]) -> str:
if data["status"] == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
state = str(data["state"])
table_number = int(data["table_number"])
players = data["players"]
if state == "joining":
return (
f"21点牌桌第 {table_number} 桌正在加入;"
f"目前玩家:{self.blackjack_players_text(players)}\n"
"输入/21点 加入 加入当前游戏;"
"主持人 /21点 多人 开始"
)
if state == "betting":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
lines = [f"21点牌桌第 {table_number} 桌正在下注:"]
for player in players:
bet = int(player["bet"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{bet if bet else '未放'}"
)
if prompt is not None:
lines.append(self.blackjack_prompt(prompt))
return "\n".join(lines)
if state == "playing":
prompt = next(
(
player for player in players
if str(player["person_key"]) == str(data["turn_person_key"])
),
None,
)
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
f"21点牌桌第 {table_number} 桌正在出牌;Taffy {dealer_text}"
]
for player in players:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']};{player['state']}"
)
if prompt is not None:
lines.append(
f"请{prompt['display_name']} 操作:"
"/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
return "当前没有进行中的21点多人牌桌"
def render_blackjack_table_bet(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_betting":
return "现在不是放筹码阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
return self.blackjack_prompt(data["prompt_player"])
if status == "insufficient":
return (
f"{data['display_name']}:金币不足,余额 {data['balance']},"
f"本次需要 {data['bet']}"
)
if status == "bet_accepted":
return (
f"{data['display_name']} 放下 {data['bet']};"
f"金币 {data['balance']}\n"
f"{self.blackjack_prompt(data['prompt_player'])}"
)
if status == "dealt":
return self.render_blackjack_table_dealt(data)
if status == "settled":
return self.render_blackjack_table_settlement(data)
raise ValueError(f"unsupported blackjack bet status: {status}")
def render_blackjack_table_dealt(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = (
f"{card_label(dealer[0])} + 暗牌"
if dealer
else "未发牌"
)
lines = [
(
f"{data['display_name']} 放下 {data['bet']};"
"全部筹码已确认。Taffy 发牌。"
),
f"Taffy 明牌 {dealer_text}",
]
for player in data["players"]:
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{self.blackjack_hand_text(player['hand'])};"
f"下注 {player['bet']}"
)
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_action(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌;发送 /21点 多人 启动"
if status == "not_playing":
return "现在不是出牌阶段;发送 /21点 状态 查看当前进度"
if status == "not_player":
return "你不在当前21点牌桌;下一桌可发送 /21点 加入"
if status == "wrong_turn":
prompt = data["prompt_player"]
return (
f"现在轮到 {prompt['display_name']};"
"/21点 要牌 或 /21点 停牌"
)
if status == "not_actionable":
return "你这一手已经结束;等待本桌结算"
if status == "settled":
return self.render_blackjack_table_settlement(data)
player = data["player"]
lines: list[str] = []
if status == "hit":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"当前 {self.blackjack_hand_text(player['hand'])}"
)
elif status == "busted":
lines.append(
f"{player['display_name']} 要牌,拿到 "
f"{card_label(data['drawn_card'])};"
f"{self.blackjack_hand_text(player['hand'])} 爆牌"
)
elif status == "stood":
lines.append(
f"{player['display_name']} 停牌;"
f"{self.blackjack_hand_text(player['hand'])}"
)
else:
raise ValueError(f"unsupported blackjack action status: {status}")
prompt = data["prompt_player"]
lines.append(
f"请{prompt['display_name']} 操作:/21点 要牌 或 /21点 停牌"
)
return "\n".join(lines)
def render_blackjack_table_settlement(self, data: dict[str, Any]) -> str:
dealer = data.get("dealer", [])
dealer_text = self.blackjack_hand_text(dealer)
lines = [
(
"Taffy 的黑杰克Blackjack 21点牌桌"
f"第 {data['table_number']} 桌结算:"
),
f"Taffy 庄家 {dealer_text}",
"这一桌每个人的变化:",
]
for player in data["players"]:
bet = int(player["bet"])
payout = int(player["payout"])
lines.append(
f"{player['display_name']} #{player['player_game_index']}:"
f"{player['outcome']};牌 {self.blackjack_hand_text(player['hand'])};"
f"下注 {bet},返还 {payout},净变化 {payout - bet:+d};"
f"金币 {player['balance']}"
)
return "\n".join(lines)
def render_blackjack_table_cancel(self, data: dict[str, Any]) -> str:
status = data["status"]
if status == "no_table":
return "当前没有21点多人牌桌"
if status == "not_host":
return f"只有本桌主持人 {data['host_display_name']} 可以取消"
return (
f"21点牌桌第 {data['table_number']} 桌已取消;"
f"{data['refunded']} 人的下注已退回"
)
@staticmethod
def parse_transfer(
arguments: str,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "1.0.0b1+1016"
version = "1.0.2b1+1016"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -38,4 +38,4 @@ __all__ = [
"SingleGroupContext",
]
__version__ = "1.0.0b1+1016"
__version__ = "1.0.2b1+1016"
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "beta 1.0.0.1016"
release = "beta 1.0.2.1016"
production_deployments_enabled_by_default = false
[tool.jwebot.workspace]
+2 -2
View File
@@ -867,9 +867,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.0.0.1016", version["text"])
self.assertIn("最新版本:beta 1.0.2.1016", version["text"])
self.assertIn("更新时间:2026-10-16", version["text"])
self.assertIn("更新摘要:Promote JWeBot", version["text"])
self.assertIn("更新摘要:Cache OCR-negative", version["text"])
self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local")
@@ -92,6 +92,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
"coin": JWEBOT_AUTHOR,
"coin-transfer": JWEBOT_AUTHOR,
"coin-ranking": JWEBOT_AUTHOR,
"coin-taffy": JWEBOT_AUTHOR,
"coin-loan": JWEBOT_AUTHOR,
"random-number": JWEBOT_AUTHOR,
"draw-lot": JWEBOT_AUTHOR,
+73
View File
@@ -176,6 +176,56 @@ class Release063GameTests(unittest.TestCase):
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
def test_multiplayer_blackjack_table_guides_turns_and_settles(self):
self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动"))
self.assertIn("目前玩家:alice", self.command("/21点 状态"))
self.assertIn("bob 已加入", self.command("/21点 加入", sender="Bob"))
self.assertIn("请alice 放自己的筹码", self.command("/21点 多人 开始"))
alice_bet = self.command("/21点 放200", event_id="alice-table-bet")
replay = self.command(
"/21点 放200",
sender="a1",
event_id="alice-table-bet",
)
self.assertEqual(replay, alice_bet)
self.assertIn("请bob 放自己的筹码", alice_bet)
self.assertEqual(self.state()["balance"], 800)
def table_deck(cards):
first = [12, 8, 9, 24, 19, 32, 25]
cards[:] = first + [card for card in cards if card not in first]
with mock.patch.object(
self.engine.local.players.rng,
"shuffle",
side_effect=table_deck,
):
dealt = self.command("/21点 放100", sender="Bob")
self.assertIn("全部筹码已确认", dealt)
self.assertIn("alice #1:K Q(20点);下注 200", dealt)
self.assertIn("bob #1:9 7(16点);下注 100", dealt)
self.assertIn("请alice 操作", dealt)
self.assertEqual(self.state("Bob")["balance"], 900)
alice_stand = self.command("/21点 停牌")
self.assertIn("alice 停牌", alice_stand)
self.assertIn("请bob 操作", alice_stand)
settled = self.command("/21点 要牌", sender="Bob")
self.assertIn("这一桌每个人的变化", settled)
self.assertIn("alice #1:你赢了", settled)
self.assertIn("下注 200,返还 400,净变化 +200;金币 1200", settled)
self.assertIn("bob #1:你输了", settled)
self.assertIn("下注 100,返还 0,净变化 -100;金币 900", settled)
self.assertEqual(self.state()["balance"], 1200)
self.assertEqual(self.state("Bob")["balance"], 900)
self.assertIn("alice #1:21点 你赢了", self.command("/游戏记录"))
self.assertIn(
"bob #1:21点 你输了",
self.command("/游戏记录", sender="Bob"),
)
def test_prize_draw_has_indexed_history(self):
with mock.patch.object(
self.engine.local.players.rng,
@@ -262,6 +312,29 @@ class Release063GameTests(unittest.TestCase):
self.assertNotIn("Dave", ranking)
self.assertIn("金币排行人数必须为 1~20", self.command("/金币排行 0"))
def test_taffy_coin_stats_sums_all_game_history(self):
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
self.command("/抽奖10")
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=0,
):
self.command("/抽奖10", sender="Bob")
stats = self.command("/Taffy金币")
self.assertIn("玩家总下注 20,总返还 50", stats)
self.assertIn("Taffy累计倒贴 30 金币", stats)
self.assertIn("最大上贡者排行(含负数):", stats)
self.assertIn("alice:上贡 10", stats)
self.assertIn("bob:从Taffy赢走 40", stats)
self.assertIn("Taffy金币人数必须为 1~20", self.command("/Taffy金币 0"))
def test_open_round_and_history_survive_restart(self):
root = Path(self.temp.name)
self.command("/彩票开始")
+99
View File
@@ -4,6 +4,7 @@ import importlib.util
import subprocess
import sys
import tempfile
import types
import unittest
from contextlib import nullcontext
from datetime import datetime, timezone
@@ -517,6 +518,64 @@ class GuiCoordinationTests(unittest.TestCase):
class OcrFallbackTests(unittest.TestCase):
def test_ocr_engine_limits_onnxruntime_threads_by_default(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 1,
"EngineConfig.onnxruntime.inter_op_num_threads": 1,
},
)
def test_ocr_engine_allows_thread_limit_overrides(self):
captured = {}
class FakeRapidOCR:
def __init__(self, **kwargs):
captured.update(kwargs)
fake_module = types.SimpleNamespace(RapidOCR=FakeRapidOCR)
fake_numpy = types.SimpleNamespace(array=lambda value: value)
with mock.patch.dict(
sys.modules,
{"rapidocr": fake_module, "numpy": fake_numpy},
), mock.patch.dict(
MODULE.os.environ,
{
"WECHAT_OCR_ONNX_INTRA_OP_THREADS": "2",
"WECHAT_OCR_ONNX_INTER_OP_THREADS": "3",
},
):
MODULE.OcrEngine()
self.assertEqual(
captured["params"],
{
"EngineConfig.onnxruntime.intra_op_num_threads": 2,
"EngineConfig.onnxruntime.inter_op_num_threads": 3,
},
)
def test_low_confidence_line_uses_detection_fallback(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 50, 132, 68, 5000)
@@ -630,6 +689,46 @@ class OcrCacheTests(unittest.TestCase):
self.assertEqual(engine.last_cache_hits, 0)
self.assertEqual(engine.last_cache_misses, 1)
def test_antialias_drift_reuses_visual_signature_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
first = MODULE.Image.new("RGB", (300, 180), (238, 238, 240))
second = MODULE.Image.new("RGB", (300, 180), (242, 242, 244))
first.putpixel((70, 75), (20, 20, 20))
second.putpixel((70, 75), (35, 35, 35))
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=self.message(60),
) as recognize:
engine.recognize_messages(first, [bubble])
messages, _ = engine.recognize_messages(second, [bubble])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(len(messages), 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_unrecognized_bubble_reuses_negative_cache(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white")
bubble = MODULE.Bubble(50, 60, 132, 40, 5000)
with mock.patch.object(
engine,
"_recognize_bubble",
return_value=None,
) as recognize:
first_messages, _ = engine.recognize_messages(image, [bubble])
second_messages, _ = engine.recognize_messages(image, [bubble])
self.assertEqual(first_messages, [])
self.assertEqual(second_messages, [])
self.assertEqual(recognize.call_count, 1)
self.assertEqual(engine.last_cache_hits, 1)
self.assertEqual(engine.last_cache_misses, 0)
def test_periodic_refresh_revalidates_cached_bubbles(self):
engine = MODULE.OcrEngine.__new__(MODULE.OcrEngine)
image = MODULE.Image.new("RGB", (300, 180), "white")
+36 -6
View File
@@ -67,6 +67,8 @@ DAMAGE_HEARTBEAT_SECONDS = 2.0
CAPTURE_BUFFER_FRAMES = 12
OCR_MESSAGE_CACHE_SIZE = 256
OCR_CACHE_REFRESH_SCANS = 120
DEFAULT_OCR_ONNX_INTRA_OP_THREADS = 1
DEFAULT_OCR_ONNX_INTER_OP_THREADS = 1
WINDOW_LAYOUT_SETTLE_SECONDS = 0.25
FOLLOW_LATEST_SETTLE_SECONDS = 0.20
FOLLOW_LATEST_MOUSE_X = 1432
@@ -224,6 +226,17 @@ def normalize_text(value: str) -> str:
return re.sub(r"\s+", " ", value).strip()
def env_int(name: str, default: int) -> int:
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
return int(raw)
except ValueError:
logging.warning("invalid integer environment value name=%s", name)
return default
def bubble_sequence_signature(
sender_crop: Image.Image,
body_crop: Image.Image,
@@ -1099,10 +1112,21 @@ class OcrEngine:
from rapidocr import RapidOCR
self._numpy = np
self._engine = RapidOCR()
self._engine = RapidOCR(
params={
"EngineConfig.onnxruntime.intra_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTRA_OP_THREADS",
DEFAULT_OCR_ONNX_INTRA_OP_THREADS,
),
"EngineConfig.onnxruntime.inter_op_num_threads": env_int(
"WECHAT_OCR_ONNX_INTER_OP_THREADS",
DEFAULT_OCR_ONNX_INTER_OP_THREADS,
),
}
)
self._message_cache: OrderedDict[
str,
tuple[str, str, float, float, float],
tuple[str, str, float, float, float] | None,
] = OrderedDict()
self._scan_count = 0
self.last_cache_hits = 0
@@ -1178,7 +1202,7 @@ class OcrEngine:
def _cache(self) -> OrderedDict[
str,
tuple[str, str, float, float, float],
tuple[str, str, float, float, float] | None,
]:
cache = getattr(self, "_message_cache", None)
if cache is None:
@@ -1258,15 +1282,17 @@ class OcrEngine:
bubble_crop = image.crop(
(bubble.x, bubble.y, bubble.right, bubble.bottom)
)
key = self._cache_key(sender_crop, bubble_crop)
sequence_signature = bubble_sequence_signature(
sender_crop,
bubble_crop,
)
cached = cache.get(key)
if cached is not None:
key = sequence_signature
if key in cache:
cached = cache[key]
hits += 1
cache.move_to_end(key)
if cached is None:
continue
messages.append(
IncomingMessage(
sender=cached[0],
@@ -1287,6 +1313,10 @@ class OcrEngine:
visual_signature=sequence_signature,
)
if message is None:
cache[key] = None
cache.move_to_end(key)
while len(cache) > OCR_MESSAGE_CACHE_SIZE:
cache.popitem(last=False)
continue
cache[key] = (
message.sender,