feat(plugin): 实现完整的插件注册管理系统
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled

新增了插件相关的完整领域模型、应用服务、基础设施实现,包括:
1. 插件状态、注册模式、来源等基础枚举和数据结构
2. 插件清单解析、发现、加载工具类
3. 插件注册表领域服务和内存存储实现
4. 插件相关的命令、查询、事件定义
5. 插件REST API接口和DTO映射
6. 集成了原有通道适配器到插件系统
7. 新增内置插件注册和自动发现能力
This commit is contained in:
Kris 2026-05-31 16:44:13 +08:00
parent 395475628c
commit 9e503becd3
302 changed files with 9982 additions and 5861 deletions

View File

@ -0,0 +1,6 @@
from yuxi.channel.application.command.plugin.activate_command import ActivateCommand as ActivateCommand
from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand as DeactivateCommand
from yuxi.channel.application.command.plugin.reload_command import ReloadCommand as ReloadCommand
from yuxi.channel.application.command.plugin.register_command import RegisterCommand as RegisterCommand
__all__ = ["RegisterCommand", "ActivateCommand", "DeactivateCommand", "ReloadCommand"]

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ActivateCommand:
plugin_id: str
config_override: dict | None = None

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class DeactivateCommand:
plugin_id: str

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class RegisterCommand:
plugin_id: str
manifest_path: str | None = None
manifest_data: dict | None = None
mode: str = "full"

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ReloadCommand:
plugin_id: str
force: bool = False

View File

@ -0,0 +1,39 @@
from __future__ import annotations
from pydantic import BaseModel
class PluginSummaryDTO(BaseModel):
plugin_id: str
name: str
version: str
channel_type: str
status: str
source: str
is_configured: bool
class PluginDetailDTO(BaseModel):
plugin_id: str
name: str
version: str
channel_type: str
status: str
source: str
is_configured: bool
capabilities: dict
config_schema: dict | None = None
registered_at: str | None = None
error: str | None = None
class PluginListDTO(BaseModel):
plugins: list[PluginSummaryDTO]
total: int
class ReloadResultDTO(BaseModel):
plugin_id: str
old_version: str
new_version: str
success: bool

View File

@ -0,0 +1,30 @@
from __future__ import annotations
import logging
from yuxi.channel.domain.event.plugin.plugin_activated_event import PluginActivatedEvent
from yuxi.channel.domain.event.plugin.plugin_deactivated_event import PluginDeactivatedEvent
from yuxi.channel.domain.event.plugin.plugin_registered_event import PluginRegisteredEvent
from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent
logger = logging.getLogger(__name__)
class PluginEventHandler:
def on_registered(self, event: PluginRegisteredEvent) -> None:
logger.info("plugin registered: %s (%s)", event.plugin_id, event.channel_type)
def on_activated(self, event: PluginActivatedEvent) -> None:
logger.info("plugin activated: %s (%s)", event.plugin_id, event.channel_type)
def on_deactivated(self, event: PluginDeactivatedEvent) -> None:
logger.info("plugin deactivated: %s (%s)", event.plugin_id, event.channel_type)
def on_reloaded(self, event: PluginReloadedEvent) -> None:
logger.info(
"plugin reloaded: %s (%s) %s -> %s",
event.plugin_id,
event.channel_type,
event.old_version,
event.new_version,
)

View File

@ -28,6 +28,9 @@ class ValidationMiddleware:
if ctx.is_aborted:
return ctx
if ctx.message.metadata.get("auth_method"):
return await call_next(ctx)
msg = ctx.message
if not msg.message_id:

View File

@ -0,0 +1,6 @@
from yuxi.channel.application.query.plugin.get_plugin_manifest_query import (
GetPluginManifestQuery as GetPluginManifestQuery,
)
from yuxi.channel.application.query.plugin.list_plugins_query import ListPluginsQuery as ListPluginsQuery
__all__ = ["ListPluginsQuery", "GetPluginManifestQuery"]

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class GetPluginManifestQuery:
plugin_id: str

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class ListPluginsQuery:
origin: str | None = None
status: str | None = None

View File

@ -1,12 +1,20 @@
from __future__ import annotations
from yuxi.channel.domain.exception.agent_config_not_found import AgentConfigNotFoundException
from yuxi.channel.domain.exception.duplicate_binding import DuplicateBindingException
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
from yuxi.channel.domain.port.agent_config_lookup_port import AgentConfigLookupPort
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort
class BindingService:
def __init__(self, binding_repo: BindingRepositoryPort) -> None:
def __init__(
self,
binding_repo: BindingRepositoryPort,
agent_config_lookup: AgentConfigLookupPort | None = None,
) -> None:
self._repo = binding_repo
self._agent_config_lookup = agent_config_lookup
async def create(
self,
@ -15,12 +23,33 @@ class BindingService:
account_id: str,
group_id: str,
agent_config_id: int,
created_by: str | None = None,
) -> ChannelBinding:
existing = await self._repo.find_binding(channel_type=channel_type, account_id=account_id, group_id=group_id)
if existing:
raise DuplicateBindingException(channel_type, account_id, group_id)
if self._agent_config_lookup and not await self._agent_config_lookup.exists(agent_config_id):
raise AgentConfigNotFoundException(agent_config_id)
return await self._repo.create_binding(
channel_type=channel_type,
account_id=account_id,
group_id=group_id,
agent_config_id=agent_config_id,
created_by=created_by,
)
async def update(
self,
binding_id: int,
*,
agent_config_id: int | None = None,
is_enabled: bool | None = None,
updated_by: str | None = None,
) -> ChannelBinding | None:
return await self._repo.update_binding(
binding_id, agent_config_id=agent_config_id, is_enabled=is_enabled, updated_by=updated_by
)
async def delete(self, binding_id: int) -> bool:

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib
import json
from yuxi.channel.application.service.auth_service import AuthService
from yuxi.channel.domain.middleware.configurable import Configurable
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.service.pipeline import Pipeline
@ -17,29 +18,37 @@ class ConfigService:
pipeline: Pipeline,
*,
channel_config: ChannelConfig | None = None,
auth_service: AuthService | None = None,
) -> None:
self._config = config_data
self._config_reload = config_reload
self._pipeline = pipeline
self._channel_config = channel_config
self._auth_service = auth_service
self._config_hash: str | None = None
async def reload(self) -> tuple[list[str], str | None]:
reloaded = await self._config_reload.reload()
if reloaded:
self._config = reloaded
new_hash = hashlib.sha256(
json.dumps(self._config, sort_keys=True).encode(),
).hexdigest()[:8]
if new_hash == self._config_hash:
return [], new_hash
updated = self._update_configurable_middlewares()
if self._channel_config:
items = await self._channel_config.on_config_updated(self._config)
if items:
updated.extend(items)
self._sync_auth_credentials(items)
config_hash = hashlib.sha256(
json.dumps(self._config, sort_keys=True).encode(),
).hexdigest()[:8]
return updated, config_hash
self._config_hash = new_hash
return updated, new_hash
@property
def config(self) -> dict:
@ -55,3 +64,12 @@ class ConfigService:
updated.extend(items)
return updated
def _sync_auth_credentials(self, updated_items: list[str]) -> None:
if not self._auth_service:
return
if "auth_token" in updated_items or "auth_password" in updated_items:
self._auth_service.update_credentials(
token=self._channel_config.auth_token,
password=self._channel_config.auth_password,
)

View File

@ -11,6 +11,7 @@ from yuxi.channel.domain.model.message.dispatch_result import DispatchResult, Se
from yuxi.channel.domain.model.message.stream_chat_request import StreamChatRequest
from yuxi.channel.domain.model.session.channel_session import ChannelSession
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry
from yuxi.channel.domain.port.agent_port import AgentPort
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort
@ -31,12 +32,14 @@ class DeliveryService:
event_publisher: EventPublisherPort,
agent_port: AgentPort,
*,
registry: PluginRegistry | None = None,
message_log_repo: MessageLogRepositoryPort | None = None,
metrics: MetricsPort | None = None,
agent_timeout: float = 120.0,
typing_interval: float = 3.0,
) -> None:
self._adapters = adapters
self._registry = registry
self._message_repo = message_repo
self._outbox = outbox_repo
self._events = event_publisher
@ -46,6 +49,11 @@ class DeliveryService:
self._agent_timeout = agent_timeout
self._typing_interval = typing_interval
def _get_adapter(self, channel_type: str) -> ChannelAdapterPort | None:
if self._registry:
return self._registry.get_adapter(channel_type)
return self._adapters.get(channel_type)
async def deliver(
self,
payload: dict,
@ -89,7 +97,7 @@ class DeliveryService:
await self._metrics.record_worker_dispatch_duration(channel_type, time.monotonic() - start)
return DispatchResult(success=True, message_id=message_id)
adapter = self._adapters.get(channel_type)
adapter = self._get_adapter(channel_type)
if not adapter:
if self._metrics:
await self._metrics.record_worker_dispatch_total(channel_type, "no_adapter")
@ -154,7 +162,7 @@ class DeliveryService:
metadata=payload.get("metadata", {}),
)
adapter = self._adapters.get(channel_type)
adapter = self._get_adapter(channel_type)
typing_task = await self._start_typing(adapter, session.thread_id)
try:
response = await asyncio.wait_for(

View File

@ -63,10 +63,12 @@ class InboundService:
return
try:
pipeline_result = "skipped" if ctx.is_skipped else ("aborted" if ctx.is_aborted else "passed")
status = "processing" if pipeline_result == "accepted" else "completed"
await self._message_log_repo.update_pipeline_result(
trace_id=ctx.trace_id,
message_id=ctx.message.message_id,
pipeline_result=pipeline_result,
status=status,
abort_reason=ctx.abort_reason if ctx.is_aborted else None,
)
except Exception:

View File

@ -0,0 +1,207 @@
from __future__ import annotations
import logging
from yuxi.channel.application.command.plugin.activate_command import ActivateCommand
from yuxi.channel.application.command.plugin.deactivate_command import DeactivateCommand
from yuxi.channel.application.command.plugin.reload_command import ReloadCommand
from yuxi.channel.application.dto.plugin_dto import (
PluginDetailDTO,
PluginListDTO,
PluginSummaryDTO,
ReloadResultDTO,
)
from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent
from yuxi.channel.domain.exception.plugin_exception import (
ActiveSessionExistsException,
InvalidPluginStateException,
PluginNotFoundException,
PluginNotConfiguredException,
PluginReloadException,
)
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginSpec
from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort
from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort
from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService
from yuxi.channel.infrastructure.plugin.registry_impl import PluginRegistryImpl
logger = logging.getLogger(__name__)
class PluginRegistryAppService:
def __init__(
self,
registry_impl: PluginRegistryImpl,
event_publisher: EventPublisherPort | None = None,
):
self._impl = registry_impl
self._registry = registry_impl.registry
self._event_publisher = event_publisher
def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None:
return self._registry.get_adapter(channel_type)
def get_channel_type_for_plugin(self, plugin_id: str) -> str | None:
record = self._registry.get_record(plugin_id)
return record.channel_type if record else None
def list_plugins(self, origin: str | None = None, status: str | None = None) -> PluginListDTO:
spec = None
if origin or status:
spec = PluginSpec(
origin=origin,
status=PluginStatus(status) if status else None,
)
records = self._registry.list_records(spec)
summaries = [
PluginSummaryDTO(
plugin_id=r.plugin_id,
name=r.name,
version=r.version,
channel_type=r.channel_type,
status=r.status.value,
source=r.source.origin.value,
is_configured=r.is_configured,
)
for r in records
]
return PluginListDTO(plugins=summaries, total=len(summaries))
def get_plugin(self, plugin_id: str) -> PluginDetailDTO:
record = self._registry.get_record(plugin_id)
if not record:
raise PluginNotFoundException(plugin_id)
return PluginDetailDTO(
plugin_id=record.plugin_id,
name=record.name,
version=record.version,
channel_type=record.channel_type,
status=record.status.value,
source=record.source.origin.value,
is_configured=record.is_configured,
capabilities={
"media": record.capabilities.media,
"group": record.capabilities.group,
"dm": record.capabilities.dm,
"streaming": record.capabilities.streaming,
"typing": record.capabilities.typing,
"reaction": record.capabilities.reaction,
"thread": record.capabilities.thread,
"max_text_length": record.capabilities.max_text_length,
},
config_schema=record.config_schema,
registered_at=record.registered_at.isoformat() if record.registered_at else None,
error=record.error,
)
def get_manifest(self, plugin_id: str) -> dict:
record = self._registry.get_record(plugin_id)
if not record:
raise PluginNotFoundException(plugin_id)
return {
"id": record.manifest.id,
"name": record.manifest.name,
"version": record.manifest.version,
"channel_type": record.manifest.channel_type,
"capabilities": {
"media": record.manifest.capabilities.media,
"group": record.manifest.capabilities.group,
"dm": record.manifest.capabilities.dm,
"streaming": record.manifest.capabilities.streaming,
"typing": record.manifest.capabilities.typing,
"reaction": record.manifest.capabilities.reaction,
"thread": record.manifest.capabilities.thread,
"max_text_length": record.manifest.capabilities.max_text_length,
},
"config_schema": record.manifest.config_schema,
"entry": record.manifest.entry,
"dependencies": record.manifest.dependencies,
}
def get_config_schema(self, plugin_id: str) -> dict | None:
record = self._registry.get_record(plugin_id)
if not record:
raise PluginNotFoundException(plugin_id)
return record.config_schema
async def activate(self, command: ActivateCommand) -> PluginDetailDTO:
record = self._registry.get_record(command.plugin_id)
if not record:
raise PluginNotFoundException(command.plugin_id)
if not PluginRegistryDomainService.can_activate(record):
raise InvalidPluginStateException(command.plugin_id, record.status.value, "activate")
config = command.config_override or {}
if config:
record.set_config(config)
if not record.is_configured:
raise PluginNotConfiguredException(command.plugin_id)
PluginRegistryDomainService.transition_to_configured(record)
await self._registry.activate(command.plugin_id, **config)
return self.get_plugin(command.plugin_id)
async def deactivate(
self, command: DeactivateCommand, *, session_repo: SessionRepositoryPort | None = None
) -> PluginDetailDTO:
record = self._registry.get_record(command.plugin_id)
if not record:
raise PluginNotFoundException(command.plugin_id)
if not PluginRegistryDomainService.can_deactivate(record):
raise InvalidPluginStateException(command.plugin_id, record.status.value, "deactivate")
if session_repo and record.channel_type:
count = await session_repo.count_active_sessions(record.channel_type)
if count > 0:
raise ActiveSessionExistsException(command.plugin_id, record.channel_type, count)
await self._registry.deactivate(command.plugin_id)
return self.get_plugin(command.plugin_id)
async def reload_plugin(self, command: ReloadCommand) -> ReloadResultDTO:
old_record = self._registry.get_record(command.plugin_id)
if not old_record:
raise PluginNotFoundException(command.plugin_id)
if not PluginRegistryDomainService.can_reload(old_record):
raise InvalidPluginStateException(command.plugin_id, old_record.status.value, "reload")
old_version = old_record.version
channel_type = old_record.channel_type
config = self._impl.resolve_config(old_record)
try:
await self._registry.reload(command.plugin_id, **config)
except Exception as exc:
logger.error("reload plugin %s failed: %s", command.plugin_id, exc)
raise PluginReloadException(command.plugin_id) from exc
new_record = self._registry.get_record(command.plugin_id)
new_version = new_record.version if new_record else old_version
if self._event_publisher:
try:
await self._event_publisher.publish(
PluginReloadedEvent(
plugin_id=command.plugin_id,
old_version=old_version,
new_version=new_version,
channel_type=channel_type,
)
)
except Exception:
logger.warning("failed to publish reload event for %s", command.plugin_id)
return ReloadResultDTO(
plugin_id=command.plugin_id,
old_version=old_version,
new_version=new_version,
success=True,
)

View File

@ -9,6 +9,7 @@ feishu:
app_id: ""
app_secret: ""
verification_token: ""
encrypt_key: ""
ws_enabled: true
dingtalk:

View File

@ -1,4 +1,4 @@
import yuxi.channel.channels.feishu
import yuxi.channel.channels.dingtalk
import yuxi.channel.channels.web
import yuxi.channel.channels.hooks
import yuxi.channel.channels.feishu # noqa: F401
import yuxi.channel.channels.dingtalk # noqa: F401
import yuxi.channel.channels.web # noqa: F401
import yuxi.channel.channels.hooks # noqa: F401

View File

@ -1,16 +1,63 @@
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.channel_request_verifier_port import ChannelRequestVerifierPort
_channel_adapters: dict[str, type[ChannelAdapterPort]] = {}
_channel_registry: dict[str, dict] = {}
_compat_registry: dict[str, type[ChannelAdapterPort]] = {}
def register_channel(name: str, adapter_cls: type[ChannelAdapterPort]) -> None:
_channel_adapters[name] = adapter_cls
def register_channel(
name: str,
adapter_cls: type[ChannelAdapterPort],
*,
config_cls: type | None = None,
verifier_factory: Callable[[dict], ChannelRequestVerifierPort] | None = None,
env_mapping: dict[str, str] | None = None,
infra_dependencies: list[str] | None = None,
) -> None:
_compat_registry[name] = adapter_cls
_channel_registry[name] = {
"adapter_cls": adapter_cls,
"config_cls": config_cls,
"verifier_factory": verifier_factory,
"env_mapping": env_mapping or {},
"infra_dependencies": infra_dependencies or [],
}
try:
from yuxi.channel.infrastructure.plugin.registry_impl import get_registry
registry = get_registry()
if registry:
registry.register_builtin_adapter(name, adapter_cls)
except ImportError:
pass
def get_channel_meta(name: str) -> dict:
return _channel_registry.get(name, {})
def get_all_meta() -> dict[str, dict]:
return dict(_channel_registry)
def get_registered_channels() -> dict[str, type[ChannelAdapterPort]]:
return dict(_channel_adapters)
try:
from yuxi.channel.infrastructure.plugin.registry_impl import get_registry
registry = get_registry()
if registry:
return registry.get_registered_adapter_classes()
except ImportError:
pass
return dict(_compat_registry)
def get_compat_registry() -> dict[str, type[ChannelAdapterPort]]:
return dict(_compat_registry)

View File

@ -1,4 +1,18 @@
from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter
from yuxi.channel.channels._registry import register_channel
from yuxi.channel.channels.dingtalk.adapter import DingTalkAdapter
from yuxi.channel.channels.dingtalk.config import DingTalkConfig
register_channel("dingtalk", DingTalkAdapter)
def _create_dingtalk_verifier(config: dict):
from yuxi.channel.channels.dingtalk.verifier import DingTalkRequestVerifier
return DingTalkRequestVerifier(sign_secret=config.get("client_secret", ""))
register_channel(
"dingtalk",
DingTalkAdapter,
config_cls=DingTalkConfig,
verifier_factory=_create_dingtalk_verifier,
env_mapping={"DINGTALK_CLIENT_ID": "client_id", "DINGTALK_CLIENT_SECRET": "client_secret"},
)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from yuxi.channel.channels.dingtalk.config import DingTalkConfig
from yuxi.channel.channels.dingtalk.outbound import DINGTALK_CAPABILITIES, DingTalkOutbound
from yuxi.channel.channels.dingtalk.translator import DingTalkTranslator
from yuxi.channel.channels.dingtalk.ws_connection import DingTalkWsConnection
@ -9,6 +10,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
logger = logging.getLogger(__name__)
@ -54,7 +56,7 @@ class DingTalkAdapter:
return self._ws
@property
def route_contributor(self) -> _DingTalkRouteContributor:
def route_contributor(self) -> ChannelRouteContributor | None:
return _DingTalkRouteContributor()
@classmethod
@ -65,6 +67,14 @@ class DingTalkAdapter:
"ws_enabled": True,
}
@classmethod
def from_config(cls, config: DingTalkConfig) -> DingTalkAdapter:
return cls(
client_id=config.client_id,
client_secret=config.client_secret,
ws_enabled=config.ws_enabled,
)
async def open(self) -> None:
await self._outbound.start()
self._opened = True
@ -73,7 +83,7 @@ class DingTalkAdapter:
self._opened = False
async def receive_message(self, raw: dict) -> UnifiedMessage:
return DingTalkTranslator.translate_event(raw)
return DingTalkTranslator.translate(raw)
async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult:
try:

View File

@ -1,6 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from yuxi.channel.channels.dingtalk.outbound import DINGTALK_CAPABILITIES
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
@dataclass
@ -8,3 +11,4 @@ class DingTalkConfig:
client_id: str = ""
client_secret: str = ""
ws_enabled: bool = True
capabilities: ChannelCapabilities = field(default_factory=lambda: DINGTALK_CAPABILITIES)

View File

@ -1,58 +1,61 @@
from __future__ import annotations
import json
import logging
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi import APIRouter, Body, Request
from fastapi.responses import JSONResponse
from yuxi.channel.channels.dingtalk.translator import DingTalkTranslator
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/channel/dingtalk/callback")
async def dingtalk_callback(request: Request):
body = await request.json()
@router.post("/callback")
async def dingtalk_callback(
request: Request,
body: bytes = Body(..., max_length=256 * 1024),
):
try:
raw = json.loads(body)
except json.JSONDecodeError:
return JSONResponse(status_code=202, content={"status": "accepted"})
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
adapter = container.adapters.get("dingtalk")
if not adapter:
return JSONResponse(status_code=202, content={"status": "accepted"})
verifier = container.verifiers.get("dingtalk")
verify_result = None
if verifier and verifier.enabled:
verify_result = await verifier.verify(body, dict(request.headers))
if not verify_result.passed:
logger.warning("dingtalk verify failed: %s", verify_result.reason)
if container.metrics:
await container.metrics.record_verification_failed("dingtalk", verify_result.reason)
return JSONResponse(status_code=202, content={"status": "accepted"})
try:
message = DingTalkTranslator.translate_event(body)
message = await adapter.receive_message(raw)
except Exception:
logger.exception("dingtalk event parse failed, body=%s", body)
logger.exception("dingtalk event parse failed")
return JSONResponse(status_code=202, content={"status": "accepted"})
msg_id = body.get("msgId", str(uuid4()))
msg_id = raw.get("msgId", str(uuid4()))
message.metadata["trace_id"] = msg_id
message.metadata["idempotency_key"] = msg_id
message.metadata["client_ip"] = request.client.host if request.client else "unknown"
if verify_result:
message.metadata["auth_method"] = verify_result.method
result = await container.inbound_service.submit(message, channel_type="dingtalk", trace_id=msg_id)
if result.is_aborted:
logger.warning("dingtalk message aborted: %s [%s]", result.abort_reason, msg_id)
if container.event_publisher:
from yuxi.channel.domain.event.message_blocked import MessageBlocked
await container.event_publisher.publish(
MessageBlocked(
message_id=message.message_id,
channel_type="dingtalk",
session_id="",
reason=result.abort_reason or "pipeline_aborted",
trace_id=msg_id,
)
)
return JSONResponse(status_code=202, content={"status": "accepted", "trace_id": msg_id})

View File

@ -7,7 +7,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType
class DingTalkTranslator:
@staticmethod
def translate_event(raw: dict) -> UnifiedMessage:
def translate(raw: dict) -> UnifiedMessage:
event = raw.get("event", {})
message = event.get("message", {})
sender = event.get("sender", {})
@ -29,3 +29,7 @@ class DingTalkTranslator:
},
raw_payload=raw,
)
@staticmethod
def translate_event(raw: dict) -> UnifiedMessage:
return DingTalkTranslator.translate(raw)

View File

@ -0,0 +1,53 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult
logger = logging.getLogger(__name__)
_TIMESTAMP_TOLERANCE_SECONDS = 3600
class DingTalkRequestVerifier:
def __init__(self, *, sign_secret: str = "") -> None:
self._sign_secret = sign_secret
@property
def channel_type(self) -> str:
return "dingtalk"
@property
def enabled(self) -> bool:
return bool(self._sign_secret)
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
if not self._sign_secret:
return VerifyResult(passed=True, method="dingtalk_none", reason="no_secret_configured")
timestamp = headers.get("timestamp", "")
sign = headers.get("sign", "")
if not timestamp or not sign:
return VerifyResult(passed=False, method="dingtalk_sign", reason="missing_sign_headers")
try:
ts = int(timestamp)
except (ValueError, TypeError):
return VerifyResult(passed=False, method="dingtalk_sign", reason="invalid_timestamp")
if abs(time.time() - ts / 1000) > _TIMESTAMP_TOLERANCE_SECONDS:
return VerifyResult(passed=False, method="dingtalk_sign", reason="timestamp_expired")
string_to_sign = f"{timestamp}\n{self._sign_secret}"
expected = base64.b64encode(
hmac.new(self._sign_secret.encode(), string_to_sign.encode(), hashlib.sha256).digest()
).decode()
if not hmac.compare_digest(sign, expected):
return VerifyResult(passed=False, method="dingtalk_sign", reason="sign_mismatch")
return VerifyResult(passed=True, method="dingtalk_sign")

View File

@ -1,4 +1,18 @@
from yuxi.channel.channels._registry import register_channel
from yuxi.channel.channels.feishu.adapter import FeishuAdapter
from yuxi.channel.channels.feishu.config import FeishuConfig
register_channel("feishu", FeishuAdapter)
def _create_feishu_verifier(config: dict):
from yuxi.channel.channels.feishu.verifier import FeishuRequestVerifier
return FeishuRequestVerifier(encrypt_key=config.get("encrypt_key", ""))
register_channel(
"feishu",
FeishuAdapter,
config_cls=FeishuConfig,
verifier_factory=_create_feishu_verifier,
env_mapping={"FEISHU_APP_ID": "app_id", "FEISHU_APP_SECRET": "app_secret"},
)

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import logging
from yuxi.channel.channels.feishu.config import FeishuConfig
from yuxi.channel.channels.feishu.outbound import FEISHU_CAPABILITIES, FeishuOutbound
from yuxi.channel.channels.feishu.translator import FeishuTranslator
from yuxi.channel.channels.feishu.ws_connection import FeishuWsConnection
@ -9,7 +10,7 @@ from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
logger = logging.getLogger(__name__)
@ -30,10 +31,12 @@ class FeishuAdapter:
app_id: str = "",
app_secret: str = "",
verification_token: str = "",
encrypt_key: str = "",
ws_enabled: bool = True,
) -> None:
self._outbound = FeishuOutbound(app_id=app_id, app_secret=app_secret)
self._verification_token = verification_token
self._encrypt_key = encrypt_key
self._ws_enabled = ws_enabled
self._ws: FeishuWsConnection | None = None
if ws_enabled and app_id and app_secret:
@ -66,9 +69,20 @@ class FeishuAdapter:
"app_id": "",
"app_secret": "",
"verification_token": "",
"encrypt_key": "",
"ws_enabled": True,
}
@classmethod
def from_config(cls, config: FeishuConfig) -> FeishuAdapter:
return cls(
app_id=config.app_id,
app_secret=config.app_secret,
verification_token=config.verification_token,
encrypt_key=config.encrypt_key,
ws_enabled=config.ws_enabled,
)
async def open(self) -> None:
await self._outbound.start()
self._opened = True
@ -77,7 +91,7 @@ class FeishuAdapter:
self._opened = False
async def receive_message(self, raw: dict) -> UnifiedMessage:
return FeishuTranslator.translate_event(raw)
return FeishuTranslator.translate(raw)
async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult:
try:

View File

@ -1,6 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from yuxi.channel.channels.feishu.outbound import FEISHU_CAPABILITIES
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
@dataclass
@ -8,4 +11,6 @@ class FeishuConfig:
app_id: str = ""
app_secret: str = ""
verification_token: str = ""
encrypt_key: str = ""
ws_enabled: bool = True
capabilities: ChannelCapabilities = field(default_factory=lambda: FEISHU_CAPABILITIES)

View File

@ -1,75 +1,80 @@
from __future__ import annotations
import json
import logging
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from yuxi.channel.channels.feishu.translator import FeishuTranslator
from fastapi import APIRouter, Body, HTTPException, Request
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/channel/feishu/event")
async def feishu_event(request: Request):
body = await request.json()
@router.post("/event")
async def feishu_event(
request: Request,
body: bytes = Body(..., max_length=256 * 1024),
):
try:
raw = json.loads(body)
except json.JSONDecodeError:
return {"code": 0}
if body.get("type") == "url_verification":
return {"challenge": body.get("challenge", "")}
if raw.get("type") == "url_verification":
return {"challenge": raw.get("challenge", "")}
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
adapter = container.adapters.get("feishu")
if not adapter:
logger.warning("feishu adapter not available, dropping event")
return {"code": 0}
verifier = container.verifiers.get("feishu")
verify_result = None
if verifier and verifier.enabled:
verify_result = await verifier.verify(body, dict(request.headers))
if not verify_result.passed:
logger.warning("feishu verify failed: %s", verify_result.reason)
if container.metrics:
await container.metrics.record_verification_failed("feishu", verify_result.reason)
return {"code": 0}
try:
message = FeishuTranslator.translate_event(body)
message = await adapter.receive_message(raw)
except Exception:
logger.exception("feishu event parse failed, body=%s", body)
logger.exception("feishu event parse failed")
return {"code": 0}
trace_id = body.get("header", {}).get("event_id", str(uuid4()))
trace_id = raw.get("header", {}).get("event_id", str(uuid4()))
message.metadata["trace_id"] = trace_id
message.metadata["idempotency_key"] = trace_id
message.metadata["client_ip"] = request.client.host if request.client else "unknown"
if verify_result:
message.metadata["auth_method"] = verify_result.method
result = await container.inbound_service.submit(message, channel_type="feishu", trace_id=trace_id)
if result.is_aborted:
logger.warning("feishu message aborted: %s [%s]", result.abort_reason, trace_id)
if container.event_publisher:
from yuxi.channel.domain.event.message_blocked import MessageBlocked
await container.event_publisher.publish(
MessageBlocked(
message_id=message.message_id,
channel_type="feishu",
session_id="",
reason=result.abort_reason or "pipeline_aborted",
trace_id=trace_id,
)
)
return {"code": 0}
@router.get("/channel/feishu/verify")
@router.get("/verify")
async def feishu_verify(challenge: str, token: str, request: Request):
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
expected_token = container.channel_config.feishu_verification_token
adapter = container.adapters.get("feishu")
if not adapter:
raise HTTPException(status_code=503)
expected_token = getattr(adapter, "_verification_token", "")
if expected_token and token != expected_token:
raise HTTPException(status_code=403, detail="verification token mismatch")

View File

@ -10,7 +10,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType
class FeishuTranslator:
@staticmethod
def translate_event(raw: dict) -> UnifiedMessage:
def translate(raw: dict) -> UnifiedMessage:
event = raw.get("event", {})
message = event.get("message", {})
sender = event.get("sender", {})
@ -37,6 +37,10 @@ class FeishuTranslator:
raw_payload=raw,
)
@staticmethod
def translate_event(raw: dict) -> UnifiedMessage:
return FeishuTranslator.translate(raw)
def _extract_text(message: dict) -> str:
content_raw = message.get("content", "")

View File

@ -0,0 +1,52 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import logging
import time
from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult
logger = logging.getLogger(__name__)
_TIMESTAMP_TOLERANCE_SECONDS = 3600
class FeishuRequestVerifier:
def __init__(self, *, encrypt_key: str = "") -> None:
self._encrypt_key = encrypt_key
@property
def channel_type(self) -> str:
return "feishu"
@property
def enabled(self) -> bool:
return bool(self._encrypt_key)
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
if not self._encrypt_key:
return VerifyResult(passed=True, method="feishu_none", reason="no_encrypt_key_configured")
signature = headers.get("x-lark-signature", "")
timestamp = headers.get("x-lark-request-timestamp", "")
nonce = headers.get("x-lark-request-nonce", "")
if not signature or not timestamp:
return VerifyResult(passed=False, method="feishu_signature", reason="missing_signature_headers")
try:
ts = int(timestamp)
except (ValueError, TypeError):
return VerifyResult(passed=False, method="feishu_signature", reason="invalid_timestamp")
if abs(time.time() - ts) > _TIMESTAMP_TOLERANCE_SECONDS:
return VerifyResult(passed=False, method="feishu_signature", reason="timestamp_expired")
content = f"{timestamp}{nonce}".encode() + body
expected = base64.b64encode(hmac.new(self._encrypt_key.encode(), content, hashlib.sha256).digest()).decode()
if not hmac.compare_digest(signature, expected):
return VerifyResult(passed=False, method="feishu_signature", reason="signature_mismatch")
return VerifyResult(passed=True, method="feishu_signature")

View File

@ -1,4 +1,23 @@
from yuxi.channel.channels._registry import register_channel
from yuxi.channel.channels.hooks.adapter import HooksAdapter
from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig
register_channel("hooks", HooksAdapter)
def _create_hooks_verifier(config: dict):
from yuxi.channel.channels.hooks.verifier import HooksRequestVerifier
mappings_raw = config.get("mappings", [])
mappings: dict[tuple[str, str], HookMapping] = {}
for m in mappings_raw:
mapping = HookMapping(**m) if isinstance(m, dict) else m
mappings[(mapping.match_path, mapping.match_source)] = mapping
return HooksRequestVerifier(mappings=mappings)
register_channel(
"hooks",
HooksAdapter,
config_cls=HooksConfig,
verifier_factory=_create_hooks_verifier,
infra_dependencies=["hook_mappings"],
)

View File

@ -2,28 +2,18 @@ from __future__ import annotations
import logging
from yuxi.channel.channels.hooks.config import HookMapping
from yuxi.channel.channels.hooks.config import HookMapping, HooksConfig
from yuxi.channel.channels.hooks.outbound import HOOKS_CAPABILITIES
from yuxi.channel.channels.hooks.translator import HooksTranslator
from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
logger = logging.getLogger(__name__)
HOOKS_CAPABILITIES = ChannelCapabilities(
media=False,
group=False,
dm=True,
streaming=False,
typing=False,
reaction=False,
thread=False,
max_text_length=32768,
)
class _HooksRouteContributor:
@property
@ -64,6 +54,27 @@ class HooksAdapter:
"mappings": [],
}
@classmethod
def from_config(cls, config: HooksConfig) -> HooksAdapter:
mapping_dicts = [
{
"match_path": m.match_path,
"match_source": m.match_source,
"default_agent_id": m.default_agent_id,
"allowed_agent_ids": m.allowed_agent_ids,
"default_session_key": m.default_session_key,
"allow_request_session_key": m.allow_request_session_key,
"allowed_session_key_prefixes": m.allowed_session_key_prefixes,
"session_key_strategy": m.session_key_strategy,
"channel": m.channel,
"deliver": m.deliver,
"max_body_bytes": m.max_body_bytes,
"secret": m.secret,
}
for m in config.mappings
]
return cls(mappings=mapping_dicts)
def match_hook(self, path: str, source: str = "*") -> HookMapping | None:
exact = self._mappings.get((path, source))
if exact:
@ -83,7 +94,8 @@ class HooksAdapter:
mapping = self.match_hook(path, source)
if not mapping:
raise ValueError(f"no hook mapping for path={path}, source={source}")
return HooksTranslator.translate(raw, mapping)
raw["_hook_mapping"] = mapping
return HooksTranslator.translate(raw)
async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult:
logger.info(

View File

@ -2,6 +2,9 @@ from __future__ import annotations
from dataclasses import dataclass, field
from yuxi.channel.channels.hooks.outbound import HOOKS_CAPABILITIES
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
@dataclass
class HookMapping:
@ -22,3 +25,4 @@ class HookMapping:
@dataclass
class HooksConfig:
mappings: list[HookMapping] = field(default_factory=list)
capabilities: ChannelCapabilities = field(default_factory=lambda: HOOKS_CAPABILITIES)

View File

@ -0,0 +1,30 @@
from __future__ import annotations
import logging
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
logger = logging.getLogger(__name__)
HOOKS_CAPABILITIES = ChannelCapabilities(
media=False,
group=False,
dm=True,
streaming=False,
typing=False,
reaction=False,
thread=False,
max_text_length=32768,
)
class HooksOutbound:
async def send_text(self, session_id: str, text: str, **kwargs) -> bool:
logger.info("hooks send to %s (fire-and-forget)", session_id)
return True
async def send_typing(self, session_id: str) -> None:
pass
async def send_media(self, session_id: str, *, url: str, media_type: str, **kwargs) -> bool:
return True

View File

@ -1,60 +1,23 @@
from __future__ import annotations
import hashlib
import hmac
import json
import logging
from uuid import uuid4
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from yuxi.channel.channels.hooks.translator import HooksTranslator
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP
from yuxi.channel.interfaces.rest.dto.accepted_response import AcceptedResponse
from yuxi.channel.interfaces.rest.dto.error_response import ErrorResponse
from yuxi.channel.interfaces.rest.dto.idempotency import resolve_idempotency_key
logger = logging.getLogger(__name__)
router = APIRouter()
class ErrorResponse(BaseModel):
error: str
code: str
detail: dict | None = None
trace_id: str | None = None
class AcceptedResponse(BaseModel):
trace_id: str
status: str = "accepted"
message_id: str | None = None
def _verify_hook_auth(mapping, request: Request, body: bytes) -> None:
if not mapping.secret:
return
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, mapping.secret):
return
raise HTTPException(status_code=401, detail="invalid bearer token")
signature = request.headers.get("x-signature-256") or request.headers.get("x-hub-signature-256")
if signature:
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(mapping.secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected):
return
raise HTTPException(status_code=401, detail="invalid signature")
raise HTTPException(status_code=401, detail="authentication required")
@router.post("/channel/hooks/{match_path:path}", response_model=AcceptedResponse)
@router.post("/{match_path:path}", response_model=AcceptedResponse)
async def receive_hook(
match_path: str,
request: Request,
@ -63,41 +26,54 @@ async def receive_hook(
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
adapter = container.adapters.get("hooks")
if not adapter:
raise HTTPException(status_code=503, detail="hooks adapter not available")
verifier = container.verifiers.get("hooks")
verify_result = None
if verifier and verifier.enabled:
headers = dict(request.headers)
headers["x-hook-path"] = match_path
headers["x-hook-source"] = request.headers.get("X-Hook-Source", "*")
verify_result = await verifier.verify(body, headers)
if not verify_result.passed:
if verify_result.reason == "no_hook_mapping":
raise HTTPException(status_code=404, detail=verify_result.reason)
raise HTTPException(status_code=401, detail=verify_result.reason)
mapping = adapter.match_hook(match_path, request.headers.get("X-Hook-Source", "*"))
if not mapping:
raise HTTPException(status_code=404, detail=f"no hook mapping for path: {match_path}")
try:
if container.metrics:
await container.metrics.record_hooks_received(match_path)
except Exception:
logger.debug("failed to record hooks_received metric")
_verify_hook_auth(mapping, request, body)
if len(body) > mapping.max_body_bytes:
if mapping and len(body) > mapping.max_body_bytes:
raise HTTPException(status_code=413, detail="payload too large")
if container.metrics:
try:
await container.metrics.record_hooks_received(match_path)
except Exception:
logger.debug("failed to record hooks_received metric")
try:
raw = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid JSON")
trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4())))
idempotency_key = request.headers.get("idempotency-key")
raw["match_path"] = match_path
raw["match_source"] = request.headers.get("X-Hook-Source", "*")
message = HooksTranslator.translate(raw, mapping)
try:
message = await adapter.receive_message(raw)
except Exception:
logger.exception("hooks message parse failed")
raise HTTPException(status_code=400, detail="message parse failed")
trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4())))
idempotency_key = resolve_idempotency_key(raw, dict(request.headers), "hooks")
message.metadata["trace_id"] = trace_id
if idempotency_key:
message.metadata["idempotency_key"] = idempotency_key
message.metadata["idempotency_key"] = idempotency_key
message.metadata["client_ip"] = request.client.host if request.client else "unknown"
if verify_result:
message.metadata["auth_method"] = verify_result.method
result = await container.inbound_service.submit(message, channel_type="hooks", trace_id=trace_id)
@ -113,21 +89,12 @@ async def receive_hook(
).model_dump(),
)
if result.is_skipped:
return JSONResponse(
status_code=202,
content=AcceptedResponse(
trace_id=trace_id,
status="skipped",
message_id=message.message_id,
).model_dump(),
)
status = "skipped" if result.is_skipped else "accepted"
return JSONResponse(
status_code=202,
content=AcceptedResponse(
trace_id=trace_id,
status="accepted",
status=status,
message_id=message.message_id,
).model_dump(),
)

View File

@ -1,6 +1,5 @@
from __future__ import annotations
from yuxi.channel.channels.hooks.config import HookMapping
from yuxi.channel.domain.model.message.peer import Peer
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_type import ChannelType
@ -8,7 +7,11 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType
class HooksTranslator:
@staticmethod
def translate(raw: dict, mapping: HookMapping) -> UnifiedMessage:
def translate(raw: dict) -> UnifiedMessage:
mapping = raw.pop("_hook_mapping", None)
if not mapping:
raise ValueError("no hook mapping in raw")
agent_id = raw.get("agent_id", mapping.default_agent_id)
if not mapping.allowed_agent_ids or agent_id not in mapping.allowed_agent_ids:
agent_id = mapping.default_agent_id

View File

@ -0,0 +1,51 @@
from __future__ import annotations
import hashlib
import hmac
import logging
from yuxi.channel.channels.hooks.config import HookMapping
from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult
logger = logging.getLogger(__name__)
class HooksRequestVerifier:
def __init__(self, *, mappings: dict[tuple[str, str], HookMapping]) -> None:
self._mappings = mappings
@property
def channel_type(self) -> str:
return "hooks"
@property
def enabled(self) -> bool:
return any(m.secret for m in self._mappings.values())
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
path = headers.get("x-hook-path", "")
source = headers.get("x-hook-source", "*")
mapping = self._mappings.get((path, source)) or self._mappings.get((path, "*"))
if not mapping:
return VerifyResult(passed=False, method="hooks_path", reason="no_hook_mapping")
if not mapping.secret:
return VerifyResult(passed=True, method="hooks_none", reason="no_secret_configured")
auth_header = headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, mapping.secret):
return VerifyResult(passed=True, method="hooks_bearer")
return VerifyResult(passed=False, method="hooks_bearer", reason="invalid_bearer_token")
signature = headers.get("x-signature-256") or headers.get("x-hub-signature-256")
if signature:
if signature.startswith("sha256="):
signature = signature[7:]
expected = hmac.new(mapping.secret.encode(), body, hashlib.sha256).hexdigest()
if hmac.compare_digest(signature, expected):
return VerifyResult(passed=True, method="hooks_hmac")
return VerifyResult(passed=False, method="hooks_hmac", reason="invalid_signature")
return VerifyResult(passed=False, method="hooks_auth", reason="authentication_required")

View File

@ -1,4 +1,18 @@
from yuxi.channel.channels._registry import register_channel
from yuxi.channel.channels.web.adapter import WebAdapter
from yuxi.channel.channels.web.config import WebConfig
register_channel("web", WebAdapter)
def _create_web_verifier(config: dict):
from yuxi.channel.channels.web.verifier import WebRequestVerifier
return WebRequestVerifier(auth_token=config.get("auth_token", ""))
register_channel(
"web",
WebAdapter,
config_cls=WebConfig,
verifier_factory=_create_web_verifier,
infra_dependencies=["sse_push"],
)

View File

@ -2,13 +2,14 @@ from __future__ import annotations
import logging
from yuxi.channel.channels.web.config import WebConfig
from yuxi.channel.channels.web.outbound import WEB_CAPABILITIES, WebOutbound
from yuxi.channel.channels.web.translator import WebTranslator
from yuxi.channel.domain.model.message.dispatch_result import SendResult
from yuxi.channel.domain.model.message.unified_message import UnifiedMessage
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.model.shared.channel_type import ChannelType
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor
from yuxi.channel.domain.port.sse_push_port import SsePushPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
@ -50,6 +51,10 @@ class WebAdapter:
"max_connections": 1000,
}
@classmethod
def from_config(cls, config: WebConfig) -> WebAdapter:
return cls(sse_push=None)
async def open(self) -> None:
self._opened = True
@ -57,7 +62,7 @@ class WebAdapter:
self._opened = False
async def receive_message(self, raw: dict) -> UnifiedMessage:
return WebTranslator.translate_message(raw)
return WebTranslator.translate(raw)
async def send_message(self, session_id: str, content: str, *, channel_type: str, metadata: dict) -> SendResult:
try:

View File

@ -1,8 +1,12 @@
from __future__ import annotations
from dataclasses import dataclass
from dataclasses import dataclass, field
from yuxi.channel.channels.web.outbound import WEB_CAPABILITIES
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
@dataclass
class WebConfig:
max_connections: int = 1000
capabilities: ChannelCapabilities = field(default_factory=lambda: WEB_CAPABILITIES)

View File

@ -7,7 +7,6 @@ from uuid import uuid4
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import JSONResponse
from yuxi.channel.channels.web.translator import WebTranslator
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP
logger = logging.getLogger(__name__)
@ -15,7 +14,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/channel/web/message")
@router.post("/message")
async def web_message(
request: Request,
body: bytes = Body(..., max_length=256 * 1024),
@ -23,26 +22,36 @@ async def web_message(
from yuxi.channel.container import get_channel
container = get_channel(request)
if not container:
raise HTTPException(status_code=503, detail="channel not initialized")
adapter = container.adapters.get("web")
if not adapter:
raise HTTPException(status_code=503, detail="web adapter not available")
verifier = container.verifiers.get("web")
verify_result = None
if verifier and verifier.enabled:
verify_result = await verifier.verify(body, dict(request.headers))
if not verify_result.passed:
raise HTTPException(status_code=401, detail=verify_result.reason)
try:
raw = json.loads(body)
except json.JSONDecodeError:
raise HTTPException(status_code=400, detail="invalid JSON")
message = WebTranslator.translate_message(raw)
try:
message = await adapter.receive_message(raw)
except Exception:
logger.exception("web message parse failed")
raise HTTPException(status_code=400, detail="message parse failed")
trace_id = raw.get("trace_id", request.headers.get("x-trace-id", str(uuid4())))
idempotency_key = request.headers.get("idempotency-key")
idempotency_key = request.headers.get("idempotency-key") or trace_id
message.metadata["trace_id"] = trace_id
if idempotency_key:
message.metadata["idempotency_key"] = idempotency_key
message.metadata["idempotency_key"] = idempotency_key
message.metadata["client_ip"] = request.client.host if request.client else "unknown"
if verify_result:
message.metadata["auth_method"] = verify_result.method
result = await container.inbound_service.submit(message, channel_type="web", trace_id=trace_id)

View File

@ -7,7 +7,7 @@ from yuxi.channel.domain.model.shared.channel_type import ChannelType
class WebTranslator:
@staticmethod
def translate_message(raw: dict) -> UnifiedMessage:
def translate(raw: dict) -> UnifiedMessage:
return UnifiedMessage(
message_id=raw.get("id", ""),
channel_type=ChannelType.WEB,
@ -20,3 +20,7 @@ class WebTranslator:
metadata=raw.get("metadata", {}),
raw_payload=raw,
)
@staticmethod
def translate_message(raw: dict) -> UnifiedMessage:
return WebTranslator.translate(raw)

View File

@ -0,0 +1,34 @@
from __future__ import annotations
import hmac
import logging
from yuxi.channel.domain.port.channel_request_verifier_port import VerifyResult
logger = logging.getLogger(__name__)
class WebRequestVerifier:
def __init__(self, *, auth_token: str = "") -> None:
self._auth_token = auth_token
@property
def channel_type(self) -> str:
return "web"
@property
def enabled(self) -> bool:
return bool(self._auth_token)
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult:
if not self._auth_token:
return VerifyResult(passed=True, method="web_none", reason="no_auth_configured")
auth_header = headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if hmac.compare_digest(token, self._auth_token):
return VerifyResult(passed=True, method="web_bearer")
return VerifyResult(passed=False, method="web_bearer", reason="invalid_bearer_token")
return VerifyResult(passed=False, method="web_auth", reason="authentication_required")

View File

@ -17,10 +17,15 @@ from yuxi.channel.application.service.delivery_service import DeliveryService
from yuxi.channel.application.service.dispatch_service import DispatchService
from yuxi.channel.application.service.inbound_service import InboundService
from yuxi.channel.application.service.session_resolver import SessionResolver
from yuxi.channel.channels._registry import get_registered_channels
from yuxi.channel.channels._registry import get_channel_meta
from yuxi.channel.domain.port.agent_port import AgentPort
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.application.service.plugin_registry_app_service import PluginRegistryAppService
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.channel_request_verifier_port import ChannelRequestVerifierPort
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.port.content_filter_port import ContentFilterPort
from yuxi.channel.domain.port.event_publisher_port import EventPublisherPort
@ -28,8 +33,6 @@ from yuxi.channel.domain.port.metrics_port import MetricsPort
from yuxi.channel.domain.port.queue_port import QueuePort
from yuxi.channel.domain.port.rate_limit_port import RateLimitPort
from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort
from yuxi.channel.domain.repository.message_log_repository import MessageLogRepositoryPort
from yuxi.channel.domain.repository.message_repository import MessageRepositoryPort
@ -50,6 +53,7 @@ from yuxi.channel.infrastructure.messaging.redis_pubsub_publisher import RedisPu
from yuxi.channel.infrastructure.messaging.redis_pubsub_subscriber import RedisPubSubSubscriber
from yuxi.channel.infrastructure.messaging.redis_stream_queue import RedisStreamQueue
from yuxi.channel.infrastructure.metrics.prometheus_metrics import PrometheusMetricsAdapter
from yuxi.channel.infrastructure.persistence.repository.pg_agent_config_lookup import PgAgentConfigLookup
from yuxi.channel.infrastructure.persistence.repository.pg_binding_repository import PgBindingRepository
from yuxi.channel.infrastructure.persistence.repository.pg_message_log_repository import PgMessageLogRepository
from yuxi.channel.infrastructure.persistence.repository.pg_message_repository import PgMessageRepository
@ -142,6 +146,7 @@ class ChannelContainer:
outbox_worker: OutboxRetryWorker
session_factory: WorkerSessionFactory
adapters: dict[str, ChannelAdapterPort] = field(default_factory=dict)
verifiers: dict[str, ChannelRequestVerifierPort] = field(default_factory=dict)
sse_endpoint: SseEndpoint | None = None
ws_manager: WsConnectionManager | None = None
binding_repo: BindingRepositoryPort | None = None
@ -162,6 +167,25 @@ class ChannelContainer:
startup_tracer: StartupTracer = field(default_factory=StartupTracer)
redis: aioredis.Redis | None = None
pubsub_subscriber: RedisPubSubSubscriber | None = None
_plugin_registry: PluginRegistry | None = field(default=None, repr=False, compare=False)
plugin_registry_impl: object | None = None
plugin_app_service: PluginRegistryAppService | None = None
def get_adapters_snapshot(self) -> dict[str, ChannelAdapterPort]:
if self._plugin_registry:
return self._plugin_registry.list_adapters()
return dict(self.adapters)
def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None:
if self._plugin_registry:
return self._plugin_registry.get_adapter(channel_type)
return self.adapters.get(channel_type)
def require(self, attr_name: str) -> object:
value = getattr(self, attr_name, None)
if value is None:
raise HTTPException(status_code=503, detail=f"{attr_name} not initialized")
return value
async def shutdown(self) -> None:
async def _safe(coro, label: str):
@ -190,7 +214,7 @@ class ChannelContainer:
await _safe(self.ws_manager.stop_all(), "ws_connections")
if self.sse_endpoint:
await _safe(self.sse_endpoint.stop(), "sse_endpoint")
for name, adapter in self.adapters.items():
for name, adapter in self.get_adapters_snapshot().items():
await _safe(adapter.close(), f"adapter:{name}")
if self.redis:
await _safe(self.redis.aclose(), "redis")
@ -228,6 +252,7 @@ class ChannelContainerFactory:
mq_workers: int = 4,
mq_max_concurrent: int = 20,
default_agent_config_id: int = 1,
plugin_registry_impl: object | None = None,
) -> ChannelContainer:
tracer = StartupTracer()
@ -252,14 +277,29 @@ class ChannelContainerFactory:
tracer.end()
tracer.begin("adapters")
adapters, sse_endpoint = await ChannelContainerFactory._build_adapters(config, infra)
adapters, sse_endpoint = await ChannelContainerFactory._build_adapters(
config,
infra,
plugin_registry_impl=plugin_registry_impl,
)
tracer.end()
ChannelContainerFactory._inject_bot_ids(pipeline, adapters)
for warning in ChannelContainerFactory._validate_channel_consistency(adapters, config):
logger.warning(warning)
tracer.begin("verifiers")
verifiers = ChannelContainerFactory._build_verifiers(config, adapters)
tracer.end()
tracer.begin("ws_connections")
ws_manager = await ChannelContainerFactory._build_ws_connections(
adapters, pipeline, tracer, metrics=infra.metrics
adapters,
pipeline,
tracer,
metrics=infra.metrics,
registry=plugin_registry_impl.registry if plugin_registry_impl else None,
)
tracer.end()
@ -274,6 +314,8 @@ class ChannelContainerFactory:
default_agent_config_id=default_agent_config_id,
mq_workers=mq_workers,
mq_max_concurrent=mq_max_concurrent,
registry=plugin_registry_impl.registry if plugin_registry_impl else None,
auth_service=auth_service,
)
await workers.worker_pool.start()
await workers.outbox_worker.start()
@ -291,6 +333,7 @@ class ChannelContainerFactory:
container = ChannelContainer(
pipeline=pipeline,
adapters=adapters,
verifiers=verifiers,
inbound_service=inbound_service,
dispatch_service=workers.dispatch_service,
delivery_service=workers.dispatch_service.delivery_service,
@ -324,6 +367,14 @@ class ChannelContainerFactory:
pubsub_subscriber=pubsub_subscriber,
)
if plugin_registry_impl is not None:
container._plugin_registry = plugin_registry_impl.registry
container.plugin_registry_impl = plugin_registry_impl
container.plugin_app_service = PluginRegistryAppService(
registry_impl=plugin_registry_impl,
event_publisher=infra.event_publisher,
)
if infra.config_reload:
async def _on_config_change(config: dict):
@ -406,54 +457,100 @@ class ChannelContainerFactory:
async def _build_adapters(
config: ChannelConfig,
infra: _InfraBundle,
*,
plugin_registry_impl: object | None = None,
) -> tuple[dict[str, ChannelAdapterPort], SseEndpoint]:
import yuxi.channel.channels # noqa: F401
from yuxi.channel.channels._registry import get_registered_channels
sse_endpoint = SseEndpoint(metrics=infra.metrics)
await sse_endpoint.start()
infra_bundle: dict[str, object] = {
"sse_push": sse_endpoint,
"hook_mappings": config.get_channel_config("hooks").get("mappings", []),
}
adapters: dict[str, ChannelAdapterPort] = {}
for name, cls in get_registered_channels().items():
default_config = cls.get_default_config()
override = config.get_channel_config(name)
merged = {**default_config, **override}
if name == "web":
merged["sse_push"] = sse_endpoint
if plugin_registry_impl is not None:
from yuxi.channel.domain.service.plugin_registry_domain_service import PluginRegistryDomainService
if name == "hooks":
hook_mappings = config.hook_mappings
if hook_mappings:
merged["mappings"] = hook_mappings
registry: PluginRegistry = plugin_registry_impl.registry
env_overrides = ChannelContainerFactory._apply_env_overrides(name, merged)
merged.update(env_overrides)
for record in registry.list_records():
if not record.source.enabled:
continue
adapter = cls(**merged)
await adapter.open()
adapters[name] = adapter
default_config = record.get_default_config()
override = config.get_channel_config(record.channel_type)
merged = {**default_config, **override}
meta = get_channel_meta(record.channel_type)
for dep in meta.get("infra_dependencies", []):
if dep in infra_bundle and dep not in merged:
merged[dep] = infra_bundle[dep]
env_mapping = meta.get("env_mapping", {})
if env_mapping:
env_overrides = ChannelContainerFactory._apply_env_overrides_generic(env_mapping)
merged.update(env_overrides)
record.set_config(merged)
PluginRegistryDomainService.transition_to_configured(record)
adapter = await registry.activate(record.plugin_id, **merged)
assert isinstance(adapter, ChannelAdapterPort), (
f"{type(adapter).__name__} does not implement ChannelAdapterPort"
)
adapters = registry.list_adapters()
else:
for name, cls in get_registered_channels().items():
meta = get_channel_meta(name)
default_config = cls.get_default_config()
override = config.get_channel_config(name)
merged = {**default_config, **override}
for dep in meta.get("infra_dependencies", []):
if dep in infra_bundle and dep not in merged:
merged[dep] = infra_bundle[dep]
env_mapping = meta.get("env_mapping", {})
if env_mapping:
env_overrides = ChannelContainerFactory._apply_env_overrides_generic(env_mapping)
merged.update(env_overrides)
adapter = cls(**merged)
assert isinstance(adapter, ChannelAdapterPort), f"{cls.__name__} does not implement ChannelAdapterPort"
await adapter.open()
adapters[name] = adapter
return adapters, sse_endpoint
@staticmethod
def _apply_env_overrides(channel_name: str, config: dict) -> dict:
def _apply_env_overrides_generic(env_mapping: dict[str, str]) -> dict:
overrides: dict = {}
if channel_name == "feishu":
app_id = os.getenv("FEISHU_APP_ID", "")
app_secret = os.getenv("FEISHU_APP_SECRET", "")
if app_id:
overrides["app_id"] = app_id
if app_secret:
overrides["app_secret"] = app_secret
elif channel_name == "dingtalk":
client_id = os.getenv("DINGTALK_CLIENT_ID", "")
client_secret = os.getenv("DINGTALK_CLIENT_SECRET", "")
if client_id:
overrides["client_id"] = client_id
if client_secret:
overrides["client_secret"] = client_secret
for env_var, config_key in env_mapping.items():
value = os.getenv(env_var, "")
if value:
overrides[config_key] = value
return overrides
@staticmethod
def _validate_channel_consistency(adapters: dict, config: ChannelConfig) -> list[str]:
from yuxi.channel.domain.model.shared.channel_type import ChannelType
warnings_list: list[str] = []
valid_types = {t.value for t in ChannelType}
for name in adapters:
if name not in valid_types:
warnings_list.append(f"adapter '{name}' not in ChannelType enum")
if not config.get_channel_config(name):
warnings_list.append(f"no config section for channel '{name}' in YAML")
return warnings_list
@staticmethod
def _inject_bot_ids(pipeline: Pipeline, adapters: dict[str, ChannelAdapterPort]) -> None:
from yuxi.channel.application.pipeline.middlewares.mention_gate_middleware import MentionGateMiddleware
@ -466,15 +563,39 @@ class ChannelContainerFactory:
middleware.set_bot_id(bot_id)
break
@staticmethod
def _build_verifiers(
config: ChannelConfig,
adapters: dict[str, ChannelAdapterPort],
) -> dict[str, ChannelRequestVerifierPort]:
from yuxi.channel.channels._registry import get_channel_meta
verifiers: dict[str, ChannelRequestVerifierPort] = {}
for name in adapters:
meta = get_channel_meta(name)
verifier_factory = meta.get("verifier_factory")
if verifier_factory:
channel_config = config.get_channel_config(name)
verifiers[name] = verifier_factory(channel_config)
for name, verifier in verifiers.items():
if not verifier.enabled:
logger.warning("channel '%s' is running without request verification", name)
return verifiers
@staticmethod
async def _build_ws_connections(
adapters: dict[str, ChannelAdapterPort],
pipeline: Pipeline,
tracer: StartupTracer,
metrics: MetricsPort | None = None,
*,
registry: PluginRegistry | None = None,
) -> WsConnectionManager:
ws_manager = WsConnectionManager(metrics=metrics)
ws_manager.set_adapters(adapters)
ws_manager = WsConnectionManager(metrics=metrics, registry=registry)
if not registry:
ws_manager.set_adapters(adapters)
for name, adapter in adapters.items():
ws = adapter.ws_connection
@ -499,6 +620,8 @@ class ChannelContainerFactory:
default_agent_config_id: int = 1,
mq_workers: int = 4,
mq_max_concurrent: int = 20,
registry: PluginRegistry | None = None,
auth_service: AuthService | None = None,
) -> _WorkerBundle:
session_factory = WorkerSessionFactory(pg_manager)
db_session_factory = pg_manager.get_async_session_context
@ -509,7 +632,7 @@ class ChannelContainerFactory:
infra.cache_port,
agent_id_resolver=lambda aid: _resolve_agent_id(db_session_factory, aid),
)
outbox_repo = PgOutboxRepository(db_session_factory, redis)
outbox_repo = PgOutboxRepository(db_session_factory, infra.cache_port)
message_repo = PgMessageRepository(db_session_factory)
message_log_repo = PgMessageLogRepository(db_session_factory)
@ -523,6 +646,7 @@ class ChannelContainerFactory:
delivery_service = DeliveryService(
adapters=adapters,
registry=registry,
message_repo=message_repo,
outbox_repo=outbox_repo,
event_publisher=infra.event_publisher,
@ -547,14 +671,15 @@ class ChannelContainerFactory:
config=WorkerPoolConfig(num_workers=mq_workers, max_concurrent=mq_max_concurrent),
)
outbox_worker = OutboxRetryWorker(outbox_repo, adapters, redis, metrics=infra.metrics)
outbox_worker = OutboxRetryWorker(outbox_repo, adapters, redis, metrics=infra.metrics, registry=registry)
binding_service = BindingService(binding_repo)
binding_service = BindingService(binding_repo, PgAgentConfigLookup(db_session_factory))
config_service = ConfigService(
config_data=config.raw_data,
config_reload=infra.config_reload,
pipeline=pipeline,
channel_config=config,
auth_service=auth_service,
)
return _WorkerBundle(

View File

@ -0,0 +1,17 @@
from yuxi.channel.domain.event.plugin.plugin_activated_event import (
PluginActivatedEvent as PluginActivatedEvent,
)
from yuxi.channel.domain.event.plugin.plugin_deactivated_event import (
PluginDeactivatedEvent as PluginDeactivatedEvent,
)
from yuxi.channel.domain.event.plugin.plugin_reload_event import PluginReloadedEvent as PluginReloadedEvent
from yuxi.channel.domain.event.plugin.plugin_registered_event import (
PluginRegisteredEvent as PluginRegisteredEvent,
)
__all__ = [
"PluginRegisteredEvent",
"PluginActivatedEvent",
"PluginDeactivatedEvent",
"PluginReloadedEvent",
]

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PluginActivatedEvent:
plugin_id: str
channel_type: str

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PluginDeactivatedEvent:
plugin_id: str
channel_type: str

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PluginRegisteredEvent:
plugin_id: str
channel_type: str

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class PluginReloadedEvent:
plugin_id: str
old_version: str
new_version: str
channel_type: str

View File

@ -1,5 +1,49 @@
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError
from yuxi.channel.domain.exception.channel_error import ChannelError
from yuxi.channel.domain.exception.recoverable_error import RecoverableError
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError
from yuxi.channel.domain.exception.abort_mapping import ABORT_CODE_TO_HTTP as ABORT_CODE_TO_HTTP
from yuxi.channel.domain.exception.agent_crash_error import AgentCrashError as AgentCrashError
from yuxi.channel.domain.exception.channel_error import ChannelError as ChannelError
from yuxi.channel.domain.exception.plugin_exception import (
ActiveSessionExistsException as ActiveSessionExistsException,
ConfigNotFoundException as ConfigNotFoundException,
ConfigValidationException as ConfigValidationException,
DuplicatePluginException as DuplicatePluginException,
InvalidManifestException as InvalidManifestException,
InvalidPluginStateException as InvalidPluginStateException,
PluginAdapterNotFoundException as PluginAdapterNotFoundException,
PluginAlreadyActivatedException as PluginAlreadyActivatedException,
PluginAlreadyRegisteredException as PluginAlreadyRegisteredException,
PluginException as PluginException,
PluginNotFoundException as PluginNotFoundException,
PluginNotConfiguredException as PluginNotConfiguredException,
PluginRegistrationException as PluginRegistrationException,
PluginReloadException as PluginReloadException,
RegistryLockedException as RegistryLockedException,
SnapshotException as SnapshotException,
)
from yuxi.channel.domain.exception.recoverable_error import RecoverableError as RecoverableError
from yuxi.channel.domain.exception.session_exception import SessionNotFoundError as SessionNotFoundError
from yuxi.channel.domain.exception.unrecoverable_error import UnrecoverableError as UnrecoverableError
__all__ = [
"ABORT_CODE_TO_HTTP",
"ActiveSessionExistsException",
"AgentCrashError",
"ChannelError",
"ConfigNotFoundException",
"ConfigValidationException",
"DuplicatePluginException",
"InvalidManifestException",
"InvalidPluginStateException",
"PluginAdapterNotFoundException",
"PluginAlreadyActivatedException",
"PluginAlreadyRegisteredException",
"PluginException",
"PluginNotFoundException",
"PluginNotConfiguredException",
"PluginRegistrationException",
"PluginReloadException",
"RegistryLockedException",
"RecoverableError",
"SessionNotFoundError",
"SnapshotException",
"UnrecoverableError",
]

View File

@ -0,0 +1,7 @@
from __future__ import annotations
class AgentConfigNotFoundException(Exception):
def __init__(self, agent_config_id: int):
self.agent_config_id = agent_config_id
super().__init__(f"agent config not found: {agent_config_id}")

View File

@ -0,0 +1,9 @@
from __future__ import annotations
class DuplicateBindingException(Exception):
def __init__(self, channel_type: str, account_id: str, group_id: str):
self.channel_type = channel_type
self.account_id = account_id
self.group_id = group_id
super().__init__(f"binding already exists: {channel_type}/{account_id}/{group_id}")

View File

@ -0,0 +1,92 @@
from __future__ import annotations
class PluginException(Exception):
def __init__(self, message: str = "", plugin_id: str = ""):
self.plugin_id = plugin_id
super().__init__(message)
class DuplicatePluginException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin already exists: {plugin_id}", plugin_id)
class PluginNotFoundException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin not found: {plugin_id}", plugin_id)
class PluginAlreadyRegisteredException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin already registered: {plugin_id}", plugin_id)
class PluginAlreadyActivatedException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin already activated: {plugin_id}", plugin_id)
class PluginNotConfiguredException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin not configured: {plugin_id}", plugin_id)
class PluginAdapterNotFoundException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"adapter class not found for plugin: {plugin_id}", plugin_id)
class PluginRegistrationException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin registration failed: {plugin_id}", plugin_id)
class PluginReloadException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"plugin reload failed: {plugin_id}", plugin_id)
class InvalidManifestException(PluginException):
def __init__(self, message: str):
super().__init__(f"invalid manifest: {message}")
class RegistryLockedException(PluginException):
def __init__(self):
super().__init__("registry is locked")
class SnapshotException(PluginException):
def __init__(self, message: str = "snapshot operation failed"):
super().__init__(message)
class ConfigValidationException(PluginException):
def __init__(self, plugin_id: str, message: str):
super().__init__(f"config validation failed for {plugin_id}: {message}", plugin_id)
class ConfigNotFoundException(PluginException):
def __init__(self, plugin_id: str):
super().__init__(f"config not found for plugin: {plugin_id}", plugin_id)
class InvalidPluginStateException(PluginException):
def __init__(self, plugin_id: str, current_status: str, target_action: str):
super().__init__(
f"cannot {target_action} plugin {plugin_id} in {current_status} state",
plugin_id,
)
self.current_status = current_status
self.target_action = target_action
class ActiveSessionExistsException(PluginException):
def __init__(self, plugin_id: str, channel_type: str, active_count: int):
super().__init__(
f"channel {channel_type} has {active_count} active sessions, graceful deactivate required",
plugin_id,
)
self.channel_type = channel_type
self.active_count = active_count

View File

@ -0,0 +1,9 @@
from __future__ import annotations
from yuxi.channel.domain.exception.channel_error import ChannelError
class SessionNotFoundError(ChannelError):
def __init__(self, thread_id: str):
self.thread_id = thread_id
super().__init__(f"Session not found for thread_id: {thread_id}")

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
@ -14,8 +15,8 @@ class ChannelBinding:
is_enabled: bool = True
created_by: str | None = None
updated_by: str | None = None
created_at: str = ""
updated_at: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
def resolve_session_key_strategy(self) -> str:
if self.session_key_strategy != "auto":

View File

@ -1 +1,2 @@
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry as OutboxEntry
from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus as OutboxStatus

View File

@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
@dataclass
@ -13,7 +14,7 @@ class OutboxEntry:
status: str
retry_count: int
max_retries: int
next_retry_at: str | None
next_retry_at: datetime | None
last_error: str | None
trace_id: str | None
extra_metadata: dict | None

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from enum import StrEnum
class OutboxStatus(StrEnum):
PENDING = "pending"
PROCESSING = "processing"
RETRYING = "retrying"
SENT = "sent"
FAILED = "failed"
DEAD = "dead"

View File

@ -0,0 +1,23 @@
from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest as PluginManifest
from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord as PluginRecord
from yuxi.channel.domain.model.plugin_registry.plugin_registration_mode import (
PluginRegistrationMode as PluginRegistrationMode,
)
from yuxi.channel.domain.model.plugin_registry.plugin_registry import PluginRegistry as PluginRegistry
from yuxi.channel.domain.model.plugin_registry.plugin_source import (
PluginOrigin as PluginOrigin,
)
from yuxi.channel.domain.model.plugin_registry.plugin_source import (
PluginSource as PluginSource,
)
from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus as PluginStatus
__all__ = [
"PluginManifest",
"PluginRecord",
"PluginRegistrationMode",
"PluginRegistry",
"PluginOrigin",
"PluginSource",
"PluginStatus",
]

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from dataclasses import dataclass, field
from yuxi.channel.domain.exception.plugin_exception import InvalidManifestException
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
@dataclass(frozen=True)
class PluginManifest:
id: str
name: str
version: str
channel_type: str
capabilities: ChannelCapabilities
config_schema: dict | None = None
entry: str = ""
dependencies: list[str] = field(default_factory=list)
def __post_init__(self):
if not self.id or not self.channel_type:
raise InvalidManifestException("plugin id and channel_type are required")
if not self.version:
object.__setattr__(self, "version", "0.0.0")

View File

@ -0,0 +1,54 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class PluginPersistentRecord:
id: int | None = None
plugin_id: str = ""
name: str = ""
version: str = "0.0.0"
channel_type: str = ""
origin: str = ""
status: str = "discovered"
root_dir: str | None = None
entry_file: str | None = None
manifest: dict = field(default_factory=dict)
capabilities: dict = field(default_factory=dict)
config_schema: dict | None = None
config: dict = field(default_factory=dict)
error: str | None = None
is_enabled: bool = True
registered_at: datetime | None = None
activated_at: datetime | None = None
created_by: str | None = None
updated_by: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
@dataclass
class PluginConfigHistory:
id: int | None = None
plugin_id: str = ""
config: dict = field(default_factory=dict)
changed_by: str | None = None
change_reason: str | None = None
created_at: datetime | None = None
@dataclass
class PluginAuditLog:
id: int | None = None
plugin_id: str = ""
action: str = ""
old_status: str | None = None
new_status: str | None = None
old_version: str | None = None
new_version: str | None = None
detail: dict | None = None
error_message: str | None = None
created_by: str | None = None
created_at: datetime | None = None

View File

@ -0,0 +1,97 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import UTC, datetime
from yuxi.channel.domain.exception.plugin_exception import (
PluginAdapterNotFoundException,
PluginAlreadyRegisteredException,
)
from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest
from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginSource
from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
@dataclass
class PluginRecordId:
value: str = field(default_factory=lambda: uuid.uuid4().hex)
@staticmethod
def generate() -> PluginRecordId:
return PluginRecordId()
@dataclass
class PluginRecord:
record_id: PluginRecordId
plugin_id: str
name: str
version: str
channel_type: str
source: PluginSource
manifest: PluginManifest
capabilities: ChannelCapabilities
config_schema: dict | None = None
status: PluginStatus = PluginStatus.DISCOVERED
adapter_class: type | None = None
config: dict = field(default_factory=dict)
error: str | None = None
registered_at: datetime | None = None
@property
def is_configured(self) -> bool:
if not self.config_schema:
return True
required = self.config_schema.get("required", [])
return all(key in self.config for key in required)
def set_config(self, config: dict) -> None:
self.config = config
def set_adapter_class(self, adapter_cls: type) -> None:
self.adapter_class = adapter_cls
def get_default_config(self) -> dict:
if self.adapter_class and hasattr(self.adapter_class, "get_default_config"):
return self.adapter_class.get_default_config()
return {}
def build_adapter(self, **config) -> ChannelAdapterPort:
if not self.adapter_class:
raise PluginAdapterNotFoundException(self.plugin_id)
merged = {**self.config, **config}
return self.adapter_class(**merged)
def register(self, api: object) -> None:
if self.status != PluginStatus.DISCOVERED:
raise PluginAlreadyRegisteredException(self.plugin_id)
self.status = PluginStatus.REGISTERED
self.registered_at = datetime.now(UTC)
def mark_error(self, error: str) -> None:
self.error = error
self.status = PluginStatus.ERROR
def to_snapshot(self) -> dict:
return {
"plugin_id": self.plugin_id,
"name": self.name,
"version": self.version,
"channel_type": self.channel_type,
"status": self.status.value,
"source": self.source.origin.value,
"is_configured": self.is_configured,
"capabilities": {
"media": self.capabilities.media,
"group": self.capabilities.group,
"dm": self.capabilities.dm,
"streaming": self.capabilities.streaming,
"typing": self.capabilities.typing,
"reaction": self.capabilities.reaction,
"thread": self.capabilities.thread,
"max_text_length": self.capabilities.max_text_length,
},
}

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from enum import StrEnum
class PluginRegistrationMode(StrEnum):
FULL = "full"
DISCOVERY = "discovery"
SETUP_ONLY = "setup_only"
CLI_METADATA = "cli_metadata"

View File

@ -0,0 +1,173 @@
from __future__ import annotations
import asyncio
import copy
import uuid
from dataclasses import dataclass
from yuxi.channel.domain.exception.plugin_exception import (
DuplicatePluginException,
PluginAlreadyActivatedException,
PluginNotConfiguredException,
PluginNotFoundException,
)
from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord
from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
@dataclass
class RegistrySnapshot:
plugins: dict[str, PluginRecord]
adapters: dict[str, ChannelAdapterPort]
@dataclass
class PluginSpec:
origin: str | None = None
status: PluginStatus | None = None
def is_satisfied_by(self, record: PluginRecord) -> bool:
if self.origin and record.source.origin.value != self.origin:
return False
if self.status and record.status != self.status:
return False
return True
class PluginRegistry:
def __init__(self) -> None:
self._registry_id: str = uuid.uuid4().hex
self._plugins: dict[str, PluginRecord] = {}
self._adapters: dict[str, ChannelAdapterPort] = {}
self._lock = asyncio.Lock()
self._plugin_locks: dict[str, asyncio.Lock] = {}
@property
def registry_id(self) -> str:
return self._registry_id
def _get_plugin_lock(self, plugin_id: str) -> asyncio.Lock:
lock = self._plugin_locks.get(plugin_id)
if lock is None:
lock = asyncio.Lock()
self._plugin_locks[plugin_id] = lock
return lock
async def register(self, record: PluginRecord, api: object | None = None) -> None:
async with self._lock:
if record.plugin_id in self._plugins:
raise DuplicatePluginException(record.plugin_id)
snapshot = self._snapshot()
try:
if api is not None:
record.register(api)
else:
record.status = PluginStatus.REGISTERED
self._plugins[record.plugin_id] = record
except Exception:
self._restore(snapshot)
raise
def register_builtin(self, record: PluginRecord) -> None:
if record.plugin_id in self._plugins:
return
record.status = PluginStatus.REGISTERED
self._plugins[record.plugin_id] = record
async def activate(self, plugin_id: str, **config) -> ChannelAdapterPort:
async with self._lock:
async with self._get_plugin_lock(plugin_id):
record = self._get_plugin(plugin_id)
if not record.is_configured:
raise PluginNotConfiguredException(plugin_id)
if record.channel_type in self._adapters:
raise PluginAlreadyActivatedException(plugin_id)
adapter = record.build_adapter(**config)
await adapter.open()
self._adapters[record.channel_type] = adapter
record.status = PluginStatus.ACTIVE
return adapter
async def deactivate(self, plugin_id: str) -> None:
async with self._lock:
async with self._get_plugin_lock(plugin_id):
record = self._get_plugin(plugin_id)
adapter = self._adapters.pop(record.channel_type, None)
if adapter:
try:
await adapter.close()
except Exception:
pass
record.status = PluginStatus.DISABLED
async def reload(self, plugin_id: str, **config) -> ChannelAdapterPort:
async with self._lock:
async with self._get_plugin_lock(plugin_id):
record = self._get_plugin(plugin_id)
snapshot = self._deep_snapshot()
old_adapter = self._adapters.pop(record.channel_type, None)
if old_adapter:
try:
await old_adapter.close()
except Exception:
pass
record.status = PluginStatus.CONFIGURED
try:
new_adapter = record.build_adapter(**config)
await new_adapter.open()
self._adapters[record.channel_type] = new_adapter
record.status = PluginStatus.ACTIVE
return new_adapter
except Exception:
self._restore(snapshot)
raise
def get_adapter(self, channel_type: str) -> ChannelAdapterPort | None:
return self._adapters.get(channel_type)
def get_record(self, plugin_id: str) -> PluginRecord | None:
return self._plugins.get(plugin_id)
def list_records(self, spec: PluginSpec | None = None) -> list[PluginRecord]:
records = list(self._plugins.values())
if spec:
records = [r for r in records if spec.is_satisfied_by(r)]
return records
def list_adapters(self) -> dict[str, ChannelAdapterPort]:
return dict(self._adapters)
def _get_plugin(self, plugin_id: str) -> PluginRecord:
record = self._plugins.get(plugin_id)
if not record:
raise PluginNotFoundException(plugin_id)
return record
def snapshot(self) -> RegistrySnapshot:
return self._snapshot()
def restore(self, snapshot: RegistrySnapshot) -> None:
self._restore(snapshot)
def _snapshot(self) -> RegistrySnapshot:
return RegistrySnapshot(
plugins=dict(self._plugins),
adapters=dict(self._adapters),
)
def _deep_snapshot(self) -> RegistrySnapshot:
return RegistrySnapshot(
plugins={k: copy.copy(v) for k, v in self._plugins.items()},
adapters=dict(self._adapters),
)
def _restore(self, snapshot: RegistrySnapshot) -> None:
self._plugins = snapshot.plugins
self._adapters = snapshot.adapters

View File

@ -0,0 +1,67 @@
from __future__ import annotations
from yuxi.channel.domain.model.plugin_registry.plugin_manifest import PluginManifest
from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord, PluginRecordId
from yuxi.channel.domain.model.plugin_registry.plugin_source import PluginOrigin, PluginSource
from yuxi.channel.domain.model.shared.channel_capabilities import ChannelCapabilities
class PluginRegistryFactory:
@staticmethod
def create_from_manifest(
manifest: PluginManifest,
source: PluginSource,
adapter_class: type | None = None,
) -> PluginRecord:
record = PluginRecord(
record_id=PluginRecordId.generate(),
plugin_id=manifest.id,
name=manifest.name,
version=manifest.version,
channel_type=manifest.channel_type,
source=source,
manifest=manifest,
capabilities=manifest.capabilities,
config_schema=manifest.config_schema,
)
if adapter_class is not None:
record.set_adapter_class(adapter_class)
return record
@staticmethod
def create_builtin(
plugin_id: str,
name: str,
channel_type: str,
adapter_class: type,
capabilities: ChannelCapabilities | None = None,
) -> PluginRecord:
caps = capabilities or ChannelCapabilities()
manifest = PluginManifest(
id=plugin_id,
name=name,
version="1.0.0",
channel_type=channel_type,
capabilities=caps,
entry="",
)
source = PluginSource(
origin=PluginOrigin.BUNDLED,
root_dir=f"channels/{channel_type}",
entry_file=f"channels/{channel_type}/adapter.py",
)
record = PluginRecord(
record_id=PluginRecordId.generate(),
plugin_id=plugin_id,
name=name,
version="1.0.0",
channel_type=channel_type,
source=source,
manifest=manifest,
capabilities=caps,
)
record.set_adapter_class(adapter_class)
return record

View File

@ -0,0 +1,18 @@
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
class PluginOrigin(StrEnum):
BUNDLED = "bundled"
WORKSPACE = "workspace"
INSTALLED = "installed"
@dataclass(frozen=True)
class PluginSource:
origin: PluginOrigin
root_dir: str
entry_file: str
enabled: bool = True

View File

@ -0,0 +1,12 @@
from __future__ import annotations
from enum import StrEnum
class PluginStatus(StrEnum):
DISCOVERED = "discovered"
REGISTERED = "registered"
CONFIGURED = "configured"
ACTIVE = "active"
ERROR = "error"
DISABLED = "disabled"

View File

@ -1,16 +1,26 @@
from yuxi.channel.domain.port.agent_port import AgentPort
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort
from yuxi.channel.domain.port.channel_route_contributor import ChannelRouteContributor
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort
from yuxi.channel.domain.port.content_filter_port import ContentFilterPort, FilterResult
from yuxi.channel.domain.port.event_publisher_port import DomainEvent, EventPublisherPort
from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort
from yuxi.channel.domain.port.metrics_port import MetricsPort
from yuxi.channel.domain.port.queue_port import QueuePort
from yuxi.channel.domain.port.rate_limit_port import RateLimitPort
from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort
from yuxi.channel.domain.port.sse_push_port import SsePushPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort
from yuxi.channel.domain.port.agent_port import AgentPort as AgentPort
from yuxi.channel.domain.port.bot_loop_guard_port import BotLoopGuardPort as BotLoopGuardPort
from yuxi.channel.domain.port.cache_port import CachePort as CachePort
from yuxi.channel.domain.port.channel_adapter_port import ChannelAdapterPort as ChannelAdapterPort
from yuxi.channel.domain.port.channel_request_verifier_port import (
ChannelRequestVerifierPort as ChannelRequestVerifierPort,
VerifyResult as VerifyResult,
)
from yuxi.channel.interfaces.rest.router.contributor import ChannelRouteContributor as ChannelRouteContributor
from yuxi.channel.domain.port.circuit_breaker_port import CircuitBreakerPort as CircuitBreakerPort
from yuxi.channel.domain.port.config_reload_port import ConfigReloadPort as ConfigReloadPort
from yuxi.channel.domain.port.content_filter_port import (
ContentFilterPort as ContentFilterPort,
FilterResult as FilterResult,
)
from yuxi.channel.domain.port.event_publisher_port import (
DomainEvent as DomainEvent,
EventPublisherPort as EventPublisherPort,
)
from yuxi.channel.domain.port.keyword_matcher_port import KeywordMatcherPort as KeywordMatcherPort
from yuxi.channel.domain.port.metrics_port import MetricsPort as MetricsPort
from yuxi.channel.domain.port.queue_port import QueuePort as QueuePort
from yuxi.channel.domain.port.rate_limit_port import RateLimitPort as RateLimitPort
from yuxi.channel.domain.port.signature_verify_port import SignatureVerifyPort as SignatureVerifyPort
from yuxi.channel.domain.port.sse_push_port import SsePushPort as SsePushPort
from yuxi.channel.domain.port.ws_connection_port import WsConnectionPort as WsConnectionPort

View File

@ -0,0 +1,8 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class AgentConfigLookupPort(Protocol):
async def exists(self, agent_config_id: int) -> bool: ...

View File

@ -18,3 +18,5 @@ class CachePort(Protocol):
async def ttl(self, key: str) -> int: ...
async def eval(self, script: str, keys: list[str], args: list[str | int]) -> tuple: ...
async def publish(self, channel: str, message: str) -> None: ...

View File

@ -0,0 +1,22 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
@dataclass(frozen=True)
class VerifyResult:
passed: bool
method: str = ""
reason: str = ""
@runtime_checkable
class ChannelRequestVerifierPort(Protocol):
@property
def channel_type(self) -> str: ...
@property
def enabled(self) -> bool: ...
async def verify(self, body: bytes, headers: dict[str, str]) -> VerifyResult: ...

View File

@ -40,3 +40,5 @@ class MetricsPort(Protocol):
async def set_circuit_breaker_state(self, agent_config_id: int, state: int) -> None: ...
async def record_circuit_breaker_rejected(self, agent_config_id: int) -> None: ...
async def record_verification_failed(self, channel_type: str, reason: str) -> None: ...

View File

@ -0,0 +1,10 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class PluginLoaderPort(Protocol):
async def load(self, entry_file: str) -> object: ...
def extract_adapter_class(self, module: object) -> type | None: ...

View File

@ -0,0 +1,11 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class PluginRegistrationApiPort(Protocol):
@property
def is_closed(self) -> bool: ...
def close(self) -> None: ...

View File

@ -1,12 +1,12 @@
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort as BindingRepositoryPort
from yuxi.channel.domain.repository.message_log_repository import (
MessageLogData,
MessageLogQuery,
MessageLogRepositoryPort,
MessageLogData as MessageLogData,
MessageLogQuery as MessageLogQuery,
MessageLogRepositoryPort as MessageLogRepositoryPort,
)
from yuxi.channel.domain.repository.message_repository import (
ChannelMessageData,
MessageRepositoryPort,
ChannelMessageData as ChannelMessageData,
MessageRepositoryPort as MessageRepositoryPort,
)
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort
from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort as OutboxRepositoryPort
from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort as SessionRepositoryPort

View File

@ -7,7 +7,9 @@ from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
@runtime_checkable
class BindingRepositoryPort(Protocol):
async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None: ...
async def find_binding(
self, *, channel_type: str, account_id: str, group_id: str, is_enabled: bool = True
) -> ChannelBinding | None: ...
async def create_binding(
self,
@ -35,5 +37,3 @@ class BindingRepositoryPort(Protocol):
async def list_bindings(
self, *, channel_type: str | None = None, offset: int = 0, limit: int = 50
) -> tuple[list[ChannelBinding], int]: ...
async def invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None: ...

View File

@ -58,6 +58,7 @@ class MessageLogRepositoryPort(Protocol):
trace_id: str,
message_id: str,
pipeline_result: str,
status: str,
abort_reason: str | None = None,
) -> bool: ...
@ -71,7 +72,7 @@ class MessageLogRepositoryPort(Protocol):
error_message: str | None = None,
processing_time_ms: int | None = None,
agent_config_id: int | None = None,
session_id: int | None = None,
session_id: str | None = None,
conversation_id: int | None = None,
) -> bool: ...
@ -79,4 +80,4 @@ class MessageLogRepositoryPort(Protocol):
async def get_by_message_id(self, message_id: str) -> list[MessageLogData]: ...
async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]: ...
async def query_logs(self, query: MessageLogQuery) -> tuple[list[MessageLogData], int]: ...

View File

@ -23,7 +23,9 @@ class MessageRepositoryPort(Protocol):
content: str,
message_id: str | None = None,
extra_metadata: dict | None = None,
) -> ChannelMessageData: ...
) -> ChannelMessageData:
"""保存用户消息。当 thread_id 对应的会话不存在时抛出 SessionNotFoundError。"""
...
async def save_assistant_message(
self,
@ -31,4 +33,6 @@ class MessageRepositoryPort(Protocol):
thread_id: str,
content: str,
extra_metadata: dict | None = None,
) -> ChannelMessageData: ...
) -> ChannelMessageData:
"""保存助手消息。当 thread_id 对应的会话不存在时抛出 SessionNotFoundError。"""
...

View File

@ -18,7 +18,7 @@ class OutboxRepositoryPort(Protocol):
extra_metadata: dict | None = None,
) -> OutboxEntry: ...
async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]: ...
async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list[OutboxEntry]: ...
async def get_by_id(self, entry_id: int) -> OutboxEntry | None: ...

View File

@ -0,0 +1,26 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import PluginAuditLog
@runtime_checkable
class PluginAuditLogRepositoryPort(Protocol):
async def save(self, log: PluginAuditLog) -> PluginAuditLog: ...
async def list_by_plugin_id(
self,
plugin_id: str,
*,
offset: int = 0,
limit: int = 20,
) -> tuple[list[PluginAuditLog], int]: ...
async def list_by_action(
self,
action: str,
*,
offset: int = 0,
limit: int = 50,
) -> tuple[list[PluginAuditLog], int]: ...

View File

@ -0,0 +1,20 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import PluginConfigHistory
@runtime_checkable
class PluginConfigHistoryRepositoryPort(Protocol):
async def save(self, history: PluginConfigHistory) -> PluginConfigHistory: ...
async def list_by_plugin_id(
self,
plugin_id: str,
*,
offset: int = 0,
limit: int = 20,
) -> tuple[list[PluginConfigHistory], int]: ...
async def get_latest(self, plugin_id: str) -> PluginConfigHistory | None: ...

View File

@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Protocol, runtime_checkable
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import (
PluginPersistentRecord,
)
@runtime_checkable
class PluginRepositoryPort(Protocol):
async def get_by_plugin_id(self, plugin_id: str) -> PluginPersistentRecord | None: ...
async def get_by_channel_type(self, channel_type: str) -> PluginPersistentRecord | None: ...
async def list_plugins(
self,
*,
origin: str | None = None,
status: str | None = None,
is_enabled: bool | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[list[PluginPersistentRecord], int]: ...
async def create(self, record: PluginPersistentRecord) -> PluginPersistentRecord: ...
async def update(self, record: PluginPersistentRecord) -> PluginPersistentRecord | None: ...
async def update_status(self, plugin_id: str, status: str, *, error: str | None = None) -> bool: ...
async def update_config(self, plugin_id: str, config: dict) -> bool: ...
async def soft_delete(self, plugin_id: str, *, updated_by: str | None = None) -> bool: ...
async def list_by_origins(self, origins: list[str]) -> list[PluginPersistentRecord]: ...
async def bulk_upsert(self, records: list[PluginPersistentRecord]) -> list[PluginPersistentRecord]: ...

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from yuxi.channel.domain.model.plugin_registry.plugin_record import PluginRecord
from yuxi.channel.domain.model.plugin_registry.plugin_status import PluginStatus
class PluginRegistryDomainService:
@staticmethod
def can_activate(record: PluginRecord) -> bool:
return record.status not in (PluginStatus.DISCOVERED, PluginStatus.ACTIVE)
@staticmethod
def can_deactivate(record: PluginRecord) -> bool:
return record.status == PluginStatus.ACTIVE
@staticmethod
def can_reload(record: PluginRecord) -> bool:
return record.status == PluginStatus.ACTIVE
@staticmethod
def transition_to_configured(record: PluginRecord) -> None:
if record.status not in (PluginStatus.REGISTERED, PluginStatus.ERROR, PluginStatus.DISABLED):
return
record.status = PluginStatus.CONFIGURED

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import warnings
from yuxi.utils.logging_config import logger
@ -28,11 +30,15 @@ class ChannelConfig:
async def on_config_updated(self, config: dict) -> list[str]:
old_auth_token = self._data.get("auth", {}).get("token")
old_auth_password = self._data.get("auth", {}).get("password")
self._data = config
updated = []
new_auth_token = config.get("auth", {}).get("token")
new_auth_password = config.get("auth", {}).get("password")
if old_auth_token != new_auth_token:
updated.append("auth_token")
if old_auth_password != new_auth_password:
updated.append("auth_password")
return updated
@property
@ -58,10 +64,12 @@ class ChannelConfig:
@property
def hook_mappings(self) -> list[dict]:
warnings.warn("use HooksConfig.mappings instead", DeprecationWarning, stacklevel=2)
return self._data.get("hooks", {}).get("mappings", [])
@property
def feishu_verification_token(self) -> str | None:
warnings.warn("use FeishuConfig.verification_token instead", DeprecationWarning, stacklevel=2)
return self._data.get("feishu", {}).get("verification_token")
@property
@ -78,6 +86,7 @@ class ChannelConfig:
@property
def feishu_ws_config(self) -> dict | None:
warnings.warn("use FeishuConfig.ws_config instead", DeprecationWarning, stacklevel=2)
feishu = self._data.get("feishu", {})
mode = feishu.get("mode", "webhook")
if mode not in ("websocket", "both"):
@ -90,6 +99,7 @@ class ChannelConfig:
@property
def dingtalk_ws_config(self) -> dict | None:
warnings.warn("use DingTalkConfig.ws_config instead", DeprecationWarning, stacklevel=2)
dingtalk = self._data.get("dingtalk", {})
mode = dingtalk.get("mode", "webhook")
if mode not in ("websocket", "both"):

View File

@ -124,6 +124,12 @@ CIRCUIT_BREAKER_REJECTED = Counter(
["agent_config_id"],
)
VERIFICATION_FAILURES = Counter(
"channel_verification_failures_total",
"Total number of verification failures",
["channel_type", "reason"],
)
WS_CONNECTION_STATUS = Gauge(
"channel_ws_connection_status",
"WebSocket connection status (0=disconnected, 1=connected)",
@ -251,3 +257,9 @@ class PrometheusMetricsAdapter(MetricsPort):
CIRCUIT_BREAKER_REJECTED.labels(agent_config_id=str(agent_config_id)).inc()
except Exception:
logger.debug("failed to record circuit_breaker_rejected metric")
async def record_verification_failed(self, channel_type: str, reason: str) -> None:
try:
VERIFICATION_FAILURES.labels(channel_type=channel_type, reason=reason).inc()
except Exception:
logger.debug("failed to record verification_failed metric")

View File

@ -11,10 +11,10 @@ def to_domain(row: ChannelBindingModel) -> ChannelBinding:
account_id=row.account_id,
group_id=row.group_id,
agent_config_id=row.agent_config_id,
session_key_strategy=getattr(row, "session_key_strategy", "auto"),
session_key_strategy=row.session_key_strategy,
is_enabled=bool(row.is_enabled),
created_by=row.created_by,
updated_by=row.updated_by,
created_at=row.created_at.isoformat() if row.created_at else "",
updated_at=row.updated_at.isoformat() if row.updated_at else "",
created_at=row.created_at,
updated_at=row.updated_at,
)

View File

@ -0,0 +1,107 @@
from __future__ import annotations
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import (
PluginAuditLog,
PluginConfigHistory,
PluginPersistentRecord,
)
from yuxi.storage.postgres.models_channel import (
ChannelPlugin,
ChannelPluginAuditLog,
ChannelPluginConfigHistory,
)
def to_plugin_domain(row: ChannelPlugin) -> PluginPersistentRecord:
return PluginPersistentRecord(
id=row.id,
plugin_id=row.plugin_id,
name=row.name,
version=row.version,
channel_type=row.channel_type,
origin=row.origin,
status=row.status,
root_dir=row.root_dir,
entry_file=row.entry_file,
manifest=row.manifest or {},
capabilities=row.capabilities or {},
config_schema=row.config_schema,
config=row.config or {},
error=row.error,
is_enabled=bool(row.is_enabled),
registered_at=row.registered_at,
activated_at=row.activated_at,
created_by=row.created_by,
updated_by=row.updated_by,
created_at=row.created_at,
updated_at=row.updated_at,
)
def to_plugin_orm(domain: PluginPersistentRecord) -> ChannelPlugin:
return ChannelPlugin(
plugin_id=domain.plugin_id,
name=domain.name,
version=domain.version,
channel_type=domain.channel_type,
origin=domain.origin,
status=domain.status,
root_dir=domain.root_dir,
entry_file=domain.entry_file,
manifest=domain.manifest or None,
capabilities=domain.capabilities or None,
config_schema=domain.config_schema,
config=domain.config or None,
error=domain.error,
is_enabled=domain.is_enabled,
registered_at=domain.registered_at,
activated_at=domain.activated_at,
created_by=domain.created_by,
updated_by=domain.updated_by,
)
def apply_plugin_updates(row: ChannelPlugin, domain: PluginPersistentRecord) -> None:
row.name = domain.name
row.version = domain.version
row.channel_type = domain.channel_type
row.origin = domain.origin
row.status = domain.status
row.root_dir = domain.root_dir
row.entry_file = domain.entry_file
row.manifest = domain.manifest or None
row.capabilities = domain.capabilities or None
row.config_schema = domain.config_schema
row.config = domain.config or None
row.error = domain.error
row.is_enabled = domain.is_enabled
row.registered_at = domain.registered_at
row.activated_at = domain.activated_at
row.updated_by = domain.updated_by
def to_config_history_domain(row: ChannelPluginConfigHistory) -> PluginConfigHistory:
return PluginConfigHistory(
id=row.id,
plugin_id=row.plugin_id,
config=row.config or {},
changed_by=row.changed_by,
change_reason=row.change_reason,
created_at=row.created_at,
)
def to_audit_log_domain(row: ChannelPluginAuditLog) -> PluginAuditLog:
return PluginAuditLog(
id=row.id,
plugin_id=row.plugin_id,
action=row.action,
old_status=row.old_status,
new_status=row.new_status,
old_version=row.old_version,
new_version=row.new_version,
detail=row.detail,
error_message=row.error_message,
created_by=row.created_by,
created_at=row.created_at,
)

View File

@ -0,0 +1,17 @@
from __future__ import annotations
from yuxi.channel.domain.model.session.channel_session import ChannelSession
from yuxi.storage.postgres.models_business import Conversation
def to_domain(row: Conversation) -> ChannelSession:
return ChannelSession(
id=row.id,
thread_id=row.thread_id,
user_id=row.user_id,
agent_id=row.agent_id,
channel_type=row.channel_type,
channel_session_key=row.channel_session_key,
status=row.status,
title=row.title,
)

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from sqlalchemy import exists as sa_exists, select
from yuxi.storage.postgres.models_business import AgentConfig
class PgAgentConfigLookup:
def __init__(self, session_factory):
self._session_factory = session_factory
async def exists(self, agent_config_id: int) -> bool:
async with self._session_factory() as db:
stmt = select(sa_exists().where(AgentConfig.id == agent_config_id))
return await db.scalar(stmt) or False

View File

@ -2,11 +2,16 @@ from __future__ import annotations
import json
import logging
from dataclasses import fields as dataclass_fields
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from yuxi.channel.domain.exception.agent_config_not_found import AgentConfigNotFoundException
from yuxi.channel.domain.exception.duplicate_binding import DuplicateBindingException
from yuxi.channel.domain.model.binding.channel_binding import ChannelBinding
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.repository.binding_repository import BindingRepositoryPort
from yuxi.channel.infrastructure.persistence.converter.binding_converter import (
to_domain,
)
@ -20,7 +25,36 @@ _BINDING_NULL_CACHE_TTL = 10
_BINDING_NULL_MARKER = "__none__"
class PgBindingRepository:
def _serialize_binding(binding: ChannelBinding) -> str:
data = {
"id": binding.id,
"channel_type": binding.channel_type,
"account_id": binding.account_id,
"group_id": binding.group_id,
"agent_config_id": binding.agent_config_id,
"session_key_strategy": binding.session_key_strategy,
"is_enabled": binding.is_enabled,
"created_by": binding.created_by,
"updated_by": binding.updated_by,
"created_at": binding.created_at.isoformat() if binding.created_at else None,
"updated_at": binding.updated_at.isoformat() if binding.updated_at else None,
}
return json.dumps(data, ensure_ascii=False)
def _deserialize_binding(data: dict) -> ChannelBinding:
from datetime import datetime
valid_keys = {f.name for f in dataclass_fields(ChannelBinding)}
filtered = {k: v for k, v in data.items() if k in valid_keys}
if "created_at" in filtered and isinstance(filtered["created_at"], str):
filtered["created_at"] = datetime.fromisoformat(filtered["created_at"])
if "updated_at" in filtered and isinstance(filtered["updated_at"], str):
filtered["updated_at"] = datetime.fromisoformat(filtered["updated_at"])
return ChannelBinding(**filtered)
class PgBindingRepository(BindingRepositoryPort):
def __init__(self, session_factory, cache_port: CachePort | None = None):
self._session_factory = session_factory
self._cache = cache_port
@ -28,7 +62,9 @@ class PgBindingRepository:
def _cache_key(self, channel_type: str, account_id: str, group_id: str) -> str:
return f"channel:binding:{channel_type}:{account_id}:{group_id}"
async def find_binding(self, *, channel_type: str, account_id: str, group_id: str) -> ChannelBinding | None:
async def find_binding(
self, *, channel_type: str, account_id: str, group_id: str, is_enabled: bool = True
) -> ChannelBinding | None:
if self._cache:
try:
cache_key = self._cache_key(channel_type, account_id, group_id)
@ -37,7 +73,7 @@ class PgBindingRepository:
if cached == _BINDING_NULL_MARKER:
return None
data = json.loads(cached)
return ChannelBinding(**data)
return _deserialize_binding(data)
except Exception:
logger.warning("Redis cache read failed, falling back to DB")
@ -46,7 +82,7 @@ class PgBindingRepository:
ChannelBindingModel.channel_type == channel_type,
ChannelBindingModel.account_id == account_id,
ChannelBindingModel.group_id == group_id,
ChannelBindingModel.is_enabled.is_(True),
ChannelBindingModel.is_enabled.is_(is_enabled),
ChannelBindingModel.is_deleted == 0,
)
result = await db.execute(stmt)
@ -66,7 +102,7 @@ class PgBindingRepository:
cache_key = self._cache_key(channel_type, account_id, group_id)
await self._cache.set(
cache_key,
json.dumps(binding.__dict__, ensure_ascii=False),
_serialize_binding(binding),
ex=_BINDING_CACHE_TTL,
)
except Exception:
@ -94,9 +130,18 @@ class PgBindingRepository:
updated_by=created_by,
)
db.add(row)
await db.flush()
try:
await db.flush()
except IntegrityError as e:
pgcode = getattr(e.orig, "pgcode", "") or ""
if pgcode == "23503":
raise AgentConfigNotFoundException(agent_config_id)
raise DuplicateBindingException(channel_type, account_id, group_id)
await db.refresh(row)
return to_domain(row)
binding = to_domain(row)
await self._write_positive_cache(binding)
return binding
async def update_binding(
self,
@ -126,16 +171,14 @@ class PgBindingRepository:
row.updated_at = utc_now_naive()
await db.flush()
await self.invalidate_cache(
channel_type=cache_keys[0],
account_id=cache_keys[1],
group_id=cache_keys[2],
)
await db.refresh(row)
binding = to_domain(row)
await self._invalidate_cache(
channel_type=cache_keys[0],
account_id=cache_keys[1],
group_id=cache_keys[2],
)
return binding
async def delete_binding(self, binding_id: int, *, updated_by: str | None = None) -> bool:
@ -149,18 +192,19 @@ class PgBindingRepository:
if not row:
return False
cache_keys = (row.channel_type, row.account_id, row.group_id)
row.is_deleted = 1
row.deleted_at = utc_now_naive()
row.updated_by = updated_by
await db.flush()
await self.invalidate_cache(
channel_type=row.channel_type,
account_id=row.account_id,
group_id=row.group_id,
)
await self._invalidate_cache(
channel_type=cache_keys[0],
account_id=cache_keys[1],
group_id=cache_keys[2],
)
return True
async def get_binding(self, binding_id: int) -> ChannelBinding | None:
@ -191,10 +235,23 @@ class PgBindingRepository:
result = await db.execute(stmt)
return [to_domain(row) for row in result.scalars().all()], total
async def invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None:
async def _invalidate_cache(self, *, channel_type: str, account_id: str, group_id: str) -> None:
if self._cache:
try:
cache_key = self._cache_key(channel_type, account_id, group_id)
await self._cache.delete(cache_key)
except Exception:
logger.warning("Redis cache invalidation failed, cache will expire via TTL")
async def _write_positive_cache(self, binding: ChannelBinding) -> None:
if not self._cache:
return
try:
cache_key = self._cache_key(binding.channel_type, binding.account_id, binding.group_id)
await self._cache.set(
cache_key,
_serialize_binding(binding),
ex=_BINDING_CACHE_TTL,
)
except Exception:
logger.warning("Redis positive cache write failed, cache will be populated on next read")

View File

@ -7,6 +7,7 @@ from sqlalchemy import select, update
from yuxi.channel.domain.repository.message_log_repository import (
MessageLogData,
MessageLogQuery,
MessageLogRepositoryPort,
)
from yuxi.storage.postgres.models_channel import ChannelMessageLog
from yuxi.utils.datetime_utils import utc_now_naive
@ -37,7 +38,7 @@ def _to_data(row: ChannelMessageLog) -> MessageLogData:
)
class PgMessageLogRepository:
class PgMessageLogRepository(MessageLogRepositoryPort):
def __init__(self, session_factory):
self._session_factory = session_factory
@ -81,6 +82,7 @@ class PgMessageLogRepository:
trace_id: str,
message_id: str,
pipeline_result: str,
status: str,
abort_reason: str | None = None,
) -> bool:
async with self._session_factory() as db:
@ -94,7 +96,7 @@ class PgMessageLogRepository:
.values(
pipeline_result=pipeline_result,
abort_reason=abort_reason,
status="processing" if pipeline_result == "accepted" else "completed",
status=status,
updated_at=utc_now_naive(),
)
)
@ -111,7 +113,7 @@ class PgMessageLogRepository:
error_message: str | None = None,
processing_time_ms: int | None = None,
agent_config_id: int | None = None,
session_id: int | None = None,
session_id: str | None = None,
conversation_id: int | None = None,
) -> bool:
values: dict = {
@ -169,21 +171,26 @@ class PgMessageLogRepository:
result = await db.execute(stmt)
return [_to_data(row) for row in result.scalars().all()]
async def query_logs(self, query: MessageLogQuery) -> list[MessageLogData]:
async def query_logs(self, query: MessageLogQuery) -> tuple[list[MessageLogData], int]:
from sqlalchemy import func
async with self._session_factory() as db:
stmt = select(ChannelMessageLog).where(ChannelMessageLog.is_deleted == 0)
base = select(ChannelMessageLog).where(ChannelMessageLog.is_deleted == 0)
if query.channel_type:
stmt = stmt.where(ChannelMessageLog.channel_type == query.channel_type)
base = base.where(ChannelMessageLog.channel_type == query.channel_type)
if query.conversation_id:
stmt = stmt.where(ChannelMessageLog.conversation_id == query.conversation_id)
base = base.where(ChannelMessageLog.conversation_id == query.conversation_id)
if query.status:
stmt = stmt.where(ChannelMessageLog.status == query.status)
base = base.where(ChannelMessageLog.status == query.status)
if query.direction:
stmt = stmt.where(ChannelMessageLog.direction == query.direction)
base = base.where(ChannelMessageLog.direction == query.direction)
if query.pipeline_result:
stmt = stmt.where(ChannelMessageLog.pipeline_result == query.pipeline_result)
base = base.where(ChannelMessageLog.pipeline_result == query.pipeline_result)
if query.worker_result:
stmt = stmt.where(ChannelMessageLog.worker_result == query.worker_result)
stmt = stmt.order_by(ChannelMessageLog.created_at.desc()).offset(query.offset).limit(query.limit)
base = base.where(ChannelMessageLog.worker_result == query.worker_result)
count_stmt = select(func.count()).select_from(base.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base.order_by(ChannelMessageLog.created_at.desc()).offset(query.offset).limit(query.limit)
result = await db.execute(stmt)
return [_to_data(row) for row in result.scalars().all()]
return [_to_data(row) for row in result.scalars().all()], total

View File

@ -2,18 +2,28 @@ from __future__ import annotations
import logging
from sqlalchemy import select
from yuxi.channel.domain.exception.session_exception import SessionNotFoundError
from yuxi.channel.domain.repository.message_repository import (
ChannelMessageData,
MessageRepositoryPort,
)
from yuxi.repositories.conversation_repository import ConversationRepository
from yuxi.storage.postgres.models_business import Conversation, Message
from yuxi.utils.datetime_utils import utc_now_naive
logger = logging.getLogger(__name__)
class PgMessageRepository:
class PgMessageRepository(MessageRepositoryPort):
def __init__(self, session_factory):
self._session_factory = session_factory
async def _find_conversation(self, db, thread_id: str):
stmt = select(Conversation).where(Conversation.thread_id == thread_id)
result = await db.execute(stmt)
return result.scalar_one_or_none()
async def save_user_message(
self,
*,
@ -27,13 +37,21 @@ class PgMessageRepository:
meta["channel_message_id"] = message_id
async with self._session_factory() as db:
conv_repo = ConversationRepository(db)
msg = await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
conv = await self._find_conversation(db, thread_id)
if not conv:
raise SessionNotFoundError(thread_id)
msg = Message(
conversation_id=conv.id,
role="user",
content=content,
message_type="text",
extra_metadata=meta,
)
db.add(msg)
conv.updated_at = utc_now_naive()
await db.flush()
await db.refresh(msg)
return ChannelMessageData(
id=msg.id,
@ -52,13 +70,21 @@ class PgMessageRepository:
extra_metadata: dict | None = None,
) -> ChannelMessageData:
async with self._session_factory() as db:
conv_repo = ConversationRepository(db)
msg = await conv_repo.add_message_by_thread_id(
thread_id=thread_id,
conv = await self._find_conversation(db, thread_id)
if not conv:
raise SessionNotFoundError(thread_id)
msg = Message(
conversation_id=conv.id,
role="assistant",
content=content,
message_type="text",
extra_metadata=extra_metadata,
)
db.add(msg)
conv.updated_at = utc_now_naive()
await db.flush()
await db.refresh(msg)
return ChannelMessageData(
id=msg.id,

View File

@ -4,10 +4,12 @@ import json
import logging
from datetime import timedelta
import redis.asyncio as aioredis
from sqlalchemy import select
from sqlalchemy import select, update
from yuxi.channel.domain.model.outbox.outbox_entry import OutboxEntry
from yuxi.channel.domain.model.outbox.outbox_status import OutboxStatus
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.repository.outbox_repository import OutboxRepositoryPort
from yuxi.storage.postgres.models_channel import ChannelOutbox
from yuxi.utils.datetime_utils import utc_now_naive
@ -16,6 +18,7 @@ logger = logging.getLogger(__name__)
_BASE_DELAY = 5
_MAX_DELAY = 300
_OUTBOX_NOTIFY_CHANNEL = "channel:outbox:notify"
_PROCESSING_TIMEOUT_MINUTES = 5
def _to_domain(row: ChannelOutbox) -> OutboxEntry:
@ -28,7 +31,7 @@ def _to_domain(row: ChannelOutbox) -> OutboxEntry:
status=row.status,
retry_count=row.retry_count,
max_retries=row.max_retries,
next_retry_at=row.next_retry_at.isoformat() if row.next_retry_at else None,
next_retry_at=row.next_retry_at,
last_error=row.last_error,
trace_id=row.trace_id,
extra_metadata=row.extra_metadata,
@ -40,14 +43,14 @@ def _calc_next_retry(retry_count: int):
return utc_now_naive() + timedelta(seconds=delay)
class PgOutboxRepository:
class PgOutboxRepository(OutboxRepositoryPort):
def __init__(
self,
session_factory,
redis: aioredis.Redis | None = None,
cache_port: CachePort | None = None,
):
self._session_factory = session_factory
self._redis = redis
self._cache = cache_port
async def enqueue(
self,
@ -65,7 +68,7 @@ class PgOutboxRepository:
session_id=session_id,
channel_type=channel_type,
content=content,
status="pending",
status=OutboxStatus.PENDING,
trace_id=trace_id,
extra_metadata=extra_metadata,
)
@ -74,9 +77,9 @@ class PgOutboxRepository:
await db.refresh(row)
entry = _to_domain(row)
if self._redis:
if self._cache:
try:
await self._redis.publish(
await self._cache.publish(
_OUTBOX_NOTIFY_CHANNEL,
json.dumps({"entry_id": entry.id}),
)
@ -85,13 +88,25 @@ class PgOutboxRepository:
return entry
async def fetch_pending(self, *, limit: int = 20) -> list[OutboxEntry]:
async def fetch_and_lock(self, *, limit: int = 20, worker_id: str = "") -> list[OutboxEntry]:
now = utc_now_naive()
processing_timeout = timedelta(minutes=_PROCESSING_TIMEOUT_MINUTES)
async with self._session_factory() as db:
timeout_stmt = (
update(ChannelOutbox)
.where(
ChannelOutbox.status == OutboxStatus.PROCESSING,
ChannelOutbox.updated_at < now - processing_timeout,
ChannelOutbox.is_deleted == 0,
)
.values(status=OutboxStatus.PENDING, updated_at=utc_now_naive())
)
await db.execute(timeout_stmt)
stmt = (
select(ChannelOutbox)
.where(
ChannelOutbox.status.in_(["pending", "retrying"]),
ChannelOutbox.status.in_([OutboxStatus.PENDING, OutboxStatus.RETRYING]),
ChannelOutbox.is_deleted == 0,
)
.where((ChannelOutbox.next_retry_at.is_(None)) | (ChannelOutbox.next_retry_at <= now))
@ -100,7 +115,15 @@ class PgOutboxRepository:
.with_for_update(skip_locked=True)
)
result = await db.execute(stmt)
return [_to_domain(row) for row in result.scalars().all()]
rows = result.scalars().all()
if not rows:
return []
for row in rows:
row.status = OutboxStatus.PROCESSING
await db.flush()
return [_to_domain(row) for row in rows]
async def get_by_id(self, entry_id: int) -> OutboxEntry | None:
async with self._session_factory() as db:
@ -124,60 +147,64 @@ class PgOutboxRepository:
return None
row.retry_count += 1
row.status = "retrying"
row.next_retry_at = _calc_next_retry(row.retry_count)
if last_error:
row.last_error = last_error
if row.retry_count >= row.max_retries:
row.status = OutboxStatus.DEAD
row.last_error = last_error or "max_retries_exceeded"
else:
row.status = OutboxStatus.RETRYING
row.next_retry_at = _calc_next_retry(row.retry_count)
if last_error:
row.last_error = last_error
row.updated_at = utc_now_naive()
await db.flush()
await db.refresh(row)
return _to_domain(row)
async def mark_sent(self, entry_id: int) -> bool:
async with self._session_factory() as db:
stmt = select(ChannelOutbox).where(
ChannelOutbox.id == entry_id,
ChannelOutbox.is_deleted == 0,
stmt = (
update(ChannelOutbox)
.where(
ChannelOutbox.id == entry_id,
ChannelOutbox.status == OutboxStatus.PROCESSING,
ChannelOutbox.is_deleted == 0,
)
.values(status=OutboxStatus.SENT, updated_at=utc_now_naive())
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if not row:
return False
row.status = "sent"
await db.flush()
return True
return result.rowcount > 0
async def mark_dead(self, entry_id: int, *, last_error: str) -> bool:
async with self._session_factory() as db:
stmt = select(ChannelOutbox).where(
ChannelOutbox.id == entry_id,
ChannelOutbox.is_deleted == 0,
stmt = (
update(ChannelOutbox)
.where(
ChannelOutbox.id == entry_id,
ChannelOutbox.status == OutboxStatus.PROCESSING,
ChannelOutbox.is_deleted == 0,
)
.values(status=OutboxStatus.DEAD, last_error=last_error, updated_at=utc_now_naive())
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if not row:
return False
row.status = "dead"
row.last_error = last_error
await db.flush()
return True
return result.rowcount > 0
async def mark_failed(self, entry_id: int, *, last_error: str) -> bool:
async with self._session_factory() as db:
stmt = select(ChannelOutbox).where(
ChannelOutbox.id == entry_id,
ChannelOutbox.is_deleted == 0,
stmt = (
update(ChannelOutbox)
.where(
ChannelOutbox.id == entry_id,
ChannelOutbox.status == OutboxStatus.PROCESSING,
ChannelOutbox.is_deleted == 0,
)
.values(status=OutboxStatus.FAILED, last_error=last_error, updated_at=utc_now_naive())
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if not row:
return False
row.status = "failed"
row.last_error = last_error
await db.flush()
return True
return result.rowcount > 0
async def list_outbox(
self,

View File

@ -0,0 +1,82 @@
from __future__ import annotations
import logging
from sqlalchemy import desc, func, select
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import (
PluginAuditLog,
)
from yuxi.channel.infrastructure.persistence.converter.plugin_converter import (
to_audit_log_domain,
)
from yuxi.storage.postgres.models_channel import ChannelPluginAuditLog
logger = logging.getLogger(__name__)
class PgPluginAuditLogRepository:
def __init__(self, session_factory):
self._session_factory = session_factory
async def save(self, log: PluginAuditLog) -> PluginAuditLog:
async with self._session_factory() as db:
orm = ChannelPluginAuditLog(
plugin_id=log.plugin_id,
action=log.action,
old_status=log.old_status,
new_status=log.new_status,
old_version=log.old_version,
new_version=log.new_version,
detail=log.detail,
error_message=log.error_message,
created_by=log.created_by,
)
db.add(orm)
await db.flush()
await db.refresh(orm)
return to_audit_log_domain(orm)
async def list_by_plugin_id(
self,
plugin_id: str,
*,
offset: int = 0,
limit: int = 20,
) -> tuple[list[PluginAuditLog], int]:
async with self._session_factory() as db:
base = select(ChannelPluginAuditLog).where(
ChannelPluginAuditLog.plugin_id == plugin_id,
ChannelPluginAuditLog.is_deleted == 0,
)
count_stmt = select(func.count()).select_from(base.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base.order_by(desc(ChannelPluginAuditLog.created_at)).offset(offset).limit(limit)
result = await db.execute(stmt)
records = [to_audit_log_domain(row) for row in result.scalars().all()]
return records, total
async def list_by_action(
self,
action: str,
*,
offset: int = 0,
limit: int = 50,
) -> tuple[list[PluginAuditLog], int]:
async with self._session_factory() as db:
base = select(ChannelPluginAuditLog).where(
ChannelPluginAuditLog.action == action,
ChannelPluginAuditLog.is_deleted == 0,
)
count_stmt = select(func.count()).select_from(base.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base.order_by(desc(ChannelPluginAuditLog.created_at)).offset(offset).limit(limit)
result = await db.execute(stmt)
records = [to_audit_log_domain(row) for row in result.scalars().all()]
return records, total

View File

@ -0,0 +1,71 @@
from __future__ import annotations
import logging
from sqlalchemy import desc, func, select
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import (
PluginConfigHistory,
)
from yuxi.channel.infrastructure.persistence.converter.plugin_converter import (
to_config_history_domain,
)
from yuxi.storage.postgres.models_channel import ChannelPluginConfigHistory
logger = logging.getLogger(__name__)
class PgPluginConfigHistoryRepository:
def __init__(self, session_factory):
self._session_factory = session_factory
async def save(self, history: PluginConfigHistory) -> PluginConfigHistory:
async with self._session_factory() as db:
orm = ChannelPluginConfigHistory(
plugin_id=history.plugin_id,
config=history.config or None,
changed_by=history.changed_by,
change_reason=history.change_reason,
created_by=history.changed_by,
)
db.add(orm)
await db.flush()
await db.refresh(orm)
return to_config_history_domain(orm)
async def list_by_plugin_id(
self,
plugin_id: str,
*,
offset: int = 0,
limit: int = 20,
) -> tuple[list[PluginConfigHistory], int]:
async with self._session_factory() as db:
base = select(ChannelPluginConfigHistory).where(
ChannelPluginConfigHistory.plugin_id == plugin_id,
ChannelPluginConfigHistory.is_deleted == 0,
)
count_stmt = select(func.count()).select_from(base.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base.order_by(desc(ChannelPluginConfigHistory.created_at)).offset(offset).limit(limit)
result = await db.execute(stmt)
records = [to_config_history_domain(row) for row in result.scalars().all()]
return records, total
async def get_latest(self, plugin_id: str) -> PluginConfigHistory | None:
async with self._session_factory() as db:
stmt = (
select(ChannelPluginConfigHistory)
.where(
ChannelPluginConfigHistory.plugin_id == plugin_id,
ChannelPluginConfigHistory.is_deleted == 0,
)
.order_by(desc(ChannelPluginConfigHistory.created_at))
.limit(1)
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
return to_config_history_domain(row) if row else None

View File

@ -0,0 +1,226 @@
from __future__ import annotations
import logging
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert
from yuxi.channel.domain.model.plugin_registry.plugin_persistent_record import (
PluginPersistentRecord,
)
from yuxi.channel.domain.repository.plugin_repository import PluginRepositoryPort
from yuxi.channel.infrastructure.persistence.converter.plugin_converter import (
apply_plugin_updates,
to_plugin_domain,
to_plugin_orm,
)
from yuxi.storage.postgres.models_channel import ChannelPlugin
from yuxi.utils.datetime_utils import utc_now_naive
logger = logging.getLogger(__name__)
class PgPluginRepository(PluginRepositoryPort):
def __init__(self, session_factory):
self._session_factory = session_factory
async def get_by_plugin_id(self, plugin_id: str) -> PluginPersistentRecord | None:
async with self._session_factory() as db:
stmt = select(ChannelPlugin).where(
ChannelPlugin.plugin_id == plugin_id,
ChannelPlugin.is_deleted == 0,
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
return to_plugin_domain(row) if row else None
async def get_by_channel_type(self, channel_type: str) -> PluginPersistentRecord | None:
async with self._session_factory() as db:
stmt = select(ChannelPlugin).where(
ChannelPlugin.channel_type == channel_type,
ChannelPlugin.is_deleted == 0,
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
return to_plugin_domain(row) if row else None
async def list_plugins(
self,
*,
origin: str | None = None,
status: str | None = None,
is_enabled: bool | None = None,
offset: int = 0,
limit: int = 50,
) -> tuple[list[PluginPersistentRecord], int]:
async with self._session_factory() as db:
base = select(ChannelPlugin).where(ChannelPlugin.is_deleted == 0)
if origin:
base = base.where(ChannelPlugin.origin == origin)
if status:
base = base.where(ChannelPlugin.status == status)
if is_enabled is not None:
base = base.where(ChannelPlugin.is_enabled.is_(is_enabled))
count_stmt = select(func.count()).select_from(base.subquery())
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
stmt = base.order_by(ChannelPlugin.id.asc()).offset(offset).limit(limit)
result = await db.execute(stmt)
records = [to_plugin_domain(row) for row in result.scalars().all()]
return records, total
async def create(self, record: PluginPersistentRecord) -> PluginPersistentRecord:
async with self._session_factory() as db:
orm = to_plugin_orm(record)
db.add(orm)
await db.flush()
await db.refresh(orm)
return to_plugin_domain(orm)
async def update(self, record: PluginPersistentRecord) -> PluginPersistentRecord | None:
if not record.plugin_id:
return None
async with self._session_factory() as db:
stmt = select(ChannelPlugin).where(
ChannelPlugin.plugin_id == record.plugin_id,
ChannelPlugin.is_deleted == 0,
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
if not row:
return None
apply_plugin_updates(row, record)
row.updated_at = utc_now_naive()
await db.flush()
await db.refresh(row)
return to_plugin_domain(row)
async def update_status(self, plugin_id: str, status: str, *, error: str | None = None) -> bool:
async with self._session_factory() as db:
values: dict = {"status": status, "updated_at": utc_now_naive()}
if error is not None:
values["error"] = error
stmt = (
update(ChannelPlugin)
.where(
ChannelPlugin.plugin_id == plugin_id,
ChannelPlugin.is_deleted == 0,
)
.values(**values)
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount > 0
async def update_config(self, plugin_id: str, config: dict) -> bool:
async with self._session_factory() as db:
stmt = (
update(ChannelPlugin)
.where(
ChannelPlugin.plugin_id == plugin_id,
ChannelPlugin.is_deleted == 0,
)
.values(config=config, updated_at=utc_now_naive())
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount > 0
async def soft_delete(self, plugin_id: str, *, updated_by: str | None = None) -> bool:
async with self._session_factory() as db:
stmt = (
update(ChannelPlugin)
.where(
ChannelPlugin.plugin_id == plugin_id,
ChannelPlugin.is_deleted == 0,
)
.values(
is_deleted=1,
deleted_at=utc_now_naive(),
updated_by=updated_by,
updated_at=utc_now_naive(),
)
)
result = await db.execute(stmt)
await db.flush()
return result.rowcount > 0
async def list_by_origins(self, origins: list[str]) -> list[PluginPersistentRecord]:
if not origins:
return []
async with self._session_factory() as db:
stmt = select(ChannelPlugin).where(
ChannelPlugin.origin.in_(origins),
ChannelPlugin.is_deleted == 0,
)
result = await db.execute(stmt)
return [to_plugin_domain(row) for row in result.scalars().all()]
async def bulk_upsert(self, records: list[PluginPersistentRecord]) -> list[PluginPersistentRecord]:
if not records:
return []
async with self._session_factory() as db:
values = []
for r in records:
values.append(
{
"plugin_id": r.plugin_id,
"name": r.name,
"version": r.version,
"channel_type": r.channel_type,
"origin": r.origin,
"status": r.status,
"root_dir": r.root_dir,
"entry_file": r.entry_file,
"manifest": r.manifest or None,
"capabilities": r.capabilities or None,
"config_schema": r.config_schema,
"config": r.config or None,
"error": r.error,
"is_enabled": r.is_enabled,
"is_deleted": 0,
"registered_at": r.registered_at,
"updated_at": utc_now_naive(),
}
)
stmt = insert(ChannelPlugin).values(values)
stmt = stmt.on_conflict_do_update(
index_elements=["plugin_id"],
set_={
"name": stmt.excluded.name,
"version": stmt.excluded.version,
"channel_type": stmt.excluded.channel_type,
"origin": stmt.excluded.origin,
"status": stmt.excluded.status,
"root_dir": stmt.excluded.root_dir,
"entry_file": stmt.excluded.entry_file,
"manifest": stmt.excluded.manifest,
"capabilities": stmt.excluded.capabilities,
"config_schema": stmt.excluded.config_schema,
"config": stmt.excluded.config,
"error": stmt.excluded.error,
"is_enabled": stmt.excluded.is_enabled,
"is_deleted": ChannelPlugin.is_deleted,
"deleted_at": ChannelPlugin.deleted_at,
"updated_at": stmt.excluded.updated_at,
},
)
await db.execute(stmt)
await db.flush()
plugin_ids = [r.plugin_id for r in records]
select_stmt = select(ChannelPlugin).where(
ChannelPlugin.plugin_id.in_(plugin_ids),
ChannelPlugin.is_deleted == 0,
)
result = await db.execute(select_stmt)
return [to_plugin_domain(row) for row in result.scalars().all()]

View File

@ -10,6 +10,10 @@ from sqlalchemy.exc import IntegrityError
from yuxi.channel.domain.model.session.channel_session import ChannelSession
from yuxi.channel.domain.port.cache_port import CachePort
from yuxi.channel.domain.repository.session_repository import SessionRepositoryPort
from yuxi.channel.infrastructure.persistence.converter.session_converter import (
to_domain,
)
from yuxi.storage.postgres.models_business import Conversation
logger = logging.getLogger(__name__)
@ -17,20 +21,7 @@ logger = logging.getLogger(__name__)
_LOCK_TIMEOUT = 10
def _to_domain(row: Conversation) -> ChannelSession:
return ChannelSession(
id=row.id,
thread_id=row.thread_id,
user_id=row.user_id,
agent_id=row.agent_id,
channel_type=row.channel_type,
channel_session_key=row.channel_session_key,
status=row.status,
title=row.title,
)
class PgSessionRepository:
class PgSessionRepository(SessionRepositoryPort):
def __init__(
self,
session_factory,
@ -56,22 +47,24 @@ class PgSessionRepository:
existing = await self._find_existing(channel_type, session_key, agent_id)
if existing:
return _to_domain(existing)
return to_domain(existing)
lock_key = f"channel:session_lock:{session_key}:{agent_id}"
lock_value = str(uuid_lib.uuid4())
lock_acquired = False
if self._cache:
try:
lock_acquired = await self._cache.set(lock_key, "1", ex=self._lock_timeout, nx=True)
lock_acquired = await self._cache.set(lock_key, lock_value, ex=self._lock_timeout, nx=True)
if not lock_acquired:
await asyncio.sleep(0.1)
existing = await self._find_existing(channel_type, session_key, agent_id)
if existing:
return _to_domain(existing)
lock_acquired = await self._cache.set(lock_key, "1", ex=self._lock_timeout, nx=True)
return to_domain(existing)
lock_acquired = await self._cache.set(lock_key, lock_value, ex=self._lock_timeout, nx=True)
except Exception:
logger.warning("Redis lock failed, proceeding without lock")
lock_acquired = False
try:
async with self._session_factory() as db:
@ -89,19 +82,29 @@ class PgSessionRepository:
db.add(conv)
await db.flush()
await db.refresh(conv)
return _to_domain(conv)
return to_domain(conv)
except IntegrityError:
existing = await self._find_existing(channel_type, session_key, agent_id)
if existing:
return _to_domain(existing)
return to_domain(existing)
raise
finally:
if self._cache and lock_acquired:
try:
await self._cache.delete(lock_key)
await self._safe_release_lock(lock_key, lock_value)
except Exception:
pass
async def _safe_release_lock(self, lock_key: str, lock_value: str) -> None:
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
await self._cache.eval(lua_script, keys=[lock_key], args=[lock_value])
async def get_by_thread_id(self, thread_id: str) -> ChannelSession | None:
async with self._session_factory() as db:
stmt = select(Conversation).where(
@ -110,7 +113,7 @@ class PgSessionRepository:
)
result = await db.execute(stmt)
row = result.scalar_one_or_none()
return _to_domain(row) if row else None
return to_domain(row) if row else None
async def _find_existing(self, channel_type: str, session_key: str, agent_id: str) -> Conversation | None:
async with self._session_factory() as db:

View File

@ -0,0 +1,11 @@
from yuxi.channel.infrastructure.plugin.registry_impl import (
PluginRegistryImpl as PluginRegistryImpl,
)
from yuxi.channel.infrastructure.plugin.registry_impl import (
get_registry as get_registry,
)
from yuxi.channel.infrastructure.plugin.registry_impl import (
set_registry as set_registry,
)
__all__ = ["PluginRegistryImpl", "get_registry", "set_registry"]

View File

@ -0,0 +1,35 @@
from __future__ import annotations
import logging
logger = logging.getLogger(__name__)
class PluginRegistrationApi:
def __init__(self, plugin_id: str, config: dict | None = None):
self._plugin_id = plugin_id
self._config = config or {}
self._closed = False
@property
def is_closed(self) -> bool:
return self._closed
def close(self) -> None:
self._closed = True
@property
def config(self) -> dict:
if self._closed:
raise RuntimeError(f"registration API closed for plugin: {self._plugin_id}")
return self._config
class ApiGuard:
def create(
self, plugin_id: str, config: dict | None = None, logger_: logging.Logger | None = None
) -> PluginRegistrationApi:
api = PluginRegistrationApi(plugin_id, config)
if logger_:
logger_.debug("created registration API for plugin: %s", plugin_id)
return api

Some files were not shown because too many files have changed in this diff Show More