chore: sync current WechatHookBot workspace

This commit is contained in:
2026-03-09 15:48:45 +08:00
parent 4016c1e6eb
commit 9119e2307d
195 changed files with 24438 additions and 17498 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
"""
统一配置管理器
单例模式,提供:
- 配置缓存,避免重复读取文件
- 配置热更新检测
- 类型安全的配置访问
"""
"""
统一配置管理器
单例模式,提供:
- 配置缓存,避免重复读取文件
- 配置热更新检测
- 类型安全的配置访问
"""
import tomllib
from pathlib import Path
from threading import Lock
@@ -15,176 +15,218 @@ from typing import Any, Dict, Optional
from loguru import logger
PROJECT_ROOT = Path(__file__).resolve().parent.parent
MAIN_CONFIG_PATH = PROJECT_ROOT / "main_config.toml"
class ConfigManager:
"""
配置管理器 (线程安全单例)
使用示例:
from utils.config_manager import get_config
# 获取单个配置项
admins = get_config().get("Bot", "admins", [])
# 获取整个配置节
bot_config = get_config().get_section("Bot")
# 检查并重新加载
if get_config().reload_if_changed():
logger.info("配置已更新")
"""
_instance: Optional["ConfigManager"] = None
_lock = Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
instance = super().__new__(cls)
instance._initialized = False
cls._instance = instance
return cls._instance
"""
配置管理器 (线程安全单例)
使用示例:
from utils.config_manager import get_config
# 获取单个配置项
admins = get_config().get("Bot", "admins", [])
# 获取整个配置节
bot_config = get_config().get_section("Bot")
# 检查并重新加载
if get_config().reload_if_changed():
logger.info("配置已更新")
"""
_instance: Optional["ConfigManager"] = None
_lock = Lock()
def __new__(cls):
if cls._instance is None:
with cls._lock:
if cls._instance is None:
instance = super().__new__(cls)
instance._initialized = False
cls._instance = instance
return cls._instance
def __init__(self):
if self._initialized:
return
self._config: Dict[str, Any] = {}
self._config_path = Path("main_config.toml")
self._config_path = MAIN_CONFIG_PATH
self._file_mtime: float = 0
self._config_lock = Lock()
self._reload()
self._initialized = True
logger.debug("ConfigManager 初始化完成")
def _reload(self) -> bool:
"""重新加载配置文件"""
try:
if not self._config_path.exists():
logger.warning(f"配置文件不存在: {self._config_path}")
return False
current_mtime = self._config_path.stat().st_mtime
if current_mtime == self._file_mtime and self._config:
return False # 文件未变化
with self._config_lock:
with open(self._config_path, "rb") as f:
self._config = tomllib.load(f)
self._file_mtime = current_mtime
logger.debug("配置文件已重新加载")
return True
except Exception as e:
logger.error(f"加载配置文件失败: {e}")
return False
def get(self, section: str, key: str, default: Any = None) -> Any:
"""
获取配置项
Args:
section: 配置节名称,如 "Bot"
key: 配置项名称,如 "admins"
default: 默认值
Returns:
配置值或默认值
"""
return self._config.get(section, {}).get(key, default)
def get_section(self, section: str) -> Dict[str, Any]:
"""
获取整个配置节
Args:
section: 配置节名称
Returns:
配置节字典的副本
"""
return self._config.get(section, {}).copy()
def get_all(self) -> Dict[str, Any]:
"""获取完整配置(只读副本)"""
return self._config.copy()
def reload_if_changed(self) -> bool:
"""
如果文件有变化则重新加载
Returns:
是否重新加载了配置
"""
try:
if not self._config_path.exists():
return False
current_mtime = self._config_path.stat().st_mtime
if current_mtime != self._file_mtime:
return self._reload()
except Exception:
pass
return False
def _reload(self) -> bool:
"""重新加载配置文件"""
try:
if not self._config_path.exists():
logger.warning(f"配置文件不存在: {self._config_path}")
return False
current_mtime = self._config_path.stat().st_mtime
if current_mtime == self._file_mtime and self._config:
return False # 文件未变化
with self._config_lock:
with open(self._config_path, "rb") as f:
self._config = tomllib.load(f)
self._file_mtime = current_mtime
logger.debug("配置文件已重新加载")
return True
except Exception as e:
logger.error(f"加载配置文件失败: {e}")
return False
def get(self, section: str, key: str, default: Any = None) -> Any:
"""
获取配置项
Args:
section: 配置节名称,如 "Bot"
key: 配置项名称,如 "admins"
default: 默认值
Returns:
配置值或默认值
"""
return self._config.get(section, {}).get(key, default)
def get_section(self, section: str) -> Dict[str, Any]:
"""
获取整个配置节
Args:
section: 配置节名称
Returns:
配置节字典的副本
"""
return self._config.get(section, {}).copy()
def get_all(self) -> Dict[str, Any]:
"""获取完整配置(只读副本)"""
return self._config.copy()
def reload_if_changed(self) -> bool:
"""
如果文件有变化则重新加载
Returns:
是否重新加载了配置
"""
try:
if not self._config_path.exists():
return False
current_mtime = self._config_path.stat().st_mtime
if current_mtime != self._file_mtime:
return self._reload()
except Exception:
pass
return False
def force_reload(self) -> bool:
"""强制重新加载配置"""
self._file_mtime = 0
return self._reload()
def apply_config(self, new_config: Dict[str, Any], mtime: Optional[float] = None) -> bool:
"""
直接应用外部加载的配置(用于热更新)
# ==================== 便捷函数 ====================
Args:
new_config: 新配置字典
mtime: 配置文件的修改时间(可选)
Returns:
是否成功应用
"""
if new_config is None:
return False
try:
with self._config_lock:
self._config = new_config
if mtime is None and self._config_path.exists():
mtime = self._config_path.stat().st_mtime
if mtime:
self._file_mtime = mtime
return True
except Exception as e:
logger.error(f"应用配置失败: {e}")
return False
# ==================== 便捷函数 ====================
def get_config() -> ConfigManager:
"""获取配置管理器实例"""
return ConfigManager()
def get_project_root() -> Path:
"""获取项目根目录。"""
return PROJECT_ROOT
def get_main_config_path() -> Path:
"""获取主配置文件路径。"""
return MAIN_CONFIG_PATH
def get_bot_config() -> Dict[str, Any]:
"""快捷获取 [Bot] 配置节"""
return get_config().get_section("Bot")
def get_performance_config() -> Dict[str, Any]:
"""快捷获取 [Performance] 配置节"""
return get_config().get_section("Performance")
def get_database_config() -> Dict[str, Any]:
"""快捷获取 [Database] 配置节"""
return get_config().get_section("Database")
def get_scheduler_config() -> Dict[str, Any]:
"""快捷获取 [Scheduler] 配置节"""
return get_config().get_section("Scheduler")
def get_queue_config() -> Dict[str, Any]:
"""快捷获取 [Queue] 配置节"""
return get_config().get_section("Queue")
def get_concurrency_config() -> Dict[str, Any]:
"""快捷获取 [Concurrency] 配置节"""
return get_config().get_section("Concurrency")
def get_webui_config() -> Dict[str, Any]:
"""快捷获取 [WebUI] 配置节"""
return get_config().get_section("WebUI")
# ==================== 导出列表 ====================
__all__ = [
def get_performance_config() -> Dict[str, Any]:
"""快捷获取 [Performance] 配置节"""
return get_config().get_section("Performance")
def get_database_config() -> Dict[str, Any]:
"""快捷获取 [Database] 配置节"""
return get_config().get_section("Database")
def get_scheduler_config() -> Dict[str, Any]:
"""快捷获取 [Scheduler] 配置节"""
return get_config().get_section("Scheduler")
def get_queue_config() -> Dict[str, Any]:
"""快捷获取 [Queue] 配置节"""
return get_config().get_section("Queue")
def get_concurrency_config() -> Dict[str, Any]:
"""快捷获取 [Concurrency] 配置节"""
return get_config().get_section("Concurrency")
def get_webui_config() -> Dict[str, Any]:
"""快捷获取 [WebUI] 配置节"""
return get_config().get_section("WebUI")
# ==================== 导出列表 ====================
__all__ = [
'ConfigManager',
'get_config',
'get_project_root',
'get_main_config_path',
'get_bot_config',
'get_performance_config',
'get_database_config',
'get_scheduler_config',
'get_queue_config',
'get_concurrency_config',
'get_webui_config',
]
'get_scheduler_config',
'get_queue_config',
'get_concurrency_config',
'get_webui_config',
]

View File

@@ -190,11 +190,14 @@ class ContextStore:
if self._use_redis_for_group_history():
redis_cache = get_cache()
try:
key = f"group_history:{_safe_chat_id(chat_id)}"
# 使用与写入时相同的 key 格式: "group_history:{chat_id}"
# 注意redis_cache._make_key 会生成 "group_history:{chat_id}" 格式
# 这里直接使用原始 chat_id与 add_group_message 保持一致
key = f"group_history:{chat_id}"
redis_cache.delete(key)
logger.debug(f"[ContextStore] 已清除 Redis 群聊历史: {chat_id}")
logger.info(f"[ContextStore] 已清除 Redis 群聊历史: {key}")
except Exception as e:
logger.debug(f"[ContextStore] 清除 Redis 群聊历史失败: {e}")
logger.warning(f"[ContextStore] 清除 Redis 群聊历史失败: {e}")
# 清除本地文件中的群聊历史
history_file = self._get_history_file(chat_id)

View File

@@ -131,12 +131,19 @@ MESSAGE_DECORATOR_TYPES: Dict[str, str] = {
'voice_message': '语音消息',
'video_message': '视频消息',
'emoji_message': '表情消息',
'link_message': '链接消息',
'card_message': '名片消息',
'miniapp_message': '小程序消息',
'file_message': '文件消息',
'quote_message': '引用消息',
'pat_message': '拍一拍',
'at_message': '@消息',
'system_message': '系统消息',
'other_message': '其他消息',
'chatroom_member_add': '群成员新增',
'chatroom_member_remove': '群成员删除',
'chatroom_info_change': '群信息变化',
'chatroom_member_nickname_change': '群成员昵称修改',
}
@@ -148,12 +155,19 @@ on_image_message = _create_message_decorator('image_message', '图片消息')
on_voice_message = _create_message_decorator('voice_message', '语音消息')
on_video_message = _create_message_decorator('video_message', '视频消息')
on_emoji_message = _create_message_decorator('emoji_message', '表情消息')
on_link_message = _create_message_decorator('link_message', '链接消息')
on_card_message = _create_message_decorator('card_message', '名片消息')
on_miniapp_message = _create_message_decorator('miniapp_message', '小程序消息')
on_file_message = _create_message_decorator('file_message', '文件消息')
on_quote_message = _create_message_decorator('quote_message', '引用消息')
on_pat_message = _create_message_decorator('pat_message', '拍一拍')
on_at_message = _create_message_decorator('at_message', '@消息')
on_system_message = _create_message_decorator('system_message', '系统消息')
on_other_message = _create_message_decorator('other_message', '其他消息')
on_chatroom_member_add = _create_message_decorator('chatroom_member_add', '群成员新增')
on_chatroom_member_remove = _create_message_decorator('chatroom_member_remove', '群成员删除')
on_chatroom_info_change = _create_message_decorator('chatroom_info_change', '群信息变化')
on_chatroom_member_nickname_change = _create_message_decorator('chatroom_member_nickname_change', '群成员昵称修改')
# ==================== 导出列表 ====================
@@ -170,12 +184,19 @@ __all__ = [
'on_voice_message',
'on_video_message',
'on_emoji_message',
'on_link_message',
'on_card_message',
'on_miniapp_message',
'on_file_message',
'on_quote_message',
'on_pat_message',
'on_at_message',
'on_system_message',
'on_other_message',
'on_chatroom_member_add',
'on_chatroom_member_remove',
'on_chatroom_info_change',
'on_chatroom_member_nickname_change',
# 工具
'MESSAGE_DECORATOR_TYPES',
'_create_message_decorator',

View File

@@ -183,17 +183,25 @@ class EventManager:
start_time = time.time()
all_completed = True
logger.debug(
f"[EventManager] 触发: {event_type}, "
f"处理器数量: {len(handlers)}"
)
# logger.debug(
# f"[EventManager] 触发: {event_type}, "
# f"处理器数量: {len(handlers)}"
# )
performance_monitor = None
try:
from utils.bot_utils import get_performance_monitor
performance_monitor = get_performance_monitor()
except Exception:
performance_monitor = None
for handler_info in handlers:
stats.handler_calls += 1
success = True
handler_start = time.perf_counter()
try:
logger.debug(f"[EventManager] 调用: {handler_info.handler_name}")
# logger.debug(f"[EventManager] 调用: {handler_info.handler_name}")
result = await handler_info.handler(*args, **kwargs)
# 检查是否中断
@@ -207,12 +215,21 @@ class EventManager:
break
except Exception as e:
success = False
stats.error_count += 1
logger.error(
f"[EventManager] {handler_info.handler_name} 执行失败: {e}"
)
logger.debug(f"详细错误:\n{traceback.format_exc()}")
# 继续执行其他处理器
finally:
if performance_monitor:
handler_elapsed = time.perf_counter() - handler_start
performance_monitor.record_plugin_execution(
handler_info.instance.__class__.__name__,
handler_elapsed,
success
)
elapsed_ms = (time.time() - start_time) * 1000
stats.total_time_ms += elapsed_ms
@@ -247,13 +264,31 @@ class EventManager:
semaphore = asyncio.Semaphore(max_concurrency)
performance_monitor = None
try:
from utils.bot_utils import get_performance_monitor
performance_monitor = get_performance_monitor()
except Exception:
performance_monitor = None
async def run_handler(handler_info: HandlerInfo):
async with semaphore:
success = True
handler_start = time.perf_counter()
try:
return await handler_info.handler(*args, **kwargs)
except Exception as e:
success = False
logger.error(f"[EventManager] {handler_info.handler_name} 失败: {e}")
return None
finally:
if performance_monitor:
handler_elapsed = time.perf_counter() - handler_start
performance_monitor.record_plugin_execution(
handler_info.instance.__class__.__name__,
handler_elapsed,
success
)
tasks = [run_handler(h) for h in handlers]
return await asyncio.gather(*tasks, return_exceptions=True)

View File

@@ -139,13 +139,8 @@ class HookBot:
)
return
# 5. 格式转换
try:
message = normalize_message(msg_type, data)
except Exception as e:
logger.error(f"格式转换失败: {e}")
self._stats.record_error()
return
# 5. 消息已在 bot.py 中标准化,直接使用
message = data
# 6. 过滤检查
if not self._filter.should_process(message):

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,413 @@
"""
群成员信息服务
提供统一的用户信息查询接口,优先从 MemberSync 数据库读取,
避免频繁调用 API 和 Redis 缓存
"""
import aiosqlite
from pathlib import Path
from typing import Optional, Dict, List
from loguru import logger
class MemberInfoService:
"""
群成员信息服务(单例模式)
优先级:
1. MemberSync SQLite 数据库(最快、最可靠)
2. 其他缓存/历史(由调用方处理,本服务不触发 API
"""
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
# MemberSync 数据库路径
self.db_path = Path(__file__).parent.parent / "plugins" / "MemberSync" / "data" / "member_sync.db"
self._columns_cache = None
self._initialized = True
logger.info(f"[MemberInfoService] 初始化,数据库路径: {self.db_path}")
async def _get_table_columns(self, db: aiosqlite.Connection) -> set:
"""获取 group_members 表的列信息(带缓存)"""
if self._columns_cache is not None:
return self._columns_cache
try:
cursor = await db.execute("PRAGMA table_info(group_members)")
rows = await cursor.fetchall()
self._columns_cache = {row[1] for row in rows if row and len(row) > 1}
except Exception:
self._columns_cache = set()
return self._columns_cache
async def get_member_info(self, wxid: str) -> Optional[Dict]:
"""
获取成员信息(昵称 + 头像 + 个性签名)
Args:
wxid: 用户 wxid
Returns:
成员信息字典 {"wxid": str, "nickname": str, "avatar_url": str}
如果不存在返回 None
"""
if not self.db_path.exists():
logger.debug(f"[MemberInfoService] 数据库不存在: {self.db_path}")
return None
try:
async with aiosqlite.connect(self.db_path) as db:
columns = await self._get_table_columns(db)
fields = ["wxid", "nickname", "avatar_url"]
if "signature" in columns:
fields.append("signature")
if "group_nickname" in columns:
fields.append("group_nickname")
cursor = await db.execute(
f"SELECT {', '.join(fields)} FROM group_members WHERE wxid = ?",
(wxid,)
)
row = await cursor.fetchone()
if row:
result = {
"wxid": row[0],
"nickname": row[1],
"avatar_url": row[2] or ""
}
if "signature" in columns:
result["signature"] = row[fields.index("signature")] or ""
if "group_nickname" in columns:
result["group_nickname"] = row[fields.index("group_nickname")] or ""
logger.debug(f"[MemberInfoService] 数据库命中: {wxid} -> {result['nickname']}")
return result
else:
logger.debug(f"[MemberInfoService] 数据库未找到: {wxid}")
return None
except Exception as e:
logger.error(f"[MemberInfoService] 查询失败: {e}")
return None
async def get_member_nickname(self, wxid: str) -> Optional[str]:
"""
快速获取成员昵称
Args:
wxid: 用户 wxid
Returns:
昵称,如果不存在返回 None
"""
info = await self.get_member_info(wxid)
return info["nickname"] if info else None
async def get_member_avatar(self, wxid: str) -> Optional[str]:
"""
快速获取成员头像 URL
Args:
wxid: 用户 wxid
Returns:
头像 URL如果不存在返回 None
"""
info = await self.get_member_info(wxid)
return info["avatar_url"] if info else None
async def get_member_signature(self, wxid: str) -> Optional[str]:
"""快速获取成员个性签名"""
info = await self.get_member_info(wxid)
return info.get("signature") if info else None
async def get_chatroom_members(self, chatroom_wxid: str) -> List[Dict]:
"""
获取指定群聊的所有成员
Args:
chatroom_wxid: 群聊 ID
Returns:
成员信息列表
"""
if not self.db_path.exists():
return []
try:
async with aiosqlite.connect(self.db_path) as db:
columns = await self._get_table_columns(db)
fields = ["wxid", "nickname", "avatar_url"]
if "group_nickname" in columns:
fields.append("group_nickname")
if "signature" in columns:
fields.append("signature")
cursor = await db.execute(
f"SELECT {', '.join(fields)} FROM group_members WHERE chatroom_wxid = ?",
(chatroom_wxid,)
)
rows = await cursor.fetchall()
result = [
{
"wxid": row[0],
"nickname": row[1],
"avatar_url": row[2] or "",
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
}
for row in rows
]
logger.debug(f"[MemberInfoService] 获取群 {chatroom_wxid} 成员: {len(result)}")
return result
except Exception as e:
logger.error(f"[MemberInfoService] 获取群成员失败: {e}")
return []
async def get_chatroom_member_info(self, chatroom_wxid: str, wxid: str) -> Optional[Dict]:
"""
获取指定群聊中的成员信息
Args:
chatroom_wxid: 群聊 ID
wxid: 用户 wxid
Returns:
成员信息字典 {"wxid": str, "nickname": str, "avatar_url": str}
如果不存在返回 None
"""
if not self.db_path.exists():
return None
if not chatroom_wxid or not wxid:
return None
try:
async with aiosqlite.connect(self.db_path) as db:
columns = await self._get_table_columns(db)
fields = ["wxid", "nickname", "avatar_url"]
if "group_nickname" in columns:
fields.append("group_nickname")
if "signature" in columns:
fields.append("signature")
cursor = await db.execute(
f"""
SELECT {', '.join(fields)}
FROM group_members
WHERE chatroom_wxid = ? AND wxid = ?
""",
(chatroom_wxid, wxid)
)
row = await cursor.fetchone()
if row:
result = {
"wxid": row[0],
"nickname": row[1],
"avatar_url": row[2] or "",
}
if "group_nickname" in columns:
result["group_nickname"] = row[fields.index("group_nickname")] or ""
if "signature" in columns:
result["signature"] = row[fields.index("signature")] or ""
return result
except Exception as e:
logger.error(f"[MemberInfoService] 查询群成员失败: {e}")
return None
async def get_chatroom_member_nickname(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
"""获取指定群聊中的成员昵称"""
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
return info["nickname"] if info else None
async def get_chatroom_member_avatar(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
"""获取指定群聊中的成员头像 URL"""
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
return info["avatar_url"] if info else None
async def get_chatroom_member_signature(self, chatroom_wxid: str, wxid: str) -> Optional[str]:
"""获取指定群聊中的成员个性签名"""
info = await self.get_chatroom_member_info(chatroom_wxid, wxid)
return info.get("signature") if info else None
async def get_chatroom_member_wxids(self, chatroom_wxid: str) -> List[str]:
"""
获取指定群聊的所有成员 wxid 列表
Args:
chatroom_wxid: 群聊 ID
Returns:
wxid 列表
"""
if not self.db_path.exists():
return []
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT wxid FROM group_members WHERE chatroom_wxid = ?",
(chatroom_wxid,)
)
rows = await cursor.fetchall()
return [row[0] for row in rows]
except Exception as e:
logger.error(f"[MemberInfoService] 获取群成员wxid列表失败: {e}")
return []
async def get_all_members(self) -> List[Dict]:
"""
获取所有成员信息
Returns:
成员信息列表
"""
if not self.db_path.exists():
logger.debug(f"[MemberInfoService] 数据库不存在: {self.db_path}")
return []
try:
async with aiosqlite.connect(self.db_path) as db:
columns = await self._get_table_columns(db)
fields = ["wxid", "nickname", "avatar_url"]
if "group_nickname" in columns:
fields.append("group_nickname")
if "signature" in columns:
fields.append("signature")
cursor = await db.execute(
f"SELECT {', '.join(fields)} FROM group_members ORDER BY updated_at DESC"
)
rows = await cursor.fetchall()
result = [
{
"wxid": row[0],
"nickname": row[1],
"avatar_url": row[2] or "",
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
}
for row in rows
]
logger.debug(f"[MemberInfoService] 获取所有成员: {len(result)}")
return result
except Exception as e:
logger.error(f"[MemberInfoService] 查询所有成员失败: {e}")
return []
async def get_members_by_wxids(self, wxids: List[str]) -> Dict[str, Dict]:
"""
批量获取成员信息
Args:
wxids: wxid 列表
Returns:
{wxid: {"nickname": str, "avatar_url": str}} 字典
"""
if not self.db_path.exists() or not wxids:
return {}
try:
async with aiosqlite.connect(self.db_path) as db:
columns = await self._get_table_columns(db)
fields = ["wxid", "nickname", "avatar_url"]
if "group_nickname" in columns:
fields.append("group_nickname")
if "signature" in columns:
fields.append("signature")
# 构建 IN 查询
placeholders = ",".join("?" * len(wxids))
cursor = await db.execute(
f"SELECT {', '.join(fields)} FROM group_members WHERE wxid IN ({placeholders})",
wxids
)
rows = await cursor.fetchall()
result = {
row[0]: {
"nickname": row[1],
"avatar_url": row[2] or "",
"group_nickname": row[fields.index("group_nickname")] or "" if "group_nickname" in columns else "",
"signature": row[fields.index("signature")] or "" if "signature" in columns else "",
}
for row in rows
}
logger.debug(f"[MemberInfoService] 批量查询: 请求 {len(wxids)} 人,命中 {len(result)}")
return result
except Exception as e:
logger.error(f"[MemberInfoService] 批量查询失败: {e}")
return {}
async def check_member_exists(self, wxid: str) -> bool:
"""
检查成员是否存在于数据库
Args:
wxid: 用户 wxid
Returns:
是否存在
"""
if not self.db_path.exists():
return False
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"SELECT 1 FROM group_members WHERE wxid = ? LIMIT 1",
(wxid,)
)
row = await cursor.fetchone()
return row is not None
except Exception as e:
logger.error(f"[MemberInfoService] 检查成员存在失败: {e}")
return False
# 全局单例
_service_instance: Optional[MemberInfoService] = None
def get_member_service() -> MemberInfoService:
"""获取全局单例"""
global _service_instance
if _service_instance is None:
_service_instance = MemberInfoService()
return _service_instance
# 便捷函数
async def get_member_info(wxid: str) -> Optional[Dict]:
"""便捷函数:获取成员信息"""
return await get_member_service().get_member_info(wxid)
async def get_member_nickname(wxid: str) -> Optional[str]:
"""便捷函数:获取成员昵称"""
return await get_member_service().get_member_nickname(wxid)
async def get_member_avatar(wxid: str) -> Optional[str]:
"""便捷函数:获取成员头像"""
return await get_member_service().get_member_avatar(wxid)
async def get_member_signature(wxid: str) -> Optional[str]:
"""便捷函数:获取成员个性签名"""
return await get_member_service().get_member_signature(wxid)

View File

@@ -1,145 +1,153 @@
"""
消息去重器模块
防止同一条消息被重复处理(某些环境下回调会重复触发)
"""
import asyncio
import time
from typing import Any, Dict, Optional
from loguru import logger
class MessageDeduplicator:
"""
消息去重器
使用基于时间的滑动窗口实现去重:
- 记录最近处理的消息 ID
- 在 TTL 时间内重复的消息会被过滤
- 自动清理过期记录,限制内存占用
"""
def __init__(
self,
ttl_seconds: float = 30.0,
max_size: int = 5000,
):
"""
初始化去重器
Args:
ttl_seconds: 消息 ID 的有效期0 表示禁用去重
max_size: 最大缓存条目数,防止内存泄漏
"""
self.ttl_seconds = max(float(ttl_seconds), 0.0)
self.max_size = max(int(max_size), 0)
self._cache: Dict[str, float] = {} # key -> timestamp
self._lock = asyncio.Lock()
@staticmethod
"""
消息去重器模块
防止同一条消息被重复处理(某些环境下回调会重复触发)
"""
import asyncio
import time
from typing import Any, Dict, Optional
from loguru import logger
class MessageDeduplicator:
"""
消息去重器
使用基于时间的滑动窗口实现去重:
- 记录最近处理的消息 ID
- 在 TTL 时间内重复的消息会被过滤
- 自动清理过期记录,限制内存占用
"""
def __init__(
self,
ttl_seconds: float = 30.0,
max_size: int = 5000,
):
"""
初始化去重器
Args:
ttl_seconds: 消息 ID 的有效期0 表示禁用去重
max_size: 最大缓存条目数,防止内存泄漏
"""
self.ttl_seconds = max(float(ttl_seconds), 0.0)
self.max_size = max(int(max_size), 0)
self._cache: Dict[str, float] = {} # key -> timestamp
self._lock = asyncio.Lock()
@staticmethod
def extract_msg_id(data: Dict[str, Any]) -> str:
"""
从原始消息数据中提取消息 ID
Args:
data: 原始消息数据
Returns:
消息 ID 字符串,提取失败返回空字符串
"""
for key in ("msgid", "msg_id", "MsgId", "id"):
"""
从原始消息数据中提取消息 ID
Args:
data: 原始消息数据
Returns:
消息 ID 字符串,提取失败返回空字符串
"""
for key in (
"newMsgId",
"new_msg_id",
"msgId",
"msgid",
"msg_id",
"MsgId",
"id",
):
value = data.get(key)
if value:
return str(value)
return ""
async def is_duplicate(self, data: Dict[str, Any]) -> bool:
"""
检查消息是否重复
Args:
data: 原始消息数据
Returns:
True 表示是重复消息False 表示是新消息
"""
if self.ttl_seconds <= 0:
return False
msg_id = self.extract_msg_id(data)
if not msg_id:
# 没有消息 ID 时不做去重,避免误判
return False
key = f"msgid:{msg_id}"
now = time.time()
async with self._lock:
# 检查是否存在且未过期
last_seen = self._cache.get(key)
if last_seen is not None and (now - last_seen) < self.ttl_seconds:
return True
# 记录新消息
self._cache.pop(key, None) # 确保插入到末尾(保持顺序)
self._cache[key] = now
# 清理过期条目
self._cleanup_expired(now)
# 限制大小
self._limit_size()
return False
def _cleanup_expired(self, now: float):
"""清理过期条目(需在锁内调用)"""
cutoff = now - self.ttl_seconds
while self._cache:
first_key = next(iter(self._cache))
if self._cache[first_key] >= cutoff:
break
self._cache.pop(first_key, None)
def _limit_size(self):
"""限制缓存大小(需在锁内调用)"""
if self.max_size <= 0:
return
while len(self._cache) > self.max_size:
first_key = next(iter(self._cache))
self._cache.pop(first_key, None)
def clear(self):
"""清空缓存"""
self._cache.clear()
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
return {
"cached_count": len(self._cache),
"ttl_seconds": self.ttl_seconds,
"max_size": self.max_size,
}
@classmethod
def from_config(cls, perf_config: Dict[str, Any]) -> "MessageDeduplicator":
"""
从配置创建去重器
Args:
perf_config: Performance 配置节
Returns:
MessageDeduplicator 实例
"""
return cls(
ttl_seconds=perf_config.get("dedup_ttl_seconds", 30),
max_size=perf_config.get("dedup_max_size", 5000),
)
# ==================== 导出 ====================
__all__ = ['MessageDeduplicator']
async def is_duplicate(self, data: Dict[str, Any]) -> bool:
"""
检查消息是否重复
Args:
data: 原始消息数据
Returns:
True 表示是重复消息False 表示是新消息
"""
if self.ttl_seconds <= 0:
return False
msg_id = self.extract_msg_id(data)
if not msg_id:
# 没有消息 ID 时不做去重,避免误判
return False
key = f"msgid:{msg_id}"
now = time.time()
async with self._lock:
# 检查是否存在且未过期
last_seen = self._cache.get(key)
if last_seen is not None and (now - last_seen) < self.ttl_seconds:
return True
# 记录新消息
self._cache.pop(key, None) # 确保插入到末尾(保持顺序)
self._cache[key] = now
# 清理过期条目
self._cleanup_expired(now)
# 限制大小
self._limit_size()
return False
def _cleanup_expired(self, now: float):
"""清理过期条目(需在锁内调用)"""
cutoff = now - self.ttl_seconds
while self._cache:
first_key = next(iter(self._cache))
if self._cache[first_key] >= cutoff:
break
self._cache.pop(first_key, None)
def _limit_size(self):
"""限制缓存大小(需在锁内调用)"""
if self.max_size <= 0:
return
while len(self._cache) > self.max_size:
first_key = next(iter(self._cache))
self._cache.pop(first_key, None)
def clear(self):
"""清空缓存"""
self._cache.clear()
def get_stats(self) -> Dict[str, Any]:
"""获取统计信息"""
return {
"cached_count": len(self._cache),
"ttl_seconds": self.ttl_seconds,
"max_size": self.max_size,
}
@classmethod
def from_config(cls, perf_config: Dict[str, Any]) -> "MessageDeduplicator":
"""
从配置创建去重器
Args:
perf_config: Performance 配置节
Returns:
MessageDeduplicator 实例
"""
return cls(
ttl_seconds=perf_config.get("dedup_ttl_seconds", 30),
max_size=perf_config.get("dedup_max_size", 5000),
)
# ==================== 导出 ====================
__all__ = ['MessageDeduplicator']

View File

@@ -1,33 +1,35 @@
"""
消息发送钩子工具
用于自动记录机器人发送的消息到 MessageLogger
"""
"""
消息发送钩子工具
用于自动记录机器人发送的消息到 MessageLogger
"""
from loguru import logger
from utils.config_manager import get_config
async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", media_url: str = ""):
"""
记录机器人发送的消息到 MessageLogger
Args:
to_wxid: 接收者微信ID
content: 消息内容
msg_type: 消息类型 (text/image/video/file等)
media_url: 媒体文件URL (可选)
"""
"""
记录机器人发送的消息到 MessageLogger
Args:
to_wxid: 接收者微信ID
content: 消息内容
msg_type: 消息类型 (text/image/video/file等)
media_url: 媒体文件URL (可选)
"""
try:
logger.info(f"message_hook: 开始记录机器人消息")
# 动态导入避免循环依赖
from plugins.MessageLogger.main import MessageLogger
logger.info(f"message_hook: MessageLogger 导入成功")
# 获取 MessageLogger 实例
message_logger = MessageLogger.get_instance()
logger.info(f"message_hook: MessageLogger 实例: {message_logger}")
# 获取 MessageLogger 实例
message_logger = MessageLogger.get_instance()
logger.info(f"message_hook: MessageLogger 实例: {message_logger}")
if message_logger:
logger.info(f"message_hook: 调用 save_bot_message")
await message_logger.save_bot_message(to_wxid, content, msg_type, media_url)
@@ -48,11 +50,9 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me
bot_nickname = "机器人"
bot_wxid = ""
try:
import tomllib
with open("main_config.toml", "rb") as f:
main_config = tomllib.load(f)
bot_nickname = main_config.get("Bot", {}).get("nickname") or bot_nickname
bot_wxid = main_config.get("Bot", {}).get("wxid") or ""
bot_config = get_config().get_section("Bot")
bot_nickname = bot_config.get("nickname") or bot_nickname
bot_wxid = bot_config.get("wxid") or ""
except Exception:
pass
@@ -78,50 +78,50 @@ async def log_bot_message(to_wxid: str, content: str, msg_type: str = "text", me
logger.error(f"记录机器人消息失败: {e}")
import traceback
logger.error(f"详细错误: {traceback.format_exc()}")
def create_message_hook(original_method):
"""
创建消息发送钩子装饰器
Args:
original_method: 原始的发送消息方法
Returns:
包装后的方法
"""
async def wrapper(self, to_wxid: str, content: str, *args, **kwargs):
# 调用原始方法
result = await original_method(self, to_wxid, content, *args, **kwargs)
# 记录消息
await log_bot_message(to_wxid, content, "text")
return result
return wrapper
def create_file_message_hook(original_method, msg_type: str):
"""
创建文件消息发送钩子装饰器
Args:
original_method: 原始的发送文件方法
msg_type: 消息类型
Returns:
包装后的方法
"""
async def wrapper(self, to_wxid: str, file_path: str, *args, **kwargs):
# 调用原始方法
result = await original_method(self, to_wxid, file_path, *args, **kwargs)
# 记录消息
import os
filename = os.path.basename(file_path)
await log_bot_message(to_wxid, f"[{msg_type}] {filename}", msg_type, file_path)
return result
def create_message_hook(original_method):
"""
创建消息发送钩子装饰器
Args:
original_method: 原始的发送消息方法
Returns:
包装后的方法
"""
async def wrapper(self, to_wxid: str, content: str, *args, **kwargs):
# 调用原始方法
result = await original_method(self, to_wxid, content, *args, **kwargs)
# 记录消息
await log_bot_message(to_wxid, content, "text")
return result
return wrapper
def create_file_message_hook(original_method, msg_type: str):
"""
创建文件消息发送钩子装饰器
Args:
original_method: 原始的发送文件方法
msg_type: 消息类型
Returns:
包装后的方法
"""
async def wrapper(self, to_wxid: str, file_path: str, *args, **kwargs):
# 调用原始方法
result = await original_method(self, to_wxid, file_path, *args, **kwargs)
# 记录消息
import os
filename = os.path.basename(file_path)
await log_bot_message(to_wxid, f"[{msg_type}] {filename}", msg_type, file_path)
return result
return wrapper

View File

@@ -1,151 +1,190 @@
"""
消息队列模块
提供高性能的优先级消息队列,支持多种溢出策略:
- drop_oldest: 丢弃最旧的消息
- drop_lowest: 丢弃优先级最低的消息
- sampling: 按采样率丢弃消息
- reject: 拒绝新消息
"""
import asyncio
import heapq
import random
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
# ==================== 消息优先级常量 ====================
class MessagePriority:
"""消息优先级常量"""
CRITICAL = 100 # 系统消息、登录信息
HIGH = 80 # 管理员命令、群成员变动
NORMAL = 50 # @bot 消息(默认)
LOW = 20 # 普通群消息
# ==================== 溢出策略 ====================
"""
消息队列模块
提供高性能的优先级消息队列,支持多种溢出策略:
- drop_oldest: 丢弃最旧的消息
- drop_lowest: 丢弃优先级最低的消息
- sampling: 按采样率丢弃消息
- reject: 拒绝新消息
"""
import asyncio
import heapq
import random
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Optional, Tuple
from loguru import logger
# ==================== 消息优先级常量 ====================
class MessagePriority:
"""消息优先级常量"""
CRITICAL = 100 # 系统消息、登录信息
HIGH = 80 # 管理员命令、群成员变动
NORMAL = 50 # @bot 消息(默认)
LOW = 20 # 普通群消息
# ==================== 溢出策略 ====================
class OverflowStrategy(Enum):
"""队列溢出策略"""
DROP_OLDEST = "drop_oldest" # 丢弃最旧的消息
DROP_LOWEST = "drop_lowest" # 丢弃优先级最低的消息
SAMPLING = "sampling" # 按采样率丢弃
REJECT = "reject" # 拒绝新消息
DEGRADE = "degrade" # 降级:丢弃低优先级新消息
# ==================== 优先级消息 ====================
@dataclass(order=True)
class PriorityMessage:
"""优先级消息"""
priority: int = field(compare=True)
timestamp: float = field(compare=True)
msg_type: int = field(compare=False)
data: Dict[str, Any] = field(compare=False)
def __init__(self, msg_type: int, data: Dict[str, Any], priority: int = None):
# 优先级越高,数值越大,但 heapq 是最小堆,所以取负数
self.priority = -(priority if priority is not None else MessagePriority.NORMAL)
self.timestamp = time.time()
self.msg_type = msg_type
self.data = data
# ==================== 优先级消息队列 ====================
class PriorityMessageQueue:
"""
优先级消息队列
特性:
- 基于堆的优先级队列
- 支持多种溢出策略
- 线程安全(使用 asyncio.Lock
- 支持任务计数和 join
"""
# ==================== 优先级消息 ====================
@dataclass(order=True)
class PriorityMessage:
"""优先级消息"""
priority: int = field(compare=True)
timestamp: float = field(compare=True)
msg_type: int = field(compare=False)
data: Dict[str, Any] = field(compare=False)
def __init__(self, msg_type: int, data: Dict[str, Any], priority: int = None):
# 优先级越高,数值越大,但 heapq 是最小堆,所以取负数
self.priority = -(priority if priority is not None else MessagePriority.NORMAL)
self.timestamp = time.time()
self.msg_type = msg_type
self.data = data
# ==================== 优先级消息队列 ====================
class PriorityMessageQueue:
"""
优先级消息队列
特性:
- 基于堆的优先级队列
- 支持多种溢出策略
- 线程安全(使用 asyncio.Lock
- 支持任务计数和 join
"""
def __init__(
self,
maxsize: int = 1000,
overflow_strategy: str = "drop_oldest",
sampling_rate: float = 0.5,
):
"""
初始化队列
Args:
maxsize: 最大队列大小
overflow_strategy: 溢出策略 (drop_oldest, drop_lowest, sampling, reject)
sampling_rate: 采样策略的保留率 (0.0-1.0)
"""
self.maxsize = maxsize
self.overflow_strategy = OverflowStrategy(overflow_strategy)
):
"""
初始化队列
Args:
maxsize: 最大队列大小
overflow_strategy: 溢出策略 (drop_oldest, drop_lowest, sampling, reject)
sampling_rate: 采样策略的保留率 (0.0-1.0)
"""
self.maxsize = maxsize
if isinstance(overflow_strategy, OverflowStrategy):
self.overflow_strategy = overflow_strategy
else:
self.overflow_strategy = OverflowStrategy(overflow_strategy)
self.sampling_rate = max(0.0, min(1.0, sampling_rate))
self._heap: List[PriorityMessage] = []
self._lock = asyncio.Lock()
self._not_empty = asyncio.Event()
self._unfinished_tasks = 0
self._finished = asyncio.Event()
self._finished.set()
# 统计
self._total_put = 0
self._total_dropped = 0
self._total_rejected = 0
def qsize(self) -> int:
"""返回队列大小"""
return len(self._heap)
def empty(self) -> bool:
"""队列是否为空"""
return len(self._heap) == 0
def full(self) -> bool:
"""队列是否已满"""
return len(self._heap) >= self.maxsize
self._heap: List[PriorityMessage] = []
self._lock = asyncio.Lock()
self._not_empty = asyncio.Event()
self._unfinished_tasks = 0
self._finished = asyncio.Event()
self._finished.set()
# 统计
self._total_put = 0
self._total_dropped = 0
self._total_rejected = 0
def qsize(self) -> int:
"""返回队列大小"""
return len(self._heap)
def empty(self) -> bool:
"""队列是否为空"""
return len(self._heap) == 0
def full(self) -> bool:
"""队列是否已满"""
return len(self._heap) >= self.maxsize
async def put(
self,
msg_type: int,
data: Dict[str, Any],
priority: int = None,
) -> bool:
"""
添加消息到队列
Args:
msg_type: 消息类型
data: 消息数据
priority: 优先级(可选)
Returns:
是否成功添加
"""
) -> bool:
"""
添加消息到队列
Args:
msg_type: 消息类型
data: 消息数据
priority: 优先级(可选)
Returns:
是否成功添加
"""
async with self._lock:
self._total_put += 1
# 处理队列满的情况
if self.full():
if not self._handle_overflow():
effective_priority = priority if priority is not None else MessagePriority.NORMAL
if not self._handle_overflow(effective_priority):
self._total_rejected += 1
return False
msg = PriorityMessage(msg_type, data, priority)
msg = PriorityMessage(msg_type, data, priority if priority is not None else MessagePriority.NORMAL)
heapq.heappush(self._heap, msg)
self._unfinished_tasks += 1
self._finished.clear()
self._not_empty.set()
return True
def _handle_overflow(self) -> bool:
def _drop_oldest(self) -> bool:
if not self._heap:
return False
oldest_idx = 0
for i, msg in enumerate(self._heap):
if msg.timestamp < self._heap[oldest_idx].timestamp:
oldest_idx = i
self._heap.pop(oldest_idx)
heapq.heapify(self._heap)
self._total_dropped += 1
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
if not self._heap:
self._not_empty.clear()
if self._unfinished_tasks == 0:
self._finished.set()
return True
def _drop_lowest(self) -> bool:
if not self._heap:
return False
lowest_idx = 0
for i, msg in enumerate(self._heap):
if msg.priority > self._heap[lowest_idx].priority:
lowest_idx = i
self._heap.pop(lowest_idx)
heapq.heapify(self._heap)
self._total_dropped += 1
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
if not self._heap:
self._not_empty.clear()
if self._unfinished_tasks == 0:
self._finished.set()
return True
def _handle_overflow(self, incoming_priority: int) -> bool:
"""
处理队列溢出
@@ -158,105 +197,84 @@ class PriorityMessageQueue:
if self.overflow_strategy == OverflowStrategy.DROP_OLDEST:
# 找到最旧的消息timestamp 最小)
if self._heap:
oldest_idx = 0
for i, msg in enumerate(self._heap):
if msg.timestamp < self._heap[oldest_idx].timestamp:
oldest_idx = i
self._heap.pop(oldest_idx)
heapq.heapify(self._heap)
self._total_dropped += 1
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
return True
return self._drop_oldest()
elif self.overflow_strategy == OverflowStrategy.DROP_LOWEST:
# 找到优先级最低的消息priority 值最大,因为是负数)
if self._heap:
lowest_idx = 0
for i, msg in enumerate(self._heap):
if msg.priority > self._heap[lowest_idx].priority:
lowest_idx = i
self._heap.pop(lowest_idx)
heapq.heapify(self._heap)
self._total_dropped += 1
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
return True
return self._drop_lowest()
elif self.overflow_strategy == OverflowStrategy.SAMPLING:
# 按采样率决定是否接受
if random.random() < self.sampling_rate:
# 接受新消息,丢弃最旧的
if self._heap:
oldest_idx = 0
for i, msg in enumerate(self._heap):
if msg.timestamp < self._heap[oldest_idx].timestamp:
oldest_idx = i
self._heap.pop(oldest_idx)
heapq.heapify(self._heap)
self._total_dropped += 1
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
return True
return self._drop_oldest()
else:
self._total_dropped += 1
return False
elif self.overflow_strategy == OverflowStrategy.DEGRADE:
if incoming_priority < MessagePriority.HIGH:
self._total_dropped += 1
return False
return self._drop_lowest()
return False
async def get(self, timeout: float = None) -> Tuple[int, Dict[str, Any]]:
"""
获取优先级最高的消息
Args:
timeout: 超时时间None 表示无限等待
Returns:
(msg_type, data) 元组
Raises:
asyncio.TimeoutError: 超时
"""
start_time = time.time()
while True:
async with self._lock:
if self._heap:
msg = heapq.heappop(self._heap)
if not self._heap:
self._not_empty.clear()
return (msg.msg_type, msg.data)
# 计算剩余超时时间
if timeout is not None:
elapsed = time.time() - start_time
remaining = timeout - elapsed
if remaining <= 0:
raise asyncio.TimeoutError("Queue get timeout")
try:
await asyncio.wait_for(self._not_empty.wait(), timeout=remaining)
except asyncio.TimeoutError:
raise asyncio.TimeoutError("Queue get timeout")
else:
await self._not_empty.wait()
def get_nowait(self) -> Tuple[int, Dict[str, Any]]:
"""非阻塞获取消息"""
if not self._heap:
raise asyncio.QueueEmpty()
msg = heapq.heappop(self._heap)
if not self._heap:
self._not_empty.clear()
return (msg.msg_type, msg.data)
def task_done(self):
"""标记任务完成"""
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
if self._unfinished_tasks == 0:
self._finished.set()
async def join(self):
"""等待所有任务完成"""
await self._finished.wait()
async def get(self, timeout: float = None) -> Tuple[int, Dict[str, Any]]:
"""
获取优先级最高的消息
Args:
timeout: 超时时间None 表示无限等待
Returns:
(msg_type, data) 元组
Raises:
asyncio.TimeoutError: 超时
"""
start_time = time.time()
while True:
async with self._lock:
if self._heap:
msg = heapq.heappop(self._heap)
if not self._heap:
self._not_empty.clear()
return (msg.msg_type, msg.data)
# 计算剩余超时时间
if timeout is not None:
elapsed = time.time() - start_time
remaining = timeout - elapsed
if remaining <= 0:
raise asyncio.TimeoutError("Queue get timeout")
try:
await asyncio.wait_for(self._not_empty.wait(), timeout=remaining)
except asyncio.TimeoutError:
raise asyncio.TimeoutError("Queue get timeout")
else:
await self._not_empty.wait()
def get_nowait(self) -> Tuple[int, Dict[str, Any]]:
"""非阻塞获取消息"""
if not self._heap:
raise asyncio.QueueEmpty()
msg = heapq.heappop(self._heap)
if not self._heap:
self._not_empty.clear()
return (msg.msg_type, msg.data)
def task_done(self):
"""标记任务完成"""
self._unfinished_tasks = max(0, self._unfinished_tasks - 1)
if self._unfinished_tasks == 0:
self._finished.set()
async def join(self):
"""等待所有任务完成"""
await self._finished.wait()
def clear(self):
"""清空队列"""
self._heap.clear()
@@ -264,42 +282,84 @@ class PriorityMessageQueue:
self._unfinished_tasks = 0
self._finished.set()
async def update_config(
self,
maxsize: Optional[int] = None,
overflow_strategy: Optional[str] = None,
sampling_rate: Optional[float] = None,
):
"""运行中更新队列配置。"""
async with self._lock:
if maxsize is not None:
self.maxsize = max(int(maxsize), 1)
if overflow_strategy is not None:
if isinstance(overflow_strategy, OverflowStrategy):
self.overflow_strategy = overflow_strategy
else:
try:
self.overflow_strategy = OverflowStrategy(overflow_strategy)
except ValueError:
logger.warning(f"忽略无效的队列溢出策略: {overflow_strategy}")
if sampling_rate is not None:
self.sampling_rate = max(0.0, min(1.0, float(sampling_rate)))
while len(self._heap) > self.maxsize:
self._drop_oldest()
if not self._heap:
self._not_empty.clear()
else:
self._not_empty.set()
def get_stats(self) -> Dict[str, Any]:
"""获取队列统计信息"""
return {
"current_size": len(self._heap),
"max_size": self.maxsize,
"total_put": self._total_put,
"total_dropped": self._total_dropped,
"total_rejected": self._total_rejected,
"unfinished_tasks": self._unfinished_tasks,
"overflow_strategy": self.overflow_strategy.value,
"max_size": self.maxsize,
"total_put": self._total_put,
"total_dropped": self._total_dropped,
"total_rejected": self._total_rejected,
"unfinished_tasks": self._unfinished_tasks,
"overflow_strategy": self.overflow_strategy.value,
"utilization": len(self._heap) / max(self.maxsize, 1),
}
def drop_lowest_priority(self) -> bool:
"""兼容接口:丢弃优先级最低的消息"""
return self._drop_lowest()
@classmethod
def from_config(cls, queue_config: Dict[str, Any]) -> "PriorityMessageQueue":
"""
从配置创建队列
Args:
queue_config: Queue 配置节
Args:
queue_config: Queue 配置节
Returns:
PriorityMessageQueue 实例
"""
overflow_strategy = queue_config.get("overflow_strategy", "drop_oldest")
try:
overflow_strategy = OverflowStrategy(overflow_strategy)
except ValueError:
logger.warning(f"无效的队列溢出策略: {overflow_strategy},将使用 drop_oldest")
overflow_strategy = OverflowStrategy.DROP_OLDEST
return cls(
maxsize=queue_config.get("max_size", 1000),
overflow_strategy=queue_config.get("overflow_strategy", "drop_oldest"),
overflow_strategy=overflow_strategy,
sampling_rate=queue_config.get("sampling_rate", 0.5),
)
# ==================== 导出 ====================
__all__ = [
'MessagePriority',
'OverflowStrategy',
'PriorityMessage',
'PriorityMessageQueue',
]
# ==================== 导出 ====================
__all__ = [
'MessagePriority',
'OverflowStrategy',
'PriorityMessage',
'PriorityMessageQueue',
]

48
utils/operation_lock.py Normal file
View File

@@ -0,0 +1,48 @@
"""
全局操作锁
用于在关键操作期间暂停消息处理(例如全量成员同步)
"""
import asyncio
from loguru import logger
class OperationLock:
"""全局暂停/恢复控制"""
_paused: bool = False
_reason: str = ""
_pause_event: asyncio.Event = asyncio.Event()
_pause_event.set()
@classmethod
def pause(cls, reason: str = "") -> None:
if cls._paused:
return
cls._paused = True
cls._reason = reason or ""
cls._pause_event.clear()
logger.warning(f"[OperationLock] 消息处理已暂停: {cls._reason}")
@classmethod
def resume(cls) -> None:
if not cls._paused:
return
cls._paused = False
cls._reason = ""
cls._pause_event.set()
logger.warning("[OperationLock] 消息处理已恢复")
@classmethod
def is_paused(cls) -> bool:
return cls._paused
@classmethod
def reason(cls) -> str:
return cls._reason
@classmethod
async def wait_if_paused(cls) -> None:
if cls._paused:
await cls._pause_event.wait()

View File

@@ -3,13 +3,14 @@ import inspect
import os
import sys
import traceback
from pathlib import Path
from typing import Dict, Type, List, Union
from loguru import logger
# from WechatAPI import WechatAPIClient # 注释掉WechatHookBot 不需要这个导入
from utils.singleton import Singleton
from utils.config_manager import get_bot_config
from utils.config_manager import get_bot_config, get_project_root
from utils.llm_tooling import register_plugin_tools, unregister_plugin_tools
from .event_manager import EventManager
from .plugin_base import PluginBase
@@ -22,6 +23,7 @@ class PluginManager(metaclass=Singleton):
self.plugin_info: Dict[str, dict] = {} # 新增:存储所有插件信息
self.bot = None
self._plugins_dir = get_project_root() / "plugins"
# 使用统一配置管理器
bot_config = get_bot_config()
@@ -118,9 +120,14 @@ class PluginManager(metaclass=Singleton):
bool: 是否成功加载插件
"""
found = False
for dirname in os.listdir("plugins"):
if not self._plugins_dir.exists():
logger.warning(f"插件目录不存在: {self._plugins_dir}")
return False
for plugin_dir in self._plugins_dir.iterdir():
dirname = plugin_dir.name
try:
if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"):
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
module = importlib.import_module(f"plugins.{dirname}.main")
importlib.reload(module)
@@ -137,6 +144,7 @@ class PluginManager(metaclass=Singleton):
if not found:
logger.warning(f"未找到插件类 {plugin_name}")
return False
def _resolve_load_order(self, plugin_classes: List[Type[PluginBase]]) -> List[Type[PluginBase]]:
"""
@@ -202,8 +210,13 @@ class PluginManager(metaclass=Singleton):
all_plugin_classes = []
plugin_disabled_map = {}
for dirname in os.listdir("plugins"):
if os.path.isdir(f"plugins/{dirname}") and os.path.exists(f"plugins/{dirname}/main.py"):
if not self._plugins_dir.exists():
logger.warning(f"插件目录不存在: {self._plugins_dir}")
return loaded_plugins
for plugin_dir in self._plugins_dir.iterdir():
dirname = plugin_dir.name
if plugin_dir.is_dir() and (plugin_dir / "main.py").exists():
try:
module = importlib.import_module(f"plugins.{dirname}.main")
for name, obj in inspect.getmembers(module):
@@ -369,10 +382,15 @@ class PluginManager(metaclass=Singleton):
return []
async def refresh_plugins(self):
for dirname in os.listdir("plugins"):
if not self._plugins_dir.exists():
logger.warning(f"插件目录不存在: {self._plugins_dir}")
return
for plugin_dir in self._plugins_dir.iterdir():
dirname = plugin_dir.name
try:
dirpath = f"plugins/{dirname}"
if os.path.isdir(dirpath) and os.path.exists(f"{dirpath}/main.py"):
dirpath = Path(plugin_dir)
if dirpath.is_dir() and (dirpath / "main.py").exists():
# 验证目录名合法性
if not dirname.isidentifier():
logger.warning(f"跳过非法插件目录名: {dirname}")
@@ -415,8 +433,7 @@ class PluginManager(metaclass=Singleton):
try:
# 尝试从 AIChat 插件配置读取
import tomllib
from pathlib import Path
aichat_config_path = Path("plugins/AIChat/config.toml")
aichat_config_path = self._plugins_dir / "AIChat" / "config.toml"
if aichat_config_path.exists():
with open(aichat_config_path, "rb") as f:
aichat_config = tomllib.load(f)
@@ -429,8 +446,7 @@ class PluginManager(metaclass=Singleton):
"""获取工具超时配置"""
try:
import tomllib
from pathlib import Path
aichat_config_path = Path("plugins/AIChat/config.toml")
aichat_config_path = self._plugins_dir / "AIChat" / "config.toml"
if aichat_config_path.exists():
with open(aichat_config_path, "rb") as f:
aichat_config = tomllib.load(f)

563
utils/webui.py Normal file
View File

@@ -0,0 +1,563 @@
"""
WebUI 多功能面板
功能:实时日志查看 + 配置编辑器 + 插件管理
前端静态文件位于 utils/webui_static/ 目录。
"""
import asyncio
import base64
import collections
import copy
import hashlib
import hmac
import secrets
import time
import tomllib
from pathlib import Path
from aiohttp import web, WSMsgType
from loguru import logger
try:
import tomli_w
except ImportError:
tomli_w = None
class LogBuffer:
"""环形日志缓冲区"""
def __init__(self, maxlen: int = 500):
self._buffer = collections.deque(maxlen=maxlen)
self._clients: set[web.WebSocketResponse] = set()
def append(self, line: str):
self._buffer.append(line)
dead = set()
for ws in self._clients:
if ws.closed:
dead.add(ws)
continue
task = asyncio.get_event_loop().create_task(ws.send_str(line))
task.add_done_callback(lambda t, w=ws: self._clients.discard(w) if t.exception() else None)
self._clients -= dead
def get_history(self) -> list[str]:
return list(self._buffer)
def add_client(self, ws: web.WebSocketResponse):
self._clients.add(ws)
def remove_client(self, ws: web.WebSocketResponse):
self._clients.discard(ws)
_log_buffer = LogBuffer()
def get_log_buffer() -> LogBuffer:
return _log_buffer
def loguru_sink(message):
"""loguru sink"""
text = str(message).rstrip("\n")
_log_buffer.append(text)
# 主配置 section 中文名映射
SECTION_LABELS = {
"HttpHook": "Hook 连接",
"Bot": "机器人",
"Database": "数据库",
"Performance": "性能",
"Queue": "消息队列",
"Concurrency": "并发控制",
"Scheduler": "定时任务",
"WebUI": "WebUI",
}
# 插件配置 section 中文名映射
PLUGIN_CONFIG_LABELS = {
"AIChat": {
"plugin": "插件基本信息",
"api": "AI API 配置",
"proxy": "代理配置",
"prompt": "人设配置",
"output": "输出后处理",
"behavior": "触发行为",
"memory": "对话记忆",
"history": "群组历史记录",
"image_description": "图片描述",
"video_recognition": "视频识别",
"rate_limit": "限流配置",
"redis": "Redis 存储",
"tools": "LLM 工具",
"tools.timeout": "工具超时",
"tools.concurrency": "工具并发",
"vector_memory": "向量长期记忆",
},
}
# 静态文件目录
_STATIC_DIR = Path(__file__).parent / "webui_static"
SESSION_COOKIE_NAME = "whb_webui_session"
PBKDF2_ROUNDS = 240000
DEFAULT_WEBUI_USERNAME = "admin"
DEFAULT_WEBUI_PASSWORD = "admin123456"
DEFAULT_SESSION_TIMEOUT_SECONDS = 8 * 60 * 60
AUTH_MANAGED_KEYS = {"auth_username", "auth_password_hash"}
def hash_password(password: str, *, salt: str | None = None) -> str:
"""生成 PBKDF2 密码哈希。"""
if not isinstance(password, str) or password == "":
raise ValueError("密码不能为空")
if salt is None:
salt = secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt.encode("utf-8"),
PBKDF2_ROUNDS,
)
encoded = base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return f"pbkdf2_sha256${PBKDF2_ROUNDS}${salt}${encoded}"
def verify_password(password: str, stored_hash: str) -> bool:
"""校验明文密码是否匹配哈希。"""
if not isinstance(password, str) or not isinstance(stored_hash, str):
return False
try:
algo, rounds, salt, digest = stored_hash.split("$", 3)
rounds_int = int(rounds)
except (ValueError, TypeError):
return False
if algo != "pbkdf2_sha256" or rounds_int <= 0:
return False
calculated = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt.encode("utf-8"),
rounds_int,
)
calculated_encoded = base64.urlsafe_b64encode(calculated).decode("ascii").rstrip("=")
return hmac.compare_digest(calculated_encoded, digest)
class WebUIServer:
"""WebUI HTTP 服务器"""
def __init__(self, host: str = "0.0.0.0", port: int = 5001, config_path: str = "main_config.toml"):
self.host = host
self.port = port
self.config_path = Path(config_path)
self.app = web.Application(middlewares=[self._auth_middleware])
self.runner = None
self._sessions: dict[str, dict[str, float | str]] = {}
self._auth_update_lock = asyncio.Lock()
self._setup_routes()
self._ensure_auth_initialized()
def _setup_routes(self):
self.app.router.add_get("/", self._handle_index)
self.app.router.add_get("/api/auth/status", self._handle_auth_status)
self.app.router.add_post("/api/auth/login", self._handle_auth_login)
self.app.router.add_post("/api/auth/logout", self._handle_auth_logout)
self.app.router.add_post("/api/auth/change-credentials", self._handle_change_credentials)
self.app.router.add_get("/ws", self._handle_ws)
self.app.router.add_get("/api/config", self._handle_get_config)
self.app.router.add_post("/api/config", self._handle_save_config)
self.app.router.add_get("/api/plugins", self._handle_get_plugins)
self.app.router.add_post("/api/plugins/toggle", self._handle_toggle_plugin)
self.app.router.add_get("/api/plugins/{name}/config", self._handle_get_plugin_config)
self.app.router.add_post("/api/plugins/{name}/config", self._handle_save_plugin_config)
self.app.router.add_static("/static/", path=_STATIC_DIR, name="static")
@staticmethod
def _normalize_session_timeout(value) -> int:
try:
timeout = int(value)
except Exception:
return DEFAULT_SESSION_TIMEOUT_SECONDS
return max(300, timeout)
def _load_main_config(self) -> dict:
with open(self.config_path, "rb") as f:
return tomllib.load(f)
def _save_main_config(self, data: dict):
if tomli_w is None:
raise RuntimeError("tomli_w 未安装,无法写入 main_config.toml")
with open(self.config_path, "wb") as f:
tomli_w.dump(data, f)
def _ensure_auth_initialized(self):
try:
data = self._load_main_config()
except Exception as e:
logger.error(f"读取配置失败,无法初始化 WebUI 认证: {e}")
return
webui_cfg = data.setdefault("WebUI", {})
changed = False
username = str(webui_cfg.get("auth_username", "")).strip()
if not username:
webui_cfg["auth_username"] = DEFAULT_WEBUI_USERNAME
changed = True
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
if not pwd_hash:
webui_cfg["auth_password_hash"] = hash_password(DEFAULT_WEBUI_PASSWORD)
changed = True
logger.warning(
"WebUI 未配置管理员账号密码,已初始化默认账号: "
f"{DEFAULT_WEBUI_USERNAME} / {DEFAULT_WEBUI_PASSWORD},请尽快在 WebUI 的安全页面修改。"
)
normalized_timeout = self._normalize_session_timeout(
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
)
if webui_cfg.get("session_timeout_seconds") != normalized_timeout:
webui_cfg["session_timeout_seconds"] = normalized_timeout
changed = True
if changed:
try:
self._save_main_config(data)
except Exception as e:
logger.error(f"写入 WebUI 认证初始化配置失败: {e}")
def _get_auth_settings(self) -> tuple[str, str, int]:
try:
data = self._load_main_config()
webui_cfg = data.get("WebUI", {})
username = str(webui_cfg.get("auth_username", DEFAULT_WEBUI_USERNAME)).strip() or DEFAULT_WEBUI_USERNAME
pwd_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
timeout = self._normalize_session_timeout(
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
)
if not pwd_hash:
# 配置异常时兜底,避免直接失去登录能力
pwd_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
return username, pwd_hash, timeout
except Exception as e:
logger.error(f"读取 WebUI 认证配置失败,使用默认兜底: {e}")
return DEFAULT_WEBUI_USERNAME, hash_password(DEFAULT_WEBUI_PASSWORD), DEFAULT_SESSION_TIMEOUT_SECONDS
def _cleanup_sessions(self):
now = time.time()
expired_tokens = [
token
for token, session in self._sessions.items()
if float(session.get("expires_at", 0)) <= now
]
for token in expired_tokens:
self._sessions.pop(token, None)
def _create_session(self, username: str, timeout_seconds: int) -> str:
self._cleanup_sessions()
token = secrets.token_urlsafe(32)
self._sessions[token] = {
"username": username,
"expires_at": time.time() + timeout_seconds,
}
return token
def _invalidate_session(self, token: str):
if token:
self._sessions.pop(token, None)
def _set_session_cookie(self, response: web.Response, request: web.Request, token: str, timeout_seconds: int):
response.set_cookie(
SESSION_COOKIE_NAME,
token,
max_age=timeout_seconds,
path="/",
httponly=True,
secure=request.secure,
samesite="Lax",
)
def _get_session_username(self, request: web.Request, *, refresh: bool = True) -> str | None:
token = request.cookies.get(SESSION_COOKIE_NAME, "")
if not token:
return None
self._cleanup_sessions()
session = self._sessions.get(token)
if not session:
return None
now = time.time()
expires_at = float(session.get("expires_at", 0))
if expires_at <= now:
self._sessions.pop(token, None)
return None
username = str(session.get("username", "")).strip()
if not username:
self._sessions.pop(token, None)
return None
if refresh:
_, _, timeout = self._get_auth_settings()
session["expires_at"] = now + timeout
return username
@staticmethod
def _is_public_path(path: str) -> bool:
return (
path == "/"
or path.startswith("/static/")
or path == "/favicon.ico"
or path == "/api/auth/status"
or path == "/api/auth/login"
)
@web.middleware
async def _auth_middleware(self, request: web.Request, handler):
path = request.path
if self._is_public_path(path):
return await handler(request)
username = self._get_session_username(request)
if not username:
if path.startswith("/api/") or path == "/ws":
return web.json_response({"ok": False, "error": "未登录或会话已过期"}, status=401)
raise web.HTTPFound("/")
request["webui_user"] = username
return await handler(request)
async def _handle_auth_status(self, request: web.Request) -> web.Response:
configured_username, _, _ = self._get_auth_settings()
current_username = self._get_session_username(request, refresh=False)
return web.json_response({
"ok": True,
"authenticated": bool(current_username),
"username": current_username or configured_username,
})
async def _handle_auth_login(self, request: web.Request) -> web.Response:
try:
body = await request.json()
except Exception:
body = {}
username = str(body.get("username", "")).strip()
password = str(body.get("password", ""))
configured_username, configured_hash, timeout = self._get_auth_settings()
if username != configured_username or not verify_password(password, configured_hash):
return web.json_response({"ok": False, "error": "用户名或密码错误"}, status=401)
token = self._create_session(configured_username, timeout)
response = web.json_response({"ok": True, "username": configured_username})
self._set_session_cookie(response, request, token, timeout)
return response
async def _handle_auth_logout(self, request: web.Request) -> web.Response:
token = request.cookies.get(SESSION_COOKIE_NAME, "")
if token:
self._invalidate_session(token)
response = web.json_response({"ok": True})
response.del_cookie(SESSION_COOKIE_NAME, path="/")
return response
async def _handle_change_credentials(self, request: web.Request) -> web.Response:
if tomli_w is None:
return web.json_response({"ok": False, "error": "tomli_w 未安装,无法保存认证配置"})
try:
body = await request.json()
current_password = str(body.get("current_password", ""))
new_username = str(body.get("new_username", "")).strip()
new_password = str(body.get("new_password", ""))
except Exception:
return web.json_response({"ok": False, "error": "请求参数格式错误"})
if not current_password:
return web.json_response({"ok": False, "error": "请输入当前密码"})
if not new_username:
return web.json_response({"ok": False, "error": "账号不能为空"})
if len(new_password) < 8:
return web.json_response({"ok": False, "error": "新密码长度至少 8 位"})
async with self._auth_update_lock:
try:
data = self._load_main_config()
webui_cfg = data.setdefault("WebUI", {})
stored_hash = str(webui_cfg.get("auth_password_hash", "")).strip()
if not stored_hash:
stored_hash = hash_password(DEFAULT_WEBUI_PASSWORD)
if not verify_password(current_password, stored_hash):
return web.json_response({"ok": False, "error": "当前密码错误"})
webui_cfg["auth_username"] = new_username
webui_cfg["auth_password_hash"] = hash_password(new_password)
webui_cfg["session_timeout_seconds"] = self._normalize_session_timeout(
webui_cfg.get("session_timeout_seconds", DEFAULT_SESSION_TIMEOUT_SECONDS)
)
self._save_main_config(data)
timeout = self._normalize_session_timeout(webui_cfg["session_timeout_seconds"])
self._sessions.clear()
new_token = self._create_session(new_username, timeout)
response = web.json_response({"ok": True, "username": new_username})
self._set_session_cookie(response, request, new_token, timeout)
return response
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_index(self, request: web.Request) -> web.Response:
index_file = _STATIC_DIR / "index.html"
return web.FileResponse(index_file)
async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse()
await ws.prepare(request)
_log_buffer.add_client(ws)
for line in _log_buffer.get_history():
await ws.send_str(line)
try:
async for msg in ws:
if msg.type == WSMsgType.ERROR:
break
finally:
_log_buffer.remove_client(ws)
return ws
async def _handle_get_config(self, request: web.Request) -> web.Response:
try:
data = self._load_main_config()
safe_data = copy.deepcopy(data)
webui_cfg = safe_data.get("WebUI")
if isinstance(webui_cfg, dict):
for key in AUTH_MANAGED_KEYS:
webui_cfg.pop(key, None)
return web.json_response({"ok": True, "data": safe_data, "labels": SECTION_LABELS})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_save_config(self, request: web.Request) -> web.Response:
if tomli_w is None:
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
try:
body = await request.json()
data = body.get("data", {})
# 避免配置编辑器覆盖认证字段(认证字段专用接口维护)
current = self._load_main_config()
current_webui = current.get("WebUI", {})
new_webui = data.setdefault("WebUI", {})
for key in AUTH_MANAGED_KEYS:
if key in current_webui and key not in new_webui:
new_webui[key] = current_webui[key]
self._save_main_config(data)
self._ensure_auth_initialized()
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_get_plugins(self, request: web.Request) -> web.Response:
try:
from utils.plugin_manager import PluginManager
pm = PluginManager()
plugins = []
for name, info in pm.plugin_info.items():
directory = info.get("directory", name)
cfg_path = Path("plugins") / directory / "config.toml"
plugins.append({
"name": name,
"description": info.get("description", ""),
"author": info.get("author", ""),
"version": info.get("version", ""),
"directory": directory,
"enabled": info.get("enabled", False),
"has_config": cfg_path.exists(),
})
plugins.sort(key=lambda p: p["name"])
return web.json_response({"ok": True, "plugins": plugins})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_toggle_plugin(self, request: web.Request) -> web.Response:
try:
from utils.plugin_manager import PluginManager
body = await request.json()
name = body.get("name", "")
enable = body.get("enable", False)
if name == "ManagePlugin":
return web.json_response({"ok": False, "error": "ManagePlugin 不可禁用"})
pm = PluginManager()
if enable:
ok = await pm.load_plugin(name)
if ok:
return web.json_response({"ok": True})
return web.json_response({"ok": False, "error": f"启用 {name} 失败"})
else:
ok = await pm.unload_plugin(name)
if ok:
return web.json_response({"ok": True})
return web.json_response({"ok": False, "error": f"禁用 {name} 失败"})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_get_plugin_config(self, request: web.Request) -> web.Response:
name = request.match_info["name"]
try:
from utils.plugin_manager import PluginManager
pm = PluginManager()
info = pm.plugin_info.get(name)
if not info:
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
directory = info.get("directory", name)
cfg_path = Path("plugins") / directory / "config.toml"
if not cfg_path.exists():
return web.json_response({"ok": False, "error": "该插件无配置文件"})
with open(cfg_path, "rb") as f:
data = tomllib.load(f)
labels = PLUGIN_CONFIG_LABELS.get(name, {})
return web.json_response({"ok": True, "data": data, "labels": labels})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def _handle_save_plugin_config(self, request: web.Request) -> web.Response:
if tomli_w is None:
return web.json_response({"ok": False, "error": "tomli_w 未安装"})
name = request.match_info["name"]
try:
from utils.plugin_manager import PluginManager
pm = PluginManager()
info = pm.plugin_info.get(name)
if not info:
return web.json_response({"ok": False, "error": f"插件 {name} 不存在"})
directory = info.get("directory", name)
cfg_path = Path("plugins") / directory / "config.toml"
body = await request.json()
data = body.get("data", {})
with open(cfg_path, "wb") as f:
tomli_w.dump(data, f)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)})
async def start(self):
self.runner = web.AppRunner(self.app)
await self.runner.setup()
site = web.TCPSite(self.runner, self.host, self.port)
await site.start()
logger.success(f"WebUI 已启动: http://{self.host}:{self.port}")
async def stop(self):
if self.runner:
await self.runner.cleanup()
logger.info("WebUI 已停止")

165
utils/webui_static/app.js Normal file
View File

@@ -0,0 +1,165 @@
const { createApp, ref, provide, onMounted, onUnmounted } = Vue;
const app = createApp({
setup() {
const api = useApi();
const currentPage = ref('log');
const authReady = ref(false);
const authenticated = ref(false);
const authUser = ref('');
const loginLoading = ref(false);
const loginForm = ref({
username: '',
password: '',
});
provide('currentPage', currentPage);
async function refreshAuthState() {
const json = await api.getAuthStatus();
if (json) {
authenticated.value = !!json.authenticated;
authUser.value = json.username || '';
if (json.username && !loginForm.value.username) {
loginForm.value.username = json.username;
}
} else {
authenticated.value = false;
authUser.value = '';
}
authReady.value = true;
}
async function login() {
const username = (loginForm.value.username || '').trim();
const password = loginForm.value.password || '';
if (!username) {
ElementPlus.ElMessage.warning('请输入账号');
return;
}
if (!password) {
ElementPlus.ElMessage.warning('请输入密码');
return;
}
loginLoading.value = true;
const json = await api.login(username, password);
loginLoading.value = false;
if (!json) return;
authenticated.value = true;
authUser.value = json.username || username;
loginForm.value.password = '';
currentPage.value = 'log';
ElementPlus.ElMessage.success('登录成功');
}
async function logout() {
await api.logout();
authenticated.value = false;
loginForm.value.password = '';
currentPage.value = 'log';
}
function handleAuthExpired() {
const wasAuthenticated = authenticated.value;
authenticated.value = false;
loginForm.value.password = '';
currentPage.value = 'log';
if (wasAuthenticated) {
ElementPlus.ElMessage.warning('登录状态已失效,请重新登录');
}
}
function handleAuthUpdated(event) {
const username = event?.detail?.username;
if (username) {
authUser.value = username;
}
}
function handleLayoutAuthUpdated(username) {
if (username) {
authUser.value = username;
}
}
onMounted(() => {
window.addEventListener('webui-auth-required', handleAuthExpired);
window.addEventListener('webui-auth-updated', handleAuthUpdated);
refreshAuthState();
});
onUnmounted(() => {
window.removeEventListener('webui-auth-required', handleAuthExpired);
window.removeEventListener('webui-auth-updated', handleAuthUpdated);
});
return {
authReady,
authenticated,
authUser,
loginLoading,
loginForm,
login,
logout,
handleLayoutAuthUpdated,
};
},
template: `
<div v-if="authReady" class="app-root">
<div v-if="!authenticated"
class="login-page">
<el-card class="login-card">
<template #header>
<div class="login-title">WechatHookBot 控制台</div>
<div class="login-subtitle">Bright Tech UI · 安全登录</div>
</template>
<el-form label-position="top">
<el-form-item label="账号">
<el-input
v-model="loginForm.username"
autocomplete="username"
placeholder="请输入账号" />
</el-form-item>
<el-form-item label="密码">
<el-input
v-model="loginForm.password"
show-password
autocomplete="current-password"
placeholder="请输入密码"
@keyup.enter="login" />
</el-form-item>
<el-button type="primary" :loading="loginLoading" style="width:100%" @click="login">
登录
</el-button>
</el-form>
</el-card>
</div>
<AppLayout
v-else
:auth-user="authUser"
@logout="logout"
@auth-updated="handleLayoutAuthUpdated" />
</div>
`
});
app.use(ElementPlus, {
locale: ElementPlusLocaleZhCn,
});
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component);
}
app.component('AppLayout', window.AppLayout);
app.component('LogViewer', window.LogViewer);
app.component('ConfigEditor', window.ConfigEditor);
app.component('ConfigSection', window.ConfigSection);
app.component('PluginList', window.PluginList);
app.component('PluginConfigDialog', window.PluginConfigDialog);
app.component('SecuritySettings', window.SecuritySettings);
app.mount('#app');

View File

@@ -0,0 +1,60 @@
window.AppLayout = {
props: {
authUser: {
type: String,
default: '',
},
},
emits: ['logout', 'auth-updated'],
setup() {
const { inject } = Vue;
const currentPage = inject('currentPage');
const menuItems = [
{ index: 'log', icon: 'Document', label: '日志' },
{ index: 'config', icon: 'Setting', label: '配置' },
{ index: 'plugin', icon: 'Box', label: '插件' },
{ index: 'security', icon: 'Lock', label: '安全' },
];
return { currentPage, menuItems };
},
template: `
<el-container class="app-shell">
<el-aside width="220px" class="app-aside">
<div class="brand-panel">
<div class="brand-title">WechatHookBot</div>
<div class="brand-sub">Control Surface</div>
</div>
<el-menu :default-active="currentPage"
class="app-menu"
@select="(idx) => currentPage = idx"
background-color="transparent">
<el-menu-item v-for="item in menuItems" :key="item.index" :index="item.index">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-main class="app-main">
<div class="app-topbar">
<div class="topbar-title">Real-time Operations Panel</div>
<div class="topbar-right">
<span class="auth-pill">
当前账号: {{ authUser || '-' }}
</span>
<el-button size="small" @click="$emit('logout')">退出登录</el-button>
</div>
</div>
<div class="content-stage">
<LogViewer v-show="currentPage === 'log'" />
<ConfigEditor v-show="currentPage === 'config'" />
<PluginList v-show="currentPage === 'plugin'" />
<SecuritySettings
v-show="currentPage === 'security'"
@updated="$emit('auth-updated', $event)" />
</div>
</el-main>
</el-container>
`
};

View File

@@ -0,0 +1,51 @@
window.ConfigEditor = {
setup() {
const { ref, onMounted } = Vue;
const api = useApi();
const configData = ref({});
const configLabels = ref({});
const loaded = ref(false);
const saving = ref(false);
async function load() {
const json = await api.getConfig();
if (json) {
configData.value = json.data;
configLabels.value = json.labels || {};
loaded.value = true;
}
}
async function save() {
saving.value = true;
const json = await api.saveConfig(configData.value);
if (json) ElementPlus.ElMessage.success('配置已保存');
saving.value = false;
}
onMounted(load);
return { configData, configLabels, loaded, saving, save };
},
template: `
<div class="panel-page">
<div class="panel-scroll">
<template v-if="loaded">
<ConfigSection
v-for="(fields, section) in configData" :key="section"
:section="section"
:label="configLabels[section] || section"
:fields="fields"
v-model="configData[section]" />
</template>
<div v-else class="panel-loading">
加载中...
</div>
</div>
<div class="panel-footer">
<el-button type="primary" @click="save" :loading="saving">保存配置</el-button>
</div>
</div>
`
};

View File

@@ -0,0 +1,84 @@
window.ConfigSection = {
props: {
section: String,
label: String,
fields: Object,
modelValue: Object,
},
emits: ['update:modelValue'],
setup(props, { emit }) {
const { computed } = Vue;
const flatFields = computed(() => {
const result = [];
for (const [key, val] of Object.entries(props.fields)) {
if (typeof val === 'object' && !Array.isArray(val)) continue;
result.push({ key, val });
}
return result;
});
function updateField(key, newVal) {
props.modelValue[key] = newVal;
}
function fieldType(val) {
if (typeof val === 'boolean') return 'boolean';
if (typeof val === 'number') return 'number';
if (Array.isArray(val)) return 'array';
return 'string';
}
function removeTag(key, index) {
props.modelValue[key].splice(index, 1);
}
function addTag(key, val) {
if (!val || !val.trim()) return;
if (!Array.isArray(props.modelValue[key])) props.modelValue[key] = [];
props.modelValue[key].push(val.trim());
}
return { flatFields, updateField, fieldType, removeTag, addTag };
},
template: `
<el-card shadow="never" class="config-card">
<template #header>
<div class="config-card-header">
<span class="config-card-title">{{ label }}</span>
<span class="config-card-section">[{{ section }}]</span>
</div>
</template>
<div v-for="item in flatFields" :key="item.key"
class="config-row">
<div class="config-key">
{{ item.key }}
</div>
<div class="config-val">
<el-switch v-if="fieldType(item.val) === 'boolean'"
:model-value="modelValue[item.key]"
@update:model-value="updateField(item.key, $event)"
active-text="开" inactive-text="关" />
<el-input-number v-else-if="fieldType(item.val) === 'number'"
:model-value="modelValue[item.key]"
@update:model-value="updateField(item.key, $event)"
:step="Number.isInteger(item.val) ? 1 : 0.1"
controls-position="right" style="width: 200px" />
<div v-else-if="fieldType(item.val) === 'array'" class="config-tags">
<el-tag v-for="(tag, ti) in modelValue[item.key]" :key="ti"
closable @close="removeTag(item.key, ti)"
style="margin-bottom:2px">
{{ tag }}
</el-tag>
<el-input size="small" style="width:140px"
placeholder="回车添加"
@keyup.enter="addTag(item.key, $event.target.value); $event.target.value=''" />
</div>
<el-input v-else
:model-value="String(modelValue[item.key] ?? '')"
@update:model-value="updateField(item.key, $event)" />
</div>
</div>
</el-card>
`
};

View File

@@ -0,0 +1,113 @@
window.LogViewer = {
setup() {
const { ref, computed, onMounted, onUnmounted, nextTick, watch } = Vue;
const { connected, logs, paused, connect, clear, togglePause, destroy } = useWebSocket();
const filterText = ref('');
const containerRef = ref(null);
function onWheel(event) {
const el = containerRef.value;
if (!el) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const scrollingUp = event.deltaY < 0;
const scrollingDown = event.deltaY > 0;
// 防止日志容器触底/触顶时把滚轮事件传递给外层页面
if ((scrollingUp && atTop) || (scrollingDown && atBottom)) {
event.preventDefault();
}
event.stopPropagation();
}
const filteredLogs = computed(() => {
const kw = filterText.value.toLowerCase();
if (!kw) return logs.value;
return logs.value.filter(line => line.toLowerCase().includes(kw));
});
watch(filteredLogs, () => {
if (!paused.value) {
nextTick(() => {
if (containerRef.value) {
containerRef.value.scrollTop = containerRef.value.scrollHeight;
}
});
}
});
const levelColors = {
DEBUG: 'var(--el-text-color-placeholder)',
INFO: 'var(--el-color-primary)',
SUCCESS: 'var(--el-color-success)',
WARNING: 'var(--el-color-warning)',
ERROR: 'var(--el-color-danger)',
CRITICAL: '#b42318',
};
function colorize(raw) {
let s = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(
/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})/,
'<span style="color:var(--el-text-color-placeholder)">$1</span>'
);
const m = raw.match(/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/);
if (m) {
s = s.replace(
/\|\s*(DEBUG|INFO|SUCCESS|WARNING|ERROR|CRITICAL)\s*\|/,
'| <span style="color:' + levelColors[m[1]] + '">' + m[1] + '</span> |'
);
}
return s;
}
onMounted(() => {
connect();
if (containerRef.value) {
containerRef.value.addEventListener('wheel', onWheel, { passive: false });
}
});
onUnmounted(() => {
if (containerRef.value) {
containerRef.value.removeEventListener('wheel', onWheel);
}
destroy();
});
return {
filterText, paused, connected, filteredLogs,
containerRef, togglePause, clear, colorize
};
},
template: `
<div class="panel-page">
<div class="log-toolbar">
<el-input v-model="filterText" placeholder="搜索过滤..." clearable
class="log-search" size="small">
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-button size="small" :type="paused ? 'primary' : ''" @click="togglePause">
{{ paused ? '恢复' : '暂停' }}
</el-button>
<el-button size="small" @click="clear">清空</el-button>
<span class="log-status" :class="connected ? 'is-online' : 'is-offline'">
<span class="status-dot"></span>
{{ connected ? '已连接' : '已断开' }}
</span>
</div>
<div ref="containerRef"
class="log-stream">
<div v-for="(line, i) in filteredLogs" :key="i"
class="log-line"
v-html="colorize(line)">
</div>
</div>
</div>
`
};

View File

@@ -0,0 +1,82 @@
window.PluginConfigDialog = {
props: {
visible: Boolean,
pluginName: String,
},
emits: ['update:visible'],
setup(props, { emit }) {
const { ref, watch, computed } = Vue;
const api = useApi();
const configData = ref({});
const configLabels = ref({});
const saving = ref(false);
const loading = ref(false);
watch(() => props.visible, async (val) => {
if (val && props.pluginName) {
loading.value = true;
const json = await api.getPluginConfig(props.pluginName);
loading.value = false;
if (json) {
configData.value = json.data;
configLabels.value = json.labels || {};
} else {
emit('update:visible', false);
}
}
});
function collectSections(obj, prefix) {
const result = [];
for (const [key, val] of Object.entries(obj)) {
if (typeof val !== 'object' || Array.isArray(val)) continue;
const fullKey = prefix ? prefix + '.' + key : key;
result.push({
key: fullKey,
fields: val,
label: configLabels.value[fullKey] || fullKey,
});
result.push(...collectSections(val, fullKey));
}
return result;
}
const sections = computed(() => collectSections(configData.value, ''));
async function save() {
saving.value = true;
const json = await api.savePluginConfig(props.pluginName, configData.value);
if (json) {
ElementPlus.ElMessage.success('插件配置已保存');
emit('update:visible', false);
}
saving.value = false;
}
function close() { emit('update:visible', false); }
return { configData, sections, saving, loading, save, close };
},
template: `
<el-dialog :model-value="visible"
@update:model-value="$emit('update:visible', $event)"
:title="pluginName + ' 配置'"
width="720px" top="8vh" destroy-on-close>
<div v-if="loading" class="panel-loading">
加载中...
</div>
<div v-else class="dialog-body-scroll">
<ConfigSection v-for="sec in sections" :key="sec.key"
:section="sec.key"
:label="sec.label"
:fields="sec.fields"
v-model="sec.fields" />
</div>
<template #footer>
<el-button @click="close">取消</el-button>
<el-button type="primary" @click="save" :loading="saving">保存</el-button>
</template>
</el-dialog>
`
};

View File

@@ -0,0 +1,73 @@
window.PluginList = {
setup() {
const { ref, onMounted } = Vue;
const api = useApi();
const plugins = ref([]);
const loaded = ref(false);
const dialogVisible = ref(false);
const dialogPluginName = ref('');
async function load() {
const json = await api.getPlugins();
if (json) { plugins.value = json.plugins; loaded.value = true; }
}
async function toggle(plugin, enable) {
const json = await api.togglePlugin(plugin.name, enable);
if (json) {
ElementPlus.ElMessage.success((enable ? '已启用: ' : '已禁用: ') + plugin.name);
await load();
}
}
function openConfig(name) {
dialogPluginName.value = name;
dialogVisible.value = true;
}
onMounted(load);
return { plugins, loaded, toggle, openConfig, dialogVisible, dialogPluginName };
},
template: `
<div class="panel-scroll">
<template v-if="loaded">
<el-card v-for="p in plugins" :key="p.name" shadow="hover" class="plugin-card">
<div class="plugin-main">
<div class="plugin-info">
<div class="plugin-title">
<span class="plugin-name">{{ p.name }}</span>
<span class="plugin-version">
v{{ p.version }}
</span>
</div>
<div class="plugin-desc">
{{ p.description }}
</div>
<div class="plugin-meta">
作者: {{ p.author }} · 目录: {{ p.directory }}
</div>
</div>
<div class="plugin-actions">
<el-button v-if="p.has_config" size="small" @click="openConfig(p.name)">
配置
</el-button>
<el-tag :type="p.enabled ? 'success' : 'danger'" size="small">
{{ p.enabled ? '已启用' : '已禁用' }}
</el-tag>
<el-switch :model-value="p.enabled"
@update:model-value="toggle(p, $event)"
:disabled="p.name === 'ManagePlugin'"
active-text="开" inactive-text="关" />
</div>
</div>
</el-card>
</template>
<div v-else class="panel-loading">
加载中...
</div>
<PluginConfigDialog v-model:visible="dialogVisible" :plugin-name="dialogPluginName" />
</div>
`
};

View File

@@ -0,0 +1,138 @@
window.SecuritySettings = {
emits: ['updated'],
setup(props, { emit }) {
const { ref, onMounted } = Vue;
const api = useApi();
const loading = ref(false);
const saving = ref(false);
const currentUsername = ref('');
const form = ref({
current_password: '',
new_username: '',
new_password: '',
confirm_password: '',
});
async function load() {
loading.value = true;
const json = await api.getAuthStatus();
if (json) {
currentUsername.value = json.username || '';
form.value.new_username = json.username || '';
}
loading.value = false;
}
async function save() {
const payload = {
current_password: form.value.current_password || '',
new_username: (form.value.new_username || '').trim(),
new_password: form.value.new_password || '',
};
if (!payload.current_password) {
ElementPlus.ElMessage.warning('请输入当前密码');
return;
}
if (!payload.new_username) {
ElementPlus.ElMessage.warning('请输入新账号');
return;
}
if (payload.new_password.length < 8) {
ElementPlus.ElMessage.warning('新密码至少 8 位');
return;
}
if (payload.new_password !== form.value.confirm_password) {
ElementPlus.ElMessage.warning('两次输入的新密码不一致');
return;
}
saving.value = true;
const json = await api.changeCredentials(payload);
saving.value = false;
if (!json) return;
currentUsername.value = json.username || payload.new_username;
form.value.current_password = '';
form.value.new_password = '';
form.value.confirm_password = '';
form.value.new_username = currentUsername.value;
emit('updated', currentUsername.value);
window.dispatchEvent(new CustomEvent('webui-auth-updated', {
detail: { username: currentUsername.value },
}));
ElementPlus.ElMessage.success('账号密码已更新');
}
onMounted(load);
return {
loading,
saving,
currentUsername,
form,
save,
};
},
template: `
<div class="panel-scroll">
<el-card shadow="never" class="security-wrap">
<template #header>
<div class="plugin-main">
<span class="plugin-name">管理员账号安全</span>
<el-tag size="small" type="info">当前账号: {{ currentUsername || '-' }}</el-tag>
</div>
</template>
<el-skeleton v-if="loading" :rows="6" animated />
<div v-else>
<el-alert
title="密码仅保存为哈希值,修改后会立即写入 main_config.toml"
type="info"
:closable="false"
style="margin-bottom:16px;" />
<el-form label-position="top">
<el-form-item label="当前密码">
<el-input
v-model="form.current_password"
show-password
autocomplete="current-password"
placeholder="请输入当前密码" />
</el-form-item>
<el-form-item label="新账号">
<el-input
v-model="form.new_username"
autocomplete="username"
placeholder="请输入新账号" />
</el-form-item>
<el-form-item label="新密码(至少 8 位)">
<el-input
v-model="form.new_password"
show-password
autocomplete="new-password"
placeholder="请输入新密码" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="form.confirm_password"
show-password
autocomplete="new-password"
placeholder="请再次输入新密码"
@keyup.enter="save" />
</el-form-item>
<el-button type="primary" :loading="saving" @click="save">
保存账号密码
</el-button>
</el-form>
</div>
</el-card>
</div>
`,
};

View File

@@ -0,0 +1,67 @@
window.useApi = function useApi() {
async function request(url, options = {}, extra = {}) {
const { silent = false, skipAuthRedirect = false } = extra;
try {
const mergedHeaders = {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const res = await fetch(url, {
credentials: 'same-origin',
...options,
headers: mergedHeaders,
});
let json = null;
try {
json = await res.json();
} catch (e) {
if (!silent) ElementPlus.ElMessage.error('服务端返回格式错误');
return null;
}
if (res.status === 401) {
if (!skipAuthRedirect) {
window.dispatchEvent(new CustomEvent('webui-auth-required'));
}
if (!silent) ElementPlus.ElMessage.error(json.error || '未登录或会话已过期');
return null;
}
if (!json.ok) {
if (!silent) ElementPlus.ElMessage.error(json.error || '请求失败');
return null;
}
return json;
} catch (e) {
if (!silent) ElementPlus.ElMessage.error('网络请求失败');
return null;
}
}
return {
getAuthStatus: () => request('/api/auth/status', {}, { silent: true, skipAuthRedirect: true }),
login: (username, password) => request('/api/auth/login', {
method: 'POST', body: JSON.stringify({ username, password })
}, { skipAuthRedirect: true }),
logout: () => request('/api/auth/logout', { method: 'POST' }, { silent: true }),
changeCredentials: (payload) => request('/api/auth/change-credentials', {
method: 'POST', body: JSON.stringify(payload)
}),
getConfig: () => request('/api/config'),
saveConfig: (data) => request('/api/config', {
method: 'POST', body: JSON.stringify({ data })
}),
getPlugins: () => request('/api/plugins'),
togglePlugin: (name, enable) => request('/api/plugins/toggle', {
method: 'POST', body: JSON.stringify({ name, enable })
}),
getPluginConfig: (name) =>
request(`/api/plugins/${encodeURIComponent(name)}/config`),
savePluginConfig: (name, data) =>
request(`/api/plugins/${encodeURIComponent(name)}/config`, {
method: 'POST', body: JSON.stringify({ data })
}),
};
};

View File

@@ -0,0 +1,35 @@
window.useWebSocket = function useWebSocket() {
const { ref, onUnmounted } = Vue;
const connected = ref(false);
const logs = ref([]);
const paused = ref(false);
let ws = null;
let reconnectTimer = null;
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/ws`);
ws.onopen = () => { connected.value = true; };
ws.onclose = () => {
connected.value = false;
reconnectTimer = setTimeout(connect, 3000);
};
ws.onmessage = (e) => {
// 使用新数组引用,确保依赖 logs 的 watch/computed 能稳定触发(用于自动滚动到底部)
const nextLogs = [...logs.value, e.data];
logs.value = nextLogs.length > 2000 ? nextLogs.slice(-1500) : nextLogs;
};
}
function clear() { logs.value = []; }
function togglePause() { paused.value = !paused.value; }
function destroy() {
if (reconnectTimer) clearTimeout(reconnectTimer);
if (ws) ws.close();
}
return { connected, logs, paused, connect, clear, togglePause, destroy };
};

View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WechatHookBot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="https://unpkg.com/element-plus/dist/index.css">
<link rel="stylesheet" href="/static/style.css?v=20260302_3">
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="https://unpkg.com/element-plus"></script>
<script src="https://unpkg.com/element-plus/dist/locale/zh-cn.js"></script>
<script src="https://unpkg.com/@element-plus/icons-vue"></script>
<script src="/static/composables/useWebSocket.js?v=20260302_3"></script>
<script src="/static/composables/useApi.js?v=20260302_3"></script>
<script src="/static/components/ConfigSection.js?v=20260302_3"></script>
<script src="/static/components/LogViewer.js?v=20260302_3"></script>
<script src="/static/components/ConfigEditor.js?v=20260302_3"></script>
<script src="/static/components/PluginConfigDialog.js?v=20260302_3"></script>
<script src="/static/components/PluginList.js?v=20260302_3"></script>
<script src="/static/components/SecuritySettings.js?v=20260302_3"></script>
<script src="/static/components/AppLayout.js?v=20260302_3"></script>
<script src="/static/app.js?v=20260302_3"></script>
</body>
</html>

View File

@@ -0,0 +1,591 @@
:root {
--wb-primary: #0077ff;
--wb-primary-alt: #00b8ff;
--wb-ink: #0f2a4b;
--wb-subtle: #5f7da3;
--wb-page: #eef5ff;
--wb-panel: rgba(255, 255, 255, 0.76);
--wb-card: rgba(255, 255, 255, 0.92);
--wb-border: #d7e4f6;
--wb-border-strong: #c7d8ef;
--wb-grid: rgba(0, 102, 255, 0.08);
--wb-shadow-lg: 0 24px 56px rgba(24, 74, 150, 0.16);
--wb-shadow-md: 0 12px 30px rgba(20, 76, 154, 0.12);
--el-color-primary: var(--wb-primary);
--el-color-success: #17b26a;
--el-color-warning: #f79009;
--el-color-danger: #f04438;
--el-bg-color: #f8fbff;
--el-bg-color-page: var(--wb-page);
--el-bg-color-overlay: #ffffff;
--el-fill-color-blank: #ffffff;
--el-border-color: var(--wb-border);
--el-border-color-light: #e4edf9;
--el-border-color-lighter: #edf3fb;
--el-text-color-primary: var(--wb-ink);
--el-text-color-regular: #26486f;
--el-text-color-secondary: var(--wb-subtle);
--el-text-color-placeholder: #8ca4c1;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
}
body {
margin: 0;
color: var(--el-text-color-primary);
font-family: "Space Grotesk", "PingFang SC", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at 15% 12%, rgba(0, 167, 255, 0.2), transparent 42%),
radial-gradient(circle at 85% 8%, rgba(0, 98, 255, 0.17), transparent 44%),
linear-gradient(180deg, #f9fcff 0%, #edf4ff 100%);
position: relative;
overflow: hidden;
}
body::before,
body::after {
content: "";
position: fixed;
pointer-events: none;
inset: 0;
z-index: -1;
}
body::before {
background-image:
linear-gradient(transparent 31px, var(--wb-grid) 32px),
linear-gradient(90deg, transparent 31px, var(--wb-grid) 32px);
background-size: 32px 32px;
opacity: 0.55;
}
body::after {
background:
radial-gradient(circle at 18% 78%, rgba(0, 168, 255, 0.16), transparent 34%),
radial-gradient(circle at 80% 74%, rgba(0, 110, 255, 0.14), transparent 38%);
}
.app-root {
height: 100%;
overscroll-behavior: none;
}
.app-shell {
height: 100vh;
padding: 14px;
gap: 12px;
overscroll-behavior: none;
}
.app-aside {
border: 1px solid var(--wb-border);
border-radius: 22px;
overflow: hidden;
background: var(--wb-panel);
backdrop-filter: blur(14px);
box-shadow: var(--wb-shadow-lg);
}
.brand-panel {
padding: 20px 16px 16px;
border-bottom: 1px solid var(--el-border-color-light);
}
.brand-title {
font-size: 18px;
font-weight: 700;
color: #083a72;
letter-spacing: 0.4px;
}
.brand-sub {
margin-top: 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: 1.4px;
text-transform: uppercase;
color: #6284af;
}
.app-menu {
border-right: none !important;
background: transparent !important;
padding: 10px 8px 14px;
}
.app-menu .el-menu-item {
height: 42px;
margin-bottom: 6px;
border-radius: 12px;
color: #2f557f;
font-weight: 600;
}
.app-menu .el-menu-item .el-icon {
font-size: 16px;
margin-right: 10px;
}
.app-menu .el-menu-item:hover {
background: rgba(0, 122, 255, 0.12);
color: #005ac0;
}
.app-menu .el-menu-item.is-active {
color: #ffffff !important;
background: linear-gradient(135deg, #0084ff 0%, #00b0ff 100%);
box-shadow: 0 10px 22px rgba(0, 129, 255, 0.3);
}
.app-main {
border: 1px solid var(--wb-border);
border-radius: 22px;
padding: 0 !important;
overflow: hidden !important;
display: flex;
flex-direction: column;
background: var(--wb-panel);
backdrop-filter: blur(14px);
box-shadow: var(--wb-shadow-lg);
}
.app-topbar {
height: 58px;
padding: 0 18px;
border-bottom: 1px solid var(--el-border-color-light);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(246, 250, 255, 0.92));
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.topbar-title {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.6px;
text-transform: uppercase;
color: #6584ac;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.auth-pill {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
color: #0b4d94;
border: 1px solid #b8d8ff;
background: linear-gradient(120deg, #eaf6ff 0%, #f4fbff 100%);
}
.content-stage {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
}
.panel-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overscroll-behavior: contain;
}
.panel-scroll {
flex: 1;
overflow-y: auto;
padding: 18px;
overscroll-behavior: contain;
}
.panel-loading {
padding: 40px;
text-align: center;
color: var(--el-text-color-placeholder);
}
.panel-footer {
padding: 12px 18px;
border-top: 1px solid var(--el-border-color-light);
display: flex;
justify-content: flex-end;
background: rgba(251, 253, 255, 0.8);
}
.log-toolbar {
padding: 10px 16px;
display: flex;
align-items: center;
gap: 10px;
border-bottom: 1px solid var(--el-border-color-light);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(246, 250, 255, 0.8));
}
.log-search {
width: 260px;
}
.log-status {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 12px;
color: #5f7ca1;
}
.status-dot {
width: 9px;
height: 9px;
border-radius: 50%;
background: #9ab4d8;
box-shadow: 0 0 0 4px rgba(154, 180, 216, 0.2);
}
.log-status.is-online .status-dot {
background: #0fb772;
box-shadow: 0 0 0 4px rgba(15, 183, 114, 0.16);
}
.log-status.is-offline .status-dot {
background: #f04438;
box-shadow: 0 0 0 4px rgba(240, 68, 56, 0.14);
}
.log-stream {
flex: 1;
overflow-y: auto;
padding: 12px 16px 16px;
font-family: "JetBrains Mono", "Cascadia Code", "Consolas", monospace;
font-size: 13px;
line-height: 1.65;
overscroll-behavior: contain;
}
.log-line {
padding: 2px 0;
white-space: pre-wrap;
word-break: break-word;
}
.config-card {
margin-bottom: 14px;
}
.config-card-header {
display: flex;
align-items: baseline;
gap: 8px;
}
.config-card-title {
font-weight: 700;
}
.config-card-section {
color: var(--el-text-color-placeholder);
font-size: 12px;
}
.config-row {
display: flex;
align-items: center;
padding: 10px 0;
gap: 12px;
border-bottom: 1px dashed var(--el-border-color-light);
}
.config-key {
width: 220px;
flex-shrink: 0;
color: #4f7097;
font-size: 13px;
font-weight: 600;
}
.config-val {
flex: 1;
}
.config-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
}
.plugin-card {
margin-bottom: 12px;
}
.plugin-main {
display: flex;
align-items: center;
gap: 14px;
}
.plugin-info {
flex: 1;
min-width: 0;
}
.plugin-title {
display: flex;
align-items: center;
gap: 8px;
}
.plugin-name {
font-size: 15px;
font-weight: 700;
}
.plugin-version {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.plugin-desc {
font-size: 13px;
color: var(--el-text-color-secondary);
margin-top: 4px;
}
.plugin-meta {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 4px;
}
.plugin-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.dialog-body-scroll {
max-height: 60vh;
overflow-y: auto;
overscroll-behavior: contain;
}
.security-wrap {
max-width: 680px;
}
.login-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.login-card {
width: min(92vw, 430px);
}
.login-title {
font-size: 21px;
font-weight: 700;
color: #0d335f;
letter-spacing: 0.4px;
}
.login-subtitle {
margin-top: 6px;
font-size: 13px;
color: #6b87ab;
}
.el-card {
border-radius: 18px;
border: 1px solid var(--wb-border);
background: var(--wb-card);
box-shadow: var(--wb-shadow-md);
}
.el-card__header {
border-bottom: 1px solid var(--el-border-color-light);
}
.el-dialog {
border-radius: 20px;
overflow: hidden;
}
.el-dialog__header {
margin-right: 0;
padding: 16px 18px;
border-bottom: 1px solid var(--el-border-color-light);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(249, 252, 255, 0.86));
}
.el-dialog__body {
padding: 16px 18px;
}
.el-dialog__footer {
padding: 10px 18px 16px;
}
.el-input__wrapper,
.el-textarea__inner,
.el-input-number__decrease,
.el-input-number__increase,
.el-input-number .el-input__wrapper {
border-radius: 12px !important;
}
.el-input__wrapper,
.el-textarea__inner,
.el-input-number .el-input__wrapper {
box-shadow: 0 0 0 1px var(--wb-border-strong) inset !important;
background: #f8fbff;
}
.el-input__wrapper.is-focus,
.el-textarea__inner:focus,
.el-input-number .el-input__wrapper.is-focus {
box-shadow: 0 0 0 2px rgba(0, 119, 255, 0.22) inset !important;
}
.el-button {
border-radius: 12px;
font-weight: 600;
}
.el-button--primary {
border: none;
color: #ffffff;
background: linear-gradient(135deg, var(--wb-primary) 0%, var(--wb-primary-alt) 100%);
box-shadow: 0 10px 22px rgba(0, 123, 255, 0.26);
}
.el-button--primary:hover {
color: #ffffff;
filter: brightness(1.04);
}
.el-tag {
border-radius: 999px;
}
.el-alert {
border-radius: 14px;
}
@media (max-width: 980px) {
.app-shell {
padding: 10px;
gap: 8px;
}
.app-aside {
width: 86px !important;
min-width: 86px !important;
border-radius: 16px;
}
.brand-panel {
padding: 14px 8px;
}
.brand-title {
font-size: 13px;
text-align: center;
}
.brand-sub {
display: none;
}
.app-menu {
padding: 8px 6px 10px;
}
.app-menu .el-menu-item {
justify-content: center;
padding: 0 !important;
}
.app-menu .el-menu-item .el-icon {
margin-right: 0;
}
.app-menu .el-menu-item span {
display: none;
}
.app-topbar {
height: 52px;
padding: 0 12px;
}
.topbar-title {
display: none;
}
.log-search {
width: 180px;
}
.config-key {
width: 132px;
}
}
@media (max-width: 720px) {
.log-toolbar {
flex-wrap: wrap;
justify-content: flex-start;
}
.log-search {
width: 100%;
}
.log-status {
margin-left: 0;
}
.plugin-main {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.plugin-actions {
width: 100%;
justify-content: flex-end;
}
.config-row {
align-items: flex-start;
flex-direction: column;
gap: 8px;
}
.config-key {
width: 100%;
}
}