Let outside services and computers' browser notifications give bots work
A bot hook (bot_hooks) is a secret URL, POST /api/hooks/<token>, whose calls queue the hook's brief plus the raw body as a task, per user and bot like a routine. Only the token's hash is kept (shown once, rotatable); an optional HMAC-SHA256 signing secret is stored encrypted. Calls are rate limited per address and per hook, recorded in bot_hook_events (pruned daily), deduplicated by delivery id, and a call while the last run still waits joins its brief instead of queueing another. The payload is fenced data and HookRunNote tells the bot never to take instructions from it; failed runs retry like routines. A hook can instead watch a computer's browser notifications: the app installs a small watcher that owns org.freedesktop.Notifications on the sandbox's session bus, so Chrome hands it every notification (it only looks for that service at start-up, so Chrome is restarted once, never mid-task), and it posts each one to its hooks, optionally filtered. Computers reach the app at COMPUTER_APP_URL or the app container's name. Users make hooks in the conversation's Hooks modal and manage them all on /admin/routines; bots never create or change hooks. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Bump with every change to `NotifyWatchScript`: a computer whose running
|
||||
* watcher is older gets the new script and a restart.
|
||||
*/
|
||||
export const NotifyWatchVersion = "1";
|
||||
|
||||
/**
|
||||
* The browser-notification watcher the app runs on a computer
|
||||
* (`syncComputerNotifyWatch`), as `gem`, with the sandbox's own Python
|
||||
* and GLib. It owns `org.freedesktop.Notifications` on the session bus,
|
||||
* so Chrome sends its notifications to it instead of its silent built-in
|
||||
* message center (Chrome looks for the service only when it starts), and
|
||||
* posts each one as JSON to every hook URL in `~/.cobble/notify-hooks`.
|
||||
* Nothing is shown on screen. Chrome writes the site on the body's first
|
||||
* line; it's split out as `origin`.
|
||||
*/
|
||||
export const NotifyWatchScript = String.raw`#!/usr/bin/env python3
|
||||
"""Cobble's browser notification watcher (version VERSION_PLACEHOLDER).
|
||||
|
||||
Forwards Chrome's notifications to the hook URLs in ~/.cobble/notify-hooks.
|
||||
Started and kept alive by the Cobble app; a second copy exits.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from gi.repository import Gio, GLib
|
||||
|
||||
VERSION = "VERSION_PLACEHOLDER"
|
||||
DIR = os.path.join(os.path.expanduser("~"), ".cobble")
|
||||
HOOKS_FILE = os.path.join(DIR, "notify-hooks")
|
||||
STATE_FILE = os.path.join(DIR, "notify-watch.json")
|
||||
BUS_NAME = "org.freedesktop.Notifications"
|
||||
OBJECT_PATH = "/org/freedesktop/Notifications"
|
||||
TAG = re.compile(r"<[^>]+>")
|
||||
CHROME_ACTIONS = {"Activate", "Settings"}
|
||||
|
||||
INTROSPECTION = """
|
||||
<node>
|
||||
<interface name="org.freedesktop.Notifications">
|
||||
<method name="GetCapabilities"><arg type="as" direction="out"/></method>
|
||||
<method name="Notify">
|
||||
<arg type="s" direction="in"/><arg type="u" direction="in"/><arg type="s" direction="in"/>
|
||||
<arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="as" direction="in"/>
|
||||
<arg type="a{sv}" direction="in"/><arg type="i" direction="in"/><arg type="u" direction="out"/>
|
||||
</method>
|
||||
<method name="CloseNotification"><arg type="u" direction="in"/></method>
|
||||
<method name="GetServerInformation">
|
||||
<arg type="s" direction="out"/><arg type="s" direction="out"/>
|
||||
<arg type="s" direction="out"/><arg type="s" direction="out"/>
|
||||
</method>
|
||||
<signal name="NotificationClosed"><arg type="u"/><arg type="u"/></signal>
|
||||
<signal name="ActionInvoked"><arg type="u"/><arg type="s"/></signal>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
next_id = [0]
|
||||
|
||||
|
||||
def log(message):
|
||||
print(time.strftime("%Y-%m-%d %H:%M:%S"), message, flush=True)
|
||||
|
||||
|
||||
def hook_urls():
|
||||
try:
|
||||
with open(HOOKS_FILE, encoding="utf-8") as f:
|
||||
return [line.strip() for line in f if line.strip().startswith("http")]
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
|
||||
def plain(text):
|
||||
text = TAG.sub("", text or "")
|
||||
return text.replace("<", "<").replace(">", ">").replace(""", '"').replace("&", "&").strip()
|
||||
|
||||
|
||||
def split_origin(body):
|
||||
parts = body.split("\n\n", 1)
|
||||
if len(parts) == 2 and parts[0] and " " not in parts[0] and "." in parts[0]:
|
||||
return parts[0], parts[1]
|
||||
return "", body
|
||||
|
||||
|
||||
def post(url, data, key):
|
||||
for attempt in range(3):
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url, data=data, method="POST",
|
||||
headers={"Content-Type": "application/json", "Idempotency-Key": key},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=10) as res:
|
||||
if res.status < 500:
|
||||
return
|
||||
except urllib.error.HTTPError as error:
|
||||
if error.code < 500 and error.code != 429:
|
||||
log("a hook refused a notification: %s" % error.code)
|
||||
return
|
||||
except Exception as error:
|
||||
log("couldn't reach a hook: %s" % error)
|
||||
time.sleep(5 * 2 ** attempt)
|
||||
|
||||
|
||||
def forward(payload):
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
for url in hook_urls():
|
||||
key = "notify-%s" % uuid.uuid4()
|
||||
threading.Thread(target=post, args=(url, data, key), daemon=True).start()
|
||||
|
||||
|
||||
def on_call(conn, sender, path, interface, method, params, invocation):
|
||||
if method == "GetCapabilities":
|
||||
invocation.return_value(GLib.Variant("(as)", (["body", "actions"],)))
|
||||
elif method == "GetServerInformation":
|
||||
invocation.return_value(GLib.Variant("(ssss)", ("cobble-notify-watch", "Cobble", VERSION, "1.2")))
|
||||
elif method == "Notify":
|
||||
app_name, replaces_id, _icon, summary, body, actions, _hints, _timeout = params.unpack()
|
||||
if replaces_id:
|
||||
notification_id = replaces_id
|
||||
else:
|
||||
next_id[0] += 1
|
||||
notification_id = next_id[0]
|
||||
invocation.return_value(GLib.Variant("(u)", (notification_id,)))
|
||||
origin, text = split_origin(plain(body))
|
||||
labels = [actions[i + 1] for i in range(0, len(actions) - 1, 2)]
|
||||
forward({
|
||||
"source": "browser_notification",
|
||||
"app": app_name,
|
||||
"origin": origin,
|
||||
"title": plain(summary),
|
||||
"body": text,
|
||||
"actions": [label for label in labels if label not in CHROME_ACTIONS],
|
||||
"received_at": int(time.time() * 1000),
|
||||
})
|
||||
elif method == "CloseNotification":
|
||||
(notification_id,) = params.unpack()
|
||||
invocation.return_value(None)
|
||||
conn.emit_signal(None, OBJECT_PATH, BUS_NAME, "NotificationClosed", GLib.Variant("(uu)", (notification_id, 3)))
|
||||
else:
|
||||
invocation.return_dbus_error("org.freedesktop.DBus.Error.UnknownMethod", method)
|
||||
|
||||
|
||||
def on_bus(conn, name):
|
||||
node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION)
|
||||
conn.register_object(OBJECT_PATH, node.interfaces[0], on_call, None, None)
|
||||
|
||||
|
||||
def on_name(conn, name):
|
||||
os.makedirs(DIR, exist_ok=True)
|
||||
with open(STATE_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump({"pid": os.getpid(), "version": VERSION, "started_at": int(time.time())}, f)
|
||||
log("watching notifications (version %s)" % VERSION)
|
||||
|
||||
|
||||
def on_lost(conn, name):
|
||||
log("the notification service is taken or the bus went away; exiting")
|
||||
os._exit(1)
|
||||
|
||||
|
||||
Gio.bus_own_name(Gio.BusType.SESSION, BUS_NAME, Gio.BusNameOwnerFlags.DO_NOT_QUEUE, on_bus, on_name, on_lost)
|
||||
GLib.MainLoop().run()
|
||||
`.replaceAll("VERSION_PLACEHOLDER", NotifyWatchVersion);
|
||||
Reference in New Issue
Block a user