feat(channel/routing): 新增消息路由相关模块
实现了完整的消息路由匹配系统,包含数据模型、会话密钥生成和路由匹配逻辑,提供了从消息上下文到代理配置的路由能力
This commit is contained in:
parent
3ce713eb9e
commit
231166cde8
29
backend/package/yuxi/channel/routing/__init__.py
Normal file
29
backend/package/yuxi/channel/routing/__init__.py
Normal file
@ -0,0 +1,29 @@
|
||||
from yuxi.channel.routing.matcher import MessageContext, RouteMatcher, VerificationContext
|
||||
from yuxi.channel.routing.models import (
|
||||
BindingMatch,
|
||||
EvaluatedBinding,
|
||||
NormalizedPeer,
|
||||
PeerConstraint,
|
||||
PeerConstraintState,
|
||||
PeerKind,
|
||||
RouteBinding,
|
||||
RouteResult,
|
||||
)
|
||||
from yuxi.channel.routing.session_key import SessionKeyBuilder, resolve_thread_session_keys, session_key_to_thread_id
|
||||
|
||||
__all__ = [
|
||||
"BindingMatch",
|
||||
"EvaluatedBinding",
|
||||
"MessageContext",
|
||||
"NormalizedPeer",
|
||||
"PeerConstraint",
|
||||
"PeerConstraintState",
|
||||
"PeerKind",
|
||||
"RouteBinding",
|
||||
"RouteMatcher",
|
||||
"RouteResult",
|
||||
"SessionKeyBuilder",
|
||||
"VerificationContext",
|
||||
"resolve_thread_session_keys",
|
||||
"session_key_to_thread_id",
|
||||
]
|
||||
387
backend/package/yuxi/channel/routing/matcher.py
Normal file
387
backend/package/yuxi/channel/routing/matcher.py
Normal file
@ -0,0 +1,387 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from yuxi.channel.routing.models import (
|
||||
_PEER_KIND_ALIASES,
|
||||
EvaluatedBinding,
|
||||
NormalizedPeer,
|
||||
PeerConstraint,
|
||||
PeerConstraintState,
|
||||
PeerKind,
|
||||
RouteBinding,
|
||||
RouteResult,
|
||||
)
|
||||
from yuxi.channel.routing.session_key import SessionKeyBuilder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CACHE_KEYS = 3000
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerificationContext:
|
||||
verified_by: str
|
||||
scope: str
|
||||
timestamp: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageContext:
|
||||
channel: str
|
||||
account_id: str
|
||||
peer_kind: PeerKind
|
||||
peer_id: str
|
||||
channel_config_id: str | None = None
|
||||
parent_peer_kind: PeerKind | None = None
|
||||
parent_peer_id: str | None = None
|
||||
guild_id: str | None = None
|
||||
team_id: str | None = None
|
||||
roles: list[str] = field(default_factory=list)
|
||||
member_role_ids: list[str] = field(default_factory=list)
|
||||
_verification_ctx: VerificationContext | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _BindingIndex:
|
||||
by_peer: dict[str, list[EvaluatedBinding]] = field(default_factory=dict)
|
||||
by_peer_wildcard: list[EvaluatedBinding] = field(default_factory=list)
|
||||
by_guild_with_roles: dict[str, list[EvaluatedBinding]] = field(default_factory=dict)
|
||||
by_guild: dict[str, list[EvaluatedBinding]] = field(default_factory=dict)
|
||||
by_team: dict[str, list[EvaluatedBinding]] = field(default_factory=dict)
|
||||
by_account: list[EvaluatedBinding] = field(default_factory=list)
|
||||
by_channel: list[EvaluatedBinding] = field(default_factory=list)
|
||||
|
||||
|
||||
def _build_index_key(msg_ctx: MessageContext) -> str:
|
||||
channel = msg_ctx.channel
|
||||
account_id = msg_ctx.account_id or "*"
|
||||
config_id = msg_ctx.channel_config_id or "*"
|
||||
return f"{channel}:{account_id}:{config_id}"
|
||||
|
||||
|
||||
def _build_route_cache_key(msg_ctx: MessageContext) -> str:
|
||||
channel = msg_ctx.channel
|
||||
account_id = msg_ctx.account_id or "*"
|
||||
config_id = msg_ctx.channel_config_id or "*"
|
||||
peer = f"{msg_ctx.peer_kind.value}:{msg_ctx.peer_id}" if msg_ctx.peer_kind and msg_ctx.peer_id else "-"
|
||||
parent = (
|
||||
f"{msg_ctx.parent_peer_kind.value}:{msg_ctx.parent_peer_id}"
|
||||
if msg_ctx.parent_peer_kind and msg_ctx.parent_peer_id
|
||||
else "-"
|
||||
)
|
||||
guild = msg_ctx.guild_id or "-"
|
||||
team = msg_ctx.team_id or "-"
|
||||
all_roles = sorted(set(msg_ctx.roles) | set(msg_ctx.member_role_ids))
|
||||
roles = ",".join(all_roles) if all_roles else "-"
|
||||
return f"{channel}:{account_id}:{config_id}:{peer}:{parent}:{guild}:{team}:{roles}"
|
||||
|
||||
|
||||
def _peer_lookup_keys(kind: PeerKind, peer_id: str) -> list[str]:
|
||||
if kind == PeerKind.GROUP:
|
||||
return [f"group:{peer_id}", f"channel:{peer_id}"]
|
||||
if kind == PeerKind.CHANNEL:
|
||||
return [f"channel:{peer_id}", f"group:{peer_id}"]
|
||||
return [f"{kind.value}:{peer_id}"]
|
||||
|
||||
|
||||
class RouteMatcher:
|
||||
def __init__(self, session_builder: SessionKeyBuilder | None = None):
|
||||
self._lock = asyncio.Lock()
|
||||
self._bindings: list[RouteBinding] = []
|
||||
self._default_agent_config_id: int = 0
|
||||
self._cache: OrderedDict[str, RouteResult] = OrderedDict()
|
||||
self._index_cache: dict[str, _BindingIndex] = {}
|
||||
self._bindings_dirty: bool = True
|
||||
self._session_builder: SessionKeyBuilder = session_builder or SessionKeyBuilder()
|
||||
|
||||
async def set_default_agent(self, agent_config_id: int) -> None:
|
||||
async with self._lock:
|
||||
self._default_agent_config_id = agent_config_id
|
||||
|
||||
async def set_bindings(self, bindings: list[RouteBinding]) -> None:
|
||||
async with self._lock:
|
||||
self._bindings = list(bindings)
|
||||
self._bindings_dirty = True
|
||||
self._cache.clear()
|
||||
self._index_cache.clear()
|
||||
|
||||
async def invalidate_cache(self) -> None:
|
||||
async with self._lock:
|
||||
self._cache.clear()
|
||||
self._index_cache.clear()
|
||||
self._bindings_dirty = True
|
||||
|
||||
async def resolve(self, msg_ctx: MessageContext) -> RouteResult:
|
||||
if msg_ctx._verification_ctx is None:
|
||||
raise ValueError(
|
||||
"MessageContext must carry a VerificationContext before calling resolve(). "
|
||||
"RouteMatcher.resolve() requires upstream authentication (allowlist/pairing) "
|
||||
"via MessageDispatcher. Set msg_ctx._verification_ctx with verified_by and scope."
|
||||
)
|
||||
|
||||
cache_key = _build_route_cache_key(msg_ctx)
|
||||
|
||||
async with self._lock:
|
||||
if cache_key in self._cache:
|
||||
self._cache.move_to_end(cache_key)
|
||||
return self._cache[cache_key]
|
||||
|
||||
if self._bindings_dirty:
|
||||
self._rebuild_index()
|
||||
self._bindings_dirty = False
|
||||
|
||||
index_key = _build_index_key(msg_ctx)
|
||||
index = self._index_cache.get(index_key)
|
||||
if index is None:
|
||||
wildcard_key = f"{msg_ctx.channel}:{msg_ctx.account_id or '*'}:*"
|
||||
index = self._index_cache.get(wildcard_key)
|
||||
if index is None:
|
||||
channel_wildcard_key = f"{msg_ctx.channel}:*:*"
|
||||
index = self._index_cache.get(channel_wildcard_key)
|
||||
if index is None:
|
||||
return self._build_default_result(msg_ctx)
|
||||
|
||||
result = self._resolve_from_index(msg_ctx, index)
|
||||
if result is None:
|
||||
result = self._build_default_result(msg_ctx)
|
||||
|
||||
logger.debug(
|
||||
"Route resolved: matched_by=%s agent_config_id=%s priority=%s session_key=%s",
|
||||
result.matched_by, result.agent_config_id, result.matched_priority,
|
||||
result.session_key,
|
||||
)
|
||||
|
||||
if len(self._cache) >= MAX_CACHE_KEYS:
|
||||
self._cache.popitem(last=False)
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _evaluate_binding(self, binding: RouteBinding) -> EvaluatedBinding | None:
|
||||
m = binding.match
|
||||
if m is None:
|
||||
return None
|
||||
peer_state, normalized = self._normalize_peer_constraint(m.peer)
|
||||
|
||||
if peer_state == PeerConstraintState.INVALID:
|
||||
return None
|
||||
|
||||
return EvaluatedBinding(
|
||||
agent_config_id=binding.agent_config_id,
|
||||
dm_scope=binding.dm_scope,
|
||||
session_dm_scope=binding.session_dm_scope,
|
||||
normalized_peer=normalized,
|
||||
peer_state=peer_state,
|
||||
guild_id=m.guild_id,
|
||||
roles=m.roles,
|
||||
team_id=m.team_id,
|
||||
account_id=m.account_id,
|
||||
channel=m.channel,
|
||||
channel_config_id=m.channel_config_id,
|
||||
priority=binding.priority,
|
||||
)
|
||||
|
||||
def _normalize_peer_constraint(
|
||||
self, peer: PeerConstraint | None,
|
||||
) -> tuple[PeerConstraintState, NormalizedPeer | None]:
|
||||
if peer is None:
|
||||
return PeerConstraintState.NONE, None
|
||||
if not hasattr(peer, "kind") or not hasattr(peer, "peer_id"):
|
||||
return PeerConstraintState.NONE, None
|
||||
kind = peer.kind
|
||||
peer_id = peer.peer_id
|
||||
if kind is None or not peer_id:
|
||||
return PeerConstraintState.INVALID, None
|
||||
if peer_id == "*":
|
||||
return PeerConstraintState.WILDCARD, NormalizedPeer(
|
||||
kind=kind, kind_lookup=_PEER_KIND_ALIASES.get(kind, {kind}), peer_id="*"
|
||||
)
|
||||
return PeerConstraintState.VALID, NormalizedPeer(
|
||||
kind=kind, kind_lookup=_PEER_KIND_ALIASES.get(kind, {kind}), peer_id=peer_id
|
||||
)
|
||||
|
||||
def _rebuild_index(self) -> None:
|
||||
self._index_cache.clear()
|
||||
channel_account_groups: dict[str, list[EvaluatedBinding]] = {}
|
||||
|
||||
for binding in self._bindings:
|
||||
evaluated = self._evaluate_binding(binding)
|
||||
if evaluated is None:
|
||||
continue
|
||||
m = binding.match
|
||||
channel = m.channel
|
||||
account_id = m.account_id or "*"
|
||||
config_id = m.channel_config_id or "*"
|
||||
key = f"{channel}:{account_id}:{config_id}"
|
||||
channel_account_groups.setdefault(key, []).append(evaluated)
|
||||
|
||||
for key, evaled_list in channel_account_groups.items():
|
||||
self._index_cache[key] = self._build_index(evaled_list)
|
||||
|
||||
def _build_index(self, bindings: list[EvaluatedBinding]) -> _BindingIndex:
|
||||
idx = _BindingIndex()
|
||||
for b in bindings:
|
||||
state = b.peer_state
|
||||
if state == PeerConstraintState.VALID:
|
||||
np = b.normalized_peer
|
||||
for lookup_key in _peer_lookup_keys(np.kind, np.peer_id):
|
||||
idx.by_peer.setdefault(lookup_key, []).append(b)
|
||||
elif state == PeerConstraintState.WILDCARD:
|
||||
idx.by_peer_wildcard.append(b)
|
||||
elif b.guild_id is not None and b.roles:
|
||||
idx.by_guild_with_roles.setdefault(b.guild_id, []).append(b)
|
||||
elif b.guild_id is not None:
|
||||
idx.by_guild.setdefault(b.guild_id, []).append(b)
|
||||
elif b.team_id is not None:
|
||||
idx.by_team.setdefault(b.team_id, []).append(b)
|
||||
elif b.account_id is not None and b.account_id != "*":
|
||||
idx.by_account.append(b)
|
||||
else:
|
||||
idx.by_channel.append(b)
|
||||
|
||||
idx.by_peer = {k: sorted(v, key=lambda b: (-b.priority, b.agent_config_id))
|
||||
for k, v in idx.by_peer.items()}
|
||||
idx.by_peer_wildcard.sort(key=lambda b: (-b.priority, b.agent_config_id))
|
||||
idx.by_guild_with_roles = {k: sorted(v, key=lambda b: (-b.priority, b.agent_config_id))
|
||||
for k, v in idx.by_guild_with_roles.items()}
|
||||
idx.by_guild = {k: sorted(v, key=lambda b: (-b.priority, b.agent_config_id))
|
||||
for k, v in idx.by_guild.items()}
|
||||
idx.by_team = {k: sorted(v, key=lambda b: (-b.priority, b.agent_config_id))
|
||||
for k, v in idx.by_team.items()}
|
||||
idx.by_account.sort(key=lambda b: (-b.priority, b.agent_config_id))
|
||||
idx.by_channel.sort(key=lambda b: (-b.priority, b.agent_config_id))
|
||||
|
||||
return idx
|
||||
|
||||
def _resolve_from_index(self, msg: MessageContext, idx: _BindingIndex) -> RouteResult | None:
|
||||
|
||||
for lookup_key in _peer_lookup_keys(msg.peer_kind, msg.peer_id):
|
||||
candidates = idx.by_peer.get(lookup_key)
|
||||
if candidates:
|
||||
logger.debug("Route matched by peer: %s -> agent=%s", lookup_key, candidates[0].agent_config_id)
|
||||
return self._build_result(msg, candidates[0], "binding.peer")
|
||||
|
||||
if msg.parent_peer_kind and msg.parent_peer_id:
|
||||
for pk_key in _peer_lookup_keys(msg.parent_peer_kind, msg.parent_peer_id):
|
||||
candidates = idx.by_peer.get(pk_key)
|
||||
if candidates:
|
||||
np = candidates[0].normalized_peer
|
||||
if np is not None and msg.peer_kind.value in np.kind_lookup:
|
||||
logger.debug(
|
||||
"Route matched by parent peer: %s -> agent=%s",
|
||||
pk_key, candidates[0].agent_config_id,
|
||||
)
|
||||
return self._build_result(msg, candidates[0], "binding.peer.parent")
|
||||
|
||||
for wb in idx.by_peer_wildcard:
|
||||
np = wb.normalized_peer
|
||||
if np is not None and msg.peer_kind.value in np.kind_lookup:
|
||||
logger.debug("Route matched by peer wildcard -> agent=%s", wb.agent_config_id)
|
||||
return self._build_result(msg, wb, "binding.peer.wildcard")
|
||||
|
||||
if msg.guild_id:
|
||||
gr = idx.by_guild_with_roles.get(msg.guild_id)
|
||||
if gr:
|
||||
msg_all_roles = set(msg.roles) | set(msg.member_role_ids)
|
||||
for b in gr:
|
||||
if any(r in msg_all_roles for r in b.roles):
|
||||
logger.debug(
|
||||
"Route matched by guild+roles: %s -> agent=%s",
|
||||
msg.guild_id, b.agent_config_id,
|
||||
)
|
||||
return self._build_result(msg, b, "binding.guild+roles")
|
||||
|
||||
if msg.guild_id:
|
||||
g = idx.by_guild.get(msg.guild_id)
|
||||
if g:
|
||||
logger.debug("Route matched by guild: %s -> agent=%s", msg.guild_id, g[0].agent_config_id)
|
||||
return self._build_result(msg, g[0], "binding.guild")
|
||||
|
||||
if msg.team_id:
|
||||
t = idx.by_team.get(msg.team_id)
|
||||
if t:
|
||||
logger.debug("Route matched by team: %s -> agent=%s", msg.team_id, t[0].agent_config_id)
|
||||
return self._build_result(msg, t[0], "binding.team")
|
||||
|
||||
for b in idx.by_account:
|
||||
if b.account_id == msg.account_id:
|
||||
logger.debug("Route matched by account: %s -> agent=%s", msg.account_id, b.agent_config_id)
|
||||
return self._build_result(msg, b, "binding.account")
|
||||
|
||||
if idx.by_channel:
|
||||
logger.debug("Route matched by channel fallback -> agent=%s", idx.by_channel[0].agent_config_id)
|
||||
return self._build_result(msg, idx.by_channel[0], "binding.channel")
|
||||
|
||||
return None
|
||||
|
||||
def _build_result(self, msg: MessageContext, binding: EvaluatedBinding, matched_by: str) -> RouteResult:
|
||||
agent_id_str = str(binding.agent_config_id)
|
||||
effective_dm_scope = binding.session_dm_scope or binding.dm_scope
|
||||
|
||||
if msg.peer_kind == PeerKind.DIRECT:
|
||||
session_key = self._session_builder.build_dm_session(
|
||||
agent_id_str,
|
||||
msg.channel,
|
||||
msg.account_id,
|
||||
msg.peer_id,
|
||||
dm_scope=effective_dm_scope,
|
||||
)
|
||||
else:
|
||||
session_key = self._session_builder.build_group_session(
|
||||
agent_id_str,
|
||||
msg.channel,
|
||||
msg.peer_kind.value,
|
||||
msg.peer_id,
|
||||
)
|
||||
main_session_key = self._session_builder.build_main_session(agent_id_str)
|
||||
last_route_policy = "main" if session_key == main_session_key else "session"
|
||||
|
||||
return RouteResult(
|
||||
agent_config_id=binding.agent_config_id,
|
||||
channel=msg.channel,
|
||||
account_id=msg.account_id,
|
||||
session_key=session_key,
|
||||
main_session_key=main_session_key,
|
||||
matched_by=matched_by,
|
||||
matched_priority=binding.priority,
|
||||
dm_scope=binding.dm_scope,
|
||||
last_route_policy=last_route_policy,
|
||||
)
|
||||
|
||||
def _build_default_result(self, msg: MessageContext) -> RouteResult:
|
||||
agent_id_str = str(self._default_agent_config_id)
|
||||
|
||||
if msg.peer_kind == PeerKind.DIRECT:
|
||||
session_key = self._session_builder.build_dm_session(
|
||||
agent_id_str,
|
||||
msg.channel,
|
||||
msg.account_id,
|
||||
msg.peer_id,
|
||||
dm_scope="per-channel-peer",
|
||||
)
|
||||
else:
|
||||
session_key = self._session_builder.build_group_session(
|
||||
agent_id_str,
|
||||
msg.channel,
|
||||
msg.peer_kind.value,
|
||||
msg.peer_id,
|
||||
)
|
||||
main_session_key = self._session_builder.build_main_session(agent_id_str)
|
||||
last_route_policy = "main" if session_key == main_session_key else "session"
|
||||
|
||||
return RouteResult(
|
||||
agent_config_id=self._default_agent_config_id,
|
||||
channel=msg.channel,
|
||||
account_id=msg.account_id,
|
||||
session_key=session_key,
|
||||
main_session_key=main_session_key,
|
||||
matched_by="default",
|
||||
matched_priority=9,
|
||||
last_route_policy=last_route_policy,
|
||||
)
|
||||
|
||||
def resolve_agent_id(self, agent_id: str) -> str:
|
||||
return SessionKeyBuilder.normalize_agent_id(agent_id)
|
||||
85
backend/package/yuxi/channel/routing/models.py
Normal file
85
backend/package/yuxi/channel/routing/models.py
Normal file
@ -0,0 +1,85 @@
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
from types import MappingProxyType
|
||||
|
||||
|
||||
class PeerKind(StrEnum):
|
||||
DIRECT = "direct"
|
||||
GROUP = "group"
|
||||
CHANNEL = "channel"
|
||||
|
||||
|
||||
_PEER_KIND_ALIASES: MappingProxyType[str, set[str]] = MappingProxyType({
|
||||
"direct": {"direct", "dm"},
|
||||
"group": {"group", "channel"},
|
||||
"channel": {"channel", "group"},
|
||||
})
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerConstraint:
|
||||
kind: PeerKind
|
||||
peer_id: str | None = None
|
||||
|
||||
|
||||
class PeerConstraintState(StrEnum):
|
||||
VALID = "valid"
|
||||
WILDCARD = "wildcard"
|
||||
NONE = "none"
|
||||
INVALID = "invalid"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NormalizedPeer:
|
||||
kind: PeerKind
|
||||
kind_lookup: set[str]
|
||||
peer_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class BindingMatch:
|
||||
channel: str
|
||||
channel_config_id: str | None = None
|
||||
account_id: str | None = None
|
||||
peer: PeerConstraint | None = None
|
||||
guild_id: str | None = None
|
||||
team_id: str | None = None
|
||||
roles: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteBinding:
|
||||
agent_config_id: int
|
||||
match: BindingMatch
|
||||
dm_scope: str = "per-channel-peer"
|
||||
priority: int = 0
|
||||
session_dm_scope: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteResult:
|
||||
agent_config_id: int
|
||||
channel: str
|
||||
account_id: str
|
||||
session_key: str
|
||||
main_session_key: str
|
||||
matched_by: str
|
||||
matched_priority: int
|
||||
dm_scope: str = "per-channel-peer"
|
||||
last_route_policy: str = "session"
|
||||
|
||||
|
||||
@dataclass
|
||||
class EvaluatedBinding:
|
||||
agent_config_id: int
|
||||
dm_scope: str
|
||||
session_dm_scope: str | None
|
||||
normalized_peer: NormalizedPeer | None
|
||||
peer_state: PeerConstraintState
|
||||
guild_id: str | None
|
||||
roles: list[str]
|
||||
team_id: str | None
|
||||
account_id: str | None
|
||||
channel: str
|
||||
channel_config_id: str | None = None
|
||||
priority: int = 0
|
||||
159
backend/package/yuxi/channel/routing/session_key.py
Normal file
159
backend/package/yuxi/channel/routing/session_key.py
Normal file
@ -0,0 +1,159 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DM_SCOPE_MAIN = "main"
|
||||
DM_SCOPE_PER_PEER = "per-peer"
|
||||
DM_SCOPE_PER_CHANNEL_PEER = "per-channel-peer"
|
||||
DM_SCOPE_PER_ACCOUNT_CHANNEL_PEER = "per-account-channel-peer"
|
||||
|
||||
_VALID_DM_SCOPES = frozenset({
|
||||
DM_SCOPE_MAIN,
|
||||
DM_SCOPE_PER_PEER,
|
||||
DM_SCOPE_PER_CHANNEL_PEER,
|
||||
DM_SCOPE_PER_ACCOUNT_CHANNEL_PEER,
|
||||
})
|
||||
|
||||
_NAMESPACE = uuid.UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
||||
_SESSION_KEY_RE = re.compile(r"^agent:([^:]+):(.+)$")
|
||||
_AGENT_ID_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||
|
||||
|
||||
class SessionKeyBuilder:
|
||||
def __init__(self, identity_links: dict[str, list[str]] | None = None):
|
||||
self._identity_links = identity_links or {}
|
||||
self._linked_id_lower: dict[str, str] = {}
|
||||
for identity, ids in self._identity_links.items():
|
||||
for linked_id in ids:
|
||||
lid_lower = linked_id.lower()
|
||||
if lid_lower not in self._linked_id_lower:
|
||||
self._linked_id_lower[lid_lower] = identity
|
||||
|
||||
@staticmethod
|
||||
def normalize_agent_id(agent_id: str) -> str:
|
||||
aid = agent_id.strip().lower()
|
||||
if not aid:
|
||||
raise ValueError("agent_id must not be empty")
|
||||
if _AGENT_ID_RE.match(aid):
|
||||
return aid
|
||||
normalized = re.sub(r"[^a-z0-9_-]", "-", aid)
|
||||
if normalized and normalized[0].isdigit():
|
||||
normalized = "a-" + normalized
|
||||
if len(normalized) > 64:
|
||||
logger.warning("Agent ID truncated from %d to 64 chars: %r", len(normalized), normalized)
|
||||
return normalized[:64]
|
||||
|
||||
@staticmethod
|
||||
def normalize_thread_id(thread_id: str) -> str:
|
||||
tid = thread_id.strip().lower()
|
||||
return re.sub(r"[^a-z0-9_-]", "-", tid)[:64]
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_key_part(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9_.@-]", "-", value.strip().lower())[:200]
|
||||
|
||||
def build_main_session(self, agent_id: str) -> str:
|
||||
return f"agent:{self.normalize_agent_id(agent_id)}:main"
|
||||
|
||||
def build_group_session(self, agent_id: str, channel: str, peer_kind: str, peer_id: str) -> str:
|
||||
aid = self.normalize_agent_id(agent_id)
|
||||
ch = self._sanitize_key_part(channel)
|
||||
pk = self._sanitize_key_part(peer_kind)
|
||||
pid = self._sanitize_key_part(peer_id)
|
||||
return f"agent:{aid}:{ch}:{pk}:{pid}"
|
||||
|
||||
def build_dm_session(
|
||||
self,
|
||||
agent_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
peer_id: str,
|
||||
dm_scope: str = DM_SCOPE_PER_CHANNEL_PEER,
|
||||
) -> str:
|
||||
if dm_scope not in _VALID_DM_SCOPES:
|
||||
raise ValueError(f"Invalid dm_scope: {dm_scope!r}")
|
||||
aid = self.normalize_agent_id(agent_id)
|
||||
ch = self._sanitize_key_part(channel)
|
||||
acc = self._sanitize_key_part(account_id)
|
||||
resolved_peer_id = self._resolve_linked_peer_id(channel, peer_id, dm_scope)
|
||||
pid = self._sanitize_key_part(resolved_peer_id)
|
||||
|
||||
if dm_scope == DM_SCOPE_MAIN:
|
||||
return f"agent:{aid}:main"
|
||||
if dm_scope == DM_SCOPE_PER_PEER:
|
||||
return f"agent:{aid}:direct:{pid}"
|
||||
if dm_scope == DM_SCOPE_PER_CHANNEL_PEER:
|
||||
return f"agent:{aid}:{ch}:direct:{pid}"
|
||||
return f"agent:{aid}:{ch}:{acc}:direct:{pid}"
|
||||
|
||||
def build_thread_key(self, base_key: str, thread_id: str) -> str:
|
||||
tid = self.normalize_thread_id(thread_id)
|
||||
return f"{base_key}:thread:{tid}"
|
||||
|
||||
def build_group_history_key(self, channel: str, account_id: str, peer_kind: str, peer_id: str) -> str:
|
||||
ch = self._sanitize_key_part(channel)
|
||||
acc = self._sanitize_key_part(account_id)
|
||||
pk = self._sanitize_key_part(peer_kind)
|
||||
pid = self._sanitize_key_part(peer_id)
|
||||
return f"{ch}:{acc}:{pk}:{pid}"
|
||||
|
||||
def _resolve_linked_peer_id(self, channel: str, peer_id: str, dm_scope: str) -> str:
|
||||
if dm_scope == DM_SCOPE_MAIN or not self._identity_links:
|
||||
return peer_id
|
||||
|
||||
result = self._linked_id_lower.get(peer_id.lower())
|
||||
if result is not None:
|
||||
return result
|
||||
result = self._linked_id_lower.get(f"{channel}:{peer_id}".lower())
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
return peer_id
|
||||
|
||||
@staticmethod
|
||||
def parse_agent_id(session_key: str) -> str | None:
|
||||
m = _SESSION_KEY_RE.match(session_key)
|
||||
return m.group(1) if m else None
|
||||
|
||||
@staticmethod
|
||||
def build_command_target_key(
|
||||
agent_id: str,
|
||||
channel_type: str,
|
||||
target_session_part: str,
|
||||
) -> str:
|
||||
"""构建原生命令跨会话路由 Key
|
||||
|
||||
当在某个会话中执行命令需要路由到另一个会话时使用。
|
||||
例如: 在群聊 A 中 /switch B → agent:agent_id:cmd-target:channel:target
|
||||
"""
|
||||
aid = SessionKeyBuilder.normalize_agent_id(agent_id)
|
||||
target = SessionKeyBuilder.normalize_thread_id(target_session_part)
|
||||
return f"agent:{aid}:cmd-target:{channel_type}:{target}"
|
||||
|
||||
@staticmethod
|
||||
def parse_command_target_session_key(session_key: str) -> str | None:
|
||||
"""从 SessionKey 中解析出 CommandTarget 的目标部分"""
|
||||
if ":cmd-target:" not in session_key:
|
||||
return None
|
||||
parts = session_key.split(":")
|
||||
cmd_idx = parts.index("cmd-target")
|
||||
remaining = parts[cmd_idx + 1:]
|
||||
if not remaining:
|
||||
return None
|
||||
return ":".join(remaining)
|
||||
|
||||
|
||||
def session_key_to_thread_id(session_key: str) -> str:
|
||||
sha = hashlib.sha256(session_key.encode("utf-8")).digest()
|
||||
return str(uuid.uuid5(_NAMESPACE, sha.hex()))
|
||||
|
||||
|
||||
def resolve_thread_session_keys(base_key: str, thread_id: str) -> dict[str, str]:
|
||||
tid = SessionKeyBuilder.normalize_thread_id(thread_id)
|
||||
return {
|
||||
"session_key": f"{base_key}:thread:{tid}",
|
||||
"parent_session_key": base_key,
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user