24 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
JWeBot Contributors ddebe075d7 Release beta 1.1.2.1019
check / local-gate (push) Failing after 53s
2026-10-19 16:56:59 +08:00
JWeBot Contributors 522e349491 Release beta 1.1.1 stacked scan boosts 2026-10-16 17:31:45 +08:00
JWeBot Contributors 35ef82f14f Release beta 1.1.0 scan and duplicate fixes 2026-10-16 17:14:41 +08:00
JWeBot Contributors b1d8e4e8e7 Release beta 1.0.5 primogem economy 2026-10-16 16:46:44 +08:00
JWeBot Contributors 127fea8cd0 Release beta 1.0.4 update settlement
check / local-gate (push) Failing after 1m7s
2026-10-16 16:10:19 +08:00
JWeBot Contributors bf6b738baa Release beta 1.0.3 Mora wallet updates 2026-10-16 15:44:34 +08:00
JWeBot Contributors 22edf6ff15 Cache OCR-negative bubbles
check / local-gate (push) Failing after 42s
2026-10-16 11:38:03 +08:00
JWeBot Contributors 572159f571 Fix OCR thread test isolation
check / local-gate (push) Failing after 17m10s
2026-10-16 11:19:55 +08:00
JWeBot Contributors 99a251c19a Limit OCR runtime threads
check / local-gate (push) Failing after 44s
2026-10-16 11:16:23 +08:00
JWeBot Contributors 3fa04755d0 Add multiplayer blackjack table flow
check / local-gate (push) Failing after 51s
2026-10-16 11:03:32 +08:00
134 changed files with 12109 additions and 1932 deletions
+16
View File
@@ -24,6 +24,22 @@ These rules are mandatory for every automated change in this repository.
or adaptive restart-stability gates. or adaptive restart-stability gates.
- If a deployment gate fails, verify rollback state before retrying. - If a deployment gate fails, verify rollback state before retrying.
## Mandatory Release Settlement
- Every successful production update must run `jwebot_release_update.py`
after cutover gates pass and before the release is declared complete.
- Each release grants exactly 200 Mora to every account that exists when the
settlement starts. The grant and account movement are idempotent by version.
- The first bullet in each release's `CHANGELOG.md` section must be one concise,
user-facing sentence suitable for the group update announcement.
- After crediting accounts, send the version, first changelog bullet, and
`Mora x200` compensation notice through the guarded group sender.
- Never blindly retry an uncertain announcement. Verify the group visually,
then use `--retry-uncertain-announcement` only when the prior message did not
land.
- A production release is incomplete until compensation, announcement,
SQLite integrity, empty queues, Edge health, and service stability pass.
## Configuration And Access ## Configuration And Access
- Real profiles live under `~/.config/jwebot/`, never in the repository. - Real profiles live under `~/.config/jwebot/`, never in the repository.
+201
View File
@@ -1,5 +1,206 @@
# Changelog # 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 会偶尔自然冒泡,抛出限时答题奖励摩拉,并在停机、上线和更新就绪时主动说明状态。
- Use recent safe group context to replace some hourly scan slots with an
ambient chat message during active daytime windows.
- Add a 200+ question CS, Apex, Overwatch, and LoL trivia bank with
five-minute `/回答 A/B/C/D` answers, recent-question avoidance, and
rarity-based Mora rewards.
- Limit ambient chat with deterministic probability, multi-hour cooldown, and
minimum recent-speaker activity so proactive replies feel occasional.
- Send guarded shutdown and startup lifecycle notices during production
cutover before the normal ready-stage update announcement and compensation.
## beta 1.1.1.1016 - 2026-10-16 17:19:45
- 修复扫码加权重复购买只刷新不叠加的问题。
- Change scan boost semantics to stack each successful purchase as an
independent 24-hour +5 weight entry.
- Preserve legacy boost entries by deriving their 24-hour expiry from their
creation time.
## beta 1.1.0.1016 - 2026-10-16 17:04:25
- 修复 OCR 偶发重复识别消耗型命令导致重复回复的问题,并完善摩拉/扫码榜展示。
- Extend OCR semantic duplicate suppression for resource-mutating local
commands so a visible command bubble re-detected after the short 8 second
window is still ignored.
- Keep ordinary local commands on the short duplicate window to avoid blocking
intentional repeated game/query commands.
- Show scan-boost coupon count in `/摩拉`, and make `/扫码榜` list every
configured scan candidate plus next-hour scan weights.
- This major settlement grants each existing account both 200 Mora and
1 primogem.
## beta 1.0.5.1016 - 2026-10-16 16:30:44
- 新增原石系统:成就可获得原石,并开放祈愿、扫码加权、Taffy发话和原石榜。
- Add primogem account state, primogem movement history, wish records, scan
boost coupons, Taffy speech credits, scheduled speech delivery, and
primogem-source ranking without a SQLite schema migration.
- Award primogems from blackjack natural 21, blackjack 20-to-21 hit challenges,
Taffy blackjack win streaks, and personal consecutive lottery high-hit pairs.
- Extend post-release settlement so this version grants each existing account
both 200 Mora and 1 primogem, and show `/version` update time down to seconds.
## beta 1.0.4.1016 - 2026-10-16
- 新增版本更新补偿流程:每次生产更新后向所有既有账户发放 200 摩拉,并在目标群发布更新公告。
- Add a version-idempotent post-release settlement tool that creates an online
SQLite backup, grants Mora through the production account schema, records a
version-labelled movement, and tracks announcement delivery independently.
- Run release settlement only after coordinated cutover and rollback gates are
disarmed, keeping a healthy runtime active when GUI delivery is uncertain.
- Require the first changelog bullet to be a concise user-facing announcement
summary and require visual confirmation before explicitly retrying an
uncertain announcement.
## beta 1.0.3.1016 - 2026-10-16
- Rename the virtual currency user-facing copy to Mora (`摩拉`) while keeping
the existing `/金币`, `/金币排行`, and `/Taffy金币` commands as aliases.
- Add recent account movement details to `/摩拉` and `/金币`, covering the
latest transfer, loan, repayment, lottery, draw, and blackjack net changes.
- Suppress short-window semantic duplicate OCR events so one visible command
bubble cannot trigger the same local reply multiple times under new event IDs.
## beta 1.0.2.1016 - 2026-10-16
- Cache OCR-negative visible bubbles and reuse stable visual signatures as the
OCR cache key so unreadable or lightly re-rendered bubbles do not trigger
repeated 10+ second RapidOCR passes during active chat windows.
## beta 1.0.1.1016 - 2026-10-16
- Limit RapidOCR ONNXRuntime intra/inter operation threads by default so the
adaptive OCR recorder no longer saturates CPU after restart and slow recovery
windows do not stretch local command detection into 10+ second scans.
## beta 1.0.0.1016 - 2026-10-16 ## beta 1.0.0.1016 - 2026-10-16
- Promote JWeBot from alpha to beta after the local, staging, privacy, and - Promote JWeBot from alpha to beta after the local, staging, privacy, and
+28 -12
View File
@@ -8,12 +8,13 @@ JWeBot 是运行于 Linux 的单群微信自动回复系统。它通过本地 OC
观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件, 观察选定的微信窗口,将消息写入持久化 Edge 账本,调用经过审核的命令插件,
并通过带视觉确认的 GUI 发送链路回复。 并通过带视觉确认的 GUI 发送链路回复。
当前版本:`beta 1.0.0.1016`,更新日期:2026-10-16。 当前版本:`beta 2.0.5.1023`,更新时间:2026-10-23 17:16:12。
1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、 1.0 Beta 收口了当前稳定功能:本地命令插件、人物与上下文索引、
Steam 状态与时长查询、虚拟金币游戏、多人彩票、21 点、CS Major Steam 状态与时长查询、虚拟摩拉游戏、多人彩票、21 点、CS Major
自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则 自走棋、生产部署与回滚门禁。CS Major 自走棋的选手、教练、叙事和规则
已经外置为包内 JSON,虚拟金币支持转账、贷款、还款和群内排行榜。 已经外置为包内 JSON,知识问答题库也以 500 题 JSON 数据随 Kernel
发布,虚拟摩拉支持转账、贷款、还款和群内排行榜。
完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见 完整命令见[命令目录](docs/command-catalog.md)。历史版本记录见
[CHANGELOG](CHANGELOG.md)。 [CHANGELOG](CHANGELOG.md)。
@@ -82,12 +83,16 @@ PATH="$PWD/.venv/bin:$PATH" ./tools/check-local.sh
/功能列表 /功能列表
/上下文 [100|200|300](/context) /上下文 [100|200|300](/context)
/人物列表 [页码](/people);/人物 [名称或别名](/person) /人物列表 [页码](/people);/人物 [名称或别名](/person)
/金币(/wallet);/抽奖 [金额](/draw) /摩拉(兼容 /金币、/wallet);/许愿池 金额(兼 /许愿、/抽奖、/draw)
/大富翁榜(/金币排行) /大富翁榜(/摩拉排行,兼容 /金币排行)
/转账 对方 金额(/transfer name amount) /转账 对方 金额(/transfer name amount)
/贷款 [金额](/loan amount);/还款 [金额|全部](/repay) /贷款 [金额](/loan amount);/还款 [金额|全部](/repay)
/彩票 开始|报名|当前状态|开奖|取消(/lottery) /彩票 开始|报名|当前状态|开奖|取消(/lottery)
/21点 开始 [1~500];开局后 /21点 要牌|停牌|状态|结束 /21点 开始 [1~2000];开局后 /21点 要牌|停牌|状态|结束;/21点 胜率
/21点 多人 启动;/21点 加入;/21点 多人 开始;/21点 放200
/摩拉(兼 /原石);/原石祈愿;/原石榜;/扫码加权 人名(24小时+5);/Taffy发话 内容
/知识问答 [主题];500 题冷门题库,优先抽取少出现过的题并显示出现次数;支持主题:反恐精英、顶点英雄、守望先锋、英雄联盟、少女乐团派对、韩流、原神、缪斯学园偶像、我的世界;/回答 A
/游戏记录 [页码](/gamehistory)
/游戏记录 [页码](/gamehistory) /游戏记录 [页码](/gamehistory)
/游戏帮助(/games) /游戏帮助(/games)
/自走棋开始|报名|开赛|状态|档案 /自走棋开始|报名|开赛|状态|档案
@@ -188,7 +193,12 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
``` ```
部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应 部署必须通过微信窗口就绪、队列清空、Edge 健康、SQLite 完整性和自适应
录制服务重启稳定性检查。旧 OCR 服务保留安装,但保持禁用。 录制服务重启稳定性检查。切换成功后,部署器按版本幂等地向每个既有账户
发放摩拉、写入账户流水,并使用当前 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 服务保留安装,但保持禁用。
### 仓库结构 ### 仓库结构
@@ -262,16 +272,17 @@ visually confirmed GUI bridge.
Current version: Current version:
```text ```text
beta 1.0.0.1016 beta 2.0.5.1023
``` ```
Updated: 2026-10-16. Updated: 2026-10-23 17:16:12.
The 1.0 Beta baseline consolidates the stable local command set, person and The 1.0 Beta baseline consolidates the stable local command set, person and
context indexes, Steam presence/playtime queries, virtual coin games, context indexes, Steam presence/playtime queries, virtual coin games,
multiplayer lottery, blackjack, CS Major autochess, and production deployment multiplayer lottery, blackjack, CS Major autochess, and production deployment
gates. CS Major autochess game content is now packaged as JSON data, and gates. CS Major autochess game content and the 500-question trivia bank are
virtual coins support transfer, loans, repayment, and group leaderboards. now packaged as JSON data, and virtual coins support transfer, loans,
repayment, and group leaderboards.
## Runtime Flow ## Runtime Flow
@@ -451,7 +462,12 @@ sudo ./deploy/scripts/deploy-jwebot-production.sh /path/to/JWeBot jwebot
The deployer refuses cutover unless the ready-window, empty-queue, Edge health, The deployer refuses cutover unless the ready-window, empty-queue, Edge health,
SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units SQLite integrity, and adaptive restart-stability gates pass. Legacy OCR units
remain installed but disabled. remain installed but disabled. After cutover, version-idempotent release
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 ## Repository Map
+1 -1
View File
@@ -1 +1 @@
beta 1.0.0.1016 beta 2.0.5.1023
+35 -1
View File
@@ -341,6 +341,19 @@ verify_edge_health() {
'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)' 'import json,sys; value=json.load(sys.stdin); raise SystemExit(0 if value.get("status") == "ok" else 1)'
} }
send_release_lifecycle_notice() {
local stage=$1
/usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \
HOME="${target_home}" \
XDG_RUNTIME_DIR="${user_runtime}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \
"${runtime_python}" "${source_dir}/jwebot_release_update.py" \
--release-file "${source_dir}/VERSION" \
--database "${target_edge_state}/edge.sqlite3" \
--delivery-helper "${target_runtime}/jwebot_edge_delivery.py" \
--lifecycle-stage "${stage}"
}
render_root_configuration() { render_root_configuration() {
local output_dir="$1" local output_dir="$1"
sed \ sed \
@@ -421,6 +434,7 @@ test -x "${runtime_python}"
for path in \ for path in \
"${source_dir}/wechat_local_ocr.py" \ "${source_dir}/wechat_local_ocr.py" \
"${source_dir}/wechat-gui-bridge" \ "${source_dir}/wechat-gui-bridge" \
"${source_dir}/jwebot_release_update.py" \
"${source_dir}/deploy/scripts/install-wechat-health-watch.sh" \ "${source_dir}/deploy/scripts/install-wechat-health-watch.sh" \
"${source_dir}/deploy/systemd/system/wechat-local-ocr-recorder.service" \ "${source_dir}/deploy/systemd/system/wechat-local-ocr-recorder.service" \
"${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \ "${source_dir}/deploy/systemd/system/wechat-local-ocr-responder.service" \
@@ -430,7 +444,9 @@ do
done done
bash -n "${source_dir}/wechat-gui-bridge" bash -n "${source_dir}/wechat-gui-bridge"
sh -n "${source_dir}/deploy/scripts/install-wechat-health-watch.sh" sh -n "${source_dir}/deploy/scripts/install-wechat-health-watch.sh"
"${runtime_python}" -m py_compile "${source_dir}/wechat_local_ocr.py" "${runtime_python}" -m py_compile \
"${source_dir}/wechat_local_ocr.py" \
"${source_dir}/jwebot_release_update.py"
preflight_rendered_dir=$(mktemp -d) preflight_rendered_dir=$(mktemp -d)
render_root_configuration "${preflight_rendered_dir}" render_root_configuration "${preflight_rendered_dir}"
systemd-analyze verify \ systemd-analyze verify \
@@ -468,6 +484,9 @@ jwebot_log "backup ready path=${backup_dir}"
armed=1 armed=1
jwebot_progress 3 8 "stop services" jwebot_progress 3 8 "stop services"
jwebot_log "cutover starting" jwebot_log "cutover starting"
jwebot_log "release lifecycle shutdown notice starting"
send_release_lifecycle_notice shutdown
jwebot_log "release lifecycle shutdown notice ready"
user_systemctl stop \ user_systemctl stop \
wechat-ocr-adaptive-recorder.service \ wechat-ocr-adaptive-recorder.service \
jwebot-edge.service \ jwebot-edge.service \
@@ -606,6 +625,10 @@ if [[ -f "${target_edge_state}/edge.sqlite3" ]]; then
fi fi
jwebot_log "database ready quick_check=${db_check:-absent}" jwebot_log "database ready quick_check=${db_check:-absent}"
jwebot_log "release lifecycle startup notice starting"
send_release_lifecycle_notice startup
jwebot_log "release lifecycle startup notice ready"
jwebot_progress 7 8 "verify backup" jwebot_progress 7 8 "verify backup"
( (
cd "${backup_dir}" cd "${backup_dir}"
@@ -619,6 +642,17 @@ success=1
armed=0 armed=0
trap - EXIT HUP INT TERM trap - EXIT HUP INT TERM
jwebot_log "release settlement starting"
release_update_output=$(
/usr/sbin/runuser -u "${target_user}" -- /usr/bin/env \
HOME="${target_home}" \
XDG_RUNTIME_DIR="${user_runtime}" \
DBUS_SESSION_BUS_ADDRESS="unix:path=${user_runtime}/bus" \
"${runtime_python}" "${target_runtime}/jwebot_release_update.py"
)
printf '%s\n' "${release_update_output}"
jwebot_log "release settlement ready"
jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")" jwebot_log "deployment ready version=$(cat "${source_dir}/VERSION")"
jwebot_log "backup active path=${backup_dir}" jwebot_log "backup active path=${backup_dir}"
jwebot_log "legacy OCR inactive" jwebot_log "legacy OCR inactive"
+16 -2
View File
@@ -7,6 +7,7 @@ user_unit_source_dir="$source_dir/deploy/systemd/user"
config_source_dir="$source_dir/packages/jwebot-config/src/jwebot_config" config_source_dir="$source_dir/packages/jwebot-config/src/jwebot_config"
sdk_source_dir="$source_dir/packages/jwebot-sdk/src/jwebot_sdk" sdk_source_dir="$source_dir/packages/jwebot-sdk/src/jwebot_sdk"
kernel_source_dir="$source_dir/packages/jwebot-kernel/src/jwebot_kernel" 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_source_dir="$source_dir/packages/jwebot-official-extensions/src/jwebot_official_extensions"
official_adapter_source_dir="$official_source_dir/adapters" official_adapter_source_dir="$official_source_dir/adapters"
official_autochess_data_source_dir="$official_source_dir/data/cs_autochess" official_autochess_data_source_dir="$official_source_dir/data/cs_autochess"
@@ -27,6 +28,7 @@ adaptive_script_name=wechat_adaptive_recorder.py
adaptive_bridge_name=wechat_adaptive_gui_bridge.py adaptive_bridge_name=wechat_adaptive_gui_bridge.py
edge_script_name=jwebot_edge.py edge_script_name=jwebot_edge.py
edge_delivery_name=jwebot_edge_delivery.py edge_delivery_name=jwebot_edge_delivery.py
release_update_name=jwebot_release_update.py
service_name=wechat-health-watch.service service_name=wechat-health-watch.service
timer_name=wechat-health-watch.timer timer_name=wechat-health-watch.timer
adaptive_service_name=wechat-ocr-adaptive-recorder.service adaptive_service_name=wechat-ocr-adaptive-recorder.service
@@ -209,6 +211,7 @@ rollback() {
restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700 restore_file "$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" 0700
restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700 restore_file "$edge_script_name" "$target_dir/$edge_script_name" 0700
restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700 restore_file "$edge_delivery_name" "$target_dir/$edge_delivery_name" 0700
restore_file "$release_update_name" "$target_dir/$release_update_name" 0700
for package_name in \ for package_name in \
jwebot_config \ jwebot_config \
jwebot_sdk \ jwebot_sdk \
@@ -246,6 +249,7 @@ for path in \
"$runtime_source_dir/$adaptive_bridge_name" \ "$runtime_source_dir/$adaptive_bridge_name" \
"$runtime_source_dir/$edge_script_name" \ "$runtime_source_dir/$edge_script_name" \
"$runtime_source_dir/$edge_delivery_name" \ "$runtime_source_dir/$edge_delivery_name" \
"$runtime_source_dir/$release_update_name" \
"$user_unit_source_dir/$service_name" \ "$user_unit_source_dir/$service_name" \
"$user_unit_source_dir/$timer_name" \ "$user_unit_source_dir/$timer_name" \
"$user_unit_source_dir/$adaptive_service_name" \ "$user_unit_source_dir/$adaptive_service_name" \
@@ -289,7 +293,8 @@ for name in \
"$adaptive_script_name" \ "$adaptive_script_name" \
"$adaptive_bridge_name" \ "$adaptive_bridge_name" \
"$edge_script_name" \ "$edge_script_name" \
"$edge_delivery_name" "$edge_delivery_name" \
"$release_update_name"
do do
if [ -f "$target_dir/$name" ]; then if [ -f "$target_dir/$name" ]; then
cp -a "$target_dir/$name" "$backup_dir/$name" cp -a "$target_dir/$name" "$backup_dir/$name"
@@ -342,6 +347,9 @@ install -m 0700 "$runtime_source_dir/$adaptive_script_name" "$target_dir/$adapti
install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name" install -m 0700 "$runtime_source_dir/$adaptive_bridge_name" "$target_dir/$adaptive_bridge_name"
install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name" install -m 0700 "$runtime_source_dir/$edge_script_name" "$target_dir/$edge_script_name"
install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name" install -m 0700 "$runtime_source_dir/$edge_delivery_name" "$target_dir/$edge_delivery_name"
install -m 0700 \
"$runtime_source_dir/$release_update_name" \
"$target_dir/$release_update_name"
rm -rf \ rm -rf \
"$target_dir/jwebot_config" \ "$target_dir/jwebot_config" \
"$target_dir/jwebot_sdk" \ "$target_dir/jwebot_sdk" \
@@ -351,6 +359,7 @@ install -d -m 0700 \
"$target_dir/jwebot_config" \ "$target_dir/jwebot_config" \
"$target_dir/jwebot_sdk" \ "$target_dir/jwebot_sdk" \
"$target_dir/jwebot_kernel" \ "$target_dir/jwebot_kernel" \
"$target_dir/jwebot_kernel/data" \
"$target_dir/jwebot_official_extensions" \ "$target_dir/jwebot_official_extensions" \
"$target_dir/jwebot_official_extensions/adapters" \ "$target_dir/jwebot_official_extensions/adapters" \
"$target_dir/jwebot_official_extensions/data" \ "$target_dir/jwebot_official_extensions/data" \
@@ -364,6 +373,10 @@ done
for path in "$kernel_source_dir"/*.py; do for path in "$kernel_source_dir"/*.py; do
install -m 0600 "$path" "$target_dir/jwebot_kernel/$(basename "$path")" install -m 0600 "$path" "$target_dir/jwebot_kernel/$(basename "$path")"
done 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 for path in "$official_source_dir"/*.py; do
install -m 0600 "$path" "$target_dir/jwebot_official_extensions/$(basename "$path")" install -m 0600 "$path" "$target_dir/jwebot_official_extensions/$(basename "$path")"
done done
@@ -395,7 +408,8 @@ for name in \
"$adaptive_script_name" \ "$adaptive_script_name" \
"$adaptive_bridge_name" \ "$adaptive_bridge_name" \
"$edge_script_name" \ "$edge_script_name" \
"$edge_delivery_name" "$edge_delivery_name" \
"$release_update_name"
do do
"$runtime_python" -m py_compile "$target_dir/$name" "$runtime_python" -m py_compile "$target_dir/$name"
done done
+19 -7
View File
@@ -1,6 +1,6 @@
# JWeBot Command Catalog # JWeBot Command Catalog
Current baseline: `beta 1.0.0.1016`. Current baseline: `beta 2.0.0.1021`.
This catalog records the reviewed commands shipped with the built-in local This catalog records the reviewed commands shipped with the built-in local
extensions. Runtime ownership is frozen at startup from the generated extensions. Runtime ownership is frozen at startup from the generated
@@ -12,7 +12,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
|---|---|---| |---|---|---|
| `/功能列表` | `/help` | Show the command list. | | `/功能列表` | `/help` | Show the command list. |
| `/状态` | `/status` | Show local runtime status. | | `/状态` | `/status` | Show local runtime status. |
| `/version` | - | Show installed package versions plus the latest changelog date and summary. | | `/version` | `/版本` | Show installed package versions plus the latest changelog time and summary. |
| `/debug` | - | Show privacy-safe telemetry diagnostics. | | `/debug` | - | Show privacy-safe telemetry diagnostics. |
| `/余额` | - | Show configured direct-provider balance status. | | `/余额` | - | Show configured direct-provider balance status. |
@@ -33,7 +33,7 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| Command | Alias | Purpose | | Command | Alias | Purpose |
|---|---|---| |---|---|---|
| `/扫码排行` | - | Show scan ranking for the current group. | | `/扫码排行` | `/扫码榜`, `/今日扫码` | Show all scan candidates, today's counts, and next-hour scan weights for the current group. |
| `/复读榜` | - | Show repeat ranking for the current group. | | `/复读榜` | - | Show repeat ranking for the current group. |
| `/查记录 关键词` | - | Search local group history by keyword. | | `/查记录 关键词` | - | Search local group history by keyword. |
@@ -41,22 +41,34 @@ descriptor inventory; plugin code delegates to reviewed service profiles.
| Command | Alias | Purpose | | Command | Alias | Purpose |
|---|---|---| |---|---|---|
| `/金币` | `/wallet` | Initialize or show the current person's virtual coin account. | | `/摩拉` | `/金币`, `/原石`, `/wallet` | Initialize or show the current person's Mora and primogem account. |
| `/大富翁榜 [人数]` | `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped coin leaderboard. | | `/大富翁榜 [人数]` | `/摩拉排行`, `/摩拉排行榜`, `/金币排行`, `/金币排行榜`, `/richlist` | Show the group-scoped Mora leaderboard. |
| `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. | | `/转账 对方 金额` | `/transfer name amount` | Transfer virtual coins to an initialized group account. |
| `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. | | `/贷款 [金额]` | `/loan amount`, `/借款` | Borrow up to 500 virtual coins. |
| `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. | | `/还款 [金额|全部]` | `/repay [amount|all]` | Repay an active virtual coin loan. |
| `/抽奖 [金额]` | `/draw` | Play a single-player prize draw. | | `/原石祈愿` | `/祈愿`, `/wish` | Spend 1 primogem to draw 20-10000 Mora with expected value 3600 Mora, with a chance to gain a scan-boost ticket. |
| `/原石榜 [人数]` | `/primogem榜` | Show the group primogem leaderboard with source details. |
| `/扫码加权 人名` | `/扫码概率 人名` | 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` | 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 start` | Open a multiplayer lottery round. |
| `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. | | `/彩票 报名 数字 金额` | `/lottery join` | Join or update a lottery entry. |
| `/彩票 状态` | `/lottery status` | Show the current lottery round. | | `/彩票 状态` | `/lottery status` | Show the current lottery round. |
| `/彩票 开奖` | `/lottery draw` | Host-only draw for the current lottery round. | | `/彩票 开奖` | `/lottery draw` | Host-only draw for the current lottery round. |
| `/彩票 取消` | `/lottery cancel` | Host-only cancellation with refunds. | | `/彩票 取消` | `/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 hit` | Draw one blackjack card. |
| `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. | | `/21点 停牌` | `/blackjack stand` | Stand and settle against the dealer. |
| `/21点 状态` | `/blackjack status` | Show the current blackjack round. | | `/21点 状态` | `/blackjack status` | Show the current blackjack round. |
| `/21点 结束` | `/blackjack end` | End 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-2000 Mora. |
| `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. | | `/游戏记录 [页码]` | `/gamehistory` | Show indexed personal game history. |
| `/游戏帮助` | `/games` | Show local game rules. | | `/游戏帮助` | `/games` | Show local game rules. |
+9
View File
@@ -90,6 +90,15 @@ Steam uses `JWEBOT_EDGE_STEAM_ID64` and
- Host timeout and fallback; - Host timeout and fallback;
- GUI sender timeout. - GUI sender timeout.
The OCR runtime also accepts optional environment overrides for constrained
hosts:
- `WECHAT_OCR_ONNX_INTRA_OP_THREADS` defaults to `1`;
- `WECHAT_OCR_ONNX_INTER_OP_THREADS` defaults to `1`.
These values limit RapidOCR's ONNXRuntime CPU thread pools so service restarts
do not temporarily saturate the host while the OCR cache is cold.
### Debug ### Debug
`components/debug.toml` generates: `components/debug.toml` generates:
+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.
+9 -9
View File
@@ -48,7 +48,7 @@
每位玩家开局拥有: 每位玩家开局拥有:
- 生命值:`100` - 生命值:`100`
- 金币:`10` - 摩拉:`10`
- 教练:赛前第一轮选秀决定 - 教练:赛前第一轮选秀决定
- 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容 - 初始选手:赛前第二、三轮选秀决定,系统随后补齐阵容
- 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场 - 阵容位:固定 `5 个选手位 + 1 个教练位`,比赛时必须全部上场
@@ -206,7 +206,7 @@
### 7.2 教练获取 ### 7.2 教练获取
教练由第一轮公共选秀决定,本届赛事内不能用金币升级教练。不同教练 教练由第一轮公共选秀决定,本届赛事内不能用摩拉升级教练。不同教练
自带等级、属性倾向和全队加成。 自带等级、属性倾向和全队加成。
高费选手的商店概率随赛事进程推进,不再绑定教练等级。 高费选手的商店概率随赛事进程推进,不再绑定教练等级。
@@ -270,7 +270,7 @@
- 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。 - 第一轮教练选秀展示固定 10 名教练;第二、三轮选手选秀展示 6 名选手。
- 训练赛和正赛运营阶段默认展示一组 6 人商店。 - 训练赛和正赛运营阶段默认展示一组 6 人商店。
- 玩家可支付金币执行刷新。 - 玩家可支付摩拉执行刷新。
- 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。 - 玩家可锁定本轮 6 人,使下一轮仍保留同一组候选。
### 8.4 顺位 ### 8.4 顺位
@@ -294,7 +294,7 @@
## 10. 训练赛与赛事挑战 ## 10. 训练赛与赛事挑战
训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家 训练赛不再是阈值型 PvE。它是正式小组赛前的多轮真实对阵,用来给玩家
检验阵容、获取金币并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内 检验阵容、获取摩拉并自然插入 D 牌窗口。媒体日和赞助挑战仍作为赛事内
插曲出现,但只提供资源变动,不淘汰玩家。 插曲出现,但只提供资源变动,不淘汰玩家。
### 10.1 活动类型 ### 10.1 活动类型
@@ -308,11 +308,11 @@
训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛, 训练赛按参赛队伍生成 round-robin 轮次。四队测试局会有三轮训练赛,
每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。 每队在训练阶段遇到另外三支队伍;奇数队伍时轮空队会与本地陪练队热身。
训练赛只发放金币,不记录小组胜负,也不扣生命。 训练赛只发放摩拉,不记录小组胜负,也不扣生命。
每轮训练赛结束后进入运营窗口: 每轮训练赛结束后进入运营窗口:
- `/刷新` 即 D 牌,消耗金币换一组 6 人商店; - `/刷新` 即 D 牌,消耗摩拉换一组 6 人商店;
- `/选1~/选6` 买入商店选手,重复卡自动累积星级; - `/选1~/选6` 买入商店选手,重复卡自动累积星级;
- `/锁定` 保留当前商店; - `/锁定` 保留当前商店;
- `/上阵` 调整 5 人首发; - `/上阵` 调整 5 人首发;
@@ -321,10 +321,10 @@
### 10.3 训练赛结算逻辑 ### 10.3 训练赛结算逻辑
训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。 训练赛使用与正式比赛相同的单图 MR15 逐回合模拟,只是赛制为 BO1。
胜方获得更多金币,负方也有保底金币,保证测试局能继续运营。 胜方获得更多摩拉,负方也有保底摩拉,保证测试局能继续运营。
媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放 媒体日和赞助挑战根据阵容完整度、教练、战术和本地确定性随机因子发放
金币,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。 摩拉,但播报不展示“目标/发挥”这类计算日志,只写剧情和奖励。
## 11. PvP 赛制 ## 11. PvP 赛制
@@ -618,7 +618,7 @@ Jacky 选择了 zonic!
- AI 测试战队加入 - AI 测试战队加入
- 赛制与种子公布 - 赛制与种子公布
- 三轮选秀候选池、选择结果和选秀完成 - 三轮选秀候选池、选择结果和选秀完成
- 每轮训练赛对阵、比分、金币和运营提示 - 每轮训练赛对阵、比分、摩拉和运营提示
- 媒体日或赞助挑战 - 媒体日或赞助挑战
- 每场 BO3/BO5 对阵与结果 - 每场 BO3/BO5 对阵与结果
- 每张加时地图 - 每张加时地图
+4 -4
View File
@@ -4,7 +4,7 @@
1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。 1. 任意群友发送 `/彩票 开始`。发起人是本轮主持人。
2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100 2. 每个人发送 `/彩票 报名 3 4 7 100`,表示选择 3、4、7,下注 100
金币。也可简写为 `/彩票347100`;省略金额时默认下注 10。 摩拉。也可简写为 `/彩票347100`;省略金额时默认下注 10。
3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。 3. 发送 `/彩票 当前状态` 查看本轮主持人、报名人数、每个人的号码和下注。
4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字, 4. 主持人发送 `/彩票 开奖`。机器人从 0~9 中抽取三个互不重复的数字,
统一排序后结算。 统一排序后结算。
@@ -45,12 +45,12 @@
| 2 个 | 120% | | 2 个 | 120% |
| 3 个 | 300% | | 3 个 | 300% |
金币只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个 摩拉只使用整数;百分比计算的小数部分向下取整。例如下注 100,命中两个
返还 120,净变化为 `+20`。 返还 120,净变化为 `+20`。
## 个人局号与记录 ## 个人局号与记录
抽奖、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示 许愿池、彩票和 21 点分别维护本人的游戏局号。第一次参与某种游戏显示
`Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时, `Jacky #1:...`,下一把显示 `Jacky #2:...`。配置别名解析到同一人物时,
共用同一账户、局号和历史。 共用同一账户、局号和历史。
@@ -58,7 +58,7 @@
`/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、 `/gamehistory [page]` 查看最近记录,每页五条。记录包含游戏、个人局号、
结果、下注、返还和净变化,最多保留最近 50 条。 结果、下注、返还和净变化,最多保留最近 50 条。
21 点下注范围同步调整为 1~500。`/21点 开始 100` 只表示本局下注 100; 21 点下注范围现为 1~2000。`/21点 开始 100` 只表示本局下注 100;
发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。 发牌后再单独发送 `/21点 要牌` 或 `/21点 停牌`。
## 持久化与并发 ## 持久化与并发
+32 -20
View File
@@ -7,11 +7,11 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
| 插件 | 中文指令 | 英文指令 | | 插件 | 中文指令 | 英文指令 |
|---|---|---| |---|---|---|
| context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` | | context-summary | `/上下文 [100|200|300]` | `/context [100|200|300]` |
| single-player-games | `/金币` | `/wallet` | | single-player-games | `/摩拉`(兼容 `/金币`) | `/wallet` |
| coin-ranking | `/大富翁榜`、`/金币排行` | `/richlist` | | coin-ranking | `/大富翁榜`、`/摩拉排行`(兼容 `/金币排行`) | `/richlist` |
| coin-transfer | `/转账 对方 金额` | `/transfer name amount` | | coin-transfer | `/转账 对方 金额` | `/transfer name amount` |
| coin-loan | `/贷款 [金额]`、`/还款 [金额\|全部]` | `/loan amount`、`/repay [amount\|all]` | | 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 | `/彩票 开始/报名/当前状态/开奖/取消` | `/lottery start/join/status/draw/cancel` |
| single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) | | single-player-games | `/21点 开始 [金额]`(空格可省略) | `/blackjack start [金额]`(空格可省略) |
| single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` | | single-player-games | `/21点 要牌/停牌/状态/结束` | `/blackjack hit/stand/status/end` |
@@ -23,7 +23,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。 命令所有权来自 `descriptors.py`,由同步工具生成三套受审核 SDK adapter。
每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。 每个 adapter 只提交命名 service-profile 请求,由 Kernel 执行数据库操作。
注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额; 注册在启动期完成,运行期冻结。`/余额` 保持查询提供商余额;
游戏虚拟余额使用 `/金币`。 游戏虚拟余额使用 `/摩拉`,`/金币` 保留为兼容别名。
## 上下文总结 ## 上下文总结
@@ -43,7 +43,7 @@ OpenClaw;中文、英文命令对应同一实现,英文不区分大小写,
当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名 当前 OCR 输入只有显示名,没有微信原生账号 ID。Kernel 将昵称或别名
解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算 解析到私有配置中的 canonical_name,再以“当前群+规范人物名”计算
person_key。相同人物的已配置别名共用金币和牌局,其他群使用独立账户。 person_key。相同人物的已配置别名共用摩拉和牌局,其他群使用独立账户。
没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。 没有配置的显示名使用独立临时身份;未识别到发送人的消息不能操作账户。
`/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、 `/人物` 默认查看本人;指定参数时只查询已配置人物。输出包括展示名、
@@ -53,35 +53,38 @@ person_key。相同人物的已配置别名共用金币和牌局,其他群使
昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域; 昵称识别不是账号身份认证。修改 canonical_name 会改变当前人物作用域;
应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令 应优先追加别名而不是重命名。除 `/转账` 外,用户不能通过游戏命令
指定他人的账户;转账只支持同群虚拟金币账户,收款人需先发送 `/金币` 指定他人的账户;转账只支持同群虚拟摩拉账户,收款人需先发送 `/摩拉`
初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟金币不能充值、 或 `/金币` 初始化。`/贷款` 和 `/还款` 只作用于本人账户。虚拟摩拉不能充值、
赠币或兑换真实资产。 赠币或兑换真实资产。
## 虚拟金币与游戏管理 ## 虚拟摩拉与游戏管理
每个群内人物首次使用账户时一次性获得 1000 虚拟金币。再次查询、 每个群内人物首次使用账户时一次性获得 1000 虚拟摩拉。再次查询、
重启服务或切换已配置别名都不会重复领取。抽奖下注为 2~100,彩票和 重启服务或切换已配置别名都不会重复领取。许愿池投入为 1000~10000,
21 点下注为 1~500;余额不足或参数非法不扣款。 按 1000 摩拉递增;彩票下注为 1~500,21 点下注为 1~2000;
余额不足或参数非法不扣款。
`/转账 对方 金额` 会把本人的虚拟金币转给同群已初始化账户,英文为 `/转账 对方 金额` 会把本人的虚拟摩拉转给同群已初始化账户,英文为
`/transfer name amount`。金额范围 1~100000;不允许给自己转账。 `/transfer name amount`。金额范围 1~100000;不允许给自己转账。
转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id 转账、响应记录和重试去重写在同一个 ledger 事务内,同一 event_id
不会重复扣款。 不会重复扣款。
`/贷款 [金额]` 会给本人账户借入 1~500 金币;同一账户同一时间最多 `/贷款 [金额]` 会给本人账户借入 1~500 摩拉;同一账户同一时间最多
一笔未还贷款。`/还款 [金额|全部]` 从当前金币余额中扣除并减少未还 一笔未还贷款。`/还款 [金额|全部]` 从当前摩拉余额中扣除并减少未还
贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。 贷款,借款、还款、响应记录和重试去重同样写在一个 ledger 事务内。
`/大富翁榜` 或 `/金币排行 [人数]` 只读取当前群已有金币账户,不会创建 `/大富翁榜` 或 `/摩拉排行 [人数]`(兼容 `/金币排行`)只读取当前群已有摩拉账户,不会创建
调用者账户。榜单按当前金币余额降序排列,人数范围 1~20;账户存在 调用者账户。榜单按当前摩拉余额降序排列,人数范围 1~20;账户存在
未还贷款时会在名字后标注贷款本金。 未还贷款时会在名字后标注贷款本金。
抽奖默认下注 10:5% 概率返还五倍下注,20% 返还两倍,其余无返还。 `/许愿池 金额` 会把摩拉投入公共许愿池,每 1000 摩拉结算 1 愿;
`/许愿`、`/抽奖` 和 `/draw` 是同一功能入口。许愿可能获得摩拉回响、
扫码概率券、Taffy 发话额度、原石或池底大愿,8 愿没有道具类奖励后触发保底。
alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关, alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数字顺序无关,
下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见 下注范围 1~500,命中 1/2/3 个分别返还 50%/120%/300%。完整流程见
[多人彩票与游戏记录](group-lottery-0.6.3.md)。 [多人彩票与游戏记录](group-lottery-0.6.3.md)。
21 点默认下注 50、范围 1~500。`/21点 开始 100` 表示拿 100 金币作为本局下注进场, 21 点默认下注 50、范围 1~2000。`/21点 开始 100` 表示拿 100 摩拉作为本局下注进场,
等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌, 等价的 OCR 紧凑形式是 `/21点开始100`。开局会扣除下注并发两张牌,
然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在 然后根据牌面单独发送 `/21点 要牌` 或 `/21点 停牌`;开局与操作写在
同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌, 同一条消息时不会扣款,而会返回分步提示。牌局使用洗牌后的单副 52 张牌,
@@ -90,9 +93,18 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头, 仅针对本人牌局。发牌、状态和结算回复以当前群内人物的配置展示名开头,
例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、 例如 `jacky #1:你的牌 ...`;别名解析到同一人物时沿用相同展示名、
账户、局号和游戏记录。 账户、局号和游戏记录。
多人 21 点使用 `/21点 多人 启动` 创建 Taffy 牌桌,开桌者自动入座。
其他玩家在下注前用 `/21点 加入` 进入当前牌桌;主持人发送 `/21点 多人 开始`
后,Taffy 会按座位顺序提示每个人 `/21点 放200` 放筹码,范围 1~2000。
所有玩家下注后统一发牌,轮到本人时用 `/21点 要牌` 或 `/21点 停牌` 操作;
结算按座位顺序列出每个人的下注、返还、净变化和当前摩拉。
自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍, 自然 21 点立即结算,返还 2.5 倍下注向下取整;普通胜利返还两倍,
平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔 平局返还下注,失败不返还。每人最多一局;牌局进行中不接受另一笔
抽奖或 21 点开局,多人彩票报名仍可独立进行。 许愿池或 21 点开局,多人彩票报名仍可独立进行。
`/21点 胜率` 会列出当前群所有有已结算记录的玩家,以及 Taffy 的
胜/负/平、胜率和累计净赚净亏;主动认输和超时计负,牌桌取消不计入统计。
十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态 十分钟无有效游戏操作的牌局在下一次账户操作时按认输结束;查询状态
不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自 不延长有效期。主动结束同样按认输处理,不退下注。其他人可以各自
@@ -100,7 +112,7 @@ alpha 0.6.3 将旧的个人即时彩票替换为群内多人轮次。三位数
Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存: Kernel 用一个 `BEGIN IMMEDIATE` 事务同时保存:
1. `player_accounts` 中的金币、牌局和胜负统计; 1. `player_accounts` 中的摩拉、牌局和胜负统计;
2. `player_actions` 中的事件、命令及响应。 2. `player_actions` 中的事件、命令及响应。
同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更 同一群中的同一 event_id 只结算一次,重试返回原响应。相同事件若变更
+23
View File
@@ -18,6 +18,9 @@ exception.
- every package `__version__`; - every package `__version__`;
- `README.md`; - `README.md`;
- `CHANGELOG.md`. - `CHANGELOG.md`.
- The first bullet under each release heading in `CHANGELOG.md` must be one
concise, user-facing sentence. It is the canonical group announcement
summary.
- Do not mark production complete merely because code was committed. Production - Do not mark production complete merely because code was committed. Production
is complete only after staging, cutover, acceptance, observation, and backup is complete only after staging, cutover, acceptance, observation, and backup
restore checks pass. restore checks pass.
@@ -63,6 +66,26 @@ Before touching production runtime files on production host:
SQLite gates. SQLite gates.
- If any gate fails, confirm rollback state before retrying. - If any gate fails, confirm rollback state before retrying.
## Mandatory Post-Release Settlement
- After cutover and rollback gates pass, run the installed
`jwebot_release_update.py`.
- The settlement grants exactly 200 Mora to every account that exists at the
start of that release settlement and appends a version-labelled account
movement.
- Compensation uses the full release string as its idempotency key. Rerunning
the same release must not grant Mora twice.
- The tool sends one guarded announcement containing the version, the first
changelog bullet, and the `Mora x200` compensation notice.
- Announcement state is tracked separately from compensation. A missing or
uncertain GUI receipt must never roll back a healthy runtime or trigger
another grant.
- If delivery is uncertain, inspect the configured group before using
`--retry-uncertain-announcement`; never retry blindly.
- Production is not complete until the settlement reports success, SQLite
`quick_check` is `ok`, queues are empty, and service restart counts remain
stable.
## Backup And Restore ## Backup And Restore
- Pre-deploy and post-deploy baselines must include runtime files, systemd - Pre-deploy and post-deploy baselines must include runtime files, systemd
+28 -1
View File
@@ -73,7 +73,12 @@ The deployer prints an eight-stage progress bar and:
- leaves legacy OCR disabled; - leaves legacy OCR disabled;
- verifies Edge health and adaptive restart stability; - verifies Edge health and adaptive restart stability;
- writes and verifies backup SHA-256 checksums; - writes and verifies backup SHA-256 checksums;
- rolls back files, state, and unit topology on failure. - rolls back files, state, and unit topology on cutover failure;
- after rollback is disarmed, grants each existing account 200 Mora, records a
version-labelled movement, and sends the guarded group update announcement.
The first bullet in the current `CHANGELOG.md` release section is the canonical
one-sentence announcement summary. Keep it concise and user-facing.
## Post-Cutover ## Post-Cutover
@@ -104,6 +109,28 @@ PY
Player-visible acceptance must use deliberate synthetic messages in the Player-visible acceptance must use deliberate synthetic messages in the
configured group and confirm exactly one ACK and one final response. configured group and confirm exactly one ACK and one final response.
The deployer also runs the installed release settlement tool. Its successful
JSON result must report the expected account count and announcement delivery.
The operation is idempotent by the full release string, so rerunning the same
command never grants Mora twice:
```sh
/opt/jwebot/venv/bin/python \
~/.local/lib/wechat-health-watch/jwebot_release_update.py
```
If it reports `AnnouncementUncertainError`, inspect the configured group first.
Only when the announcement is confirmed absent may the operator retry:
```sh
/opt/jwebot/venv/bin/python \
~/.local/lib/wechat-health-watch/jwebot_release_update.py \
--retry-uncertain-announcement
```
Do not repeat the full deployment to repair an uncertain announcement. The
runtime cutover and version-keyed compensation may already be complete.
## Rollback ## Rollback
If any gate fails: If any gate fails:
+2 -2
View File
@@ -14,9 +14,9 @@ Rules:
- `X.Y.Z` is the project version. - `X.Y.Z` is the project version.
- `MMDD` is the delivery date. - `MMDD` is the delivery date.
- Python package versions use PEP 440 pre-release form. For beta builds, - Python package versions use PEP 440 pre-release form. For beta builds,
`beta 1.0.0.1016` maps to `1.0.0b1+1016`. `beta 1.0.1.1016` maps to `1.0.1b1+1016`.
- Normal small updates increment only the patch version, for example - Normal small updates increment only the patch version, for example
`beta 1.0.0.1016` to `beta 1.0.1.1016`. `beta 1.0.1.1016` to `beta 1.0.2.1016`.
- Minor, major, or stable-release changes require explicit user review and - Minor, major, or stable-release changes require explicit user review and
milestone approval. milestone approval.
- Local repository migration does not imply a production deployment. - Local repository migration does not imply a production deployment.
+4 -31
View File
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for coin-loan.""" """Generated reviewed adapter for coin-loan."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-loan' PROFILE = 'coin-loan'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '贷款' DESCRIPTION = '贷款'
HELP_ENTRIES = ('/贷款;/还款',) HELP_ENTRIES = ('/贷款;/还款',)
COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay') COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+2
View File
@@ -10,6 +10,8 @@ Help:
Registered commands: Registered commands:
- `/摩拉排行`
- `/摩拉排行榜`
- `/金币排行` - `/金币排行`
- `/金币排行榜` - `/金币排行榜`
- `/大富翁榜` - `/大富翁榜`
+1 -1
View File
@@ -8,7 +8,7 @@ entrypoint = "coin_ranking:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/金币排行", "/金币排行榜", "/大富翁榜", "/richlist"] commands = ["/摩拉排行", "/摩拉排行榜", "/金币排行", "/金币排行榜", "/大富翁榜", "/richlist"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["coin-ranking"] service_profiles = ["coin-ranking"]
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for coin-ranking.""" """Generated reviewed adapter for coin-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-ranking' PROFILE = 'coin-ranking'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '富榜' DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',) HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -21,8 +21,8 @@ class PluginTests(unittest.TestCase):
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '富榜') self.assertEqual(DESCRIPTION, '富榜')
self.assertEqual(HELP_ENTRIES, ('/大富翁榜',)) self.assertEqual(HELP_ENTRIES, ('/大富翁榜',))
self.assertIn('/金币排行', registered) self.assertIn('/摩拉排行', registered)
self.assertEqual(len(registered), 4) self.assertEqual(len(registered), 6)
if __name__ == "__main__": if __name__ == "__main__":
+19
View File
@@ -0,0 +1,19 @@
# coin-taffy
Author: `jwebot`
Taffy摩拉
Help:
- `/Taffy摩拉(兼容 /Taffy金币)`
Registered commands:
- `/Taffy摩拉`
- `/Taffy金币`
- `/taffycoins`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `coin-taffy` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "coin-taffy"
author = "jwebot"
description = "Taffy摩拉"
help = ["/Taffy摩拉(兼容 /Taffy金币)"]
version = "1.0.0"
api_version = "1"
entrypoint = "coin_taffy:plugin"
type = "command"
[capabilities]
commands = ["/Taffy摩拉", "/Taffy金币", "/taffycoins"]
storage = false
network_profiles = []
service_profiles = ["coin-taffy"]
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy摩拉'
HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',)
COMMANDS = ('/Taffy摩拉', '/Taffy金币', '/taffycoins')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from coin_taffy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, 'Taffy摩拉')
self.assertEqual(HELP_ENTRIES, ('/Taffy摩拉(兼容 /Taffy金币)',))
self.assertIn('/Taffy摩拉', registered)
self.assertEqual(len(registered), 3)
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -2,7 +2,7 @@
Author: `jwebot` Author: `jwebot`
金币转账 摩拉转账
Help: Help:
+1 -1
View File
@@ -1,6 +1,6 @@
id = "coin-transfer" id = "coin-transfer"
author = "jwebot" author = "jwebot"
description = "金币转账" description = "摩拉转账"
help = ["/转账 对方 金额;/transfer"] help = ["/转账 对方 金额;/transfer"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for coin-transfer.""" """Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-transfer' PROFILE = 'coin-transfer'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币转账' DESCRIPTION = '摩拉转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',) HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer') COMMANDS = ('/转账', '/transfer')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币转账') self.assertEqual(DESCRIPTION, '摩拉转账')
self.assertEqual(HELP_ENTRIES, ('/转账 对方 金额;/transfer',)) self.assertEqual(HELP_ENTRIES, ('/转账 对方 金额;/transfer',))
self.assertIn('/转账', registered) self.assertIn('/转账', registered)
self.assertEqual(len(registered), 2) self.assertEqual(len(registered), 2)
+4 -31
View File
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for coin.""" """Generated reviewed adapter for coin."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin' PROFILE = 'coin'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '抛一枚硬币' DESCRIPTION = '抛一枚硬币'
HELP_ENTRIES = ('/翻硬币',) HELP_ENTRIES = ('/翻硬币',)
COMMANDS = ('/翻硬币',) COMMANDS = ('/翻硬币',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for cs-major-autochess.""" """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' PROFILE = 'cs-major-autochess'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '自走棋' DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',) HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for daily-fortune.""" """Generated reviewed adapter for daily-fortune."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'daily-fortune' PROFILE = 'daily-fortune'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '生成当天稳定一致的运势' DESCRIPTION = '生成当天稳定一致的运势'
HELP_ENTRIES = ('/今日运势',) HELP_ENTRIES = ('/今日运势',)
COMMANDS = ('/今日运势',) COMMANDS = ('/今日运势',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+4 -31
View File
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for dice.""" """Generated reviewed adapter for dice."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'dice' PROFILE = 'dice'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '骰子和多人骰子挑战' DESCRIPTION = '骰子和多人骰子挑战'
HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]') HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]')
COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战') COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+4 -31
View File
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for draw-lot.""" """Generated reviewed adapter for draw-lot."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'draw-lot' PROFILE = 'draw-lot'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '抽取一条本地签文' DESCRIPTION = '抽取一条本地签文'
HELP_ENTRIES = ('/抽签',) HELP_ENTRIES = ('/抽签',)
COMMANDS = ('/抽签',) COMMANDS = ('/抽签',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+19
View File
@@ -0,0 +1,19 @@
# game-history
Author: `jwebot`
游戏记录
Help:
- `/游戏记录 /个人记录 /gamehistory`
Registered commands:
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `game-history` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "game-history"
author = "jwebot"
description = "游戏记录"
help = ["/游戏记录 /个人记录 /gamehistory"]
version = "1.0.0"
api_version = "1"
entrypoint = "game_history:plugin"
type = "command"
[capabilities]
commands = ["/游戏记录", "/个人记录", "/gamehistory"]
storage = false
network_profiles = []
service_profiles = ["game-history"]
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for game-history."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'game-history'
AUTHOR = 'jwebot'
DESCRIPTION = '游戏记录'
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from game_history import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '游戏记录')
self.assertEqual(HELP_ENTRIES, ('/游戏记录 /个人记录 /gamehistory',))
self.assertIn('/游戏记录', registered)
self.assertEqual(len(registered), 3)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for guess-number.""" """Generated reviewed adapter for guess-number."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'guess-number' PROFILE = 'guess-number'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '群内持久化猜数字游戏' DESCRIPTION = '群内持久化猜数字游戏'
HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',) HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',)
COMMANDS = ('/猜数字', '/猜') COMMANDS = ('/猜数字', '/猜')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for history-search.""" """Generated reviewed adapter for history-search."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'history-search' PROFILE = 'history-search'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '搜索本地群聊文字记录' DESCRIPTION = '搜索本地群聊文字记录'
HELP_ENTRIES = ('/查记录 关键词',) HELP_ENTRIES = ('/查记录 关键词',)
COMMANDS = ('/查记录', '/历史搜索') COMMANDS = ('/查记录', '/历史搜索')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,25 @@
# primogem-economy
Author: `jwebot`
原石系统
Help:
- `/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容`
Registered commands:
- `/原石祈愿`
- `/祈愿`
- `/wish`
- `/原石榜`
- `/primogem榜`
- `/扫码加权`
- `/扫码概率`
- `/Taffy发话`
- `/taffysay`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `primogem-economy` Kernel service profile
and returns a structured `ReplyPlan`.
@@ -0,0 +1,14 @@
id = "primogem-economy"
author = "jwebot"
description = "原石系统"
help = ["/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容"]
version = "1.0.0"
api_version = "1"
entrypoint = "primogem_economy:plugin"
type = "command"
[capabilities]
commands = ["/原石祈愿", "/祈愿", "/wish", "/原石榜", "/primogem榜", "/扫码加权", "/扫码概率", "/Taffy发话", "/taffysay"]
storage = false
network_profiles = []
service_profiles = ["primogem-economy"]
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for primogem-economy."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'primogem-economy'
AUTHOR = 'jwebot'
DESCRIPTION = '原石系统'
HELP_ENTRIES = ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',)
COMMANDS = ('/原石祈愿', '/祈愿', '/wish', '/原石榜', '/primogem榜', '/扫码加权', '/扫码概率', '/Taffy发话', '/taffysay')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from primogem_economy import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '原石系统')
self.assertEqual(HELP_ENTRIES, ('/原石祈愿 /原石榜;/扫码加权 人;/Taffy发话 [定时HH:MM] 内容',))
self.assertIn('/原石祈愿', registered)
self.assertEqual(len(registered), 9)
if __name__ == "__main__":
unittest.main()
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for random-number.""" """Generated reviewed adapter for random-number."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'random-number' PROFILE = 'random-number'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '在指定整数范围内取随机数' DESCRIPTION = '在指定整数范围内取随机数'
HELP_ENTRIES = ('/随机数 最小值 最大值',) HELP_ENTRIES = ('/随机数 最小值 最大值',)
COMMANDS = ('/随机数',) COMMANDS = ('/随机数',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for repeat-ranking.""" """Generated reviewed adapter for repeat-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'repeat-ranking' PROFILE = 'repeat-ranking'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '查看北京时间今日复读排行' DESCRIPTION = '查看北京时间今日复读排行'
HELP_ENTRIES = ('/复读榜',) HELP_ENTRIES = ('/复读榜',)
COMMANDS = ('/复读榜', '/复读') COMMANDS = ('/复读榜', '/复读')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for rock-paper-scissors.""" """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' PROFILE = 'rock-paper-scissors'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '多人石头剪刀布游戏' DESCRIPTION = '多人石头剪刀布游戏'
HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',) HELP_ENTRIES = ('/石头剪刀布 [开始|加入|出 选择|状态|结算|结束]',)
COMMANDS = ('/石头剪刀布',) COMMANDS = ('/石头剪刀布',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for scan-ranking.""" """Generated reviewed adapter for scan-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'scan-ranking' PROFILE = 'scan-ranking'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '查看北京时间今日扫码排行' DESCRIPTION = '查看北京时间今日扫码排行'
HELP_ENTRIES = ('/扫码排行',) HELP_ENTRIES = ('/扫码排行',)
COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码') COMMANDS = ('/扫码排行', '/扫码榜', '/今日扫码')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -2,21 +2,20 @@
Author: `jwebot` Author: `jwebot`
金币游戏 摩拉游戏
Help: Help:
- `/金币 /wallet;/游戏记录 /gamehistory` - `/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助`
- `/抽奖 [金额];/draw`
- `/彩票 开始|报名|状态|开奖|取消;/lottery`
- `/21点 开始 [1~500];/blackjack start`
- `/21点 要牌|停牌|状态|结束;/blackjack`
- `/游戏帮助;/games`
Registered commands: Registered commands:
- `/摩拉`
- `/金币` - `/金币`
- `/wallet` - `/wallet`
- `/原石`
- `/许愿池`
- `/许愿`
- `/抽奖` - `/抽奖`
- `/draw` - `/draw`
- `/彩票` - `/彩票`
@@ -25,9 +24,6 @@ Registered commands:
- `/21点` - `/21点`
- `/二十一点` - `/二十一点`
- `/blackjack` - `/blackjack`
- `/游戏记录`
- `/个人记录`
- `/gamehistory`
- `/游戏帮助` - `/游戏帮助`
- `/games` - `/games`
@@ -1,14 +1,14 @@
id = "single-player-games" id = "single-player-games"
author = "jwebot" author = "jwebot"
description = "金币游戏" description = "摩拉游戏"
help = ["/金币 /wallet;/游戏记录 /gamehistory", "/抽奖 [金额];/draw", "/彩票 开始|报名|状态|开奖|取消;/lottery", "/21点 开始 [1~500];/blackjack start", "/21点 要牌|停牌|状态|结束;/blackjack", "/游戏帮助;/games"] help = ["/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "single_player_games:plugin" entrypoint = "single_player_games:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/金币", "/wallet", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏记录", "/个人记录", "/gamehistory", "/游戏帮助", "/games"] commands = ["/摩拉", "/金币", "/wallet", "/原石", "/许愿池", "/许愿", "/抽奖", "/draw", "/彩票", "/买彩票", "/lottery", "/21点", "/二十一点", "/blackjack", "/游戏帮助", "/games"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["single-player-games"] service_profiles = ["single-player-games"]
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for single-player-games.""" """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' PROFILE = 'single-player-games'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币游戏' DESCRIPTION = '摩拉游戏'
HELP_ENTRIES = ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games') HELP_ENTRIES = ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',)
COMMANDS = ('/金币', '/wallet', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏记录', '/个人记录', '/gamehistory', '/游戏帮助', '/games') COMMANDS = ('/摩拉', '/金币', '/wallet', '/原石', '/许愿池', '/许愿', '/抽奖', '/draw', '/彩票', '/买彩票', '/lottery', '/21点', '/二十一点', '/blackjack', '/游戏帮助', '/games')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -19,10 +19,10 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'jwebot') self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '金币游戏') self.assertEqual(DESCRIPTION, '摩拉游戏')
self.assertEqual(HELP_ENTRIES, ('/金币 /wallet;/游戏记录 /gamehistory', '/抽奖 [金额];/draw', '/彩票 开始|报名|状态|开奖|取消;/lottery', '/21点 开始 [1~500];/blackjack start', '/21点 要牌|停牌|状态|结束;/blackjack', '/游戏帮助;/games')) self.assertEqual(HELP_ENTRIES, ('/摩拉/原石 /许愿池 /彩票 /21点 /游戏帮助',))
self.assertIn('/金币', registered) self.assertIn('/摩拉', registered)
self.assertEqual(len(registered), 15) self.assertEqual(len(registered), 16)
if __name__ == "__main__": if __name__ == "__main__":
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for steam-presence.""" """Generated reviewed adapter for steam-presence."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'steam-presence' PROFILE = 'steam-presence'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = 'Steam 状态与时长' DESCRIPTION = 'Steam 状态与时长'
HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',) HELP_ENTRIES = ('/steam;/steam时长 游戏[、玩家]',)
COMMANDS = ('/steam',) COMMANDS = ('/steam',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+18
View File
@@ -0,0 +1,18 @@
# trivia-quiz
Author: `jwebot`
冷门知识问答
Help:
- `/回答 A;/answer A`
Registered commands:
- `/回答`
- `/answer`
The adapter has no direct Ledger, GUI, environment, shell, or network access.
It delegates to the reviewed `trivia-quiz` Kernel service profile
and returns a structured `ReplyPlan`.
+14
View File
@@ -0,0 +1,14 @@
id = "trivia-quiz"
author = "jwebot"
description = "冷门知识问答"
help = ["/回答 A;/answer A"]
version = "1.0.0"
api_version = "1"
entrypoint = "trivia_quiz:plugin"
type = "command"
[capabilities]
commands = ["/回答", "/answer"]
storage = false
network_profiles = []
service_profiles = ["trivia-quiz"]
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for trivia-quiz."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'trivia-quiz'
AUTHOR = 'jwebot'
DESCRIPTION = '冷门知识问答'
HELP_ENTRIES = ('/回答 A;/answer A',)
COMMANDS = ('/回答', '/answer')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -0,0 +1,29 @@
import sys
import unittest
from pathlib import Path
from unittest import mock
SOURCE = Path(__file__).resolve().parents[1] / "src"
sys.path.insert(0, str(SOURCE))
from trivia_quiz import AUTHOR, DESCRIPTION, HELP_ENTRIES, plugin
class PluginTests(unittest.TestCase):
def test_registers_declared_commands_and_author(self):
context = mock.Mock()
plugin(context)
registered = [
call.args[0]
for call in context.commands.register.call_args_list
]
self.assertEqual(AUTHOR, 'jwebot')
self.assertEqual(DESCRIPTION, '冷门知识问答')
self.assertEqual(HELP_ENTRIES, ('/回答 A;/answer A',))
self.assertIn('/回答', registered)
self.assertEqual(len(registered), 2)
if __name__ == "__main__":
unittest.main()
@@ -2,7 +2,7 @@
Author: `official` Author: `official`
提取当前群最近100、200或300条本地文字的上下文 上下文总结
Help: Help:
@@ -1,6 +1,6 @@
id = "context-summary" id = "context-summary"
author = "official" author = "official"
description = "提取当前群最近100、200或300条本地文字的上下文" description = "上下文总结"
help = ["/上下文 [100|200|300];/context [100|200|300]"] help = ["/上下文 [100|200|300];/context [100|200|300]"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for context-summary.""" """Generated reviewed adapter for context-summary."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '提取当前群最近100、200或300条本地文字的上下文') self.assertEqual(DESCRIPTION, '上下文总结')
self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',)) self.assertEqual(HELP_ENTRIES, ('/上下文 [100|200|300];/context [100|200|300]',))
self.assertIn('/上下文', registered) self.assertIn('/上下文', registered)
self.assertEqual(len(registered), 2) self.assertEqual(len(registered), 2)
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for debug-telemetry.""" """Generated reviewed adapter for debug-telemetry."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'debug-telemetry' PROFILE = 'debug-telemetry'
@@ -8,38 +8,11 @@ AUTHOR = 'official'
DESCRIPTION = '埋点 p50/p95' DESCRIPTION = '埋点 p50/p95'
HELP_ENTRIES = ('/debug',) HELP_ENTRIES = ('/debug',)
COMMANDS = ('/debug',) COMMANDS = ('/debug',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+2 -1
View File
@@ -6,10 +6,11 @@ JWeBot 版本
Help: Help:
- `/version` - `/version /版本`
Registered commands: Registered commands:
- `/版本`
- `/version` - `/version`
The adapter has no direct Ledger, GUI, environment, shell, or network access. The adapter has no direct Ledger, GUI, environment, shell, or network access.
@@ -1,14 +1,14 @@
id = "jwebot-version" id = "jwebot-version"
author = "official" author = "official"
description = "JWeBot 版本" description = "JWeBot 版本"
help = ["/version"] help = ["/version /版本"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
entrypoint = "jwebot_version:plugin" entrypoint = "jwebot_version:plugin"
type = "command" type = "command"
[capabilities] [capabilities]
commands = ["/version"] commands = ["/版本", "/version"]
storage = false storage = false
network_profiles = [] network_profiles = []
service_profiles = ["jwebot-version"] service_profiles = ["jwebot-version"]
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for jwebot-version.""" """Generated reviewed adapter for jwebot-version."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'jwebot-version' PROFILE = 'jwebot-version'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = 'JWeBot 版本' DESCRIPTION = 'JWeBot 版本'
HELP_ENTRIES = ('/version',) HELP_ENTRIES = ('/version /版本',)
COMMANDS = ('/version',) COMMANDS = ('/版本', '/version')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -20,9 +20,9 @@ class PluginTests(unittest.TestCase):
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, 'JWeBot 版本') self.assertEqual(DESCRIPTION, 'JWeBot 版本')
self.assertEqual(HELP_ENTRIES, ('/version',)) self.assertEqual(HELP_ENTRIES, ('/version /版本',))
self.assertIn('/version', registered) self.assertIn('/版本', registered)
self.assertEqual(len(registered), 1) self.assertEqual(len(registered), 2)
if __name__ == "__main__": if __name__ == "__main__":
@@ -2,7 +2,7 @@
Author: `official` Author: `official`
查询当前群已配置人物、基本属性和别名 人物索引
Help: Help:
@@ -1,6 +1,6 @@
id = "person-directory" id = "person-directory"
author = "official" author = "official"
description = "查询当前群已配置人物、基本属性和别名" description = "人物索引"
help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"] help = ["/人物列表 [页码] /people;/人物 [名称或别名] /person"]
version = "1.0.0" version = "1.0.0"
api_version = "1" api_version = "1"
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for person-directory.""" """Generated reviewed adapter for person-directory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'person-directory' PROFILE = 'person-directory'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名' DESCRIPTION = '人物索引'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -19,7 +19,7 @@ class PluginTests(unittest.TestCase):
for call in context.commands.register.call_args_list for call in context.commands.register.call_args_list
] ]
self.assertEqual(AUTHOR, 'official') self.assertEqual(AUTHOR, 'official')
self.assertEqual(DESCRIPTION, '查询当前群已配置人物、基本属性和别名') self.assertEqual(DESCRIPTION, '人物索引')
self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)) self.assertEqual(HELP_ENTRIES, ('/人物列表 [页码] /people;/人物 [名称或别名] /person',))
self.assertIn('/人物列表', registered) self.assertIn('/人物列表', registered)
self.assertEqual(len(registered), 6) self.assertEqual(len(registered), 6)
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for person-memory.""" """Generated reviewed adapter for person-memory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'person-memory' PROFILE = 'person-memory'
@@ -8,38 +8,11 @@ AUTHOR = 'official'
DESCRIPTION = '管理个人和同群人物记忆' DESCRIPTION = '管理个人和同群人物记忆'
HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',) HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',)
COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助') COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for server-status.""" """Generated reviewed adapter for server-status."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'server-status' PROFILE = 'server-status'
@@ -8,38 +8,11 @@ AUTHOR = 'official'
DESCRIPTION = '服务器状态' DESCRIPTION = '服务器状态'
HELP_ENTRIES = ('/状态;/status',) HELP_ENTRIES = ('/状态;/status',)
COMMANDS = ('/状态', '/status') COMMANDS = ('/状态', '/status')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
+730
View File
@@ -0,0 +1,730 @@
#!/usr/bin/env python3
"""Apply the mandatory release lifecycle notices, grant, and announcement."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import sqlite3
import subprocess
import sys
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent
sys.path[:0] = [
str(ROOT / "packages" / "jwebot-official-extensions" / "src"),
str(ROOT / "packages" / "jwebot-sdk" / "src"),
]
from jwebot_official_extensions import __version__ as OFFICIAL_VERSION # noqa: E402
from jwebot_official_extensions.player_games import ( # noqa: E402
append_coin_event,
append_primogem_event,
normalize_player_account,
)
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
MAX_ANNOUNCEMENT_CHARS = 900
MAX_LIFECYCLE_CHARS = 300
LIFECYCLE_STAGES = frozenset({"shutdown", "startup"})
RELEASE_PATTERN = re.compile(
r"^beta (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)\.(?P<date>\d{4})$"
)
CHANGELOG_HEADING = re.compile(
r"^##\s+(?P<release>beta \d+\.\d+\.\d+\.\d{4})"
r"(?:\s+-\s+\d{4}-\d{2}-\d{2}(?:\s+\d{2}:\d{2}:\d{2})?)?\s*$"
)
class ReleaseUpdateError(RuntimeError):
"""Raised when release settlement cannot complete safely."""
class AnnouncementUncertainError(ReleaseUpdateError):
"""Raised when an announcement may have been sent without a final receipt."""
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def isoformat_utc(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat(
timespec="milliseconds"
).replace("+00:00", "Z")
def release_to_pep440(release: str) -> str:
match = RELEASE_PATTERN.fullmatch(release)
if match is None:
raise ReleaseUpdateError(f"unsupported release format: {release}")
return (
f"{match.group('major')}.{match.group('minor')}."
f"{match.group('patch')}b1+{match.group('date')}"
)
def read_release(path: Path) -> str:
try:
release = path.read_text(encoding="utf-8").strip()
except OSError as error:
raise ReleaseUpdateError("cannot read VERSION") from error
release_to_pep440(release)
return release
def read_changelog_summary(path: Path, release: str) -> str:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except OSError as error:
raise ReleaseUpdateError("cannot read CHANGELOG.md") from error
section_index: int | None = None
for index, line in enumerate(lines):
match = CHANGELOG_HEADING.fullmatch(line)
if match is not None and match.group("release") == release:
section_index = index
break
if section_index is None:
raise ReleaseUpdateError("release is missing from CHANGELOG.md")
parts: list[str] = []
collecting = False
for line in lines[section_index + 1 :]:
if line.startswith("## "):
break
if line.startswith("- "):
if collecting:
break
collecting = True
parts.append(line[2:].strip())
continue
if collecting:
if line.startswith(" "):
parts.append(line.strip())
continue
if not line.strip():
break
summary = " ".join(" ".join(parts).split())
if not summary:
raise ReleaseUpdateError("release changelog needs a first summary bullet")
if len(summary) > MAX_SUMMARY_CHARS:
raise ReleaseUpdateError("release changelog summary is too long")
return summary
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{mora}{extra}\n"
"补偿范围:截至本公告发布前已创建摩拉账户的玩家\n"
"发放方式:补偿已直接到账,无需领取\n"
"感谢大家对 Taffy 的支持与理解,祝大家游戏愉快!"
)
if len(announcement) > MAX_ANNOUNCEMENT_CHARS:
raise ReleaseUpdateError("release announcement is too long")
return announcement
def build_lifecycle_message(release: str, stage: str) -> str:
if stage == "shutdown":
if release == "beta 2.0.0.1021":
message = (
"Taffy 准备停机更新,本次更新是架构级大版本更新,"
f"预期版本{release},离线的时候保持耐心迎接Taffy的归来哦~"
)
else:
message = (
f"Taffy 准备停机更新到 {release},会短暂离线一下;"
"别慌,我很快回来喵"
)
elif stage == "startup":
message = (
f"Taffy 刚上线,正在做 {release} 的最后自检;"
"确认就绪后会发送更新内容和补偿说明喵"
)
else:
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
if len(message) > MAX_LIFECYCLE_CHARS:
raise ReleaseUpdateError("release lifecycle message is too long")
return message
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))
def announcement_key(release: str, group_fingerprint: str) -> str:
group_hash = hashlib.sha256(group_fingerprint.encode()).hexdigest()
return f"release_announcement:{release}:{group_hash}"
def release_slug(release: str) -> str:
return re.sub(r"[^a-zA-Z0-9]+", "-", release).strip("-").lower()
def release_groups(database_path: Path) -> list[str]:
connection = sqlite3.connect(f"file:{database_path}?mode=ro", uri=True)
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("Edge SQLite integrity check failed")
rows = connection.execute(
"""
SELECT DISTINCT group_fingerprint
FROM player_accounts
ORDER BY group_fingerprint
"""
).fetchall()
finally:
connection.close()
return [str(row[0]) for row in rows]
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)
except json.JSONDecodeError as error:
raise ReleaseUpdateError("release compensation marker is invalid") from error
if (
not isinstance(marker, dict)
or marker.get("release") != release
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)
):
raise ReleaseUpdateError("release compensation marker does not match")
return marker
def backup_database(
source: sqlite3.Connection,
backup_dir: Path,
release: str,
now: datetime,
) -> Path:
backup_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(backup_dir, 0o700)
stamp = now.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
destination = backup_dir / (
f"edge-before-{release_slug(release)}-compensation-{stamp}.sqlite3"
)
target = sqlite3.connect(destination)
try:
source.backup(target)
if target.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("release compensation backup failed integrity check")
finally:
target.close()
destination.chmod(0o600)
return destination
def load_compensation_marker(
connection: sqlite3.Connection,
release: str,
) -> dict[str, Any] | None:
row = connection.execute(
"SELECT value FROM metadata WHERE key = ?",
(compensation_key(release),),
).fetchone()
if row is None:
return None
return checked_marker(str(row[0]), release)
def apply_compensation(
database_path: Path,
backup_dir: Path,
release: str,
*,
now: datetime | None = None,
) -> tuple[dict[str, Any], str]:
applied_at = now or utc_now()
connection = sqlite3.connect(database_path, timeout=30)
connection.row_factory = sqlite3.Row
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("Edge SQLite integrity check failed")
existing = load_compensation_marker(connection, release)
if existing is not None:
return existing, "already_applied"
backup_path = backup_database(
connection,
backup_dir,
release,
applied_at,
)
connection.execute("BEGIN IMMEDIATE")
try:
existing = load_compensation_marker(connection, release)
if existing is not None:
connection.execute("COMMIT")
return existing, "already_applied"
rows = connection.execute(
"""
SELECT group_fingerprint, person_key, state_json
FROM player_accounts
ORDER BY group_fingerprint, person_key
"""
).fetchall()
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} 版本更新补偿"
primogem_reason = f"{release} 版本更新补偿"
for row in rows:
try:
state = json.loads(str(row["state_json"]))
except json.JSONDecodeError as error:
raise ReleaseUpdateError("Mora account state is invalid") from error
if not isinstance(state, dict):
raise ReleaseUpdateError("Mora account state is invalid")
normalize_player_account(state)
balance = state.get("balance")
if (
type(balance) is not int
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 + mora_amount
state["primogems"] += primogem_amount
append_coin_event(state, mora_amount, event_reason)
state["coin_events"][-1]["created_at"] = applied_at.timestamp()
if primogem_amount:
append_primogem_event(
state,
primogem_amount,
primogem_reason,
)
state["primogem_events"][-1]["created_at"] = (
applied_at.timestamp()
)
connection.execute(
"""
UPDATE player_accounts
SET state_json = ?, updated_at = ?
WHERE group_fingerprint = ? AND person_key = ?
""",
(
json.dumps(state, ensure_ascii=False),
timestamp,
row["group_fingerprint"],
row["person_key"],
),
)
marker = {
"release": release,
"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 + mora_amount * len(rows)
),
"old_primogem_sum": old_primogem_sum,
"new_primogem_sum": (
old_primogem_sum + primogem_amount * len(rows)
),
"applied_at": timestamp,
"backup": backup_path.name,
}
connection.execute(
"""
INSERT INTO metadata (key, value, updated_at)
VALUES (?, ?, ?)
""",
(
compensation_key(release),
json.dumps(marker, ensure_ascii=False, separators=(",", ":")),
timestamp,
),
)
connection.execute("COMMIT")
return marker, "applied"
except Exception:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
finally:
connection.close()
def metadata_value(
connection: sqlite3.Connection,
key: str,
) -> dict[str, Any] | None:
row = connection.execute(
"SELECT value FROM metadata WHERE key = ?",
(key,),
).fetchone()
if row is None:
return None
try:
value = json.loads(str(row[0]))
except json.JSONDecodeError as error:
raise ReleaseUpdateError("announcement marker is invalid") from error
if not isinstance(value, dict):
raise ReleaseUpdateError("announcement marker is invalid")
return value
def store_metadata(
connection: sqlite3.Connection,
key: str,
value: dict[str, Any],
updated_at: str,
) -> None:
connection.execute(
"""
INSERT INTO metadata (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE
SET value = excluded.value, updated_at = excluded.updated_at
""",
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":")),
updated_at,
),
)
def run_delivery_helper(
delivery_helper: Path,
group_fingerprint: str,
announcement: str,
event_id: str,
) -> None:
payload = {
"group_fingerprint": group_fingerprint,
"reply": announcement,
"event_id": event_id,
}
try:
result = subprocess.run(
[sys.executable, str(delivery_helper)],
input=json.dumps(payload, ensure_ascii=False),
text=True,
capture_output=True,
timeout=70,
check=False,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise AnnouncementUncertainError("announcement transport failed") from error
if result.returncode != 0:
raise AnnouncementUncertainError(
f"announcement helper failed with return code {result.returncode}"
)
def deliver_announcements(
database_path: Path,
release: str,
groups: list[str],
announcement: str,
delivery_helper: Path,
*,
retry_uncertain: bool = False,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
now: Callable[[], datetime] = utc_now,
) -> dict[str, int]:
delivered = 0
skipped = 0
for group in groups:
key = announcement_key(release, group)
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
connection = sqlite3.connect(database_path, timeout=30)
try:
existing = metadata_value(connection, key)
state = str(existing.get("state", "")) if existing else ""
if state == "delivered":
skipped += 1
continue
if state in {"sending", "uncertain"} and not retry_uncertain:
raise AnnouncementUncertainError(
"previous announcement delivery is uncertain; "
"visually confirm before using --retry-uncertain-announcement"
)
attempt = int(existing.get("attempt", 0)) + 1 if existing else 1
started_at = isoformat_utc(now())
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "sending",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
},
started_at,
)
finally:
connection.close()
event_id = f"release-{release_slug(release)}-{group_hash}"
try:
deliverer(
delivery_helper,
group,
announcement,
event_id,
)
except Exception as error:
failed_at = isoformat_utc(now())
connection = sqlite3.connect(database_path, timeout=30)
try:
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "uncertain",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
"failed_at": failed_at,
"error_type": type(error).__name__[:80],
},
failed_at,
)
finally:
connection.close()
if isinstance(error, ReleaseUpdateError):
raise
raise AnnouncementUncertainError(
"announcement delivery is uncertain"
) from error
completed_at = isoformat_utc(now())
connection = sqlite3.connect(database_path, timeout=30)
try:
with connection:
store_metadata(
connection,
key,
{
"release": release,
"state": "delivered",
"attempt": attempt,
"group_hash": group_hash,
"started_at": started_at,
"delivered_at": completed_at,
},
completed_at,
)
finally:
connection.close()
delivered += 1
return {"delivered": delivered, "skipped": skipped}
def run_release_update(
*,
release_file: Path,
changelog: Path,
database_path: Path,
backup_dir: Path,
delivery_helper: Path,
retry_uncertain: bool = False,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
applied_at: datetime | None = None,
clock: Callable[[], datetime] = utc_now,
) -> dict[str, Any]:
release = read_release(release_file)
expected_package_version = release_to_pep440(release)
if OFFICIAL_VERSION != expected_package_version:
raise ReleaseUpdateError(
"installed package version does not match VERSION"
)
summary = read_changelog_summary(changelog, release)
announcement = build_announcement(release, summary)
marker, compensation_state = apply_compensation(
database_path,
backup_dir,
release,
now=applied_at,
)
groups = [str(value) for value in marker["group_fingerprints"]]
announcement_result = deliver_announcements(
database_path,
release,
groups,
announcement,
delivery_helper,
retry_uncertain=retry_uncertain,
deliverer=deliverer,
now=clock,
)
connection = sqlite3.connect(
f"file:{database_path}?mode=ro",
uri=True,
)
try:
if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok":
raise ReleaseUpdateError("post-release SQLite integrity check failed")
finally:
connection.close()
return {
"status": "ok",
"release": release,
"compensation": compensation_state,
"amount_each": mora_compensation_amount(release),
"primogems_each": primogem_compensation_amount(release),
"accounts": int(marker["accounts"]),
"groups": len(groups),
"announcements_delivered": announcement_result["delivered"],
"announcements_skipped": announcement_result["skipped"],
"backup": str(marker["backup"]),
}
def run_release_lifecycle(
*,
release_file: Path,
database_path: Path,
delivery_helper: Path,
stage: str,
deliverer: Callable[[Path, str, str, str], None] = run_delivery_helper,
) -> dict[str, Any]:
if stage not in LIFECYCLE_STAGES:
raise ReleaseUpdateError(f"unsupported lifecycle stage: {stage}")
release = read_release(release_file)
message = build_lifecycle_message(release, stage)
groups = release_groups(database_path)
delivered = 0
for group in groups:
group_hash = hashlib.sha256(group.encode()).hexdigest()[:12]
event_id = f"release-{release_slug(release)}-{stage}-{group_hash}"
deliverer(delivery_helper, group, message, event_id)
delivered += 1
return {
"status": "ok",
"release": release,
"stage": stage,
"groups": len(groups),
"delivered": delivered,
}
def parse_args() -> argparse.Namespace:
home = Path.home()
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--release-file",
type=Path,
default=ROOT / "VERSION",
)
parser.add_argument(
"--changelog",
type=Path,
default=ROOT / "CHANGELOG.md",
)
parser.add_argument(
"--database",
type=Path,
default=home / ".local/state/jwebot-edge/edge.sqlite3",
)
parser.add_argument(
"--backup-dir",
type=Path,
default=home / ".local/state/jwebot/admin-backups",
)
parser.add_argument(
"--delivery-helper",
type=Path,
default=ROOT / "jwebot_edge_delivery.py",
)
parser.add_argument(
"--retry-uncertain-announcement",
action="store_true",
help="retry only after visually confirming that the prior send did not land",
)
parser.add_argument(
"--lifecycle-stage",
choices=sorted(LIFECYCLE_STAGES),
help="send a guarded lifecycle notice instead of release settlement",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
if args.lifecycle_stage:
result = run_release_lifecycle(
release_file=args.release_file,
database_path=args.database,
delivery_helper=args.delivery_helper,
stage=args.lifecycle_stage,
)
else:
result = run_release_update(
release_file=args.release_file,
changelog=args.changelog,
database_path=args.database,
backup_dir=args.backup_dir,
delivery_helper=args.delivery_helper,
retry_uncertain=args.retry_uncertain_announcement,
)
except ReleaseUpdateError as error:
print(
json.dumps(
{
"status": "error",
"error_type": type(error).__name__,
"message": str(error),
},
ensure_ascii=False,
),
file=sys.stderr,
)
return 1
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-config" name = "jwebot-config"
version = "1.0.0b1+1016" version = "2.0.5b1+1023"
description = "Profile-based configuration compiler for JWeBot" description = "Profile-based configuration compiler for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
@@ -36,4 +36,4 @@ __all__ = [
"write_default_profile", "write_default_profile",
] ]
__version__ = "1.0.0b1+1016" __version__ = "2.0.5b1+1023"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-extension-host" name = "jwebot-extension-host"
version = "1.0.0b1+1016" version = "2.0.5b1+1023"
description = "Single-release, single-invocation process host for JWeBot extensions" description = "Single-release, single-invocation process host for JWeBot extensions"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.0b1+1016", "jwebot-sdk==2.0.5b1+1023",
"jwebot-kernel==1.0.0b1+1016", "jwebot-kernel==2.0.5b1+1023",
] ]
[project.scripts] [project.scripts]
@@ -16,4 +16,4 @@ __all__ = [
"process_request", "process_request",
] ]
__version__ = "1.0.0b1+1016" __version__ = "2.0.5b1+1023"
+7 -4
View File
@@ -4,15 +4,18 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "jwebot-kernel" name = "jwebot-kernel"
version = "1.0.0b1+1016" version = "2.0.5b1+1023"
description = "Stable routing and extension authority for JWeBot" description = "Stable routing and extension authority for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-config==1.0.0b1+1016", "jwebot-config==2.0.5b1+1023",
"jwebot-sdk==1.0.0b1+1016", "jwebot-sdk==2.0.5b1+1023",
"jwebot-official-extensions==1.0.0b1+1016", "jwebot-official-extensions==2.0.5b1+1023",
"tomli>=2; python_version < '3.11'", "tomli>=2; python_version < '3.11'",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
[tool.setuptools.package-data]
jwebot_kernel = ["data/*.json"]
@@ -107,4 +107,4 @@ __all__ = [
"verify_installed_release_signature", "verify_installed_release_signature",
] ]
__version__ = "1.0.0b1+1016" __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] [project]
name = "jwebot-official-extensions" name = "jwebot-official-extensions"
version = "1.0.0b1+1016" version = "2.0.5b1+1023"
description = "Reviewed local command adapters and broker implementation for JWeBot" description = "Reviewed local command adapters and broker implementation for JWeBot"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"jwebot-sdk==1.0.0b1+1016", "jwebot-sdk==2.0.5b1+1023",
] ]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
@@ -90,4 +90,4 @@ __all__ = [
"render_feature_list", "render_feature_list",
] ]
__version__ = "1.0.0b1+1016" __version__ = "2.0.5b1+1023"
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for coin.""" """Generated reviewed adapter for coin."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin' PROFILE = 'coin'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '抛一枚硬币' DESCRIPTION = '抛一枚硬币'
HELP_ENTRIES = ('/翻硬币',) HELP_ENTRIES = ('/翻硬币',)
COMMANDS = ('/翻硬币',) COMMANDS = ('/翻硬币',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for coin-loan.""" """Generated reviewed adapter for coin-loan."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-loan' PROFILE = 'coin-loan'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '贷款' DESCRIPTION = '贷款'
HELP_ENTRIES = ('/贷款;/还款',) HELP_ENTRIES = ('/贷款;/还款',)
COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay') COMMANDS = ('/贷款', '/借款', '/loan', '/borrow', '/还款', '/repay')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for coin-ranking.""" """Generated reviewed adapter for coin-ranking."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-ranking' PROFILE = 'coin-ranking'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '富榜' DESCRIPTION = '富榜'
HELP_ENTRIES = ('/大富翁榜',) HELP_ENTRIES = ('/大富翁榜',)
COMMANDS = ('/金币排行', '/金币排行榜', '/大富翁榜', '/richlist') COMMANDS = ('/摩拉排行', '/摩拉排行榜', '/金币排行', '/金币排行榜', '/大富翁榜', '/richlist')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for coin-taffy."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-taffy'
AUTHOR = 'jwebot'
DESCRIPTION = 'Taffy摩拉'
HELP_ENTRIES = ('/Taffy摩拉(兼容 /Taffy金币)',)
COMMANDS = ('/Taffy摩拉', '/Taffy金币', '/taffycoins')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for coin-transfer.""" """Generated reviewed adapter for coin-transfer."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'coin-transfer' PROFILE = 'coin-transfer'
AUTHOR = 'jwebot' AUTHOR = 'jwebot'
DESCRIPTION = '金币转账' DESCRIPTION = '摩拉转账'
HELP_ENTRIES = ('/转账 对方 金额;/transfer',) HELP_ENTRIES = ('/转账 对方 金额;/transfer',)
COMMANDS = ('/转账', '/transfer') COMMANDS = ('/转账', '/transfer')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for context-summary.""" """Generated reviewed adapter for context-summary."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'context-summary' PROFILE = 'context-summary'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '提取当前群最近100、200或300条本地文字的上下文' DESCRIPTION = '上下文总结'
HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',) HELP_ENTRIES = ('/上下文 [100|200|300];/context [100|200|300]',)
COMMANDS = ('/上下文', '/context') COMMANDS = ('/上下文', '/context')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for cs-major-autochess.""" """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' PROFILE = 'cs-major-autochess'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '自走棋' DESCRIPTION = '自走棋'
HELP_ENTRIES = ('/自走棋 开始|报名|选',) HELP_ENTRIES = ('/自走棋 开始|报名|选',)
COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合') COMMANDS = ('/自走棋', '/cs自走棋', '/autochess', '/选', '/刷新', '/锁定', '/战术', '/升级教练', '/上阵', '/下阵', '/出售', '/阵容', '/赛程', '/结束回合')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for daily-fortune.""" """Generated reviewed adapter for daily-fortune."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'daily-fortune' PROFILE = 'daily-fortune'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '生成当天稳定一致的运势' DESCRIPTION = '生成当天稳定一致的运势'
HELP_ENTRIES = ('/今日运势',) HELP_ENTRIES = ('/今日运势',)
COMMANDS = ('/今日运势',) COMMANDS = ('/今日运势',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for debug-telemetry.""" """Generated reviewed adapter for debug-telemetry."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'debug-telemetry' PROFILE = 'debug-telemetry'
@@ -8,38 +8,11 @@ AUTHOR = 'official'
DESCRIPTION = '埋点 p50/p95' DESCRIPTION = '埋点 p50/p95'
HELP_ENTRIES = ('/debug',) HELP_ENTRIES = ('/debug',)
COMMANDS = ('/debug',) COMMANDS = ('/debug',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for dice.""" """Generated reviewed adapter for dice."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'dice' PROFILE = 'dice'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '骰子和多人骰子挑战' DESCRIPTION = '骰子和多人骰子挑战'
HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]') HELP_ENTRIES = ('/丢骰子 [NdM]', '/丢骰子挑战 [5d6|5d20|状态|结算|战绩|结束]')
COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战') COMMANDS = ('/丢骰子', '/骰子', '/丢骰子挑战', '/骰子挑战')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for draw-lot.""" """Generated reviewed adapter for draw-lot."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'draw-lot' PROFILE = 'draw-lot'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '抽取一条本地签文' DESCRIPTION = '抽取一条本地签文'
HELP_ENTRIES = ('/抽签',) HELP_ENTRIES = ('/抽签',)
COMMANDS = ('/抽签',) COMMANDS = ('/抽签',)
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -0,0 +1,18 @@
"""Generated reviewed adapter for game-history."""
from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'game-history'
AUTHOR = 'jwebot'
DESCRIPTION = '游戏记录'
HELP_ENTRIES = ('/游戏记录 /个人记录 /gamehistory',)
COMMANDS = ('/游戏记录', '/个人记录', '/gamehistory')
ADAPTER = ServiceCommandAdapter(
PROFILE,
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
def plugin(context: SingleGroupContext) -> None:
ADAPTER.register(context, COMMANDS)
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for guess-number.""" """Generated reviewed adapter for guess-number."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'guess-number' PROFILE = 'guess-number'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '群内持久化猜数字游戏' DESCRIPTION = '群内持久化猜数字游戏'
HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',) HELP_ENTRIES = ('/猜数字 开始 [上限];/猜 数字;/猜数字 [状态|结束]',)
COMMANDS = ('/猜数字', '/猜') COMMANDS = ('/猜数字', '/猜')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for history-search.""" """Generated reviewed adapter for history-search."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'history-search' PROFILE = 'history-search'
@@ -8,38 +8,11 @@ AUTHOR = 'jwebot'
DESCRIPTION = '搜索本地群聊文字记录' DESCRIPTION = '搜索本地群聊文字记录'
HELP_ENTRIES = ('/查记录 关键词',) HELP_ENTRIES = ('/查记录 关键词',)
COMMANDS = ('/查记录', '/历史搜索') COMMANDS = ('/查记录', '/历史搜索')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for jwebot-version.""" """Generated reviewed adapter for jwebot-version."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'jwebot-version' PROFILE = 'jwebot-version'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = 'JWeBot 版本' DESCRIPTION = 'JWeBot 版本'
HELP_ENTRIES = ('/version',) HELP_ENTRIES = ('/version /版本',)
COMMANDS = ('/version',) COMMANDS = ('/版本', '/version')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,45 +1,18 @@
"""Generated reviewed adapter for person-directory.""" """Generated reviewed adapter for person-directory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'person-directory' PROFILE = 'person-directory'
AUTHOR = 'official' AUTHOR = 'official'
DESCRIPTION = '查询当前群已配置人物、基本属性和别名' DESCRIPTION = '人物索引'
HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',) HELP_ENTRIES = ('/人物列表 [页码] /people;/人物 [名称或别名] /person',)
COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person') COMMANDS = ('/人物列表', '/别名列表', '/people', '/人物', '/个人属性', '/person')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))
@@ -1,6 +1,6 @@
"""Generated reviewed adapter for person-memory.""" """Generated reviewed adapter for person-memory."""
from jwebot_sdk import Event, ExtensionResult, ReplyPlan, SingleGroupContext from jwebot_sdk import ServiceCommandAdapter, SingleGroupContext
PROFILE = 'person-memory' PROFILE = 'person-memory'
@@ -8,38 +8,11 @@ AUTHOR = 'official'
DESCRIPTION = '管理个人和同群人物记忆' DESCRIPTION = '管理个人和同群人物记忆'
HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',) HELP_ENTRIES = ('/记住 内容;/我的记忆;/忘记 关键词或全部;/人物记忆 [显示名]',)
COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助') COMMANDS = ('/记住', '/我的记忆', '/忘记', '/人物记忆', '/记忆帮助')
ADAPTER = ServiceCommandAdapter(
def _handler(context: SingleGroupContext, command: str):
def handle(event: Event, arguments: str) -> ExtensionResult:
response = context.services.call(
PROFILE, 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}, metadata={"local_extension": PROFILE, "author": AUTHOR},
) )
text = response.get("text")
if not isinstance(text, str) or not text.strip():
raise ValueError("local service returned invalid text")
return ExtensionResult(
reply=ReplyPlan.text(text),
metadata={"local_extension": PROFILE, "author": AUTHOR},
)
return handle
def plugin(context: SingleGroupContext) -> None: def plugin(context: SingleGroupContext) -> None:
for command in COMMANDS: ADAPTER.register(context, COMMANDS)
context.commands.register(command, _handler(context, command))

Some files were not shown because too many files have changed in this diff Show More