本次提交对渠道模块进行了全面升级,包含以下核心改进: 1. 新增二维码登录相关协议方法,完善登录流程 2. 优化配置监听逻辑,增加渠道运行状态前置校验 3. 重构动作注册机制,支持动态注册渠道动作并新增批量操作能力 4. 扩展渠道能力模型,新增广播、文件传输等支持 5. 优化适配器加载路径,新增元宝适配器支持 6. 新增凭证过期检查与告警能力,完善运维监控 7. 重构统计收集器,支持多维度渠道统计数据 8. 优化消息路由策略,新增策略缓存与安全处理逻辑 9. 重构基础适配器,新增凭证管理工具方法 10. 完善状态存储功能,支持凭证数据管理与批量清理 11. 重构渠道管理器,新增配置校验、动态渠道管理、限流能力 12. 优化健康检查与状态上报逻辑,完善审计日志与异常处理
347 lines
12 KiB
Python
347 lines
12 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="")
|
|
|
|
config_schema: ClassVar[dict[str, dict[str, Any]]] = {}
|
|
|
|
_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]:
|
|
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,
|
|
)
|
|
|
|
@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)
|
|
|
|
@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
|