feat(bot): 实现 BotManager 并完善机器人注销逻辑

添加全局 BotManager 单例用于统一管理所有 Bot 实例
在 WS 关闭和 ReverseWSManager 清理时调用注销逻辑
修改广播插件使用 BotManager 获取所有活跃 Bot 实例
移除 echo 插件的权限限制并更新文档配置
This commit is contained in:
2026-03-08 12:25:13 +08:00
parent 789d4e8ac7
commit dec1a43f28
8 changed files with 120 additions and 33 deletions

View File

@@ -0,0 +1,57 @@
from typing import Dict, List, Optional, TYPE_CHECKING
import threading
from ..utils.logger import ModuleLogger
if TYPE_CHECKING:
from ..bot import Bot
class BotManager:
"""
Bot 实例管理器
负责统一管理所有活跃的 Bot 实例(包括正向 WS 和反向 WS 连接的 Bot
提供注册、注销和获取 Bot 实例的方法。
"""
def __init__(self):
self._bots: Dict[str, "Bot"] = {} # type: ignore[assignment] # key: bot_id (str), value: Bot instance
self._lock = threading.RLock()
self.logger = ModuleLogger("BotManager")
def register_bot(self, bot: "Bot") -> None:
"""
注册一个 Bot 实例
"""
if not bot or not bot.self_id:
self.logger.warning("尝试注册无效的 Bot 实例")
return
bot_id = str(bot.self_id)
with self._lock:
self._bots[bot_id] = bot
self.logger.info(f"Bot 实例已注册: {bot_id}")
def unregister_bot(self, bot_id: str) -> None:
"""
注销一个 Bot 实例
"""
with self._lock:
if bot_id in self._bots:
del self._bots[bot_id]
self.logger.info(f"Bot 实例已注销: {bot_id}")
def get_bot(self, bot_id: str) -> Optional["Bot"]:
"""
根据 ID 获取 Bot 实例
"""
with self._lock:
return self._bots.get(str(bot_id))
def get_all_bots(self) -> List["Bot"]:
"""
获取所有活跃的 Bot 实例
"""
with self._lock:
return list(self._bots.values())
# 全局单例实例
bot_manager = BotManager()

View File

@@ -255,6 +255,10 @@ class ReverseWSManager:
del self._client_health[client_id] del self._client_health[client_id]
with self._bots_lock: with self._bots_lock:
if client_id in self.bots: if client_id in self.bots:
# 从 BotManager 注销
from .bot_manager import bot_manager
if self.bots[client_id].self_id:
bot_manager.unregister_bot(str(self.bots[client_id].self_id))
del self.bots[client_id] del self.bots[client_id]
# 清理该客户端的防重复数据 # 清理该客户端的防重复数据

View File

@@ -232,6 +232,11 @@ class WS:
""" """
self.logger.info("正在关闭 WebSocket 客户端...") self.logger.info("正在关闭 WebSocket 客户端...")
# 从 BotManager 注销
if self.bot and self.self_id:
from .managers.bot_manager import bot_manager
bot_manager.unregister_bot(str(self.self_id))
if self.ws: if self.ws:
await self.ws.close() await self.ws.close()

View File

@@ -63,12 +63,32 @@ python setup_mypyc.py build_ext --inplace
# 你的 OneBot 地址 # 你的 OneBot 地址
# 我们用的是正向连接,也就是 Bot 主动去连 OneBot # 我们用的是正向连接,也就是 Bot 主动去连 OneBot
uri = "ws://127.0.0.1:3001" uri = "ws://127.0.0.1:3001"
token = "" token = ""
#当然你也可以配置逆向连接
[reverse_ws]
enabled = true # 是否启用
host = "0.0.0.0" # 监听地址
port = 3002 # 监听端口
token = ""
[redis] [redis]
host = "127.0.0.1" host = "127.0.0.1"
port = 6379 port = 6379
db = 0 db = 0
# MySQL 配置
[mysql]
# MySQL 主机地址
host = "114.66.61.199"
# MySQL 端口
port = 42398
# MySQL 用户名
user = "neobot"
# MySQL 密码
password = "neobot"
# MySQL 数据库名称
db = "neobot"
``` ```
`uri` 改成你自己的 OneBot 地址。 `uri` 改成你自己的 OneBot 地址。
@@ -87,7 +107,4 @@ python -X jit -X gil=0 main.py
如果你看到日志刷出来,最后显示 "连接成功!",恭喜,你成功了! 如果你看到日志刷出来,最后显示 "连接成功!",恭喜,你成功了!
现在,试着给你的机器人发个 `/help`看看会返回什么东西 现在,试着给你的机器人发个 `/help`看看会返回什么东西
**多前端支持**
如果需要同时连接多个 OneBot 实现(如多个 QQ 账号GIL-free 模式可以确保每个连接真正并行处理事件,不会相互阻塞。

16
main.py
View File

@@ -24,21 +24,6 @@ from core.services.local_file_server import start_local_file_server, stop_local_
# 尝试使用高性能事件循环
try:
if sys.platform == 'win32':
# winloop 与 Playwright 存在兼容性问题 (不支持 startupinfo),暂时禁用
# import winloop
# asyncio.set_event_loop_policy(winloop.EventLoopPolicy())
# print("已启用 winloop 高性能事件循环")
print("Windows 平台检测到 Playwright已自动禁用 winloop 以确保兼容性")
else:
import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
print("已启用 uvloop 高性能事件循环")
except ImportError:
print("未检测到高性能事件循环库 (uvloop/winloop),将使用默认事件循环")
# 将项目根目录添加到 sys.path # 将项目根目录添加到 sys.path
ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT_DIR) sys.path.insert(0, ROOT_DIR)
@@ -233,7 +218,6 @@ if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())
except KeyboardInterrupt: except KeyboardInterrupt:
# 捕获 KeyboardInterrupt不做任何操作让 asyncio.run 正常结束 # 捕获 KeyboardInterrupt不做任何操作让 asyncio.run 正常结束
# 这样 main 函数中的 finally 块会被执行
pass pass
except Exception as e: except Exception as e:
main_logger.exception("程序发生未处理的全局异常") main_logger.exception("程序发生未处理的全局异常")

View File

@@ -114,10 +114,21 @@ async def broadcast_subscription_loop():
logger.info(f"[Broadcast] 收到跨机器人广播消息: 来源 {robot_id}") logger.info(f"[Broadcast] 收到跨机器人广播消息: 来源 {robot_id}")
# 获取当前机器人的实例 # 获取所有活跃的 Bot 实例
from core.ws import WS from core.managers.bot_manager import bot_manager
if WS.instance: all_bots = bot_manager.get_all_bots()
await broadcast_message_to_groups(WS.instance, message_data, robot_id)
if not all_bots:
logger.warning("[Broadcast] 没有活跃的 Bot 实例,无法转发广播消息")
continue
# 遍历所有 Bot 进行广播
for bot in all_bots:
# 避免重复广播:如果消息来源就是当前 Bot则跳过
if str(bot.self_id) == str(robot_id):
continue
await broadcast_message_to_groups(bot, message_data, robot_id)
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
logger.error(f"[Broadcast] 解析广播消息失败: {e}") logger.error(f"[Broadcast] 解析广播消息失败: {e}")
@@ -178,16 +189,26 @@ async def handle_broadcast_content(event: MessageEvent):
await event.reply("捕获到的消息为空,已取消广播。") await event.reply("捕获到的消息为空,已取消广播。")
return True return True
# 获取当前机器人ID使用反向WS的机器人ID # 获取当前机器人ID
from core.ws import WS
robot_id = "unknown" robot_id = "unknown"
if WS.instance and hasattr(WS.instance, 'self_id'): if event.bot and hasattr(event.bot, 'self_id'):
robot_id = str(WS.instance.self_id) robot_id = str(event.bot.self_id)
# --- 执行本地广播 --- # --- 执行本地广播 ---
# 1. 先让接收到指令的这个 Bot 进行广播
await broadcast_message_to_groups(event.bot, message_to_broadcast, robot_id) await broadcast_message_to_groups(event.bot, message_to_broadcast, robot_id)
# --- 通过 Redis 发布消息给其他机器人 --- # 2. 获取其他所有 Bot 并进行广播(针对同一进程内的其他 Bot
from core.managers.bot_manager import bot_manager
all_bots = bot_manager.get_all_bots()
for bot in all_bots:
# 跳过已经广播过的 Bot (即当前接收指令的 Bot)
if str(bot.self_id) == robot_id:
continue
await broadcast_message_to_groups(bot, message_to_broadcast, robot_id)
# --- 通过 Redis 发布消息给其他进程的机器人 ---
try: try:
if redis_manager.redis: if redis_manager.redis:
broadcast_data = { broadcast_data = {

View File

@@ -6,7 +6,6 @@ Echo 与交互插件
from core.managers.command_manager import matcher from core.managers.command_manager import matcher
from core.bot import Bot from core.bot import Bot
from models.events.message import MessageEvent from models.events.message import MessageEvent
from core.permission import Permission
__plugin_meta__ = { __plugin_meta__ = {
"name": "echo", "name": "echo",
@@ -14,7 +13,7 @@ __plugin_meta__ = {
"usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞", "usage": "/echo [内容] - 复读内容\n/赞我 - 让机器人给你点赞",
} }
@matcher.command("echo", permission=Permission.ADMIN) @matcher.command("echo")
async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]): async def handle_echo(bot: Bot, event: MessageEvent, args: list[str]):
""" """
处理 echo 指令,原样回复用户输入的内容 处理 echo 指令,原样回复用户输入的内容

View File

@@ -7,7 +7,7 @@ anyio==4.12.1
astroid==4.0.3 astroid==4.0.3
attrs==25.4.0 attrs==25.4.0
beautifulsoup4==4.14.3 beautifulsoup4==4.14.3
bilibili-api-python==2024.12.1 bilibili-api-python
bs4==0.0.2 bs4==0.0.2
cachetools==6.2.4 cachetools==6.2.4
certifi==2026.1.4 certifi==2026.1.4