Files
HeXi/hexi/core/plugin_manager.py
T
sansenhoshi 131b92b319 结构调整
视频解析多图/多媒体结构 消息体适配
2026-09-08 14:25:32 +08:00

337 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""插件热插拔管理:运行时加载/卸载/重载插件(仅超管)
通过直接导入模块并自行维护注册表,绕开 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()