1. 简化message_actions.py中获取适配器的逻辑 2. 新增适配器合法性校验工具方法 3. 新增会话映射过期清理功能 4. 重构渠道状态机与基础适配器实现 5. 统一渠道操作异常处理逻辑 6. 新增凭证状态查询与刷新接口 7. 优化健康检查与自动重连逻辑 8. 新增统计数据缓存与批量查询优化 9. 修复部分数据库操作的异常处理逻辑
376 lines
14 KiB
Python
376 lines
14 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,
|
|
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
|