From 231166cde8e06caf8478898706c1aa492493650e Mon Sep 17 00:00:00 2001 From: Kris <2893855659@qq.com> Date: Thu, 21 May 2026 10:32:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel/routing):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E8=B7=AF=E7=94=B1=E7=9B=B8=E5=85=B3=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了完整的消息路由匹配系统,包含数据模型、会话密钥生成和路由匹配逻辑,提供了从消息上下文到代理配置的路由能力 --- .../package/yuxi/channel/routing/__init__.py | 29 ++ .../package/yuxi/channel/routing/matcher.py | 387 ++++++++++++++++++ .../package/yuxi/channel/routing/models.py | 85 ++++ .../yuxi/channel/routing/session_key.py | 159 +++++++ 4 files changed, 660 insertions(+) create mode 100644 backend/package/yuxi/channel/routing/__init__.py create mode 100644 backend/package/yuxi/channel/routing/matcher.py create mode 100644 backend/package/yuxi/channel/routing/models.py create mode 100644 backend/package/yuxi/channel/routing/session_key.py diff --git a/backend/package/yuxi/channel/routing/__init__.py b/backend/package/yuxi/channel/routing/__init__.py new file mode 100644 index 00000000..c79064df --- /dev/null +++ b/backend/package/yuxi/channel/routing/__init__.py @@ -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", +] diff --git a/backend/package/yuxi/channel/routing/matcher.py b/backend/package/yuxi/channel/routing/matcher.py new file mode 100644 index 00000000..947dadd2 --- /dev/null +++ b/backend/package/yuxi/channel/routing/matcher.py @@ -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) diff --git a/backend/package/yuxi/channel/routing/models.py b/backend/package/yuxi/channel/routing/models.py new file mode 100644 index 00000000..bf53e2d1 --- /dev/null +++ b/backend/package/yuxi/channel/routing/models.py @@ -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 diff --git a/backend/package/yuxi/channel/routing/session_key.py b/backend/package/yuxi/channel/routing/session_key.py new file mode 100644 index 00000000..705d4f92 --- /dev/null +++ b/backend/package/yuxi/channel/routing/session_key.py @@ -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, + }