14 Commits
Author SHA1 Message Date
JWeBot Contributors 0ffd0b2a16 Release beta 2.0.5.1023
check / local-gate (push) Failing after 1m9s
2026-10-23 17:19:23 +08:00
JWeBot Contributors 6375ab5c79 Release beta 2.0.4.1023 2026-10-23 17:18:29 +08:00
JWeBot Contributors 22f5240fa7 Release beta 2.0.2.1021
check / local-gate (push) Failing after 1m56s
2026-10-21 18:17:12 +08:00
JWeBot Contributors 645a282b22 Release beta 2.0.1.1021
check / local-gate (push) Failing after 5m16s
2026-10-21 17:38:47 +08:00
JWeBot Contributors e4365841ed Release beta 2.0.0.1021
check / local-gate (push) Failing after 1m38s
2026-10-21 17:05:09 +08:00
JWeBot Contributors 5a3c4fb94c Release beta 1.2.8.1019
check / local-gate (push) Failing after 2m49s
2026-10-19 22:56:36 +08:00
JWeBot Contributors 5e309f0b8f Release beta 1.2.7.1019
check / local-gate (push) Failing after 1m53s
2026-10-19 22:41:48 +08:00
JWeBot Contributors ed2fd0200f Release beta 1.2.6.1019
check / local-gate (push) Failing after 2m29s
2026-10-19 22:29:36 +08:00
JWeBot Contributors 8670745235 Release beta 1.2.5.1019
check / local-gate (push) Failing after 1m49s
2026-10-19 22:14:35 +08:00
JWeBot Contributors eb31446076 Release beta 1.2.4.1019
check / local-gate (push) Failing after 41s
2026-10-19 18:17:51 +08:00
JWeBot Contributors 5300898bc3 Release beta 1.2.3.1019
check / local-gate (push) Failing after 1m6s
2026-10-19 18:07:17 +08:00
JWeBot Contributors ac630576c7 Release beta 1.2.2.1019
check / local-gate (push) Failing after 59s
2026-10-19 17:40:46 +08:00
JWeBot Contributors 3a7c11e44d Release beta 1.2.1.1019
check / local-gate (push) Failing after 16m58s
2026-10-19 17:26:44 +08:00
JWeBot Contributors e6d6ddf1c6 Release beta 1.2.0.1019
check / local-gate (push) Failing after 15s
2026-10-19 17:09:40 +08:00
93 changed files with 5921 additions and 2204 deletions
+123
View File
@@ -1,5 +1,128 @@
# Changelog
## beta 2.0.5.1023 - 2026-10-23 17:16:12
- 21 点胜率显示修复完成:`/摩拉` 里的 21 点战绩现在会和 `/21点 胜率` 使用同一套统计口径。
- 修复 `/摩拉` 误读旧版通用胜负字段的问题,避免旧抽奖等历史数据污染 21 点胜率展示。
- 超时认输、主动认输、单人结算和多人牌桌仍统一写入 `blackjack_stats`,本次只调整展示读取来源。
## beta 2.0.4.1023 - 2026-10-23 16:56:33
- 许愿池和 21 点小版本更新完成:`/抽奖` 已并入许愿池,21 点新增群内胜率统计并调整下注上限。
- 旧 `/抽奖` 返倍玩法合并进许愿池,保留 `/抽奖 金额` 与 `/draw 金额` 入口,使用方式与 `/许愿池 金额` 一致。
- 许愿池每 1000 摩拉结算 1 愿,单次支持 1000~10000 摩拉,可能获得摩拉回响、扫码概率券、Taffy 发话额度或原石。
- 许愿池新增公共池水、8 愿无道具保底和池底大愿;游戏记录和 Taffy 摩拉统计会按“许愿池”归档。
- 21 点单人下注范围调整为 1~2000,多人牌桌下注范围调整为 1~2000;`/21点 胜率` 会展示当前群玩家和 Taffy 的胜/负/平、胜率与净收支。
## beta 2.0.3.1023 - 2026-10-23 16:09:38
- 冷门知识问答小版本更新完成:题库外置为 500 题,并会优先抽取更少出现过的题。
- 知识问答基础题库迁移到 `jwebot_kernel/data/trivia_questions.json`,运行时启动校验并展开为 500 道选项题。
- 题库补充更多机制、数值、版本与活动细节题,覆盖反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像和我的世界。
- 出题历史新增 `ask_counts`,每次选题优先从当前主题内出现次数最低的基础题中抽取,并继续避开最近题目。
- 问答题头新增 `出现次数:N` 标志,复看当前题时也能看到这题已经出现过几次。
## beta 2.0.2.1021 - 2026-10-21 18:11:35
- 上下文追问小版本更新完成:Taffy 现在会优先相信本地游戏状态,并能把短句追评接到上一条 21 点或主动发言。
- 普通聊天入口新增本地游戏事实抢答:命中“某人在玩什么/现在咋样”等问题时,直接读取本地账本回复,不再排队生成可能互相矛盾的第二条闲聊最终回复。
- 新增群级 `active_threads` 线程锚点,Taffy 本地命令结果、聊天最终回复和主动广播都会短时记录为当前话题,供“还真是”“胆子大”等短追评优先引用。
- 上下文打分增强 `active_thread` 标记,并在系统提示中要求模型优先采用 `local_result`/`active_thread` 本地事实,只有多个候选冲突时才追问。
- 单人和多人 21 点状态现在可被本地摘要复用,回答会包含当前牌局、手牌点数、下注、阶段和轮到谁操作等关键信息。
## beta 2.0.1.1021 - 2026-10-21 17:32:46
- 上下文管理小版本更新完成:Taffy 现在会按发送者、点名对象和游戏结果结构化选取上下文,低置信身份不再写入记忆或执行绑定命令。
- 聊天生成改用 `STRUCTURED_RECENT_GROUP_CONTEXT`,历史片段带有
speaker、route、reason、score 和 tags,避免把不同群友的消息当成同一个人连续发言。
- 上下文候选扩容并加入本地相关性打分:同发送者、被点名人物、词面重叠、
本地游戏/命令结果和追问语义会被优先保留;自指问题默认不混入其他人的历史。
- Edge 事件和本地上下文历史新增 sender/body confidence、source 和 observed_at
持久化;低置信 sender 只保留文本证据,不绑定人物记忆、账户或奖励类命令。
- Taffy 的可见回复和已送达主动广播会写入上下文历史,后续追问能引用;
主动冒泡仍跳过 Taffy 自己的话,避免自我循环。
## 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
- 冷门知识问答题库整体重写:改为更偏游戏内机制、数值、版本和活动细节的深度题。
- Trivia display now allows necessary proper nouns while preferring
Chinese-first names such as 中文(English) when a common translation exists.
## beta 1.2.7.1019 - 2026-10-19 22:37:40
- 冷门知识问答新增缪斯学园偶像和我的世界题库,并支持 LoveLive 与 Minecraft 主题别名。
- LoveLive trivia is limited to μ's-only facts, while displayed trivia content
remains localized in Chinese.
## beta 1.2.6.1019 - 2026-10-19 22:26:26
- 冷门知识问答新增少女乐团派对、韩流和原神题库,并支持 `/知识问答 主题` 定向开题。
- Unknown trivia topics now return the supported topic list, while automatic
quiz broadcasts still draw from the full trivia bank.
## beta 1.2.5.1019 - 2026-10-19 22:12:52
- 冷门知识问答题库改为中文展示:题干、选项和解释不再混用英文词。
- Keep trivia answer letters unchanged while localizing displayed trivia
domains, prompts, choices, and explanations.
## beta 1.2.4.1019 - 2026-10-19 18:15:01
- 增强 Taffy 对本地游戏结果的理解:闲聊上下文扩大到 25 轮,并明确标注本地命令结果的发送者和类型。
- Format local command history as tagged context entries so follow-up phrases
like "这一把" can link back to recent blackjack and other game results.
## beta 1.2.3.1019 - 2026-10-19 18:04:57
- 扩大 Taffy 闲聊可读取的上下文窗口:后续回复现在最多参考最近 15 轮相关对话。
- Increase the default direct-chat context window from 6 to 15 retained turns.
## beta 1.2.2.1019 - 2026-10-19 17:31:56
- 修复本地命令后的闲聊断上下文,新增 `/知识问答` 强制开题,并让多条回复按 0.5~1 秒随机间隔逐条发送。
- Include non-tool local command results in the follow-up chat context so game
and account actions like `/原石祈愿` can be referenced naturally afterward.
- Exclude utility local commands such as `/version`, `/状态`, `/debug`, and
feature-list output from chat context to avoid noisy system details.
- Add randomized spacing between queued multi-part replies and immediate local
multi-reply sends.
- Add `/知识问答` as a Kernel-owned local command that starts a cold-knowledge
quiz immediately while reusing the active five-minute question when one is
already running.
## beta 1.2.1.1019 - 2026-10-19 17:23:58
- 修复 Taffy 短句拆分只换行不分条的问题:多句闲聊现在会按多条消息逐条发送。
- Encode multi-sentence chat replies with the existing multi-reply marker so
direct and cloud chat results enter the multi-message delivery path.
- Split queued delivery helper calls per message part, giving each part a
stable per-part event id while preserving structured local command output.
## beta 1.2.0.1019 - 2026-10-19 17:05:47
- 优化 Taffy 群聊语气:回复更短、更像群友,减少 AI 式自我解释,并保留主动插话、冷门问答和更新状态提示。
- Add a chat-specific reply style layer that removes common AI-like padding,
keeps direct unknown-subject clarification, and splits long casual replies
into shorter lines.
- Apply the style rule to direct chat and cloud-agent replies while leaving
structured local command output unchanged.
- This 1.2 release grants each existing account both 200 Mora and
1 primogem.
## beta 1.1.2.1019 - 2026-10-19 16:38:10
- 新增低频主动插话、随机冷门知识问答和更新生命周期提示:Taffy 会偶尔自然冒泡,抛出限时答题奖励摩拉,并在停机、上线和更新就绪时主动说明状态。
+20 -15
View File
@@ -8,12 +8,13 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.1.2.1019`,更新时间:2026-10-19 16:38:10。
当前版本:`beta 2.0.5.1023`,更新时间:2026-10-23 17:16:12。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则
已经外置为包内 JSON,虚拟摩拉支持转账、贷款、还款和群内排行榜。
已经外置为包内 JSON,知识问答题库也以 500 题 JSON 数据随 Kernel
发布,虚拟摩拉支持转账、贷款、还款和群内排行榜。
完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见
[CHANGELOG](CHANGELOG.md)。
@@ -82,14 +83,15 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/功能列表
/上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person)
/摩拉(兼容 /金币、/wallet);/抽奖 [金额](/draw)
/摩拉(兼容 /金币、/wallet);/许愿池 金额(兼 /许愿、/抽奖、/draw)
/大富翁榜(/摩拉排行,兼容 /金币排行)
/转账 对方 金额(/transfer name amount)
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束
/21点 开始 [1~2000];开局后 /21点 要牌|停牌|状态|结束;/21点 胜率
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容
/知识问答 [主题];500 题冷门题库,优先抽取少出现过的题并显示出现次数;支持主题:反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像、我的世界;/回答 A
/游戏记录 [页码](/gamehistory)
/游戏记录 [页码](/gamehistory)
/游戏帮助(/games)
@@ -192,9 +194,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 服务保留安装,但保持禁用。
### 仓库结构
@@ -269,16 +272,17 @@ visually confirmed GUI bridge.
Current version:
```text
beta 1.1.2.1019
beta 2.0.5.1023
```
Updated: 2026-10-19 16:38:10.
Updated: 2026-10-23 17:16:12.
The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games,
multiplayer lottery, blackjack, CS Major autochess, and production deployment
gates. CS Major autochess game content is now packaged as JSON data, and
virtual coins support transfer, loans, repayment, and group leaderboards.
gates. CS Major autochess game content and the 500-question trivia bank are
now packaged as JSON data, and virtual coins support transfer, loans,
repayment, and group leaderboards.
## Runtime Flow
@@ -459,10 +463,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
+1 -1
View File
@@ -1 +1 @@
beta 1.1.2.1019
beta 2.0.5.1023
@@ -7,6 +7,7 @@ user_unit_source_dir="$source_dir/deploy/systemd/user"
config_source_dir="$source_dir/packages/jwebot-config/src/jwebot_config"
sdk_source_dir="$source_dir/packages/jwebot-sdk/src/jwebot_sdk"
kernel_source_dir="$source_dir/packages/jwebot-kernel/src/jwebot_kernel"
kernel_data_source_dir="$kernel_source_dir/data"
official_source_dir="$source_dir/packages/jwebot-official-extensions/src/jwebot_official_extensions"
official_adapter_source_dir="$official_source_dir/adapters"
official_autochess_data_source_dir="$official_source_dir/data/cs_autochess"
@@ -358,6 +359,7 @@ install -d -m 0700 \
"$target_dir/jwebot_config" \
"$target_dir/jwebot_sdk" \
"$target_dir/jwebot_kernel" \
"$target_dir/jwebot_kernel/data" \
"$target_dir/jwebot_official_extensions" \
"$target_dir/jwebot_official_extensions/adapters" \
"$target_dir/jwebot_official_extensions/data" \
@@ -371,6 +373,10 @@ done
for path in "$kernel_source_dir"/*.py; do
install -m 0600 "$path" "$target_dir/jwebot_kernel/$(basename "$path")"
done
for path in "$kernel_data_source_dir"/*.json; do
[ -e "$path" ] || continue
install -m 0600 "$path" "$target_dir/jwebot_kernel/data/$(basename "$path")"
done
for path in "$official_source_dir"/*.py; do
install -m 0600 "$path" "$target_dir/jwebot_official_extensions/$(basename "$path")"
done
+6 -4
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog
Current baseline: `beta 1.1.2.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
@@ -51,22 +51,24 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| `/扫码加权 人名` | `/扫码概率 人名` | Spend a scan-boost ticket or 1 primogem to give that person +5 hourly scan weight for 24 hours. |
| `/Taffy发话 内容` | `/taffysay content` | Spend Taffy speech credit; 1 primogem converts to 2 credits when needed. |
| `/Taffy发话 定时 HH:MM 内容` | - | Schedule a Taffy speech through the guarded clock delivery queue. |
| `/知识问答 [主题]` | - | Start a five-minute cold-knowledge quiz immediately, optionally limited to one supported topic. Supported topics: 反恐精英, 顶点英雄, 守望先锋, 英雄联盟, 少女乐团派对, 韩流, 原神, 缪斯学园偶像, 我的世界. English aliases like `LoveLive` and `Minecraft` are accepted as input. |
| `/回答 A` | `/answer A` | Answer the active five-minute cold-knowledge quiz for a rarity-based Mora reward. |
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. |
| `/许愿池 金额` | `/许愿`, `/抽奖`, `/draw` | Put 1000-10000 Mora into the wish pool in 1000-Mora units for Mora echoes, scan-boost tickets, Taffy speech credits, primogems, pool jackpot, and pity progress. |
| `/彩票 开始` | `/lottery start` | Open a multiplayer lottery round. |
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
| `/彩票 状态` | `/lottery status` | Show the current lottery round. |
| `/彩票 开奖` | `/lottery draw` | Host-only draw for the current lottery round. |
| `/彩票 取消` | `/lottery cancel` | Host-only cancellation with refunds. |
| `/21点 开始 [1-500]` | `/blackjack start` | Start a blackjack round with the chosen stake. |
| `/21点 开始 [1-2000]` | `/blackjack start` | Start a blackjack round with the chosen stake. |
| `/21点 要牌` | `/blackjack hit` | Draw one blackjack card. |
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End the current blackjack round. |
| `/21点 胜率` | `/blackjack stats` | Show every player and Taffy's group-scoped win rate and net Mora result. |
| `/21点 多人 启动` | - | Open a Taffy-hosted blackjack table and auto-seat the host. |
| `/21点 加入` | - | Join the current blackjack table before betting starts. |
| `/21点 多人 开始` | - | Host-only close joining and start ordered betting prompts. |
| `/21点 放金额` | - | Place the prompted table stake, 1-10000 coins. |
| `/21点 放金额` | - | Place the prompted table stake, 1-2000 Mora. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. |
+61
View File
@@ -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": "<local test label>"
}
]
}
```
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.
+2 -2
View File
@@ -50,7 +50,7 @@
## 个人局号与记录
抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示
许愿池、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示
`Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时,
共用同一账户、局号和历史。
@@ -58,7 +58,7 @@
`/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、
结果、下注、返还和净变化,最多保留最近 50 条。
21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100;
21 点下注范围现为 1~2000。`/21点 开始 100` 只表示本局下注 100;
发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。
## 持久化与并发
+13 -7
View File
@@ -11,7 +11,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
| coin-ranking | `/大富翁榜`、`/摩拉排行`(兼容 `/金币排行`) | `/richlist` |
| coin-transfer | `/转账 对方 金额` | `/transfer name amount` |
| coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` |
| single-player-games | `/抽奖 [金额]` | `/draw [金额]` |
| single-player-games | `/许愿池 金额`(兼容 `/许愿`、`/抽奖`) | `/draw 金额` |
| single-player-games | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
| single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
@@ -60,8 +60,9 @@ person_key。相同人物的已配置别名共用摩拉和牌局,其他群使
## 虚拟摩拉与游戏管理
每个群内人物首次使用账户时一次性获得 1000 虚拟摩拉。再次查询、
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和
21 点下注为 1~500;余额不足或参数非法不扣款。
重启服务或切换已配置别名都不会重复领取。许愿池投入为 1000~10000,
按 1000 摩拉递增;彩票下注为 1~500,21 点下注为 1~2000;
余额不足或参数非法不扣款。
`/转账 对方 金额` 会把本人的虚拟摩拉转给同群已初始化账户,英文为
`/transfer name amount`。金额范围 1~100000;不允许给自己转账。
@@ -76,12 +77,14 @@ person_key。相同人物的已配置别名共用摩拉和牌局,其他群使
调用者账户。榜单按当前摩拉余额降序排列,人数范围 1~20;账户存在
未还贷款时会在名字后标注贷款本金。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。
`/许愿池 金额` 会把摩拉投入公共许愿池,每 1000 摩拉结算 1 愿;
`/许愿`、`/抽奖` 和 `/draw` 是同一功能入口。许愿可能获得摩拉回响、
扫码概率券、Taffy 发话额度、原石或池底大愿,8 愿没有道具类奖励后触发保底。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
[多人彩票与游戏记录](group-lottery-0.6.3.md)。
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场,
21 点默认下注 50、范围 1~2000。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场,
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
@@ -93,12 +96,15 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~10000。
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~2000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前摩拉。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。
许愿池或 21 点开局,多人彩票报名仍可独立进行。
`/21点 胜率` 会列出当前群所有有已结算记录的玩家,以及 Taffy 的
胜/负/平、胜率和累计净赚净亏;主动认输和超时计负,牌桌取消不计入统计。
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
+6 -33
View File
@@ -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)
@@ -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)
+6 -33
View File
@@ -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)
@@ -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)
+6 -33
View File
@@ -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)
@@ -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)
@@ -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)
+6 -33
View File
@@ -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)
+6 -33
View File
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -6,7 +6,7 @@ Author: `jwebot`
Help:
- `/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助`
- `/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助`
Registered commands:
@@ -14,6 +14,8 @@ Registered commands:
- `/金币`
- `/wallet`
- `/原石`
- `/许愿池`
- `/许愿`
- `/抽奖`
- `/draw`
- `/彩票`
@@ -1,14 +1,14 @@
id = "single-player-games"
author = "jwebot"
description = "摩拉游戏"
help = ["/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助"]
help = ["/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助"]
version = "1.0.0"
api_version = "1"
entrypoint = "single_player_games:plugin"
type = "command"
[capabilities]
commands = ["/摩拉", "/金币", "/wallet", "/原石", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"]
commands = ["/摩拉", "/金币", "/wallet", "/原石", "/许愿池", "/许愿", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"]
storage = false
network_profiles = []
service_profiles = ["single-player-games"]
@@ -1,45 +1,18 @@
"""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'
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
HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',)
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
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)
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '摩拉游戏')
self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助',))
self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',))
self.assertIn('/摩拉', registered)
self.assertEqual(len(registered), 14)
self.assertEqual(len(registered), 16)
if __name__ == "__main__":
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
+32 -14
View File
@@ -32,9 +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
@@ -126,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 的支持与理解,祝大家游戏愉快!"
@@ -144,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} 的最后自检;"
@@ -164,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))
@@ -195,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)
@@ -203,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)
):
@@ -286,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} 版本更新补偿"
@@ -301,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(
@@ -334,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": (
@@ -593,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),
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-config"
version = "1.1.2b1+1019"
version = "2.0.5b1+1023"
description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9"
dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile",
]
__version__ = "1.1.2b1+1019"
__version__ = "2.0.5b1+1023"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-extension-host"
version = "1.1.2b1+1019"
version = "2.0.5b1+1023"
description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.1.2b1+1019",
"jwebot-kernel==1.1.2b1+1019",
"jwebot-sdk==2.0.5b1+1023",
"jwebot-kernel==2.0.5b1+1023",
]
[project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request",
]
__version__ = "1.1.2b1+1019"
__version__ = "2.0.5b1+1023"
+7 -4
View File
@@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-kernel"
version = "1.1.2b1+1019"
version = "2.0.5b1+1023"
description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-config==1.1.2b1+1019",
"jwebot-sdk==1.1.2b1+1019",
"jwebot-official-extensions==1.1.2b1+1019",
"jwebot-config==2.0.5b1+1023",
"jwebot-sdk==2.0.5b1+1023",
"jwebot-official-extensions==2.0.5b1+1023",
"tomli>=2; python_version < '3.11'",
]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
jwebot_kernel = ["data/*.json"]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature",
]
__version__ = "1.1.2b1+1019"
__version__ = "2.0.5b1+1023"
@@ -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,
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-official-extensions"
version = "1.1.2b1+1019"
version = "2.0.5b1+1023"
description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9"
dependencies = [
"jwebot-sdk==1.1.2b1+1019",
"jwebot-sdk==2.0.5b1+1023",
]
[tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list",
]
__version__ = "1.1.2b1+1019"
__version__ = "2.0.5b1+1023"
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -1,45 +1,18 @@
"""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'
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
HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',)
COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
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)
@@ -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)
@@ -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)
@@ -63,6 +63,7 @@ LOCAL_COMMAND_ALIASES = {
"扫码概率": "scan_boost",
"Taffy发话": "taffy_say",
"taffysay": "taffy_say",
"知识问答": "trivia_start",
"回答": "trivia_answer",
"answer": "trivia_answer",
"转账": "transfer",
@@ -73,6 +74,8 @@ LOCAL_COMMAND_ALIASES = {
"borrow": "loan",
"还款": "repay",
"repay": "repay",
"许愿池": "prize_draw",
"许愿": "prize_draw",
"抽奖": "prize_draw",
"draw": "prize_draw",
"彩票": "lottery",
@@ -151,6 +154,11 @@ DIRECT_KERNEL_COMMANDS = (
help_entries=("/余额",),
command_kinds=("deepseek_balance",),
),
KernelCommandDescriptor(
description="冷门问答",
help_entries=("/知识问答 [主题]",),
command_kinds=("trivia_start",),
),
)
DIRECT_KERNEL_COMMAND_KINDS = frozenset(
kind
@@ -171,7 +179,7 @@ LOCAL_EXTENSIONS = (
author=JWEBOT_AUTHOR,
description="摩拉游戏",
help_entries=(
"/摩拉/原石 /抽奖 /彩票 /21点 /游戏帮助",
"/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助",
),
command_kinds=(
"wallet",
@@ -9,15 +9,21 @@ from typing import Any
INITIAL_COINS = 1000
MIN_DRAW_BET = 2
MAX_DRAW_BET = 100
WISH_POOL_GAME_KEY = "wish_pool"
WISH_POOL_UNIT = 1000
MIN_WISH_POOL_AMOUNT = 1000
MAX_WISH_POOL_AMOUNT = 10_000
WISH_POOL_CONTRIBUTION = 200
WISH_POOL_PITY_LIMIT = 8
WISH_POOL_JACKPOT_PERCENT = 20
WISH_POOL_JACKPOT_CAP = 20_000
MIN_LOTTERY_BET = 1
MAX_LOTTERY_BET = 500
DEFAULT_LOTTERY_BET = 10
MIN_BLACKJACK_BET = 1
MAX_BLACKJACK_BET = 500
MAX_BLACKJACK_BET = 2_000
MIN_BLACKJACK_TABLE_BET = 1
MAX_BLACKJACK_TABLE_BET = 10_000
MAX_BLACKJACK_TABLE_BET = 2_000
MAX_BLACKJACK_TABLE_PLAYERS = 8
MIN_TRANSFER_AMOUNT = 1
MAX_TRANSFER_AMOUNT = 100_000
@@ -51,7 +57,7 @@ WISH_MORA_TABLE = (
)
WISH_SCAN_TICKET_PERCENT = 10
GAME_LABELS = {
"prize_draw": "抽奖",
"prize_draw": "许愿池",
"lottery": "彩票",
"blackjack": "21点",
}
@@ -66,14 +72,14 @@ BLACKJACK_ACTIONS = {
"end": "end",
}
BLACKJACK_START = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?$"
r"^(?:开始|start)(?P<bet>\d{1,4})?$"
)
BLACKJACK_COMBINED_ACTION = re.compile(
r"^(?:开始|start)(?P<bet>\d{1,3})?"
r"^(?:开始|start)(?P<bet>\d{1,4})?"
r"(?:要牌|hit|停牌|stand)$"
)
BLACKJACK_TABLE_BET = re.compile(
r"^(?:放|下注|bet)(?P<bet>\d{1,5})$",
r"^(?:放|下注|bet)(?P<bet>\d{1,4})$",
re.IGNORECASE,
)
TRANSFER_MARKED = re.compile(
@@ -232,6 +238,19 @@ def recent_primogem_event_lines(
return lines
def blackjack_outcome_stat(outcome: object) -> str | None:
text = str(outcome or "")
if not text or "取消" in text:
return None
if "平" in text:
return "ties"
if any(token in text for token in ("输", "认输", "失败", "爆")):
return "losses"
if "赢" in text or "获胜" in text:
return "wins"
return None
def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account.setdefault("balance", INITIAL_COINS)
account.setdefault("game", None)
@@ -245,6 +264,12 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account["scan_boost_tickets"] = (
scan_tickets if type(scan_tickets) is int and scan_tickets >= 0 else 0
)
wish_pool_pity = account.get("wish_pool_pity", 0)
account["wish_pool_pity"] = (
wish_pool_pity
if type(wish_pool_pity) is int and wish_pool_pity >= 0
else 0
)
blackjack_streak = account.get("blackjack_taffy_win_streak", 0)
account["blackjack_taffy_win_streak"] = (
blackjack_streak
@@ -289,6 +314,45 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
account["game_history"] = [
entry for entry in history if isinstance(entry, dict)
][-GAME_HISTORY_LIMIT:]
blackjack_stats = account.get("blackjack_stats")
if not isinstance(blackjack_stats, dict):
blackjack_stats = {
"wins": 0,
"losses": 0,
"ties": 0,
"stake": 0,
"payout": 0,
}
for entry in account["game_history"]:
if str(entry.get("kind", "")) != "blackjack":
continue
stat = blackjack_outcome_stat(entry.get("outcome"))
if stat is None:
continue
try:
stake = int(entry.get("stake", 0))
payout = int(entry.get("payout", 0))
except (TypeError, ValueError):
continue
blackjack_stats[stat] += 1
blackjack_stats["stake"] += max(0, stake)
blackjack_stats["payout"] += max(0, payout)
else:
blackjack_stats = {
key: (
int(blackjack_stats.get(key, 0))
if type(blackjack_stats.get(key, 0)) is int
and int(blackjack_stats.get(key, 0)) >= 0
else 0
)
for key in ("wins", "losses", "ties", "stake", "payout")
}
blackjack_stats["hands"] = (
blackjack_stats["wins"]
+ blackjack_stats["losses"]
+ blackjack_stats["ties"]
)
account["blackjack_stats"] = blackjack_stats
events = account.get("coin_events")
if not isinstance(events, list):
events = []
@@ -364,6 +428,24 @@ def normalize_player_account(account: dict[str, Any]) -> dict[str, Any]:
return account
def record_blackjack_result(
account: dict[str, Any],
*,
outcome: str,
stake: int,
payout: int,
) -> None:
stat = blackjack_outcome_stat(outcome)
if stat is None:
return
normalize_player_account(account)
stats = account["blackjack_stats"]
stats[stat] += 1
stats["hands"] += 1
stats["stake"] += max(0, int(stake))
stats["payout"] += max(0, int(payout))
def next_game_index(account: dict[str, Any], kind: str) -> int:
normalize_player_account(account)
counts = account["game_counts"]
@@ -525,6 +607,39 @@ class PlayerGames:
self.display_label = display_label
self.rng = random.SystemRandom()
@staticmethod
def wish_pool_usage() -> str:
return (
"用法:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw);"
"金额必须按1000递增,每1000摩拉算1愿"
)
@staticmethod
def normalize_wish_pool_state(state: dict[str, Any]) -> dict[str, Any]:
balance = state.get("balance", 0)
state["balance"] = balance if type(balance) is int and balance >= 0 else 0
total_wishes = state.get("total_wishes", 0)
state["total_wishes"] = (
total_wishes
if type(total_wishes) is int and total_wishes >= 0
else 0
)
return state
@staticmethod
def parse_wish_pool_amount(arguments: str) -> int | None:
value = arguments.strip()
if not value:
return 0
match = re.fullmatch(
r"(?P<amount>\d{1,5})(?:\s*(?:摩拉|金币|coin|coins))?",
value,
re.IGNORECASE,
)
if match is None:
return None
return int(match["amount"])
@staticmethod
def rules() -> str:
return (
@@ -538,17 +653,20 @@ class PlayerGames:
"(理论期望3600摩拉)并可能获得扫码概率券;"
"/扫码加权 人名 在24小时内给TA整点扫码+5权重;"
"/Taffy发话 内容 或 /Taffy发话 定时 HH:MM 内容。\n"
"抽奖:/抽奖 [2~100],5%返5倍、20%返2倍,其余无返还。\n"
"许愿池:/许愿池 1000~10000(兼容 /许愿、/抽奖、/draw),"
"每1000摩拉1愿,可能获得摩拉回响、扫码概率券、"
"Taffy发话额度或原石,8愿无道具后触发保底。\n"
"多人彩票:/彩票 开始;/彩票 报名 3 4 7 [1~500];"
"/彩票 当前状态;/彩票 开奖。三位号码顺序无关,省略金额默认10;"
"命中1/2/3个返下注的50%/120%/300%。\n"
"21点先用 /21点 开始 [1~500] 进场,金额是本局下注,默认50;空格可省略。\n"
"21点先用 /21点 开始 [1~2000] 进场,金额是本局下注,默认50;空格可省略。\n"
"看到牌后再单独发送 /21点 要牌|停牌|状态|结束"
"(/blackjack hit|stand|status|end);"
"/21点 胜率 查看本群所有玩家及Taffy的21点胜率和净收支;"
"每人最多一局,10分钟无操作视为认输,结束不退下注。\n"
"多人21点:/21点 多人 启动 创建牌桌;/21点 加入;"
"主持人 /21点 多人 开始 后按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌|停牌。\n"
"(1~2000);轮到自己 /21点 要牌|停牌。\n"
"21点获胜返2倍、平局返下注、自然21点返2.5倍向下取整,庄家软17停牌。"
"以上均含本金;虚拟摩拉不能充值或兑换。"
)
@@ -582,10 +700,11 @@ class PlayerGames:
if kind == "wallet" and arguments:
return "用法:/摩拉(兼容 /金币、/wallet),查看本人的虚拟摩拉;/游戏帮助 查看规则"
if kind == "prize_draw":
if arguments and not re.fullmatch(r"\d{1,3}", arguments):
return "用法:/抽奖 [2~100摩拉](/draw),默认10摩拉"
bet = int(arguments or 10)
elif kind == "blackjack":
return self.wish_pool(row, arguments)
if kind == "blackjack":
compacted = re.sub(r"\s+", "", arguments.casefold())
if compacted in {"胜率", "战绩", "stats", "rate", "rates"}:
return self.blackjack_win_rates(row)
table_command = self.parse_blackjack_table_command(arguments)
if table_command is not None:
table_action, table_bet = table_command
@@ -595,7 +714,7 @@ class PlayerGames:
table_bet,
arguments,
)
compacted = re.sub(r"\s+", "", arguments.casefold()) or "状态"
compacted = compacted or "状态"
start = BLACKJACK_START.fullmatch(compacted)
if start is not None:
action = "start"
@@ -613,8 +732,9 @@ class PlayerGames:
"/21点 要牌 或 /21点 停牌"
)
return (
"用法:先 /21点 开始 [1~500](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;空格可省略"
"用法:先 /21点 开始 [1~2000](金额是本局下注,默认50);"
"开局后再单独发送 /21点 要牌|停牌|状态|结束;"
"/21点 胜率 查看群内战绩;空格可省略"
)
group = str(row["group_fingerprint"])
if (
@@ -640,14 +760,12 @@ class PlayerGames:
)
else:
bet = 0
if kind == "prize_draw" and not MIN_DRAW_BET <= bet <= MAX_DRAW_BET:
return "抽奖下注必须为 2~100 的整数摩拉"
if (
kind == "blackjack"
and action == "start"
and not MIN_BLACKJACK_BET <= bet <= MAX_BLACKJACK_BET
):
return "21点下注必须为 1~500 的整数摩拉"
return "21点下注必须为 1~2000 的整数摩拉"
person = self.ledger.resolve_person(
str(row["group_fingerprint"]),
str(row["sender"]),
@@ -684,6 +802,12 @@ class PlayerGames:
stake=int(game["bet"]),
payout=0,
)
record_blackjack_result(
account,
outcome="超时认输",
stake=int(game["bet"]),
payout=0,
)
account["game"] = None
account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
@@ -692,8 +816,12 @@ class PlayerGames:
state = "进行中" if account.get("game") else "无进行中的牌局"
loan = account.get("loan")
loan_text = f"未还贷款 {loan['principal']};" if loan else "无贷款;"
total = account["wins"] + account["losses"] + account["ties"]
win_rate = (account["wins"] / total * 100) if total else 0.0
blackjack_stats = account["blackjack_stats"]
total = int(blackjack_stats["hands"])
wins = int(blackjack_stats["wins"])
losses = int(blackjack_stats["losses"])
ties = int(blackjack_stats["ties"])
win_rate = (wins / total * 100) if total else 0.0
lines = [
(
f"{player_label}:{expired}当前摩拉 {account['balance']};"
@@ -702,8 +830,8 @@ class PlayerGames:
f"{loan_text}"
),
(
f"21点 {state};已结算胜 {account['wins']} / "
f"负 {account['losses']} / 平 {account['ties']},"
f"21点 {state};已结算胜 {wins} / "
f"负 {losses} / 平 {ties},"
f"胜率 {win_rate:.2f}%。"
),
"最近10次账户变动:",
@@ -743,39 +871,210 @@ class PlayerGames:
index,
)
return player_reply(expired + self.show_hand(game), index)
index = next_game_index(account, "prize_draw")
roll = self.rng.randrange(100)
payout = bet * (5 if roll < 5 else 2 if roll < 25 else 0)
result = "大奖" if roll < 5 else "小奖" if roll < 25 else "未中奖"
account["balance"] += payout
account["wins" if payout > bet else "losses"] += 1
append_game_record(
account,
kind="prize_draw",
index=index,
outcome=result,
stake=bet,
payout=payout,
summary=f"抽奖结果 {result}",
)
append_game_coin_event(
account,
kind="prize_draw",
outcome=result,
stake=bet,
payout=payout,
)
return player_reply(
expired
+ f"{result};下注 {bet},返还 {payout},"
f"净变化 {payout - bet:+d};摩拉 {account['balance']}",
index,
)
raise LocalCommandError("未知本地游戏指令")
return self.ledger.apply_player_action(
str(row["group_fingerprint"]), str(row["sender"]),
str(row["event_id"]), kind + ":" + arguments, execute)
def wish_pool(self, row: dict[str, Any], arguments: str) -> str:
amount = self.parse_wish_pool_amount(arguments)
if amount is None:
return self.wish_pool_usage()
if (
amount != 0
and (
amount < MIN_WISH_POOL_AMOUNT
or amount > MAX_WISH_POOL_AMOUNT
or amount % WISH_POOL_UNIT != 0
)
):
return (
"许愿池投入必须为 1000~10000 的整数摩拉,"
"且按 1000 递增"
)
group = str(row["group_fingerprint"])
sender = str(row["sender"])
event_id = str(row["event_id"])
person = self.ledger.resolve_person(group, sender)
player_label = self.display_label(person["display_name"])
def player_reply(text: str, index: int | None = None) -> str:
suffix = f" #{index}" if index is not None else ""
return f"{player_label}{suffix}:{text}"
def action(
account: dict[str, Any],
pool_state: dict[str, Any],
) -> str:
normalize_player_account(account)
self.normalize_wish_pool_state(pool_state)
if amount == 0:
return (
f"{self.wish_pool_usage()};当前池水 {pool_state['balance']};"
f"你的保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}"
)
if account.get("game"):
return "你已有一局21点,请先 /21点 停牌 或 /21点 结束"
if account["balance"] < amount:
return f"摩拉不足:余额 {account['balance']},本次需要 {amount}"
wishes = amount // WISH_POOL_UNIT
index = next_game_index(account, "prize_draw")
account["balance"] -= amount
pool_gain = 0
mora_payout = 0
jackpot_payout = 0
scan_tickets = 0
taffy_credits = 0
primogems = 0
dry_wishes = 0
small_returns = 0
medium_returns = 0
big_returns = 0
pity_hits = 0
jackpot_hits = 0
for _ in range(wishes):
pool_state["balance"] += WISH_POOL_CONTRIBUTION
pool_state["total_wishes"] += 1
pool_gain += WISH_POOL_CONTRIBUTION
force_pity = account["wish_pool_pity"] >= WISH_POOL_PITY_LIMIT
item_awarded = False
if force_pity:
pity_hits += 1
gift_roll = self.rng.randrange(100)
if gift_roll < 70:
scan_tickets += 1
elif gift_roll < 95:
taffy_credits += 1
else:
primogems += 1
item_awarded = True
else:
roll = self.rng.randrange(1000)
if roll < 440:
dry_wishes += 1
elif roll < 660:
small_returns += 1
mora_payout += self.rng.randrange(200, 601)
elif roll < 780:
medium_returns += 1
mora_payout += self.rng.randrange(800, 1401)
elif roll < 860:
scan_tickets += 1
item_awarded = True
elif roll < 920:
taffy_credits += 1
item_awarded = True
elif roll < 950:
primogems += 1
item_awarded = True
elif roll < 980:
big_returns += 1
mora_payout += self.rng.randrange(2000, 3501)
elif roll < 995:
scan_tickets += 1
taffy_credits += 1
item_awarded = True
elif roll < 999:
primogems += 2
item_awarded = True
else:
primogems += 2
jackpot = min(
pool_state["balance"] * WISH_POOL_JACKPOT_PERCENT
// 100,
WISH_POOL_JACKPOT_CAP,
)
pool_state["balance"] -= jackpot
jackpot_payout += jackpot
jackpot_hits += 1
item_awarded = True
if item_awarded:
account["wish_pool_pity"] = 0
else:
account["wish_pool_pity"] += 1
total_mora_payout = mora_payout + jackpot_payout
account["balance"] += total_mora_payout
account["scan_boost_tickets"] += scan_tickets
account["taffy_say_credits"] += taffy_credits
if primogems:
account["primogems"] += primogems
append_primogem_event(account, primogems, "许愿池")
outcome_parts: list[str] = []
if jackpot_hits:
outcome_parts.append("池底大愿")
if scan_tickets or taffy_credits or primogems or pity_hits:
outcome_parts.append("祝福")
if big_returns:
outcome_parts.append("大回响")
elif medium_returns or small_returns:
outcome_parts.append("回响")
if not outcome_parts:
outcome_parts.append("沉底")
outcome = "、".join(dict.fromkeys(outcome_parts))
append_game_record(
account,
kind="prize_draw",
index=index,
outcome=outcome,
stake=amount,
payout=total_mora_payout,
summary=(
f"许愿池 {wishes}愿;池水+{pool_gain};"
f"摩拉+{total_mora_payout};扫码券+{scan_tickets};"
f"发话+{taffy_credits};原石+{primogems}"
),
)
append_game_coin_event(
account,
kind="prize_draw",
outcome=outcome,
stake=amount,
payout=total_mora_payout,
)
result_parts: list[str] = []
if total_mora_payout:
mora_text = f"摩拉 +{total_mora_payout}"
if jackpot_payout:
mora_text += f"(含池底 {jackpot_payout})"
result_parts.append(mora_text)
if scan_tickets:
result_parts.append(f"扫码概率券 +{scan_tickets} 张")
if taffy_credits:
result_parts.append(f"Taffy发话额度 +{taffy_credits} 次")
if primogems:
result_parts.append(f"原石 +{primogems} 个")
if not result_parts:
result_parts.append("池水起了点波纹,啥也没捞到")
if pity_hits:
result_parts.append(f"触发保底 {pity_hits} 次")
return player_reply(
(
f"投入 {amount} 摩拉,向许愿池许了 {wishes} 愿;"
f"结果:{','.join(result_parts)};"
f"当前摩拉 {account['balance']},"
f"原石 {account['primogems']} 个,"
f"扫码概率券 {account['scan_boost_tickets']} 张,"
f"Taffy发话额度 {account['taffy_say_credits']} 次;"
f"池水 {pool_state['balance']};"
f"保底 {account['wish_pool_pity']}/{WISH_POOL_PITY_LIMIT}"
),
index,
)
return self.ledger.apply_player_local_game_action(
group,
sender,
event_id,
"prize_draw:" + str(amount or ""),
WISH_POOL_GAME_KEY,
action,
)
def primogem_command(
self,
row: dict[str, Any],
@@ -1055,6 +1354,45 @@ class PlayerGames:
)
return "\n".join(lines)
@staticmethod
def blackjack_net_text(net: int) -> str:
if net > 0:
return f"净赚 {net} 摩拉"
if net < 0:
return f"净亏 {-net} 摩拉"
return "净赚净亏 0 摩拉"
def blackjack_win_rates(self, row: dict[str, Any]) -> str:
stats = self.ledger.player_blackjack_stats(
str(row["group_fingerprint"])
)
hands = int(stats["hands"])
if hands == 0:
return "当前群还没有已结算的21点记录"
taffy_wins = int(stats["taffy_wins"])
taffy_losses = int(stats["taffy_losses"])
ties = int(stats["ties"])
taffy_rate = taffy_wins / hands * 100
lines = [
f"21点胜率:本群已结算 {hands} 手",
(
f"Taffy:胜 {taffy_wins} / 负 {taffy_losses} / 平 {ties},"
f"胜率 {taffy_rate:.2f}%;"
f"{self.blackjack_net_text(int(stats['taffy_net']))}"
),
"玩家:",
]
for index, player in enumerate(stats["players"], start=1):
player_hands = int(player["hands"])
win_rate = int(player["wins"]) / player_hands * 100
lines.append(
f"{index}. {player['display_name']}:"
f"胜 {player['wins']} / 负 {player['losses']} / "
f"平 {player['ties']},胜率 {win_rate:.2f}%;"
f"{self.blackjack_net_text(int(player['net']))}"
)
return "\n".join(lines)
@staticmethod
def parse_blackjack_table_command(
arguments: str,
@@ -1117,7 +1455,7 @@ class PlayerGames:
def blackjack_prompt(player: dict[str, Any]) -> str:
return (
f"请{player['display_name']} 放自己的筹码\n"
"输入/21点 放200(1~10000)"
"输入/21点 放200(1~2000)"
)
def group_blackjack(
@@ -1135,7 +1473,7 @@ class PlayerGames:
return (
"多人21点用法:/21点 多人 启动;/21点 加入;"
"主持人 /21点 多人 开始;按提示 /21点 放200"
"(1~10000);轮到自己 /21点 要牌 或 /21点 停牌"
"(1~2000);轮到自己 /21点 要牌 或 /21点 停牌"
)
if action == "start":
return self.ledger.start_group_blackjack(
@@ -1181,7 +1519,7 @@ class PlayerGames:
)
if action == "bet":
if not MIN_BLACKJACK_TABLE_BET <= amount <= MAX_BLACKJACK_TABLE_BET:
return "21点多人牌桌筹码必须为 1~10000 的整数摩拉"
return "21点多人牌桌筹码必须为 1~2000 的整数摩拉"
deck = list(range(52))
self.rng.shuffle(deck)
return self.ledger.place_group_blackjack_bet(
@@ -1868,6 +2206,12 @@ class PlayerGames:
stake=bet,
payout=0,
)
record_blackjack_result(
account,
outcome="主动认输",
stake=bet,
payout=0,
)
account["game"] = None
account["losses"] += 1
account["blackjack_taffy_win_streak"] = 0
@@ -1922,6 +2266,12 @@ class PlayerGames:
stake=int(bet),
payout=int(payout),
)
record_blackjack_result(
account,
outcome=outcome,
stake=int(bet),
payout=int(payout),
)
achievements = blackjack_achievement_lines(
account,
outcome=outcome,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "jwebot-sdk"
version = "1.1.2b1+1019"
version = "2.0.5b1+1023"
description = "Typed, capability-limited extension API for JWeBot plugins"
requires-python = ">=3.9"
dependencies = []
@@ -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.1.2b1+1019"
__version__ = "2.0.5b1+1023"
+66 -2
View File
@@ -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))
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.jwebot]
release = "beta 1.1.2.1019"
release = "beta 2.0.5.1023"
production_deployments_enabled_by_default = false
[tool.jwebot.workspace]
+236
View File
@@ -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()
+2
View File
@@ -62,6 +62,8 @@ class DeploymentScriptTests(unittest.TestCase):
"jwebot_config",
"jwebot_sdk",
"jwebot_kernel",
"jwebot_kernel/data",
"kernel_data_source_dir",
"jwebot_official_extensions",
"data/cs_autochess",
"official_autochess_data_source_dir",
+502 -5
View File
@@ -211,6 +211,10 @@ class RoutingTests(unittest.TestCase):
MODULE.classify_question("/steam 在线"),
("local", "local_steam_friends"),
)
self.assertEqual(
MODULE.classify_question("/知识问答"),
("local", "local_trivia_start"),
)
def test_reply_has_one_suffix_within_delivery_limit(self):
self.assertEqual(MODULE.clean_reply("收到。"), "收到喵")
@@ -219,6 +223,35 @@ class RoutingTests(unittest.TestCase):
self.assertEqual(len(MODULE.clean_reply("好" * 900)), 900)
self.assertEqual(MODULE.FAST_ACKNOWLEDGEMENT, "喵......")
def test_chat_reply_style_removes_ai_like_padding(self):
self.assertEqual(
MODULE.clean_chat_reply(
"李神是啥我都不清楚,怎么比,总不能凭空给自己打满分吧\n"
"反正我就是个会唠嗑的,聊聊天、记记小账还行,"
"别的功能想都别想喵"
),
"李神是啥喵",
)
self.assertEqual(
MODULE.clean_chat_reply("作为AI我无法比较。\n李神是谁?"),
"李神是谁喵",
)
self.assertEqual(
MODULE.clean_chat_reply("你说“一切”是啥意思喵?做题就 /回答 A喵"),
"你说“一切”是啥意思喵",
)
self.assertEqual(
MODULE.unpack_reply_parts(MODULE.clean_chat_reply(
"这波像是脑子知道要撤,但是手完全没同意,"
"而且还顺手把队友也拖下水了。"
)),
[
"这波像是脑子知道要撤喵",
"但是手完全没同意喵",
"而且还顺手把队友也拖下水了喵",
],
)
def test_profile_memory_rejects_bare_versions_but_keeps_audio_context(self):
self.assertFalse(MODULE.profile_fact_is_safe("5.1"))
self.assertTrue(MODULE.profile_fact_is_safe("最近在研究 5.1 音响配置"))
@@ -277,12 +310,288 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(first["text"], MODULE.FAST_ACKNOWLEDGEMENT)
self.assertEqual(second["text"], MODULE.FAST_ACKNOWLEDGEMENT)
self.assertEqual(observed_context, [("你好", "第一条回复喵")])
self.assertEqual(len(observed_context), 1)
self.assertEqual(observed_context[0]["speaker"], "Alice")
self.assertEqual(observed_context[0]["user_text"], "你好")
self.assertEqual(observed_context[0]["taffy_text"], "第一条回复喵")
self.assertIn("same_sender", observed_context[0]["tags"])
self.assertEqual(
self.application.ledger.row("event-2")["response_text"],
"第二条回复喵",
)
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(
[(item["speaker"], item["user_text"], item["taffy_text"])
for item in observed["group-second"]],
[("Alice", "群里的第一条", "group-first 回复喵")],
)
self.assertEqual(
[(item["speaker"], item["user_text"], item["taffy_text"])
for item in observed["direct-second"]],
[("Alice", "私聊里的第一条", "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 = []
def complete(**kwargs):
observed_context.extend(kwargs["context"])
return "我看到了喵"
self.application.deepseek.complete = complete
self.application.accept(event("version-context", "@JWeBot /version"))
local = self.application.accept(event("coin-context", "@JWeBot /翻硬币"))
self.application.accept(event("chat-context", "@JWeBot 刚才是什么结果"))
self.application.process_direct_chat_once()
self.assertTrue(
any(
item["route"] == "local"
and item["route_reason"] == "local_coin"
and item["speaker"] == "Alice"
and item["user_text"] == "/翻硬币"
and item["taffy_text"] == local["text"]
for item in observed_context
)
)
self.assertTrue(
all(item["user_text"] != "/version" for item in observed_context)
)
def test_self_referential_context_does_not_cross_senders(self):
observed_context = []
def complete(**kwargs):
observed_context[:] = list(kwargs["context"])
return "我只看你的喵"
self.application.deepseek.complete = complete
self.application.deliver = mock.Mock()
self.application.accept(event("alice-first", "@JWeBot Alice 的事", sender="Alice"))
self.application.process_direct_chat_once()
self.application.accept(event("bob-first", "@JWeBot Bob 的事", sender="Bob"))
self.application.process_direct_chat_once()
self.application.accept(event("bob-follow", "@JWeBot 我刚才说啥", sender="Bob"))
self.application.process_direct_chat_once()
self.assertTrue(observed_context)
self.assertEqual(
{item["speaker"] for item in observed_context},
{"Bob"},
)
self.assertTrue(
all("Alice 的事" not in item["user_text"] for item in observed_context)
)
def test_mentioned_person_context_can_cross_senders(self):
observed_context = []
def complete(**kwargs):
observed_context[:] = list(kwargs["context"])
return "看 Bob 那句喵"
self.application.deepseek.complete = complete
self.application.deliver = mock.Mock()
self.application.accept(event("bob-mentioned", "@JWeBot Bob 的事", sender="Bob"))
self.application.process_direct_chat_once()
self.application.accept(
event("alice-asks-bob", "@JWeBot Bob 刚才说啥", sender="Alice")
)
self.application.process_direct_chat_once()
self.assertTrue(
any(
item["speaker"] == "Bob"
and item["user_text"] == "Bob 的事"
and "mentioned_person" in item["tags"]
for item in observed_context
)
)
def test_low_confidence_sender_does_not_receive_identity_memory(self):
captured = {}
def complete(**kwargs):
captured.update(kwargs)
return "先确认是谁喵"
self.application.deepseek.complete = complete
self.application.deliver = mock.Mock()
self.application.accept(
{
**event("low-conf-chat", "@JWeBot 我是谁", sender="alice"),
"sender_confidence": 0.42,
"body_confidence": 0.98,
"source": "rapidocr-local",
}
)
self.application.process_direct_chat_once()
self.assertEqual(captured["person_memory"], ())
self.assertIsNone(
self.application.ledger.person_profile(
"group-a",
MODULE.person_scope_key("group-a", "Alice"),
)
)
def test_low_confidence_identity_bound_local_command_is_rejected(self):
response = self.application.accept(
{
**event("low-conf-draw", "@JWeBot /抽奖 10", sender="alice"),
"sender_confidence": 0.42,
"body_confidence": 0.98,
"source": "rapidocr-local",
}
)
self.assertEqual(response["route"], "local")
self.assertIn("没认稳", response["text"])
self.assertEqual(
self.application.ledger.row("low-conf-draw")["status"],
"local_rejected",
)
def test_delivered_taffy_broadcast_is_available_to_context(self):
self.application.ledger.reserve_clock_broadcast(
group_fingerprint="group-a",
slot_key="hourly:2026-10-21T17",
local_date="2026-10-21",
kind="ambient_chat",
message="我刚刚路过说了一句喵",
)
self.assertTrue(
self.application.ledger.complete_clock_broadcast(
"group-a",
"hourly:2026-10-21T17",
)
)
context = self.application.ledger.conversation_context(
"group-a",
exclude_event_id="current",
sender="Alice",
question="Taffy 刚刚说了什么",
)
self.assertTrue(
any(
item["speaker"] == "Taffy"
and item["taffy_text"] == "我刚刚路过说了一句喵"
and "taffy_broadcast" in item["tags"]
for item in context
)
)
def test_local_game_status_question_answers_without_direct_chat(self):
person = self.application.ledger.resolve_person("group-a", "Bob")
account = MODULE.normalize_player_account({})
account["game"] = {
"bet": 50,
"deck": [1, 2, 3],
"player": [5, 10],
"dealer": [8, 2],
"updated_at": time.time(),
"index": 1,
}
with self.application.ledger.lock, self.application.ledger.connection:
self.application.ledger._save_player_account(
"group-a",
str(person["person_key"]),
account,
MODULE.utc_now(),
)
self.application.deepseek.complete = mock.Mock(
side_effect=AssertionError("direct chat should not run")
)
response = self.application.accept(
event("bob-local-status", "@JWeBot Bob 在玩什么吗", sender="Alice")
)
self.assertEqual(response["text"].count("喵"), 1)
self.assertIn("bob 在玩单人21点 #1", response["text"])
self.assertIn("16点", response["text"])
self.assertEqual(response["execution_path"], "local-command")
self.assertEqual(
self.application.ledger.row("bob-local-status")["status"],
"chat_completed",
)
self.assertEqual(
self.application.ledger.status()["direct_chat_pending"],
0,
)
self.application.deepseek.complete.assert_not_called()
def test_short_evaluation_uses_active_taffy_thread(self):
observed_context = []
def complete(**kwargs):
observed_context[:] = list(kwargs["context"])
return "他那手确实挺敢喵"
self.application.deepseek.complete = complete
self.application.deliver = mock.Mock()
self.application.ledger.reserve_clock_broadcast(
group_fingerprint="group-a",
slot_key="hourly:2026-10-21T18",
local_date="2026-10-21",
kind="ambient_chat",
message="21点那把16点还敢停牌喵",
)
self.assertTrue(
self.application.ledger.complete_clock_broadcast(
"group-a",
"hourly:2026-10-21T18",
)
)
self.application.accept(
event("short-active-thread", "@JWeBot 还真是 胆子大", sender="Alice")
)
self.application.process_direct_chat_once()
self.assertTrue(
any(
"active_thread" in item["tags"]
and "16点还敢停牌" in item["taffy_text"]
for item in observed_context
)
)
def test_route_and_ack_telemetry_is_privacy_safe(self):
observed = []
with tempfile.TemporaryDirectory() as directory:
@@ -412,6 +721,63 @@ class LedgerAndApplicationTests(unittest.TestCase):
"chat_delivered",
)
def test_delivery_sends_multi_reply_as_separate_helper_calls(self):
runtime_python = Path(self.temporary.name) / "python"
delivery_helper = Path(self.temporary.name) / "jwebot_edge_delivery.py"
runtime_python.write_text("# runtime\n", encoding="utf-8")
delivery_helper.write_text("# helper\n", encoding="utf-8")
result = mock.Mock(returncode=0, stdout="", stderr="")
reply = MODULE.pack_reply_parts(["第一段喵", "第二段喵"])
with mock.patch.object(MODULE, "DELIVERY_HELPER", delivery_helper), \
mock.patch.dict(
os.environ,
{"JWEBOT_RUNTIME_PYTHON": str(runtime_python)},
), \
mock.patch.object(
MODULE.random,
"uniform",
return_value=0.75,
) as uniform, \
mock.patch.object(MODULE.time, "sleep") as sleep, \
mock.patch.object(
MODULE.subprocess,
"run",
return_value=result,
) as run:
self.application.deliver(
{
"event_id": "event-1",
"group_fingerprint": "group-a",
"result_text": reply,
}
)
payloads = [
json.loads(call.kwargs["input"])
for call in run.call_args_list
]
self.assertEqual(
payloads,
[
{
"event_id": "event-1:part-1",
"group_fingerprint": "group-a",
"reply": "第一段喵",
},
{
"event_id": "event-1:part-2",
"group_fingerprint": "group-a",
"reply": "第二段喵",
},
],
)
uniform.assert_called_once_with(
MODULE.MULTI_REPLY_DELAY_MIN_SECONDS,
MODULE.MULTI_REPLY_DELAY_MAX_SECONDS,
)
sleep.assert_called_once_with(0.75)
def test_two_direct_generations_run_in_parallel(self):
entered = []
entered_lock = threading.Lock()
@@ -925,9 +1291,9 @@ class LedgerAndApplicationTests(unittest.TestCase):
self.assertEqual(version["route"], "local")
self.assertIn("JWeBot 版本", version["text"])
self.assertIn("kernel", version["text"])
self.assertIn("最新版本:beta 1.1.2.1019", version["text"])
self.assertIn("更新时间:2026-10-19 16:38:10", version["text"])
self.assertIn("更新摘要:新增低频主动插话、随机冷门知识问答", version["text"])
self.assertIn("最新版本:beta 2.0.5.1023", version["text"])
self.assertIn("更新时间:2026-10-23 17:16:12", version["text"])
self.assertIn("更新摘要:21 点胜率显示修复完成", version["text"])
self.assertEqual(debug["route"], "local")
self.assertIn("调试埋点", debug["text"])
self.assertEqual(balance["route"], "local")
@@ -1207,7 +1573,34 @@ class LedgerAndApplicationTests(unittest.TestCase):
)
def test_trivia_quiz_broadcast_and_answer_rewards_mora(self):
self.assertGreater(len(MODULE.trivia_question_bank()), 200)
bank = MODULE.trivia_question_bank()
self.assertEqual(len(bank), 500)
display_text = "\n".join(
"\n".join(
[
str(question["domain"]),
str(question["question"]),
*[str(option) for option in question["options"]],
str(question["explanation"]),
]
)
for question in bank
)
self.assertIn("新牛仔(NewJeans)", display_text)
self.assertIn("碧碧(BiBi)", display_text)
self.assertNotIn("地平线一直想回去见到的儿子", display_text)
self.assertNotIn("蒙德风神的真实魔神名", display_text)
topics = {str(question["topic"]) for question in bank}
self.assertIn("缪斯学园偶像", topics)
self.assertIn("我的世界", topics)
self.assertEqual(
MODULE.canonical_trivia_topic("LoveLive!"),
"缪斯学园偶像",
)
self.assertEqual(
MODULE.canonical_trivia_topic("Minecraft"),
"我的世界",
)
ledger = self.application.ledger
observed_at = MODULE.datetime(
2026,
@@ -1247,10 +1640,12 @@ class LedgerAndApplicationTests(unittest.TestCase):
delivery = self.application.deliver.call_args.args[0]
self.assertIn("冷门知识问答", delivery["result_text"])
self.assertIn("限时5分钟", delivery["result_text"])
self.assertIn("出现次数:1", delivery["result_text"])
self.assertIn("/回答 A", delivery["result_text"])
state = ledger.local_game("group-a", MODULE.TRIVIA_GAME_KEY)
self.assertIsNotNone(state)
assert state is not None
self.assertEqual(state["ask_count"], 1)
state["expires_at"] = time.time() + 300
ledger.save_local_game("group-a", MODULE.TRIVIA_GAME_KEY, state)
answer = str(state["answer"])
@@ -1271,6 +1666,87 @@ class LedgerAndApplicationTests(unittest.TestCase):
account = ledger._load_player_account("group-a", str(person["person_key"]))
self.assertEqual(account["balance"], 1000 + reward)
def test_trivia_quiz_prefers_least_asked_base_id(self):
group = "group-trivia-balance"
topic = "原神"
topic_base_ids = sorted(
{
str(question["base_id"])
for question in MODULE.trivia_question_bank()
if question["topic"] == topic
}
)
self.assertGreater(len(topic_base_ids), 3)
least_asked = topic_base_ids[2]
ask_counts = {base_id: 4 for base_id in topic_base_ids}
ask_counts[least_asked] = 1
self.application.ledger.save_local_game(
group,
MODULE.TRIVIA_HISTORY_KEY,
{
"recent_base_ids": topic_base_ids[: MODULE.TRIVIA_RECENT_BASE_LIMIT],
"ask_counts": ask_counts,
},
)
selected = self.application.clock.select_trivia_question(
group=group,
local_now=MODULE.datetime(
2026,
10,
23,
13,
0,
tzinfo=MODULE.BEIJING_TIMEZONE,
),
topic=topic,
)
self.assertEqual(selected["base_id"], least_asked)
self.assertEqual(selected["ask_count"], 2)
self.assertIn(
"出现次数:2",
self.application.clock.render_trivia_message(selected),
)
def test_trivia_quiz_can_be_started_by_local_command(self):
self.application.deepseek.complete = mock.Mock(side_effect=AssertionError)
unknown = self.application.accept(
event("trivia-force-unknown", "@JWeBot /知识问答 星际")
)
first = self.application.accept(
event("trivia-force", "@JWeBot /知识问答 LoveLive!")
)
second = self.application.accept(
event("trivia-force-existing", "@JWeBot /知识问答 Minecraft")
)
self.assertIn("支持主题:", unknown["text"])
self.assertIn("原神", unknown["text"])
self.assertIn("少女乐团派对", unknown["text"])
self.assertIn("缪斯学园偶像", unknown["text"])
self.assertIn("我的世界", unknown["text"])
self.assertEqual(first["route"], "local")
self.assertEqual(first["execution_path"], "local-command")
self.assertIn("冷门知识问答", first["text"])
self.assertIn("缪斯学园偶像", first["text"])
self.assertIn("回答格式:/回答 A", first["text"])
self.assertIn("已经有一题在进行中", second["text"])
self.assertIn("缪斯学园偶像", second["text"])
self.assertEqual(
self.application.ledger.row("trivia-force")["status"],
"local_completed",
)
state = self.application.ledger.local_game(
"group-a",
MODULE.TRIVIA_GAME_KEY,
)
self.assertIsNotNone(state)
assert state is not None
self.assertEqual(state["topic"], "缪斯学园偶像")
self.application.deepseek.complete.assert_not_called()
def test_ambient_chat_cooldown_keeps_next_hour_scan_broadcast(self):
ledger = self.application.ledger
observed_at = MODULE.datetime(
@@ -2175,6 +2651,10 @@ class DirectClientTests(unittest.TestCase):
self.assertNotIn("temperature", payload)
self.assertEqual(request.get_header("Authorization"), "Bearer test-key")
self.assertIn(MODULE.BOT_IDENTITY_NOTE, payload["messages"][0]["content"])
self.assertIn(
MODULE.TAFFY_CHAT_STYLE_NOTE,
payload["messages"][0]["content"],
)
self.assertTrue(
any(
message["content"].startswith("CURRENT_GROUP_SENDER_LABEL:")
@@ -2763,6 +3243,23 @@ class CloudRunnerTests(unittest.TestCase):
self.assertEqual(MODULE.cloud_reply_text(nested), "嵌套内容喵")
def test_cloud_protocol_applies_chat_reply_style(self):
self.assertEqual(
MODULE.cloud_reply_text(
json.dumps(
{
"route": "reply",
"text": (
"李神是啥我都不清楚,怎么比,"
"总不能凭空给自己打满分吧"
),
},
ensure_ascii=False,
)
),
"李神是啥喵",
)
def test_cloud_protocol_ignores_a_suffix_after_the_json_object(self):
self.assertEqual(
MODULE.cloud_reply_text(
@@ -152,6 +152,7 @@ class LocalExtensionInventoryTests(unittest.TestCase):
{
"feature_list",
"deepseek_balance",
"trivia_start",
}
),
)
+14 -13
View File
@@ -40,6 +40,7 @@ class Release061Tests(unittest.TestCase):
def test_aliases_are_corresponding_and_case_insensitive(self):
pairs = [("上下文", "Context"), ("摩拉", "wallet"), ("抽奖", "draw"),
("许愿池", "draw"), ("许愿", "draw"),
("买彩票", "lottery"), ("21点", "blackjack"),
("人物列表", "people"), ("人物", "person"), ("游戏帮助", "games")]
for chinese, english in pairs:
@@ -84,46 +85,46 @@ class Release061Tests(unittest.TestCase):
def test_initial_funds_aliases_groups_and_replay(self):
self.assertIn("摩拉 1000", self.command("/wallet", event_id="wallet-1"))
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
first = self.command("/draw 10", event_id="draw-1")
replay = self.command("/draw 10", sender="a1", event_id="draw-1")
first = self.command("/draw 1000", event_id="draw-1")
replay = self.command("/draw 1000", sender="a1", event_id="draw-1")
self.assertEqual(first, replay)
self.assertEqual(self.state("a1")["balance"], 990)
self.assertIn("许愿池", first)
self.assertEqual(self.state("a1")["balance"], 0)
self.assertIn("摩拉 1000", self.command("/wallet", sender="Bob"))
self.assertIn("摩拉 1000", self.command("/wallet", group="group-b"))
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local
self.assertIn("摩拉 990", self.command("/wallet", event_id="restart"))
self.assertIn("摩拉 0", self.command("/wallet", event_id="restart"))
def test_concurrent_replay_debits_once_and_rolls_back_failure(self):
replies = []
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
threads = [threading.Thread(target=lambda: replies.append(
self.command("/draw 100", event_id="concurrent"))) for _ in range(8)]
self.command("/draw 1000", event_id="concurrent"))) for _ in range(8)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
self.assertEqual(len(replies), 8)
self.assertEqual(len(set(replies)), 1)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(self.state()["balance"], 0)
def broken(state):
state["balance"] = -1
return "bad"
with self.assertRaises(MODULE.LocalCommandError):
self.engine.ledger.apply_player_action("group-a", "Alice", "broken", "test", broken)
self.assertEqual(self.state()["balance"], 900)
self.assertEqual(self.state()["balance"], 0)
with self.assertRaises(MODULE.LocalCommandError):
self.command("/draw 100", sender="Bob", event_id="concurrent")
self.command("/draw 1000", sender="Bob", event_id="concurrent")
def test_invalid_bets_insufficient_funds_and_lottery(self):
for question in ("/draw -1", "/draw 0", "/draw 101", "/lottery xyz",
"/blackjack start -1", "/blackjack hit 10"):
self.assertRegex(self.command(question), "用法|下注")
self.assertRegex(self.command(question), "用法|投入|下注")
with mock.patch.object(self.local.players.rng, "randrange", return_value=99):
for _ in range(10):
self.command("/draw 100")
self.assertIn("摩拉不足", self.command("/draw 2"))
self.command("/draw 1000")
self.assertIn("摩拉不足", self.command("/draw 1000"))
self.assertEqual(self.state()["balance"], 0)
def test_blackjack_is_private_persistent_and_settles_exactly_once(self):
@@ -136,7 +137,7 @@ class Release061Tests(unittest.TestCase):
self.assertIn("暗牌", start)
self.assertEqual(self.state()["balance"], 950)
self.assertIn("没有进行中", self.command("/blackjack status", sender="Bob"))
self.assertIn("已有一局", self.command("/draw"))
self.assertIn("已有一局", self.command("/draw 1000"))
self.engine.ledger.connection.close()
self.engine = MODULE.EdgeApplication(config(self.root))
self.local = self.engine.local
+77 -17
View File
@@ -154,7 +154,20 @@ class Release063GameTests(unittest.TestCase):
self.assertEqual(self.state()["balance"], 500)
self.assertIn("已报名 1 人", self.command("/彩票状态"))
def test_blackjack_uses_1_to_500_and_indexed_history(self):
def test_blackjack_uses_1_to_2000_and_indexed_history(self):
self.command("/摩拉")
account = self.state()
account["balance"] = 2500
person_key = self.engine.ledger.resolve_person(
"group-a",
"Alice",
)["person_key"]
self.engine.ledger._save_player_account(
"group-a",
person_key,
account,
MODULE.utc_now(),
)
def tied_deck(cards):
first = [9, 6, 8, 7]
cards[:] = first + [card for card in cards if card not in first]
@@ -164,17 +177,36 @@ class Release063GameTests(unittest.TestCase):
"shuffle",
side_effect=tied_deck,
):
started = self.command("/21点开始500")
started = self.command("/21点开始2000")
self.assertTrue(started.startswith("alice #1:"))
self.assertEqual(self.state()["balance"], 500)
settled = self.command("/21点停牌")
self.assertTrue(settled.startswith("alice #1:"))
self.assertIn("平局", settled)
self.assertEqual(self.state()["balance"], 1000)
self.assertEqual(self.state()["balance"], 2500)
history = self.command("/游戏记录")
self.assertIn("alice #1:21点 平局", history)
self.assertIn("下注 500,返还 500", history)
self.assertIn("1~500", self.command("/21点开始501"))
self.assertIn("下注 2000,返还 2000", history)
rates = self.command("/21点胜率")
self.assertIn("Taffy:胜 0 / 负 0 / 平 1,胜率 0.00%", rates)
self.assertIn(
"alice:胜 0 / 负 0 / 平 1,胜率 0.00%;净赚净亏 0 摩拉",
rates,
)
account = self.state()
account["wins"] = 12
account["losses"] = 15
account["ties"] = 3
self.engine.ledger._save_player_account(
"group-a",
person_key,
account,
MODULE.utc_now(),
)
wallet = self.command("/摩拉")
self.assertIn("21点 无进行中的牌局;已结算胜 0 / 负 0 / 平 1", wallet)
self.assertNotIn("已结算胜 12 / 负 15 / 平 3", wallet)
self.assertIn("1~2000", self.command("/21点开始2001"))
def test_multiplayer_blackjack_table_guides_turns_and_settles(self):
self.assertIn("黑杰克Blackjack 21点牌桌", self.command("/21点 多人 启动"))
@@ -225,26 +257,54 @@ class Release063GameTests(unittest.TestCase):
"bob #1:21点 你输了",
self.command("/游戏记录", sender="Bob"),
)
rates = self.command("/21点 胜率")
self.assertIn("本群已结算 2 手", rates)
self.assertIn(
"Taffy:胜 1 / 负 1 / 平 0,胜率 50.00%;净亏 100 摩拉",
rates,
)
self.assertIn(
"alice:胜 1 / 负 0 / 平 0,胜率 100.00%;净赚 200 摩拉",
rates,
)
self.assertIn(
"bob:胜 0 / 负 1 / 平 0,胜率 0.00%;净亏 100 摩拉",
rates,
)
self.assertIn("+200:21点 获胜", self.command("/金币"))
self.assertIn(
"-100:21点 失败",
self.command("/金币", sender="Bob"),
)
def test_prize_draw_has_indexed_history(self):
def test_wish_pool_has_indexed_history(self):
self.command("/摩拉")
account = self.state()
account["balance"] = 2000
person_key = self.engine.ledger.resolve_person(
"group-a",
"Alice",
)["person_key"]
self.engine.ledger._save_player_account(
"group-a",
person_key,
account,
MODULE.utc_now(),
)
self.engine.ledger.connection.commit()
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=99,
):
first = self.command("/抽奖10")
second = self.command("/抽奖10")
first = self.command("/许愿1000")
second = self.command("/抽奖1000")
self.assertTrue(first.startswith("alice #1:"))
self.assertTrue(second.startswith("alice #2:"))
history = self.command("/gamehistory")
self.assertIn("alice #2:抽奖 未中奖", history)
self.assertIn("alice #1:抽奖 未中奖", history)
self.assertIn("alice #2:许愿池 沉底", history)
self.assertIn("alice #1:许愿池 沉底", history)
def test_transfer_requires_initialized_target_and_replays_once(self):
self.assertIn("摩拉 1000", self.command("/摩拉"))
@@ -345,21 +405,21 @@ class Release063GameTests(unittest.TestCase):
"randrange",
return_value=99,
):
self.command("/抽奖10")
self.command("/抽奖1000")
with mock.patch.object(
self.engine.local.players.rng,
"randrange",
return_value=0,
side_effect=[950, 3000],
):
self.command("/抽奖10", sender="Bob")
self.command("/抽奖1000", sender="Bob")
stats = self.command("/Taffy摩拉")
self.assertIn("玩家总下注 20,总返还 50", stats)
self.assertIn("Taffy累计倒贴 30 摩拉", stats)
self.assertIn("玩家总下注 2000,总返还 3000", stats)
self.assertIn("Taffy累计倒贴 1000 摩拉", stats)
self.assertIn("最大上贡者排行(含负数):", stats)
self.assertIn("alice:上贡 10", stats)
self.assertIn("bob:从Taffy赢走 40", stats)
self.assertIn("alice:上贡 1000", stats)
self.assertIn("bob:从Taffy赢走 2000", stats)
self.assertIn("Taffy摩拉人数必须为 1~20", self.command("/Taffy摩拉 0"))
def test_open_round_and_history_survive_restart(self):
+31 -11
View File
@@ -20,7 +20,8 @@ SPEC.loader.exec_module(MODULE)
class ReleaseUpdateTests(unittest.TestCase):
release = "beta 1.1.2.1019"
release = "beta 2.0.5.1023"
major_release = "beta 2.0.0.1021"
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
@@ -112,15 +113,26 @@ class ReleaseUpdateTests(unittest.TestCase):
announcement = MODULE.build_announcement(self.release, summary)
self.assertIn(f"{self.release} 更新现已完成", announcement)
self.assertIn("更新补偿:摩拉 x200", announcement)
self.assertNotIn("原石 x1", announcement)
self.assertNotIn("原石 x", announcement)
self.assertNotIn("第二条", announcement)
def test_policy_release_announces_primogem(self):
for release in ("beta 1.0.5.1016", "beta 1.1.0.1016"):
for release in (
"beta 1.0.5.1016",
"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.major_release), 400)
self.assertEqual(MODULE.primogem_compensation_amount(self.major_release), 2)
announcement = MODULE.build_announcement(self.major_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)
@@ -145,11 +157,24 @@ 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],
f"Taffy 准备停机更新到 {self.release},会短暂离线一下;别慌,我很快回来喵",
)
self.assertIn("shutdown", delivered[0][3])
self.assertEqual([state["balance"] for state in self.account_states()], [1000, 50])
def test_major_release_shutdown_message_is_ceremonial(self):
message = MODULE.build_lifecycle_message(self.major_release, "shutdown")
self.assertEqual(
message,
(
"Taffy 准备停机更新,本次更新是架构级大版本更新,"
f"预期版本{self.major_release},离线的时候保持耐心迎接Taffy的归来哦~"
),
)
def test_release_update_grants_once_and_announces_once(self):
delivered = []
@@ -193,12 +218,7 @@ class ReleaseUpdateTests(unittest.TestCase):
]
self.assertEqual(len(matches), 1)
self.assertEqual(matches[0]["created_at"], applied_at.timestamp())
primogem_matches = [
event
for event in state["primogem_events"]
if event["reason"] == f"{self.release} 版本更新补偿"
]
self.assertEqual(primogem_matches, [])
self.assertEqual(state["primogem_events"], [])
self.assertEqual(self.announcement_marker()["state"], "delivered")
self.assertEqual(len(list(self.backups.glob("*.sqlite3"))), 1)
+66
View File
@@ -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()
+257
View File
@@ -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)
+6 -33
View File
@@ -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)
'''
+10
View File
@@ -10,6 +10,7 @@ import hashlib
import logging
import os
import queue
import random
import socket
import sys
import threading
@@ -43,6 +44,8 @@ GUI_SENDER_TIMEOUT_SECONDS = float(
os.environ.get("JWEBOT_GUI_SENDER_TIMEOUT_SECONDS", "55")
)
GUI_SENDER_MAX_PAYLOAD_BYTES = 65536
MULTI_REPLY_DELAY_MIN_SECONDS = 0.5
MULTI_REPLY_DELAY_MAX_SECONDS = 1.0
BASE_WIDTH = 1440
BASE_HEIGHT = 873
@@ -1052,6 +1055,13 @@ def run() -> None:
event_id=event_id,
source=f"local-multi-{index}",
)
if index < len(texts):
time.sleep(
random.uniform(
MULTI_REPLY_DELAY_MIN_SECONDS,
MULTI_REPLY_DELAY_MAX_SECONDS,
)
)
except Exception as error:
logging.warning(
"multi reply GUI delivery failed event_hash=%s error_type=%s",
+154 -30
View File
@@ -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",
)