mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(gateway): add profile-based routing for inbound messages
Adds gateway.profile_routes config that routes specific Discord
guilds/channels/threads (and other platforms) to different profiles.
The routing engine uses hierarchical specificity matching
(thread > channel > guild) with bounded LRU caching for forum post
resolution.
Routing result is stamped on source.profile by BasePlatformAdapter
.build_source() at inbound time. When gateway.multiplex_profiles is on,
the existing _profile_runtime_scope machinery picks up source.profile
and runs the whole turn inside the profile's HERMES_HOME — so memory,
skills, config, and secrets all resolve to that profile automatically.
No new isolation code is added; this PR only adds the routing decision
layer on top of the existing multiplexing infrastructure.
Configuration:
gateway:
multiplex_profiles: true
profile_routes:
- name: server-default
platform: discord
guild_id: "GUILD_ID"
profile: server-profile
- name: special-channel
platform: discord
guild_id: "GUILD_ID"
chat_id: "CHANNEL_ID"
profile: channel-profile
When multiplex_profiles is off, profile_routes is ignored (no behavior
change for single-profile gateways).
Tests: 29 unit tests covering specificity scoring, hierarchical
matching, path-traversal validation, and config parsing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6efec39ecf
commit
5e65f6d79f
6 changed files with 568 additions and 5 deletions
|
|
@ -721,6 +721,11 @@ class GatewayConfig:
|
|||
# fresh session exactly as if the reset policy had fired. 0 = disabled.
|
||||
session_store_max_age_days: int = 90
|
||||
|
||||
# Profile-based routing: route specific guilds/channels/threads to
|
||||
# different profiles. See gateway/profile_routing.py. Each entry is a
|
||||
# dict with: name, platform, profile, and optional guild_id/chat_id/thread_id.
|
||||
profile_routes: list = field(default_factory=list)
|
||||
|
||||
def get_connected_platforms(self) -> List[Platform]:
|
||||
"""Return list of platforms that are enabled and configured."""
|
||||
connected = []
|
||||
|
|
@ -827,6 +832,7 @@ class GatewayConfig:
|
|||
"unauthorized_dm_behavior": self.unauthorized_dm_behavior,
|
||||
"streaming": self.streaming.to_dict(),
|
||||
"session_store_max_age_days": self.session_store_max_age_days,
|
||||
"profile_routes": self.profile_routes,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
@ -919,6 +925,10 @@ class GatewayConfig:
|
|||
except (TypeError, ValueError):
|
||||
session_store_max_age_days = 90
|
||||
|
||||
# Parse profile routes (validated by gateway.profile_routing)
|
||||
from gateway.profile_routing import parse_profile_routes
|
||||
profile_routes = parse_profile_routes(data.get("profile_routes") or [])
|
||||
|
||||
return cls(
|
||||
platforms=platforms,
|
||||
default_reset_policy=default_policy,
|
||||
|
|
@ -941,6 +951,7 @@ class GatewayConfig:
|
|||
unauthorized_dm_behavior=unauthorized_dm_behavior,
|
||||
streaming=StreamingConfig.from_dict(data.get("streaming", {})),
|
||||
session_store_max_age_days=session_store_max_age_days,
|
||||
profile_routes=profile_routes,
|
||||
)
|
||||
|
||||
def get_unauthorized_dm_behavior(self, platform: Optional[Platform] = None) -> str:
|
||||
|
|
@ -1053,6 +1064,17 @@ def load_gateway_config() -> GatewayConfig:
|
|||
if "multiplex_profiles" in yaml_cfg:
|
||||
gw_data["multiplex_profiles"] = yaml_cfg["multiplex_profiles"]
|
||||
|
||||
# Profile-based routing rules: accept either top-level
|
||||
# ``profile_routes`` or the nested ``gateway.profile_routes`` form
|
||||
# (matching the multiplex_profiles parity above).
|
||||
_pr = yaml_cfg.get("profile_routes")
|
||||
if _pr is None:
|
||||
_gw_section = yaml_cfg.get("gateway")
|
||||
if isinstance(_gw_section, dict):
|
||||
_pr = _gw_section.get("profile_routes")
|
||||
if isinstance(_pr, list):
|
||||
gw_data["profile_routes"] = _pr
|
||||
|
||||
gateway_section = yaml_cfg.get("gateway")
|
||||
if isinstance(gateway_section, dict):
|
||||
if "multiplex_profiles" in gateway_section and "multiplex_profiles" not in gw_data:
|
||||
|
|
|
|||
|
|
@ -5507,10 +5507,47 @@ class BasePlatformAdapter(ABC):
|
|||
auto_thread_created: bool = False,
|
||||
auto_thread_initial_name: Optional[str] = None,
|
||||
) -> SessionSource:
|
||||
"""Helper to build a SessionSource for this platform."""
|
||||
"""Helper to build a SessionSource for this platform.
|
||||
|
||||
When ``gateway.profile_routes`` is configured, the routing engine
|
||||
resolves the matching profile from guild/chat/thread and stamps it on
|
||||
``source.profile``. Downstream code (``_resolve_profile_home_for_source``
|
||||
in run.py) reads that field to enter ``_profile_runtime_scope`` for
|
||||
per-profile HERMES_HOME isolation.
|
||||
"""
|
||||
# Normalize empty topic to None
|
||||
if chat_topic is not None and not chat_topic.strip():
|
||||
chat_topic = None
|
||||
|
||||
# Resolve profile from configured routes (None when no match / no routes)
|
||||
profile = None
|
||||
runner = getattr(self, "gateway_runner", None)
|
||||
if runner is not None:
|
||||
try:
|
||||
profile = runner._profile_name_for_source(
|
||||
SessionSource(
|
||||
platform=self.platform,
|
||||
chat_id=str(chat_id),
|
||||
chat_name=chat_name,
|
||||
chat_type=chat_type,
|
||||
user_id=str(user_id) if user_id else None,
|
||||
user_name=user_name,
|
||||
thread_id=str(thread_id) if thread_id else None,
|
||||
chat_topic=chat_topic.strip() if chat_topic else None,
|
||||
user_id_alt=user_id_alt,
|
||||
chat_id_alt=chat_id_alt,
|
||||
is_bot=is_bot,
|
||||
guild_id=str(guild_id) if guild_id else None,
|
||||
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
|
||||
message_id=str(message_id) if message_id else None,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Profile resolution failed for %s/%s, defaulting to active profile",
|
||||
self.platform, chat_id, exc_info=True,
|
||||
)
|
||||
|
||||
return SessionSource(
|
||||
platform=self.platform,
|
||||
chat_id=str(chat_id),
|
||||
|
|
@ -5527,6 +5564,7 @@ class BasePlatformAdapter(ABC):
|
|||
guild_id=str(guild_id) if guild_id else None,
|
||||
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
|
||||
message_id=str(message_id) if message_id else None,
|
||||
profile=profile,
|
||||
role_authorized=role_authorized,
|
||||
auto_thread_created=auto_thread_created,
|
||||
auto_thread_initial_name=auto_thread_initial_name,
|
||||
|
|
|
|||
196
gateway/profile_routing.py
Normal file
196
gateway/profile_routing.py
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
"""Profile-based routing for the gateway with hierarchical matching.
|
||||
|
||||
Allows a single Hermes instance to route specific Discord guilds/channels/threads
|
||||
to different profiles — each with their own model, tools, memory, and persona.
|
||||
|
||||
Matching priority (most specific first):
|
||||
1. platform + chat_id + thread_id (exact thread) — specificity 8
|
||||
2. platform + chat_id (channel route) — specificity 4
|
||||
3. platform + guild_id (guild/server route) — specificity 2
|
||||
4. No match → default profile
|
||||
|
||||
Hierarchical matching:
|
||||
For Discord forum channels, checks the full parent chain:
|
||||
- Forum channel → Forum post → Comment
|
||||
- Matches if any level of the hierarchy matches a configured route
|
||||
|
||||
Configuration (config.yaml):
|
||||
|
||||
gateway:
|
||||
profile_routes:
|
||||
- name: server-default
|
||||
platform: discord
|
||||
guild_id: "YOUR_GUILD_ID"
|
||||
profile: server-profile
|
||||
|
||||
- name: special-channel
|
||||
platform: discord
|
||||
guild_id: "YOUR_GUILD_ID"
|
||||
chat_id: "YOUR_CHANNEL_ID"
|
||||
profile: channel-profile
|
||||
|
||||
- name: thread-route
|
||||
platform: discord
|
||||
chat_id: "YOUR_CHANNEL_ID"
|
||||
thread_id: "YOUR_THREAD_ID"
|
||||
profile: thread-profile
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Bounded LRU cache for forum post to channel mappings.
|
||||
# OrderedDict evicts least-recently-used entries when full.
|
||||
_MAX_FORUM_CACHE = 10000
|
||||
_forum_post_cache: OrderedDict[str, str] = OrderedDict() # post_id -> channel_id
|
||||
|
||||
def register_forum_post(post_id: str, channel_id: str) -> None:
|
||||
"""Register a forum post's parent channel for hierarchical matching."""
|
||||
_forum_post_cache[post_id] = channel_id
|
||||
_forum_post_cache.move_to_end(post_id)
|
||||
while len(_forum_post_cache) > _MAX_FORUM_CACHE:
|
||||
_forum_post_cache.popitem(last=False)
|
||||
logger.debug("Registered forum post %s -> channel %s", post_id, channel_id)
|
||||
|
||||
|
||||
def resolve_forum_channel(post_id: str) -> Optional[str]:
|
||||
"""Get the parent channel ID for a forum post, if cached."""
|
||||
return _forum_post_cache.get(post_id)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProfileRoute:
|
||||
"""A single routing rule that maps a platform scope to a profile."""
|
||||
|
||||
name: str
|
||||
platform: str
|
||||
profile: str
|
||||
guild_id: Optional[str] = None
|
||||
chat_id: Optional[str] = None
|
||||
thread_id: Optional[str] = None
|
||||
enabled: bool = True
|
||||
|
||||
@property
|
||||
def specificity(self) -> int:
|
||||
"""Higher value = more specific match."""
|
||||
s = 0
|
||||
if self.guild_id:
|
||||
s += 2
|
||||
if self.chat_id:
|
||||
s += 4
|
||||
if self.thread_id:
|
||||
s += 8
|
||||
return s
|
||||
|
||||
def matches(
|
||||
self,
|
||||
platform: str,
|
||||
guild_id: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Return True if this route matches the given source fields.
|
||||
|
||||
Supports hierarchical matching for Discord forums:
|
||||
- Direct channel match: chat_id == route.chat_id
|
||||
- Thread in channel: parent_chat_id == route.chat_id
|
||||
- Forum post: parent_chat_id is the forum post, check if post belongs to route's channel
|
||||
- Comment on forum post: parent_chat_id is the forum post, check hierarchy
|
||||
"""
|
||||
if not self.enabled:
|
||||
return False
|
||||
if self.platform != platform:
|
||||
return False
|
||||
if self.thread_id and self.thread_id != thread_id:
|
||||
return False
|
||||
|
||||
# Hierarchical chat_id matching
|
||||
if self.chat_id:
|
||||
# Direct match
|
||||
if self.chat_id == chat_id:
|
||||
return True
|
||||
# Parent match (thread or direct child)
|
||||
if self.chat_id == parent_chat_id:
|
||||
return True
|
||||
# Forum post hierarchy: check if parent_chat_id is a forum post
|
||||
# that belongs to this channel
|
||||
if parent_chat_id:
|
||||
parent_channel = resolve_forum_channel(parent_chat_id)
|
||||
if parent_channel and self.chat_id == parent_channel:
|
||||
return True
|
||||
|
||||
# If chat_id was specified but didn't match any level, fail
|
||||
if self.chat_id:
|
||||
return False
|
||||
|
||||
if self.guild_id and self.guild_id != guild_id:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
|
||||
"""Parse profile_routes from config.yaml into ProfileRoute objects.
|
||||
|
||||
Returns routes sorted by specificity (most specific first).
|
||||
"""
|
||||
if not raw:
|
||||
return []
|
||||
routes: List[ProfileRoute] = []
|
||||
for entry in raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name", "")
|
||||
platform = entry.get("platform", "")
|
||||
profile = entry.get("profile", "")
|
||||
if not platform or not profile:
|
||||
logger.warning(
|
||||
"Skipping profile route %s: missing platform or profile",
|
||||
name,
|
||||
)
|
||||
continue
|
||||
# Validate profile name to prevent path traversal
|
||||
try:
|
||||
from hermes_constants import validate_profile_name as _validate
|
||||
profile = _validate(profile)
|
||||
except (ValueError, ImportError):
|
||||
logger.warning("Skipping profile route %s: invalid profile name %r", name, profile)
|
||||
continue
|
||||
routes.append(
|
||||
ProfileRoute(
|
||||
name=name,
|
||||
platform=platform,
|
||||
profile=profile,
|
||||
guild_id=entry.get("guild_id"),
|
||||
chat_id=entry.get("chat_id"),
|
||||
thread_id=entry.get("thread_id"),
|
||||
enabled=entry.get("enabled", True),
|
||||
)
|
||||
)
|
||||
# Sort: most specific first so the first match wins.
|
||||
routes.sort(key=lambda r: r.specificity, reverse=True)
|
||||
logger.debug("Loaded %d profile routes (most-specific-first)", len(routes))
|
||||
return routes
|
||||
|
||||
|
||||
def match_profile_route(
|
||||
routes: List[ProfileRoute],
|
||||
platform: str,
|
||||
guild_id: Optional[str] = None,
|
||||
chat_id: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
parent_chat_id: Optional[str] = None,
|
||||
) -> Optional[ProfileRoute]:
|
||||
"""Return the best-matching route, or None for no match."""
|
||||
for route in routes:
|
||||
if route.matches(platform, guild_id=guild_id, chat_id=chat_id, thread_id=thread_id, parent_chat_id=parent_chat_id):
|
||||
return route
|
||||
return None
|
||||
|
|
@ -17293,16 +17293,62 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
persist_user_timestamp=persist_user_timestamp,
|
||||
)
|
||||
|
||||
def _profile_name_for_source(self, source: SessionSource) -> Optional[str]:
|
||||
"""Resolve the profile name for an inbound source via configured routes.
|
||||
|
||||
Returns ``None`` when no routes are configured or no route matches.
|
||||
Callers (``build_source``, ``_resolve_profile_home_for_source``) treat
|
||||
``None`` as "use the default/active profile". When
|
||||
``gateway.profile_routes`` is configured, the most specific matching
|
||||
route wins (guild < channel < thread). See :mod:`gateway.profile_routing`
|
||||
for matching rules.
|
||||
"""
|
||||
config = getattr(self, "config", None)
|
||||
routes = getattr(config, "profile_routes", None)
|
||||
if not routes:
|
||||
return None
|
||||
from gateway.profile_routing import match_profile_route
|
||||
try:
|
||||
matched = match_profile_route(
|
||||
routes,
|
||||
platform=source.platform.value,
|
||||
guild_id=getattr(source, "guild_id", None),
|
||||
chat_id=source.chat_id,
|
||||
thread_id=getattr(source, "thread_id", None),
|
||||
parent_chat_id=getattr(source, "parent_chat_id", None),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Profile route matching failed for %s/%s, falling back to default",
|
||||
source.platform, source.chat_id, exc_info=True,
|
||||
)
|
||||
return None
|
||||
if matched:
|
||||
return matched.profile
|
||||
logger.info(
|
||||
"No profile route matched: platform=%s chat_id=%s thread_id=%s parent_chat_id=%s",
|
||||
source.platform.value, source.chat_id,
|
||||
getattr(source, "thread_id", None), getattr(source, "parent_chat_id", None),
|
||||
)
|
||||
return None
|
||||
|
||||
def _resolve_profile_home_for_source(self, source: SessionSource) -> "Path":
|
||||
"""Resolve which profile's HERMES_HOME should serve this inbound source.
|
||||
|
||||
Prefers the profile the source was routed to (``source.profile`` — set
|
||||
by the /p/<profile>/ URL prefix or a per-credential adapter), falling
|
||||
back to the active profile (the multiplexer's own home).
|
||||
Resolution order:
|
||||
1. ``source.profile`` — set by /p/<profile>/ URL prefix, per-credential
|
||||
adapter ownership, OR profile_routes matching at ``build_source`` time.
|
||||
2. ``_profile_name_for_source`` — re-run routing here as a defensive
|
||||
fallback for sources that bypass ``build_source``.
|
||||
3. The active profile (the multiplexer's own home).
|
||||
"""
|
||||
from hermes_cli.profiles import get_active_profile_name, get_profile_dir
|
||||
try:
|
||||
name = (source.profile or "").strip() or get_active_profile_name() or "default"
|
||||
name = (source.profile or "").strip()
|
||||
if not name:
|
||||
name = self._profile_name_for_source(source)
|
||||
if not name:
|
||||
name = get_active_profile_name() or "default"
|
||||
return get_profile_dir(name)
|
||||
except Exception:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import stat
|
|||
import sys
|
||||
import sysconfig
|
||||
from contextvars import ContextVar, Token
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
|
|
@ -1217,3 +1218,46 @@ FINISH_REASON_LENGTH = "length"
|
|||
|
||||
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
||||
OPENROUTER_MODELS_URL = f"{OPENROUTER_BASE_URL}/models"
|
||||
|
||||
# ─── Profile Normalization ────────────────────────────────────────────────
|
||||
|
||||
# Standard (non-isolated) profile names. All three are treated identically
|
||||
# for gating and filtering purposes. Named profiles (e.g. "ai-expert")
|
||||
# are anything *not* in this tuple.
|
||||
STANDARD_PROFILES: tuple[str, ...] = ("main", "default")
|
||||
|
||||
|
||||
_VALID_PROFILE_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
|
||||
def normalize_profile(name: str | None) -> str:
|
||||
"""Canonicalize a profile name. Never raises.
|
||||
|
||||
Returns ``"main"`` for all standard/empty profiles so that downstream
|
||||
code only needs to compare against a single value. Named profiles
|
||||
are returned as-is (lowercased, stripped).
|
||||
"""
|
||||
if not name or name.strip().lower() in STANDARD_PROFILES:
|
||||
return "main"
|
||||
return name.strip().lower()
|
||||
|
||||
|
||||
def validate_profile_name(name: str | None) -> str:
|
||||
"""Validate and canonicalize. Raises ValueError for invalid names.
|
||||
|
||||
Use at config parse boundaries. normalize_profile() is the safe
|
||||
runtime version that never raises.
|
||||
"""
|
||||
result = normalize_profile(name)
|
||||
if result == "main":
|
||||
return result
|
||||
if not _VALID_PROFILE_RE.match(result):
|
||||
raise ValueError(
|
||||
f"Invalid profile name: {name!r} (must match [a-z0-9][a-z0-9_-]*)"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def is_standard_profile(name: str | None) -> bool:
|
||||
"""Return True for default/main/None/empty — the unscoped profile."""
|
||||
return not name or name.strip().lower() in STANDARD_PROFILES
|
||||
|
|
|
|||
217
tests/gateway/test_profile_routing.py
Normal file
217
tests/gateway/test_profile_routing.py
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
"""Tests for gateway/profile_routing.py — profile-based routing."""
|
||||
|
||||
import pytest
|
||||
from gateway.profile_routing import (
|
||||
ProfileRoute,
|
||||
parse_profile_routes,
|
||||
match_profile_route,
|
||||
)
|
||||
|
||||
|
||||
class TestProfileRoute:
|
||||
def test_specificity_thread(self):
|
||||
r = ProfileRoute(name="t", platform="discord", profile="p",
|
||||
guild_id="g", chat_id="c", thread_id="t")
|
||||
assert r.specificity == 14 # 2 + 4 + 8
|
||||
|
||||
def test_specificity_channel(self):
|
||||
r = ProfileRoute(name="c", platform="discord", profile="p",
|
||||
guild_id="g", chat_id="c")
|
||||
assert r.specificity == 6 # 2 + 4
|
||||
|
||||
def test_specificity_guild(self):
|
||||
r = ProfileRoute(name="g", platform="discord", profile="p",
|
||||
guild_id="g")
|
||||
assert r.specificity == 2
|
||||
|
||||
def test_specificity_minimal(self):
|
||||
r = ProfileRoute(name="m", platform="telegram", profile="p")
|
||||
assert r.specificity == 0
|
||||
|
||||
def test_frozen(self):
|
||||
r = ProfileRoute(name="x", platform="discord", profile="p")
|
||||
with pytest.raises(AttributeError):
|
||||
r.name = "y"
|
||||
|
||||
|
||||
class TestProfileRouteMatching:
|
||||
def test_exact_thread_match(self):
|
||||
r = ProfileRoute(name="t", platform="discord", profile="trader",
|
||||
guild_id="111", chat_id="222", thread_id="333")
|
||||
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
|
||||
assert not r.matches("discord", guild_id="111", chat_id="222", thread_id="444")
|
||||
|
||||
def test_channel_match(self):
|
||||
r = ProfileRoute(name="c", platform="discord", profile="helper",
|
||||
chat_id="222")
|
||||
assert r.matches("discord", chat_id="222")
|
||||
assert not r.matches("discord", chat_id="333")
|
||||
assert not r.matches("telegram", chat_id="222")
|
||||
|
||||
def test_guild_match(self):
|
||||
r = ProfileRoute(name="g", platform="discord", profile="server",
|
||||
guild_id="111")
|
||||
assert r.matches("discord", guild_id="111")
|
||||
assert not r.matches("discord", guild_id="222")
|
||||
|
||||
def test_disabled_route_no_match(self):
|
||||
r = ProfileRoute(name="d", platform="discord", profile="off",
|
||||
guild_id="111", enabled=False)
|
||||
assert not r.matches("discord", guild_id="111")
|
||||
|
||||
def test_guild_route_matches_any_channel_in_guild(self):
|
||||
r = ProfileRoute(name="g", platform="discord", profile="server",
|
||||
guild_id="111")
|
||||
assert r.matches("discord", guild_id="111", chat_id="222")
|
||||
assert r.matches("discord", guild_id="111", chat_id="222", thread_id="333")
|
||||
|
||||
def test_extra_fields_ignored(self):
|
||||
r = ProfileRoute(name="g", platform="discord", profile="server",
|
||||
guild_id="111")
|
||||
assert r.matches("discord", guild_id="111", chat_id="any")
|
||||
|
||||
|
||||
class TestParseProfileRoutes:
|
||||
def test_empty(self):
|
||||
assert parse_profile_routes(None) == []
|
||||
assert parse_profile_routes([]) == []
|
||||
|
||||
def test_valid_routes_sorted_by_specificity(self):
|
||||
raw = [
|
||||
{"name": "guild", "platform": "discord", "profile": "p", "guild_id": "1"},
|
||||
{"name": "thread", "platform": "discord", "profile": "p",
|
||||
"guild_id": "1", "chat_id": "2", "thread_id": "3"},
|
||||
{"name": "channel", "platform": "discord", "profile": "p", "chat_id": "2"},
|
||||
]
|
||||
routes = parse_profile_routes(raw)
|
||||
names = [r.name for r in routes]
|
||||
assert names == ["thread", "channel", "guild"]
|
||||
|
||||
def test_skips_invalid(self):
|
||||
raw = [
|
||||
{"platform": "discord"},
|
||||
{"profile": "p"},
|
||||
"not a dict",
|
||||
{"name": "ok", "platform": "telegram", "profile": "p"},
|
||||
]
|
||||
routes = parse_profile_routes(raw)
|
||||
assert len(routes) == 1
|
||||
assert routes[0].name == "ok"
|
||||
|
||||
def test_enabled_flag(self):
|
||||
raw = [
|
||||
{"name": "off", "platform": "discord", "profile": "p",
|
||||
"guild_id": "1", "enabled": False},
|
||||
{"name": "on", "platform": "discord", "profile": "p", "guild_id": "1"},
|
||||
]
|
||||
routes = parse_profile_routes(raw)
|
||||
assert not routes[0].enabled
|
||||
assert routes[1].enabled
|
||||
|
||||
|
||||
class TestMatchProfileRoute:
|
||||
def test_no_routes(self):
|
||||
assert match_profile_route([], "discord") is None
|
||||
|
||||
def test_returns_first_match(self):
|
||||
routes = [
|
||||
ProfileRoute(name="thread", platform="discord", profile="trader",
|
||||
guild_id="1", chat_id="2", thread_id="3"),
|
||||
ProfileRoute(name="channel", platform="discord", profile="helper",
|
||||
chat_id="2"),
|
||||
]
|
||||
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2", thread_id="3")
|
||||
assert m is not None
|
||||
assert m.profile == "trader"
|
||||
|
||||
def test_falls_through_to_channel(self):
|
||||
routes = [
|
||||
ProfileRoute(name="thread", platform="discord", profile="trader",
|
||||
guild_id="1", chat_id="2", thread_id="3"),
|
||||
ProfileRoute(name="channel", platform="discord", profile="helper",
|
||||
chat_id="2"),
|
||||
]
|
||||
m = match_profile_route(routes, "discord", guild_id="1", chat_id="2")
|
||||
assert m is not None
|
||||
assert m.profile == "helper"
|
||||
|
||||
def test_no_match_returns_none(self):
|
||||
routes = [
|
||||
ProfileRoute(name="r", platform="telegram", profile="p"),
|
||||
]
|
||||
assert match_profile_route(routes, "discord") is None
|
||||
|
||||
|
||||
class TestSessionKeyIntegration:
|
||||
def test_default_profile_key(self):
|
||||
from gateway.session import build_session_key, SessionSource, Platform
|
||||
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
|
||||
chat_type="channel", user_id="456")
|
||||
key = build_session_key(src)
|
||||
assert key.startswith("agent:main:")
|
||||
|
||||
def test_custom_profile_key(self):
|
||||
from gateway.session import build_session_key, SessionSource, Platform
|
||||
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
|
||||
chat_type="channel", user_id="456")
|
||||
key = build_session_key(src, profile="trader")
|
||||
assert key.startswith("agent:trader:")
|
||||
assert key == "agent:trader:discord:channel:123:456"
|
||||
|
||||
def test_isolated_sessions(self):
|
||||
from gateway.session import build_session_key, SessionSource, Platform
|
||||
src = SessionSource(platform=Platform.DISCORD, chat_id="123",
|
||||
chat_type="channel", user_id="456")
|
||||
key_default = build_session_key(src)
|
||||
key_trader = build_session_key(src, profile="trader")
|
||||
assert key_default != key_trader
|
||||
|
||||
def test_dm_profile_scoped(self):
|
||||
from gateway.session import build_session_key, SessionSource, Platform
|
||||
src = SessionSource(platform=Platform.DISCORD, chat_id="999",
|
||||
chat_type="dm", user_id="111")
|
||||
key = build_session_key(src, profile="bot2")
|
||||
assert key == "agent:bot2:discord:dm:999"
|
||||
|
||||
|
||||
|
||||
class TestParentChatIdMatching:
|
||||
"""Thread messages carry thread_id as chat_id; parent_chat_id is the channel."""
|
||||
|
||||
def test_channel_route_matches_via_parent_chat_id(self):
|
||||
r = ProfileRoute(name="ch", platform="discord", profile="trader",
|
||||
chat_id="222")
|
||||
assert r.matches("discord", chat_id="333", parent_chat_id="222")
|
||||
|
||||
def test_channel_route_no_match_wrong_parent(self):
|
||||
r = ProfileRoute(name="ch", platform="discord", profile="trader",
|
||||
chat_id="222")
|
||||
assert not r.matches("discord", chat_id="333", parent_chat_id="444")
|
||||
|
||||
def test_match_profile_route_with_parent_chat_id(self):
|
||||
routes = [
|
||||
ProfileRoute(name="ch", platform="discord", profile="trader",
|
||||
chat_id="222"),
|
||||
]
|
||||
m = match_profile_route(routes, "discord", chat_id="333", parent_chat_id="222")
|
||||
assert m is not None
|
||||
assert m.profile == "trader"
|
||||
|
||||
def test_thread_id_does_not_match_parent_chat_id(self):
|
||||
"""thread_id only matches the actual thread_id, never parent_chat_id.
|
||||
Discord snowflakes are globally unique, so thread_id != channel_id."""
|
||||
r = ProfileRoute(name="th", platform="discord", profile="helper",
|
||||
thread_id="555")
|
||||
assert r.matches("discord", thread_id="555")
|
||||
assert not r.matches("discord", parent_chat_id="555")
|
||||
|
||||
def test_no_parent_chat_id_still_works(self):
|
||||
r = ProfileRoute(name="ch", platform="discord", profile="trader",
|
||||
chat_id="222")
|
||||
assert r.matches("discord", chat_id="222")
|
||||
|
||||
def test_guild_route_matches_with_parent_chat_id(self):
|
||||
"""Guild routes should match regardless of chat_id or parent_chat_id."""
|
||||
r = ProfileRoute(name="g", platform="discord", profile="server",
|
||||
guild_id="111")
|
||||
assert r.matches("discord", guild_id="111", chat_id="333", parent_chat_id="444")
|
||||
Loading…
Add table
Add a link
Reference in a new issue