Skip to content

Commit 6f52af8

Browse files
authored
Merge pull request #216 from PallasBot/feat/core-devx
feat(core-devx): plugin_sdk、pb_core 与开发体验路线 M0–M4
2 parents 4a79850 + c119046 commit 6f52af8

40 files changed

Lines changed: 1410 additions & 96 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ jobs:
2828

2929
- name: Install dependencies
3030
run: |
31-
uv sync --dev
31+
# core plugin matrix 测试会加载 kernel/LLM 与分片 coord(需 sqlalchemy、redis)
32+
uv sync --dev --extra pg --extra coord-redis
3233
3334
- name: Run Ruff linting
3435
run: |

docs/architecture/core-devx-roadmap.md

Lines changed: 394 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# 热重载分级(L1 / L2 / L3)
2+
3+
> [core-devx-roadmap · P3](core-devx-roadmap.md#p3--热重载分级) 对齐。
4+
> **现状**:L1 已成熟;L2/L3 以文档与 `reload_policy` 解析桩为主,CLI `pallas plugin reload` 尚未落地。
5+
6+
## 三级对照
7+
8+
| 级别 | 名称 | 变更内容 | 生效方式 | 现状 |
9+
| --- | --- | --- | --- | --- |
10+
| **L1** | 配置 | `config.py` 字段 → `webui.json` | `install_hot_reload_config` 保存后立即 reload | ✅ 默认路径 |
11+
| **L2** | 元数据 | `PluginMetadata.extra`、help 索引、ingress route | 保存后重读声明 / 重建索引(****卸载 matcher) | 部分(save hook);`reload_policy: metadata` 预留 |
12+
| **L3** | 插件代码 | Python 模块变更 | 受控 reload 或提示进程重启 | ❌ 默认需重启 |
13+
14+
## 明确不做
15+
16+
- NoneBot matcher 级热卸载/重载**不作为默认运维路径**(见 [pallas-cli.md](pallas-cli.md))。
17+
- 扩展 pip 包安装:`extension_install` 仍返回 `needs_restart`;与 **牛牛重启**`pb_core`)共用调度 API。
18+
19+
## `reload_policy`(extra 可选键)
20+
21+
`PluginMetadata.extra` 声明插件作者期望的重载粒度(供能力总览与未来 CLI 读取):
22+
23+
|| 含义 |
24+
| --- | --- |
25+
| `config_only` | 仅 L1(**默认**;与现网一致) |
26+
| `metadata` | L2:允许重读 extra / help / ingress,不卸载 matcher |
27+
| `full` | L3:尝试重载模块;失败则提示重启 |
28+
29+
解析 API:`src.features.plugin_reload.reload_policy_from_metadata()`
30+
31+
示例:
32+
33+
```python
34+
extra={
35+
...
36+
"reload_policy": "config_only",
37+
}
38+
```
39+
40+
## 运维入口
41+
42+
| 场景 | 推荐 |
43+
| --- | --- |
44+
| 改插件开关/阈值 | WebUI **插件** 页保存(L1) |
45+
| 改命令权限 / CD | WebUI **命令权限** / **命令冷却**(L1) |
46+
| 改 help / ingress 声明 | 暂需重启;L2 落地后按 `reload_policy` 提示 |
47+
| 改 Python 代码 | 重启 Bot;群内 **牛牛重启**`pallas restart` |
48+
| 安装官方扩展 | WebUI 插件商店;勾选「安装并重启」或手动重启 |
49+
50+
## 相关文档
51+
52+
- [WebUI 配置与热重载](../common/webui/README.md)
53+
- [settings-storage.md](settings-storage.md)
54+
- [core-devx-roadmap.md](core-devx-roadmap.md)

docs/architecture/plugin-convention.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@
5656

5757
## WebUI 配置与命令权限
5858

59+
- **`PluginMetadata.extra` 键名表**`command_permissions``plugin_storage``ingress_route` 等):见 [core-devx-roadmap.md · 内核键名约定](core-devx-roadmap.md#内核键名约定)
60+
- **新内核插件包名**`pb_<role>`(如 `pb_core``pb_webui``pb_protocol``pb` = Pallas-Bot,与 NoneBot 的 `nb` 对齐)。历史 `pallas_webui` / `pallas_protocol` 改名见 [core-devx-roadmap · P7](core-devx-roadmap.md#p7--历史插件-pb_-改名m5)
5961
- 需在 WebUI 保存 **`webui.json`** 后立即生效的插件配置:在 `config.py` 使用 `src.console.webui.install_hot_reload_config`(见 [WebUI 插件配置](../common/webui/README.md));已有自定义缓存的插件可登记到 `plugin_webui_registry`(如决斗插件)。
6062
- 可配置命令权限:在 `PluginMetadata.extra` 声明 `command_permissions`,matcher 使用 `src.features.cmd_perm.permission_for_command`(见 [cmd_perm](../common/cmd_perm/README.md))。
6163
- 命令冷却:在 handler 内使用 `src.features.command_limits`(见 [command_limits](../common/command_limits/README.md));可在 `extra["command_limits"]` 声明默认 CD 供文档与后续扩展。

docs/common/webui/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212

1313
命令权限说明见 [cmd_perm](../cmd_perm/README.md)
1414

15+
**热重载分级**(L1 配置 / L2 元数据 / L3 代码):见 [hot-reload-tiers.md](../../architecture/hot-reload-tiers.md)。插件可在 `extra["reload_policy"]` 声明期望粒度(默认 `config_only`)。
16+
1517
## 插件接入热重载
1618

1719
`config.py` 末尾:

docs/common/webui/api/02-plugins.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
| PUT | `/plugins/help-menu-visibility` || 更新帮助可见性 |
1010
| GET | `/plugins/global-disable` | | 全局禁用插件名集合 |
1111
| PUT | `/plugins/global-disable` || 批量禁用/启用(保护核心插件) |
12+
| GET | `/plugins/capabilities` | | 插件能力聚合(命令权限/CD、LLM tools、storage keys、`reload_policy`|
1213
| GET | `/plugins/group-fleet-whitelist` | | 群舰队白名单插件 |
1314
| PUT | `/plugins/group-fleet-whitelist` || 更新舰队白名单 |
1415

@@ -49,6 +50,7 @@ PUT 成功后:
4950
## 前端对应
5051

5152
- `fetchPlugins``fetchPluginConfig``putPluginConfig`
53+
- `fetchPluginCapabilities`(插件页「能力总览」与卡片预览)
5254
- `fetchHelpMenuVisibility``putHelpMenuVisibility`
5355
- `fetchGlobalPluginDisable``putGlobalPluginDisable`
5456
- `fetchGroupFleetWhitelist``putGroupFleetWhitelist`

docs/develop/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
| [插件规范](../architecture/plugin-convention.md) | `src/plugins` 约定 |
2424
| [站点定制](../architecture/site-customization-and-updates.md) | `local/plugins`、官方扩展 |
2525
| [4.0 路线图](../architecture/pallas-4.0-roadmap.md) · [瘦身迁移](../architecture/pallas-4.0-slim.md) | core / extra |
26+
| [Core 开发体验路线](../architecture/core-devx-roadmap.md) | plugin_sdk、`pb_core`、M5 `pb_webui`/`pb_protocol` 改名 |
27+
| [热重载分级](../architecture/hot-reload-tiers.md) | L1 配置 / L2 元数据 / L3 代码 |
2628
| [Bot ↔ AI 仓](../architecture/pallas-ai-service.md) | LLM 协同 |
2729
| [cmd_perm](../common/cmd_perm/README.md) · [command_limits](../common/command_limits/README.md) | 权限与冷却 |
2830
| [WebUI 配置](../common/webui/README.md) · [message_scrub](../common/message_scrub/README.md) | 热重载与审查 |

docs/develop/plugin/cookbook.md

Lines changed: 79 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ extra_plugin_dirs = ["local/plugins"]
4646
local/plugins/praise_me/
4747
├── __init__.py # PluginMetadata + 注册 Matcher(保持短)
4848
├── config.py # Pydantic + WebUI 热重载
49-
├── store.py # 读写 data/praise_me/ 下的 JSON(纯函数,便于测)
50-
└── handlers.py # 三个命令的处理逻辑
49+
├── store.py # 群级点赞计数(GroupPluginStorage + 纯函数,便于测)
50+
└── handlers.py # 命令处理逻辑
5151
```
5252

5353
在仓库根执行:
@@ -95,34 +95,32 @@ get_config = plugin_webui.get
9595

9696
## 3、数据落盘(按群计数)
9797

98-
点赞数按 **** 存 JSON,路径用 `plugin_data_dir`,不要手写 `data/` 字符串。
98+
群级结构化状态优先走 **声明式 `plugin_storage` + `GroupPluginStorage`**(写入 `GroupConfig` 文档的 `plugin_storage` 字段,与 help / duel 等同源)。
99+
**不要**再为这种小 JSON 手写 `data/<plugin>/groups/*.json``plugin_data_dir` 留给图片、导出、缓存等大文件(见 [插件结构 · 路径与持久化](structure.md))。
100+
101+
`store.py` 封装读写;纯函数 `add_praise` / `top_praisers` 便于单测:
99102

100103
```python
101104
# local/plugins/praise_me/store.py
102105
from __future__ import annotations
103106

104-
import json
105-
from pathlib import Path
106-
107-
from src.foundation.paths import plugin_data_dir
107+
from src.features.plugin_storage import GroupPluginStorage
108108

109+
PLUGIN_NAME = "praise_me"
110+
COUNTS_KEY = "praise_counts"
109111

110-
def counts_path(group_id: int) -> Path:
111-
return plugin_data_dir("praise_me") / "groups" / f"{group_id}.json"
112112

113-
114-
def load_counts(group_id: int) -> dict[str, int]:
115-
path = counts_path(group_id)
116-
if not path.is_file():
113+
async def load_counts(group_id: int) -> dict[str, int]:
114+
store = GroupPluginStorage(PLUGIN_NAME, group_id)
115+
raw = await store.get(COUNTS_KEY)
116+
if not isinstance(raw, dict):
117117
return {}
118-
raw = json.loads(path.read_text(encoding="utf-8"))
119118
return {str(k): int(v) for k, v in raw.items()}
120119

121120

122-
def save_counts(group_id: int, counts: dict[str, int]) -> None:
123-
path = counts_path(group_id)
124-
path.parent.mkdir(parents=True, exist_ok=True)
125-
path.write_text(json.dumps(counts, ensure_ascii=False, indent=2), encoding="utf-8")
121+
async def save_counts(group_id: int, counts: dict[str, int]) -> None:
122+
store = GroupPluginStorage(PLUGIN_NAME, group_id)
123+
await store.set(COUNTS_KEY, counts)
126124

127125

128126
def add_praise(counts: dict[str, int], user_id: str) -> int:
@@ -135,26 +133,38 @@ def top_praisers(counts: dict[str, int], limit: int = 5) -> list[tuple[str, int]
135133
return pairs[:limit]
136134
```
137135

138-
文件会落在 `data/praise_me/groups/<群号>.json`,备份时整目录拷走即可。
136+
要点:
137+
138+
- 存储键名 **`praise_counts`** 须在 `__init__.py``extra["plugin_storage"]` 声明(下一节一并写入);未声明键在运行时会报 `PluginStorageKeyError`
139+
- 备份/迁移时随群配置走库;WebUI **插件能力** API 可看到已声明的 storage keys。
139140

140141
---
141142

142143
## 4、元数据、权限与帮助图
143144

144145
`__init__.py` 声明 `PluginMetadata`。帮助图文案格式见 [cmd_perm](../../common/cmd_perm/README.md)
145146

147+
**推荐**[plugin_sdk](../../architecture/core-devx-roadmap.md#p1--plugin_sdk) 注册口令(权限 + 可选 CD 一次封装):
148+
146149
```python
147150
# local/plugins/praise_me/__init__.py
148-
from nonebot import on_command
149151
from nonebot.plugin import PluginMetadata
150152

151-
from src.features.cmd_perm import group_message_permission_for_command
152153
from src.features.cmd_perm.metadata_defaults import (
153154
PLUGIN_EXTRA_VERSION,
154155
PLUGIN_HOMEPAGE,
155156
PLUGIN_MENU_TEMPLATE,
156157
)
157158
from src.features.cmd_perm.metadata_text import SCENE_GROUP, join_usage, usage_line
159+
from src.features.plugin_sdk import (
160+
bind_alias_handlers,
161+
command_limit_list,
162+
command_limit_row,
163+
command_perm_list,
164+
command_perm_row,
165+
group_command,
166+
)
167+
from src.features.plugin_storage import plugin_storage_list, plugin_storage_row
158168

159169
from .handlers import handle_praise, handle_rank
160170

@@ -171,13 +181,16 @@ __plugin_meta__ = PluginMetadata(
171181
extra={
172182
"version": PLUGIN_EXTRA_VERSION,
173183
"menu_template": PLUGIN_MENU_TEMPLATE,
174-
"command_permissions": [
175-
{"id": "praise_me.praise", "label": "牛牛赞我", "default": "everyone"},
176-
{"id": "praise_me.rank", "label": "牛牛赞榜", "default": "everyone"},
177-
],
178-
"command_limits": [
179-
{"id": "praise_me.praise", "cd_sec": 0},
180-
],
184+
"command_permissions": command_perm_list(
185+
command_perm_row("praise_me.praise", "牛牛赞我", "everyone"),
186+
command_perm_row("praise_me.rank", "牛牛赞榜", "everyone"),
187+
),
188+
"command_limits": command_limit_list(
189+
command_limit_row("praise_me.praise", 0),
190+
),
191+
"plugin_storage": plugin_storage_list(
192+
plugin_storage_row("praise_counts", scope="group", label="群内点赞计数"),
193+
),
181194
"menu_data": [
182195
{
183196
"func": "牛牛赞我",
@@ -201,28 +214,20 @@ __plugin_meta__ = PluginMetadata(
201214
},
202215
)
203216

204-
praise_cmd = on_command(
205-
"牛牛赞我",
206-
priority=5,
207-
block=True,
208-
permission=group_message_permission_for_command("praise_me.praise"),
209-
)
210-
rank_cmd = on_command(
211-
"牛牛赞榜",
212-
priority=5,
213-
block=True,
214-
permission=group_message_permission_for_command("praise_me.rank"),
215-
)
217+
praise_cmd = group_command("praise_me.praise", "牛牛赞我", cd_sec=0)
218+
rank_cmd = group_command("praise_me.rank", "牛牛赞榜", cd_sec=0)
216219

217-
praise_cmd.handle()(handle_praise)
218-
rank_cmd.handle()(handle_rank)
220+
bind_alias_handlers(praise_cmd, handle_praise)
221+
bind_alias_handlers(rank_cmd, handle_rank)
219222
```
220223

224+
仍可直接 `on_command` + `group_message_permission_for_command`(见 [getting-started](getting-started.md));新插件优先 SDK。
225+
221226
注意:
222227

223228
- 命令 ID `praise_me.praise` 在 metadata、matcher、`command_limits`**必须一致**
224229
- `usage` / `trigger_condition` **不要**写「群管可用」——权限由 WebUI / cmd_perm 自动展示。
225-
- `command_limits``praise` 先填 `0`冷却秒数改由配置驱动,在 handler 里用同一套 helper(下一节)。
230+
- `command_limits``praise` `0`默认 CD 由配置 `praise_cd_sec` 驱动,在 handler 里显式检查(下一节)。
226231

227232
---
228233

@@ -232,60 +237,60 @@ rank_cmd.handle()(handle_rank)
232237

233238
```python
234239
# local/plugins/praise_me/handlers.py
235-
from nonebot.adapters.onebot.v11 import GroupMessageEvent
236-
237240
from src.features.command_limits import is_command_cooldown_ready, refresh_command_cooldown
241+
from src.features.plugin_sdk import PluginHandlerContext
238242

239243
from .config import get_config
240244
from .store import add_praise, load_counts, save_counts, top_praisers
241245

242246

243-
async def handle_praise(event: GroupMessageEvent):
244-
from . import praise_cmd
245-
247+
async def handle_praise(ctx: PluginHandlerContext) -> None:
246248
cfg = get_config()
247249
if not cfg.enable:
248-
await praise_cmd.finish("点赞功能已关闭。")
250+
await ctx.finish("点赞功能已关闭。")
249251

250252
cd = cfg.praise_cd_sec
251-
if cd > 0 and not await is_command_cooldown_ready(event, "praise_me.praise", cd):
252-
await praise_cmd.finish(f"点太快啦,{cd} 秒后再赞吧。")
253+
if cd > 0 and not await is_command_cooldown_ready(ctx.event, "praise_me.praise", cd):
254+
await ctx.finish(f"点太快啦,{cd} 秒后再赞吧。")
253255
if cd > 0:
254-
await refresh_command_cooldown(event, "praise_me.praise", cd)
256+
await refresh_command_cooldown(ctx.event, "praise_me.praise", cd)
255257

256-
gid = event.group_id
257-
uid = str(event.user_id)
258-
counts = load_counts(gid)
258+
gid = ctx.group_id
259+
if gid is None:
260+
return
261+
uid = ctx.user_id
262+
counts = await load_counts(gid)
259263
total = add_praise(counts, uid)
260-
save_counts(gid, counts)
261-
262-
await praise_cmd.finish(cfg.praise_reply.format(total=total))
264+
await save_counts(gid, counts)
263265

266+
await ctx.finish(cfg.praise_reply.format(total=total))
264267

265-
async def handle_rank(event: GroupMessageEvent):
266-
from . import rank_cmd
267268

269+
async def handle_rank(ctx: PluginHandlerContext) -> None:
268270
cfg = get_config()
269271
if not cfg.enable:
270-
await rank_cmd.finish("点赞功能已关闭。")
272+
await ctx.finish("点赞功能已关闭。")
271273

272-
gid = event.group_id
273-
uid = str(event.user_id)
274-
counts = load_counts(gid)
274+
gid = ctx.group_id
275+
if gid is None:
276+
return
277+
uid = ctx.user_id
278+
counts = await load_counts(gid)
275279
mine = counts.get(uid, 0)
276280
top = top_praisers(counts, limit=5)
277281

278282
if not top:
279-
await rank_cmd.finish("还没有人赞过牛牛,发送「牛牛赞我」抢第一个吧。")
283+
await ctx.finish("还没有人赞过牛牛,发送「牛牛赞我」抢第一个吧。")
280284

281285
lines = [f"{i}. QQ {qq}{n}" for i, (qq, n) in enumerate(top, start=1)]
282286
body = "\n".join(lines)
283-
await rank_cmd.finish(f"本群赞榜 Top {len(top)}\n{body}\n\n你的赞数:{mine}")
287+
await ctx.finish(f"本群赞榜 Top {len(top)}\n{body}\n\n你的赞数:{mine}")
284288
```
285289

286290
说明:
287291

288-
- **冷却**`is_command_cooldown_ready` / `refresh_command_cooldown` 的 key 为 `cmd_limit:{command_id}`,与 [command_limits](../../common/command_limits/README.md) 一致。
292+
- **存储**`GroupPluginStorage` 读写须在 metadata 声明键;与 [command_limits](../../common/command_limits/README.md) 一样纳入能力总览。
293+
- **冷却**`is_command_cooldown_ready` / `refresh_command_cooldown` 的 key 为 `cmd_limit:{command_id}`
289294
- 配置里的 `praise_cd_sec` 为 0 时不做 CD 检查。
290295
- 本插件只读用户口令,不接 [message_scrub](../../common/message_scrub/README.md);复读/做梦类才需要。
291296

@@ -301,16 +306,16 @@ async def handle_rank(event: GroupMessageEvent):
301306

302307
## 6、拆文件与注册方式(小结)
303308

304-
上面把 handler 写在 `handlers.py``__init__.py` 里用 `praise_cmd.handle()(handle_praise)` 注册。
309+
上面把 handler 写在 `handlers.py``__init__.py` 里用 `bind_alias_handlers` 注册。
305310
也可以直接在 `__init__.py``@praise_cmd.handle()`,但教程刻意练习 **入口轻量、业务外置**,与 [插件结构](structure.md) 一致。
306311

307-
Matcher 选型见 [Matcher 决策树](../../skills/pallas-plugin-development/references/02-matchers-decision.md):口令型用 `on_command` + `group_message_permission_for_command`
312+
口令型优先 [plugin_sdk](../../architecture/core-devx-roadmap.md#p1--plugin_sdk)`group_command`选型见 [Matcher 决策树](../../skills/pallas-plugin-development/references/02-matchers-decision.md)
308313

309314
---
310315

311316
## 7、测试
312317

313-
纯函数 `store.py` 最适合单测,不必每次起真 Bot。
318+
`store.py` 里的纯函数最适合单测,不必每次起真 Bot`GroupPluginStorage` 集成测法见 `tests/features/test_plugin_storage.py`
314319

315320
`tests/plugins/praise_me/test_store.py`(贡献主仓时):
316321

@@ -388,9 +393,10 @@ uv run pytest tests/plugins/praise_me/
388393
- [ ] `__init__.py` 短;`config` / `store` / `handlers` 已拆分
389394
- [ ] WebUI 热重载;handler 内 `get_config()`
390395
- [ ] 命令 ID 与 cmd_perm、matcher、`command_limits` 一致
396+
- [ ] 群/用户结构化状态:`extra["plugin_storage"]` + `GroupPluginStorage`(Cookbook §3–§4)
391397
- [ ] `usage` / `trigger_condition` 无写死权限句
392-
- [ ] 路径用 `plugin_data_dir`
393-
- [ ] 有最小单测(至少 `store`
398+
- [ ] 大文件/缓存才用 `plugin_data_dir` / `resource_dir`
399+
- [ ] 有最小单测(至少 `store` 纯函数
394400
- [ ] `docs/plugins/praise_me/README.md` 已写
395401

396402
提交流程:[贡献与提交流程](../workflow.md)
@@ -404,7 +410,7 @@ uv run pytest tests/plugins/praise_me/
404410
| 方向 | 提示 |
405411
| --- | --- |
406412
| 私聊查赞 | 再加 `on_command` + `private_message_permission_for_command` |
407-
| 全服榜 |`src.foundation.db` 做持久化,或汇总多群 JSON |
413+
| 全服榜 |`src.foundation.db` 做持久化,或 deploy 级 `plugin_storage` |
408414
| 帮助图样式 | 保持 `menu_data` 与 metadata 同步即可 |
409415
| 贡献主仓 | 挪到 `src/plugins/praise_me/`,PR 附测试与插件文档 |
410416
| 官方扩展包 | 4.0 玩法类可走扩展仓 + `uv sync --extra plugins-*` |

0 commit comments

Comments
 (0)