@@ -46,8 +46,8 @@ extra_plugin_dirs = ["local/plugins"]
4646local/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
102105from __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
128126def 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
149151from nonebot.plugin import PluginMetadata
150152
151- from src.features.cmd_perm import group_message_permission_for_command
152153from src.features.cmd_perm.metadata_defaults import (
153154 PLUGIN_EXTRA_VERSION ,
154155 PLUGIN_HOMEPAGE ,
155156 PLUGIN_MENU_TEMPLATE ,
156157)
157158from 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
159169from .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-
237240from src.features.command_limits import is_command_cooldown_ready, refresh_command_cooldown
241+ from src.features.plugin_sdk import PluginHandlerContext
238242
239243from .config import get_config
240244from .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