ForcePilot/backend/package/yuxi/channels/base.py
Kris ede29b1809 refactor(channel): 完成频道模块大重构与功能扩展
本次提交对频道模块进行了全面重构并新增多项核心功能:
1.  优化适配器状态获取逻辑,修复状态返回空值问题
2.  新增4种频道异常类型,完善错误处理体系
3.  大幅精简Mixin类,移除冗余的抽象方法定义
4.  重构适配器注册系统,统一注册入口并新增内置适配器加载方法
5.  扩展插件系统,新增更多元数据配置项支持
6.  新增线程类型、会话范围等模型定义,扩展事件类型枚举
7.  优化用户映射逻辑,使用PostgreSQL upsert避免重复创建
8.  新增历史消息注入模块,支持多格式历史格式化与缓存管理
9.  新增线程能力配置与各平台预置适配配置
10. 新增线程绑定管理器,支持多类型线程绑定生命周期管理
11. 重构__init__.py,整理导出模块与类型
12. 扩展基础适配器类,新增凭证解析、状态存储等核心方法
13. 重写消息路由器,支持按频道加载策略、安全校验与多命令处理
14. 新增/history、/context、/summary等交互命令实现
15. 优化消息记录与统计逻辑,完善路由调度链路
2026-05-13 16:41:11 +08:00

245 lines
8.3 KiB
Python

from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar
from yuxi.channels.capabilities import CAPS_SIMPLE_TEXT, ChannelCapabilities
from yuxi.channels.meta import ChannelMeta
from yuxi.channels.models import (
ChannelMessage,
ChannelResponse,
ChannelType,
DeliveryResult,
HealthStatus,
)
from yuxi.channels.protocols.gateway import ChannelGatewayProtocol
from yuxi.channels.protocols.lifecycle import ChannelLifecycleProtocol
if TYPE_CHECKING:
from yuxi.channels.auth.backoff import ExponentialBackoff
from yuxi.channels.auth.secret_manager import SecretManager
from yuxi.channels.services.plugin_state_store import PluginStateStore
class BaseChannelAdapter(ChannelLifecycleProtocol, ChannelGatewayProtocol, ABC):
channel_id: ClassVar[str]
channel_type: ClassVar[ChannelType]
text_chunk_limit: ClassVar[int] = 4096
supports_markdown: ClassVar[bool] = False
supports_streaming: ClassVar[bool] = False
streaming_modes: ClassVar[list[str]] = ["off"]
max_media_size_mb: ClassVar[int] = 100
webhook_path: ClassVar[str | None] = None
capabilities: ClassVar[ChannelCapabilities] = CAPS_SIMPLE_TEXT
meta: ClassVar[ChannelMeta] = ChannelMeta(id="", label="")
_state_store: PluginStateStore | None = None
def __init__(self, config: dict[str, Any] | None = None):
self.config = config or {}
self._status: Any = None
self._message_handler: Callable[[ChannelMessage], Awaitable[None]] | None = None
self._stream_state: dict[str, int] = {}
self._credential_backoff = self._create_backoff()
@property
def secret_manager(self) -> SecretManager:
from yuxi.channels.auth.secret_manager import get_secret_manager
return get_secret_manager()
@staticmethod
def _create_backoff() -> ExponentialBackoff:
from yuxi.channels.auth.backoff import BackoffConfig, ExponentialBackoff
return ExponentialBackoff(BackoffConfig(base_seconds=5.0, max_seconds=300.0, jitter_pct=0.2))
async def resolve_credential(self, key: str, fallback_keys: list[str] | None = None) -> str | None:
from yuxi.channels.auth.secret_manager import SecretSource
sm = self.secret_manager
sources = [
SecretSource.CONFIG,
SecretSource.ENV,
SecretSource.FILE,
SecretSource.SECRET_REF,
SecretSource.EXEC,
]
resolved = await sm.resolve_secret(key, sources=sources, config=self.config)
if resolved:
return resolved
if fallback_keys:
for fk in fallback_keys:
resolved = await sm.resolve_secret(fk, sources=sources, config=self.config)
if resolved:
return resolved
return None
def _log_config_safely(self) -> None:
from yuxi.channels.auth.secret_manager import SecretManager
from yuxi.utils.logging_config import logger
safe_config = SecretManager.redact_config(self.config)
logger.debug(f"[{self.channel_id}] Config (redacted): {safe_config}")
async def state_get(self, key: str, namespace: str = "default") -> Any | None:
if self._state_store is None:
return None
return await self._state_store.get(self.channel_id, key, namespace)
async def state_set(
self,
key: str,
value: Any,
namespace: str = "default",
ttl_seconds: int | None = None,
) -> None:
if self._state_store is None:
return
await self._state_store.set(self.channel_id, key, value, namespace, ttl_seconds)
async def state_delete(self, key: str, namespace: str = "default") -> None:
if self._state_store is None:
return
await self._state_store.delete(self.channel_id, key, namespace)
def _get_stream_state(self, chat_id: str, msg_id: str) -> int:
return self._stream_state.get(f"{chat_id}:{msg_id}", 0)
def _set_stream_state(self, chat_id: str, msg_id: str, state: int) -> None:
self._stream_state[f"{chat_id}:{msg_id}"] = state
def _clear_stream_state(self, chat_id: str, msg_id: str) -> None:
self._stream_state.pop(f"{chat_id}:{msg_id}", None)
@abstractmethod
async def connect(self) -> None: ...
@abstractmethod
async def disconnect(self) -> None: ...
@abstractmethod
async def send(self, response: ChannelResponse) -> DeliveryResult: ...
async def receive(self) -> AsyncIterator[ChannelMessage]:
raise NotImplementedError
yield # type: ignore[misc]
@abstractmethod
def normalize_inbound(self, raw: Any) -> ChannelMessage: ...
@abstractmethod
def format_outbound(self, response: ChannelResponse) -> Any: ...
@abstractmethod
async def health_check(self) -> HealthStatus: ...
def on_message(self, handler: Callable[[ChannelMessage], Awaitable[None]]) -> None:
self._message_handler = handler
async def _handle_message(self, message: ChannelMessage) -> None:
if self._message_handler:
await self._message_handler(message)
async def send_media(self, chat_id: str, media_type: str, data: Any) -> DeliveryResult:
raise NotImplementedError
async def edit_message(self, chat_id: str, msg_id: str, content: str) -> DeliveryResult:
raise NotImplementedError
async def delete_message(self, chat_id: str, msg_id: str) -> DeliveryResult:
raise NotImplementedError
async def send_reaction(self, chat_id: str, msg_id: str, emoji: str) -> DeliveryResult:
raise NotImplementedError
async def send_stream_chunk(self, chat_id: str, msg_id: str, chunk: str, finished: bool) -> DeliveryResult:
identity = self._build_stream_identity(chat_id, msg_id)
response = ChannelResponse(identity=identity, content=chunk)
return await self.send(response)
def _build_stream_identity(self, chat_id: str, msg_id: str) -> Any:
from yuxi.channels.models import ChannelIdentity
return ChannelIdentity(
channel_id=self.channel_id,
channel_type=self.channel_type,
channel_user_id="",
channel_chat_id=chat_id,
channel_message_id=msg_id,
)
async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool:
return True
async def _refresh_token_if_needed(self) -> bool:
from yuxi.utils.logging_config import logger
try:
self._credential_backoff.mark_attempt()
return True
except Exception as e:
delay = self._credential_backoff.next_delay
logger.warning(
f"[{self.channel_id}] Token refresh failed "
f"(attempt {self._credential_backoff.attempt}), "
f"next retry in {delay:.1f}s: {e}"
)
await self._credential_backoff.wait()
return False
async def pre_connect(self) -> dict:
return {}
def is_enabled(self) -> bool:
return bool(self.config.get("enabled", False))
def is_configured(self) -> bool:
return bool(self.config)
@property
def status(self) -> str:
return getattr(self, "_status", "unknown") or "unknown"
def snapshot(self) -> dict[str, Any]:
from yuxi.channels.models import build_snapshot_from_adapter
return build_snapshot_from_adapter(self).model_dump()
def resolve_account_state(self, configured: bool, enabled: bool) -> str:
if not configured:
return "not_configured"
if not enabled:
return "disabled"
return "active"
def collect_status_issues(self, accounts: list) -> list[str]:
issues = []
if not self.is_configured():
issues.append("not_configured")
if not self.is_enabled():
issues.append("disabled")
return issues
async def check_ready(self) -> bool:
return self.is_enabled() and self.is_configured()
def on_config_changed(self, prev_cfg: dict, next_cfg: dict) -> None:
pass
async def run_startup_maintenance(self) -> None:
pass
async def logout_account(self, ctx) -> None:
raise NotImplementedError
async def login_with_qr_start(self, force: bool, timeout_ms: int) -> str:
raise NotImplementedError