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, ChannelStatus, 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="") config_schema: ClassVar[dict[str, dict[str, Any]]] = {} VALID_TRANSITIONS: ClassVar[dict[str, set[str]]] = { ChannelStatus.DISCONNECTED.value: {ChannelStatus.CONNECTING.value}, ChannelStatus.CONNECTING.value: { ChannelStatus.CONNECTED.value, ChannelStatus.ERROR.value, ChannelStatus.DISCONNECTED.value, }, ChannelStatus.CONNECTED.value: {"disconnecting", ChannelStatus.RECONNECTING.value, ChannelStatus.ERROR.value}, "disconnecting": {ChannelStatus.DISCONNECTED.value}, ChannelStatus.RECONNECTING.value: { ChannelStatus.CONNECTED.value, ChannelStatus.ERROR.value, ChannelStatus.DISCONNECTED.value, }, ChannelStatus.ERROR.value: {ChannelStatus.CONNECTING.value, ChannelStatus.DISCONNECTED.value}, } _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) async def credential_get(self, mode: str | None = None) -> dict | None: key = f"{self.channel_id}:default" if mode is None else f"{self.channel_id}:{mode}" return await self.state_get(key, namespace="credentials") async def credential_set( self, value: dict, mode: str | None = None, ttl_seconds: int | None = None, ) -> None: key = f"{self.channel_id}:default" if mode is None else f"{self.channel_id}:{mode}" await self.state_set(key, value, namespace="credentials", ttl_seconds=ttl_seconds) async def credential_delete(self, mode: str | None = None) -> None: key = f"{self.channel_id}:default" if mode is None else f"{self.channel_id}:{mode}" await self.state_delete(key, namespace="credentials") 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]: return 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, ) @classmethod def get_config_schema(cls) -> dict[str, dict[str, Any]]: return dict(cls.config_schema) async def verify_webhook_signature(self, headers: dict, body: bytes) -> bool: return True async def _refresh_token_if_needed(self) -> bool: return False async def refresh_credential(self) -> dict[str, Any]: from yuxi.utils.logging_config import logger try: refreshed = await self._refresh_token_if_needed() if refreshed: logger.info(f"[{self.channel_id}] Credential refreshed successfully") return {"channel_id": self.channel_id, "refreshed": True, "message": "凭证刷新成功"} return { "channel_id": self.channel_id, "refreshed": False, "message": "该渠道不支持手动刷新凭证或凭证无需刷新", } except Exception as e: logger.error(f"[{self.channel_id}] Credential refresh failed: {e}") return {"channel_id": self.channel_id, "refreshed": False, "message": f"凭证刷新失败: {e}"} async def reload_config(self, new_config: dict[str, Any]) -> None: prev_cfg = dict(self.config) self.config.update(new_config) self.on_config_changed(prev_cfg, dict(self.config)) async def get_credential_status(self) -> dict[str, Any]: entries = await self._get_credential_db_entries() if entries: last_entry = entries[-1] earliest_expiry = None any_expired = False for e in entries: if e["is_expired"]: any_expired = True if e["expires_at"] and (earliest_expiry is None or e["expires_at"] < earliest_expiry): earliest_expiry = e["expires_at"] return { "has_credential": True, "source": "db", "credential_type": last_entry.get("entry_key", "unknown"), "last_updated": last_entry.get("updated_at"), "expires_at": earliest_expiry, "is_expired": any_expired, } config_has_credential = self._config_has_sensitive_keys() if config_has_credential: return { "has_credential": True, "source": "config", "credential_type": "api_key", "last_updated": None, "expires_at": None, "is_expired": False, } env_has_credential = self._env_has_credential_keys() if env_has_credential: return { "has_credential": True, "source": "env", "credential_type": "api_key", "last_updated": None, "expires_at": None, "is_expired": False, } return { "has_credential": False, "source": "none", "credential_type": None, "last_updated": None, "expires_at": None, "is_expired": False, } async def _get_credential_db_entries(self) -> list[dict[str, Any]]: if self._state_store is None: return [] return await self._state_store.get_credential_entries(self.channel_id) def _config_has_sensitive_keys(self) -> bool: from yuxi.channels.auth.secret_manager import SecretManager return any(SecretManager.is_sensitive_key(k) for k in self.config) def _env_has_credential_keys(self) -> bool: import os from yuxi.channels.auth.secret_manager import SecretManager return any(SecretManager.is_sensitive_key(k) and os.getenv(k) for k in os.environ) 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) def _transition(self, new_status: str) -> None: current = self.status valid_targets = self.VALID_TRANSITIONS.get(current) if valid_targets is None: raise ValueError(f"Unknown current status: {current}") if new_status not in valid_targets: raise ValueError( f"Illegal state transition: {current} -> {new_status}. " f"Allowed transitions from {current}: {valid_targets}" ) @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) -> dict: raise NotImplementedError async def login_with_qr_check(self, session_id: str) -> dict: raise NotImplementedError