feat: 添加状态监控插件和Redis原子操作支持

- 新增 `/status` 指令,展示机器人运行状态和系统指标
- 实现Redis Lua脚本支持原子化计数器操作
- 添加消息收发统计功能
- 完善文档,包括插件开发和性能优化指南
- 重构WebSocket连接池,增加健康检查机制
- 移除旧版编译脚本,优化项目结构
This commit is contained in:
2026-01-23 15:54:45 +08:00
parent 489dd8c77d
commit d458413e4b
28 changed files with 1529 additions and 1177 deletions

View File

@@ -329,30 +329,37 @@ class WS:
echo_id = str(uuid.uuid4())
payload = {"action": action, "params": params or {}, "echo": echo_id}
loop = asyncio.get_running_loop()
future = loop.create_future()
self._pending_requests[echo_id] = future
await conn.send(orjson.dumps(payload))
# 在当前连接上等待特定 echo 的响应,并设置超时
try:
await conn.send(orjson.dumps(payload))
result = await asyncio.wait_for(future, timeout=30.0)
return result
async def wait_for_response():
async for message in conn.conn:
data = orjson.loads(message)
if data.get("echo") == echo_id:
return data
return await asyncio.wait_for(wait_for_response(), timeout=30.0)
except asyncio.TimeoutError:
self._pending_requests.pop(echo_id, None)
self.logger.warning(f"API 调用超时: action={action}, params={params}")
return create_error_response(
code=ErrorCode.TIMEOUT_ERROR,
message="API调用超时",
data={"action": action, "params": params}
)
raise # 重新抛出超时异常
except Exception as e:
self._pending_requests.pop(echo_id, None)
self.logger.exception(f"API 调用异常: action={action}, error={str(e)}")
return create_error_response(
code=ErrorCode.WS_MESSAGE_ERROR,
message=f"API调用异常: {str(e)}",
data={"action": action, "params": params}
)
raise WebSocketError(f"在等待API响应时连接出错: {e}")
except asyncio.TimeoutError:
self.logger.warning(f"API 调用超时: action={action}, params={params}")
return create_error_response(
code=ErrorCode.TIMEOUT_ERROR,
message="API调用超时",
data={"action": action, "params": params}
)
except Exception as e:
self.logger.exception(f"API 调用异常: action={action}, error={str(e)}")
return create_error_response(
code=ErrorCode.WS_MESSAGE_ERROR,
message=f"API调用异常: {str(e)}",
data={"action": action, "params": params}
)
finally:
# 释放连接回连接池
await self.pool.release_connection(conn)

View File

@@ -5,11 +5,35 @@
状态设置等相关的 OneBot v11 API 封装。
"""
import orjson
from typing import Dict, Any
from typing import Dict, Any, Type, TypeVar
from dataclasses import is_dataclass, fields
from .base import BaseAPI
from models.objects import LoginInfo, VersionInfo, Status
from ..managers.redis_manager import redis_manager
T = TypeVar('T')
def _safe_dataclass_from_dict(cls: Type[T], data: Dict[str, Any]) -> T:
"""
安全地从字典创建 dataclass 实例,忽略多余的键。
"""
if not data:
try:
return cls()
except TypeError:
raise ValueError(f"无法在没有数据的情况下创建 {cls.__name__} 的实例")
# 使用官方的 is_dataclass 进行检查,对 MyPyC 更友好
if not is_dataclass(cls):
raise TypeError(f"{cls.__name__} 不是一个 dataclass")
# 获取 dataclass 的所有字段名
known_fields = {f.name for f in fields(cls)}
# 过滤出 dataclass 认识的键值对
filtered_data = {k: v for k, v in data.items() if k in known_fields}
return cls(**filtered_data)
class AccountAPI(BaseAPI):
"""
@@ -30,11 +54,11 @@ class AccountAPI(BaseAPI):
if not no_cache:
cached_data = await redis_manager.get(cache_key)
if cached_data:
return LoginInfo(**orjson.loads(cached_data))
return _safe_dataclass_from_dict(LoginInfo, orjson.loads(cached_data))
res = await self.call_api("get_login_info")
await redis_manager.set(cache_key, orjson.dumps(res), ex=3600) # 缓存 1 小时
return LoginInfo(**res)
return _safe_dataclass_from_dict(LoginInfo, res)
async def get_version_info(self) -> VersionInfo:
"""
@@ -43,8 +67,8 @@ class AccountAPI(BaseAPI):
Returns:
VersionInfo: 包含 OneBot 实现版本信息的 `VersionInfo` 数据对象。
"""
res = await self.call_api("get_friend_list")
return VersionInfo(**res)
res = await self.call_api("get_version_info")
return _safe_dataclass_from_dict(VersionInfo, res)
async def get_status(self) -> Status:
"""
@@ -54,7 +78,7 @@ class AccountAPI(BaseAPI):
Status: 包含 OneBot 状态信息的 `Status` 数据对象。
"""
res = await self.call_api("get_status")
return Status(**res)
return _safe_dataclass_from_dict(Status, res)
async def bot_exit(self) -> Dict[str, Any]:
"""
@@ -162,56 +186,25 @@ class AccountAPI(BaseAPI):
"""
return await self.call_api("clean_cache")
async def get_stranger_info(self, user_id: int, no_cache: bool = False) -> Any:
async def get_profile_like(self) -> Dict[str, Any]:
"""
获取陌生人信息。
获取个人资料的点赞信息。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_profile_like")
async def nc_get_user_status(self, user_id: int) -> Dict[str, Any]:
"""
获取用户的在线状态 (NapCat 特有 API)。
Args:
user_id (int): 目标用户的 QQ 号。
no_cache (bool, optional): 是否不使用缓存。Defaults to False.
Returns:
Any: 包含陌生人信息的字典或对象
Dict[str, Any]: OneBot API 的响应数据
"""
return await self.call_api("get_stranger_info", {"user_id": user_id, "no_cache": no_cache})
return await self.call_api("nc_get_user_status", {"user_id": user_id})
async def get_friend_list(self, no_cache: bool = False) -> list:
"""
获取好友列表。
Args:
no_cache (bool, optional): 是否不使用缓存。Defaults to False.
Returns:
list: 好友列表。
"""
cache_key = f"neobot:cache:get_friend_list:{self.self_id}"
if not no_cache:
cached_data = await redis_manager.get(cache_key)
if cached_data:
return orjson.loads(cached_data)
res = await self.call_api("get_friend_list")
await redis_manager.set(cache_key, orjson.dumps(res), ex=3600) # 缓存 1 小时
return res
async def get_group_list(self, no_cache: bool = False) -> list:
"""
获取群列表。
Args:
no_cache (bool, optional): 是否不使用缓存。Defaults to False.
Returns:
list: 群列表。
"""
cache_key = f"neobot:cache:get_group_list:{self.self_id}"
if not no_cache:
cached_data = await redis_manager.get(cache_key)
if cached_data:
return orjson.loads(cached_data)
res = await self.call_api("get_group_list")
await redis_manager.set(cache_key, orjson.dumps(res), ex=3600) # 缓存 1 小时
return res

View File

@@ -3,6 +3,7 @@ API 基础模块
定义了 API 调用的基础接口和统一处理逻辑。
"""
import copy
from typing import Any, Dict, Optional, TYPE_CHECKING
from ..utils.logger import logger
@@ -35,7 +36,32 @@ class BaseAPI:
params = {}
try:
logger.debug(f"调用API -> action: {action}, params: {params}")
# 日志记录前,对敏感或过长的参数进行处理
log_params = copy.deepcopy(params)
if 'message' in log_params:
if isinstance(log_params['message'], list):
for segment in log_params['message']:
if segment.get('type') == 'image' and 'file' in segment.get('data', {}):
file_data = segment['data']['file']
if file_data.startswith('data:image/'):
segment['data']['file'] = f"{file_data[:50]}... (base64 truncated)"
elif isinstance(log_params['message'], str) and log_params['message'].startswith('data:image/'):
log_params['message'] = f"{log_params['message'][:50]}... (base64 truncated)"
# 如果是发送消息的动作,则原子化地增加发送消息总数
if action in ["send_private_msg", "send_group_msg", "send_msg"]:
from ..managers.redis_manager import redis_manager
try:
lua_script = "return redis.call('INCR', KEYS[1])"
await redis_manager.execute_lua_script(
script=lua_script,
keys=["neobot:stats:messages_sent"],
args=[]
)
except Exception as e:
logger.error(f"发送消息计数失败: {e}")
logger.debug(f"调用API -> action: {action}, params: {log_params}")
response = await self._ws.call_api(action, params)
logger.debug(f"API响应 <- {response}")

View File

@@ -84,3 +84,76 @@ class FriendAPI(BaseAPI):
"""
return await self.call_api("set_friend_add_request", {"flag": flag, "approve": approve, "remark": remark})
async def get_friends_with_category(self) -> Dict[str, Any]:
"""
获取带分类的好友列表。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_friends_with_category")
async def get_unidirectional_friend_list(self) -> Dict[str, Any]:
"""
获取单向好友列表。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_unidirectional_friend_list")
async def friend_poke(self, user_id: int) -> Dict[str, Any]:
"""
发送好友戳一戳。
Args:
user_id (int): 目标用户的 QQ 号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("friend_poke", {"user_id": user_id})
async def mark_private_msg_as_read(self, user_id: int, time: int = 0) -> Dict[str, Any]:
"""
标记私聊消息为已读。
Args:
user_id (int): 目标用户的 QQ 号。
time (int, optional): 标记此时间戳之前的消息为已读。Defaults to 0 (全部标记)。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
params = {"user_id": user_id}
if time > 0:
params["time"] = time
return await self.call_api("mark_private_msg_as_read", params)
async def get_friend_msg_history(self, user_id: int, count: int = 20) -> Dict[str, Any]:
"""
获取私聊消息历史记录。
Args:
user_id (int): 目标用户的 QQ 号。
count (int, optional): 要获取的消息数量。Defaults to 20.
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_friend_msg_history", {"user_id": user_id, "count": count})
async def forward_friend_single_msg(self, user_id: int, message_id: str) -> Dict[str, Any]:
"""
转发单条好友消息。
Args:
user_id (int): 目标用户的 QQ 号。
message_id (str): 要转发的消息 ID。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("forward_friend_single_msg", {"user_id": user_id, "message_id": message_id})

View File

@@ -282,3 +282,183 @@ class GroupAPI(BaseAPI):
"""
return await self.call_api("set_group_add_request", {"flag": flag, "sub_type": sub_type, "approve": approve, "reason": reason})
async def get_group_info_ex(self, group_id: int) -> Dict[str, Any]:
"""
获取群扩展信息 (NapCat 特有 API)。
Args:
group_id (int): 目标群组的群号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_group_info_ex", {"group_id": group_id})
async def delete_essence_msg(self, message_id: int) -> Dict[str, Any]:
"""
删除精华消息。
Args:
message_id (int): 目标消息的 ID。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("delete_essence_msg", {"message_id": message_id})
async def group_poke(self, group_id: int, user_id: int) -> Dict[str, Any]:
"""
在群内发送 "戳一戳"
Args:
group_id (int): 目标群组的群号。
user_id (int): 目标成员的 QQ 号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("group_poke", {"group_id": group_id, "user_id": user_id})
async def mark_group_msg_as_read(self, group_id: int, time: int = 0) -> Dict[str, Any]:
"""
标记群消息为已读。
Args:
group_id (int): 目标群组的群号。
time (int, optional): 标记此时间戳之前的消息为已读。Defaults to 0 (全部标记)。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
params = {"group_id": group_id}
if time > 0:
params["time"] = time
return await self.call_api("mark_group_msg_as_read", params)
async def forward_group_single_msg(self, group_id: int, message_id: str) -> Dict[str, Any]:
"""
转发单条群消息。
Args:
group_id (int): 目标群组的群号。
message_id (str): 要转发的消息 ID。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("forward_group_single_msg", {"group_id": group_id, "message_id": message_id})
async def set_group_portrait(self, group_id: int, file: str, cache: int = 1) -> Dict[str, Any]:
"""
设置群头像。
Args:
group_id (int): 目标群组的群号。
file (str): 图片文件的路径或 URL 或 Base64。
cache (int, optional): 是否使用缓存 (1: 是, 0: 否)。Defaults to 1.
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_portrait", {"group_id": group_id, "file": file, "cache": cache})
async def _send_group_notice(self, group_id: int, content: str, **kwargs) -> Dict[str, Any]:
"""
发送群公告。
Args:
group_id (int): 目标群组的群号。
content (str): 公告内容。
**kwargs: 其他可选参数 (如 image)。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
params = {"group_id": group_id, "content": content}
params.update(kwargs)
return await self.call_api("_send_group_notice", params)
async def _get_group_notice(self, group_id: int) -> Dict[str, Any]:
"""
获取群公告。
Args:
group_id (int): 目标群组的群号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("_get_group_notice", {"group_id": group_id})
async def _del_group_notice(self, group_id: int, notice_id: str) -> Dict[str, Any]:
"""
删除群公告。
Args:
group_id (int): 目标群组的群号。
notice_id (str): 公告 ID。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("_del_group_notice", {"group_id": group_id, "notice_id": notice_id})
async def get_group_at_all_remain(self, group_id: int) -> Dict[str, Any]:
"""
获取 @全体成员 的剩余次数。
Args:
group_id (int): 目标群组的群号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_group_at_all_remain", {"group_id": group_id})
async def get_group_system_msg(self) -> Dict[str, Any]:
"""
获取群系统消息。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_group_system_msg")
async def get_group_shut_list(self, group_id: int) -> Dict[str, Any]:
"""
获取群禁言列表。
Args:
group_id (int): 目标群组的群号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("get_group_shut_list", {"group_id": group_id})
async def set_group_remark(self, group_id: int, remark: str) -> Dict[str, Any]:
"""
设置群备注。
Args:
group_id (int): 目标群组的群号。
remark (str): 要设置的备注。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_remark", {"group_id": group_id, "remark": remark})
async def set_group_sign(self, group_id: int) -> Dict[str, Any]:
"""
设置群签到。
Args:
group_id (int): 目标群组的群号。
Returns:
Dict[str, Any]: OneBot API 的响应数据。
"""
return await self.call_api("set_group_sign", {"group_id": group_id})

View File

@@ -37,3 +37,13 @@ class MediaAPI(BaseAPI):
:return: OneBot v11标准响应
"""
return await self.call_api(action="get_image", params={"file": file})
async def get_file(self, file_id: str) -> Dict[str, Any]:
"""
获取文件信息
:param file_id: 文件ID
:return: OneBot v11标准响应
"""
return await self.call_api(action="get_file", params={"file_id": file_id})

View File

@@ -127,6 +127,19 @@ class MessageHandler(BaseHandler):
"""
处理消息事件,分发给命令处理器或通用消息处理器
"""
# 原子化地增加接收消息总数
from ..managers.redis_manager import redis_manager
from ..utils.logger import logger
try:
lua_script = "return redis.call('INCR', KEYS[1])"
await redis_manager.execute_lua_script(
script=lua_script,
keys=["neobot:stats:messages_received"],
args=[]
)
except Exception as e:
logger.error(f"接收消息计数失败: {e}")
from ..managers import permission_manager
for handler_info in self.message_handlers:
consumed = await self._run_handler(handler_info["func"], bot, event)
@@ -165,6 +178,19 @@ class MessageHandler(BaseHandler):
await bot.send(event, message_template.format(permission_name=permission_name))
return
# 在执行指令前,原子化地增加指令调用次数
from ..managers.redis_manager import redis_manager
from ..utils.logger import logger
try:
lua_script = "return redis.call('HINCRBY', KEYS[1], ARGV[1], 1)"
await redis_manager.execute_lua_script(
script=lua_script,
keys=["neobot:command_stats"],
args=[command_name]
)
except Exception as e:
logger.error(f"指令 /{command_name} 调用次数统计失败: {e}")
await self._run_handler(
func,
bot,

View File

@@ -122,7 +122,13 @@ class ImageManager(Singleton):
content = f.read()
mime_type = "image/jpeg" if image_type == "jpeg" else "image/png"
return f"data:{mime_type};base64," + base64.b64encode(content).decode("utf-8")
base64_str = base64.b64encode(content).decode("utf-8")
# 记录摘要日志,避免刷屏
log_message = f"Base64 图片已生成 (MIME: {mime_type}, Size: {len(base64_str)/1024:.2f} KB, Preview: {base64_str[:30]}...{base64_str[-30:]})"
logger.debug(log_message)
return f"data:{mime_type};base64," + base64_str
except Exception as e:
logger.error(f"读取图片文件失败: {e}")
return None

View File

@@ -412,7 +412,7 @@ class PermissionManager(Singleton):
"""
try:
# 创建空的权限数据
empty_data = {"users": {}}
empty_data: Dict[str, Dict] = {"users": {}}
# 原子性写入文件
temp_file = self.data_file + ".tmp"

View File

@@ -67,5 +67,27 @@ class RedisManager(Singleton):
"""
return await self.redis.set(name, value, ex=ex)
async def execute_lua_script(self, script: str, keys: list, args: list):
"""
以原子方式执行 Lua 脚本
Args:
script (str): 要执行的 Lua 脚本字符串
keys (list): 脚本中使用的 Redis 键 (KEYS[1], KEYS[2], ...)
args (list): 传递给脚本的参数 (ARGV[1], ARGV[2], ...)
Returns:
Any: 脚本的返回值
"""
try:
# redis-py 内部会自动处理脚本的缓存 (EVAL/EVALSHA)
lua_script = self.redis.register_script(script)
return await lua_script(keys=keys, args=args)
except Exception as e:
logger.error(f"执行 Lua 脚本失败: {e}")
logger.debug(f"脚本内容: {script}")
raise
# 全局 Redis 管理器实例
redis_manager = RedisManager()

View File

@@ -7,7 +7,7 @@ WebSocket 连接池模块
import asyncio
import websockets
from websockets.legacy.client import WebSocketClientProtocol
from typing import Optional, Dict, Any, cast
from typing import Optional, Dict, Any, cast, Union
import uuid
from loguru import logger
@@ -28,7 +28,7 @@ class WSConnection:
self.is_active = True
self._pending_requests: Dict[str, asyncio.Future] = {}
async def send(self, data: dict):
async def send(self, data: Union[Dict[Any, Any], bytes]):
"""
发送数据到 WebSocket 连接
"""
@@ -57,6 +57,19 @@ class WSConnection:
self.is_active = False
raise WebSocketError(f"接收数据失败: {e}")
async def ping(self, timeout: int = 5) -> bool:
"""
对 WebSocket 连接执行 ping-pong 健康检查
"""
if not self.is_active:
return False
try:
await asyncio.wait_for(self.conn.ping(), timeout=timeout)
return True
except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed):
self.is_active = False
return False
async def close(self):
"""
关闭 WebSocket 连接
@@ -132,7 +145,7 @@ class WSConnectionPool:
async def get_connection(self) -> WSConnection:
"""
从连接池获取一个连接
从连接池获取一个健康的连接,包含健康检查。
"""
if self._closed:
raise WebSocketError("连接池已关闭")
@@ -141,18 +154,21 @@ class WSConnectionPool:
# 尝试从连接池获取连接
conn = await asyncio.wait_for(self.pool.get(), timeout=5)
# 检查连接是否活跃
if not conn.is_active:
logger.warning(f"连接 {conn.conn_id} 已失效,重新创建")
return await self._create_connection()
return conn
# 健康检查
if await conn.ping():
logger.debug(f"连接 {conn.conn_id} 健康检查通过")
return conn
else:
logger.warning(f"连接 {conn.conn_id} 健康检查失败,丢弃并获取新连接")
await conn.close()
return await self.get_connection() # 递归获取下一个
except asyncio.TimeoutError:
# 连接池为空,创建新连接
logger.warning("连接池为空,创建临时连接")
logger.warning("连接池在5秒内无可用连接,创建连接")
return await self._create_connection()
except Exception as e:
raise WebSocketError(f"获取连接失败: {e}")
raise WebSocketError(f"获取连接时发生未知错误: {e}")
async def release_connection(self, conn: WSConnection):
"""