""" node_policy — Node-aware routing for matrix-bridge-dagi. Resolves which NODA (NODA1, NODA2, …) a message should be tagged with based on: 1. Explicit `node=X` kwarg in the message body (mixed rooms only) 2. Dynamic store override (PolicyStore, set by operators via !node set) ← M6.0 3. Static per-room mapping from BRIDGE_ROOM_NODE_MAP env 4. BRIDGE_DEFAULT_NODE (fallback) The resolved node_id is embedded in the Router metadata so downstream services (Router / Memory / Agent) can apply per-node policies. This module does NOT change the HTTP endpoint called — the Router URL stays the same. """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Dict, FrozenSet, Optional, Tuple # Regex to find 'node=X' anywhere in message text (case-insensitive) _NODE_KWARG_RE = re.compile(r"\bnode=(\w+)\b", re.IGNORECASE) # Node resolution sources (priority order) NODE_SOURCE_EXPLICIT = "explicit" NODE_SOURCE_STORE = "store" # M6.0: dynamic PolicyStore override NODE_SOURCE_ROOM_MAP = "room_map" NODE_SOURCE_DEFAULT = "default" @dataclass(frozen=True) class NodeResolution: """Result of resolving the target node for a message.""" node_id: str source: str rejected_node: Optional[str] = None # set when explicit node was not allowlisted @dataclass class NodePolicy: """ Node resolution policy. Attributes: allowed_nodes: Set of valid node names (uppercase). default_node: Fallback node when no explicit or room-map match. room_node_map: Optional per-room override (room_id → node_id). """ allowed_nodes: FrozenSet[str] default_node: str room_node_map: Dict[str, str] = field(default_factory=dict) def resolve( self, room_id: str, explicit_node: Optional[str] = None, store_override: Optional[str] = None, ) -> NodeResolution: """ Resolve target node for a message. Priority (highest → lowest): 1. explicit_node kwarg (user-supplied, mixed rooms only) 2. store_override — dynamic PolicyStore entry (M6.0) 3. room_node_map — static BRIDGE_ROOM_NODE_MAP env entry 4. default_node """ if explicit_node is not None: upper = explicit_node.upper() if upper in self.allowed_nodes: return NodeResolution(node_id=upper, source=NODE_SOURCE_EXPLICIT) # Rejected — report bad value and fall through to best available fallback = self._fallback(room_id, store_override) return NodeResolution( node_id=fallback.node_id, source=fallback.source, rejected_node=upper, ) return self._fallback(room_id, store_override) def _fallback( self, room_id: str, store_override: Optional[str] = None, ) -> NodeResolution: """Resolve node without an explicit kwarg (store → env map → default).""" if store_override is not None: upper = store_override.upper() if upper in self.allowed_nodes: return NodeResolution(node_id=upper, source=NODE_SOURCE_STORE) if room_id in self.room_node_map: mapped = self.room_node_map[room_id].upper() if mapped in self.allowed_nodes: return NodeResolution(node_id=mapped, source=NODE_SOURCE_ROOM_MAP) return NodeResolution(node_id=self.default_node, source=NODE_SOURCE_DEFAULT) def as_info_dict(self) -> dict: """Return a safe dict for health/ops snapshots (no secrets).""" return { "default_node": self.default_node, "allowed_nodes": sorted(self.allowed_nodes), "room_overrides": len(self.room_node_map), } def parse_node_policy( raw_allowed: str, default_node: str, raw_room_map: str, ) -> NodePolicy: """ Parse node policy from env-style config strings. raw_allowed: "NODA1,NODA2" default_node: "NODA1" raw_room_map: "!roomA:server=NODA2;!roomB:server=NODA1" """ default = default_node.strip().upper() or "NODA1" allowed: FrozenSet[str] = frozenset( n.strip().upper() for n in raw_allowed.split(",") if n.strip() ) if not allowed: allowed = frozenset([default]) elif default not in allowed: # default must always be reachable allowed = allowed | frozenset([default]) room_map: Dict[str, str] = {} for entry in raw_room_map.split(";"): entry = entry.strip() if not entry or "=" not in entry: continue room_id_raw, node_raw = entry.split("=", 1) room_id = room_id_raw.strip() node = node_raw.strip().upper() if room_id and node: room_map[room_id] = node return NodePolicy( allowed_nodes=allowed, default_node=default, room_node_map=room_map, ) def extract_node_kwarg(text: str) -> Tuple[Optional[str], str]: """ Extract 'node=X' kwarg from message text. Returns (node_id_or_None, cleaned_text_without_kwarg). Preserves the rest of the message — no other transformations. Example: "/sofiia node=NODA2 Hello!" → ("NODA2", "/sofiia Hello!") """ m = _NODE_KWARG_RE.search(text) if m: node = m.group(1).upper() cleaned = _NODE_KWARG_RE.sub("", text, count=1) # Collapse runs of whitespace introduced by the removal cleaned = " ".join(cleaned.split()) return node, cleaned return None, text def node_rejected_reply(requested: str, allowed: FrozenSet[str]) -> str: """Reply when user requests a node not in the allowlist.""" allowed_list = ", ".join(f"`{n}`" for n in sorted(allowed)) return ( f"⚠️ Unknown node: `{requested}`\n" f"Allowed: {allowed_list}\n" f"_Example: `/sofiia node=NODA1 Hello!`_" )