ForcePilot/backend/package/yuxi/channels/manager.py
Kris 1f78c44b03 refactor: 整理并清理项目中的冗余代码与格式问题
这是一个批量整理提交,包含以下主要改动:
1.  删除多处冗余的空行和未使用的导入
2.  修复文件末尾缺少换行符的问题
3.  调整部分模块的导入顺序与代码排版
4.  修复部分配置默认值与策略逻辑
5.  新增多个功能模块与辅助工具
6.  完善异常处理与日志记录
7.  修复速率限制、消息缓存、权限校验等逻辑bug
8.  废弃部分旧有API与配置项并添加警告提示
2026-05-12 14:51:53 +08:00

682 lines
26 KiB
Python

from __future__ import annotations
import asyncio
import time
from collections import defaultdict, deque
from typing import Any
from sqlalchemy import func, select
from yuxi.channels.base import BaseChannelAdapter
from yuxi.channels.infra.broadcast import EventBroadcaster
from yuxi.channels.infra.circuit_breaker import CircuitBreaker
from yuxi.channels.infra.config_watcher import ConfigWatcher
from yuxi.channels.services.context import GatewayRequestContext
from yuxi.channels.services.doctor import ConfigDoctor, DiagnosisIssue
from yuxi.channels.exceptions import ChannelException
from yuxi.channels.services.maintenance import MaintenanceRunner
from yuxi.channels.models import ChannelStatus
from yuxi.channels.registry import ChannelRegistry, _BUILTIN_ADAPTERS, _load_builtin_adapters
from yuxi.channels.router import MessageRouter
from yuxi.channels.services.runtime_state import RuntimeState
from yuxi.channels.services.stats_collector import StatsCollector
from yuxi.channels.services.webhook_registry import WebhookRegistry
from yuxi.channels.services.ws_logger import WsLogger
from yuxi.storage.postgres.manager import pg_manager
from yuxi.storage.postgres.models_channels import ChannelMsgRecord
from yuxi.utils.logging_config import logger
HEALTH_CHECK_INTERVAL = 60
CONFIG_WATCH_INTERVAL = 30.0
class ChannelManager:
def __init__(self, registry: ChannelRegistry | None = None, router: MessageRouter | None = None):
self._registry = registry or ChannelRegistry()
self._router = router or MessageRouter(channel_manager=self)
self._adapters: dict[str, BaseChannelAdapter] = {}
self._circuit_breakers: dict[str, CircuitBreaker] = defaultdict(lambda: CircuitBreaker())
self._health_tasks: dict[str, asyncio.Task] = {}
self._initialized = False
self._phase: str = "not_started"
self._rate_limiters: dict[str, deque[float]] = {}
self._rate_limit_locks: dict[str, asyncio.Lock] = {}
self._channels_config: dict[str, dict[str, Any]] = {}
self._watcher: ConfigWatcher | None = None
self._doctor: ConfigDoctor | None = None
self._broadcaster: EventBroadcaster | None = None
self._ctx: GatewayRequestContext | None = None
self.runtime_state = RuntimeState()
self._auth_limiter: Any = None
self._ws_handlers: dict[str, Any] = {}
self._scheduled_tasks: list[asyncio.Task] = []
self._ws_logger: WsLogger | None = None
self._maintenance_runner: MaintenanceRunner | None = None
self._stats_collector: StatsCollector | None = None
self._webhook_registry: WebhookRegistry | None = None
@property
def phase(self) -> str:
return self._phase
@property
def context(self) -> GatewayRequestContext | None:
return self._ctx
@property
def broadcaster(self) -> EventBroadcaster | None:
return self._broadcaster
@property
def watcher(self) -> ConfigWatcher | None:
return self._watcher
@property
def doctor(self) -> ConfigDoctor | None:
return self._doctor
async def load_config(self) -> None:
if self._phase not in ("not_started",):
return
_load_builtin_adapters()
for channel_id, adapter_cls in _BUILTIN_ADAPTERS.items():
self._registry.register(channel_id, adapter_cls)
from yuxi import config as conf
self._channels_config = getattr(conf, "channels", {})
self._phase = "config_loaded"
logger.info("ChannelManager: config loaded")
async def prepare_bootstrap(self) -> None:
if self._phase not in ("config_loaded",):
return
async with pg_manager.get_async_session_context() as db:
await self._ensure_virtual_department(db)
await self._ensure_default_agent_config(db)
self._phase = "bootstrapped"
logger.info("ChannelManager: bootstrap prepared")
async def start_channels(self) -> None:
if self._phase not in ("config_loaded", "bootstrapped"):
return
for channel_id in self._registry.list_channels():
channel_conf = self._channels_config.get(channel_id, {})
if channel_conf.get("enabled", False):
try:
await self.start_channel(channel_id, channel_conf)
except Exception:
logger.exception(f"Failed to start channel {channel_id}")
self._phase = "channels_started"
logger.info(f"ChannelManager: channels started: {list(self._adapters.keys())}")
async def start_subscriptions(self) -> None:
if self._phase not in ("channels_started",):
return
self._phase = "fully_running"
logger.info("ChannelManager: subscriptions started, fully running")
async def initialize(self) -> None:
if self._initialized:
return
_load_builtin_adapters()
for channel_id, adapter_cls in _BUILTIN_ADAPTERS.items():
self._registry.register(channel_id, adapter_cls)
from yuxi import config as conf
self._channels_config = getattr(conf, "channels", {})
for channel_id in self._registry.list_channels():
channel_conf = self._channels_config.get(channel_id, {})
if channel_conf.get("enabled", False):
try:
await self.start_channel(channel_id, channel_conf)
except Exception:
logger.exception(f"Failed to start channel {channel_id}")
async with pg_manager.get_async_session_context() as db:
await self._ensure_virtual_department(db)
await self._ensure_default_agent_config(db)
self._initialized = True
self._phase = "fully_running"
logger.info(f"ChannelManager initialized with channels: {list(self._adapters.keys())}")
async def shutdown(self) -> None:
for channel_id in list(self._adapters.keys()):
try:
await self.stop_channel(channel_id)
except Exception:
logger.exception(f"Failed to stop channel {channel_id} during shutdown")
for task in self._health_tasks.values():
task.cancel()
for task in self._scheduled_tasks:
task.cancel()
if self._watcher:
await self._watcher.stop()
self._initialized = False
logger.info("ChannelManager shutdown complete")
async def startup(self) -> None:
if self._initialized:
return
try:
await self._stage_load_config()
await self._stage_prepare_bootstrap()
await self._stage_start_early_runtime()
await self._stage_create_auth_limiter()
await self._stage_init_channels()
await self._stage_create_runtime_state()
await self._stage_start_runtime_services()
await self._stage_activate_scheduled_services()
await self._stage_attach_ws_handlers()
await self._stage_start_event_subscriptions()
self._initialized = True
self._phase = "fully_running"
logger.info("ChannelManager: 10-stage startup complete")
except Exception:
logger.exception(f"ChannelManager startup failed at phase: {self._phase}")
raise
async def _stage_load_config(self) -> None:
if self._phase not in ("not_started",):
return
_load_builtin_adapters()
for channel_id, adapter_cls in _BUILTIN_ADAPTERS.items():
self._registry.register(channel_id, adapter_cls)
from yuxi import config as conf
self._channels_config = getattr(conf, "channels", {})
self._phase = "config_loaded"
logger.info("ChannelManager: [1/10] config loaded")
async def _stage_prepare_bootstrap(self) -> None:
if self._phase not in ("config_loaded",):
return
async with pg_manager.get_async_session_context() as db:
await self._ensure_virtual_department(db)
await self._ensure_default_agent_config(db)
self._phase = "bootstrapped"
logger.info("ChannelManager: [2/10] bootstrap prepared")
async def _stage_start_early_runtime(self) -> None:
if self._phase not in ("bootstrapped",):
return
self._broadcaster = EventBroadcaster()
self._ctx = GatewayRequestContext(
runtime_config=self._channels_config,
start_channel=self._ctx_start_channel,
stop_channel=self._ctx_stop_channel,
mark_channel_logged_out=self._ctx_mark_channel_logged_out,
broadcast_fn=self._broadcaster.broadcast,
node_send_to_session_fn=self._broadcaster.node_send_to_session,
)
self._phase = "early_runtime"
logger.info("ChannelManager: [3/10] early runtime started")
async def _stage_create_auth_limiter(self) -> None:
if self._phase not in ("early_runtime",):
return
self._phase = "auth_limiter_created"
logger.info("ChannelManager: [4/10] auth rate limiter created")
async def _stage_init_channels(self) -> None:
if self._phase not in ("early_runtime", "auth_limiter_created"):
return
for channel_id in self._registry.list_channels():
channel_conf = self._channels_config.get(channel_id, {})
if channel_conf.get("enabled", False):
try:
await self.start_channel(channel_id, channel_conf)
except Exception:
logger.exception(f"Failed to start channel {channel_id}")
self._phase = "channels_started"
logger.info(f"ChannelManager: [5/10] channels started: {list(self._adapters.keys())}")
async def _stage_create_runtime_state(self) -> None:
if self._phase not in ("channels_started",):
return
self.runtime_state = RuntimeState(
started_at=time.monotonic(),
active_channels=len(self._adapters),
phase=self._phase,
node_id="forcepilot-gateway",
main_node=True,
services=["doctor", "maintenance", "stats", "webhooks"],
)
self._ws_logger = WsLogger(max_entries=1000)
self._phase = "runtime_state_created"
logger.info("ChannelManager: [6/10] runtime state created")
async def _stage_start_runtime_services(self) -> None:
if self._phase not in ("runtime_state_created",):
return
self._doctor = ConfigDoctor(self)
self._maintenance_runner = MaintenanceRunner(self, self.runtime_state)
self._stats_collector = StatsCollector(self.runtime_state)
self._webhook_registry = WebhookRegistry(self)
self._phase = "runtime_services_started"
logger.info("ChannelManager: [7/10] runtime services started (doctor+maintenance+stats+webhooks)")
async def _stage_activate_scheduled_services(self) -> None:
if self._phase not in ("runtime_services_started",):
return
self._watcher = ConfigWatcher(self)
await self._watcher.watch(interval=CONFIG_WATCH_INTERVAL)
self._scheduled_tasks.append(asyncio.create_task(self._maintenance_runner.run()))
self._scheduled_tasks.append(asyncio.create_task(self._stats_collector.run()))
self._scheduled_tasks.append(asyncio.create_task(self._webhook_registry.run()))
self._phase = "scheduled_services_active"
logger.info("ChannelManager: [8/10] scheduled services activated (watcher+maintenance+stats+webhooks)")
async def _stage_attach_ws_handlers(self) -> None:
if self._phase not in ("scheduled_services_active",):
return
if self._broadcaster:
self._broadcaster.subscribe_callback("channel.status_change", self._on_channel_status_change)
self._broadcaster.subscribe_callback("tick", self._on_tick)
self._broadcaster.subscribe_callback("chat", self._on_chat_event)
self._broadcaster.subscribe_callback("channel.logout", self._on_channel_logout)
self._broadcaster.subscribe_callback("config.reload", self._on_config_reload)
self._phase = "ws_handlers_attached"
logger.info("ChannelManager: [9/10] websocket handlers attached")
async def _stage_start_event_subscriptions(self) -> None:
if self._phase not in ("ws_handlers_attached",):
return
self.runtime_state.services = ["doctor", "maintenance", "stats", "webhooks", "watcher", "ws"]
self._phase = "subscriptions_started"
logger.info("ChannelManager: [10/10] event subscriptions started")
async def _on_channel_status_change(self, event: str, payload: Any) -> None:
logger.debug(f"WS event: {event}")
async def _on_tick(self, event: str, payload: Any) -> None:
pass
async def _on_chat_event(self, event: str, payload: Any) -> None:
logger.debug(f"WS chat event: {event}")
async def _on_channel_logout(self, event: str, payload: Any) -> None:
channel_id = payload.get("channel_id") if isinstance(payload, dict) else None
account_id = payload.get("account_id") if isinstance(payload, dict) else None
logger.info(f"Channel logout: {channel_id}/{account_id}")
async def _on_config_reload(self, event: str, payload: Any) -> None:
await self.reload_config_now()
async def _ctx_start_channel(self, channel_id: str, config: dict) -> None:
await self.start_channel(channel_id, config)
async def _ctx_stop_channel(self, channel_id: str) -> None:
await self.stop_channel(channel_id)
async def _ctx_mark_channel_logged_out(self, channel_id: str, account_id: str) -> None:
logger.info(f"Channel {channel_id} account {account_id} marked as logged out")
async def diagnose_channels(self) -> list[DiagnosisIssue]:
if self._doctor is None:
return []
return await self._doctor.diagnose()
async def auto_fix_channel(self, issue: DiagnosisIssue) -> bool:
if self._doctor is None:
return False
return await self._doctor.auto_fix(issue)
async def reload_config_now(self, changed_keys: list[str] | None = None) -> dict[str, Any]:
if self._watcher is None:
return {}
return await self._watcher.reload_now(changed_keys)
async def start_channel(self, channel_id: str, config: dict[str, Any] | None = None) -> None:
if channel_id in self._adapters:
logger.warning(f"Channel {channel_id} already running")
return
adapter_cls = self._registry.get(channel_id)
if not adapter_cls:
raise ValueError(f"No adapter registered for channel {channel_id}")
config = config or self._channels_config.get(channel_id, {})
adapter = adapter_cls(config=config)
adapter.on_message(self._handle_inbound_message)
pre_connect_result = await adapter.pre_connect()
if pre_connect_result:
qr_url = pre_connect_result.get("qr_url")
if qr_url:
logger.info(f"Channel {channel_id} requires QR scan: {qr_url}")
await adapter.connect()
self._adapters[channel_id] = adapter
if channel_id not in self._health_tasks:
self._health_tasks[channel_id] = asyncio.create_task(self._health_check_loop(channel_id))
logger.info(f"Channel {channel_id} started")
async def stop_channel(self, channel_id: str) -> None:
adapter = self._adapters.pop(channel_id, None)
if not adapter:
return
task = self._health_tasks.pop(channel_id, None)
if task:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
try:
await adapter.disconnect()
except Exception:
logger.exception(f"Error disconnecting channel {channel_id}")
logger.info(f"Channel {channel_id} stopped")
async def restart_channel(self, channel_id: str) -> None:
config = self._channels_config.get(channel_id, {})
await self.stop_channel(channel_id)
await self.start_channel(channel_id, config)
async def send_outbound(self, channel_id: str, response) -> None:
adapter = self._adapters.get(channel_id)
if not adapter:
raise ChannelException(f"Channel {channel_id} not found", retryable=False)
cb = self._circuit_breakers[channel_id]
await cb.call(lambda: adapter.send(response))
async def get_channel_status(self, channel_id: str | None = None) -> dict:
if channel_id:
return await self._get_single_channel_status(channel_id)
all_channels = {}
for cid in self._registry.list_channels():
info = await self._get_single_channel_status(cid)
stats = info.get("stats") or {}
all_channels[cid] = {
"channel_id": cid,
"channel_type": info.get("channel_type"),
"display_name": info.get("display_name"),
"enabled": info.get("enabled", False),
"status": info.get("status", "not_found"),
"total_count": stats.get("total_messages", 0),
"today_count": stats.get("today_messages", 0),
"active_connections": 1 if info.get("status") == ChannelStatus.CONNECTED.value else 0,
"health": info.get("health"),
}
return {"channels": all_channels}
async def update_channel_config(self, channel_id: str, config_updates: dict[str, Any]) -> dict:
adapter = self._adapters.get(channel_id)
if not adapter:
raise ChannelException(f"Channel {channel_id} not found", retryable=False)
if hasattr(adapter, "reload_config"):
await adapter.reload_config(config_updates)
else:
for key, value in config_updates.items():
adapter.config[key] = value
if channel_id not in self._channels_config:
self._channels_config[channel_id] = {}
self._channels_config[channel_id].update(config_updates)
return {"channel_id": channel_id, "config_updated": True}
async def test_channel(self, channel_id: str) -> dict:
adapter = self._adapters.get(channel_id)
if not adapter:
return {"channel_id": channel_id, "test_result": "failure", "error": "Channel not running"}
try:
start = time.monotonic()
health = await adapter.health_check()
latency_ms = (time.monotonic() - start) * 1000
return {
"channel_id": channel_id,
"test_result": "success" if health.status == "healthy" else "degraded",
"latency_ms": round(latency_ms, 1),
"health": health.model_dump(),
}
except Exception as e:
return {"channel_id": channel_id, "test_result": "failure", "error": str(e)}
async def check_rate_limit(self, key: str, max_req: int, window_seconds: int) -> bool:
lock = self._rate_limit_locks.setdefault(key, asyncio.Lock())
async with lock:
now = time.monotonic()
history = self._rate_limiters.setdefault(key, deque(maxlen=max_req))
while history and now - history[0] > window_seconds:
history.popleft()
if len(history) >= max_req:
return False
history.append(now)
return True
async def _handle_inbound_message(self, message) -> None:
try:
await self._router.route_inbound(message)
except Exception:
logger.exception("Error handling inbound message")
async def _health_check_loop(self, channel_id: str) -> None:
while channel_id in self._adapters:
await asyncio.sleep(HEALTH_CHECK_INTERVAL)
adapter = self._adapters.get(channel_id)
if not adapter:
break
cb = self._circuit_breakers.get(channel_id)
try:
health = await adapter.health_check()
if cb and health.status == "healthy":
try:
await cb.record_success()
except Exception:
pass
elif cb:
try:
await cb.record_failure()
except Exception:
pass
except asyncio.CancelledError:
break
except Exception as e:
logger.warning(f"Health check failed for {channel_id}: {e}")
if cb:
try:
await cb.record_failure()
except Exception:
pass
async def _get_single_channel_status(self, channel_id: str) -> dict:
adapter = self._adapters.get(channel_id)
if not adapter:
adapter_cls = self._registry.get(channel_id)
if adapter_cls:
caps = (
adapter_cls.capabilities.model_dump()
if hasattr(adapter_cls, "capabilities")
else {
"text_chunk_limit": adapter_cls.text_chunk_limit,
"supports_markdown": adapter_cls.supports_markdown,
"supports_streaming": adapter_cls.supports_streaming,
"max_media_size_mb": adapter_cls.max_media_size_mb,
}
)
channel_type = adapter_cls.channel_type.value
return {
"channel_id": channel_id,
"channel_type": channel_type,
"display_name": None,
"enabled": False,
"status": ChannelStatus.DISABLED.value,
"config": {"enabled": False},
"capabilities": caps,
"health": None,
"stats": None,
}
return {"channel_id": channel_id, "status": "not_found"}
health = None
try:
health_result = await adapter.health_check()
health = health_result.model_dump()
except Exception:
pass
caps = (
type(adapter).capabilities.model_dump()
if hasattr(type(adapter), "capabilities")
else {
"text_chunk_limit": type(adapter).text_chunk_limit,
"supports_markdown": type(adapter).supports_markdown,
"supports_streaming": type(adapter).supports_streaming,
"max_media_size_mb": type(adapter).max_media_size_mb,
}
)
circuit_state = self._circuit_breakers.get(channel_id)
cb_state = circuit_state.state.value if circuit_state else "unknown"
channel_type = adapter.channel_type.value
display_name = adapter.config.get("display_name")
return {
"channel_id": channel_id,
"channel_type": channel_type,
"display_name": display_name,
"enabled": adapter.config.get("enabled", False) if hasattr(adapter, "config") else True,
"status": self._adapter_status(adapter),
"config": adapter.config,
"capabilities": caps,
"health": health,
"circuit_state": cb_state,
"stats": await self._get_channel_stats(channel_id),
}
async def _get_channel_stats(self, channel_id: str) -> dict:
try:
from yuxi.utils.datetime_utils import utc_now_naive
now = utc_now_naive()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
async with pg_manager.get_async_session_context() as session:
result = await session.execute(
select(
func.count().label("total"),
func.sum(
func.cast(
(ChannelMsgRecord.status == "success").cast(func.Integer),
func.Integer,
)
).label("success_count"),
func.sum(
func.cast(
(ChannelMsgRecord.status == "error").cast(func.Integer),
func.Integer,
)
).label("error_count"),
).where(ChannelMsgRecord.channel_id == channel_id)
)
row = result.one()
total = row.total or 0
success = row.success_count or 0
error = row.error_count or 0
today_result = await session.execute(
select(func.count()).where(
ChannelMsgRecord.channel_id == channel_id,
ChannelMsgRecord.created_at >= today_start,
)
)
today = today_result.scalar() or 0
return {
"total_messages": total,
"today_messages": today,
"success_count": int(success),
"error_count": int(error),
"success_rate": round(success / total, 3) if total > 0 else 0,
}
except Exception:
return {
"total_messages": 0,
"today_messages": 0,
"success_count": 0,
"error_count": 0,
"success_rate": 0,
}
def _adapter_status(self, adapter: BaseChannelAdapter) -> str:
return getattr(adapter, "status", "unknown")
async def _ensure_virtual_department(self, db) -> None:
from yuxi.storage.postgres.models_business import Department
result = await db.execute(select(Department).where(Department.id == -1))
if result.scalar_one_or_none() is None:
dept = Department(
id=-1,
name="\u6e20\u9053\u7f51\u5173",
description="\u591a\u6e20\u9053\u7f51\u5173\u865a\u62df\u90e8\u95e8",
)
db.add(dept)
await db.commit()
logger.info("Created virtual department (id=-1)")
async def _ensure_default_agent_config(self, db) -> None:
from yuxi.repositories.agent_config_repository import AgentConfigRepository
repo = AgentConfigRepository(db)
await repo.get_or_create_default(
department_id=-1,
agent_id="ChatbotAgent",
created_by="system",
)
logger.info("Ensured default agent config for ChatbotAgent")
channel_manager = ChannelManager()