327 lines
13 KiB
Python
327 lines
13 KiB
Python
"""渠道管理器:统一管理多聊天渠道的生命周期与消息路由。
|
||
|
||
本模块处在“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())
|