结构调整
视频解析多图/多媒体结构 消息体适配
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user