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:
@@ -4,6 +4,7 @@ Bridges Matrix/Element rooms to DAGI agents via Gateway.
|
||||
|
||||
M1 scope: 1 room ↔ 1 agent (Sofiia), audit via sofiia-console internal endpoint.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -31,6 +32,8 @@ except ImportError: # pragma: no cover
|
||||
_PROM_OK = False
|
||||
|
||||
from .config import BridgeConfig, load_config
|
||||
from .ingress import MatrixIngressLoop
|
||||
from .room_mapping import RoomMappingConfig, parse_room_map
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -71,6 +74,9 @@ _cfg: Optional[BridgeConfig] = None
|
||||
_config_error: Optional[str] = None
|
||||
_matrix_reachable: Optional[bool] = None # probed at startup
|
||||
_gateway_reachable: Optional[bool] = None # probed at startup
|
||||
_room_map: Optional[RoomMappingConfig] = None
|
||||
_ingress_task: Optional[asyncio.Task] = None
|
||||
_ingress_stop: Optional[asyncio.Event] = None
|
||||
|
||||
|
||||
async def _probe_url(url: str, timeout: float = 5.0) -> bool:
|
||||
@@ -87,14 +93,24 @@ async def _probe_url(url: str, timeout: float = 5.0) -> bool:
|
||||
# ── Lifespan ──────────────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(app_: Any):
|
||||
global _cfg, _config_error, _matrix_reachable, _gateway_reachable
|
||||
global _cfg, _config_error, _matrix_reachable, _gateway_reachable, _room_map
|
||||
try:
|
||||
_cfg = load_config()
|
||||
|
||||
# Parse room mapping
|
||||
_room_map = parse_room_map(
|
||||
os.getenv("BRIDGE_ROOM_MAP", ""),
|
||||
_cfg.bridge_allowed_agents,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"✅ matrix-bridge-dagi started | node=%s build=%s homeserver=%s room=%s agents=%s",
|
||||
"✅ matrix-bridge-dagi started | node=%s build=%s homeserver=%s "
|
||||
"room=%s agents=%s mappings=%d",
|
||||
_cfg.node_id, _cfg.build_sha, _cfg.matrix_homeserver_url,
|
||||
_cfg.sofiia_room_id, list(_cfg.bridge_allowed_agents),
|
||||
_room_map.total_mappings,
|
||||
)
|
||||
|
||||
# Connectivity smoke probes (non-blocking failures)
|
||||
_matrix_reachable = await _probe_url(
|
||||
f"{_cfg.matrix_homeserver_url}/_matrix/client/versions"
|
||||
@@ -112,12 +128,52 @@ async def lifespan(app_: Any):
|
||||
logger.warning("⚠️ DAGI Gateway NOT reachable: %s", _cfg.dagi_gateway_url)
|
||||
if _PROM_OK:
|
||||
_bridge_up.set(1)
|
||||
except RuntimeError as exc:
|
||||
|
||||
# Start ingress loop (fire-and-forget asyncio task)
|
||||
if _room_map and _room_map.total_mappings > 0:
|
||||
_ingress_stop = asyncio.Event()
|
||||
|
||||
def _on_msg(room_id: str, agent_id: str) -> None:
|
||||
if _PROM_OK:
|
||||
_messages_received.labels(room_id=room_id, agent_id=agent_id).inc()
|
||||
|
||||
def _on_gw_error(error_type: str) -> None:
|
||||
if _PROM_OK:
|
||||
_gateway_errors.labels(error_type=error_type).inc()
|
||||
|
||||
ingress = MatrixIngressLoop(
|
||||
matrix_homeserver_url=_cfg.matrix_homeserver_url,
|
||||
matrix_access_token=_cfg.matrix_access_token,
|
||||
matrix_user_id=_cfg.matrix_user_id,
|
||||
gateway_url=_cfg.dagi_gateway_url,
|
||||
node_id=_cfg.node_id,
|
||||
room_map=_room_map,
|
||||
on_message_received=_on_msg,
|
||||
on_gateway_error=_on_gw_error,
|
||||
)
|
||||
_ingress_task = asyncio.create_task(
|
||||
ingress.run(_ingress_stop),
|
||||
name="matrix_ingress_loop",
|
||||
)
|
||||
logger.info("✅ Ingress loop task started")
|
||||
else:
|
||||
logger.warning("⚠️ No room mappings — ingress loop NOT started")
|
||||
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
_config_error = str(exc)
|
||||
logger.error("❌ Config error: %s", _config_error)
|
||||
if _PROM_OK:
|
||||
_bridge_up.set(0)
|
||||
yield
|
||||
# Shutdown: cancel ingress loop
|
||||
if _ingress_stop:
|
||||
_ingress_stop.set()
|
||||
if _ingress_task and not _ingress_task.done():
|
||||
_ingress_task.cancel()
|
||||
try:
|
||||
await asyncio.wait_for(_ingress_task, timeout=5.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
logger.info("matrix-bridge-dagi shutting down")
|
||||
|
||||
# ── App ───────────────────────────────────────────────────────────────────────
|
||||
@@ -166,9 +222,32 @@ async def health() -> Dict[str, Any]:
|
||||
"allowed_agents": list(_cfg.bridge_allowed_agents),
|
||||
"gateway": _cfg.dagi_gateway_url,
|
||||
"gateway_reachable": _gateway_reachable,
|
||||
"mappings_count": _room_map.total_mappings if _room_map else 0,
|
||||
"config_ok": True,
|
||||
}
|
||||
|
||||
|
||||
# ── Bridge Mappings (read-only ops endpoint) ───────────────────────────────────
|
||||
@app.get("/bridge/mappings")
|
||||
async def bridge_mappings() -> Dict[str, Any]:
|
||||
"""
|
||||
Returns room-to-agent mapping summary.
|
||||
Safe for ops visibility — no secrets included.
|
||||
"""
|
||||
if _cfg is None or _room_map is None:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": _config_error or "service not initialised",
|
||||
"mappings": [],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"total": _room_map.total_mappings,
|
||||
"allowed_agents": list(_cfg.bridge_allowed_agents),
|
||||
"mappings": _room_map.as_summary(),
|
||||
}
|
||||
|
||||
|
||||
# ── Metrics ───────────────────────────────────────────────────────────────────
|
||||
@app.get("/metrics")
|
||||
async def metrics():
|
||||
|
||||
Reference in New Issue
Block a user