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]>
106 lines
4.3 KiB
TypeScript
106 lines
4.3 KiB
TypeScript
type Params = {
|
|
/**
|
|
* The watcher version the script on disk is (`NotifyWatchVersion`): an
|
|
* older one still running is stopped and started again.
|
|
*/
|
|
version: string;
|
|
/**
|
|
* Restart Chrome when it started before the watcher (it looks for a
|
|
* notification service only when it starts). Off while a task works
|
|
* on the computer, so its browser isn't pulled away mid-task.
|
|
*/
|
|
restart_browser: boolean;
|
|
};
|
|
|
|
/**
|
|
* The shell command that keeps a computer's notification watcher running
|
|
* (`~/.cobble/notify-watch.py`, already written): starts the session bus
|
|
* when the sandbox didn't, starts the watcher when it isn't running (or
|
|
* runs an older version), then restarts Chrome if it started before the
|
|
* watcher. Its sandbox supervisor brings Chrome straight back. Prints one
|
|
* line of JSON: `{ ok, watching, started, browser: "ok" | "restarted" |
|
|
* "needs_restart" | "missing", error? }`.
|
|
*/
|
|
export default function notifyWatchEnsureCommand({ version, restart_browser }: Params) {
|
|
const script = String.raw`
|
|
import json, os, subprocess, time
|
|
home = os.path.expanduser("~")
|
|
d = os.path.join(home, ".cobble")
|
|
state_file = os.path.join(d, "notify-watch.json")
|
|
script = os.path.join(d, "notify-watch.py")
|
|
out = {"ok": False, "watching": False, "started": False, "browser": "missing"}
|
|
|
|
def read_state():
|
|
try:
|
|
with open(state_file) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
def alive(pid):
|
|
try:
|
|
with open("/proc/%d/cmdline" % int(pid), "rb") as f:
|
|
return b"notify-watch" in f.read()
|
|
except Exception:
|
|
return False
|
|
|
|
try:
|
|
os.makedirs(d, exist_ok=True)
|
|
os.chmod(d, 0o700)
|
|
if os.path.exists(os.path.join(d, "notify-hooks")):
|
|
os.chmod(os.path.join(d, "notify-hooks"), 0o600)
|
|
env = dict(os.environ)
|
|
try:
|
|
with open("/var/run/gem/browser-supervisor.json") as f:
|
|
browser_env = (json.load(f).get("browser") or {}).get("env") or {}
|
|
for key in ("DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"):
|
|
if browser_env.get(key):
|
|
env[key] = browser_env[key]
|
|
except Exception:
|
|
pass
|
|
env.setdefault("XDG_RUNTIME_DIR", "/run/user/%d" % os.getuid())
|
|
env.setdefault("DBUS_SESSION_BUS_ADDRESS", "unix:path=%s/dbus-session-bus" % env["XDG_RUNTIME_DIR"])
|
|
socket = env["DBUS_SESSION_BUS_ADDRESS"].split("unix:path=", 1)[-1].split(",")[0]
|
|
if not os.path.exists(socket):
|
|
subprocess.run(["dbus-daemon", "--session", "--fork", "--address=unix:path=" + socket], env=env, check=True, timeout=10)
|
|
time.sleep(1)
|
|
state = read_state()
|
|
pid = state.get("pid")
|
|
if pid and alive(pid) and str(state.get("version")) != __VERSION__:
|
|
os.kill(int(pid), 15)
|
|
time.sleep(1)
|
|
if not (pid and alive(pid)):
|
|
with open(os.path.join(d, "notify-watch.log"), "ab") as log:
|
|
subprocess.Popen(["python3", script], env=env, stdout=log, stderr=log, stdin=subprocess.DEVNULL, start_new_session=True)
|
|
out["started"] = True
|
|
for _ in range(20):
|
|
time.sleep(0.25)
|
|
state = read_state()
|
|
if state.get("pid") and alive(state["pid"]) and str(state.get("version")) == __VERSION__:
|
|
break
|
|
state = read_state()
|
|
out["watching"] = bool(state.get("pid") and alive(state["pid"]))
|
|
if not out["watching"]:
|
|
out["error"] = "The notification watcher didn't start (another notification service may be running)."
|
|
browser_pid_file = "/var/run/gem/browser.pid"
|
|
if out["watching"] and os.path.exists(browser_pid_file):
|
|
browser_started = os.stat(browser_pid_file).st_mtime
|
|
if browser_started >= int(state.get("started_at") or 0):
|
|
out["browser"] = "ok"
|
|
elif __RESTART__:
|
|
with open(browser_pid_file) as f:
|
|
os.kill(int(f.read().strip()), 15)
|
|
out["browser"] = "restarted"
|
|
else:
|
|
out["browser"] = "needs_restart"
|
|
out["ok"] = out["watching"]
|
|
except Exception as error:
|
|
out["error"] = str(error)[:300]
|
|
print(json.dumps(out))
|
|
`
|
|
.replaceAll("__VERSION__", JSON.stringify(version))
|
|
.replaceAll("__RESTART__", restart_browser ? "True" : "False");
|
|
|
|
return `python3 - <<'COBBLE_NOTIFY_WATCH'\n${script.trim()}\nCOBBLE_NOTIFY_WATCH`;
|
|
}
|