Files

252 lines
8.1 KiB
Python
Raw Permalink Normal View History

# -*- 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
2026-09-08 14:25:32 +08:00
_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
2026-09-08 14:25:32 +08:00
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 "",
2026-09-08 14:25:32 +08:00
"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()