feat(matrix-bridge-dagi): add mixed-room routing by slash/mention (M2.1)

- mixed_routing.py: parse BRIDGE_MIXED_ROOM_MAP, route by /slash > @mention > name: > default
- ingress.py: _try_enqueue_mixed for mixed rooms, session isolation {room}:{agent}, reply tagging
- config.py: bridge_mixed_room_map + bridge_mixed_defaults fields
- main.py: parse mixed config, pass to MatrixIngressLoop, expose in /health + /bridge/mappings
- docker-compose: BRIDGE_MIXED_ROOM_MAP / BRIDGE_MIXED_DEFAULTS env vars, BRIDGE_ALLOWED_AGENTS multi-value
- tests: 25 routing unit tests + 10 ingress integration tests (94 total pass)

Made-with: Cursor
This commit is contained in:
Apple
2026-03-05 01:29:18 -08:00
parent 79db053b38
commit a85a11984b
7 changed files with 1049 additions and 21 deletions

View File

@@ -33,6 +33,7 @@ except ImportError: # pragma: no cover
from .config import BridgeConfig, load_config
from .ingress import MatrixIngressLoop
from .mixed_routing import MixedRoomConfig, parse_mixed_room_map
from .rate_limit import InMemoryRateLimiter
from .room_mapping import RoomMappingConfig, parse_room_map
@@ -112,6 +113,7 @@ _config_error: Optional[str] = None
_matrix_reachable: Optional[bool] = None
_gateway_reachable: Optional[bool] = None
_room_map: Optional[RoomMappingConfig] = None
_mixed_room_config: Optional[MixedRoomConfig] = None
_rate_limiter: Optional[InMemoryRateLimiter] = None
_ingress_loop: Optional["MatrixIngressLoop"] = None # for /health queue_size
_ingress_task: Optional[asyncio.Task] = None
@@ -133,16 +135,31 @@ async def _probe_url(url: str, timeout: float = 5.0) -> bool:
@asynccontextmanager
async def lifespan(app_: Any):
global _cfg, _config_error, _matrix_reachable, _gateway_reachable
global _room_map, _rate_limiter, _ingress_loop
global _room_map, _mixed_room_config, _rate_limiter, _ingress_loop
try:
_cfg = load_config()
# Parse room mapping
# Parse regular room mapping (M1/M2.0: 1 room → 1 agent)
_room_map = parse_room_map(
os.getenv("BRIDGE_ROOM_MAP", ""),
_cfg.bridge_allowed_agents,
)
# Parse mixed room mapping (M2.1: 1 room → N agents)
if _cfg.bridge_mixed_room_map:
_mixed_room_config = parse_mixed_room_map(
_cfg.bridge_mixed_room_map,
_cfg.bridge_mixed_defaults,
_cfg.bridge_allowed_agents,
)
logger.info(
"✅ Mixed room config: %d rooms, agents=%s",
_mixed_room_config.total_rooms,
[a for r in _mixed_room_config.rooms.values() for a in r.agents],
)
else:
_mixed_room_config = None
# H1: Rate limiter (inmemory, per config)
_rate_limiter = InMemoryRateLimiter(
room_rpm=_cfg.rate_limit_room_rpm,
@@ -153,12 +170,13 @@ async def lifespan(app_: Any):
_cfg.rate_limit_room_rpm, _cfg.rate_limit_sender_rpm,
)
mixed_count = _mixed_room_config.total_rooms if _mixed_room_config else 0
logger.info(
"✅ matrix-bridge-dagi started | node=%s build=%s homeserver=%s "
"room=%s agents=%s mappings=%d",
"agents=%s mappings=%d mixed_rooms=%d",
_cfg.node_id, _cfg.build_sha, _cfg.matrix_homeserver_url,
_cfg.sofiia_room_id, list(_cfg.bridge_allowed_agents),
_room_map.total_mappings,
list(_cfg.bridge_allowed_agents),
_room_map.total_mappings, mixed_count,
)
# Connectivity smoke probes (non-blocking failures)
@@ -180,7 +198,10 @@ async def lifespan(app_: Any):
_bridge_up.set(1)
# Start ingress loop (fire-and-forget asyncio task)
if _room_map and _room_map.total_mappings > 0:
_has_rooms = (_room_map and _room_map.total_mappings > 0) or (
_mixed_room_config and _mixed_room_config.total_rooms > 0
)
if _has_rooms:
_ingress_stop = asyncio.Event()
def _on_msg(room_id: str, agent_id: str) -> None:
@@ -241,6 +262,7 @@ async def lifespan(app_: Any):
queue_max_events=_cfg.queue_max_events,
worker_concurrency=_cfg.worker_concurrency,
queue_drain_timeout_s=_cfg.queue_drain_timeout_s,
mixed_room_config=_mixed_room_config,
on_message_received=_on_msg,
on_message_replied=_on_replied,
on_gateway_error=_on_gw_error,
@@ -328,6 +350,7 @@ async def health() -> Dict[str, Any]:
"gateway": _cfg.dagi_gateway_url,
"gateway_reachable": _gateway_reachable,
"mappings_count": _room_map.total_mappings if _room_map else 0,
"mixed_rooms_count": _mixed_room_config.total_rooms if _mixed_room_config else 0,
"config_ok": True,
"rate_limiter": _rate_limiter.stats() if _rate_limiter else None,
"queue": {
@@ -350,12 +373,15 @@ async def bridge_mappings() -> Dict[str, Any]:
"ok": False,
"error": _config_error or "service not initialised",
"mappings": [],
"mixed_rooms": [],
}
return {
"ok": True,
"total": _room_map.total_mappings,
"allowed_agents": list(_cfg.bridge_allowed_agents),
"mappings": _room_map.as_summary(),
"mixed_rooms_total": _mixed_room_config.total_rooms if _mixed_room_config else 0,
"mixed_rooms": _mixed_room_config.as_summary() if _mixed_room_config else [],
}