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
This commit is contained in:
156
services/matrix-bridge-dagi/app/room_mapping.py
Normal file
156
services/matrix-bridge-dagi/app/room_mapping.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user