"""Desktop notification bridge (Linux/D-Bus). Lets Bolt react to things that happen without you: a build finishing, a calendar alert, a message arriving. Notifications are tailed from `dbus-monitor`, filtered, rate-limited, and handed to the controller, which forwards them through the normal converse() path — so the pet can say "your deploy just went green" instead of only ever answering questions. Off by default (NOTIFICATION_BRIDGE): every forwarded notification is a round trip to the server, and an unfiltered desktop can be very chatty. NOTIFICATION_FILTER (a regex) is the main knob for keeping it useful. The dbus-monitor *parsing* is pure and unit tested; only the subprocess plumbing needs a real session bus. """ from __future__ import annotations import platform import re import shutil import subprocess import threading from dataclasses import dataclass from typing import Callable, Iterable, Iterator, Optional _STRING_LINE = re.compile(r'^\s*string\s+"(.*)"\s*$') _BLOCK_START = re.compile(r"^(method call|signal|method return|error)\b") @dataclass(frozen=True) class Notification: app: str summary: str body: str def as_text(self) -> str: parts = [p for p in (self.summary, self.body) if p] joined = " — ".join(parts) return f"{self.app}: {joined}" if self.app else joined def iter_notifications(lines: Iterable[str]) -> Iterator[Notification]: """Pull Notification records out of a `dbus-monitor` line stream. A Notify call prints its arguments one per line after the header; the string arguments arrive in the order app_name, app_icon, summary, body (replaces_id is a uint32, so it isn't in the string list). Anything that doesn't look like that is skipped rather than guessed at. """ collecting = False strings: list[str] = [] def _emit() -> Optional[Notification]: if len(strings) < 3: return None return Notification(app=strings[0].strip(), summary=strings[2].strip(), body=(strings[3].strip() if len(strings) > 3 else "")) for line in lines: if _BLOCK_START.match(line): if collecting: notification = _emit() if notification is not None: yield notification collecting = "member=Notify" in line strings = [] continue if not collecting: continue match = _STRING_LINE.match(line) if match: strings.append(match.group(1)) if collecting: notification = _emit() if notification is not None: yield notification class NotificationGate: """Filter + rate limit. Clock is passed in (monotonic seconds) so the rate limiting is testable without sleeping.""" def __init__(self, pattern: str = "", min_interval: float = 60.0): self._min_interval = max(0.0, min_interval) self._last_forwarded = None self._pattern = None if (pattern or "").strip(): try: self._pattern = re.compile(pattern, re.IGNORECASE) except re.error: self._pattern = None # a broken regex shouldn't mute everything def matches(self, notification: Notification) -> bool: if self._pattern is None: return True return bool(self._pattern.search(notification.as_text())) def should_forward(self, notification: Notification, now: float) -> bool: if not notification.as_text().strip(): return False if not self.matches(notification): return False if self._last_forwarded is not None and now - self._last_forwarded < self._min_interval: return False self._last_forwarded = now return True def available() -> bool: return platform.system() == "Linux" and shutil.which("dbus-monitor") is not None class NotificationWatcher: """Tails dbus-monitor on a daemon thread, calling *callback* per notification. Best-effort: if the session bus isn't reachable it reports why via start() and stays off.""" _ARGS = [ "dbus-monitor", "--session", "interface='org.freedesktop.Notifications',member='Notify'", ] def __init__(self, callback: Callable[[Notification], None]): self._callback = callback self._process: Optional[subprocess.Popen] = None self._thread: Optional[threading.Thread] = None self._running = False def start(self) -> Optional[str]: """None on success, otherwise the reason the bridge is off.""" if not available(): return "notification bridge needs Linux + dbus-monitor — skipping" try: self._process = subprocess.Popen( self._ARGS, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, bufsize=1, ) except Exception as exc: return f"couldn't start dbus-monitor ({exc}) — notification bridge off" self._running = True self._thread = threading.Thread(target=self._pump, name="notification-bridge", daemon=True) self._thread.start() return None def _pump(self) -> None: assert self._process is not None and self._process.stdout is not None try: for notification in iter_notifications(self._process.stdout): if not self._running: return try: self._callback(notification) except Exception: pass # one bad notification shouldn't end the bridge except Exception: pass def stop(self) -> None: self._running = False if self._process is not None: try: self._process.terminate() except Exception: pass self._process = None