结构调整

视频解析多图/多媒体结构 消息体适配
This commit is contained in:
2026-09-08 14:25:32 +08:00
parent 30899688a7
commit 131b92b319
72 changed files with 433 additions and 186 deletions
+18
View File
@@ -0,0 +1,18 @@
from nonebot import get_driver, logger
# 显式导入子模块注册 matcher/工具(hexi/core 为机器人核心模块,非 NoneBot 插件)
from . import ( # noqa: E402,F401
cooldown,
custom_utils,
message_utils,
plugin_control,
plugin_manager,
rate_limit,
)
@get_driver().on_startup
async def _startup_plugin_gate() -> None:
"""等所有插件 matcher 注册完成后,把统一 filter 规则注入到 application 插件。"""
n = plugin_control.instrument_plugin_gate()
logger.info(f"插件控制面: 已注入 {n} 条 gateway 规则")
+132
View File
@@ -0,0 +1,132 @@
"""命令冷却(CD):给命令触发器加冷却,支持按用户/按群/全局三种作用域
基于 rate_limit 的令牌桶实现(rate=1/seconds, capacity=1):
触发一次取走唯一令牌,seconds 秒内无法再次触发。冷却键由 scope 决定:
- "user"(默认):按用户 QQ 号 → 同一用户群聊/私聊共用同一 CD(同 nonebot1 传 user_id)
- "group":按群共享 → 全群一个 CD(同 nonebot1 传 group_id),私聊退化为按用户
- "global":所有人共享一个 CD
用法一(装饰器,包在 on_command 外面):
from hexi.core.cooldown import cooldown
# 单用户 10s CD,群聊/私聊共用
bf_cmd = cooldown(10, hint="查询太频繁了,{secs} 秒后再试")(on_command("bf3"))
# 全群共享 60s CD
daily_cmd = cooldown(60, scope="group")(on_command("打卡"))
@bf_cmd.handle()
async def handler(bot: Bot, ev: MessageEvent):
...
用法二(手动查询/触发,自定义提示文本):
from hexi.core.cooldown import Cooldown
cd = Cooldown(10)
if cd.in_cd(ev.get_user_id()):
await matcher.finish(f"冷却中,还剩 {cd.remaining(ev.get_user_id()):.0f} 秒")
cd.try_trigger(ev.get_user_id())
"""
import itertools
import math
from collections.abc import Callable
from typing import Any, Literal
from nonebot.adapters import Event
from nonebot.dependencies import Dependent
from nonebot.matcher import Matcher
from hexi.core.rate_limit import RateLimiter, get_limiter
# 无 name 时的实例序号,保证同一进程内各实例的注册表 key 互不冲突
_cd_seq = itertools.count(1)
Scope = Literal["user", "group", "global"]
def _scope_key(scope: Scope) -> Callable[[Event], str]:
"""按冷却作用域生成默认冷却键函数(与 key 参数二选一)"""
if scope == "group":
def key(event: Event) -> str:
# 群聊按群共享;私聊没有群,退化为按用户
group_id = getattr(event, "group_id", None)
if group_id is not None:
return f"group:{group_id}"
return f"user:{event.get_user_id()}"
return key
if scope == "global":
return lambda event: "global"
return lambda event: f"user:{event.get_user_id()}"
class Cooldown:
"""每用户命令冷却器:同一用户在群聊/私聊共用同一 CD(默认按键为用户 QQ 号)"""
def __init__(self, seconds: float, *, name: str | None = None):
if seconds <= 0:
raise ValueError(f"seconds 必须大于 0,收到 {seconds!r}")
self.seconds = float(seconds)
self.name = name
self._rate = 1.0 / self.seconds
def _limiter(self, key: str) -> RateLimiter:
# 复用令牌桶:rate=1/seconds、capacity=1 → 取走唯一令牌后 seconds 秒内无法再取
if self.name is None:
self.name = f"anon:{next(_cd_seq)}" # 惰性固化,保证 key 稳定
return get_limiter(f"cd:{self.name}:{key}", rate=self._rate, capacity=1)
def try_trigger(self, key: str) -> bool:
"""尝试触发一次:冷却中返回 False,否则记录触发时刻并返回 True"""
return self._limiter(key).try_acquire()
def in_cd(self, key: str) -> bool:
"""是否处于冷却中"""
return self.remaining(key) > 0
def remaining(self, key: str) -> float:
"""剩余冷却秒数(0 表示不在冷却中,可触发)"""
return self._limiter(key).remaining_secs()
def cooldown(
seconds: float,
*,
hint: str | None = None,
name: str | None = None,
scope: Scope = "user",
key: Callable[[Event], str] | None = None,
):
"""给命令触发器(on_command 的返回值)加冷却,返回加好 CD 的触发器
- scope: 冷却作用域
- "user"(默认): 按用户 QQ 号,群聊/私聊共用同一冷却(同 nonebot1 传 user_id)
- "group": 按群共享(全群一个 CD),私聊退化为按用户
- "global": 所有人共享一个 CD
- key: 自定义冷却键函数;传了 key 则忽略 scope
- hint: 冷却中提示文本,{secs} 会被替换为剩余秒数(向上取整);不传用默认文案
- name: 冷却器名称;不传时按触发器对象生成,热重载后 CD 会重置
原理:向前置插入一个 guard handler,冷却中 finish 提示并终止,
未冷却则放行后续真正的事件处理函数。
"""
cd = Cooldown(seconds, name=name)
key_fn = key or _scope_key(scope)
async def guard(matcher: Matcher, event: Event):
"""CD 检查 handler:冷却中提示并结束,否则放行"""
uid = key_fn(event)
if not cd.try_trigger(uid):
secs = math.ceil(cd.remaining(uid))
msg = (hint or "操作太频繁了,请 {secs} 秒后再试").format(secs=secs)
await matcher.finish(msg)
def deco(matcher: type[Matcher]) -> type[Matcher]:
if cd.name is None:
cd.name = f"cd:{next(_cd_seq)}"
dep = Dependent[Any].parse(call=guard, allow_types=Matcher.HANDLER_PARAM_TYPES)
matcher.handlers.insert(0, dep)
return matcher
return deco
+60
View File
@@ -0,0 +1,60 @@
from typing import List
from nonebot import get_plugin_config
from nonebot.adapters import Event
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from pydantic import BaseModel
class Config(BaseModel):
# 管理员的QQ号
SUPERUSERS: List[str] = [] # 必填
pc = get_plugin_config(Config)
# 权限等级(与 hoshino 的 priv 保持一致)
USER = 1
GROUP_ADMIN = 2
GROUP_OWNER = 3
SUPERUSER = 999
# 管理操作所需的最低权限等级
MANAGE_PRIV = GROUP_ADMIN
async def check_admin(event: Event) -> bool:
return event.get_user_id() in pc.SUPERUSERS
async def get_user_priv(bot: Bot, event: MessageEvent) -> int:
"""获取用户权限等级:超管=999 群主=3 群管理=2 普通群员=1"""
if str(event.user_id) in pc.SUPERUSERS:
return SUPERUSER
if not isinstance(event, GroupMessageEvent):
return USER
try:
info = await bot.get_group_member_info(
group_id=event.group_id, user_id=event.user_id
)
role = info.get("role")
if role == "owner":
return GROUP_OWNER
if role == "admin":
return GROUP_ADMIN
except Exception:
pass
return USER
async def check_manage(bot: Bot, event: MessageEvent) -> bool:
"""是否为管理及以上权限"""
return await get_user_priv(bot, event) >= MANAGE_PRIV
async def check_bot_admin(bot: Bot, group_id: int) -> bool:
"""bot 是否为该群的群管理/群主"""
try:
info = await bot.get_group_member_info(
group_id=group_id, user_id=bot.self_id
)
return info.get("role") != "member"
except Exception:
return False
+416
View File
@@ -0,0 +1,416 @@
# NoneBot2 + OneBot V11 完整能力清单
> 本文档整理 NoneBot2 框架和 OneBot V11 协议的全部原生能力,
> 用于指导 HexiCore 封装层的设计——只封装原生不方便用的部分。
---
# 一、NoneBot2 框架能力
## 1. 事件响应器(Matcher)
| 创建方式 | 用途 |
|---|---|
| `on_command(cmd, aliases)` | 命令匹配(最常用) |
| `on_startswith(prefix)` | 前缀匹配 |
| `on_endswith(suffix)` | 后缀匹配 |
| `on_fullmatch(text)` | 完全匹配 |
| `on_keyword(words)` | 关键词匹配 |
| `on_regex(pattern)` | 正则匹配 |
| `on_message()` | 所有消息 |
| `on_notice()` | 通知事件 |
| `on_request()` | 请求事件 |
| `on_type(event_types)` | 指定事件类型 |
**Matcher 生命周期方法:**
```python
matcher.handle() # 注册处理函数
matcher.got(key, prompt) # 等待用户输入
matcher.receive("id") # 等待下一条消息
matcher.send(msg) # 发送消息
matcher.finish(msg) # 发送并结束
matcher.reject(msg) # 拒绝输入,重新等待
matcher.pause() # 暂停,等待下一条消息
matcher.stop_propagation()# 阻止后续匹配器
```
**内置权限:**
```python
from nonebot.permission import SUPERUSER, GROUP_ADMIN, GROUP_OWNER, GROUP, PRIVATE
```
**内置规则:**
```python
from nonebot.rule import to_me, is_type, command, keyword, startswith, endswith, fullmatch, regex
```
---
## 2. 依赖注入(DI)
**类型注入(直接写类型注解即可):**
```python
async def handler(
bot: Bot,
event: Event,
state: T_State,
matcher: Matcher,
): ...
```
**参数依赖(from nonebot.params):**
| 依赖 | 返回值 |
|---|---|
| `CommandArg()` | 命令参数 `Message` |
| `Command()` | 命令名 `tuple[str, ...]` |
| `RawCommand()` | 原始命令文本 |
| `CommandStart()` | 命令前缀 |
| `EventMessage()` | 事件消息 `Message` |
| `EventPlainText()` | 纯文本 |
| `EventToMe()` | 是否 @ 机器人 `bool` |
| `Arg(key)` | `got` 获取的参数 |
| `ArgStr(key)` | `got` 参数的字符串 |
| `ArgPlainText(key)` | `got` 参数的纯文本 |
| `RegexGroup()` | 正则捕获组 |
| `RegexStr()` | 正则匹配文本 |
---
## 3. 消息系统
### Message 类(List[MessageSegment])
```python
msg.extract_plain_text() # 提取纯文本
msg.has("image") # 是否包含某类型
msg.only("text") # 是否只有某类型
msg["text"] # 过滤出所有文本段
msg["text", 0] # 第一个文本段
msg.include("text", "image") # 只保留指定类型
msg.exclude("image") # 排除指定类型
msg.count("image") # 计数
msg.index("image") # 索引
msg + "text" # 拼接
Message.template("{} {}").format(a, b) # 模板
```
### OneBot V11 MessageSegment
```python
MessageSegment.text("hello") # 文本
MessageSegment.image(file) # 图片(URL/路径/base64)
MessageSegment.record(file) # 语音
MessageSegment.video(file) # 视频
MessageSegment.at(user_id) # @某人
MessageSegment.at_all() # @全体
MessageSegment.reply(msg_id) # 回复
MessageSegment.face(id) # QQ表情
MessageSegment.poke(type, id) # 戳一戳
MessageSegment.forward(id) # 转发消息
MessageSegment.json(data) # JSON卡片
MessageSegment.xml(data) # XML卡片
MessageSegment.share(url, title) # 链接分享
MessageSegment.music(type, id) # 音乐卡片
MessageSegment.location(lat, lon) # 位置
```
---
## 4. UniMessage(跨平台消息,nonebot-plugin-alconna)
```python
UniMessage.text("hello") # 文本
UniMessage.at("123456") # @某人
UniMessage.at_all() # @全体
UniMessage.image(url="...") # 图片
UniMessage.audio(url="...") # 音频
UniMessage.voice(url="...") # 语音
UniMessage.video(url="...") # 视频
UniMessage.file("id") # 文件
UniMessage.reply("msg_id") # 回复
UniMessage.emoji("id") # 表情
```
**发送与操作:**
```python
await msg.send() # 发送
await msg.send(at_sender=True) # @发送者
await msg.send(reply_to=True) # 回复原消息
await msg.finish() # 发送并结束
receipt = await msg.send() # 获取回执
await receipt.recall(delay=5) # 5秒后撤回
await receipt.edit(UniMessage.text("新内容")) # 编辑
await receipt.reaction("thumbsup") # 添加表情回应
```
**序列化:**
```python
data = msg.dump() # 存储
msg = UniMessage.load(data) # 还原
```
---
# 二、OneBot V11 协议能力
## 1. 消息类 API
| API | 参数 | 说明 |
|---|---|---|
| `send_msg` | `message_type`, `user_id`/`group_id`, `message` | 通用发送 |
| `send_private_msg` | `user_id`, `message` | 私聊发送 |
| `send_group_msg` | `group_id`, `message` | 群聊发送 |
| `delete_msg` | `message_id` | 撤回消息 |
| `get_msg` | `message_id` | 获取消息详情 |
| `get_forward_msg` | `id` | 获取转发消息内容 |
## 2. 群管理 API
| API | 参数 | 说明 |
|---|---|---|
| `set_group_kick` | `group_id`, `user_id`, `reject_add_request` | 踢出成员 |
| `set_group_ban` | `group_id`, `user_id`, `duration` | 禁言(0=解除) |
| `set_group_anonymous_ban` | `group_id`, `anonymous_flag`, `duration` | 禁言匿名 |
| `set_group_whole_ban` | `group_id`, `enable` | 全员禁言 |
| `set_group_admin` | `group_id`, `user_id`, `enable` | 设置/取消管理员 |
| `set_group_card` | `group_id`, `user_id`, `card` | 修改群名片 |
| `set_group_name` | `group_id`, `group_name` | 修改群名 |
| `set_group_leave` | `group_id`, `is_dismiss` | 退群/解散 |
| `set_group_special_title` | `group_id`, `user_id`, `special_title` | 设置群头衔 |
| `set_group_anonymous` | `group_id`, `enable` | 开关匿名聊天 |
## 3. 请求处理 API
| API | 参数 | 说明 |
|---|---|---|
| `set_friend_add_request` | `flag`, `approve`, `remark` | 好友申请 |
| `set_group_add_request` | `flag`, `sub_type`, `approve`, `reason` | 加群/邀请申请 |
## 4. 信息查询 API
| API | 参数 | 说明 |
|---|---|---|
| `get_login_info` | — | 获取登录信息 |
| `get_stranger_info` | `user_id`, `no_cache` | 获取陌生人信息 |
| `get_friend_list` | — | 获取好友列表 |
| `get_group_info` | `group_id`, `no_cache` | 获取群信息 |
| `get_group_list` | — | 获取群列表 |
| `get_group_member_info` | `group_id`, `user_id`, `no_cache` | 获取群成员信息 |
| `get_group_member_list` | `group_id` | 获取群成员列表 |
| `get_group_honor_info` | `group_id`, `type` | 获取群荣耀信息 |
## 5. 社交 API
| API | 参数 | 说明 |
|---|---|---|
| `send_like` | `user_id`, `times` | 点赞(最多10次/天) |
## 6. 媒体 API
| API | 参数 | 说明 |
|---|---|---|
| `get_image` | `file` | 获取图片文件 |
| `get_record` | `file`, `out_format` | 获取语音文件 |
| `can_send_image` | — | 检查能否发图 |
| `can_send_record` | — | 检查能否发语音 |
## 7. 系统 API
| API | 参数 | 说明 |
|---|---|---|
| `get_status` | — | 运行状态 |
| `get_version_info` | — | 版本信息 |
| `set_restart` | `delay` | 重启 |
| `clean_cache` | — | 清理缓存 |
---
# 三、OneBot V11 事件类型
## 消息事件
| 事件类 | 关键字段 |
|---|---|
| `GroupMessageEvent` | `group_id`, `user_id`, `message_id`, `message`, `sender` |
| `PrivateMessageEvent` | `user_id`, `message_id`, `message`, `sender` |
**GroupMessageEvent.sender 字段:**
```python
sender.user_id # QQ号
sender.nickname # 昵称
sender.card # 群名片
sender.role # "owner" / "admin" / "member"
sender.title # 群头衔
sender.sex # 性别
sender.age # 年龄
sender.area # 地区
sender.level # 等级
```
## 通知事件
| 事件类 | 关键字段 | 说明 |
|---|---|---|
| `PokeNotifyEvent` | `group_id`, `user_id`, `target_id` | 戳一戳 |
| `GroupIncreaseNoticeEvent` | `group_id`, `user_id`, `operator_id` | 成员加入 |
| `GroupDecreaseNoticeEvent` | `group_id`, `user_id`, `operator_id` | 成员离开 |
| `GroupBanNoticeEvent` | `group_id`, `user_id`, `operator_id`, `duration` | 禁言 |
| `GroupAdminNoticeEvent` | `group_id`, `user_id` | 管理员变动 |
| `GroupRecallNoticeEvent` | `group_id`, `user_id`, `operator_id`, `message_id` | 群消息撤回 |
| `FriendRecallNoticeEvent` | `user_id`, `message_id` | 私聊消息撤回 |
| `FriendAddNoticeEvent` | `user_id` | 好友添加 |
| `GroupUploadNoticeEvent` | `group_id`, `user_id`, `file` | 文件上传 |
| `GroupNotifyEvent` | `sub_type="lucky_king"` | 红包运气王 |
| `GroupNotifyEvent` | `sub_type="honor"` | 荣誉变更 |
## 请求事件
| 事件类 | 关键字段 | 说明 |
|---|---|---|
| `FriendRequestEvent` | `user_id`, `comment`, `flag` | 好友申请 |
| `GroupRequestEvent` | `group_id`, `user_id`, `comment`, `flag`, `sub_type` | 加群/邀请申请 |
## 元事件
| 事件类 | 说明 |
|---|---|
| `MetaEvent` (lifecycle) | 生命周期(enable/disable/connect) |
| `MetaEvent` (heartbeat) | 心跳 |
---
# 四、HexiCore 封装建议
## 已有原生能力(无需封装,直接用)
| 能力 | 原生用法 |
|---|---|
| 发消息 | `UniMessage.text("hello").send()` |
| 发图片 | `UniMessage.image(url="...").send()` |
| @某人 | `UniMessage.at("123").send()` |
| 回复消息 | `UniMessage.reply("msg_id").send()` |
| 撤回消息 | `bot.delete_msg(message_id=...)` |
| 禁言 | `bot.set_group_ban(group_id=..., user_id=..., duration=...)` |
| 踢人 | `bot.set_group_kick(group_id=..., user_id=...)` |
| 群名片 | `bot.set_group_card(group_id=..., user_id=..., card=...)` |
| 管理员 | `bot.set_group_admin(group_id=..., user_id=..., enable=...)` |
| 群头衔 | `bot.set_group_special_title(group_id=..., user_id=..., title=...)` |
| 好友申请 | `bot.set_friend_add_request(flag=..., approve=...)` |
| 加群申请 | `bot.set_group_add_request(flag=..., sub_type=..., approve=...)` |
| 判断群/私聊 | `isinstance(event, GroupMessageEvent)` |
| 获取发送者 | `event.sender.user_id` / `event.sender.nickname` |
| 获取群号 | `event.group_id` |
| 获取消息ID | `event.message_id` |
| 判断管理员 | `event.sender.role in ("admin", "owner")` |
| 判断超级用户 | `str(event.user_id) in driver.config.superusers` |
| 遍历消息段 | `for seg in event.message: ...` |
| 获取纯文本 | `event.get_plaintext()` |
## 值得封装(原生用起来麻烦)
### 1. 戳一戳(Poke)
```python
# 原生:需要知道 API 非标准
await bot.call_api("send_poke", group_id=group_id, user_id=user_id)
# HexiCore 封装:简化为
await hexi.poke(group_id, user_id)
```
### 2. 合并转发消息
```python
# 原生:需要手动构建 node 结构
nodes = [
{"type": "node", "data": {"user_id": "123", "nickname": "name", "content": [seg]}},
...
]
await bot.call_api("send_group_forward_msg", group_id=group_id, message=nodes)
# HexiCore 封装:链式构建
forward = hexi.forward()
forward.add("123", "name", UniMessage.text("内容"))
forward.add("456", "name2", UniMessage.image(url="..."))
await forward.send(group_id)
```
### 3. 消息加精 / 取消加精
```python
# 原生:API 名称不直观
await bot.call_api("set_msg_essence", message_id=msg_id)
await bot.call_api("delete_msg_essence", message_id=msg_id)
# HexiCore 封装
await hexi.essence(msg_id) # 加精
await hexi.unessence(msg_id) # 取消
```
### 4. 获取 At 用户列表
```python
# 原生:需要遍历
at_users = [seg.data["qq"] for seg in event.message if seg.type == "at"]
# HexiCore 封装
users = hexi.get_at_users(event)
```
### 5. 是否好友 / 是否群成员
```python
# 原生:需要调用 API + 异常处理
try:
await bot.get_stranger_info(user_id=user_id)
is_friend = True
except:
is_friend = False
# HexiCore 封装
is_friend = await hexi.is_friend(user_id)
is_member = await hexi.is_member(group_id, user_id)
```
### 6. 权限推断(可发送/可撤回/可禁言等)
```python
# 原生:没有直接 API,需要根据身份推断
role = event.sender.role
can_ban = role in ("admin", "owner") # 但还需要检查机器人自身是否是管理员
# HexiCore 封装
can_ban = await hexi.can_ban(group_id, user_id) # 检查目标是否可被禁言
can_kick = await hexi.can_kick(group_id, user_id) # 检查目标是否可被踢
```
### 7. 获取回复的原始消息
```python
# 原生:event.reply 只有 message_id,需要再调 API 获取内容
reply_msg = await bot.get_msg(message_id=event.reply.message_id)
# HexiCore 封装
reply_content = hexi.get_reply_content(event) # 直接返回 Message 对象
```
### 8. 获取消息来源(群号/私聊)
```python
# 原生:需要类型判断 + 字段访问
if isinstance(event, GroupMessageEvent):
source = event.group_id
elif isinstance(event, PrivateMessageEvent):
source = event.user_id
# HexiCore 封装
source = hexi.get_source(event) # 统一返回
```
---
## 总结:HexiCore 定位
```
HexiCore = 原生能力的便利封装层
目标:让业务层用一行代码完成需要写 3-5 行的操作
原则:
1. 不重复造轮子——原生好用的直接用
2. 封装复杂 API 调用——简化参数、统一接口
3. 提供便捷判断方法——省去类型检查和异常处理
4. 保持轻量——只做薄封装,不改变底层行为
```
+156
View File
@@ -0,0 +1,156 @@
# HexiCore 能力清单(精简版)
> **设计目标**
>
> HexiCore 是原生能力的便利封装层,只封装「原生写起来麻烦」的操作。
> 普通消息发送、构建、解析直接使用 Alconna UniMessage,不重复封装。
---
# 需要封装的能力
## 一、消息操作(简化 API 调用)
| 能力 | 原生写法 | HexiCore 写法 |
|---|---|---|
| 戳一戳 | `bot.call_api("send_poke", group_id=..., user_id=...)` | `await hexi.poke(group_id, user_id)` |
| 消息加精 | `bot.call_api("set_msg_essence", message_id=...)` | `await hexi.essence(msg_id)` |
| 取消加精 | `bot.call_api("delete_msg_essence", message_id=...)` | `await hexi.unessence(msg_id)` |
| 合并转发 | 手动构建 node 结构 | `forward.add(user_id, nickname, content).send(group_id)` |
---
## 二、信息提取(省去遍历/判断)
| 能力 | 原生写法 | HexiCore 写法 |
|---|---|---|
| 获取 At 用户列表 | `[seg.data["qq"] for seg in event.message if seg.type == "at"]` | `hexi.get_at_users(event)` |
| 获取回复内容 | `bot.get_msg(message_id=event.reply.message_id)` | `hexi.get_reply_content(event)` |
| 获取消息来源 | 类型判断 + 字段访问 | `hexi.get_source(event)` |
---
## 三、身份查询(需要 API 调用)
| 能力 | 原生写法 | HexiCore 写法 |
|---|---|---|
| 是否好友 | `bot.get_stranger_info()` + 异常处理 | `await hexi.is_friend(user_id)` |
| 是否群成员 | `bot.get_group_member_info()` + 异常处理 | `await hexi.is_member(group_id, user_id)` |
| 获取群成员信息 | `bot.get_group_member_info()` | `await hexi.get_member_info(group_id, user_id)` |
---
## 四、权限推断(原生无直接 API)
| 能力 | 说明 |
|---|---|
| `hexi.can_ban(group_id, user_id)` | 检查目标是否可被禁言(机器人是管理员 + 目标不是群主) |
| `hexi.can_kick(group_id, user_id)` | 检查目标是否可被踢出 |
| `hexi.can_set_admin(group_id)` | 检查机器人是否有管理员权限 |
| `hexi.is_admin(user_id)` | 判断用户是否为管理员 |
| `hexi.is_owner(user_id)` | 判断用户是否为群主 |
| `hexi.is_superuser(user_id)` | 判断用户是否为超级用户 |
---
## 五、批量操作(省去循环)
| 能力 | 说明 |
|---|---|
| `hexi.ban_multi(group_id, user_ids, duration)` | 批量禁言 |
| `hexi.kick_multi(group_id, user_ids)` | 批量踢出 |
---
# 不需要封装(直接用原生)
| 类别 | 能力 | 原生用法 |
|---|---|---|
| 消息发送 | 文本/图片/语音/视频/文件 | `UniMessage.text/image/record/video/file().send()` |
| 消息发送 | @某人/@全体 | `UniMessage.at()/at_all().send()` |
| 消息发送 | 回复消息 | `UniMessage.reply(msg_id).send()` |
| 消息操作 | 撤回消息 | `bot.delete_msg(message_id=...)` |
| 群管理 | 禁言 | `bot.set_group_ban(group_id=..., user_id=..., duration=...)` |
| 群管理 | 踢人 | `bot.set_group_kick(group_id=..., user_id=...)` |
| 群管理 | 群名片 | `bot.set_group_card(group_id=..., user_id=..., card=...)` |
| 群管理 | 管理员 | `bot.set_group_admin(group_id=..., user_id=..., enable=...)` |
| 群管理 | 群头衔 | `bot.set_group_special_title(group_id=..., user_id=..., title=...)` |
| 群管理 | 群名 | `bot.set_group_name(group_id=..., group_name=...)` |
| 群管理 | 全员禁言 | `bot.set_group_whole_ban(group_id=..., enable=...)` |
| 请求处理 | 好友申请 | `bot.set_friend_add_request(flag=..., approve=...)` |
| 请求处理 | 加群申请 | `bot.set_group_add_request(flag=..., sub_type=..., approve=...)` |
| 事件判断 | 群/私聊消息 | `isinstance(event, GroupMessageEvent)` |
| 事件信息 | 发送者 | `event.sender.user_id` / `event.sender.nickname` |
| 事件信息 | 群号 | `event.group_id` |
| 事件信息 | 消息ID | `event.message_id` |
| 事件信息 | 纯文本 | `event.get_plaintext()` |
| 事件信息 | 消息段遍历 | `for seg in event.message: ...` |
---
# 业务层调用示例
```python
from nonebot_plugin_alconna import UniMessage
# 普通消息 — 直接用 UniMessage
await UniMessage.text("Hello").send()
await UniMessage.image(url="https://...").send()
await UniMessage.at("123456").text("你好").send()
# 平台能力 — 用 HexiCore 封装
await hexi.poke(group_id, user_id) # 戳一戳
await hexi.essence(msg_id) # 加精
await hexi.unessence(msg_id) # 取消加精
# 转发消息 — 链式构建
forward = hexi.forward()
forward.add("123", "Alice", UniMessage.text("消息1"))
forward.add("456", "Bob", UniMessage.image(url="..."))
await forward.send(group_id)
# 信息提取 — 一行搞定
users = hexi.get_at_users(event) # At 用户列表
reply = hexi.get_reply_content(event) # 回复内容
source = hexi.get_source(event) # 消息来源
# 身份查询 — 异常处理已封装
is_friend = await hexi.is_friend(user_id) # 是否好友
is_member = await hexi.is_member(group_id, uid) # 是否群成员
info = await hexi.get_member_info(group_id, uid)# 群成员信息
# 权限推断 — 自动检查机器人权限
if await hexi.can_ban(group_id, user_id):
await bot.set_group_ban(group_id=group_id, user_id=user_id, duration=60)
# 身份判断
if hexi.is_admin(event): # 管理员
if hexi.is_owner(event): # 群主
if hexi.is_superuser(event): # 超级用户
```
---
# 架构设计
```
hexi/core/
├── __init__.py # 插件入口,导出 hexi 对象
├── poke.py # 戳一戳
├── forward.py # 合并转发消息构建器
├── essence.py # 消息加精/取消
├── extract.py # 消息提取(At列表、回复内容、来源)
├── member.py # 身份查询(好友、群成员)
├── permission.py # 权限推断(可禁言、可踢人等)
└── utils.py # 工具函数
```
---
# 设计原则
1. **薄封装** — 每个函数只是对原生 API 的简化调用,不添加额外逻辑
2. **类型安全** — 使用类型注解,配合 Pydantic 校验
3. **异常处理** — 内部处理 API 异常,对外返回布尔值或 None
4. **单一职责** — 每个模块只负责一类能力
5. **可选依赖** — 业务层可以选择用原生 API 或 HexiCore 封装
+43
View File
@@ -0,0 +1,43 @@
from nonebot.adapters.onebot.v11 import MessageSegment
class TextMessage:
def __init__(self, text):
self.text = text
class ImageMessage:
def __init__(self, image):
self.image = image
class UnknownMessage:
def __init__(self, raw):
self.raw = raw
class MessageState:
def __init__(self, data_dict):
self.data_dict = data_dict
# 获取命令头
def get_command(self):
return self.data_dict['_prefix']['command']
# 获取回复
def get_reply(self):
return self.data_dict['reply']
# 获取回复对象的文本
# 获取命令参数
def get_command_arg(self):
command_arg_list = self.data_dict['_prefix']['command_arg']
if command_arg_list:
command_arg = command_arg_list[0]
if isinstance(command_arg, MessageSegment):
if command_arg.type == 'text':
return TextMessage(command_arg.data['text'])
elif command_arg.type == 'image':
return ImageMessage(command_arg.data['url'])
return None # 返回 None 表示命令参数为空或无法解析
+110
View File
@@ -0,0 +1,110 @@
from nonebot import get_bot
from nonebot import require
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent, MessageSegment
from nonebot.log import logger
require("nonebot_plugin_htmlrender")
from nonebot_plugin_htmlrender import md_to_pic
async def send_markdown(
bot: Bot,
event: MessageEvent,
markdown: str,
fallback_text: str | None = None,
) -> None:
"""发送 Markdown
优先尝试 OneBot 后端的原生 markdown 消息段;后端不支持/失败时
自动降级为 htmlrender 把 Markdown 渲染成图片发送,保证任何后端都能看到。
"""
# 1. 尝试原生 markdown 消息段(不同后端字段可能不同,失败自动降级)
try:
seg = MessageSegment("markdown", {"content": markdown})
await bot.send(event=event, message=Message(seg))
return
except Exception as e:
logger.warning(f"原生 markdown 发送失败,尝试降级为图片: {e}")
# 2. 降级:htmlrender 渲染成图片
try:
img = await md_to_pic(md=markdown, width=540, device_scale_factor=4)
await bot.send(event=event, message=MessageSegment.image(img))
except Exception as e:
logger.error(f"Markdown 渲染图片失败,发送纯文本: {e}")
await bot.send(event=event, message=fallback_text or markdown)
async def send_poke(group_id: int | None = None, user_id: int = 0):
"""发送戳一戳(OneBot V11 send_poke API)
group_id 为空时向私聊发送戳一戳;失败返回 None
"""
bot = get_bot()
params = {"user_id": int(user_id)}
if group_id:
params["group_id"] = int(group_id)
logger.debug(f"send_poke: {params}")
try:
return await bot.call_api("send_poke", **params)
except Exception as e:
logger.error(f"send_poke 失败: {e}")
return None
def get_reply_message(event: MessageEvent) -> Message:
"""获取消息内容:若为回复消息则返回被回复的消息,否则返回当前消息"""
if event.reply is not None:
logger.info(f"此条消息为回复消息: {event.reply.message}")
return event.reply.message
logger.info(f"此条消息为普通消息: {event.message}")
return event.message
def build_forward_nodes(
self_id: int,
entries: list[tuple[str, str]],
sender_name: str | None = None,
) -> list[dict]:
"""构造合并转发节点列表
entries: [(发送者名, 消息内容), ...]
sender_name: 非空时所有节点统一使用该昵称(用于把插件名等放进 content,
而不是作为转发节点的发送者昵称)
"""
return [
{
"type": "node",
"data": {
"name": sender_name if sender_name is not None else name,
"uin": str(self_id),
"content": content,
},
}
for name, content in entries
]
async def send_forward_msg(
bot: Bot,
event: MessageEvent,
entries: list[tuple[str, str]],
fallback: str,
sender_name: str | None = None,
):
"""群聊发送合并转发消息,私聊或发送失败时回退纯文本
entries: [(发送者名, 消息内容), ...]
sender_name: 非空时统一用于节点发送者昵称(插件名等应写入 content)
"""
group_id = getattr(event, "group_id", None)
if group_id:
try:
await bot.send_group_forward_msg(
group_id=group_id,
messages=build_forward_nodes(event.self_id, entries, sender_name),
)
return
except Exception as e:
logger.warning(f"合并转发发送失败,回退文本: {e}")
await bot.send(event, fallback)
+251
View File
@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""所有插件上层的 filter 规则:全局启用 + 分群控制 + 群聊/私聊使用控制。
设计:
- 以「插件模块名(含 __plugin_meta__ 的包根)」为稳定 identifier。
- 两层状态存 hexi/data/plugin_control.json:
{
"<plugin_id>": {
"global": {"enabled": true, "chat": ["group","private"]},
"groups": {"<group_id>": {"enabled": true, "chat": ["group"]}}
}
}
- 默认(未受管插件)全部放行,仅受管的做过滤,避免误伤。
- 通过往 NoneBot matcher 注册表里每个 application 插件 matcher 的
rule 追加一个 gate checker(Rule &),实现「在所有插件之上」的统一闸门。
- 启动时 instrument_plugin_gate() 扫一次;热加载/热重载后再扫一次。
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any, Optional
from nonebot import get_loaded_plugins, logger
from nonebot.internal.matcher import matchers as matchers_registry
from nonebot.rule import Rule
_PLUGIN_ROOT = Path(__file__).resolve().parents[1] # hexi/
DATA_DIR = _PLUGIN_ROOT / "data"
STORE_PATH = DATA_DIR / "plugin_control.json"
DEFAULT_CHAT = ["group", "private"]
# matcher 被注入后打标,避免重复叠加 gate checker
_GATE_ATTR = "_hexi_plugin_gate_applied"
def _default_level() -> dict[str, Any]:
return {"enabled": True, "chat": list(DEFAULT_CHAT)}
def _default_entry() -> dict[str, Any]:
return {"global": _default_level(), "groups": {}}
_controls: dict[str, dict[str, Any]] = {}
def load() -> None:
"""从磁盘加载控制面状态(幂等)。"""
global _controls
if STORE_PATH.exists():
try:
data = json.loads(STORE_PATH.read_text(encoding="utf-8"))
_controls = data if isinstance(data, dict) else {}
except Exception as e: # noqa: BLE001
logger.warning(f"加载插件控制配置失败: {type(e).__name__}: {e}")
_controls = {}
else:
_controls = {}
def save() -> None:
"""持久化控制面状态。"""
DATA_DIR.mkdir(parents=True, exist_ok=True)
STORE_PATH.write_text(
json.dumps(_controls, ensure_ascii=False, indent=2), encoding="utf-8"
)
def _norm_level(level: Optional[dict[str, Any]]) -> dict[str, Any]:
lvl = _default_level()
if level:
if "enabled" in level:
lvl["enabled"] = bool(level["enabled"])
chat = level.get("chat")
if isinstance(chat, list) and chat:
# 只保留合法值
lvl["chat"] = [c for c in chat if c in {"group", "private"}] or DEFAULT_CHAT
return lvl
def get_plugin_control(plugin_id: str) -> dict[str, Any]:
"""返回某插件的控制配置(含 global + groups),未受管返回默认全放行。"""
entry = _controls.get(plugin_id)
if not entry:
return {
"global": _default_level(),
"groups": {},
"managed": False,
}
return {
"global": _norm_level(entry.get("global")),
"groups": {
str(gid): _norm_level(level) for gid, level in (entry.get("groups") or {}).items()
},
"managed": True,
}
def set_global(
plugin_id: str, enabled: Optional[bool] = None, chat: Optional[list[str]] = None
) -> dict[str, Any]:
"""设置全局开关/聊天类型,返回最新控制配置。"""
entry = _controls.setdefault(plugin_id, _default_entry())
level = _norm_level(entry.get("global"))
if enabled is not None:
level["enabled"] = bool(enabled)
if chat is not None:
level["chat"] = [c for c in chat if c in {"group", "private"}] or DEFAULT_CHAT
entry["global"] = level
save()
return get_plugin_control(plugin_id)
def set_group(
plugin_id: str,
group_id: str,
enabled: Optional[bool] = None,
chat: Optional[list[str]] = None,
) -> dict[str, Any]:
"""设置某群对某插件的开关/聊天类型,返回最新控制配置。"""
entry = _controls.setdefault(plugin_id, _default_entry())
groups = entry.setdefault("groups", {})
gid = str(group_id)
level = _norm_level(groups.get(gid))
if enabled is not None:
level["enabled"] = bool(enabled)
if chat is not None:
level["chat"] = [c for c in chat if c in {"group", "private"}] or DEFAULT_CHAT
groups[gid] = level
save()
return get_plugin_control(plugin_id)
def remove_group(plugin_id: str, group_id: str) -> dict[str, Any]:
"""移除某群覆盖(回到继承全局)。"""
entry = _controls.get(plugin_id)
if entry:
entry.get("groups", {}).pop(str(group_id), None)
save()
return get_plugin_control(plugin_id)
def remove_plugin(plugin_id: str) -> None:
"""清空某插件所有覆盖,恢复默认放行。"""
_controls.pop(plugin_id, None)
save()
def list_plugins() -> list[dict[str, Any]]:
"""枚举所有 application 插件(或注册了配置 schema 的插件)及其控制面状态。"""
from nonebot.plugin import get_loaded_plugins
from hexi.web_hub.web_config import has_schema
result: list[dict[str, Any]] = []
seen: set[str] = set()
for p in get_loaded_plugins():
mod = p.module_name
if mod in seen:
continue
seen.add(mod)
meta = p.metadata
if not meta:
continue
if meta.type != "application" and not has_schema(mod):
continue
ctl = get_plugin_control(mod)
result.append(
{
"id": mod,
"name": meta.name,
"description": meta.description or "",
"usage": meta.usage or "",
"type": meta.type,
"enabled": ctl["global"]["enabled"],
"chat": ctl["global"]["chat"],
"groups": {gid: lvl for gid, lvl in ctl["groups"].items()},
"managed": ctl["managed"],
}
)
result.sort(key=lambda x: x["name"])
return result
def _resolve_plugin_id(module_name: str) -> Optional[str]:
"""把 matcher 的模块名回溯到带 __plugin_meta__ / NoneBot 注册表的插件根模块。"""
parts = module_name.split(".")
for i in range(len(parts), 0, -1):
cand = ".".join(parts[:i])
for p in get_loaded_plugins():
if p.module_name == cand:
return cand
mod = sys.modules.get(cand)
if mod is not None and getattr(mod, "__plugin_meta__", None) is not None:
return cand
return None
def _gate_checker(plugin_id: str):
"""为单个插件生成一个异步 rule checker(读控制面状态)。"""
async def _check(bot, event, state) -> bool: # noqa: ANN001
return _allowed(plugin_id, event)
return _check
def _allowed(plugin_id: str, event) -> bool:
"""决定某事件是否允许进入该插件。"""
cfg = get_plugin_control(plugin_id)
is_group = getattr(event, "message_type", "") == "group" or (
getattr(event, "group_id", None) is not None
)
if is_group:
gid = str(getattr(event, "group_id", "") or "")
gcfg = cfg["groups"].get(gid)
level = gcfg if gcfg is not None else cfg["global"]
else:
level = cfg["global"]
if not level["enabled"]:
return False
chat_type = "group" if is_group else "private"
return chat_type in level["chat"]
def instrument_plugin_gate() -> int:
"""给所有 application 插件 matcher 追加统一 gate 规则,返回注入数量。"""
count = 0
for group in matchers_registry.values():
for matcher in group:
if getattr(matcher, _GATE_ATTR, False):
continue
mod = getattr(matcher, "module_name", None)
if not mod:
continue
pid = _resolve_plugin_id(mod)
if not pid:
continue
checker = Rule(_gate_checker(pid))
current = getattr(matcher, "rule", None)
matcher.rule = checker if current is None else current & checker
setattr(matcher, _GATE_ATTR, pid)
count += 1
return count
# 导入时加载状态(幂等,可重复调用 load() 刷新)
load()
+336
View File
@@ -0,0 +1,336 @@
"""插件热插拔管理:运行时加载/卸载/重载插件(仅超管)
通过直接导入模块并自行维护注册表,绕开 NoneBot PluginManager 的
重复检查限制(load_plugin 每次都会向全局 _managers 追加 manager,
重复加载会触发 "Plugin already exists" 错误)。
"""
import importlib
import sys
from types import ModuleType
from nonebot import get_driver, logger, on_command, on_message, require
from nonebot.adapters.onebot.v11 import Bot, Message, MessageEvent
from nonebot.internal.matcher import matchers as matchers_registry
from nonebot.params import CommandArg
from nonebot.permission import SUPERUSER
from nonebot.plugin import (
Plugin,
_plugins,
get_loaded_plugins,
get_plugin_by_module_name,
)
from nonebot.rule import Rule, to_me
require("nonebot_plugin_alconna")
from nonebot_plugin_alconna import UniMessage
from hexi.core.message_utils import send_forward_msg
from . import plugin_control
# 插件热拔插管理(hexi.core 核心模块,非 NoneBot 插件)
def _forward_sender_name() -> str:
"""合并转发节点的发送者昵称(用机器人配置昵称,插件名写进 content)"""
nickname = get_driver().config.nickname
return next(iter(nickname), "Bot") if nickname else "Bot"
def _match_help(event: MessageEvent) -> bool:
"""匹配 help/帮助/菜单 开头(支持 at bot 后跟命令,可带插件名参数)
on_command 的命令规则只匹配第一段为文本的消息,
"at bot + help" 第一段是 at 段无法触发,故用 on_message 自定义规则。
"""
text = event.message.extract_plain_text().strip()
return text in ("help", "帮助", "菜单") or text.startswith( ("help ", "帮助 ", "菜单 ")
)
help_cmd = on_message(rule=Rule(_match_help) & to_me(), block=True)
plugin_list_cmd = on_command("插件列表", permission=SUPERUSER, block=True)
plugin_load_cmd = on_command("加载插件", permission=SUPERUSER, block=True)
plugin_unload_cmd = on_command("卸载插件", permission=SUPERUSER, block=True)
plugin_reload_cmd = on_command("重载插件", permission=SUPERUSER, block=True)
# 自管理的热插拔插件注册表:模块名 -> 模块
_hot_plugins: dict[str, ModuleType] = {}
def _registered_matchers(module_name: str) -> list:
"""获取某模块注册的全部 matcher(含子模块,注册表按优先级分组)"""
return [
m
for group in matchers_registry.values()
for m in group
if getattr(m, "module_name", None)
and (
m.module_name == module_name
or m.module_name.startswith(module_name + ".")
)
]
def _collect_module_names(plugin: Plugin) -> list[str]:
"""收集插件及其全部子插件的模块名(用于查 matcher 与清缓存)"""
names = [plugin.module_name]
for sub in plugin.sub_plugins:
names.extend(_collect_module_names(sub))
return names
def _unregister_plugin(plugin: Plugin) -> None:
"""从 NoneBot 插件注册表移除插件(递归移除子插件)"""
for sub in list(plugin.sub_plugins):
_unregister_plugin(sub)
_plugins.pop(plugin.id_, None)
if parent := plugin.parent_plugin:
parent.sub_plugins.discard(plugin)
def _is_library(name: str) -> bool:
"""判断模块是否归属「非 application」插件(library / 未声明类别)。
只有 application 插件可热拔插;library(含根包未声明类别但被依赖的核心/框架)
都应保护,热拔插会破坏依赖它的插件运行态。
优先级:NoneBot 插件注册表 > sys.modules 回溯(含父包 __plugin_meta__)。
"""
plugin = get_plugin_by_module_name(name)
if plugin is not None:
meta = plugin.metadata
return not (meta and meta.type == "application")
parts = name.split(".")
for i in range(len(parts), 0, -1):
module = sys.modules.get(".".join(parts[:i]))
if module is not None:
meta = getattr(module, "__plugin_meta__", None)
if meta is not None:
return meta.type != "application"
return False
def hot_load(name: str) -> bool:
"""热加载插件模块(直接 import,matcher 随之注册)"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,不支持热加载")
return False
if name in _hot_plugins:
return False
if any(p.module_name == name for p in get_loaded_plugins()):
return False
importlib.import_module(name)
_hot_plugins[name] = sys.modules[name]
logger.info(f"插件已热加载: {name}")
try:
plugin_control.instrument_plugin_gate()
except Exception as e: # noqa: BLE001
logger.warning(f"热加载后注入 gateway 失败: {type(e).__name__}: {e}")
return True
def hot_unload(name: str) -> bool:
"""热卸载插件:移除 matcher、清理 NoneBot 注册表与模块缓存
不限于热加载的插件:启动时加载的插件(hexi/plugins 与 pyproject.toml
声明)同样支持卸载,以 NoneBot 插件注册表为准查找。
安全限制:非 application 插件(library/未声明类别,常为核心库/框架)被其他
插件依赖,热卸载会让依赖方引用悬空并导致运行混乱,故拒绝卸载。
"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热卸载会使依赖它的插件运行混乱,已拒绝")
return False
plugin = get_plugin_by_module_name(name)
if plugin is None and name not in _hot_plugins:
return False
module_names = _collect_module_names(plugin) if plugin else [name]
for mod_name in module_names:
for m in _registered_matchers(mod_name):
group = matchers_registry.get(m.priority)
if group and m in group:
group.remove(m)
if plugin is not None:
_unregister_plugin(plugin)
# 清理模块缓存(含子模块),保证重载拿到新模块
for mod_name in [
n
for n in sys.modules
if any(n == base or n.startswith(base + ".") for base in module_names)
]:
sys.modules.pop(mod_name, None)
# 清理自管理注册表(含以子模块名登记的情况)
for key in list(_hot_plugins):
if any(key == base or key.startswith(base + ".") for base in module_names):
_hot_plugins.pop(key, None)
logger.info(f"插件已热卸载: {name}")
return True
def hot_reload(name: str) -> bool:
"""热重载插件:先卸载再加载"""
if _is_library(name):
logger.warning(f"插件 {name} 是非 application(library/未声明)插件,热重载会使依赖它的插件运行混乱,已拒绝")
return False
hot_unload(name)
return hot_load(name)
def _plugin_owner(mod_name: str) -> tuple[str, object] | None:
"""找到模块归属的插件(自身或回溯父包直到有 meta),返回 (归属模块名, meta)"""
parts = mod_name.split(".")
for i in range(len(parts), 0, -1):
cand = ".".join(parts[:i])
# 优先 NoneBot 注册表中的 metadata
for p in get_loaded_plugins():
if p.module_name == cand:
meta = p.metadata
break
else:
module = sys.modules.get(cand)
meta = getattr(module, "__plugin_meta__", None) if module else None
if meta is not None:
return cand, meta
return None
def _visible_plugins() -> list[tuple[str, object]]:
"""可显示的应用型插件列表 [(模块名, meta), ...]
以全局 matcher 注册表为准(正规插件、hexi_core 子模块、热加载插件一网打尽),
子模块 matcher 回溯到所属插件包,仅保留 type='application',排除 alconna 等 library。
"""
seen: dict[str, object] = {}
for group in matchers_registry.values():
for m in group:
mod = getattr(m, "module_name", None)
if not mod:
continue
owner = _plugin_owner(mod)
if owner and owner[0] not in seen:
seen[owner[0]] = owner[1]
result = []
for mod in sorted(seen):
meta = seen[mod]
if meta.type != "application":
continue
result.append((mod, meta))
return result
def _find_plugin(name: str):
"""按 模块名/meta名(含模糊) 查找应用型插件的 meta"""
for mod, meta in _visible_plugins():
if mod == name or meta.name == name or name in meta.name:
return meta
return None
@help_cmd.handle()
async def help_handle(bot: Bot, ev: MessageEvent):
text = ev.message.extract_plain_text().strip()
# 提取插件名参数
arg = ""
for kw in ("help", "帮助", "菜单"):
if text.startswith(kw):
arg = text[len(kw) :].strip()
break
if arg:
# 单插件详细帮助
meta = _find_plugin(arg)
if meta is None:
await UniMessage.text(
f"未找到插件「{arg}」,发送「帮助」查看所有插件"
).send()
return
content = f"【{meta.name}】{meta.description}"
if meta.usage:
content += f"\n{meta.usage}"
await send_forward_msg(
bot, ev, [(meta.name, content)], content,
sender_name=_forward_sender_name(),
)
return
# 总览(仅显示有触发命令的应用型插件,内容以插件名开头)
entries: list[tuple[str, str]] = []
fallback_lines = ["===== 插件菜单 ====="]
for _, meta in _visible_plugins():
content = f"【{meta.name}】{meta.description}"
entries.append((meta.name, content))
fallback_lines.append(content)
entries.append(("提示", "发送「帮助 插件名」查看插件详细用法"))
fallback_lines.append("发送「帮助 插件名」查看插件详细用法")
await send_forward_msg(
bot, ev, entries, "\n".join(fallback_lines),
sender_name=_forward_sender_name(),
)
@plugin_list_cmd.handle()
async def plugin_list_handle(bot: Bot, ev: MessageEvent):
entries: list[tuple[str, str]] = []
fallback_lines = ["已加载插件:"]
for mod, meta in _visible_plugins():
content = f"{meta.name}\n{mod}"
entries.append((meta.name, content))
fallback_lines.append(content)
await send_forward_msg(
bot, ev, entries, "\n".join(fallback_lines),
sender_name=_forward_sender_name(),
)
@plugin_load_cmd.handle()
async def plugin_load_handle(ev: MessageEvent, arg: Message = CommandArg()):
name = arg.extract_plain_text().strip()
if not name:
await UniMessage.text(
"用法:加载插件 <模块名>,如 hexi.plugins.nonebot_plugin_dice"
).send()
return
try:
if hot_load(name):
await UniMessage.text(f"插件 {name} 加载成功").send()
else:
await UniMessage.text(f"插件 {name} 已加载或模块不存在").send()
except Exception as e:
logger.error(f"加载插件 {name} 失败", exc_info=True)
await UniMessage.text(f"加载失败: {type(e).__name__}: {e}").send()
@plugin_unload_cmd.handle()
async def plugin_unload_handle(ev: MessageEvent, arg: Message = CommandArg()):
name = arg.extract_plain_text().strip()
if not name:
await UniMessage.text(
"用法:卸载插件 <模块名>,如 hexi.plugins.nonebot_plugin_dice"
).send()
return
if hot_unload(name):
await UniMessage.text(f"插件 {name} 已卸载").send()
else:
await UniMessage.text(f"未找到插件 {name}(未加载或模块名有误)").send()
@plugin_reload_cmd.handle()
async def plugin_reload_handle(ev: MessageEvent, arg: Message = CommandArg()):
name = arg.extract_plain_text().strip()
if not name:
await UniMessage.text(
"用法:重载插件 <模块名>,如 hexi.plugins.nonebot_plugin_dice"
).send()
return
try:
if hot_reload(name):
await UniMessage.text(f"插件 {name} 重载成功").send()
else:
await UniMessage.text(f"插件 {name} 重载失败(模块不存在)").send()
except Exception as e:
logger.error(f"重载插件 {name} 失败", exc_info=True)
await UniMessage.text(f"重载失败: {type(e).__name__}: {e}").send()
+181
View File
@@ -0,0 +1,181 @@
"""限频器(令牌桶):为依赖外部 API 的插件限速,避免请求过频被限流返回 403/429
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
用法:
from hexi.core import rate_limit
# 异步请求前取令牌(无令牌时等待,最多等 timeout 秒,超时返回 False)
if not await rate_limit.acquire("steam", rate=1, capacity=2):
await UniMessage.text("查询太频繁了,歇会儿再来~").send()
return
# 同步请求前取令牌(不等待,超限立即失败)
if not rate_limit.acquire_sync("bf_api", rate=0.5, wait=False):
return "请求过于频繁,请稍后再试"
# 拿到响应后检测是否被限流(403/429),可据此告警或退避
if rate_limit.is_ratelimited(resp.status_code):
...
"""
import asyncio
import threading
import time
from nonebot.log import logger
# 常见限流状态码:403(服务端按频率拒绝)与 429(Too Many Requests)
_RATE_LIMIT_CODES = frozenset({403, 429})
class RateLimiter:
"""令牌桶限频器
- rate: 每秒补充的令牌数;capacity: 桶容量(最大突发),默认取 max(rate, 1)
- 惰性补充:按距上次取令牌的时间差计算补充量,无需后台任务
- 线程安全:临界区用 threading.Lock 保护,同步/异步调用可并发使用
"""
def __init__(self, rate: float, capacity: int | None = None):
if rate <= 0:
raise ValueError(f"rate 必须大于 0,收到 {rate!r}")
if capacity is not None and capacity < 1:
raise ValueError(f"capacity 必须 >= 1,收到 {capacity!r}")
self._rate = float(rate)
self._capacity = float(capacity if capacity is not None else max(rate, 1))
self._tokens = self._capacity
self._last_refill = time.monotonic()
self._lock = threading.Lock()
def _refill(self) -> None:
"""按经过的时间补充令牌(调用方需持有 _lock)"""
now = time.monotonic()
self._tokens = min(
self._capacity, self._tokens + (now - self._last_refill) * self._rate
)
self._last_refill = now
def try_acquire(self) -> bool:
"""尝试取 1 个令牌:有则立即成功,无则返回 False(不阻塞)"""
with self._lock:
self._refill()
if self._tokens >= 1:
self._tokens -= 1
return True
return False
def remaining_secs(self) -> float:
"""距下一个令牌可用的秒数;0 表示当前有令牌可用(不消费令牌)"""
with self._lock:
self._refill()
return max(0.0, (1.0 - self._tokens) / self._rate)
def _wait_secs(self, deadline: float | None) -> float:
"""计算距下次尝试还需等待的秒数;已超时返回 -1 表示放弃"""
with self._lock:
self._refill()
need = max(1.0 - self._tokens, 0.0) / self._rate
if deadline is not None:
remaining = deadline - time.monotonic()
if remaining <= 0:
return -1.0
need = min(need, remaining)
return need
async def acquire(self, timeout: float | None = None) -> bool:
"""异步取 1 个令牌:无令牌时 asyncio.sleep 等待补充(不阻塞事件循环)
timeout 为 None 时无限等待;超时返回 False
"""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
if self.try_acquire():
return True
wait = self._wait_secs(deadline)
if wait < 0:
return False
await asyncio.sleep(wait)
def acquire_sync(self, timeout: float | None = None) -> bool:
"""同步取 1 个令牌:无令牌时 time.sleep 等待补充(阻塞当前线程)
timeout 为 None 时无限等待;超时返回 False
"""
deadline = None if timeout is None else time.monotonic() + timeout
while True:
if self.try_acquire():
return True
wait = self._wait_secs(deadline)
if wait < 0:
return False
time.sleep(wait)
# 按名称共享的限频器注册表:同名(如同一外部 API)的所有插件共用同一配额
_limiters: dict[str, RateLimiter] = {}
_registry_lock = threading.Lock()
def get_limiter(name: str, rate: float, capacity: int | None = None) -> RateLimiter:
"""获取(或创建)指定名称的限频器,同名共享同一配额
首次创建后,后续调用若 rate/capacity 与已有实例不一致会告警(以先创建者为准)。
"""
with _registry_lock:
limiter = _limiters.get(name)
if limiter is None:
limiter = RateLimiter(rate, capacity)
_limiters[name] = limiter
logger.debug(
f"限频器已创建: {name}"
f"(rate={limiter._rate}, capacity={limiter._capacity})"
)
elif (limiter._rate, limiter._capacity) != (
float(rate),
float(capacity if capacity is not None else max(rate, 1)),
):
logger.warning(
f"限频器 {name} 已存在(rate={limiter._rate}, "
f"capacity={limiter._capacity}),本次参数被忽略"
)
return limiter
async def acquire(
name: str,
rate: float,
capacity: int | None = None,
*,
wait: bool = True,
timeout: float | None = None,
) -> bool:
"""异步限频入口:获取(或创建)名为 name 的限频器并取令牌
wait=True 时等待令牌(最多 timeout 秒,None 为无限);
wait=False 时超限立即返回 False。
"""
limiter = get_limiter(name, rate, capacity)
if not wait:
return limiter.try_acquire()
return await limiter.acquire(timeout)
def acquire_sync(
name: str,
rate: float,
capacity: int | None = None,
*,
wait: bool = True,
timeout: float | None = None,
) -> bool:
"""同步限频入口(requests 等阻塞调用):语义同 acquire()"""
limiter = get_limiter(name, rate, capacity)
if not wait:
return limiter.try_acquire()
return limiter.acquire_sync(timeout)
def is_ratelimited(status_code: int | None) -> bool:
"""判断 HTTP 状态码是否为限流响应(403 或 429)"""
return status_code in _RATE_LIMIT_CODES