Files
beaver_project/app-instance/backend/nanobot/channels/manager.py
2026-03-13 16:40:08 +08:00

327 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""渠道管理器:统一管理多聊天渠道的生命周期与消息路由。
本模块处在“Agent 核心逻辑”和“外部 IM 平台”之间,承担两类关键职责:
1. 渠道生命周期管理:
- 按配置初始化可用渠道Telegram/Slack/Discord/WhatsApp/...
- 统一启动与停止,避免各渠道在 CLI 层分散管理。
2. 出站消息分发:
- 从 MessageBus 的 outbound 队列读取消息;
- 根据 `msg.channel` 路由到目标渠道对象并执行 `send(...)`
- 对进度消息_progress/_tool_hint按全局开关过滤。
设计原则:
- 渠道失败隔离:单个渠道启动/发送失败不应拖垮其它渠道;
- 配置驱动:是否启用由 `config.channels.*.enabled` 决定;
- 统一入口:上层只需与 MessageBus 交互,不关心各渠道细节。
"""
from __future__ import annotations
import asyncio
from typing import Any
from loguru import logger
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.config.schema import Config
class ChannelManager:
"""
渠道协调器。
你可以把它看成一个“渠道运行时容器”:
- `self.channels` 保存已启用渠道实例;
- `_dispatch_outbound()` 作为中央分发协程持续消费 outbound 消息;
- `start_all()/stop_all()` 负责渠道与分发协程的统一启停。
与 AgentLoop 的关系:
- AgentLoop 只负责“生成 OutboundMessage”
- ChannelManager 负责“把 OutboundMessage 真的发出去”。
"""
def __init__(self, config: Config, bus: MessageBus):
# 全局配置(含渠道开关、进度消息开关等)
self.config = config
# 与 AgentLoop 共享同一 MessageBus负责消费 outbound。
self.bus = bus
# name -> channel instance只存启用且成功初始化的渠道
self.channels: dict[str, BaseChannel] = {}
# 出站分发后台任务句柄(由 start_all 创建stop_all 取消)
self._dispatch_task: asyncio.Task | None = None
# 构造时即按配置初始化渠道实例(不启动网络连接,仅实例化)。
self._init_channels()
def _init_channels(self) -> None:
"""按配置初始化渠道实例。
注意:
- 这里只做“实例化”,不会进入各渠道的 start() 主循环;
- ImportError 会被捕获并记录 warning允许缺依赖时降级运行
- 未启用渠道不会创建实例,也不会出现在 enabled_channels 列表里。
"""
# Telegram 渠道:
# - 需要 telegram 配置开启;
# - 额外透传 groq_api_key用于语音/转写等能力时按渠道内部策略使用)。
if self.config.channels.telegram.enabled:
try:
from nanobot.channels.telegram import TelegramChannel
self.channels["telegram"] = TelegramChannel(
self.config.channels.telegram,
self.bus,
groq_api_key=self.config.providers.groq.api_key,
)
logger.info("Telegram channel enabled")
except ImportError as e:
logger.warning("Telegram channel not available: {}", e)
# WhatsApp 渠道(通过 bridge 连接)
if self.config.channels.whatsapp.enabled:
try:
from nanobot.channels.whatsapp import WhatsAppChannel
self.channels["whatsapp"] = WhatsAppChannel(
self.config.channels.whatsapp, self.bus
)
logger.info("WhatsApp channel enabled")
except ImportError as e:
logger.warning("WhatsApp channel not available: {}", e)
# Discord 渠道
if self.config.channels.discord.enabled:
try:
from nanobot.channels.discord import DiscordChannel
self.channels["discord"] = DiscordChannel(
self.config.channels.discord, self.bus
)
logger.info("Discord channel enabled")
except ImportError as e:
logger.warning("Discord channel not available: {}", e)
# 飞书 / Lark 渠道
if self.config.channels.feishu.enabled:
try:
from nanobot.channels.feishu import FeishuChannel
self.channels["feishu"] = FeishuChannel(
self.config.channels.feishu, self.bus
)
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning("Feishu channel not available: {}", e)
# Mochat 渠道
if self.config.channels.mochat.enabled:
try:
from nanobot.channels.mochat import MochatChannel
self.channels["mochat"] = MochatChannel(
self.config.channels.mochat, self.bus
)
logger.info("Mochat channel enabled")
except ImportError as e:
logger.warning("Mochat channel not available: {}", e)
# 钉钉渠道
if self.config.channels.dingtalk.enabled:
try:
from nanobot.channels.dingtalk import DingTalkChannel
self.channels["dingtalk"] = DingTalkChannel(
self.config.channels.dingtalk, self.bus
)
logger.info("DingTalk channel enabled")
except ImportError as e:
logger.warning("DingTalk channel not available: {}", e)
# Email 渠道IMAP 收件 + SMTP 发件)
if self.config.channels.email.enabled:
try:
from nanobot.channels.email import EmailChannel
self.channels["email"] = EmailChannel(
self.config.channels.email, self.bus
)
logger.info("Email channel enabled")
except ImportError as e:
logger.warning("Email channel not available: {}", e)
# Slack 渠道
if self.config.channels.slack.enabled:
try:
from nanobot.channels.slack import SlackChannel
self.channels["slack"] = SlackChannel(
self.config.channels.slack, self.bus
)
logger.info("Slack channel enabled")
except ImportError as e:
logger.warning("Slack channel not available: {}", e)
# QQ 渠道
if self.config.channels.qq.enabled:
try:
from nanobot.channels.qq import QQChannel
self.channels["qq"] = QQChannel(
self.config.channels.qq,
self.bus,
)
logger.info("QQ channel enabled")
except ImportError as e:
logger.warning("QQ channel not available: {}", e)
# Matrix 渠道
if self.config.channels.matrix.enabled:
try:
from nanobot.channels.matrix import MatrixChannel
self.channels["matrix"] = MatrixChannel(
self.config.channels.matrix,
self.bus,
groq_api_key=self.config.providers.groq.api_key,
)
logger.info("Matrix channel enabled")
except ImportError as e:
logger.warning("Matrix channel not available: {}", e)
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""启动单个渠道并隔离异常。
设计意图:
- 不让一个渠道的启动失败影响其它渠道启动;
- 错误统一记录日志,方便后续定位具体渠道问题。
"""
try:
await channel.start()
except Exception as e:
logger.error("Failed to start channel {}: {}", name, e)
async def start_all(self) -> None:
"""启动所有渠道与出站分发协程。
启动顺序:
1. 启动 outbound 分发任务(先就绪,避免启动早期消息丢失);
2. 并发启动所有渠道 start() 协程;
3. `gather` 挂住,直到渠道协程返回(正常应长期运行)。
"""
if not self.channels:
logger.warning("No channels enabled")
return
# 启动出站分发协程:负责消费 bus.outbound 并调用 channel.send()。
self._dispatch_task = asyncio.create_task(self._dispatch_outbound())
# 启动渠道主循环。
tasks = []
for name, channel in self.channels.items():
logger.info("Starting {} channel...", name)
tasks.append(asyncio.create_task(self._start_channel(name, channel)))
# 等待所有渠道任务(理论上它们应常驻直到 stop_all 被调用)。
# return_exceptions=True 可避免一个任务异常导致 gather 整体中断。
await asyncio.gather(*tasks, return_exceptions=True)
async def stop_all(self) -> None:
"""停止所有渠道并关闭出站分发任务。
停止顺序:
1. 先取消分发协程,避免继续从队列取消息;
2. 再逐个 stop 渠道,释放各自连接/资源;
3. 各渠道停止异常仅记录,不影响其它渠道收尾。
"""
logger.info("Stopping all channels...")
# 停止分发协程。
if self._dispatch_task:
self._dispatch_task.cancel()
try:
await self._dispatch_task
except asyncio.CancelledError:
pass
# 停止所有渠道实例。
for name, channel in self.channels.items():
try:
await channel.stop()
logger.info("Stopped {} channel", name)
except Exception as e:
logger.error("Error stopping {}: {}", name, e)
async def _dispatch_outbound(self) -> None:
"""消费 outbound 队列并路由发送到对应渠道。
分发规则:
- `msg.channel` 决定目标渠道实例;
- 若渠道不存在,记录 warning通常表示渠道未启用或名称不匹配
- 进度消息可被全局开关过滤send_progress / send_tool_hints
循环模型:
- 使用 `wait_for(..., timeout=1.0)` 做短超时轮询,
便于 stop_all 取消后快速退出;
- Timeout 属于正常空闲态,不视为错误。
"""
logger.info("Outbound dispatcher started")
while True:
try:
# 从总线获取一条待发送消息;短超时保证可取消性。
msg = await asyncio.wait_for(
self.bus.consume_outbound(),
timeout=1.0
)
# 进度消息过滤:
# - _progress=True 且 _tool_hint=True 受 send_tool_hints 控制
# - _progress=True 且非工具提示受 send_progress 控制
# 这样可以在渠道侧按需静默“中间态”,只保留最终回复。
if msg.metadata.get("_progress"):
if msg.metadata.get("_tool_hint") and not self.config.channels.send_tool_hints:
continue
if not msg.metadata.get("_tool_hint") and not self.config.channels.send_progress:
continue
# 按 channel 名路由发送。
channel = self.channels.get(msg.channel)
if channel:
try:
# 实际发送由各渠道实现统一接口BaseChannel.send
await channel.send(msg)
except Exception as e:
# 单条发送失败不终止分发循环,避免“全局停摆”。
logger.error("Error sending to {}: {}", msg.channel, e)
else:
logger.warning("Unknown channel: {}", msg.channel)
except asyncio.TimeoutError:
# 队列暂时无消息:继续下一轮轮询。
continue
except asyncio.CancelledError:
# stop_all 取消任务时走这里退出循环。
break
def get_channel(self, name: str) -> BaseChannel | None:
"""按名称获取渠道实例(未启用/不存在返回 None"""
return self.channels.get(name)
def get_status(self) -> dict[str, Any]:
"""返回所有已启用渠道的运行状态快照。
返回结构示例:
{
"telegram": {"enabled": True, "running": True},
"slack": {"enabled": True, "running": False},
}
"""
return {
name: {
# 出现在 self.channels 里即表示“配置层已启用且实例化成功”。
"enabled": True,
# running 由渠道实例自身维护,反映连接/主循环当前状态。
"running": channel.is_running
}
for name, channel in self.channels.items()
}
@property
def enabled_channels(self) -> list[str]:
"""返回当前已启用并成功初始化的渠道名称列表。"""
return list(self.channels.keys())