feat(plugin): 实现完整的插件注册管理系统
新增了插件相关的完整领域模型、应用服务、基础设施实现,包括: 1. 插件状态、注册模式、来源等基础枚举和数据结构 2. 插件清单解析、发现、加载工具类 3. 插件注册表领域服务和内存存储实现 4. 插件相关的命令、查询、事件定义 5. 插件REST API接口和DTO映射 6. 集成了原有通道适配器到插件系统 7. 新增内置插件注册和自动发现能力
This commit is contained in:
parent
395475628c
commit
9e503becd3
@ -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"]
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActivateCommand:
|
||||
plugin_id: str
|
||||
config_override: dict | None = None
|
||||
@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeactivateCommand:
|
||||
plugin_id: str
|
||||
@ -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"
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReloadCommand:
|
||||
plugin_id: str
|
||||
force: bool = False
|
||||
39
backend/package/yuxi/channel/application/dto/plugin_dto.py
Normal file
39
backend/package/yuxi/channel/application/dto/plugin_dto.py
Normal 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
|
||||
@ -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,
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class GetPluginManifestQuery:
|
||||
plugin_id: str
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ListPluginsQuery:
|
||||
origin: str | None = None
|
||||
status: str | None = None
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -9,6 +9,7 @@ feishu:
|
||||
app_id: ""
|
||||
app_secret: ""
|
||||
verification_token: ""
|
||||
encrypt_key: ""
|
||||
ws_enabled: true
|
||||
|
||||
dingtalk:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"},
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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})
|
||||
|
||||
@ -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)
|
||||
|
||||
53
backend/package/yuxi/channel/channels/dingtalk/verifier.py
Normal file
53
backend/package/yuxi/channel/channels/dingtalk/verifier.py
Normal 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")
|
||||
@ -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"},
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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", "")
|
||||
|
||||
52
backend/package/yuxi/channel/channels/feishu/verifier.py
Normal file
52
backend/package/yuxi/channel/channels/feishu/verifier.py
Normal 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")
|
||||
@ -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"],
|
||||
)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
30
backend/package/yuxi/channel/channels/hooks/outbound.py
Normal file
30
backend/package/yuxi/channel/channels/hooks/outbound.py
Normal 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
|
||||
@ -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(),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
51
backend/package/yuxi/channel/channels/hooks/verifier.py
Normal file
51
backend/package/yuxi/channel/channels/hooks/verifier.py
Normal 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")
|
||||
@ -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"],
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
34
backend/package/yuxi/channel/channels/web/verifier.py
Normal file
34
backend/package/yuxi/channel/channels/web/verifier.py
Normal 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")
|
||||
@ -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(
|
||||
|
||||
17
backend/package/yuxi/channel/domain/event/plugin/__init__.py
Normal file
17
backend/package/yuxi/channel/domain/event/plugin/__init__.py
Normal 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",
|
||||
]
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginActivatedEvent:
|
||||
plugin_id: str
|
||||
channel_type: str
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginDeactivatedEvent:
|
||||
plugin_id: str
|
||||
channel_type: str
|
||||
@ -0,0 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PluginRegisteredEvent:
|
||||
plugin_id: str
|
||||
channel_type: str
|
||||
@ -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
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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}")
|
||||
@ -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}")
|
||||
@ -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
|
||||
@ -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}")
|
||||
@ -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":
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
@ -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",
|
||||
]
|
||||
@ -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")
|
||||
@ -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
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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
|
||||
|
||||
@ -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: ...
|
||||
@ -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: ...
|
||||
|
||||
@ -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: ...
|
||||
@ -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: ...
|
||||
|
||||
@ -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: ...
|
||||
@ -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: ...
|
||||
@ -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
|
||||
|
||||
@ -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: ...
|
||||
|
||||
@ -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]: ...
|
||||
|
||||
@ -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。"""
|
||||
...
|
||||
|
||||
@ -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: ...
|
||||
|
||||
|
||||
@ -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]: ...
|
||||
@ -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: ...
|
||||
@ -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]: ...
|
||||
@ -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
|
||||
@ -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"):
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()]
|
||||
@ -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:
|
||||
|
||||
@ -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"]
|
||||
@ -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
Loading…
Reference in New Issue
Block a user