Skip to content

插件开发 Cookbook:牛牛赞我

字数
2269 字
阅读时间
11 分钟

从零跟做一只 「牛牛赞我」 插件:群内点赞计数、赞榜、控制台可改文案与冷却。
适合第一次写 Pallas 插件;读完应能独立做「口令 + 配置 + 落盘 + 测试 + 文档」的完整闭环。

预计阅读:约 20 分钟(边读边敲约 1 小时)。

0、开始前

你需要已经

  • 本地开发环境 跑通 Bot(uv run nb run 能登录 /pallas/
  • 协议端已连 QQ,群里能触发 牛牛帮助
  • 编辑器推荐 VS Code;终端会在仓库根目录执行命令

本教程放哪

为不污染主仓 diff,插件放在 local/plugins/praise_me/(站点自有插件)。
若要贡献上游,完成后整体挪到 src/plugins/praise_me/ 并补 tests/plugins/praise_me/

config/pallas.toml 增加:

toml
[bootstrap]
extra_plugin_dirs = ["local/plugins"]

改完后 重启 Bot

我们要做什么

口令作用
牛牛赞我给当前牛牛 +1 赞(每人有冷却)
牛牛赞榜本群点赞 Top 5,并显示你有多少赞

控制台可改:是否启用、点赞冷却秒数、点赞成功回复文案。


1、规划目录

养成先画结构的习惯。本插件很轻,但把「入口 / 配置 / 存储 / 业务」分开,后面好维护。

text
local/plugins/praise_me/
├── __init__.py      # PluginMetadata + 注册 Matcher(保持短)
├── config.py        # Pydantic + WebUI 热重载
├── store.py         # 读写 data/praise_me/ 下的 JSON(纯函数,便于测)
└── handlers.py      # 三个命令的处理逻辑

在仓库根执行:

bash
mkdir -p local/plugins/praise_me

命名:包名 praise_me(小写+下划线);命令 ID 用 praise_me.动作


2、配置与 WebUI 热重载

先写 config.py。控制台保存后应 立即生效,不要重启。

python
# local/plugins/praise_me/config.py
from pydantic import BaseModel, Field

from src.console.webui import install_hot_reload_config


class Config(BaseModel, extra="ignore"):
    enable: bool = Field(default=True, description="是否启用点赞功能。")
    praise_cd_sec: int = Field(default=60, ge=0, description="同一用户两次「牛牛赞我」的最小间隔(秒)。")
    praise_reply: str = Field(
        default="谢谢夸奖!本群已收到你的 {total} 个赞~",
        description="点赞成功后的回复;可用 {total} 表示该用户在本群累计赞数。",
    )


plugin_webui = install_hot_reload_config(Config, config_module=__name__)
get_config = plugin_webui.get

要点:

  • Field(description=...) 会出现在 WebUI 表单里,写给部署者看的话要清楚。
  • 业务代码里 始终 get_config(),不要在模块顶层 cfg = get_config() 后一直用旧对象。

保存后可在控制台 插件 页找到本插件项;改 praise_reply 再发「牛牛赞我」应立刻看到新文案。详见 WebUI 插件配置


3、数据落盘(按群计数)

点赞数按 存 JSON,路径用 plugin_data_dir,不要手写 data/ 字符串。

python
# local/plugins/praise_me/store.py
from __future__ import annotations

import json
from pathlib import Path

from src.foundation.paths import plugin_data_dir


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


def load_counts(group_id: int) -> dict[str, int]:
    path = counts_path(group_id)
    if not path.is_file():
        return {}
    raw = json.loads(path.read_text(encoding="utf-8"))
    return {str(k): int(v) for k, v in raw.items()}


def save_counts(group_id: int, counts: dict[str, int]) -> None:
    path = counts_path(group_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(counts, ensure_ascii=False, indent=2), encoding="utf-8")


def add_praise(counts: dict[str, int], user_id: str) -> int:
    counts[user_id] = counts.get(user_id, 0) + 1
    return counts[user_id]


def top_praisers(counts: dict[str, int], limit: int = 5) -> list[tuple[str, int]]:
    pairs = sorted(counts.items(), key=lambda item: item[1], reverse=True)
    return pairs[:limit]

文件会落在 data/praise_me/groups/<群号>.json,备份时整目录拷走即可。


4、元数据、权限与帮助图

__init__.py 声明 PluginMetadata。帮助图文案格式见 cmd_perm

python
# local/plugins/praise_me/__init__.py
from nonebot import on_command
from nonebot.plugin import PluginMetadata

from src.features.cmd_perm import group_message_permission_for_command
from src.features.cmd_perm.metadata_defaults import (
    PLUGIN_EXTRA_VERSION,
    PLUGIN_HOMEPAGE,
    PLUGIN_MENU_TEMPLATE,
)
from src.features.cmd_perm.metadata_text import SCENE_GROUP, join_usage, usage_line

from .handlers import handle_praise, handle_rank

__plugin_meta__ = PluginMetadata(
    name="牛牛赞我",
    description="群内给牛牛点赞并查看赞榜。",
    usage=join_usage(
        usage_line("牛牛赞我", "给当前牛牛点赞"),
        usage_line("牛牛赞榜", "查看本群点赞排行"),
    ),
    type="application",
    homepage=PLUGIN_HOMEPAGE,
    supported_adapters={"~onebot.v11"},
    extra={
        "version": PLUGIN_EXTRA_VERSION,
        "menu_template": PLUGIN_MENU_TEMPLATE,
        "command_permissions": [
            {"id": "praise_me.praise", "label": "牛牛赞我", "default": "everyone"},
            {"id": "praise_me.rank", "label": "牛牛赞榜", "default": "everyone"},
        ],
        "command_limits": [
            {"id": "praise_me.praise", "cd_sec": 0},
        ],
        "menu_data": [
            {
                "func": "牛牛赞我",
                "trigger_method": "on_command",
                "trigger_scene": SCENE_GROUP,
                "trigger_condition": "牛牛赞我",
                "command_permission": "praise_me.praise",
                "brief_des": "给牛牛点赞",
                "detail_des": "为本群牛牛累计点赞;同一用户有冷却间隔,可在插件配置中修改。",
            },
            {
                "func": "牛牛赞榜",
                "trigger_method": "on_command",
                "trigger_scene": SCENE_GROUP,
                "trigger_condition": "牛牛赞榜",
                "command_permission": "praise_me.rank",
                "brief_des": "查看赞榜",
                "detail_des": "展示本群点赞 Top 5,并附带你本人的赞数。",
            },
        ],
    },
)

praise_cmd = on_command(
    "牛牛赞我",
    priority=5,
    block=True,
    permission=group_message_permission_for_command("praise_me.praise"),
)
rank_cmd = on_command(
    "牛牛赞榜",
    priority=5,
    block=True,
    permission=group_message_permission_for_command("praise_me.rank"),
)

praise_cmd.handle()(handle_praise)
rank_cmd.handle()(handle_rank)

注意:

  • 命令 ID praise_me.praise 在 metadata、matcher、command_limits必须一致
  • usage / trigger_condition 不要写「群管可用」——权限由 WebUI / cmd_perm 自动展示。
  • command_limitspraise 先填 0:冷却秒数改由配置驱动,在 handler 里用同一套 helper(下一节)。

5、命令处理与冷却

handlers.py 里接配置、CD、落盘。

python
# local/plugins/praise_me/handlers.py
from nonebot.adapters.onebot.v11 import GroupMessageEvent

from src.features.command_limits import is_command_cooldown_ready, refresh_command_cooldown

from .config import get_config
from .store import add_praise, load_counts, save_counts, top_praisers


async def handle_praise(event: GroupMessageEvent):
    from . import praise_cmd

    cfg = get_config()
    if not cfg.enable:
        await praise_cmd.finish("点赞功能已关闭。")

    cd = cfg.praise_cd_sec
    if cd > 0 and not await is_command_cooldown_ready(event, "praise_me.praise", cd):
        await praise_cmd.finish(f"点太快啦,{cd} 秒后再赞吧。")
    if cd > 0:
        await refresh_command_cooldown(event, "praise_me.praise", cd)

    gid = event.group_id
    uid = str(event.user_id)
    counts = load_counts(gid)
    total = add_praise(counts, uid)
    save_counts(gid, counts)

    await praise_cmd.finish(cfg.praise_reply.format(total=total))


async def handle_rank(event: GroupMessageEvent):
    from . import rank_cmd

    cfg = get_config()
    if not cfg.enable:
        await rank_cmd.finish("点赞功能已关闭。")

    gid = event.group_id
    uid = str(event.user_id)
    counts = load_counts(gid)
    mine = counts.get(uid, 0)
    top = top_praisers(counts, limit=5)

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

    lines = [f"{i}. QQ {qq}{n} 赞" for i, (qq, n) in enumerate(top, start=1)]
    body = "\n".join(lines)
    await rank_cmd.finish(f"本群赞榜 Top {len(top)}\n{body}\n\n你的赞数:{mine}")

说明:

  • 冷却is_command_cooldown_ready / refresh_command_cooldown 的 key 为 cmd_limit:{command_id},与 command_limits 一致。
  • 配置里的 praise_cd_sec 为 0 时不做 CD 检查。
  • 本插件只读用户口令,不接 message_scrub;复读/做梦类才需要。

怎样算成功

  1. 重启 Bot 后,群里 牛牛帮助 能看到「牛牛赞我」插件(若 help 有开关,确认本群已开启)。
  2. 牛牛赞我,Bot 回复带累计赞数。
  3. 连续发送应触发冷却提示(默认 60 秒)。
  4. 牛牛赞榜 能看到排行。
  5. 控制台改 praise_reply 保存后,再赞一次文案立即变化。

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

上面把 handler 写在 handlers.py__init__.py 里用 praise_cmd.handle()(handle_praise) 注册。
也可以直接在 __init__.py@praise_cmd.handle(),但教程刻意练习 入口轻量、业务外置,与 插件结构 一致。

Matcher 选型见 Matcher 决策树:口令型用 on_command + group_message_permission_for_command


7、测试

纯函数 store.py 最适合单测,不必每次起真 Bot。

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

python
from src.plugins.praise_me.store import add_praise, top_praisers


def test_add_praise_increments():
    counts = {}
    assert add_praise(counts, "10001") == 1
    assert add_praise(counts, "10001") == 2
    assert add_praise(counts, "10002") == 1


def test_top_praisers_order():
    counts = {"a": 3, "b": 9, "c": 1}
    assert top_praisers(counts, limit=2) == [("b", 9), ("a", 3)]

站点插件阶段可把 store.py 临时拷到可 import 路径,或只在本机手动测群消息。
合并主仓前务必:

bash
uv run ruff check src/
uv run ruff format --check src/
uv run pytest tests/plugins/praise_me/

8、用户向文档

docs/plugins/praise_me/README.md 写部署者能看懂的一页(可复制 TEMPLATE.md):

markdown
# 牛牛赞我(`praise_me`

群内给牛牛点赞并查看本群赞榜。

## 用户命令

| 口令 | 场景 | 说明 |
| --- | --- | --- |
| 牛牛赞我 | 群内 | 点赞,有冷却 |
| 牛牛赞榜 | 群内 | Top 5 与本人赞数 |

## 命令权限

| 命令 ID | 默认等级 |
| --- | --- |
| `praise_me.praise` | everyone |
| `praise_me.rank` | everyone |

## 配置

| 键 | 默认 | 说明 |
| --- | --- | --- |
| enable | true | 是否启用 |
| praise_cd_sec | 60 | 点赞冷却(秒) |
| praise_reply | (见 config.py) | 成功回复,支持 `{total}` |

## 实现

`local/plugins/praise_me/``src/plugins/praise_me/`

并在 plugins/README.md 索引里加一行。


9、提交前 checklist

对照 golden plugin checklist

  • __init__.py 短;config / store / handlers 已拆分
  • WebUI 热重载;handler 内 get_config()
  • 命令 ID 与 cmd_perm、matcher、command_limits 一致
  • usage / trigger_condition 无写死权限句
  • 路径用 plugin_data_dir
  • 有最小单测(至少 store
  • docs/plugins/praise_me/README.md 已写

提交流程:贡献与提交流程


10、还可以怎么增强

按兴趣选做,不必一次做完:

方向提示
私聊查赞再加 on_command + private_message_permission_for_command
全服榜src.foundation.db 做持久化,或汇总多群 JSON
帮助图样式保持 menu_data 与 metadata 同步即可
贡献主仓挪到 src/plugins/praise_me/,PR 附测试与插件文档
官方扩展包4.0 玩法类可走扩展仓 + uv sync --extra plugins-*

延伸阅读

文档内容
插件开发入门最小骨架速览
插件结构目录拆分原则
插件进阶cmd_perm、分片、AI 等横切
插件开发 Skill · 分章Matcher、scrub、测试专题
cmd_perm权限矩阵与帮助文案细则