From 5e65f6d79f87279c937e49bc41f744c41d837596 Mon Sep 17 00:00:00 2001 From: Burgunthy Date: Sat, 27 Jun 2026 17:46:18 +0900 Subject: [PATCH] feat(gateway): add profile-based routing for inbound messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- gateway/config.py | 22 +++ gateway/platforms/base.py | 40 ++++- gateway/profile_routing.py | 196 +++++++++++++++++++++++ gateway/run.py | 54 ++++++- hermes_constants.py | 44 ++++++ tests/gateway/test_profile_routing.py | 217 ++++++++++++++++++++++++++ 6 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 gateway/profile_routing.py create mode 100644 tests/gateway/test_profile_routing.py diff --git a/gateway/config.py b/gateway/config.py index 87f1c7014788..75f878af943d 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -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: diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index d595d7033b7b..4e8820ec6f7d 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -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, diff --git a/gateway/profile_routing.py b/gateway/profile_routing.py new file mode 100644 index 000000000000..836b50639c70 --- /dev/null +++ b/gateway/profile_routing.py @@ -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 diff --git a/gateway/run.py b/gateway/run.py index 51dcd5e2f492..3094ebf59cbd 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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// 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// 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 diff --git a/hermes_constants.py b/hermes_constants.py index 6e344844812d..3f26a4a27fd7 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -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 diff --git a/tests/gateway/test_profile_routing.py b/tests/gateway/test_profile_routing.py new file mode 100644 index 000000000000..865ec065f7e0 --- /dev/null +++ b/tests/gateway/test_profile_routing.py @@ -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")