ForcePilot/backend/package/yuxi/channel/routing/router.py
Kris bab30f2715
Some checks failed
Deploy VitePress site to Pages / build (push) Has been cancelled
Ruff Format Check / Ruff Format & Lint (push) Has been cancelled
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
feat:0715
2026-07-15 12:30:58 +08:00

322 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""绑定路由器"""
import hashlib
import json
import re
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.channel.plugins.protocol import (
BindingConversationRef,
BindingRoute,
ChannelPlugin,
InboundMessage,
SessionConversationRef,
)
from yuxi.channel.routing.cache import RouteCache, get_default_route_cache
from yuxi.repositories.channel_binding_repository import ChannelBindingRepository
from yuxi.repositories.conversation_repository import ConversationRepository
if TYPE_CHECKING:
from yuxi.storage.postgres.model_channel import ChannelSession
@dataclass(frozen=True)
class _BindingContext:
"""路由匹配所需的会话上下文,兼容 ChannelSession 与 SessionConversationRef。"""
session_key: str
channel_type: str
account_id: str
chat_type: str | None
peer_id: str | None
conversation_id: int | None = None
class _MatchTier:
def __init__(self, name: str, matcher: Any) -> None:
self.name = name
self._matcher = matcher
def matches(self, rule: dict, ctx: _BindingContext, inbound: InboundMessage) -> bool:
return self._matcher(rule, ctx, inbound)
def _peer_id(ctx: _BindingContext, inbound: InboundMessage) -> str | None:
return inbound.peer_id if inbound.peer_id is not None else ctx.peer_id
def _chat_type(ctx: _BindingContext, inbound: InboundMessage) -> str | None:
return inbound.chat_type if inbound.chat_type is not None else ctx.chat_type
class BindingRouter:
"""解析渠道会话应路由到的 agent_id。
支持配置绑定(八级匹配)、运行时绑定、默认路由与 LRU 缓存。
"""
def __init__(self, route_cache: RouteCache | None = None) -> None:
self._cache = route_cache or get_default_route_cache()
self._match_tiers: tuple[_MatchTier, ...] = (
_MatchTier(
"binding.session_key",
lambda rule, ctx, _inbound: rule.get("session_key") == ctx.session_key,
),
_MatchTier(
"binding.channel_account_chat_peer",
lambda rule, ctx, inbound: (
rule.get("channel_type") == ctx.channel_type
and rule.get("account_id") == ctx.account_id
and rule.get("chat_type") == _chat_type(ctx, inbound)
and rule.get("peer_id") == _peer_id(ctx, inbound)
),
),
_MatchTier(
"binding.channel_account_peer",
lambda rule, ctx, inbound: (
rule.get("channel_type") == ctx.channel_type
and rule.get("account_id") == ctx.account_id
and rule.get("peer_id") == _peer_id(ctx, inbound)
),
),
_MatchTier(
"binding.channel_account_chat",
lambda rule, ctx, inbound: (
rule.get("channel_type") == ctx.channel_type
and rule.get("account_id") == ctx.account_id
and rule.get("chat_type") == _chat_type(ctx, inbound)
),
),
_MatchTier(
"binding.channel_chat_peer",
lambda rule, ctx, inbound: (
rule.get("channel_type") == ctx.channel_type
and rule.get("chat_type") == _chat_type(ctx, inbound)
and rule.get("peer_id") == _peer_id(ctx, inbound)
),
),
_MatchTier(
"binding.session_key_regex",
lambda rule, ctx, _inbound: (
rule.get("session_key_regex") is not None
and re.fullmatch(str(rule["session_key_regex"]), ctx.session_key) is not None
),
),
_MatchTier(
"binding.channel_account",
lambda rule, ctx, _inbound: (
rule.get("channel_type") == ctx.channel_type and rule.get("account_id") == ctx.account_id
),
),
_MatchTier(
"binding.channel_type",
lambda rule, ctx, _inbound: rule.get("channel_type") == ctx.channel_type,
),
)
def _build_cache_key(self, ctx: _BindingContext) -> str:
return f"{ctx.channel_type}:{ctx.account_id}:{ctx.session_key}"
@staticmethod
def _compute_rule_hash(rule: dict) -> str:
"""对规则字典计算稳定 sha256 hash。"""
payload = json.dumps(rule, sort_keys=True, ensure_ascii=False, default=str)
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
@staticmethod
def _parse_session_key(session_key: str) -> tuple[str | None, str | None]:
"""安全解析 session_key返回 (channel_type, account_id)。"""
if not session_key:
return None, None
parts = session_key.split(":", 2)
if len(parts) < 2:
return parts[0] if parts else None, None
return parts[0], parts[1]
def _make_context(
self,
config: dict,
plugin: ChannelPlugin,
ref: SessionConversationRef,
conversation_id: int | None = None,
) -> _BindingContext:
"""从 config 或 session_key 构建路由上下文。"""
channel_type = config.get("channel_type")
account_id = config.get("account_id")
if not channel_type or not account_id:
parsed_type, parsed_account = self._parse_session_key(ref.session_key)
channel_type = channel_type or parsed_type
account_id = account_id or parsed_account
if not channel_type or not account_id:
raise ValueError(f"Unable to resolve channel_type/account_id for session {ref.session_key}")
return _BindingContext(
session_key=ref.session_key,
channel_type=channel_type,
account_id=account_id,
chat_type=ref.chat_type,
peer_id=ref.channel_sender_id,
conversation_id=conversation_id,
)
async def resolve_runtime(
self,
config: dict,
plugin: ChannelPlugin,
ref: SessionConversationRef,
db_session: AsyncSession,
) -> BindingRoute:
"""在创建 Conversation 前解析 agent_id优先配置绑定再运行时绑定最后默认路由。"""
ctx = self._make_context(config, plugin, ref)
cache_key = self._build_cache_key(ctx)
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
inbound = InboundMessage(
channel_type=ctx.channel_type,
account_id=ctx.account_id,
chat_type=ctx.chat_type,
peer_id=ctx.peer_id,
session_key=ctx.session_key,
)
route = await self._resolve_configured_binding(ctx, config, plugin, inbound)
if route is not None:
if route.agent_id:
await self._cache.set(cache_key, route)
return route
runtime_route = await self._resolve_runtime_binding(ctx, db_session)
if runtime_route is not None:
if runtime_route.agent_id:
await self._cache.set(cache_key, runtime_route)
return runtime_route
default_route = BindingRoute(
agent_id=config.get("default_agent_id"),
session_key=ctx.session_key,
matched_by="default",
)
if default_route.agent_id:
await self._cache.set(cache_key, default_route)
return default_route
async def resolve(
self,
session: "ChannelSession",
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
db_session: AsyncSession,
) -> BindingRoute:
"""完整解析:先查缓存,再配置绑定(八级匹配),再运行时绑定,最后默认路由。"""
ctx = _BindingContext(
session_key=session.session_key,
channel_type=session.channel_type,
account_id=session.account_id,
chat_type=session.chat_type,
peer_id=inbound.peer_id if inbound.peer_id is not None else session.channel_sender_id,
conversation_id=session.conversation_id,
)
cache_key = self._build_cache_key(ctx)
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
config_route = await self._resolve_configured_binding(ctx, config, plugin, inbound)
if config_route is not None:
if config_route.agent_id:
await self._cache.set(cache_key, config_route)
return config_route
runtime_route = await self._resolve_runtime_binding(ctx, db_session)
if runtime_route is not None:
if runtime_route.agent_id:
await self._cache.set(cache_key, runtime_route)
return runtime_route
conversation_repo = ConversationRepository(db_session)
default_agent_id = config.get("default_agent_id")
if not default_agent_id and ctx.conversation_id:
conversation = await conversation_repo.get_conversation_by_id(ctx.conversation_id)
if conversation is not None:
default_agent_id = conversation.agent_id
default_route = BindingRoute(
agent_id=default_agent_id,
session_key=ctx.session_key,
matched_by="default",
)
if default_route.agent_id:
await self._cache.set(cache_key, default_route)
return default_route
async def _resolve_configured_binding(
self,
ctx: _BindingContext,
config: dict,
plugin: ChannelPlugin,
inbound: InboundMessage,
) -> BindingRoute | None:
"""按八级匹配解析配置绑定;插件可通过 compile_binding/match_binding 扩展匹配逻辑。"""
bindings = config.get("bindings", [])
# 1. 插件自定义绑定编译与匹配
if isinstance(plugin.compile_binding(config, inbound), BindingConversationRef):
for rule in bindings:
if plugin.match_binding(config, rule, inbound):
return BindingRoute(
agent_id=rule.get("agent_id"),
session_key=ctx.session_key,
matched_by="binding.plugin",
binding_rule_hash=self._compute_rule_hash(rule),
)
if not bindings:
return None
# 2. 通用八级匹配
for tier in self._match_tiers:
for rule in bindings:
if tier.matches(rule, ctx, inbound):
return BindingRoute(
agent_id=rule.get("agent_id"),
session_key=ctx.session_key,
matched_by=tier.name,
binding_rule_hash=self._compute_rule_hash(rule),
)
return None
async def _resolve_runtime_binding(
self,
ctx: _BindingContext,
db_session: AsyncSession,
) -> BindingRoute | None:
"""查询 channel_bindings 运行时绑定记录。"""
binding_repo = ChannelBindingRepository(db_session)
bindings = await binding_repo.find_runtime_bindings(
ctx.channel_type,
ctx.account_id,
ctx.session_key,
)
if not bindings:
return None
binding = bindings[0]
return BindingRoute(
agent_id=binding.agent_id,
session_key=ctx.session_key,
matched_by="runtime.binding",
binding_rule_hash=binding.binding_rule_hash,
)
async def invalidate_channel_cache(self, channel_type: str) -> int:
"""当配置变更时,按渠道类型失效路由缓存。"""
return await self._cache.invalidate_by_channel(channel_type)
async def invalidate_account_cache(self, channel_type: str, account_id: str) -> int:
"""当配置变更时,按渠道账户失效路由缓存。"""
return await self._cache.invalidate_by_account(channel_type, account_id)