337 lines
12 KiB
Python
337 lines
12 KiB
Python
"""插件热插拔管理:运行时加载/卸载/重载插件(仅超管)
|
||
|
||
通过直接导入模块并自行维护注册表,绕开 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()
|