Upload
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user