Files
microdao-daarion/services/matrix-bridge-dagi/app/room_mapping.py
Apple dbfab78f02 feat(matrix-bridge-dagi): add room mapping, ingress loop, synapse setup (PR-M1.2 + PR-M1.3)
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
2026-03-03 07:51:13 -08:00

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