PR-M1.2 — room-to-agent mapping: - adds room_mapping.py: parse BRIDGE_ROOM_MAP (format: agent:!room_id:server) - RoomMappingConfig with O(1) room→agent lookup, agent allowlist check - /bridge/mappings endpoint (read-only ops summary, no secrets) - health endpoint now includes mappings_count - 21 tests for parsing, validation, allowlist, summary PR-M1.3 — Matrix ingress loop: - adds ingress.py: MatrixIngressLoop asyncio task - sync_poll → extract → dedupe → _invoke_gateway (POST /v1/invoke) - gateway payload: agent_id, node_id, message, metadata (transport, room_id, event_id, sender) - exponential backoff on errors (2s..60s) - joins all mapped rooms at startup - metric callbacks: on_message_received, on_gateway_error - graceful shutdown via asyncio.Event - 5 ingress tests (invoke, dedupe, callbacks, empty-map idle) Synapse setup (docker-compose.synapse-node1.yml): - fixed volume: bind mount ./synapse-data instead of named volume - added port mapping 127.0.0.1:8008:8008 Synapse running on NODA1 (localhost:8008), bot @dagi_bridge:daarion.space created, room !QwHczWXgefDHBEVkTH:daarion.space created, all 4 values in .env on NODA1. Made-with: Cursor
157 lines
5.1 KiB
Python
157 lines
5.1 KiB
Python
"""
|
|
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: !<localpart>:<server>
|
|
_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
|