Voice input reused the bound text channel's cached source and replaced only `user_id`, so a second speaker inherited the profile resolved for the first. The ingress gate does not re-resolve a source that already carries a profile. Re-resolve at the voice call site instead of clearing the profile: clearing would drop the receiving bot and could re-home voice arriving on a secondary profile's own bot. `_voice_input_source` reattaches the transport provenance `from_dict` discards, and `_stamp_routed_profile` takes the receiving bot's profile as the fallback when no route matches. Kanban re-subscription could not repair a row created before sender capture: `user_id` was only written by the INSERT, so a legacy row stayed senderless and the notifier's conservative fallback left it undeliverable for good. It now self-heals like `user_id_alt`. An explicit `user_id: null` or empty string is rejected instead of widening the route to every sender, and `to_dict` omits the field when unset so a round-trip cannot reintroduce it. Numeric `0` from an adapter normalizes to "0" rather than being dropped, without changing the shared coercion used by the other fields.
186 lines
8.2 KiB
Python
186 lines
8.2 KiB
Python
"""Profile-based routing: route senders/locations to different profiles.
|
|
|
|
Matching is conjunctive; the highest additive specificity wins (``gateway.profile_routes``
|
|
in config.yaml): user_id 16, thread_id 8, chat_id 4, guild_id 2, else the default profile.
|
|
For Discord threads/forum posts ``parent_chat_id`` carries the direct parent, so a
|
|
channel route also matches any thread/post under it.
|
|
|
|
A route applies only to messages received by the bot of its ``bot_profile`` (default: the
|
|
default profile's shared bot). Telegram DM ``chat_id == user_id`` for EVERY bot, so without
|
|
this a ``chat_id`` route meant for the shared bot would re-home the same user's DM with a
|
|
dedicated secondary bot into another profile (#104933).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Baileys and Cloud share phone/JID/LID identity rules; other platforms compare exactly.
|
|
_WHATSAPP_IDENTITY_PLATFORMS = {"whatsapp", "whatsapp_cloud"}
|
|
_WHATSAPP_NON_USER_SUFFIXES = ("@g.us", "@broadcast", "@newsletter")
|
|
|
|
|
|
def _is_whatsapp_non_user_chat(chat_id: Optional[str]) -> bool:
|
|
"""True for group / broadcast / newsletter JIDs — not a sender identity."""
|
|
return bool(chat_id) and str(chat_id).strip().lower().endswith(_WHATSAPP_NON_USER_SUFFIXES)
|
|
|
|
|
|
def _whatsapp_user_chat_ids_match(platform: str, left: Optional[str], right: Optional[str]) -> bool:
|
|
"""True when two WhatsApp *user* chat_ids refer to the same person.
|
|
|
|
Uses ``expand_whatsapp_aliases`` (same helper as session keys and adapter allowlists) so a bare
|
|
number, JID and LID collapse to one identity. Group/broadcast JIDs are chats, not senders;
|
|
non-WhatsApp platforms → False.
|
|
"""
|
|
if (
|
|
(platform or "").strip().lower() not in _WHATSAPP_IDENTITY_PLATFORMS
|
|
or not left or not right
|
|
or _is_whatsapp_non_user_chat(left) or _is_whatsapp_non_user_chat(right)
|
|
):
|
|
return False
|
|
from gateway.whatsapp_identity import expand_whatsapp_aliases
|
|
|
|
left_aliases = expand_whatsapp_aliases(str(left))
|
|
return bool(left_aliases and left_aliases & expand_whatsapp_aliases(str(right)))
|
|
|
|
|
|
class ProfileRouteRejected(RuntimeError):
|
|
"""An explicit route matched a profile this gateway does not serve."""
|
|
|
|
|
|
@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
|
|
bot_profile: Optional[str] = None # None = the default profile's bot
|
|
user_id: Optional[str] = None
|
|
|
|
@property
|
|
def specificity(self) -> int:
|
|
"""Higher value = more specific match."""
|
|
return 2 * bool(self.guild_id) + 4 * bool(self.chat_id) + 8 * bool(self.thread_id) + 16 * bool(self.user_id)
|
|
|
|
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,
|
|
adapter_profile: Optional[str] = None, user_id: Optional[str] = None,
|
|
) -> bool:
|
|
"""True if every discriminator the route declares holds (AND).
|
|
|
|
``chat_id`` matches the channel directly or as the parent of a thread/forum post; WhatsApp
|
|
``chat_id`` also matches across number/JID/LID after the exact check (groups/broadcasts stay exact-only).
|
|
``adapter_profile`` is the profile owning the receiving bot (``None`` = default); it must equal
|
|
the route's ``bot_profile``.
|
|
"""
|
|
if not self.enabled or self.platform != platform:
|
|
return False
|
|
if _bot_profile_key(self.bot_profile) != _bot_profile_key(adapter_profile):
|
|
return False
|
|
if self.user_id is not None and (not self.user_id.strip() or self.user_id != user_id):
|
|
return False
|
|
if self.thread_id and self.thread_id != thread_id:
|
|
return False
|
|
if (
|
|
self.chat_id
|
|
and self.chat_id not in (chat_id, parent_chat_id)
|
|
and not _whatsapp_user_chat_ids_match(platform, self.chat_id, chat_id)
|
|
and not _whatsapp_user_chat_ids_match(platform, self.chat_id, parent_chat_id)
|
|
):
|
|
return False
|
|
return not (self.guild_id and self.guild_id != guild_id)
|
|
|
|
|
|
def _bot_profile_key(name: Optional[str]) -> Optional[str]:
|
|
"""``None`` for the default profile, else the profile name (mirrors ``set_owner_profile``)."""
|
|
name = (name or "").strip()
|
|
return None if not name or name == "default" else name
|
|
|
|
|
|
def _coerce_route_id(value: Any) -> Optional[str]:
|
|
"""Normalize a route discriminator to str for strict equality matching.
|
|
|
|
PyYAML loads unquoted numeric IDs as ``int`` while ``SessionSource`` fields are ``str``. Only
|
|
``int`` (not ``bool``) is coerced; floats stringify to something (``"123.0"``) that can never
|
|
match, so they get a load-time warning instead.
|
|
|
|
``bool`` is an ``int`` subclass but never a valid id; floats and other types stringify to something
|
|
(``"123.0"``) that can never equal an inbound id — recreating the silent no-match this exists to fix —
|
|
so they are passed through with a load-time warning instead of being silently "fixed" (#86470).
|
|
"""
|
|
if value is None or isinstance(value, str):
|
|
return value
|
|
if isinstance(value, int) and not isinstance(value, bool):
|
|
return str(value)
|
|
logger.warning(
|
|
"Profile route discriminator %r (type %s) can never match an inbound "
|
|
"id — quote it in config.yaml (e.g. chat_id: \"%s\").",
|
|
value, type(value).__name__, value,
|
|
)
|
|
return str(value)
|
|
|
|
|
|
def parse_profile_routes(raw: Optional[List[Dict[str, Any]]]) -> List[ProfileRoute]:
|
|
"""Parse profile_routes from config.yaml, sorted 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 (lazy import: cycle).
|
|
try:
|
|
from hermes_cli.profiles import normalize_profile_name, validate_profile_name
|
|
|
|
profile = normalize_profile_name(profile)
|
|
validate_profile_name(profile)
|
|
except (ValueError, ImportError):
|
|
logger.warning("Skipping profile route %s: invalid profile name %r", name, profile)
|
|
continue
|
|
has_user_id, user_id = "user_id" in entry, entry.get("user_id")
|
|
if has_user_id and (user_id is None or isinstance(user_id, str) and not user_id.strip()):
|
|
logger.warning("Skipping profile route %s: user_id cannot be null or empty", name)
|
|
continue
|
|
routes.append(ProfileRoute(
|
|
name=name, platform=platform, profile=profile,
|
|
guild_id=_coerce_route_id(entry.get("guild_id")),
|
|
chat_id=_coerce_route_id(entry.get("chat_id")),
|
|
thread_id=_coerce_route_id(entry.get("thread_id")),
|
|
user_id=_coerce_route_id(user_id),
|
|
enabled=entry.get("enabled", True),
|
|
bot_profile=_bot_profile_key(entry.get("bot_profile")),
|
|
))
|
|
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,
|
|
adapter_profile: Optional[str] = None, user_id: Optional[str] = None,
|
|
) -> Optional[ProfileRoute]:
|
|
"""Return the first (most specific) matching route, or None."""
|
|
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, adapter_profile=adapter_profile,
|
|
user_id=user_id):
|
|
return route
|
|
return None
|