""" Room-to-Agent Mapping — Phase M1 Parses BRIDGE_ROOM_MAP env var and provides: - room_id → agent_id lookup - agent_id allowlist validation (from BRIDGE_ALLOWED_AGENTS) - summary for /bridge/mappings endpoint Format of BRIDGE_ROOM_MAP: "agent_id:!room_id:server,agent2:!room2:server" e.g. "sofiia:!QwHczWXgefDHBEVkTH:daarion.space" Multiple mappings separated by comma. """ import logging import re from dataclasses import dataclass, field from typing import Dict, FrozenSet, List, Optional logger = logging.getLogger(__name__) # Room ID format: !: _ROOM_ID_RE = re.compile(r"^![A-Za-z0-9\-_.]+:[A-Za-z0-9\-_.]+$") @dataclass(frozen=True) class RoomMapping: """Single room → agent binding.""" room_id: str # e.g. "!abc:daarion.space" agent_id: str # e.g. "sofiia" @dataclass class RoomMappingConfig: """ Parsed mapping configuration. Attributes: mappings: List of RoomMapping (room_id → agent_id) allowed_agents: Frozenset of allowlisted agent ids """ mappings: List[RoomMapping] = field(default_factory=list) allowed_agents: FrozenSet[str] = field(default_factory=frozenset) # Internal index for O(1) lookup _room_to_agent: Dict[str, str] = field(default_factory=dict, repr=False, compare=False) _agent_to_rooms: Dict[str, List[str]] = field(default_factory=dict, repr=False, compare=False) def __post_init__(self) -> None: self._rebuild_index() def _rebuild_index(self) -> None: self._room_to_agent = {m.room_id: m.agent_id for m in self.mappings} self._agent_to_rooms = {} for m in self.mappings: self._agent_to_rooms.setdefault(m.agent_id, []).append(m.room_id) def agent_for_room(self, room_id: str) -> Optional[str]: """ Returns agent_id for a room_id, or None if room is not mapped. Also returns None if agent is not in allowed_agents. """ agent = self._room_to_agent.get(room_id) if agent is None: return None if agent not in self.allowed_agents: logger.warning("Room %s mapped to agent %s which is NOT in allowed_agents", room_id, agent) return None return agent def rooms_for_agent(self, agent_id: str) -> List[str]: """Returns list of room_ids mapped to an agent_id.""" return list(self._agent_to_rooms.get(agent_id, [])) def as_summary(self) -> List[Dict]: """ Returns a safe summary list for the /bridge/mappings endpoint. Room IDs are NOT secrets (they identify chat rooms), but tokens are never included. """ return [ { "room_id": m.room_id, "agent_id": m.agent_id, "allowed": m.agent_id in self.allowed_agents, } for m in self.mappings ] @property def total_mappings(self) -> int: return len(self.mappings) def parse_room_map(raw: str, allowed_agents: FrozenSet[str]) -> RoomMappingConfig: """ Parse BRIDGE_ROOM_MAP string into RoomMappingConfig. Format: "agent_id:!room_id:server[,agent2:!room2:server2,...]" Raises ValueError on malformed entries (but skips warn-only issues). """ mappings: List[RoomMapping] = [] errors: List[str] = [] if not raw or not raw.strip(): return RoomMappingConfig(mappings=[], allowed_agents=allowed_agents) for idx, entry in enumerate(raw.split(",")): entry = entry.strip() if not entry: continue # Find the colon that separates agent_id from room_id # Room IDs look like !localpart:server — the separator colon is after agent_id # Format: "sofiia:!QwHczWXgefDHBEVkTH:daarion.space" # ^agent^:^------room_id---------^ colon_idx = entry.find(":") if colon_idx < 1: errors.append(f"Entry[{idx}] missing agent:room separator: {entry!r}") continue agent_id = entry[:colon_idx].strip() room_id = entry[colon_idx + 1:].strip() if not agent_id: errors.append(f"Entry[{idx}] empty agent_id in: {entry!r}") continue if not room_id: errors.append(f"Entry[{idx}] empty room_id in: {entry!r}") continue if not _ROOM_ID_RE.match(room_id): errors.append( f"Entry[{idx}] invalid room_id format (expected !localpart:server): {room_id!r}" ) continue if agent_id not in allowed_agents: logger.warning( "Entry[%d] agent %r not in allowed_agents %s — mapping accepted but will be rejected at runtime", idx, agent_id, set(allowed_agents), ) mappings.append(RoomMapping(room_id=room_id, agent_id=agent_id)) if errors: raise ValueError(f"BRIDGE_ROOM_MAP parse errors: {'; '.join(errors)}") config = RoomMappingConfig(mappings=mappings, allowed_agents=allowed_agents) logger.info( "Room mapping loaded: %d entries, allowed_agents=%s", len(mappings), set(allowed_agents), ) return config