结构调整

视频解析多图/多媒体结构 消息体适配
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
+1
View File
@@ -0,0 +1 @@
# 机器人插件目录
@@ -29,6 +29,7 @@ __plugin_meta__ = PluginMetadata(
解绑: /解绑 (解除你的QQ与当前绑定的EAID)
修改绑定: /修改绑定 EAID (修改你的QQ与当前绑定的EAID)
""".strip(),
type="application",
extra={
},
@@ -19,12 +19,12 @@ nonebot_plugin_galgame_card/
├── repository.py 数据层:仓储(来源无关,所有读写唯一入口)
├── processor.py 治理层:纯函数(五维提取/噪声/脱敏),可单测
├── config.py 插件配置(仅图片识别开关等;Web 鉴权统一走 /hub)
├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_auth)
├── web_hub.py Web API 子应用(挂载到 /api/galgame_card,auth=hexi.web_hub.web_auth)
├── DESIGN.md 设计定稿(数据模型/流水线/九段协议/证据纪律)
└── CLAUDE.md 本文件
```
**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。
**Web 管理后台**:已嵌入统一管理台 `/hub/`,前端由 `hexi/web` 渲染,API 挂载到 `/api/galgame_card`,鉴权与 /hub 共用 `hexi.web_hub.web_auth`(OAuth2 + SQLite)。功能:群开关、参与者增删、语料/印象/画像浏览与删除、清空群数据。改后端需重启 bot。
## 核心设计(速览,细节见 DESIGN.md)
@@ -30,8 +30,8 @@ from . import models # noqa: F401 # 确保表结构注册
from . import repository, processor
from . import handlers as _handlers # noqa: E402
from hexi.web_config import register_plugin_config # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from hexi.web_hub.web_config import register_plugin_config # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
from . import web_hub # noqa: E402
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
前端由统一管理台 hexi/web 渲染。
"""
@@ -12,7 +12,7 @@ from typing import Optional
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_auth import require_admin
from hexi.web_hub.web_auth import require_admin
from . import repository
@@ -41,7 +41,7 @@ async def _first_bot():
def build_admin_app() -> FastAPI | None:
"""构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。
鉴权由 hub 统一登录态提供(hexi.web_auth)。
鉴权由 hub 统一登录态提供(hexi.web_hub.web_auth)。
"""
app = FastAPI(title="Galgame Card API")
@@ -8,7 +8,7 @@ from __future__ import annotations
import sys as _sys
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
from . import constants
@@ -10,7 +10,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot.rule import Rule, is_type
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ...services.moderation import group_silence, member_silence, silence
from ...utils.message import get_at_id, has_at_all
@@ -6,7 +6,7 @@ from nonebot import on_fullmatch, on_startswith, require
from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ...services.profile import card_edit, group_name, title_get
from ...utils.message import get_at_id
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent, MessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ..utils.permissions import is_bot_manage, self_member_info
from ..utils.text import parse_seconds
@@ -2,7 +2,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
from ..utils.permissions import is_bot_manage, self_member_info
@@ -3,7 +3,7 @@ from nonebot.adapters.onebot.v11 import Bot, GroupMessageEvent
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import (
from hexi.core.custom_utils import (
check_bot_admin,
check_manage,
)
@@ -16,7 +16,7 @@ from .utils.war_renderer import render_war_briefing # noqa: F401
from .utils import gen_ms_img, pic2b64 # noqa: F401
from .services.equipment_store import init_db as _init_equipment_db
from .web import stratagem_admin as _stratagem_admin
from hexi.web_plugin_registry import register_web_plugin
from hexi.web_hub.web_plugin_registry import register_web_plugin
__plugin_meta__ = PluginMetadata(
name="绝地潜兵小助手",
@@ -47,7 +47,7 @@ register_web_plugin(
logger.info("战备管理 Web 插件已注册(/api/helldivers)")
# 统一配置标准:把 API 相关配置暴露给 Web 管理台(/hub 插件配置)
from hexi.config_standard import register_model_config # noqa: E402
from hexi.web_hub.config_standard import register_model_config # noqa: E402
from .config import config as _hd2_config # noqa: E402
register_model_config(
@@ -2,7 +2,7 @@
"""战备管理 API:作为统一 Web 的一个模块,挂到 /api/helldivers。
- 不再自己出 HTML,页面由统一前端 (hexi/web) 渲染。
- 鉴权统一走 hexi.web_auth(OAuth2 + SQLite)。
- 鉴权统一走 hexi.web_hub.web_auth(OAuth2 + SQLite)。
"""
from __future__ import annotations
@@ -12,7 +12,7 @@ from pathlib import Path
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse
from hexi.web_auth import require_admin
from hexi.web_hub.web_auth import require_admin
from ..services.equipment_store import (
create_stratagem,
@@ -1,26 +0,0 @@
from nonebot import get_driver, logger
from nonebot.plugin import PluginMetadata
__plugin_meta__ = PluginMetadata(
name="HeXi 核心工具",
description="HeXi 公共工具库:权限分级、消息解析、限频器、命令冷却、插件热插拔等",
usage="工具库 + 帮助/插件管理命令(帮助、插件列表、加载/卸载/重载插件)",
type="application",
)
# 显式导入子模块注册 matcher(load_plugins 只加载到插件包层,不会自动导入包内模块)
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 规则")
@@ -1,132 +0,0 @@
"""命令冷却(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.plugins.nonebot_plugin_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.plugins.nonebot_plugin_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.plugins.nonebot_plugin_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
@@ -1,60 +0,0 @@
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
@@ -1,416 +0,0 @@
# 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. 保持轻量——只做薄封装,不改变底层行为
```
@@ -1,156 +0,0 @@
# 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/plugins/nonebot_plugin_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 封装
@@ -1,43 +0,0 @@
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 表示命令参数为空或无法解析
@@ -1,110 +0,0 @@
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)
@@ -1,250 +0,0 @@
# -*- 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[2] # 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_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 "",
"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()
@@ -1,308 +0,0 @@
"""插件热插拔管理:运行时加载/卸载/重载插件(仅超管)
通过直接导入模块并自行维护注册表,绕开 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,
PluginMetadata,
_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.plugins.nonebot_plugin_hexi_core.message_utils import send_forward_msg
from . import plugin_control
__plugin_meta__ = PluginMetadata(
name="插件管理",
description="热插拔插件:运行时加载/卸载/重载插件(仅超管)",
usage="插件列表 / 加载插件 <模块名> / 卸载插件 <模块名> / 重载插件 <模块名>",
type="application",
)
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 hot_load(name: str) -> bool:
"""热加载插件模块(直接 import,matcher 随之注册)"""
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 插件注册表为准查找。
"""
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:
"""热重载插件:先卸载再加载"""
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()
@@ -1,181 +0,0 @@
"""限频器(令牌桶):为依赖外部 API 的插件限速,避免请求过频被限流返回 403/429
纯逻辑实现,不依赖 NoneBot 运行时;同步(requests)与异步(httpx/aiohttp)均可用。
用法:
from hexi.plugins.nonebot_plugin_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
@@ -7,7 +7,7 @@ require("nonebot_plugin_orm")
from .config import NICKNAME, config_manager, register_config as _register_learning_config # noqa: E402
from . import web_hub # noqa: E402
from hexi.web_plugin_registry import register_web_plugin # noqa: E402
from hexi.web_hub.web_plugin_registry import register_web_plugin # noqa: E402
# 注册到统一 Web 管理台(/hub):hub 启动时挂载 /api/learning_chat
register_web_plugin(
@@ -113,7 +113,7 @@ def register_config() -> None:
getter/setter 直接读写 config_manager.config,并保存到 learning_chat.yml。
权威源是插件自身的 yml,故 nosave 不写入 plugin_config.json。
"""
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
def _get(key):
return getattr(config_manager.config, key)
@@ -6,7 +6,7 @@ import time
from dataclasses import dataclass
from functools import cached_property, cmp_to_key
# from ..nonebot_plugin_hexi_core.MessageUtils import send_poke
# from ..core.MessageUtils import send_poke
try:
import jieba_fast.analyse as jieba_analyse
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""群聊学习 Web API 子应用(挂载到 /api/learning_chat)。
统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
统一鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
前端由统一管理台 hexi/web 渲染。
"""
@@ -22,7 +22,7 @@ from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
from nonebot_plugin_orm import get_session
from hexi.web_auth import require_admin
from hexi.web_hub.web_auth import require_admin
from .services.learn import LearningChat
from .models import ChatMessage, ChatContext, ChatAnswer, ChatBlackList
@@ -5,7 +5,7 @@ from nonebot.plugin import PluginMetadata
from .config import Config, pc, save_file # noqa: E402,F401
# 统一配置标准:把管理 QQ / 机器人 QQ / 数据文件名暴露给 Web 管理台
from hexi.config_standard import register_model_config # noqa: E402
from hexi.web_hub.config_standard import register_model_config # noqa: E402
register_model_config(
__name__,
@@ -7,7 +7,7 @@ from __future__ import annotations
import sys as _sys
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
_PLUGIN_ID = __package__
@@ -18,7 +18,7 @@ __plugin_meta__ = PluginMetadata(
)
# 统一配置标准:把「插件内部常量」也暴露给 Web 管理台(来源无关)。
from hexi.config_standard import register_config_items # noqa: E402
from hexi.web_hub.config_standard import register_config_items # noqa: E402
from . import config as _cfgmod # noqa: E402
register_config_items(
@@ -20,7 +20,7 @@ from .templates import load_builtin_templates, loaded_templates
# 统一配置标准:把常用、可安全热更的配置暴露给 Web 管理台
# (路径/嵌套模型/复杂 list 字段不改,避免格式互转出错)
from hexi.config_standard import register_model_config # noqa: E402
from hexi.web_hub.config_standard import register_model_config # noqa: E402
register_model_config(
__name__,
@@ -9,7 +9,7 @@ from __future__ import annotations
import sys as _sys
from hexi.config_standard import register_config_items
from hexi.web_hub.config_standard import register_config_items
# plugin_id = NoneBot 插件模块名
_PLUGIN_ID = __package__
@@ -13,7 +13,7 @@ from nonebot.plugin.on import on_notice, on_command
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage, get_target
from hexi.plugins.nonebot_plugin_hexi_core.message_utils import send_poke
from hexi.core.message_utils import send_poke
from .. import config as _cfg
from ..services.store import add_new_jm_code, get_random_jm_code
@@ -8,7 +8,7 @@ from nonebot.log import logger
from nonebot.plugin.on import on_keyword
from nonebot_plugin_alconna import UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.message_utils import get_reply_message
from hexi.core.message_utils import get_reply_message
from ..services.gif import reverse_gif_bytes
@@ -6,12 +6,9 @@ from io import BytesIO
from pathlib import Path
from nonebot.log import logger
from PIL import Image as PILImage
from nonebot.params import Depends
from nonebot.params import CommandArg
from nonebot import on_command, require
from nonebot.permission import SUPERUSER
from nonebot import require
from typing import Optional, List, Dict
from nonebot.adapters import Message, Event
from nonebot.adapters import Event
from nonebot.plugin import PluginMetadata, inherit_supported_adapters
require("nonebot_plugin_alconna")
@@ -20,9 +17,8 @@ require("nonebot_plugin_apscheduler")
from nonebot_plugin_alconna.uniseg import get_target as uni_get_target
import nonebot_plugin_localstore as store
from nonebot_plugin_apscheduler import scheduler
from nonebot_plugin_alconna import Text, Image, UniMessage, Target, At
from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown
from nonebot_plugin_alconna import Text, Image, UniMessage, Target
from nonebot.adapters.onebot.v11 import Bot
from .config import Config
from .models import ProcessedPlayer
from .data_source import BindData, SteamInfoData, ParentData, DisableParentData
@@ -122,7 +118,7 @@ except Exception as e:
logger.error(f"初始化失败: {e}")
# 统一配置标准:把常见可调配置暴露给 Web 管理台
from hexi.config_standard import register_model_config # noqa: E402
from hexi.web_hub.config_standard import register_model_config # noqa: E402
register_model_config(
__name__,
@@ -4,14 +4,14 @@ from __future__ import annotations
import asyncio
from nonebot import logger, on_command, require
from nonebot import logger, on_command
from nonebot.adapters import Message, Event
from nonebot.adapters.onebot.v11 import Bot, MessageEvent, MessageSegment
from nonebot.params import CommandArg, Depends
from nonebot.permission import SUPERUSER
from nonebot_plugin_alconna import At, Image, Target, Text, UniMessage
from nonebot_plugin_alconna import At, Image, Target, UniMessage
from hexi.plugins.nonebot_plugin_hexi_core.cooldown import cooldown
from hexi.core.cooldown import cooldown
# 业务/状态在插件根 __init__ 中(本插件为社区插件,服务层保留在根模块)
from .. import (
@@ -36,11 +36,8 @@ from .. import (
get_user_data,
image_to_bytes,
key_select,
key_select_2,
localize_game_name,
parent_data,
simplize_steam_player_data,
steam_info_data,
_fill_missing_icon_hashes,
_query_game_names,
_upsert_game_icon_hashes,
@@ -9,7 +9,7 @@ from typing import List, Optional, Dict
from datetime import datetime, timezone
import asyncio
import json
from hexi.plugins.nonebot_plugin_hexi_core.rate_limit import acquire
from hexi.core.rate_limit import acquire
from .config import Config
@@ -22,7 +22,7 @@ from nonebot import logger, require, get_plugin_config, on_command
from nonebot.adapters.onebot.v11 import Bot, MessageEvent
from pydantic import BaseModel
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_manage
from hexi.core.custom_utils import check_manage
require("nonebot_plugin_apscheduler")
from nonebot_plugin_apscheduler import scheduler
@@ -1,4 +1,4 @@
"""统一配置注册:把插件配置按文档标准接入 hexi.web_config(Web 可读可改)。
"""统一配置注册:把插件配置按文档标准接入 hexi.web_hub.web_config(Web 可读可改)。
- temp 清理配置(env pydantic) → register_model_config
- S3 存储配置(原硬编码在 services/storage/s3.py) → register_config_items(store=s3 模块)
@@ -7,7 +7,7 @@
from __future__ import annotations
from hexi.config_standard import register_config_items, register_model_config
from hexi.web_hub.config_standard import register_config_items, register_model_config
from . import cleanup, list_proc # noqa: F401
from .services.storage import s3 as _s3mod
@@ -68,13 +68,18 @@ async def parse_douyin(
logger.info(f"短链重定向: {target_url} -> {resolved}")
target_url = resolved
else:
# 全链接: www.douyin.com/note/xxx 或 www.douyin.com/video/xxx
# 全链接: www.douyin.com/note/xxx、www.douyin.com/video/xxx,
# 以及抖音复制出的 iesdouyin.com/share/video|note/xxx(弱网/无网下 App
# 生成的恢复连接同样会是这个形态)。统一提取出作品 id 后再交给 SSR / playwright。
m = re.search(
r"(https?://(?:www\.)?douyin\.com/(?:note|video)/\d+)", url
r"(https?://(?:www\.)?(?:m\.)?(?:ies)?douyin\.com/(?:share/)?(note|video)/(\d+))",
url,
)
if not m:
return None, None, False
target_url = m.group(1)
# 统一规范化为 www.douyin.com/{note|video}/{id},避免 iesdouyin 分享页
# 在无登录态/弱网下只返回壳页,直接落到浏览器可正常打开的作品页。
target_url = f"https://www.douyin.com/{m.group(2)}/{m.group(3)}"
try:
is_img_post = False
@@ -179,6 +179,12 @@ async def dispatch_url(
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
try:
title, parsed_path, image_post = await parse_douyin(url)
if not title and not parsed_path:
# 链接识别成功但未能获取到作品(网络/风控/链接失效等),
# 给出明确失败提示,避免只发"请稍候"后无下文。
await UniMessage.text(f"无法解析到媒体:{url}").send()
logger.warning(f"媒体解析:未能获取到作品:{url}")
return None, None
return await process_douyin_res(
title, parsed_path, is_private, image_post, plan=plan
)
@@ -1,20 +1,15 @@
import json
import random
import asyncio
import os
from typing import List
from nonebot.adapters.onebot.v11 import MessageSegment, PokeNotifyEvent, Message
from nonebot.plugin.on import on_notice, on_command
from nonebot import on_type, on_message, logger
from nonebot.plugin.on import on_command
from nonebot import logger
from nonebot.adapters import Event
from nonebot.matcher import Matcher
from nonebot.params import CommandArg
from nonebot.rule import to_me
from nonebot_plugin_alconna import UniMessage, get_target
from hexi.plugins.nonebot_plugin_hexi_core.custom_utils import check_admin
# from hexi.plugins.nonebot_plugin_hexi_core.MessageUtils import send_poke
from hexi.core.custom_utils import check_admin
# from hexi.core.message_utils import send_poke
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FILE_PATH = os.path.join(BASE_DIR, "data", "list.json")
@@ -7,7 +7,7 @@ import boto3
from botocore.config import Config
from nonebot import logger
from hexi.web_config import get_effective_value
from hexi.web_hub.web_config import get_effective_value
# 该插件模块名,用于读取统一配置值库(Web 修改后生效)
_PLUGIN_ID = "hexi.plugins.nonebot_plugin_video_analysis"
@@ -1,616 +0,0 @@
# -*- coding: utf-8 -*-
"""统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件。
- 插件在自己 __init__ 里调用 hexi.web_plugin_registry.register_web_plugin(...) 注册
- hub 启动时自动挂载每个插件 API 到 /api/<id>,无需手动配置
- /hub/api/plugins 由注册中心聚合返回
"""
from __future__ import annotations
from pathlib import Path
import asyncio
import json
import logging as _logging
import os
import socket
import subprocess
import sys
import threading
import time
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
from starlette.staticfiles import StaticFiles
from nonebot import get_driver, logger
from nonebot.adapters.onebot.v11 import Adapter
from nonebot.plugin import PluginMetadata
from hexi.web_auth import (
authenticate,
change_password,
get_current_user,
get_user_by_token,
issue_token,
oauth2_scheme,
revoke_token,
sync_admin,
)
from hexi.web_plugin_registry import get_web_plugin_builder, list_web_plugins
from .dashboard import collect_dashboard
__plugin_meta__ = PluginMetadata(
name="HeXi Web Hub",
description="统一 Web 管理系统(/hub):自动聚合所有注册过的 Web 插件",
usage="/hub 进入管理台",
type="application",
)
basic_path = Path(__file__).resolve().parent
# hexi/web 是统一 Web 管理台前端(hexi/web/dist),不是插件目录下的 web
WEB_DIST = Path(__file__).resolve().parents[2] / "web" / "dist"
def _hub_version() -> str:
"""管理台版本号:以 hexi/web/package.json 为唯一来源(前端构建也读它)。"""
try:
pkg = WEB_DIST.parent / "package.json"
return str(json.loads(pkg.read_text(encoding="utf-8")).get("version", "0.1.0"))
except Exception: # noqa: BLE001
return "0.1.0"
# 仓库根目录(bot.py 所在)与 bot 端口
_REPO_ROOT = Path(__file__).resolve().parents[3]
_BOT_PORT = 39697
def _spawn_bot() -> None:
"""以分离进程方式拉起 bot.py:等旧实例释放端口后接管,日志追加到 _bot_run.log。"""
try:
log_fp = open(_REPO_ROOT / "_bot_run.log", "ab", buffering=0)
except OSError:
log_fp = None
# 子进程 bootstrap:等端口空闲 -> 直接以 __main__ 运行 bot.py(该进程即 bot,不残留 wrapper)
bootstrap = (
"import socket,time,sys,runpy\n"
"port=%d\n" % _BOT_PORT
+ "for _ in range(120):\n"
" s=socket.socket()\n"
" try:\n"
" s.bind(('0.0.0.0',port)); s.close(); break\n"
" except OSError:\n"
" s.close(); time.sleep(0.5)\n"
"else:\n"
" sys.exit(2)\n"
"runpy.run_path('bot.py', run_name='__main__')\n"
)
flags = 0
if hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
flags |= subprocess.CREATE_NEW_PROCESS_GROUP
if hasattr(subprocess, "DETACHED_PROCESS"):
flags |= subprocess.DETACHED_PROCESS
env = {**os.environ, "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
subprocess.Popen(
[sys.executable, "-c", bootstrap],
cwd=str(_REPO_ROOT),
stdout=log_fp,
stderr=subprocess.STDOUT,
creationflags=flags,
env=env,
)
def _archive_log() -> None:
"""把当前 _bot_run.log 归档到 hexi/logs/bot_run_<时间戳>.log,并把原文件清空(供新运行)。"""
log = _REPO_ROOT / "_bot_run.log"
if not log.exists():
return
logs_dir = _REPO_ROOT / "hexi" / "logs"
try:
logs_dir.mkdir(parents=True, exist_ok=True)
data = log.read_bytes()
if data:
ts = time.strftime("%Y%m%d_%H%M%S")
target = logs_dir / f"bot_run_{ts}.log"
target.write_bytes(data)
logger.info(f"已归档旧日志 -> {target.name}")
# 清空原日志,新 run 从空文件开始
open(log, "wb").close()
except OSError as e: # noqa: BLE001
logger.warning(f"归档日志失败: {type(e).__name__}: {e}")
def _tail_log_lines(
path: Path, limit: int, before: int | None
) -> tuple[list[str], int, int]:
"""从日志文件读取最近 limit 行(或 before 字节之前最近的 limit 行)。
返回 (lines, window_start_offset, file_size)。window_start_offset 可作为
下一批更早日志的 before;为 0 表示已到文件头。仅按 8KB 步进向后寻找行边界,
避免大日志全量 read_bytes。逐行 UTF-8 优先、GBK 兜底:兼容旧 GBK 日志与
切换为 UTF-8 后的新日志混存(保留 ANSI 颜色码,交给前端渲染颜色)。
"""
size = path.stat().st_size
end = size if before is None or before <= 0 or before > size else int(before)
if end <= 0:
return [], 0, size
start = 0
with open(path, "rb") as f:
pos = end
newline_count = 0
while pos > 0 and newline_count <= limit:
step = min(pos, 8192)
f.seek(pos - step)
chunk = f.read(step)
idx = chunk.rfind(b"\n")
if idx == -1:
pos -= step
continue
nl_abs = pos - step + idx
newline_count += 1
if newline_count > limit:
start = nl_abs + 1
break
pos = nl_abs
f.seek(start)
raw = f.read(end - start)
raw_lines = raw.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines.pop()
lines = []
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
lines.append(line.rstrip("\r"))
return lines, start, size
def _count_log_lines(path: Path) -> int:
"""分块统计全文件行数(仅换行计数,不全量载入)。"""
count = 0
with open(path, "rb") as f:
while True:
chunk = f.read(1 << 20)
if not chunk:
break
count += chunk.count(b"\n")
return count
def _schedule_bot_action(action: str) -> None:
"""1 秒后执行:stop 直接退出;restart 先拉起新进程再退出旧进程。"""
def _work() -> None:
time.sleep(1.0)
_archive_log()
if action == "restart":
_spawn_bot()
time.sleep(1.0)
os._exit(0)
threading.Thread(target=_work, daemon=True).start()
def _uvicorn_access_debug(record: _logging.LogRecord) -> bool:
"""把 uvicorn 访问日志级别改为 DEBUG:默认 INFO 下不显示,开启 DEBUG 才可见。"""
if record.name == "uvicorn.access":
record.levelno = _logging.DEBUG
record.levelname = "DEBUG"
return True
class PasswordModel(BaseModel):
old_password: str
new_password: str
class PluginControlModel(BaseModel):
enabled: bool | None = None
chat: list[str] | None = None
class BotConfigModel(BaseModel):
superusers: list[str] | None = None
nickname: list[str] | None = None
debug: bool | None = None
log_level: str | None = None
async def _first_bot():
"""取第一个 OneBot V11 bot,无则 None。"""
try:
from nonebot import get_adapter
bots = get_adapter(Adapter).bots
return next(iter(bots.values()), None)
except Exception: # noqa: BLE001
return None
def build_hub_app() -> FastAPI:
sync_admin()
app = FastAPI(title="HeXi Web Hub")
@app.post("/api/auth/token")
async def token_endpoint(form: OAuth2PasswordRequestForm = Depends()):
user_id = authenticate(form.username, form.password)
if user_id is None:
raise HTTPException(status_code=400, detail="用户名或密码错误")
token, expires_in = issue_token(user_id)
return {"access_token": token, "token_type": "bearer", "expires_in": expires_in}
@app.get("/api/auth/me")
async def me(user: dict = Depends(get_current_user)):
return {"ok": True, "username": user["username"]}
@app.post("/api/auth/logout")
async def logout(token: str = Depends(oauth2_scheme)):
if token and get_user_by_token(token):
revoke_token(token)
return {"ok": True}
@app.get("/api/plugins")
async def plugins(_: dict = Depends(get_current_user)):
return {"plugins": list_web_plugins()}
@app.get("/api/settings")
async def settings(user: dict = Depends(get_current_user)):
return {
"ok": True,
"user": {
"id": user["id"],
"username": user["username"],
"created_at": user.get("created_at", ""),
},
"plugins": list_web_plugins(),
"version": _hub_version(),
}
@app.get("/api/dashboard")
async def dashboard(_: dict = Depends(get_current_user)):
try:
data = await collect_dashboard()
except Exception as e: # noqa: BLE001
logger.warning(f"Dashboard 采集失败: {type(e).__name__}: {e}")
return {"ok": False, "msg": f"采集失败: {e}"}
return {"ok": True, **data}
@app.get("/api/plugins/catalog")
async def plugins_catalog(_: dict = Depends(get_current_user)):
"""全部 application 插件 + 控制面状态 + 是否带 Web 页面。"""
from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import list_plugins
from hexi.web_config import has_schema
from hexi.web_plugin_registry import get_web_plugin_by_module
items = list_plugins()
for it in items:
web = get_web_plugin_by_module(it["id"])
it["has_web"] = bool(web)
it["web_path"] = "/hub/" + web["id"] if web else None
it["has_config"] = has_schema(it["id"])
return {"plugins": items}
@app.get("/api/groups")
async def groups(_: dict = Depends(get_current_user)):
"""OneBot 当前群列表(分群控制用)。"""
bot = await _first_bot()
if bot is None:
return {"items": []}
try:
gl = await bot.get_group_list()
except Exception as e: # noqa: BLE001
logger.warning(f"获取群列表失败: {type(e).__name__}: {e}")
return {"items": []}
return {
"items": [
{"group_id": int(g["group_id"]), "group_name": g.get("group_name", "")}
for g in gl
]
}
@app.post("/api/plugins/{plugin_id}/global")
async def plugin_global(
plugin_id: str,
payload: PluginControlModel,
_: dict = Depends(get_current_user),
):
from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import set_global
ctl = set_global(plugin_id, enabled=payload.enabled, chat=payload.chat)
return {"ok": True, "control": ctl}
@app.post("/api/plugins/{plugin_id}/groups/{group_id}")
async def plugin_group(
plugin_id: str,
group_id: str,
payload: PluginControlModel,
_: dict = Depends(get_current_user),
):
from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import set_group
ctl = set_group(
plugin_id, group_id, enabled=payload.enabled, chat=payload.chat
)
return {"ok": True, "control": ctl}
@app.delete("/api/plugins/{plugin_id}/groups/{group_id}")
async def plugin_group_remove(
plugin_id: str,
group_id: str,
_: dict = Depends(get_current_user),
):
from hexi.plugins.nonebot_plugin_hexi_core.plugin_control import remove_group
ctl = remove_group(plugin_id, group_id)
return {"ok": True, "control": ctl}
@app.get("/api/plugins/{plugin_id}/config")
async def plugin_config_get(
plugin_id: str, _: dict = Depends(get_current_user)
):
from hexi.web_config import get_config
cfg = get_config(plugin_id)
if cfg is None:
return {"ok": False, "msg": "该插件未注册配置 schema"}
return {"ok": True, **cfg}
@app.post("/api/plugins/{plugin_id}/config")
async def plugin_config_set(
plugin_id: str,
payload: dict,
_: dict = Depends(get_current_user),
):
from hexi.web_config import save_config
payload = payload or {}
if "revision" not in payload:
raise HTTPException(
status_code=status.HTTP_428_PRECONDITION_REQUIRED,
detail="缺少配置 revision,请先读取最新配置",
)
values = payload.get("values") or {}
try:
cfg = save_config(
plugin_id, values, expected_revision=int(payload["revision"])
)
except RuntimeError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
except (TypeError, ValueError) as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
return {"ok": True, **cfg}
@app.get("/api/system/bot_config")
async def bot_config_get(_: dict = Depends(get_current_user)):
"""读取 bot 全局配置(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。"""
from hexi.plugins.nonebot_plugin_web_hub.bot_settings import current_values
return {"ok": True, **current_values()}
@app.post("/api/system/bot_config")
async def bot_config_set(
payload: BotConfigModel,
_: dict = Depends(get_current_user),
):
"""保存 bot 全局配置并尽量热应用到运行态。"""
from hexi.plugins.nonebot_plugin_web_hub.bot_settings import save_bot_settings
try:
result = save_bot_settings(payload.dict(exclude_none=True))
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e)) from e
return result
@app.post("/api/settings/password")
async def settings_password(
payload: PasswordModel,
token: str = Depends(oauth2_scheme),
user: dict = Depends(get_current_user),
):
if len(payload.new_password) < 6:
raise HTTPException(status_code=400, detail="新密码长度不能少于 6 位")
if not change_password(
user["id"], payload.old_password, payload.new_password, keep_token=token
):
raise HTTPException(status_code=400, detail="原密码错误")
return {"ok": True, "msg": "密码已修改"}
@app.get("/api/logs")
async def logs(
limit: int = 500,
before: int | None = None,
_: dict = Depends(get_current_user),
):
"""读取 bot 运行日志(优先 _bot_run.log)。
- before 不传:返回最近 limit 行,offset/end 为文件尾(SSE 以 end 续读)
- before 传字节偏移:返回该偏移之前最近 limit 行,offset 为这些行的起点,
继续把 offset 传给 before 即可向前翻页;offset==0 表示已到文件头。
"""
root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [
root / "_bot_run.log",
root / "hexi" / "logs" / "bot.log",
]
path = next((p for p in candidates if p.exists()), None)
if path is None:
return {"ok": False, "msg": "未找到日志文件", "lines": [], "size": 0, "total": 0}
try:
lines, start, size = _tail_log_lines(path, max(1, int(limit)), before)
total = _count_log_lines(path)
except OSError as e: # noqa: BLE001
return {"ok": False, "msg": f"读取日志失败: {e}", "lines": [], "size": 0, "total": 0}
return {
"ok": True,
"file": path.name,
"size": size,
"end": size,
"offset": start,
"has_more": start > 0,
"lines": lines,
"total": total,
}
@app.get("/api/dashboard/stream")
async def dashboard_stream(
_: dict = Depends(get_current_user),
):
"""Dashboard 状态 SSE 实时推送(每 2 秒采集一次)。"""
async def gen():
while True:
try:
data = await collect_dashboard()
payload = {"ok": True, **data}
except Exception as e: # noqa: BLE001
payload = {"ok": False, "msg": f"采集失败: {e}"}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(2)
return StreamingResponse(gen(), media_type="text/event-stream")
@app.get("/api/logs/stream")
async def logs_stream(
since: int = 0,
_: dict = Depends(get_current_user),
):
"""SSE 实时日志流:从 since(字节偏移)增量推送新日志行。"""
root = Path(__file__).resolve().parents[3] # 仓库根目录
candidates = [
root / "_bot_run.log",
root / "hexi" / "logs" / "bot.log",
]
path = next((p for p in candidates if p.exists()), None)
if path is None:
raise HTTPException(status_code=404, detail="未找到日志文件")
async def gen():
current = max(0, since)
beat = 0
await asyncio.sleep(0)
while True:
try:
size = path.stat().st_size
except OSError:
await asyncio.sleep(1.0)
continue
if current > size:
current = 0 # 日志被轮转/截断
if current < size:
try:
with open(path, "rb") as f:
f.seek(current)
raw = f.read()
except OSError:
await asyncio.sleep(1.0)
continue
last_nl = raw.rfind(b"\n")
if last_nl != -1:
# 只处理完整行,结尾半行留给下次;保留 ANSI 颜色码供前端渲染
data = raw[: last_nl + 1]
raw_lines = data.split(b"\n")
if raw_lines and raw_lines[-1] == b"":
raw_lines = raw_lines[:-1]
current = current + last_nl + 1
for raw_line in raw_lines:
try:
line = raw_line.decode("utf-8")
except UnicodeDecodeError:
line = raw_line.decode("gbk", errors="replace")
payload = {"line": line.rstrip("\r"), "offset": current}
yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.5)
beat += 1
if beat >= 30: # 每 ~15s 一次心跳保活
beat = 0
yield ": ping\n\n"
return StreamingResponse(gen(), media_type="text/event-stream")
@app.post("/api/system/bot")
async def bot_action(
payload: dict,
_: dict = Depends(get_current_user),
):
"""停止/重启 bot 进程(stop 直接退出;restart 先拉起新进程再退出旧进程)。"""
action = (payload or {}).get("action", "")
if action not in {"stop", "restart"}:
raise HTTPException(status_code=400, detail="action 必须是 stop 或 restart")
_schedule_bot_action(action)
label = "停止" if action == "stop" else "重启"
return {"ok": True, "msg": f"已请求{label} bot,约 1 秒后执行"}
assets = WEB_DIST / "assets"
if assets.exists():
app.mount("/assets", StaticFiles(directory=str(assets)), name="hub_assets")
@app.get("/")
async def index():
if (WEB_DIST / "index.html").exists():
return FileResponse(WEB_DIST / "index.html")
return HTMLResponse(
"<h1>HeXi Web Hub</h1><p>前端未构建,请在 <code>hexi/web</code> 执行 "
"<code>npm run build</code>。</p>"
)
@app.get("/{path:path}")
async def spa(path: str):
target = (WEB_DIST / path).resolve()
if path and target.is_file() and target.is_relative_to(WEB_DIST.resolve()):
return FileResponse(target)
if (WEB_DIST / "index.html").exists():
return FileResponse(WEB_DIST / "index.html")
raise HTTPException(status_code=404, detail="页面不存在")
return app
def _mount_all_web_plugins() -> None:
"""启动时挂载所有已注册插件的 API 到 /api/<id>。"""
try:
from nonebot import get_app
for plugin in list_web_plugins():
builder = get_web_plugin_builder(plugin["id"])
if not builder:
continue
try:
sub_app = builder()
if sub_app is not None:
get_app().mount("/api/" + plugin["id"], sub_app)
logger.info(f"Web 插件 API 已挂载: /api/{plugin['id']}")
except Exception as e: # noqa: BLE001
logger.warning(f"Web 插件 {plugin['id']} 挂载失败: {type(e).__name__}: {e}")
except Exception as e: # noqa: BLE001
logger.warning(f"Web 插件自动挂载失败: {type(e).__name__}: {e}")
def mount_hub() -> None:
try:
from nonebot import get_app
get_app().mount("/hub", build_hub_app())
logger.info("统一 Web 管理系统已挂载: /hub")
except Exception as e: # noqa: BLE001
logger.warning(f"统一 Web 管理系统挂载失败: {type(e).__name__}: {e}")
mount_hub()
@get_driver().on_startup
async def _startup_mount_web_plugins() -> None:
"""等所有插件 import 完成后,注册表就绪,再统一挂载各插件 API。"""
_mount_all_web_plugins()
@get_driver().on_startup
async def _downgrade_uvicorn_access_logs() -> None:
"""把 uvicorn.access 日志降级为 DEBUG(经 loguru 后按日志等级过滤)。"""
_logging.getLogger("uvicorn.access").addFilter(_uvicorn_access_debug)
@@ -1,200 +0,0 @@
# -*- coding: utf-8 -*-
"""Bot 全局配置的读取 / 写入(SUPERUSERS / NICKNAME / DEBUG / LOG_LEVEL)。
- 读取:优先取 NoneBot driver.config 的当前生效值,退化到 .env / os.environ。
- 写入:落盘到 .env(保留 JSON 列表格式),写 os.environ(即时可读),
并在可能范围内热应用到 driver.config 与 loguru(LOG_LEVEL)。
- 注意:个别插件在 import 时把 SUPERUSERS/NICKNAME 拷贝成了常量,
想让所有插件完全生效建议保存后重启 bot(前端会给出提示)。
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from nonebot import get_driver, logger
_REPO_ROOT = Path(__file__).resolve().parents[3]
# 仓库根目录的 .env(NoneBot 启动时自动加载)
_ENV_PATH = _REPO_ROOT / ".env"
# 表单键 -> 环境变量名
_KEY_ENV: dict[str, str] = {
"superusers": "SUPERUSERS",
"nickname": "NICKNAME",
"debug": "DEBUG",
"log_level": "LOG_LEVEL",
}
# LOG_LEVEL 可选项(写入时建议大写)
LOG_LEVEL_OPTIONS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
def _env_get(name: str, default: str = "") -> str:
"""os.environ > .env 文件 > default。"""
val = os.getenv(name)
if val:
return val
if _ENV_PATH.exists():
for line in _ENV_PATH.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line.startswith(name + "=") or line.startswith(name + " ="):
return line.split("=", 1)[1].strip().split(" #", maxsplit=1)[0].strip()
return default
def _parse_list(value: str) -> list[str]:
"""把 env 里可能是 JSON 数组或逗号/空白分隔的列表解析成 list[str]。"""
value = (value or "").strip()
if not value:
return []
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return [str(x) for x in parsed]
return [str(parsed)]
except Exception:
# 兼容逗号/空格分隔、带引号的形式
return [
part.strip().strip("\"'").strip()
for part in value.replace(" ", ",").split(",")
if part.strip()
]
def _serialize_list(items: list[str]) -> str:
"""序列化成 .env 使用的 JSON 数组字符串。"""
return json.dumps([str(x) for x in items], ensure_ascii=False)
def _normalize_log_level(value: Any) -> str:
"""把 log_level 统一成大写字符串。"""
if isinstance(value, int):
# loguru 数字等级:20=DEBUG 30=INFO 40=WARNING 50=ERROR
mapping = {10: "TRACE", 20: "DEBUG", 30: "INFO", 40: "WARNING", 50: "ERROR", 60: "CRITICAL"}
return mapping.get(value, "INFO")
return str(value).upper()
def current_values() -> dict[str, Any]:
"""返回当前生效值(读取优先级:driver.config > .env)。"""
try:
cfg = get_driver().config
except Exception:
cfg = None
if cfg is not None:
superusers = sorted(str(x) for x in getattr(cfg, "superusers", []) or [])
nickname = sorted(str(x) for x in getattr(cfg, "nickname", []) or [])
debug = bool(getattr(cfg, "debug", False))
log_level = _normalize_log_level(getattr(cfg, "log_level", "INFO"))
else:
superusers = _parse_list(_env_get("SUPERUSERS"))
nickname = _parse_list(_env_get("NICKNAME"))
debug = _env_get("DEBUG", "").strip().lower() in ("1", "true", "yes", "on")
log_level = _normalize_log_level(_env_get("LOG_LEVEL", "INFO"))
return {
"superusers": superusers,
"nickname": nickname,
"debug": bool(debug),
"log_level": log_level,
"log_level_options": LOG_LEVEL_OPTIONS,
"source": "config" if cfg is not None else "env",
}
def _set_env_line(name: str, value: str) -> None:
"""替换 .env 中已有的 name= 行(保留其它行),不存在则追加。"""
try:
if _ENV_PATH.exists():
lines = _ENV_PATH.read_text(encoding="utf-8").splitlines()
else:
lines = []
replaced = False
out: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith(name + "=") or stripped.startswith(name + " ="):
if not replaced:
out.append(f"{name}={value}")
replaced = True
# 重复行跳过
else:
out.append(line)
if not replaced:
out.append(f"{name}={value}")
out_text = "\n".join(out).rstrip("\n") + "\n"
_ENV_PATH.write_text(out_text, encoding="utf-8")
except Exception as e: # noqa: BLE001
logger.warning(f"写入 .env({name}) 失败: {type(e).__name__}: {e}")
def _apply_log_level(log_level: str) -> None:
"""把 LOG_LEVEL 热应用到 loguru 的默认过滤配置。"""
try:
from nonebot.log import logger as nb_logger
nb_logger.configure(extra={"nonebot_log_level": log_level})
except Exception as e: # noqa: BLE001
logger.warning(f"热应用 LOG_LEVEL 失败: {type(e).__name__}: {e}")
def save_bot_settings(values: dict[str, Any]) -> dict[str, Any]:
"""保存并尽量热应用。返回(新的当前值 + 提示)。"""
# 规范化输入
superusers = [str(x) for x in (values.get("superusers") or []) if str(x).strip()]
nickname = [str(x) for x in (values.get("nickname") or []) if str(x).strip()]
debug = bool(values.get("debug", False))
log_level = _normalize_log_level(values.get("log_level", "INFO"))
if log_level not in LOG_LEVEL_OPTIONS:
raise ValueError(f"LOG_LEVEL 必须是 {'/'.join(LOG_LEVEL_OPTIONS)} 之一")
# 1. 写 os.environ(即时可读,进程内 getenv 立刻生效)
os.environ["SUPERUSERS"] = _serialize_list(superusers)
os.environ["NICKNAME"] = _serialize_list(nickname)
os.environ["DEBUG"] = "true" if debug else "false"
os.environ["LOG_LEVEL"] = log_level
# 2. 落盘 .env(重启后依然生效)
_set_env_line("SUPERUSERS", _serialize_list(superusers))
_set_env_line("NICKNAME", _serialize_list(nickname))
_set_env_line("DEBUG", "true" if debug else "false")
_set_env_line("LOG_LEVEL", log_level)
# 3. 热应用到 driver.config
applied: list[str] = []
try:
cfg = get_driver().config
if hasattr(cfg, "superusers"):
cfg.superusers = set(superusers)
applied.append("SUPERUSERS")
if hasattr(cfg, "nickname"):
cfg.nickname = set(nickname)
applied.append("NICKNAME")
# DEBUG 在 NoneBot Config 中属于 extra 字段,直接透传
try:
setattr(cfg, "debug", debug)
applied.append("DEBUG")
except Exception:
pass
if hasattr(cfg, "log_level"):
cfg.log_level = log_level
applied.append("LOG_LEVEL")
except Exception as e: # noqa: BLE001
logger.warning(f"热应用 driver.config 失败: {type(e).__name__}: {e}")
# 4. LOG_LEVEL 应用到 loguru
_apply_log_level(log_level)
return {
"ok": True,
**current_values(),
"applied": applied,
"restart_required": True,
"notice": "已保存到 .env 并尽量热应用;部分插件在 import 时缓存了 SUPERUSERS/NICKNAME,"
"如需完全生效,请在下方执行「重启 bot」。",
}
@@ -1,310 +0,0 @@
# -*- coding: utf-8 -*-
"""统一 Web 管理台 Dashboard 数据采集。
数据来源与 nonebot_plugin_picstatus 类似:
- bot 账号 / WS 连通性:从 OneBot V11 适配器的 bots / connections 读取
- host 性能:psutil(CPU/内存/磁盘/进程)+ platform + nonebot 版本
仅在 /hub/api/dashboard 被调用时执行,不做常驻采样。
同步采集(psutil 等可能阻塞的调用)整体放入线程池执行,避免阻塞事件循环;
结果做 1 秒短缓存,多个前端标签页(含 SSE 循环)不至于各自重复采集。
"""
from __future__ import annotations
import asyncio
import platform
import time
import psutil
from nonebot import get_bots, logger
try:
from cpuinfo import get_cpu_info
except Exception: # pragma: no cover
get_cpu_info = None
_MODULE_START = time.time()
# 网络速率采样:记录上一次累计计数与时间,用于计算实时 bytes/s 用量
_NET_LAST: dict = {"time": None, "sent": None, "recv": None}
# 采集结果短缓存(多标签页 / SSE 每 2s 循环共用一份结果)
_DASH_CACHE: dict = {"time": 0.0, "data": None}
_DASH_CACHE_TTL = 1.0
def _cpu_sync() -> dict:
try:
per_core = psutil.cpu_percent(0.3, True)
except TypeError:
# 兼容部分平台不接受 percpu 参数
per_core = []
if per_core:
percent = sum(per_core) / len(per_core)
else:
percent = psutil.cpu_percent(0.3)
info: dict = {
"percent": round(float(percent), 1),
"per_core": [round(float(x), 1) for x in (per_core or [])],
"logical": psutil.cpu_count(),
"physical": psutil.cpu_count(logical=False),
"brand": "",
}
if get_cpu_info:
try:
brand = get_cpu_info().get("brand_raw", "")
info["brand"] = brand.split("@", maxsplit=1)[0].strip()
except Exception:
info["brand"] = ""
try:
info["load_avg"] = [round(float(x), 2) for x in psutil.getloadavg()]
except Exception:
info["load_avg"] = None
return info
def _memory_sync() -> dict:
mem = psutil.virtual_memory()
try:
proc = psutil.Process()
rss = proc.memory_info().rss
proc_mem_percent = proc.memory_percent()
except Exception:
rss = 0
proc_mem_percent = 0.0
return {
"percent": round(float(mem.percent), 1),
"used": int(mem.used),
"total": int(mem.total),
"available": int(mem.available),
"process_rss": int(rss),
"process_percent": round(float(proc_mem_percent), 1),
}
def _process_sync() -> dict:
try:
proc = psutil.Process()
cpu = proc.cpu_percent(0.2)
info = {
"pid": proc.pid,
"name": proc.name(),
"rss": int(proc.memory_info().rss),
"cpu_percent": round(float(cpu), 1),
"memory_percent": round(float(proc.memory_percent()), 1),
"create_time": int(proc.create_time()),
}
except Exception as e:
logger.warning(f"采集进程信息失败: {type(e).__name__}: {e}")
info = {}
return info
def _network_sync() -> dict:
"""采集网络流量累计值 + 实时速率(bytes/s)。"""
global _NET_LAST
now = time.time()
try:
io = psutil.net_io_counters(pernic=False)
except Exception:
return {
"sent": 0,
"recv": 0,
"sent_rate": 0,
"recv_rate": 0,
"packets_sent": 0,
"packets_recv": 0,
"interfaces": [],
}
sent = int(io.bytes_sent)
recv = int(io.bytes_recv)
sent_rate = recv_rate = 0.0
last = _NET_LAST
if last["time"] is not None and last["sent"] is not None:
dt = max(0.001, now - float(last["time"]))
sent_rate = max(0.0, (sent - int(last["sent"])) / dt)
recv_rate = max(0.0, (recv - int(last["recv"])) / dt)
_NET_LAST = {"time": now, "sent": sent, "recv": recv}
# 逐网卡明细(含 IP / 友好名称)
interfaces: list[dict] = []
try:
pernic = psutil.net_io_counters(pernic=True)
addrs = psutil.net_if_addrs()
stats = psutil.net_if_stats()
for name, cnt in pernic.items():
if not cnt.bytes_sent and not cnt.bytes_recv:
continue
ips = []
for addr in addrs.get(name, []):
ip = (addr.address or "").strip()
# 只保留 IPv4(带点分十进制),忽略 MAC / IPv6
if ip and ":" not in ip and ip.count(".") >= 3:
ips.append(ip)
up = bool(stats.get(name).isup) if name in stats else True
interfaces.append({
"name": name,
"up": up,
"sent": int(cnt.bytes_sent),
"recv": int(cnt.bytes_recv),
"ip": ", ".join(ips) if ips else "",
})
except Exception:
interfaces = []
return {
"sent": sent,
"recv": recv,
"sent_rate": round(sent_rate, 1),
"recv_rate": round(recv_rate, 1),
"packets_sent": int(io.packets_sent),
"packets_recv": int(io.packets_recv),
"interfaces": interfaces,
}
def _backend_sync() -> dict:
"""BOT 后端信息:NoneBot 版本 / 驱动方式 / 装载插件数。"""
from nonebot import get_loaded_plugins
try:
plugin_count = len(get_loaded_plugins())
except Exception:
plugin_count = 0
info: dict = {
"nonebot_version": _nonebot_version(),
"plugin_count": plugin_count,
"python": f"{platform.python_implementation()} {platform.python_version()}",
"driver_type": "",
"driver": "",
}
try:
from nonebot import get_driver
driver = get_driver()
info["driver_type"] = getattr(driver, "type", "") or ""
info["driver"] = getattr(driver.config, "driver", "") or ""
except Exception:
pass
return info
def _system_sync() -> dict:
boot = psutil.boot_time()
now = time.time()
return {
"hostname": platform.node(),
"os": platform.platform(),
"python": f"{platform.python_implementation()} {platform.python_version()}",
"nonebot": _nonebot_version(),
"boot_time": int(boot),
"system_uptime_seconds": int(now - boot),
"bot_uptime_seconds": int(now - _MODULE_START),
}
def _nonebot_version() -> str:
try:
import nonebot
return nonebot.__version__
except Exception:
return ""
def _disk_sync() -> list:
parts = []
for part in psutil.disk_partitions():
try:
usage = psutil.disk_usage(part.mountpoint)
except Exception:
continue
parts.append(
{
"mount": part.mountpoint,
"device": part.device,
"percent": round(float(usage.percent), 1),
"used": int(usage.used),
"total": int(usage.total),
}
)
return parts
async def _collect_bots() -> list:
try:
bots = get_bots().values()
except Exception as e: # noqa: BLE001
logger.warning(f"获取 bot 列表失败: {type(e).__name__}: {e}")
return []
result = []
for bot in bots:
adapter = getattr(bot, "adapter", None)
connections = getattr(adapter, "connections", {}) or {}
ws_connected = bool(connections.get(bot.self_id))
item = {
"self_id": bot.self_id,
"adapter": adapter.get_name() if adapter else "",
"nick": bot.self_id,
"avatar": f"https://q1.qlogo.cn/g?b=qq&nk={bot.self_id}&s=640",
"ws_connected": ws_connected,
"online": None,
"msg_recv": None,
"msg_sent": None,
}
if ws_connected:
try:
login = await bot.get_login_info()
item["nick"] = login.get("nickname") or item["nick"]
except Exception as e: # noqa: BLE001
logger.warning(
f"获取登录信息失败({bot.self_id}): {type(e).__name__}: {e}"
)
try:
status = await bot.get_status()
item["online"] = status.get("online")
stat = status.get("stat") or {}
item["msg_recv"] = stat.get("message_received") or stat.get(
"MessageReceived"
)
item["msg_sent"] = stat.get("message_sent") or stat.get("MessageSent")
except Exception as e: # noqa: BLE001
logger.warning(
f"获取连接状态失败({bot.self_id}): {type(e).__name__}: {e}"
)
result.append(item)
return result
async def collect_dashboard() -> dict:
"""聚合采集,失败字段降级,不阻塞整体返回。
同步采集整体放线程池(psutil 的 disk_usage 等可能秒级阻塞),
结果带 1 秒短缓存(SSE 每 2s 循环与多标签页共用)。
"""
now = time.time()
if _DASH_CACHE["data"] is not None and now - _DASH_CACHE["time"] < _DASH_CACHE_TTL:
return _DASH_CACHE["data"]
cpu, memory, process, system, disk, network, backend, bots = await asyncio.gather(
asyncio.to_thread(_cpu_sync),
asyncio.to_thread(_memory_sync),
asyncio.to_thread(_process_sync),
asyncio.to_thread(_system_sync),
asyncio.to_thread(_disk_sync),
asyncio.to_thread(_network_sync),
asyncio.to_thread(_backend_sync),
_collect_bots(), # 需要走 bot API,必须留在事件循环
)
data = {
"bots": bots,
"system": system,
"cpu": cpu,
"memory": memory,
"process": process,
"disk": disk,
"network": network,
"backend": backend,
}
_DASH_CACHE["time"] = now
_DASH_CACHE["data"] = data
return data