diff --git a/CHANGELOG.md b/CHANGELOG.md index d24d1a5..3a255cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## beta 2.0.0.1021 - 2026-10-21 16:57:39 + +- Beta 2.0 架构更新完成:插件 SDK 统一服务适配器上线,本地命令插件联动改为受控服务调用,私聊 shadow 测试保持只观察不发送,安全边界和时延打点一起收口。 +- 新增公开 SDK `ServiceCommandAdapter`,生成插件不再重复手写 service-broker + 转发逻辑;插件仍只能返回结构化 `ReplyPlan` 和受控 metadata。 +- 同步更新官方本地命令适配器生成器,所有 reviewed command adapter 统一走 + `ServiceCommandAdapter`,避免插件联动接口漂移。 +- 私聊 shadow 测试继续 fail-closed:只接受显式配置的会话标题哈希,生成 + `shadow_only=true`、`dispatch_allowed=false` 观察事件,Edge 在路由和账本写入前拒绝。 +- 保留现有多条消息发送策略:Kernel 统一拆分 `multi_text`,逐条视觉确认,并在消息间随机等待 + 0.5 到 1.0 秒,插件不能绕过 GUI 发送链路。 +- 发布前后保留性能基线对比要求,重点观察本地命令、chat ack、direct queue 和 + extension-host 打点,避免架构封装影响运行时延。 + ## beta 1.2.8.1019 - 2026-10-19 22:49:30 - 冷门知识问答题库整体重写:改为更偏游戏内机制、数值、版本和活动细节的深度题。 diff --git a/README.md b/README.md index 48b550d..334ea55 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 并通过带视觉确认的 GUI 发送链路回复。 -当前版本:`beta 1.2.8.1019`,更新时间:2026-10-19 22:49:30。 +当前版本:`beta 2.0.0.1021`,更新时间:2026-10-21 16:57:39。 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major @@ -193,9 +193,10 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户 -发放 200 摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告; -`beta 1.0.5.1016` 与 `beta 1.1.0.1016` 额外发放 1 原石; -后续普通小版本不额外发放原石。 +发放摩拉、写入账户流水,并使用当前 changelog 的第一条摘要发布群公告。 +普通小版本发放 200 摩拉;`beta 1.0.5.1016`、`beta 1.1.0.1016` +和 `beta 1.2.0.1019` 额外发放 1 原石;`beta 2.0.0.1021` +作为架构级大版本发放 400 摩拉和 2 原石。 旧 OCR 服务保留安装,但保持禁用。 ### 仓库结构 @@ -270,10 +271,10 @@ visually confirmed GUI bridge. Current version: ```text -beta 1.2.8.1019 +beta 2.0.0.1021 ``` -Updated: 2026-10-19 22:49:30. +Updated: 2026-10-21 16:57:39. The 1.0 Beta baseline consolidates the stable local command set, person and context indexes, Steam presence/playtime queries, virtual coin games, @@ -460,10 +461,11 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot The deployer refuses cutover unless the ready-window, empty-queue, Edge health, SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units remain installed but disabled. After cutover, version-idempotent release -settlement grants 200 Mora to every existing account, records the movement, -and sends the first changelog bullet as the guarded group announcement. -For `beta 1.0.5.1016` and `beta 1.1.0.1016`, the settlement also grants -1 primogem once; ordinary later patch releases do not grant extra primogems. +settlement grants Mora to every existing account, records the movement, and +sends the first changelog bullet as the guarded group announcement. Ordinary +patch releases grant 200 Mora. `beta 1.0.5.1016`, `beta 1.1.0.1016`, and +`beta 1.2.0.1019` also grant 1 primogem once. `beta 2.0.0.1021` is the +architecture-level major release and grants 400 Mora plus 2 primogems. ## Repository Map diff --git a/VERSION b/VERSION index 426b645..2369dd1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -beta 1.2.8.1019 +beta 2.0.0.1021 diff --git a/docs/command-catalog.md b/docs/command-catalog.md index 509ef91..78bcbdc 100644 --- a/docs/command-catalog.md +++ b/docs/command-catalog.md @@ -1,6 +1,6 @@ # JWeBot Command Catalog -Current baseline: `beta 1.2.8.1019`. +Current baseline: `beta 2.0.0.1021`. This catalog records the reviewed commands shipped with the built-in local extensions. Runtime ownership is frozen at startup from the generated diff --git a/docs/conversation-shadow-testing.md b/docs/conversation-shadow-testing.md new file mode 100644 index 0000000..29a2679 --- /dev/null +++ b/docs/conversation-shadow-testing.md @@ -0,0 +1,61 @@ +# Private Conversation Shadow Testing + +This prototype observes one manually selected private conversation without +changing the production group workflow. + +It is intentionally unable to switch conversations or send replies: + +- The recorder never enqueues matching shadow events. +- Every observation carries `shadow_only=true` and `dispatch_allowed=false`. +- JWeBot Edge ignores marked observations before routing or persistence. +- The shadow registry accepts private targets only. The main group cannot be + added to it. +- No setting in the shadow registry can enable delivery. + +## Local Configuration + +Keep the real file outside the repository: + +```text +~/.config/jwebot/conversation-shadow.json +``` + +Example with placeholder values: + +```json +{ + "format_version": 1, + "enabled": true, + "targets": [ + { + "key": "test-direct", + "kind": "direct", + "title_fingerprint": "<64 lowercase SHA-256 characters>", + "sender": "" + } + ] +} +``` + +Set `JWEBOT_CONVERSATION_SHADOW_CONFIG` to that absolute path only in a local +test process. Without the variable, the feature is not imported or enabled. + +The title fingerprint is the SHA-256 value returned by +`title_fingerprint(image)` for a screenshot where the intended conversation is +selected. It is an opaque routing value, not a trusted contact identity. + +## Controlled Test + +1. Keep production services unchanged. +2. Run the recorder in a separate local test state directory. +3. Select the allowlisted private conversation manually. +4. Let the first capture establish a baseline. +5. Send `/状态` or `JWeBot: 测试` from the other account. +6. Inspect the local history for `shadow_only=true`. +7. Assert that the OCR queue remains empty and no outgoing bubble appears. + +Ordinary private messages are observed but do not set `mention_bot`. Only slash +commands and the configured wake word are treated as potential triggers. + +Do not point this prototype at production state paths and do not add the +environment variable to a deployed runtime profile. diff --git a/extensions/jwebot/coin-loan/src/coin_loan.py b/extensions/jwebot/coin-loan/src/coin_loan.py index 8367363..dd03a89 100644 --- a/extensions/jwebot/coin-loan/src/coin_loan.py +++ b/extensions/jwebot/coin-loan/src/coin_loan.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-loan.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-loan' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '贷款' HELP_ENTRIES = ('/贷款;/还款',) COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/coin-ranking/src/coin_ranking.py b/extensions/jwebot/coin-ranking/src/coin_ranking.py index 9374bab..7f9aa99 100644 --- a/extensions/jwebot/coin-ranking/src/coin_ranking.py +++ b/extensions/jwebot/coin-ranking/src/coin_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '富榜' HELP_ENTRIES = ('/大富翁榜',) COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/coin-taffy/src/coin_taffy.py b/extensions/jwebot/coin-taffy/src/coin_taffy.py index 0b1836a..067f6ae 100644 --- a/extensions/jwebot/coin-taffy/src/coin_taffy.py +++ b/extensions/jwebot/coin-taffy/src/coin_taffy.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-taffy.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-taffy' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = 'Taffy摩拉' HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',) COMMANDS = ('/Taffy摩拉', '/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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/coin-transfer/src/coin_transfer.py b/extensions/jwebot/coin-transfer/src/coin_transfer.py index 1b257cb..b157cf6 100644 --- a/extensions/jwebot/coin-transfer/src/coin_transfer.py +++ b/extensions/jwebot/coin-transfer/src/coin_transfer.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-transfer.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-transfer' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '摩拉转账' HELP_ENTRIES = ('/转账 对方 金额;/transfer',) COMMANDS = ('/转账', '/transfer') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/coin/src/coin.py b/extensions/jwebot/coin/src/coin.py index caf95c6..0d42dfd 100644 --- a/extensions/jwebot/coin/src/coin.py +++ b/extensions/jwebot/coin/src/coin.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '抛一枚硬币' HELP_ENTRIES = ('/翻硬币',) COMMANDS = ('/翻硬币',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py b/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py index b50af17..a0ff3ec 100644 --- a/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py +++ b/extensions/jwebot/cs-major-autochess/src/cs_major_autochess.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for cs-major-autochess.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'cs-major-autochess' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '自走棋' HELP_ENTRIES = ('/自走棋 开始|报名|选',) COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/daily-fortune/src/daily_fortune.py b/extensions/jwebot/daily-fortune/src/daily_fortune.py index 00c9738..07d084d 100644 --- a/extensions/jwebot/daily-fortune/src/daily_fortune.py +++ b/extensions/jwebot/daily-fortune/src/daily_fortune.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for daily-fortune.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'daily-fortune' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '生成当天稳定一致的运势' HELP_ENTRIES = ('/今日运势',) COMMANDS = ('/今日运势',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/dice/src/dice.py b/extensions/jwebot/dice/src/dice.py index 1e4806f..d12538e 100644 --- a/extensions/jwebot/dice/src/dice.py +++ b/extensions/jwebot/dice/src/dice.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for dice.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'dice' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '骰子和多人骰子挑战' HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]') COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/draw-lot/src/draw_lot.py b/extensions/jwebot/draw-lot/src/draw_lot.py index 5f8efb6..3923f9d 100644 --- a/extensions/jwebot/draw-lot/src/draw_lot.py +++ b/extensions/jwebot/draw-lot/src/draw_lot.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for draw-lot.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'draw-lot' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '抽取一条本地签文' HELP_ENTRIES = ('/抽签',) COMMANDS = ('/抽签',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/game-history/src/game_history.py b/extensions/jwebot/game-history/src/game_history.py index 9eab42c..cb8b114 100644 --- a/extensions/jwebot/game-history/src/game_history.py +++ b/extensions/jwebot/game-history/src/game_history.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for game-history.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'game-history' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '游戏记录' HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',) COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/guess-number/src/guess_number.py b/extensions/jwebot/guess-number/src/guess_number.py index 8b7252f..e4f4557 100644 --- a/extensions/jwebot/guess-number/src/guess_number.py +++ b/extensions/jwebot/guess-number/src/guess_number.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for guess-number.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'guess-number' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '群内持久化猜数字游戏' HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',) COMMANDS = ('/猜数字', '/猜') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/history-search/src/history_search.py b/extensions/jwebot/history-search/src/history_search.py index 1deffff..6cb4d5e 100644 --- a/extensions/jwebot/history-search/src/history_search.py +++ b/extensions/jwebot/history-search/src/history_search.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for history-search.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'history-search' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '搜索本地群聊文字记录' HELP_ENTRIES = ('/查记录 关键词',) COMMANDS = ('/查记录', '/历史搜索') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/primogem-economy/src/primogem_economy.py b/extensions/jwebot/primogem-economy/src/primogem_economy.py index 30a6919..4519249 100644 --- a/extensions/jwebot/primogem-economy/src/primogem_economy.py +++ b/extensions/jwebot/primogem-economy/src/primogem_economy.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for primogem-economy.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'primogem-economy' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '原石系统' HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',) COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/random-number/src/random_number.py b/extensions/jwebot/random-number/src/random_number.py index c8e47a3..ca0e42b 100644 --- a/extensions/jwebot/random-number/src/random_number.py +++ b/extensions/jwebot/random-number/src/random_number.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for random-number.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'random-number' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '在指定整数范围内取随机数' HELP_ENTRIES = ('/随机数 最小值 最大值',) COMMANDS = ('/随机数',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/repeat-ranking/src/repeat_ranking.py b/extensions/jwebot/repeat-ranking/src/repeat_ranking.py index daaeb8b..c5bb86e 100644 --- a/extensions/jwebot/repeat-ranking/src/repeat_ranking.py +++ b/extensions/jwebot/repeat-ranking/src/repeat_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for repeat-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'repeat-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '查看北京时间今日复读排行' HELP_ENTRIES = ('/复读榜',) COMMANDS = ('/复读榜', '/复读') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py b/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py index ba94576..f23f1f5 100644 --- a/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py +++ b/extensions/jwebot/rock-paper-scissors/src/rock_paper_scissors.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for rock-paper-scissors.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'rock-paper-scissors' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '多人石头剪刀布游戏' HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',) COMMANDS = ('/石头剪刀布',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/scan-ranking/src/scan_ranking.py b/extensions/jwebot/scan-ranking/src/scan_ranking.py index 51f6c9d..73f6a1e 100644 --- a/extensions/jwebot/scan-ranking/src/scan_ranking.py +++ b/extensions/jwebot/scan-ranking/src/scan_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for scan-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'scan-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '查看北京时间今日扫码排行' HELP_ENTRIES = ('/扫码排行',) COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/single-player-games/src/single_player_games.py b/extensions/jwebot/single-player-games/src/single_player_games.py index 06704c2..6fafc53 100644 --- a/extensions/jwebot/single-player-games/src/single_player_games.py +++ b/extensions/jwebot/single-player-games/src/single_player_games.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for single-player-games.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'single-player-games' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '摩拉游戏' HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',) 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/steam-presence/src/steam_presence.py b/extensions/jwebot/steam-presence/src/steam_presence.py index 75a7dcf..4833727 100644 --- a/extensions/jwebot/steam-presence/src/steam_presence.py +++ b/extensions/jwebot/steam-presence/src/steam_presence.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for steam-presence.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'steam-presence' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = 'Steam 状态与时长' HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',) COMMANDS = ('/steam',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/jwebot/trivia-quiz/src/trivia_quiz.py b/extensions/jwebot/trivia-quiz/src/trivia_quiz.py index 80a02b9..c7d1c27 100644 --- a/extensions/jwebot/trivia-quiz/src/trivia_quiz.py +++ b/extensions/jwebot/trivia-quiz/src/trivia_quiz.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for trivia-quiz.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'trivia-quiz' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '冷门知识问答' HELP_ENTRIES = ('/回答 A;/answer A',) COMMANDS = ('/回答', '/answer') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/context-summary/src/context_summary.py b/extensions/official/context-summary/src/context_summary.py index 95e0da4..aa58abb 100644 --- a/extensions/official/context-summary/src/context_summary.py +++ b/extensions/official/context-summary/src/context_summary.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for context-summary.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'context-summary' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '上下文总结' 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/debug-telemetry/src/debug_telemetry.py b/extensions/official/debug-telemetry/src/debug_telemetry.py index d29cf30..b6d2ee6 100644 --- a/extensions/official/debug-telemetry/src/debug_telemetry.py +++ b/extensions/official/debug-telemetry/src/debug_telemetry.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for debug-telemetry.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'debug-telemetry' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '埋点 p50/p95' HELP_ENTRIES = ('/debug',) COMMANDS = ('/debug',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/jwebot-version/src/jwebot_version.py b/extensions/official/jwebot-version/src/jwebot_version.py index 265c0c4..23c35b5 100644 --- a/extensions/official/jwebot-version/src/jwebot_version.py +++ b/extensions/official/jwebot-version/src/jwebot_version.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for jwebot-version.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'jwebot-version' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = 'JWeBot 版本' HELP_ENTRIES = ('/version /版本',) COMMANDS = ('/版本', '/version') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/person-directory/src/person_directory.py b/extensions/official/person-directory/src/person_directory.py index 05607ed..9dce717 100644 --- a/extensions/official/person-directory/src/person_directory.py +++ b/extensions/official/person-directory/src/person_directory.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for person-directory.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'person-directory' @@ -8,38 +8,11 @@ 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/person-memory/src/person_memory.py b/extensions/official/person-memory/src/person_memory.py index f03b86f..3101c0c 100644 --- a/extensions/official/person-memory/src/person_memory.py +++ b/extensions/official/person-memory/src/person_memory.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for person-memory.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'person-memory' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '管理个人和同群人物记忆' HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',) COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/extensions/official/server-status/src/server_status.py b/extensions/official/server-status/src/server_status.py index 9131f98..e732ae6 100644 --- a/extensions/official/server-status/src/server_status.py +++ b/extensions/official/server-status/src/server_status.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for server-status.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'server-status' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '服务器状态' HELP_ENTRIES = ('/状态;/status',) COMMANDS = ('/状态', '/status') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/jwebot_release_update.py b/jwebot_release_update.py index edef14f..c37360b 100644 --- a/jwebot_release_update.py +++ b/jwebot_release_update.py @@ -32,10 +32,14 @@ from jwebot_official_extensions.player_games import ( # noqa: E402 COMPENSATION_AMOUNT = 200 +MORA_COMPENSATION_RELEASES = { + "beta 2.0.0.1021": 400, +} PRIMOGEM_COMPENSATION_RELEASES = { "beta 1.0.5.1016": 1, "beta 1.1.0.1016": 1, "beta 1.2.0.1019": 1, + "beta 2.0.0.1021": 2, } MAX_BALANCE = 1_000_000_000 MAX_SUMMARY_CHARS = 300 @@ -127,13 +131,14 @@ def read_changelog_summary(path: Path, release: str) -> str: def build_announcement(release: str, summary: str) -> str: + mora = mora_compensation_amount(release) primogems = primogem_compensation_amount(release) extra = f",原石 x{primogems}" if primogems else "" announcement = ( "亲爱的群友:\n" f"{release} 更新现已完成。\n" f"更新内容:{summary}\n" - f"更新补偿:摩拉 x{COMPENSATION_AMOUNT}{extra}\n" + f"更新补偿:摩拉 x{mora}{extra}\n" "补偿范围:截至本公告发布前已创建摩拉账户的玩家\n" "发放方式:补偿已直接到账,无需领取\n" "感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!" @@ -145,10 +150,16 @@ def build_announcement(release: str, summary: str) -> str: def build_lifecycle_message(release: str, stage: str) -> str: if stage == "shutdown": - message = ( - f"Taffy 准备停机更新到 {release},会短暂离线一下;" - "别慌,我很快回来喵" - ) + if release == "beta 2.0.0.1021": + message = ( + "Taffy 准备停机更新,本次更新是架构级大版本更新," + f"预期版本{release},离线的时候保持耐心迎接Taffy的归来哦~" + ) + else: + message = ( + f"Taffy 准备停机更新到 {release},会短暂离线一下;" + "别慌,我很快回来喵" + ) elif stage == "startup": message = ( f"Taffy 刚上线,正在做 {release} 的最后自检;" @@ -165,6 +176,10 @@ def compensation_key(release: str) -> str: return f"release_compensation:{release}" +def mora_compensation_amount(release: str) -> int: + return int(MORA_COMPENSATION_RELEASES.get(release, COMPENSATION_AMOUNT)) + + def primogem_compensation_amount(release: str) -> int: return int(PRIMOGEM_COMPENSATION_RELEASES.get(release, 0)) @@ -196,6 +211,7 @@ def release_groups(database_path: Path) -> list[str]: def checked_marker(raw: str, release: str) -> dict[str, Any]: + expected_mora = mora_compensation_amount(release) expected_primogems = primogem_compensation_amount(release) try: marker = json.loads(raw) @@ -204,8 +220,8 @@ def checked_marker(raw: str, release: str) -> dict[str, Any]: if ( not isinstance(marker, dict) or marker.get("release") != release - or marker.get("amount") != COMPENSATION_AMOUNT - or marker.get("mora_amount") not in {None, COMPENSATION_AMOUNT} + or marker.get("amount") != expected_mora + or marker.get("mora_amount") not in {None, expected_mora} or marker.get("primogem_amount") not in {None, expected_primogems} or not isinstance(marker.get("group_fingerprints"), list) ): @@ -287,6 +303,7 @@ def apply_compensation( groups = sorted({str(row["group_fingerprint"]) for row in rows}) old_balance_sum = 0 old_primogem_sum = 0 + mora_amount = mora_compensation_amount(release) primogem_amount = primogem_compensation_amount(release) timestamp = isoformat_utc(applied_at) event_reason = f"{release} 版本更新补偿" @@ -302,14 +319,14 @@ def apply_compensation( balance = state.get("balance") if ( type(balance) is not int - or not 0 <= balance <= MAX_BALANCE - COMPENSATION_AMOUNT + or not 0 <= balance <= MAX_BALANCE - mora_amount ): raise ReleaseUpdateError("Mora account balance is invalid") old_balance_sum += balance old_primogem_sum += int(state["primogems"]) - state["balance"] = balance + COMPENSATION_AMOUNT + state["balance"] = balance + mora_amount state["primogems"] += primogem_amount - append_coin_event(state, COMPENSATION_AMOUNT, event_reason) + append_coin_event(state, mora_amount, event_reason) state["coin_events"][-1]["created_at"] = applied_at.timestamp() if primogem_amount: append_primogem_event( @@ -335,14 +352,14 @@ def apply_compensation( ) marker = { "release": release, - "amount": COMPENSATION_AMOUNT, - "mora_amount": COMPENSATION_AMOUNT, + "amount": mora_amount, + "mora_amount": mora_amount, "primogem_amount": primogem_amount, "accounts": len(rows), "group_fingerprints": groups, "old_balance_sum": old_balance_sum, "new_balance_sum": ( - old_balance_sum + COMPENSATION_AMOUNT * len(rows) + old_balance_sum + mora_amount * len(rows) ), "old_primogem_sum": old_primogem_sum, "new_primogem_sum": ( @@ -594,7 +611,7 @@ def run_release_update( "status": "ok", "release": release, "compensation": compensation_state, - "amount_each": COMPENSATION_AMOUNT, + "amount_each": mora_compensation_amount(release), "primogems_each": primogem_compensation_amount(release), "accounts": int(marker["accounts"]), "groups": len(groups), diff --git a/packages/jwebot-config/pyproject.toml b/packages/jwebot-config/pyproject.toml index 8c91958..b300ce1 100644 --- a/packages/jwebot-config/pyproject.toml +++ b/packages/jwebot-config/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-config" -version = "1.2.8b1+1019" +version = "2.0.0b1+1021" description = "Profile-based configuration compiler for JWeBot" requires-python = ">=3.9" dependencies = [ diff --git a/packages/jwebot-config/src/jwebot_config/__init__.py b/packages/jwebot-config/src/jwebot_config/__init__.py index f61f135..9ea784f 100644 --- a/packages/jwebot-config/src/jwebot_config/__init__.py +++ b/packages/jwebot-config/src/jwebot_config/__init__.py @@ -36,4 +36,4 @@ __all__ = [ "write_default_profile", ] -__version__ = "1.2.8b1+1019" +__version__ = "2.0.0b1+1021" diff --git a/packages/jwebot-extension-host/pyproject.toml b/packages/jwebot-extension-host/pyproject.toml index 69231ec..45c1d3e 100644 --- a/packages/jwebot-extension-host/pyproject.toml +++ b/packages/jwebot-extension-host/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-extension-host" -version = "1.2.8b1+1019" +version = "2.0.0b1+1021" description = "Single-release, single-invocation process host for JWeBot extensions" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==1.2.8b1+1019", - "jwebot-kernel==1.2.8b1+1019", + "jwebot-sdk==2.0.0b1+1021", + "jwebot-kernel==2.0.0b1+1021", ] [project.scripts] diff --git a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py index 0fa16b7..e2e6216 100644 --- a/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py +++ b/packages/jwebot-extension-host/src/jwebot_extension_host/__init__.py @@ -16,4 +16,4 @@ __all__ = [ "process_request", ] -__version__ = "1.2.8b1+1019" +__version__ = "2.0.0b1+1021" diff --git a/packages/jwebot-kernel/pyproject.toml b/packages/jwebot-kernel/pyproject.toml index 907a291..676d0c5 100644 --- a/packages/jwebot-kernel/pyproject.toml +++ b/packages/jwebot-kernel/pyproject.toml @@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-kernel" -version = "1.2.8b1+1019" +version = "2.0.0b1+1021" description = "Stable routing and extension authority for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-config==1.2.8b1+1019", - "jwebot-sdk==1.2.8b1+1019", - "jwebot-official-extensions==1.2.8b1+1019", + "jwebot-config==2.0.0b1+1021", + "jwebot-sdk==2.0.0b1+1021", + "jwebot-official-extensions==2.0.0b1+1021", "tomli>=2; python_version < '3.11'", ] diff --git a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py index a6c06c5..8240d59 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/__init__.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/__init__.py @@ -107,4 +107,4 @@ __all__ = [ "verify_installed_release_signature", ] -__version__ = "1.2.8b1+1019" +__version__ = "2.0.0b1+1021" diff --git a/packages/jwebot-kernel/src/jwebot_kernel/conversation_scope.py b/packages/jwebot-kernel/src/jwebot_kernel/conversation_scope.py new file mode 100644 index 0000000..f0b2ced --- /dev/null +++ b/packages/jwebot-kernel/src/jwebot_kernel/conversation_scope.py @@ -0,0 +1,248 @@ +"""Fail-closed conversation identities for local OCR shadow testing.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any, Mapping + + +SHADOW_CONFIG_ENV = "JWEBOT_CONVERSATION_SHADOW_CONFIG" +SHADOW_CONFIG_VERSION = 1 +MAX_SHADOW_TARGETS = 8 +MAX_EVENT_TEXT = 5_000 +CONVERSATION_KEY = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +TITLE_FINGERPRINT = re.compile(r"^[0-9a-f]{64}$") + + +class ConversationConfigError(ValueError): + """Raised when an opt-in shadow conversation registry is invalid.""" + + +class ConversationKind(str, Enum): + GROUP = "group" + DIRECT = "direct" + + +def _required_text(value: object, *, name: str, limit: int) -> str: + text = str(value or "").strip() + if not text: + raise ConversationConfigError(f"{name} is required") + if len(text) > limit: + raise ConversationConfigError(f"{name} exceeds {limit} characters") + return text + + +def _trigger_question(text: str, *, bot_mention: str, direct: bool) -> str: + mention_prefix = r"(?:[@@]\s*)?" if direct else r"[@@]\s*" + mention = re.compile( + rf"^\s*{mention_prefix}{re.escape(bot_mention)}" + r"(?:\s*[::,,])?\s*(\S.*)$", + re.IGNORECASE, + ) + for line in text.splitlines(): + stripped = line.strip() + if direct and stripped.startswith("/"): + return stripped + match = mention.match(line) + if match: + return match.group(1).strip() + return "" + + +@dataclass(frozen=True) +class ConversationTarget: + """One explicitly registered title fingerprint in shadow mode.""" + + key: str + kind: ConversationKind + title_fingerprint: str + sender: str = "" + + def __post_init__(self) -> None: + if not CONVERSATION_KEY.fullmatch(self.key): + raise ConversationConfigError("conversation key must be an opaque slug") + if not TITLE_FINGERPRINT.fullmatch(self.title_fingerprint): + raise ConversationConfigError( + "title_fingerprint must be a lowercase SHA-256 digest" + ) + if self.kind is ConversationKind.DIRECT: + _required_text(self.sender, name="direct sender", limit=120) + elif self.sender: + raise ConversationConfigError("group targets cannot define a fixed sender") + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "ConversationTarget": + if not isinstance(value, Mapping): + raise ConversationConfigError("conversation target must be an object") + unknown = set(value) - { + "key", + "kind", + "title_fingerprint", + "sender", + } + if unknown: + raise ConversationConfigError("conversation target has unknown fields") + try: + kind = ConversationKind(str(value.get("kind", ""))) + except ValueError as error: + raise ConversationConfigError("conversation kind is invalid") from error + return cls( + key=_required_text(value.get("key"), name="conversation key", limit=64), + kind=kind, + title_fingerprint=_required_text( + value.get("title_fingerprint"), + name="title_fingerprint", + limit=64, + ).casefold(), + sender=str(value.get("sender", "")).strip(), + ) + + def question(self, text: str, *, bot_mention: str) -> str: + return _trigger_question( + text, + bot_mention=bot_mention, + direct=self.kind is ConversationKind.DIRECT, + ) + + +@dataclass(frozen=True) +class ShadowConversationRegistry: + """A registry that can observe but can never authorize delivery.""" + + enabled: bool = False + targets: tuple[ConversationTarget, ...] = () + + def __post_init__(self) -> None: + keys = [target.key for target in self.targets] + fingerprints = [target.title_fingerprint for target in self.targets] + if len(keys) != len(set(keys)): + raise ConversationConfigError("conversation keys must be unique") + if len(fingerprints) != len(set(fingerprints)): + raise ConversationConfigError("title fingerprints must be unique") + + @classmethod + def disabled(cls) -> "ShadowConversationRegistry": + return cls() + + @classmethod + def from_mapping(cls, value: Mapping[str, Any]) -> "ShadowConversationRegistry": + if not isinstance(value, Mapping): + raise ConversationConfigError("shadow registry must be an object") + unknown = set(value) - {"format_version", "enabled", "targets"} + if unknown: + raise ConversationConfigError("shadow registry has unknown fields") + if value.get("format_version") != SHADOW_CONFIG_VERSION: + raise ConversationConfigError("unsupported shadow registry version") + enabled = value.get("enabled", False) + if not isinstance(enabled, bool): + raise ConversationConfigError("shadow registry enabled must be boolean") + raw_targets = value.get("targets", []) + if not isinstance(raw_targets, list): + raise ConversationConfigError("shadow registry targets must be a list") + if len(raw_targets) > MAX_SHADOW_TARGETS: + raise ConversationConfigError("shadow registry has too many targets") + targets = tuple(ConversationTarget.from_mapping(item) for item in raw_targets) + if any(target.kind is not ConversationKind.DIRECT for target in targets): + raise ConversationConfigError("shadow registry accepts direct targets only") + return cls(enabled=enabled, targets=targets) + + @classmethod + def from_environment( + cls, + environment: Mapping[str, str] | None = None, + ) -> "ShadowConversationRegistry": + source = environment if environment is not None else os.environ + raw_path = str(source.get(SHADOW_CONFIG_ENV, "")).strip() + if not raw_path: + return cls.disabled() + path = Path(raw_path).expanduser() + if path.is_symlink() or not path.is_file(): + raise ConversationConfigError("shadow registry path must be a regular file") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ConversationConfigError("shadow registry cannot be read") from error + if not isinstance(value, Mapping): + raise ConversationConfigError("shadow registry must be an object") + return cls.from_mapping(value) + + def target_for_fingerprint( + self, + fingerprint: str, + ) -> ConversationTarget | None: + if not self.enabled: + return None + normalized = str(fingerprint).casefold() + return next( + ( + target + for target in self.targets + if target.title_fingerprint == normalized + ), + None, + ) + + +def build_shadow_event( + target: ConversationTarget, + *, + observed_at: str, + text: str, + body_confidence: float, + visual_signature: str, + bot_mention: str, + observed_sender: str = "", + sender_confidence: float = 0.0, + ordinal: int = 0, +) -> dict[str, Any]: + """Build a non-dispatchable observation for synthetic and shadow tests.""" + + content = _required_text(text, name="event text", limit=MAX_EVENT_TEXT) + question = target.question(content, bot_mention=bot_mention) + sender = ( + target.sender + if target.kind is ConversationKind.DIRECT + else _required_text(observed_sender, name="observed sender", limit=120) + ) + effective_sender_confidence = ( + 1.0 + if target.kind is ConversationKind.DIRECT + else max(0.0, min(1.0, float(sender_confidence))) + ) + effective_body_confidence = max(0.0, min(1.0, float(body_confidence))) + seed = ( + f"{target.key}\n{observed_at}\n{ordinal}\n{visual_signature}\n" + f"{sender}\n{content}" + ) + return { + "version": 1, + "event_id": f"shadow-{hashlib.sha256(seed.encode()).hexdigest()[:24]}", + "observed_at": observed_at, + "sender": sender, + "text": content, + "question": question, + "confidence": min( + effective_sender_confidence, + effective_body_confidence, + ), + "sender_confidence": effective_sender_confidence, + "body_confidence": effective_body_confidence, + "side": "incoming", + "mention_bot": bool(question), + "group_fingerprint": target.title_fingerprint, + "conversation_key": target.key, + "conversation_kind": target.kind.value, + "source": ( + "rapidocr-private-shadow" + if target.kind is ConversationKind.DIRECT + else "rapidocr-group-shadow" + ), + "shadow_only": True, + "dispatch_allowed": False, + } diff --git a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py index d907043..083dd1a 100644 --- a/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py +++ b/packages/jwebot-kernel/src/jwebot_kernel/edge_runtime.py @@ -8009,6 +8009,20 @@ class EdgeApplication: started = time.monotonic() event_id = str(event.get("event_id", "")) group_id = str(event.get("group_fingerprint", "")) + if event.get("shadow_only") or event.get("dispatch_allowed") is False: + self.telemetry.elapsed( + "edge.event.accepted", + started, + status="shadow_ignored", + event_id=event_id, + group_id=group_id, + route="none", + execution_path="none", + ) + return { + "status": "ignored", + "reason": "shadow_only", + } question = event_question(event) decision = route_question(question) route = decision.route.value diff --git a/packages/jwebot-official-extensions/pyproject.toml b/packages/jwebot-official-extensions/pyproject.toml index b936f17..ea47372 100644 --- a/packages/jwebot-official-extensions/pyproject.toml +++ b/packages/jwebot-official-extensions/pyproject.toml @@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-official-extensions" -version = "1.2.8b1+1019" +version = "2.0.0b1+1021" description = "Reviewed local command adapters and broker implementation for JWeBot" requires-python = ">=3.9" dependencies = [ - "jwebot-sdk==1.2.8b1+1019", + "jwebot-sdk==2.0.0b1+1021", ] [tool.setuptools.packages.find] diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py index f6c3fcc..6cd5db6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/__init__.py @@ -90,4 +90,4 @@ __all__ = [ "render_feature_list", ] -__version__ = "1.2.8b1+1019" +__version__ = "2.0.0b1+1021" diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py index caf95c6..0d42dfd 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '抛一枚硬币' HELP_ENTRIES = ('/翻硬币',) COMMANDS = ('/翻硬币',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py index 8367363..dd03a89 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_loan.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-loan.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-loan' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '贷款' HELP_ENTRIES = ('/贷款;/还款',) COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_ranking.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_ranking.py index 9374bab..7f9aa99 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_ranking.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '富榜' HELP_ENTRIES = ('/大富翁榜',) COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py index 0b1836a..067f6ae 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_taffy.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-taffy.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-taffy' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = 'Taffy摩拉' HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',) COMMANDS = ('/Taffy摩拉', '/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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py index 1b257cb..b157cf6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/coin_transfer.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for coin-transfer.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'coin-transfer' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '摩拉转账' HELP_ENTRIES = ('/转账 对方 金额;/transfer',) COMMANDS = ('/转账', '/transfer') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py index 95e0da4..aa58abb 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/context_summary.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for context-summary.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'context-summary' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '上下文总结' 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py index b50af17..a0ff3ec 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/cs_major_autochess.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for cs-major-autochess.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'cs-major-autochess' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '自走棋' HELP_ENTRIES = ('/自走棋 开始|报名|选',) COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py index 00c9738..07d084d 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/daily_fortune.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for daily-fortune.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'daily-fortune' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '生成当天稳定一致的运势' HELP_ENTRIES = ('/今日运势',) COMMANDS = ('/今日运势',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py index d29cf30..b6d2ee6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/debug_telemetry.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for debug-telemetry.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'debug-telemetry' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '埋点 p50/p95' HELP_ENTRIES = ('/debug',) COMMANDS = ('/debug',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py index 1e4806f..d12538e 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/dice.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for dice.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'dice' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '骰子和多人骰子挑战' HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]') COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py index 5f8efb6..3923f9d 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/draw_lot.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for draw-lot.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'draw-lot' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '抽取一条本地签文' HELP_ENTRIES = ('/抽签',) COMMANDS = ('/抽签',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/game_history.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/game_history.py index 9eab42c..cb8b114 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/game_history.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/game_history.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for game-history.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'game-history' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '游戏记录' HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',) COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py index 8b7252f..e4f4557 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/guess_number.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for guess-number.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'guess-number' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '群内持久化猜数字游戏' HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',) COMMANDS = ('/猜数字', '/猜') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py index 1deffff..6cb4d5e 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/history_search.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for history-search.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'history-search' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '搜索本地群聊文字记录' HELP_ENTRIES = ('/查记录 关键词',) COMMANDS = ('/查记录', '/历史搜索') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py index 265c0c4..23c35b5 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/jwebot_version.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for jwebot-version.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'jwebot-version' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = 'JWeBot 版本' HELP_ENTRIES = ('/version /版本',) COMMANDS = ('/版本', '/version') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py index 05607ed..9dce717 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_directory.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for person-directory.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'person-directory' @@ -8,38 +8,11 @@ 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py index f03b86f..3101c0c 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/person_memory.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for person-memory.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'person-memory' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '管理个人和同群人物记忆' HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',) COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/primogem_economy.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/primogem_economy.py index 30a6919..4519249 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/primogem_economy.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/primogem_economy.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for primogem-economy.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'primogem-economy' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '原石系统' HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',) COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py index c8e47a3..ca0e42b 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/random_number.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for random-number.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'random-number' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '在指定整数范围内取随机数' HELP_ENTRIES = ('/随机数 最小值 最大值',) COMMANDS = ('/随机数',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py index daaeb8b..c5bb86e 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/repeat_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for repeat-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'repeat-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '查看北京时间今日复读排行' HELP_ENTRIES = ('/复读榜',) COMMANDS = ('/复读榜', '/复读') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py index ba94576..f23f1f5 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/rock_paper_scissors.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for rock-paper-scissors.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'rock-paper-scissors' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '多人石头剪刀布游戏' HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',) COMMANDS = ('/石头剪刀布',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py index 51f6c9d..73f6a1e 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/scan_ranking.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for scan-ranking.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'scan-ranking' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '查看北京时间今日扫码排行' HELP_ENTRIES = ('/扫码排行',) COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py index 9131f98..e732ae6 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/server_status.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for server-status.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'server-status' @@ -8,38 +8,11 @@ AUTHOR = 'official' DESCRIPTION = '服务器状态' HELP_ENTRIES = ('/状态;/status',) COMMANDS = ('/状态', '/status') - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py index 06704c2..6fafc53 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/single_player_games.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for single-player-games.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'single-player-games' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '摩拉游戏' HELP_ENTRIES = ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',) 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(), - }, - ) - 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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py index 75a7dcf..4833727 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/steam_presence.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for steam-presence.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'steam-presence' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = 'Steam 状态与时长' HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',) COMMANDS = ('/steam',) - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/trivia_quiz.py b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/trivia_quiz.py index 80a02b9..c7d1c27 100644 --- a/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/trivia_quiz.py +++ b/packages/jwebot-official-extensions/src/jwebot_official_extensions/adapters/trivia_quiz.py @@ -1,6 +1,6 @@ """Generated reviewed adapter for trivia-quiz.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = 'trivia-quiz' @@ -8,38 +8,11 @@ AUTHOR = 'jwebot' DESCRIPTION = '冷门知识问答' HELP_ENTRIES = ('/回答 A;/answer A',) COMMANDS = ('/回答', '/answer') - - -def _handler(context: SingleGroupContext, command: str): - def handle(event: Event, arguments: str) -> ExtensionResult: - response = context.services.call( - PROFILE, - operation="execute", - payload={ - "command": command, - "arguments": arguments, - "event": event.to_mapping(), - }, - ) - texts = response.get("texts") - if isinstance(texts, list) and texts and all( - isinstance(item, str) and item.strip() for item in texts - ): - return ExtensionResult( - reply=ReplyPlan.multi_text(texts), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - text = response.get("text") - if not isinstance(text, str) or not text.strip(): - raise ValueError("local service returned invalid text") - return ExtensionResult( - reply=ReplyPlan.text(text), - metadata={"local_extension": PROFILE, "author": AUTHOR}, - ) - - return handle +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={"local_extension": PROFILE, "author": AUTHOR}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) diff --git a/packages/jwebot-sdk/pyproject.toml b/packages/jwebot-sdk/pyproject.toml index 121c06b..4396f12 100644 --- a/packages/jwebot-sdk/pyproject.toml +++ b/packages/jwebot-sdk/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "jwebot-sdk" -version = "1.2.8b1+1019" +version = "2.0.0b1+1021" description = "Typed, capability-limited extension API for JWeBot plugins" requires-python = ">=3.9" dependencies = [] diff --git a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py index f606241..b04d223 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/__init__.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/__init__.py @@ -8,6 +8,7 @@ from .context import ( PluginLogger, PluginStorage, Scheduler, + ServiceCommandAdapter, SingleGroupContext, ) from .contracts import ( @@ -35,7 +36,8 @@ __all__ = [ "PluginStorage", "ReplyPlan", "Scheduler", + "ServiceCommandAdapter", "SingleGroupContext", ] -__version__ = "1.2.8b1+1019" +__version__ = "2.0.0b1+1021" diff --git a/packages/jwebot-sdk/src/jwebot_sdk/context.py b/packages/jwebot-sdk/src/jwebot_sdk/context.py index d8933d7..0bb025a 100644 --- a/packages/jwebot-sdk/src/jwebot_sdk/context.py +++ b/packages/jwebot-sdk/src/jwebot_sdk/context.py @@ -2,10 +2,10 @@ from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from typing import Protocol -from .contracts import Event, ExtensionResult, Fact +from .contracts import Event, ExtensionResult, Fact, ReplyPlan CommandHandler = Callable[[Event, str], ExtensionResult] @@ -68,3 +68,67 @@ class SingleGroupContext(Protocol): facts: FactWriter scheduler: Scheduler logger: PluginLogger + + +class ServiceCommandAdapter: + """Register commands that delegate only to declared kernel service profiles.""" + + def __init__( + self, + profile: str, + *, + operation: str = "execute", + metadata: Mapping[str, str] | None = None, + ) -> None: + self.profile = self._bounded_text(profile, "profile") + self.operation = self._bounded_text(operation, "operation") + self.metadata = { + str(key): str(value) + for key, value in (metadata or {}).items() + } + + @staticmethod + def _bounded_text(value: object, name: str) -> str: + text = str(value or "").strip() + if not text or len(text) > 80: + raise ValueError(f"{name} is invalid") + return text + + def _handler(self, context: SingleGroupContext, command: str) -> CommandHandler: + command_name = self._bounded_text(command, "command") + + def handle(event: Event, arguments: str) -> ExtensionResult: + response = context.services.call( + self.profile, + operation=self.operation, + payload={ + "command": command_name, + "arguments": str(arguments), + "event": event.to_mapping(), + }, + ) + texts = response.get("texts") + if isinstance(texts, list) and texts: + if not all(isinstance(item, str) and item.strip() for item in texts): + raise ValueError("service returned invalid text parts") + return ExtensionResult( + reply=ReplyPlan.multi_text(texts), + metadata=self.metadata, + ) + text = response.get("text") + if not isinstance(text, str) or not text.strip(): + raise ValueError("service returned invalid text") + return ExtensionResult( + reply=ReplyPlan.text(text), + metadata=self.metadata, + ) + + return handle + + def register( + self, + context: SingleGroupContext, + commands: Iterable[str], + ) -> None: + for command in commands: + context.commands.register(command, self._handler(context, command)) diff --git a/pyproject.toml b/pyproject.toml index cc3f286..5dc1948 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.jwebot] -release = "beta 1.2.8.1019" +release = "beta 2.0.0.1021" production_deployments_enabled_by_default = false [tool.jwebot.workspace] diff --git a/tests/test_conversation_scope.py b/tests/test_conversation_scope.py new file mode 100644 index 0000000..3f88027 --- /dev/null +++ b/tests/test_conversation_scope.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 + +import hashlib +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [ + str(PROJECT_ROOT / "packages" / "jwebot-config" / "src"), + str(PROJECT_ROOT / "packages" / "jwebot-sdk" / "src"), + str(PROJECT_ROOT / "packages" / "jwebot-official-extensions" / "src"), + str(PROJECT_ROOT / "packages" / "jwebot-kernel" / "src"), +] + +from jwebot_kernel.conversation_scope import ( # noqa: E402 + ConversationConfigError, + ConversationKind, + ConversationTarget, + ShadowConversationRegistry, + build_shadow_event, +) + + +def fingerprint(label: str) -> str: + return hashlib.sha256(label.encode()).hexdigest() + + +class ConversationTargetTests(unittest.TestCase): + def test_group_requires_an_explicit_mention(self): + target = ConversationTarget( + key="main-group", + kind=ConversationKind.GROUP, + title_fingerprint=fingerprint("main-group"), + ) + + self.assertEqual( + target.question("@JWeBot hello", bot_mention="JWeBot"), + "hello", + ) + self.assertEqual(target.question("hello", bot_mention="JWeBot"), "") + + def test_direct_chat_accepts_only_commands_or_wake_words(self): + target = ConversationTarget( + key="test-direct", + kind=ConversationKind.DIRECT, + title_fingerprint=fingerprint("test-direct"), + sender="Test User", + ) + + self.assertEqual( + target.question("/状态", bot_mention="JWeBot"), + "/状态", + ) + self.assertEqual( + target.question("JWeBot: hello", bot_mention="JWeBot"), + "hello", + ) + self.assertEqual(target.question("普通聊天", bot_mention="JWeBot"), "") + + def test_direct_chat_requires_a_configured_sender(self): + with self.assertRaisesRegex(ConversationConfigError, "direct sender"): + ConversationTarget( + key="test-direct", + kind=ConversationKind.DIRECT, + title_fingerprint=fingerprint("test-direct"), + ) + + def test_title_identity_must_be_a_sha256_digest(self): + with self.assertRaisesRegex(ConversationConfigError, "SHA-256"): + ConversationTarget( + key="test-direct", + kind=ConversationKind.DIRECT, + title_fingerprint="visible-contact-name", + sender="Test User", + ) + + +class ShadowRegistryTests(unittest.TestCase): + def test_registry_is_disabled_without_an_explicit_path(self): + registry = ShadowConversationRegistry.from_environment({}) + + self.assertFalse(registry.enabled) + self.assertEqual(registry.targets, ()) + + def test_disabled_registry_never_matches_targets(self): + digest = fingerprint("test-direct") + registry = ShadowConversationRegistry.from_mapping( + { + "format_version": 1, + "enabled": False, + "targets": [ + { + "key": "test-direct", + "kind": "direct", + "title_fingerprint": digest, + "sender": "Test User", + } + ], + } + ) + + self.assertIsNone(registry.target_for_fingerprint(digest)) + + def test_explicit_config_loads_only_shadow_targets(self): + digest = fingerprint("test-direct") + config = { + "format_version": 1, + "enabled": True, + "targets": [ + { + "key": "test-direct", + "kind": "direct", + "title_fingerprint": digest, + "sender": "Test User", + } + ], + } + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "shadow.json" + path.write_text(json.dumps(config), encoding="utf-8") + registry = ShadowConversationRegistry.from_environment( + {"JWEBOT_CONVERSATION_SHADOW_CONFIG": str(path)} + ) + + self.assertTrue(registry.enabled) + self.assertEqual(registry.target_for_fingerprint(digest), registry.targets[0]) + + def test_duplicate_title_fingerprints_are_rejected(self): + digest = fingerprint("duplicate") + with self.assertRaisesRegex(ConversationConfigError, "fingerprints"): + ShadowConversationRegistry.from_mapping( + { + "format_version": 1, + "enabled": True, + "targets": [ + { + "key": "direct-a", + "kind": "direct", + "title_fingerprint": digest, + "sender": "Test A", + }, + { + "key": "direct-b", + "kind": "direct", + "title_fingerprint": digest, + "sender": "Test B", + }, + ], + } + ) + + def test_group_targets_are_rejected_in_private_shadow_phase(self): + with self.assertRaisesRegex(ConversationConfigError, "direct targets only"): + ShadowConversationRegistry.from_mapping( + { + "format_version": 1, + "enabled": True, + "targets": [ + { + "key": "main-group", + "kind": "group", + "title_fingerprint": fingerprint("main-group"), + } + ], + } + ) + + def test_unknown_delivery_field_is_rejected(self): + with self.assertRaisesRegex(ConversationConfigError, "unknown fields"): + ShadowConversationRegistry.from_mapping( + { + "format_version": 1, + "enabled": True, + "delivery_enabled": True, + "targets": [], + } + ) + + +class ShadowEventTests(unittest.TestCase): + def test_direct_event_uses_verified_conversation_identity(self): + digest = fingerprint("test-direct") + target = ConversationTarget( + key="test-direct", + kind=ConversationKind.DIRECT, + title_fingerprint=digest, + sender="Test User", + ) + + event = build_shadow_event( + target, + observed_at="2026-10-20T12:00:00+08:00", + text="/状态", + body_confidence=0.98, + visual_signature="visual-1", + bot_mention="JWeBot", + ) + + self.assertEqual(event["sender"], "Test User") + self.assertEqual(event["sender_confidence"], 1.0) + self.assertEqual(event["question"], "/状态") + self.assertTrue(event["mention_bot"]) + self.assertEqual(event["group_fingerprint"], digest) + self.assertEqual(event["conversation_kind"], "direct") + self.assertTrue(event["shadow_only"]) + self.assertFalse(event["dispatch_allowed"]) + + def test_unaddressed_direct_message_is_observed_but_not_triggered(self): + target = ConversationTarget( + key="test-direct", + kind=ConversationKind.DIRECT, + title_fingerprint=fingerprint("test-direct"), + sender="Test User", + ) + + event = build_shadow_event( + target, + observed_at="2026-10-20T12:00:00+08:00", + text="普通聊天", + body_confidence=0.98, + visual_signature="visual-2", + bot_mention="JWeBot", + ) + + self.assertEqual(event["question"], "") + self.assertFalse(event["mention_bot"]) + self.assertTrue(event["shadow_only"]) + self.assertFalse(event["dispatch_allowed"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_jwebot_edge.py b/tests/test_jwebot_edge.py index c75ebbd..fe6dcef 100644 --- a/tests/test_jwebot_edge.py +++ b/tests/test_jwebot_edge.py @@ -316,6 +316,48 @@ class LedgerAndApplicationTests(unittest.TestCase): "第二条回复喵", ) + def test_conversation_context_is_isolated_by_fingerprint(self): + observed = {} + + def complete(**kwargs): + observed[kwargs["event_id"]] = list(kwargs["context"]) + return f"{kwargs['event_id']} 回复喵" + + self.application.deepseek.complete = complete + self.application.deliver = mock.Mock() + for event_id, group, text in ( + ("group-first", "group-a", "@JWeBot 群里的第一条"), + ("direct-first", "direct-a", "私聊里的第一条"), + ("group-second", "group-a", "@JWeBot 群里继续"), + ("direct-second", "direct-a", "私聊继续"), + ): + self.application.accept(event(event_id, text, group=group)) + self.application.process_direct_chat_once() + + self.assertEqual( + observed["group-second"], + [("群里的第一条", "group-first 回复喵")], + ) + self.assertEqual( + observed["direct-second"], + [("私聊里的第一条", "direct-first 回复喵")], + ) + + def test_shadow_event_is_rejected_before_routing_or_persistence(self): + response = self.application.accept( + { + **event("shadow-1", "/状态", group="direct-a"), + "shadow_only": True, + "dispatch_allowed": False, + } + ) + + self.assertEqual( + response, + {"status": "ignored", "reason": "shadow_only"}, + ) + self.assertIsNone(self.application.ledger.row("shadow-1")) + def test_local_game_command_result_is_available_to_followup_chat(self): observed_context = [] @@ -1039,9 +1081,9 @@ class LedgerAndApplicationTests(unittest.TestCase): self.assertEqual(version["route"], "local") self.assertIn("JWeBot 版本", version["text"]) self.assertIn("kernel", version["text"]) - self.assertIn("最新版本:beta 1.2.8.1019", version["text"]) - self.assertIn("更新时间:2026-10-19 22:49:30", version["text"]) - self.assertIn("更新摘要:冷门知识问答题库整体重写", version["text"]) + self.assertIn("最新版本:beta 2.0.0.1021", version["text"]) + self.assertIn("更新时间:2026-10-21 16:57:39", version["text"]) + self.assertIn("更新摘要:Beta 2.0 架构更新完成", version["text"]) self.assertEqual(debug["route"], "local") self.assertIn("调试埋点", debug["text"]) self.assertEqual(balance["route"], "local") diff --git a/tests/test_release_update.py b/tests/test_release_update.py index a88e565..11b37c3 100644 --- a/tests/test_release_update.py +++ b/tests/test_release_update.py @@ -20,7 +20,7 @@ SPEC.loader.exec_module(MODULE) class ReleaseUpdateTests(unittest.TestCase): - release = "beta 1.2.8.1019" + release = "beta 2.0.0.1021" def setUp(self): self.temporary = tempfile.TemporaryDirectory() @@ -111,8 +111,7 @@ class ReleaseUpdateTests(unittest.TestCase): self.assertEqual(summary, "第一行更新摘要, 同一条摘要可以换行。") announcement = MODULE.build_announcement(self.release, summary) self.assertIn(f"{self.release} 更新现已完成", announcement) - self.assertIn("更新补偿:摩拉 x200", announcement) - self.assertNotIn("原石 x1", announcement) + self.assertIn("更新补偿:摩拉 x400,原石 x2", announcement) self.assertNotIn("第二条", announcement) def test_policy_release_announces_primogem(self): @@ -121,10 +120,17 @@ class ReleaseUpdateTests(unittest.TestCase): "beta 1.1.0.1016", "beta 1.2.0.1019", ): + self.assertEqual(MODULE.mora_compensation_amount(release), 200) self.assertEqual(MODULE.primogem_compensation_amount(release), 1) announcement = MODULE.build_announcement(release, "原石系统上线。") self.assertIn("更新补偿:摩拉 x200,原石 x1", announcement) + def test_major_release_announces_larger_compensation(self): + self.assertEqual(MODULE.mora_compensation_amount(self.release), 400) + self.assertEqual(MODULE.primogem_compensation_amount(self.release), 2) + announcement = MODULE.build_announcement(self.release, "架构更新完成。") + self.assertIn("更新补偿:摩拉 x400,原石 x2", announcement) + def test_patch_release_without_policy_does_not_announce_primogem(self): release = "beta 1.1.2.1019" self.assertEqual(MODULE.primogem_compensation_amount(release), 0) @@ -149,8 +155,13 @@ class ReleaseUpdateTests(unittest.TestCase): self.assertEqual(result["stage"], "shutdown") self.assertEqual(result["delivered"], 1) self.assertEqual(delivered[0][1], "group-a") - self.assertIn("准备停机更新", delivered[0][2]) - self.assertIn(self.release, delivered[0][2]) + self.assertEqual( + delivered[0][2], + ( + "Taffy 准备停机更新,本次更新是架构级大版本更新," + f"预期版本{self.release},离线的时候保持耐心迎接Taffy的归来哦~" + ), + ) self.assertIn("shutdown", delivered[0][3]) self.assertEqual([state["balance"] for state in self.account_states()], [1000, 50]) @@ -186,13 +197,13 @@ class ReleaseUpdateTests(unittest.TestCase): self.assertEqual(len(delivered), 1) self.assertEqual(delivered[0][1], "group-a") self.assertIn("更新摩拉账户并修复重复回复问题", delivered[0][2]) - self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) + self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450]) for state in self.account_states(): - self.assertEqual(state["primogems"], 0) + self.assertEqual(state["primogems"], 2) matches = [ event for event in state["coin_events"] - if event["delta"] == 200 + if event["delta"] == 400 and event["reason"] == f"{self.release} 版本更新补偿" ] self.assertEqual(len(matches), 1) @@ -202,7 +213,12 @@ class ReleaseUpdateTests(unittest.TestCase): for event in state["primogem_events"] if event["reason"] == f"{self.release} 版本更新补偿" ] - self.assertEqual(primogem_matches, []) + self.assertEqual(len(primogem_matches), 1) + self.assertEqual(primogem_matches[0]["delta"], 2) + self.assertEqual( + primogem_matches[0]["created_at"], + applied_at.timestamp(), + ) self.assertEqual(self.announcement_marker()["state"], "delivered") self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1) @@ -219,8 +235,8 @@ class ReleaseUpdateTests(unittest.TestCase): delivery_helper=self.delivery_helper, deliverer=uncertain, ) - self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) - self.assertEqual([state["primogems"] for state in self.account_states()], [0, 0]) + self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450]) + self.assertEqual([state["primogems"] for state in self.account_states()], [2, 2]) self.assertEqual(self.announcement_marker()["state"], "uncertain") called = [] @@ -250,8 +266,8 @@ class ReleaseUpdateTests(unittest.TestCase): ) self.assertEqual(result["compensation"], "already_applied") self.assertEqual(len(called), 1) - self.assertEqual([state["balance"] for state in self.account_states()], [1200, 250]) - self.assertEqual([state["primogems"] for state in self.account_states()], [0, 0]) + self.assertEqual([state["balance"] for state in self.account_states()], [1400, 450]) + self.assertEqual([state["primogems"] for state in self.account_states()], [2, 2]) self.assertEqual(self.announcement_marker()["state"], "delivered") diff --git a/tests/test_sdk_contracts.py b/tests/test_sdk_contracts.py index b4f19c2..19dcf31 100644 --- a/tests/test_sdk_contracts.py +++ b/tests/test_sdk_contracts.py @@ -16,6 +16,7 @@ from jwebot_sdk import ( # noqa: E402 Fact, PlanKind, ReplyPlan, + ServiceCommandAdapter, ) @@ -108,5 +109,70 @@ class PlanContractTests(unittest.TestCase): ExtensionResult.from_mapping(value) +class ServiceCommandAdapterTests(unittest.TestCase): + def event(self): + return Event( + event_id="event-1", + observed_at="2026-10-14T10:00:00+08:00", + sender="Alice", + text="@JWeBot /coin", + group_fingerprint="group-a", + mention_bot=True, + ) + + def context(self, response): + class Commands: + def __init__(self): + self.handlers = {} + + def register(self, command, handler): + self.handlers[command] = handler + + class Services: + def __init__(self): + self.calls = [] + + def call(self, profile, *, operation, payload): + self.calls.append((profile, operation, payload)) + return response + + class Context: + pass + + context = Context() + context.commands = Commands() + context.services = Services() + return context + + def test_adapter_delegates_to_declared_service_profile(self): + context = self.context({"texts": ["第一条", "第二条"]}) + adapter = ServiceCommandAdapter( + "coin", + metadata={"local_extension": "coin"}, + ) + + adapter.register(context, ["/翻硬币"]) + result = context.commands.handlers["/翻硬币"](self.event(), "") + + self.assertEqual(result.reply.kind, PlanKind.MULTI_TEXT) + self.assertEqual(result.reply.messages, ("第一条", "第二条")) + self.assertEqual(dict(result.metadata), {"local_extension": "coin"}) + self.assertEqual(context.services.calls[0][0], "coin") + self.assertEqual(context.services.calls[0][1], "execute") + self.assertEqual(context.services.calls[0][2]["command"], "/翻硬币") + self.assertEqual( + context.services.calls[0][2]["event"]["event_id"], + "event-1", + ) + + def test_adapter_rejects_invalid_service_text(self): + context = self.context({"texts": ["ok", ""]}) + adapter = ServiceCommandAdapter("coin") + + adapter.register(context, ["/翻硬币"]) + with self.assertRaisesRegex(ValueError, "invalid text parts"): + context.commands.handlers["/翻硬币"](self.event(), "") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_wechat_local_ocr.py b/tests/test_wechat_local_ocr.py index b9bb2b3..f1f9ea2 100755 --- a/tests/test_wechat_local_ocr.py +++ b/tests/test_wechat_local_ocr.py @@ -931,6 +931,263 @@ class RecorderSignatureMigrationTests(unittest.TestCase): engine.warmup.assert_called_once() +class RecorderConversationStateTests(unittest.TestCase): + def test_legacy_active_state_migrates_only_to_its_matching_conversation(self): + engine = mock.Mock() + with mock.patch.object( + MODULE, + "ensure_directories", + ), mock.patch.object( + MODULE, + "OcrEngine", + return_value=engine, + ), mock.patch.object( + MODULE, + "read_json", + return_value={ + "initialized": True, + "last_group_fingerprint": "group-a", + "last_visual_hash": "visual-a", + "visible_signatures": ["message-a"], + "recent_trigger_signatures": ["trigger-a"], + "message_signature_scheme": MODULE.MESSAGE_SIGNATURE_SCHEME, + }, + ): + recorder = MODULE.Recorder(queue_enabled=True) + + group_a = recorder.conversation_state("group-a") + group_b = recorder.conversation_state("group-b") + + self.assertTrue(group_a["initialized"]) + self.assertEqual(group_a["visible_signatures"], ["message-a"]) + self.assertFalse(group_b["initialized"]) + self.assertEqual(group_b["visible_signatures"], []) + + def test_switching_conversations_does_not_cross_deduplicate_messages(self): + engine = mock.Mock() + first_a = MODULE.IncomingMessage( + "Alice", + "@JWeBot first", + 0.98, + 0.99, + 0.98, + 100, + "a-first", + ) + first_b = MODULE.IncomingMessage( + "Bob", + "@JWeBot first", + 0.98, + 0.99, + 0.98, + 100, + "b-first", + ) + second_a = MODULE.IncomingMessage( + "Alice", + "@JWeBot second", + 0.98, + 0.99, + 0.98, + 160, + "a-second", + ) + engine.recognize_messages.side_effect = [ + ([first_a], 0.01), + ([first_b], 0.01), + ([first_a, second_a], 0.01), + ] + engine.last_cache_hits = 0 + engine.last_cache_misses = 1 + engine.last_cache_refresh = False + queued = [] + image = MODULE.Image.new( + "RGB", + (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), + "white", + ) + + with mock.patch.object( + MODULE, + "ensure_directories", + ), mock.patch.object( + MODULE, + "OcrEngine", + return_value=engine, + ), mock.patch.object( + MODULE, + "read_json", + return_value={}, + ), mock.patch.object( + MODULE, + "incoming_mask_and_bubbles", + return_value=(None, []), + ), mock.patch.object( + MODULE, + "write_history_event", + ), mock.patch.object( + MODULE, + "queue_event", + side_effect=queued.append, + ): + recorder = MODULE.Recorder(queue_enabled=True) + recorder.save_state = mock.Mock() + recorder.process_stable_image( + image, + "layout-a-1", + visual_hash="visual-a-1", + observed_at="2026-10-20T12:00:00+08:00", + group_fingerprint="group-a", + ) + recorder.process_stable_image( + image, + "layout-b-1", + visual_hash="visual-b-1", + observed_at="2026-10-20T12:00:01+08:00", + group_fingerprint="group-b", + ) + recorder.process_stable_image( + image, + "layout-a-2", + visual_hash="visual-a-2", + observed_at="2026-10-20T12:00:02+08:00", + group_fingerprint="group-a", + ) + + self.assertEqual(len(queued), 1) + self.assertEqual(queued[0]["group_fingerprint"], "group-a") + self.assertEqual(queued[0]["text"], "@JWeBot second") + states = recorder.state["conversation_states"] + self.assertEqual(states["group-a"]["visible_signatures"], ["a-first", "a-second"]) + self.assertEqual(states["group-b"]["visible_signatures"], ["b-first"]) + + def test_shadow_direct_messages_are_observed_without_queueing(self): + class ShadowTarget: + def question(self, text, *, bot_mention): + del bot_mention + return text if text.startswith("/") else "" + + class ShadowRegistry: + def target_for_fingerprint(self, fingerprint): + return ShadowTarget() if fingerprint == "direct-a" else None + + def build_shadow_event( + target, + *, + observed_at, + text, + body_confidence, + visual_signature, + bot_mention, + observed_sender, + sender_confidence, + ordinal, + ): + del ( + target, + body_confidence, + visual_signature, + bot_mention, + observed_sender, + sender_confidence, + ordinal, + ) + return { + "event_id": "shadow-event", + "observed_at": observed_at, + "sender": "Test User", + "text": text, + "mention_bot": True, + "group_fingerprint": "direct-a", + "shadow_only": True, + "dispatch_allowed": False, + } + + engine = mock.Mock() + first = MODULE.IncomingMessage( + "unknown", + "/状态", + 0.0, + 0.0, + 0.98, + 100, + "direct-first", + ) + second = MODULE.IncomingMessage( + "unknown", + "/版本", + 0.0, + 0.0, + 0.98, + 160, + "direct-second", + ) + engine.recognize_messages.side_effect = [ + ([first], 0.01), + ([first, second], 0.01), + ] + engine.last_cache_hits = 0 + engine.last_cache_misses = 1 + engine.last_cache_refresh = False + history = [] + image = MODULE.Image.new( + "RGB", + (MODULE.EXPECTED_WIDTH, MODULE.EXPECTED_HEIGHT), + "white", + ) + + with mock.patch.object( + MODULE, + "ensure_directories", + ), mock.patch.object( + MODULE, + "OcrEngine", + return_value=engine, + ), mock.patch.object( + MODULE, + "read_json", + return_value={}, + ), mock.patch.object( + MODULE, + "load_conversation_shadow_support", + return_value=(ShadowRegistry(), build_shadow_event), + ), mock.patch.object( + MODULE, + "incoming_mask_and_bubbles", + return_value=(None, []), + ), mock.patch.object( + MODULE, + "write_history_event", + side_effect=history.append, + ), mock.patch.object( + MODULE, + "queue_event", + ) as queue_event: + recorder = MODULE.Recorder(queue_enabled=True) + recorder.save_state = mock.Mock() + recorder.process_stable_image( + image, + "layout-direct-1", + visual_hash="visual-direct-1", + observed_at="2026-10-20T12:00:00+08:00", + group_fingerprint="direct-a", + ) + recorder.process_stable_image( + image, + "layout-direct-2", + visual_hash="visual-direct-2", + observed_at="2026-10-20T12:00:01+08:00", + group_fingerprint="direct-a", + ) + + queue_event.assert_not_called() + self.assertEqual(len(history), 1) + self.assertEqual(history[0]["text"], "/版本") + self.assertTrue(history[0]["shadow_only"]) + self.assertFalse(history[0]["dispatch_allowed"]) + self.assertEqual(recorder.state["shadowed_total"], 1) + + class StorageTests(unittest.TestCase): def test_elapsed_since_iso_is_timezone_aware_and_bounded(self): now = datetime(2026, 10, 14, 12, 0, 1, tzinfo=timezone.utc) diff --git a/tools/sync-local-extensions.py b/tools/sync-local-extensions.py index 8af4898..9e05484 100755 --- a/tools/sync-local-extensions.py +++ b/tools/sync-local-extensions.py @@ -68,7 +68,7 @@ def module_text(descriptor) -> str: extension_id = descriptor.extension_id return f'''"""Generated reviewed adapter for {extension_id}.""" -from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext +from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext PROFILE = {extension_id!r} @@ -76,41 +76,14 @@ AUTHOR = {descriptor.author!r} DESCRIPTION = {descriptor.description!r} HELP_ENTRIES = {descriptor.help_entries!r} COMMANDS = {descriptor.commands!r} - - -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 +ADAPTER = ServiceCommandAdapter( + PROFILE, + metadata={{"local_extension": PROFILE, "author": AUTHOR}}, +) def plugin(context: SingleGroupContext) -> None: - for command in COMMANDS: - context.commands.register(command, _handler(context, command)) + ADAPTER.register(context, COMMANDS) ''' diff --git a/wechat_local_ocr.py b/wechat_local_ocr.py index 5af6763..cb66116 100755 --- a/wechat_local_ocr.py +++ b/wechat_local_ocr.py @@ -116,6 +116,30 @@ class RouterInvocationUnavailable(RuntimeError): """A transient route invocation failure that must be retried by the queue.""" +def load_conversation_shadow_support() -> tuple[object | None, Callable[..., Any] | None]: + if not os.environ.get("JWEBOT_CONVERSATION_SHADOW_CONFIG", "").strip(): + return None, None + root = Path(__file__).resolve().parent + source_roots = [ + root / "packages" / package / "src" + for package in ( + "jwebot-config", + "jwebot-sdk", + "jwebot-official-extensions", + "jwebot-kernel", + ) + ] + for source_root in reversed(source_roots): + if source_root.is_dir(): + sys.path.insert(0, str(source_root)) + from jwebot_kernel.conversation_scope import ( # noqa: PLC0415 + ShadowConversationRegistry, + build_shadow_event, + ) + + return ShadowConversationRegistry.from_environment(), build_shadow_event + + def router_error_message( *, result: subprocess.CompletedProcess[str] | None = None, @@ -1577,27 +1601,81 @@ class Recorder: self.state["last_visual_hash"] = "" self.state["visible_signatures"] = [] self.state["recent_trigger_signatures"] = [] + raw_conversations = self.state.get("conversation_states") + if not isinstance(raw_conversations, dict): + raw_conversations = {} + self.state["conversation_states"] = { + str(fingerprint): value + for fingerprint, value in raw_conversations.items() + if isinstance(value, dict) + and value.get("message_signature_scheme") == MESSAGE_SIGNATURE_SCHEME + } self.pending_hash = "" self.pending_count = 0 + self.pending_frames: dict[str, tuple[str, int]] = {} self.no_overlap_recovery_ready = False + self.recovery_ready_conversations: set[str] = set() + ( + self.shadow_conversations, + self.build_shadow_event, + ) = load_conversation_shadow_support() def save_state(self) -> None: write_json(RECORDER_STATE, self.state) + def conversation_state(self, fingerprint: str) -> dict[str, Any]: + conversations = self.state["conversation_states"] + value = conversations.get(fingerprint) + if isinstance(value, dict): + return value + if ( + fingerprint == self.state.get("last_group_fingerprint") + and self.state.get("message_signature_scheme") + == MESSAGE_SIGNATURE_SCHEME + ): + value = { + "initialized": bool(self.state.get("initialized")), + "last_layout_hash": str(self.state.get("last_layout_hash", "")), + "last_visual_hash": str(self.state.get("last_visual_hash", "")), + "visible_signatures": list( + self.state.get("visible_signatures", []) + ), + "recent_trigger_signatures": list( + self.state.get("recent_trigger_signatures", []) + ), + "message_signature_scheme": MESSAGE_SIGNATURE_SCHEME, + } + else: + value = { + "initialized": False, + "last_layout_hash": "", + "last_visual_hash": "", + "visible_signatures": [], + "recent_trigger_signatures": [], + "message_signature_scheme": MESSAGE_SIGNATURE_SCHEME, + } + conversations[fingerprint] = value + return value + def capture_cycle(self, *, stable_frames: int) -> None: started = time.monotonic() image = capture_image() crop = message_image(image) layout_hash, visual_hash = incoming_frame_hashes(crop) - if visual_hash != self.pending_hash: - self.pending_hash = visual_hash - self.pending_count = 1 + group = title_fingerprint(image) + conversation = self.conversation_state(group) + pending_hash, pending_count = self.pending_frames.get(group, ("", 0)) + if visual_hash != pending_hash: + pending_count = 1 else: - self.pending_count += 1 + pending_count += 1 + self.pending_frames[group] = (visual_hash, pending_count) + self.pending_hash = visual_hash + self.pending_count = pending_count outcome = "pending" if ( - self.pending_count >= stable_frames - and visual_hash != self.state.get("last_visual_hash") + pending_count >= stable_frames + and visual_hash != conversation.get("last_visual_hash") ): outcome = "process" self.process_stable_image( @@ -1605,12 +1683,13 @@ class Recorder: layout_hash, visual_hash=visual_hash, observed_at=now_iso(), + group_fingerprint=group, ) telemetry_event( "ocr.capture", elapsed_ms=(time.monotonic() - started) * 1000, outcome=outcome, - count=self.pending_count, + count=pending_count, source="screen", ) @@ -1634,60 +1713,89 @@ class Recorder: *, visual_hash: str | None = None, observed_at: str | None = None, + group_fingerprint: str | None = None, ) -> None: pipeline_started = time.monotonic() crop = message_image(image) _, bubbles = incoming_mask_and_bubbles(crop) messages, elapsed = self.engine.recognize_messages(crop, bubbles) + group = group_fingerprint or title_fingerprint(image) + conversation = self.conversation_state(group) + shadow_target = ( + self.shadow_conversations.target_for_fingerprint(group) + if self.shadow_conversations is not None + else None + ) previous = [ value - for value in self.state.get("visible_signatures", []) + for value in conversation.get("visible_signatures", []) if isinstance(value, str) ] recent_trigger_signatures = [ value - for value in self.state.get("recent_trigger_signatures", []) + for value in conversation.get("recent_trigger_signatures", []) if isinstance(value, str) ] new_messages, current_signatures, overlap, resynced = new_sequence_suffix( previous, messages, recent_trigger_signatures=recent_trigger_signatures, - recover_unseen_trigger=self.no_overlap_recovery_ready, + recover_unseen_trigger=group in self.recovery_ready_conversations, ) - initialized = bool(self.state.get("initialized")) + initialized = bool(conversation.get("initialized")) observed_at = observed_at or now_iso() if not initialized: new_messages = [] resynced = True - self.state["initialized"] = True - group = title_fingerprint(image) + conversation["initialized"] = True current_trigger_signatures = [ message.signature for message in messages - if trigger_question(message.text) is not None + if ( + shadow_target.question(message.text, bot_mention=BOT_MENTION) + if shadow_target is not None + else trigger_question(message.text) + ) ] recent_trigger_signatures = ( recent_trigger_signatures + current_trigger_signatures )[-MAX_RECENT_TRIGGER_SIGNATURES:] + self.recovery_ready_conversations.add(group) self.no_overlap_recovery_ready = True appended = 0 queued = 0 + shadowed = 0 for ordinal, message in enumerate(new_messages): - event = history_event( - message, - observed_at=observed_at, - group_fingerprint=group, - ordinal=ordinal, - ) - if ( - self.queue_enabled - and event["mention_bot"] - and message.sender != "unknown" - and message.sender_confidence >= MIN_SENDER_CONFIDENCE - ): - queue_event(event) - queued += 1 + if shadow_target is not None: + if self.build_shadow_event is None: + raise RuntimeError("shadow event builder is unavailable") + event = self.build_shadow_event( + shadow_target, + observed_at=observed_at, + text=message.text, + body_confidence=message.body_confidence, + visual_signature=message.signature, + bot_mention=BOT_MENTION, + observed_sender=message.sender, + sender_confidence=message.sender_confidence, + ordinal=ordinal, + ) + shadowed += 1 + else: + event = history_event( + message, + observed_at=observed_at, + group_fingerprint=group, + ordinal=ordinal, + ) + if ( + self.queue_enabled + and event["mention_bot"] + and message.sender != "unknown" + and message.sender_confidence >= MIN_SENDER_CONFIDENCE + ): + queue_event(event) + queued += 1 write_history_event(event) appended += 1 logging.info( @@ -1698,9 +1806,23 @@ class Recorder: message.confidence, bool(queued), ) + conversation.update( + { + "last_capture_at": observed_at, + "last_layout_hash": layout_hash, + "last_visual_hash": visual_hash or layout_hash, + "last_ocr_ms": round(elapsed * 1000), + "visible_signatures": current_signatures, + "recent_trigger_signatures": recent_trigger_signatures, + "message_signature_scheme": MESSAGE_SIGNATURE_SCHEME, + "visible_count": len(messages), + "last_overlap": overlap, + "last_resynced": resynced, + } + ) self.state.update( { - "version": 1, + "version": 2, "last_capture_at": observed_at, "last_layout_hash": layout_hash, "last_visual_hash": visual_hash or layout_hash, @@ -1714,6 +1836,8 @@ class Recorder: "last_resynced": resynced, "events_total": int(self.state.get("events_total", 0)) + appended, "queued_total": int(self.state.get("queued_total", 0)) + queued, + "shadowed_total": int(self.state.get("shadowed_total", 0)) + + shadowed, "resync_total": int(self.state.get("resync_total", 0)) + int(resynced and initialized), "errors_total": int(self.state.get("errors_total", 0)), @@ -1747,7 +1871,7 @@ class Recorder: group_id=group, count=appended, depth=queued, - outcome="queued" if queued else "observed", + outcome="shadowed" if shadowed else "queued" if queued else "observed", source="rapidocr", )