forked from Keelrleek/Harbor
Messages, threads, acknowledgements, claims, bands, tasks, review items and stop flags for a team of AI agents; the chat page and board; host hooks and MCP server; autopilot; tools/watch.py. Put under version control after the weekend's changes so they have history and can be rolled back. Local only.
70 lines
3.5 KiB
Python
70 lines
3.5 KiB
Python
"""Windows notifications from the Harbor server, so the human hears about questions and urgent messages even with the
|
|
chat page closed. Off by default; switched on from the board (stored per project in the database)."""
|
|
import base64
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from xml.sax.saxutils import escape
|
|
|
|
APP_ID = r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe"
|
|
|
|
|
|
def toast_script(title, body):
|
|
xml = ('<toast><visual><binding template="ToastGeneric"><text>' + escape(str(title)[:80]) + "</text><text>"
|
|
+ escape(str(body)[:300]) + "</text></binding></visual></toast>")
|
|
xml = xml.replace("'", "''") # inside a PowerShell single-quoted string
|
|
return ("[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] "
|
|
"| Out-Null\n"
|
|
"[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null\n"
|
|
"$xml = New-Object Windows.Data.Xml.Dom.XmlDocument\n"
|
|
f"$xml.LoadXml('{xml}')\n"
|
|
"$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)\n"
|
|
f"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{APP_ID}').Show($toast)\n")
|
|
|
|
|
|
def toast(title, body):
|
|
"""Show a Windows notification; returns True when PowerShell accepted it."""
|
|
if sys.platform != "win32" or os.environ.get("HARBOR_NO_TOASTS"):
|
|
return False
|
|
encoded = base64.b64encode(toast_script(title, body).encode("utf-16-le")).decode("ascii")
|
|
try:
|
|
r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded],
|
|
capture_output=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
return r.returncode == 0
|
|
except (OSError, subprocess.SubprocessError):
|
|
return False
|
|
|
|
|
|
def pending_for_human(h, limit=3):
|
|
"""Messages to the human that deserve a notification and were not notified yet (questions, urgent, direct)."""
|
|
row = h.conn.execute("SELECT value FROM meta WHERE key='toast_last'").fetchone()
|
|
last = int(row["value"]) if row else 0
|
|
rows = [h._message_dict(r) for r in h.conn.execute(
|
|
h.MESSAGE_SELECT + " JOIN deliveries d ON d.message_id=m.id AND d.agent=? WHERE d.read_at IS NULL AND m.id>? "
|
|
"ORDER BY m.id LIMIT 200", (h.human, last))]
|
|
worth = sorted((m for m in rows if m["recipients"] != ["All"] or m["importance"] == "urgent"
|
|
or m["kind"] == "question"), key=lambda m: m["id"])
|
|
top = max([m["id"] for m in rows], default=last)
|
|
return worth[-limit:], top
|
|
|
|
|
|
def enabled(h):
|
|
row = h.conn.execute("SELECT value FROM meta WHERE key='toasts'").fetchone()
|
|
return bool(row and row["value"] == "on")
|
|
|
|
|
|
def set_enabled(h, on):
|
|
top = h.conn.execute("SELECT COALESCE(MAX(id), 0) FROM messages").fetchone()[0]
|
|
|
|
def do(c):
|
|
c.execute("INSERT INTO meta(key, value) VALUES ('toasts', ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
("on" if on else "off",))
|
|
c.execute("INSERT INTO meta(key, value) VALUES ('toast_last', ?) ON CONFLICT(key) DO UPDATE SET "
|
|
"value=excluded.value", (str(top),)) # never notify the backlog
|
|
h._write(do)
|
|
|
|
|
|
def mark(h, top):
|
|
h._write(lambda c: c.execute("INSERT INTO meta(key, value) VALUES ('toast_last', ?) ON CONFLICT(key) DO UPDATE "
|
|
"SET value=excluded.value", (str(top),)))
|