openvibers/openvibe.bot Commits
Bot: routines (steps a robot runs on demand, on a schedule or when it comes online, through the same gate) and signed webhooks for its events (#93)
Changes
@@@ -16,6 +16,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.++- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.++- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.@@@ -46,6 +46,8 @@"the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap","a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)","community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",++ "routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",++ "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks","the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)","panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control","platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",nnew file mode 100644@@@ -0,0 +1,58 @@++-- phase: expand++--++-- Robot routines and webhooks (plan T15 "schedules and automations … webhooks"). Additive only.++--++-- robot_routines: an owner's named sequence of steps (server/routines.js parses its text form against the robot's++-- profile), run on demand, on a schedule (every N minutes, or daily at a UTC time on chosen weekdays) or when the robot++-- comes online. Each step passes the same gate as a person's command, as the owner.++--++-- robot_webhooks / robot_webhook_deliveries: an owner's https endpoint for the robot's bot.* events. A delivery row is++-- written in the transaction that emits the event (server/domain emit), so it exists exactly when the event does; the++-- sender (server/webhooks.js) signs each with the hook's secret (X-OpenVibe-Signature-V2, as Events deliveries are) and++-- retries with backoff. The secret is shown once when the hook is made; deleting a robot deletes its hooks.++++CREATE TABLE IF NOT EXISTS robot_routines (++ id text PRIMARY KEY, -- rtn_…++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,++ name text NOT NULL,++ source text NOT NULL, -- the steps as the owner wrote them++ steps jsonb NOT NULL, -- the parsed steps++ trigger jsonb NOT NULL, -- { type: manual | schedule | online, … }++ enabled boolean NOT NULL DEFAULT true,++ next_run_at timestamptz, -- schedule only++ last_run_at timestamptz,++ last_result text, -- running | done | stopped: <why>++ created_by text NOT NULL,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS robot_routines_robot_idx ON robot_routines (robot_id);++CREATE INDEX IF NOT EXISTS robot_routines_due_idx ON robot_routines (next_run_at) WHERE enabled AND next_run_at IS NOT NULL;++++CREATE TABLE IF NOT EXISTS robot_webhooks (++ id text PRIMARY KEY, -- whk_…++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,++ url text NOT NULL,++ events text[] NOT NULL,++ secret text NOT NULL, -- whsec_…, shown once; signs deliveries++ enabled boolean NOT NULL DEFAULT true,++ created_by text NOT NULL,++ created_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS robot_webhooks_robot_idx ON robot_webhooks (robot_id);++++CREATE TABLE IF NOT EXISTS robot_webhook_deliveries (++ id text PRIMARY KEY, -- whd_…++ webhook_id text NOT NULL REFERENCES robot_webhooks(id) ON DELETE CASCADE,++ event_type text NOT NULL,++ body text NOT NULL, -- the exact JSON that is signed and sent++ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'delivered', 'failed')),++ attempts integer NOT NULL DEFAULT 0,++ due_at timestamptz NOT NULL,++ last_status integer,++ last_error text,++ created_at timestamptz NOT NULL,++ updated_at timestamptz NOT NULL++);++CREATE INDEX IF NOT EXISTS robot_webhook_deliveries_due_idx ON robot_webhook_deliveries (due_at) WHERE state = 'pending';++CREATE INDEX IF NOT EXISTS robot_webhook_deliveries_hook_idx ON robot_webhook_deliveries (webhook_id, created_at DESC);@@@ -332,3 +332,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.phone-foot { display: flex; gap: 0.5rem; padding: 0.75rem; padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); }.phone-stop { flex: 1; min-height: 56px; font-size: 1.15rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--danger); border-color: var(--danger); color: #fff; }.phone-stop.resume { background: var(--ok); border-color: var(--ok); color: #04130a; }++++/* Routines and webhooks (the owner's panel cards). */++.routine-actions { display: inline-flex; gap: 0.4rem; }++.routine-form, .webhook-form { display: grid; gap: 0.6rem; margin-top: 0.6rem; }++.routine-form textarea { width: 100%; box-sizing: border-box; font: 0.85rem/1.5 ui-monospace, monospace; padding: 0.6rem; border-radius: 10px; border: 1px solid var(--line); background: var(--bg); color: var(--text); }++.routine-form .trigger { border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem 0.8rem; display: grid; gap: 0.35rem; }++.routine-form .trigger input[type="number"] { width: 5.5rem; min-height: 36px; }++.routine-form .trigger input[type="time"] { min-height: 36px; }++.days { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; }++.secret-once { padding: 0.6rem 0.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); overflow-wrap: anywhere; }++.setting-note code { font-size: 0.8em; }@@@ -37,7 +37,13 @@ const TABLES = [async function extraExport(db, subject) {const robots = await db.many(`SELECT id, name, profile_id, profile_version, access_policy, limits, created_at, updated_at FROM robotsWHERE owner_subject = $1 ORDER BY created_at DESC`, [subject]);-- return robots.length ? [{ name: 'robots.json', content: robots }] : [];++ if (!robots.length) return [];++ const ids = robots.map((r) => r.id);++ const routines = await db.many('SELECT robot_id, name, source, trigger, enabled, created_at FROM robot_routines WHERE robot_id = ANY($1::text[]) ORDER BY created_at', [ids]);++ const hooks = await db.many('SELECT robot_id, url, events, created_at FROM robot_webhooks WHERE robot_id = ANY($1::text[]) ORDER BY created_at', [ids]);++ return [{ name: 'robots.json', content: robots },++ ...(routines.length ? [{ name: 'robot-routines.json', content: routines }] : []),++ ...(hooks.length ? [{ name: 'robot-webhooks.json', content: hooks }] : [])];}/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */@@@ -49,7 +49,7 @@ const CAP = {dispatch: 'bot.job.dispatch',};--function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null }) {++function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {const r = express.Router();r.use(limits.reads('bot.read'));r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));@@@ -201,6 +201,60 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.status(201).json(await domain.pairing.create(req.params.id, me(req)));}));++ // ── Routines and webhooks (server/routines.js, server/webhooks.js): the owner's, or a service's with manage ──++ const presentRoutine = (r) => ({++ id: r.id, robot_id: r.robot_id, name: r.name, steps: r.source, trigger: typeof r.trigger === 'string' ? JSON.parse(r.trigger) : r.trigger,++ enabled: r.enabled, next_run_at: r.next_run_at ? new Date(r.next_run_at).toISOString() : null,++ last_run_at: r.last_run_at ? new Date(r.last_run_at).toISOString() : null, last_result: r.last_result || null,++ });++ const needAutomation = () => { if (!routines || !webhooks) fail(503, 'bot.unavailable', 'automation is not available'); };++ r.get('/robots/:id/routines', wrap(async (req, res) => {++ needAutomation();++ await ownerOrRead(req, req.params.id);++ res.json({ routines: (await routines.list(req.params.id)).map(presentRoutine) });++ }));++ r.post('/robots/:id/routines', manage, wrap(async (req, res) => {++ needAutomation();++ const robot = await owner(req, req.params.id);++ const profile = await getProfile(domain.db, robot.profile_id, robot.profile_version);++ const b = req.body || {};++ const made = await routines.create(robot, { subject: req.principal.kind === 'service' ? actedFor(req) : me(req) },++ { name: b.name, source: b.steps, trigger: b.trigger || { type: 'manual' }, enabled: b.enabled !== false }, profile && profile.profile);++ res.status(201).json({ routine: presentRoutine(made) });++ }));++ r.post('/robots/:id/routines/:rid/run', manage, wrap(async (req, res) => {++ needAutomation();++ const robot = await owner(req, req.params.id);++ const routine = await routines.get(req.params.rid);++ if (!routine || routine.robot_id !== robot.id) fail(404, 'bot.routine_not_found', 'no such routine');++ if (!await routines.run(routine.id, { reason: 'api' })) fail(409, 'bot.routine_busy', 'a routine is already running on this robot');++ res.status(202).json({ routine: presentRoutine(await routines.get(routine.id)) });++ }));++ r.delete('/robots/:id/routines/:rid', manage, wrap(async (req, res) => {++ needAutomation();++ const robot = await owner(req, req.params.id);++ if (!await routines.remove(robot.id, req.params.rid)) fail(404, 'bot.routine_not_found', 'no such routine');++ res.status(204).end();++ }));++ r.get('/robots/:id/webhooks', wrap(async (req, res) => {++ needAutomation();++ await ownerOrRead(req, req.params.id);++ res.json({ webhooks: await webhooks.list(req.params.id), events: webhooks.EVENTS });++ }));++ // The secret is in this answer only: a hook's later reads never carry it.++ r.post('/robots/:id/webhooks', manage, wrap(async (req, res) => {++ needAutomation();++ const robot = await owner(req, req.params.id);++ const made = await webhooks.create(robot, { subject: req.principal.kind === 'service' ? actedFor(req) : me(req) }, { url: req.body?.url, events: req.body?.events });++ res.status(201).json({ webhook: made.hook, secret: made.secret });++ }));++ r.delete('/robots/:id/webhooks/:hid', manage, wrap(async (req, res) => {++ needAutomation();++ const robot = await owner(req, req.params.id);++ if (!await webhooks.remove(robot.id, req.params.hid)) fail(404, 'bot.webhook_not_found', 'no such webhook');++ res.status(204).end();++ }));++// ── Operators ─────────────────────────────────────────────────────────────────────────────────r.get('/robots/:id/operators', wrap(async (req, res) => {await member(req, req.params.id, ['owner', 'operator']);@@@ -42,6 +42,9 @@ const { createSessionRoutes, viewerMiddleware } = require('./web/session');const { createWebRoutes } = require('./web/routes');const { HOME_CSP } = require('./web/home');const { createSubmissions } = require('./submissions');++const { createRoutines } = require('./routines');++const { createWebhooks } = require('./webhooks');++const { createWebhookPoster } = require('./net/webhook-post');const { inputError } = require('./util');const { createBotReadiness, registerBotGauges } = require('./observability');@@@ -75,7 +78,12 @@ function createApp(opts = {}) {const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });const identity = opts.identity || createIdentity(config, { fetchImpl });const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });-- const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });++ // Webhooks are made first: the domain writes their deliveries in the transaction that emits each event.++ const webhooks = createWebhooks({ db, post: createWebhookPoster({ userAgent: 'OpenVibe.Bot' }), now, log, tickMs: config.automation.webhookTickMs });++ let routines = null;++ const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log, webhooks,++ onEvent: (type, robotId) => { if (type === 'bot.robot.online' && routines) routines.onOnline(robotId).catch((e) => log.warn(`[Bot] online routines: ${e.message}`)); } });++ routines = createRoutines({ db, domain, hub, now, log, tickMs: config.automation.routineTickMs });const submissions = createSubmissions({ db, config, now, log });hub.bindDomain(domain);// One jobs service for the device socket and the jobs API, so both see the same stdout rings.@@@ -129,7 +137,7 @@ function createApp(opts = {}) {release.mount(app, { registry: metrics.registry });const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });-- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions }));++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks }));app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP@@@ -138,7 +146,7 @@ function createApp(opts = {}) {{ const legal = require('openvibe-shared/legal');app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }app.use(viewerMiddleware(userAuth));-- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));++ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, routines, webhooks, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));app.use((req, res) => {if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });@@@ -157,7 +165,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions });++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions, routines, webhooks });return app;}@@@ -213,6 +213,11 @@ function loadConfig(env = process.env) {profiles: {signingKey: env.BOT_PROFILE_SIGNING_KEY || '',},++ // Routines' scheduler and the webhook sender (server/routines.js, server/webhooks.js); 0 stops a loop (tests).++ automation: {++ routineTickMs: int(env.BOT_ROUTINE_TICK_MS, 30000),++ webhookTickMs: int(env.BOT_WEBHOOK_TICK_MS, 5000),++ },// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret// references only (username_ref/password_ref), resolved when the request is built; the config never@@@ -65,7 +65,7 @@ function noteToHz(note) {}--function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {++function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {// ── Presenters ────────────────────────────────────────────────────────────────────────────────const presentRobot = (r) => (r ? {id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,@@@ -88,6 +88,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function emit(t, event_type, subject, payload) {await outbox.emitIn(t, { ...ENVELOPE, event_type, subject, payload });++ // The robot's webhooks get it in the same transaction (server/webhooks.js); listeners (the routines' "each time++ // it comes online") hear of it once the transaction has had time to commit.++ const robotId = subject && subject.type === 'robot' ? subject.id : null;++ if (webhooks && robotId) await webhooks.enqueueIn(t, event_type, robotId, payload);++ if (onEvent && robotId) { const tm = setTimeout(() => { try { onEvent(event_type, robotId, payload); } catch (e) { log.warn(`[Bot] event listener: ${e.message}`); } }, 300); if (tm.unref) tm.unref(); }}// ── Membership and roles ──────────────────────────────────────────────────────────────────────@@@ -33,7 +33,7 @@ async function main() {const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });-- const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;++ const { domain, keys, outbox, usage, hub, sim, onvif, routines, webhooks } = app.locals;keys.start().catch(() => { /* logged by the JWKS client */ });const timers = [];@@@ -44,6 +44,8 @@ async function main() {every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });outbox.start();usage.start();++ routines.start(); // scheduled routines (server/routines.js)++ webhooks.start(); // robot webhook deliveries (server/webhooks.js)}const server = app.listen(config.port, config.host, () => {@@@ -78,6 +80,8 @@ async function main() {deadlineExitCode: 0,stop: [() => timers.forEach(clearInterval),++ () => routines.stop(),++ () => webhooks.stop(),() => keys.stop(),() => sim.stopAll(),() => onvif.stopAll(),nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The one place Bot connects to an address a caller chose: a robot's webhook (plan T15; the same poster as++ * OpenVibe.Actor's). Everything else Bot fetches goes to a configured base.++ *++ * checkWebhookUrl(url) → URL, or throws WebhookRefused: https only, port 443 or 8443, no credentials, not a name++ * that can only mean this machine or a private network, not a non-public IP literal++ * createWebhookPoster({ userAgent, timeoutMs, lookup }) → post(url, { headers, body }) → { status }++ *++ * The poster checks the URL again at send time and connects through openvibe-shared/egress safeLookup, which fails++ * unless every address the name resolves to is public, so a DNS answer cannot change between the check and the++ * connect. A redirect is never followed (a 3xx is the receiver's answer like any other), and at most a few KiB of the++ * answer are read and discarded: nothing a receiver says is stored.++ */++const https = require('https');++const net = require('net');++const egress = require('openvibe-shared/egress');++++const PORTS = new Set(['', '443', '8443']);++const MAX_RESPONSE_BYTES = 4096;++++class WebhookRefused extends Error {++ constructor(detail) { super(detail); this.code = 'bot.webhook.refused'; }++}++++function checkWebhookUrl(input) {++ let u;++ try { u = new URL(String(input)); } catch { throw new WebhookRefused('not a URL'); }++ if (u.protocol !== 'https:') throw new WebhookRefused('a webhook URL is https');++ if (u.username || u.password) throw new WebhookRefused('credentials in the URL are not allowed');++ if (!PORTS.has(u.port)) throw new WebhookRefused('a webhook URL uses port 443 or 8443');++ const host = egress.normalizeHost(u.hostname);++ if (net.isIP(host) ? !egress.isPublicAddress(host) : egress.isInternalName(host)) throw new WebhookRefused(`${host} is not a public address`);++ return u;++}++++function createWebhookPoster({ userAgent = 'OpenVibe.Bot', timeoutMs = 10_000, lookup = egress.safeLookup } = {}) {++ return async function post(url, { headers = {}, body = '' } = {}) {++ const u = checkWebhookUrl(url);++ const payload = Buffer.from(String(body));++ return await new Promise((resolve, reject) => {++ let settled = false;++ const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };++ const req = https.request(u, {++ method: 'POST', lookup, timeout: timeoutMs,++ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length, 'User-Agent': userAgent, ...headers },++ }, (res) => {++ let seen = 0;++ res.on('data', (chunk) => { seen += chunk.length; if (seen > MAX_RESPONSE_BYTES) res.destroy(); });++ const done = () => finish(resolve, { status: res.statusCode });++ res.on('end', done);++ res.on('close', done);++ res.on('error', done);++ });++ req.on('timeout', () => req.destroy(Object.assign(new Error(`no answer within ${timeoutMs} ms`), { code: 'ETIMEDOUT' })));++ req.on('error', (err) => finish(reject, err));++ req.end(payload);++ });++ };++}++++module.exports = { checkWebhookUrl, createWebhookPoster, WebhookRefused };nnew file mode 100644@@@ -0,0 +1,278 @@++'use strict';++++/**++ * Robot routines (plan T15 "schedules and automations"): an owner writes a robot's steps in plain lines, Bot runs them++ * on demand, on a schedule, or when the robot comes online. Every step passes the same gate as a person's command, as++ * the robot's owner (hub.operatorCommand): the robot's limits, cooldowns and e-stop apply, and the audit has each one.++ *++ * parseSteps(text, profile) → { steps } or throws BotError 422 with the line that is wrong. One step per line:++ * forward [seconds] [speed] back [s] [speed] left [s] [speed] right [s] [speed] stop++ * wait <seconds> say <text> face <name> show <text> press <button>++ * <actuator> <value> [seconds] (number, #rrggbb or off, a note like A4 or off, on/off; held for the seconds)++ * pan|tilt|zoom <value> [seconds]++ * parseTrigger({ type, every, at, days }) → { type: 'manual' } | { type: 'schedule', every?|at+days } | { type: 'online' }++ * nextRun(trigger, from) → Date | null++ * createRoutines({ db, domain, hub, now, log }) → { list, get, create, remove, run, tick, onOnline, stop }++ *++ * A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero++ * drive ends it. A refused or failed command stops the routine there (an e-stop, the robot going offline, a cooldown++ * the owner set too long); the reason is kept in last_result. One routine runs per robot at a time.++ */++const { BotError, prefixedId, iso, text } = require('./util');++++const HOLD_MS = 150;++const MAX_STEPS = 60;++const MAX_STEP_S = 60;++const MAX_TOTAL_S = 600;++const MIN_EVERY_MIN = 5;++const TICK_MS = 30000;++++const fail = (detail) => { throw new BotError(422, 'bot.invalid_routine', detail); };++const num = (s, what, lo, hi) => {++ const n = Number(s);++ if (!Number.isFinite(n) || n < lo || n > hi) fail(`${what} must be a number from ${lo} to ${hi}`);++ return n;++};++++/** The drive axes for forward/back and left/right on this profile: [forward axis, turn axis]. */++function driveAxes(profile) {++ const axes = (profile.commands && profile.commands.drive && profile.commands.drive.axes) || null;++ if (!axes) return null;++ const fwd = ['throttle', 'y'].find((a) => axes[a]);++ const turn = ['steer', 'rotation', 'x'].find((a) => axes[a]);++ return { fwd, turn };++}++++function parseSteps(source, profile) {++ const commands = (profile && profile.commands) || {};++ const lines = String(source || '').split('\n');++ const steps = [];++ let total = 0;++ lines.forEach((raw, i) => {++ const line = raw.trim();++ if (!line || line.startsWith('#')) return; // a line starting with # is a note (colours keep their #)++ const at = `line ${i + 1} ("${line.slice(0, 40)}")`;++ const [word, ...rest] = line.split(/\s+/);++ const w = word.toLowerCase();++ const secs = (v, d) => (v == null ? d : num(v, `${at}: the seconds`, 0.05, MAX_STEP_S));++ const step = (() => {++ if (['forward', 'back', 'backward', 'left', 'right'].includes(w)) {++ const d = driveAxes(profile);++ if (!d) fail(`${at}: this robot does not drive`);++ const turning = w === 'left' || w === 'right';++ const axis = turning ? d.turn : d.fwd;++ if (!axis) fail(`${at}: this robot cannot ${turning ? 'turn' : 'drive forward'}`);++ const speed = rest[1] == null ? (turning ? 0.7 : 0.6) : num(rest[1], `${at}: the speed`, 0.05, 1);++ const sign = w === 'back' || w === 'backward' || w === 'left' ? -1 : 1;++ return { kind: 'drive', value: { [axis]: Math.round(sign * speed * 100) / 100 }, seconds: secs(rest[0], 1), hold: true };++ }++ if (w === 'stop') { if (!commands.drive) fail(`${at}: this robot does not drive`); return { kind: 'drive', value: {}, seconds: 0 }; }++ if (w === 'wait') return { kind: 'wait', seconds: secs(rest[0], 1) };++ if (w === 'say') {++ if (!commands.say) fail(`${at}: this robot does not speak`);++ const t = rest.join(' ');++ if (!t) fail(`${at}: say what?`);++ return { kind: 'say', value: { text: t.slice(0, (commands.say.max_chars || 200)) }, seconds: 0 };++ }++ if (w === 'face' || w === 'show') {++ const d = commands.display;++ if (!d) fail(`${at}: this robot has no display`);++ if (w === 'face') {++ const f = String(rest[0] || '').toLowerCase();++ if (!(d.faces || []).includes(f)) fail(`${at}: faces are ${(d.faces || []).join(', ') || 'not shown here'}`);++ return { kind: 'display', value: { face: f }, seconds: 0 };++ }++ if (!d.modes.includes('text')) fail(`${at}: this display shows no text`);++ return { kind: 'display', value: { text: rest.join(' ').slice(0, d.max_chars || 200) }, seconds: 0 };++ }++ if (w === 'press') {++ const names = (commands.button && commands.button.names) || {};++ if (!names[rest[0]]) fail(`${at}: this robot has no button ${rest[0] || ''}`.trim());++ return names[rest[0]].hold++ ? { kind: 'button', value: { name: rest[0], state: 'down' }, seconds: secs(rest[1], 0.5), hold: true, release: { name: rest[0], state: 'up' } }++ : { kind: 'button', value: { name: rest[0] }, seconds: 0 };++ }++ if (['pan', 'tilt', 'zoom'].includes(w) && commands.ptz && commands.ptz.axes[w]) {++ return { kind: 'ptz', value: { [w]: num(rest[0], `${at}: the ${w}`, -1, 1) }, seconds: secs(rest[1], 0.5), hold: true, release: { [w]: 0 } };++ }++ const act = commands.actuator && commands.actuator.names[w];++ if (act) {++ const v = String(rest[0] == null ? '' : rest[0]);++ let value;++ if (act.type === 'number') value = num(v, `${at}: the value`, act.range[0], act.range[1]);++ else if (act.type === 'bool') { if (!['on', 'off'].includes(v)) fail(`${at}: on or off`); value = v === 'on'; }++ else if (act.type === 'rgb') {++ if (v === 'off') value = null;++ else { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(v); if (!m) fail(`${at}: a colour like #ff8800, or off`); value = { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }; }++ } else {++ if (v === 'off') value = null;++ else if (/^[A-Ga-g][#b]?-?\d$/.test(v)) value = { note: v[0].toUpperCase() + v.slice(1) };++ else value = { hz: num(v, `${at}: a note like A4 or a frequency`, act.hz[0], act.hz[1]) };++ const s = secs(rest[1], 0.5);++ return { kind: 'actuator', value: { name: w, value }, seconds: value ? s : 0, hold: !!value, release: value ? { name: w, value: null } : null };++ }++ return { kind: 'actuator', value: { name: w, value }, seconds: 0 };++ }++ fail(`${at}: "${word}" is not a step this robot knows`);++ return null;++ })();++ if (step.kind !== 'wait' && !commands[step.kind]) fail(`${at}: this robot does not take ${step.kind} commands`);++ total += step.seconds || 0;++ steps.push(step);++ });++ if (!steps.length) fail('write at least one step, one per line');++ if (steps.length > MAX_STEPS) fail(`at most ${MAX_STEPS} steps`);++ if (total > MAX_TOTAL_S) fail(`a routine runs at most ${MAX_TOTAL_S / 60} minutes`);++ return { steps };++}++++const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;++function parseTrigger(t = {}) {++ const type = String(t.type || 'manual');++ if (type === 'manual' || type === 'online') return { type };++ if (type !== 'schedule') throw new BotError(422, 'bot.invalid_routine', 'the trigger is manual, schedule or online');++ if (t.every != null && t.every !== '') {++ const every = Number(t.every);++ if (!Number.isInteger(every) || every < MIN_EVERY_MIN || every > 7 * 24 * 60) throw new BotError(422, 'bot.invalid_routine', `every is a whole number of minutes, at least ${MIN_EVERY_MIN}`);++ return { type, every };++ }++ if (!TIME.test(String(t.at || ''))) throw new BotError(422, 'bot.invalid_routine', 'a schedule runs every N minutes, or at a time like 09:30 (UTC)');++ const days = Array.isArray(t.days) && t.days.length ? [...new Set(t.days.map(Number))].filter((d) => Number.isInteger(d) && d >= 0 && d <= 6).sort() : [0, 1, 2, 3, 4, 5, 6];++ if (!days.length) throw new BotError(422, 'bot.invalid_routine', 'choose at least one day');++ return { type, at: String(t.at), days };++}++++/** When a schedule next runs after `from` (a Date), or null for a trigger that is not a schedule. */++function nextRun(trigger, from) {++ if (!trigger || trigger.type !== 'schedule') return null;++ if (trigger.every) return new Date(from.getTime() + trigger.every * 60000);++ const [h, m] = trigger.at.split(':').map(Number);++ for (let i = 0; i <= 7; i++) {++ const d = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() + i, h, m));++ if (d > from && trigger.days.includes(d.getUTCDay())) return d;++ }++ return null;++}++++function describeTrigger(t) {++ if (!t || t.type === 'manual') return 'When you run it';++ if (t.type === 'online') return 'Each time it comes online';++ if (t.every) return `Every ${t.every} minutes`;++ const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];++ return `At ${t.at} UTC${t.days.length === 7 ? ' daily' : ` on ${t.days.map((d) => names[d]).join(', ')}`}`;++}++++function createRoutines({ db, domain, hub, now = () => Date.now(), log = console, tickMs = TICK_MS }) {++ const running = new Map(); // robot id → { routineId, stop: bool }++ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });++ let timer = null;++++ const list = (robotId) => db.many('SELECT * FROM robot_routines WHERE robot_id = $1 ORDER BY created_at', [robotId]);++ const get = (id) => db.maybe('SELECT * FROM robot_routines WHERE id = $1', [id]);++++ async function create(robot, person, { name, source, trigger, enabled = true }, profile) {++ const cleanName = text(name, 'name', 60);++ if (!cleanName) throw new BotError(422, 'bot.invalid_routine', 'name the routine');++ const { steps } = parseSteps(source, profile);++ const tr = parseTrigger(trigger);++ const id = prefixedId('rtn', now());++ const at = new Date(now());++ await db.query(`INSERT INTO robot_routines (id, robot_id, name, source, steps, trigger, enabled, next_run_at, created_by, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $10)`,++ [id, robot.id, cleanName, String(source).slice(0, 4000), JSON.stringify(steps), JSON.stringify(tr), !!enabled, enabled ? (nextRun(tr, at) ? iso(nextRun(tr, at).getTime()) : null) : null, person.subject, iso(at.getTime())]);++ return get(id);++ }++++ async function remove(robotId, id) {++ const r = running.get(robotId);++ if (r && r.routineId === id) r.stop = true;++ return (await db.exec('DELETE FROM robot_routines WHERE id = $1 AND robot_id = $2', [id, robotId])) > 0;++ }++++ async function finish(id, result) {++ await db.query('UPDATE robot_routines SET last_result = $2, updated_at = $3 WHERE id = $1', [id, String(result).slice(0, 200), iso(now())]);++ }++++ /** Send one command as the owner; resolves true when the device ran it, else false with the reason kept. */++ async function send(robot, step, value, ms) {++ const principal = { kind: 'user', subject: robot.owner_subject, role: 'user', via: 'routine' };++ const r = await hub.operatorCommand(robot.id, principal, robot.owner_subject, { id: prefixedId('rcmd', now()), kind: step.kind, value, ms });++ if (r.result === 'ack') return { ok: true };++ return { ok: false, why: r.reason || r.code || r.result };++ }++++ /** Run a routine now (in the background); false when one is already running on the robot. */++ async function run(id, { reason = 'run' } = {}) {++ const routine = await get(id);++ if (!routine) throw new BotError(404, 'bot.routine_not_found', 'no such routine');++ if (running.has(routine.robot_id)) return false;++ const robot = await domain.robots.get(routine.robot_id);++ if (!robot) return false;++ const state = { routineId: id, stop: false };++ running.set(robot.id, state);++ await db.query('UPDATE robot_routines SET last_run_at = $2, last_result = $3, updated_at = $2 WHERE id = $1', [id, iso(now()), 'running']);++ (async () => {++ const steps = typeof routine.steps === 'string' ? JSON.parse(routine.steps) : routine.steps;++ let result = 'done';++ try {++ for (const step of steps) {++ if (state.stop) { result = 'stopped: removed'; break; }++ if (step.kind === 'wait') { await sleep(step.seconds * 1000); continue; }++ if (!step.hold) {++ const r = await send(robot, step, step.value, null);++ if (!r.ok) { result = `stopped: ${r.why}`; break; }++ continue;++ }++ // Held: re-sent within the deadman window until the step's time is up, then released.++ const end = now() + step.seconds * 1000;++ let ok = true;++ while (now() < end && !state.stop) {++ const r = await send(robot, step, step.value, Math.min(300, end - now() + HOLD_MS));++ if (!r.ok) { result = `stopped: ${r.why}`; ok = false; break; }++ await sleep(Math.min(HOLD_MS, Math.max(0, end - now())));++ }++ const release = step.kind === 'drive' ? {} : step.release;++ if (release) await send(robot, step, release, null).catch(() => {});++ if (!ok) break;++ }++ } catch (e) {++ result = `stopped: ${e.message}`;++ log.warn(`[Bot] routine ${id}: ${e.message}`);++ } finally {++ running.delete(robot.id);++ await finish(id, result).catch(() => {});++ }++ })();++ log.log && log.log(`[Bot] routine ${id} on ${robot.id} started (${reason})`);++ return true;++ }++++ /** The scheduler: each due, enabled scheduled routine runs (if its robot is free) and gets its next time. */++ async function tick() {++ const due = await db.many("SELECT * FROM robot_routines WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= $1 ORDER BY next_run_at LIMIT 50", [iso(now())]);++ for (const r of due) {++ const trigger = typeof r.trigger === 'string' ? JSON.parse(r.trigger) : r.trigger;++ const next = nextRun(trigger, new Date(now()));++ await db.query('UPDATE robot_routines SET next_run_at = $2 WHERE id = $1', [r.id, next ? iso(next.getTime()) : null]);++ await run(r.id, { reason: 'schedule' }).catch((e) => log.warn(`[Bot] routine ${r.id}: ${e.message}`));++ }++ return due.length;++ }++++ /** A robot came online: its "each time it comes online" routines run (the first enabled one, one at a time). */++ async function onOnline(robotId) {++ const rows = await db.many("SELECT id FROM robot_routines WHERE robot_id = $1 AND enabled AND trigger->>'type' = 'online' ORDER BY created_at", [robotId]);++ for (const r of rows) if (await run(r.id, { reason: 'online' }).catch(() => false)) break;++ }++++ function start() {++ if (timer || !tickMs) return;++ timer = setInterval(() => { tick().catch((e) => log.warn(`[Bot] routines: ${e.message}`)); }, tickMs);++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; for (const s of running.values()) s.stop = true; }++++ return { list, get, create, remove, run, tick, onOnline, start, stop, isRunning: (robotId) => running.has(robotId), describeTrigger };++}++++module.exports = { createRoutines, parseSteps, parseTrigger, nextRun, describeTrigger, HOLD_MS };@@@ -253,6 +253,71 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off"><label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;++/** The step words this robot takes (server/routines.js parseSteps), for the routine form's help line. */++function stepHelp(profile) {++ const c = profile.commands || {};++ const words = [];++ if (c.drive) words.push('forward 2', 'back 1', 'left 0.5', 'right 0.5', 'stop');++ words.push('wait 1');++ if (c.say) words.push('say Hello');++ if (c.display && c.display.faces) words.push(`face ${c.display.faces[0]}`);++ if (c.display && c.display.modes.includes('text')) words.push('show Hi');++ if (c.ptz) words.push(...Object.keys(c.ptz.axes).map((a) => `${a} 0.5 1`));++ for (const [name, a] of Object.entries((c.actuator && c.actuator.names) || {})) {++ words.push(a.type === 'rgb' ? `${name} #ff8800` : a.type === 'tone' ? `${name} A4 0.3` : a.type === 'bool' ? `${name} on` : `${name} 0.5`);++ }++ for (const name of Object.keys((c.button && c.button.names) || {})) words.push(`press ${name}`);++ return words;++}++const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];++++/** The owner's routines: what exists, Run and Remove, and the add form (plain markup, works without JavaScript). */++function routinesCard(robot, profile, a) {++ const help = stepHelp(profile);++ const v = a.values || {};++ const row = (r) => {++ const last = r.last_result ? `${r.last_result === 'running' ? 'running now' : `last: ${r.last_result}`}${r.last_run_at ? ` (${new Date(r.last_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC)` : ''}` : 'not run yet';++ return `<li class="person"><span class="who"><b>${esc(r.name)}</b><small>${esc(a.describe(r.trigger))}${r.enabled ? '' : ' · off'} · ${esc(last)}</small></span>`++ + `<span class="routine-actions"><form method="post" action="/robots/${esc(robot.id)}/routines/${esc(r.id)}/run"><button type="submit">Run</button></form>`++ + `<form method="post" action="/robots/${esc(robot.id)}/routines/${esc(r.id)}/remove"><button type="submit" class="quiet">Remove</button></form></span></li>`;++ };++ const t = v.trigger || 'manual';++ return `<section class="setting-card" id="routines" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>++<p class="setting-note">Steps the robot runs by itself, through the same limits, cooldowns and e-stop as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>++${a.routineError ? `<p class="error" role="alert">${esc(a.routineError)}</p>` : ''}++${a.routines.length ? `<ul class="people-list">${a.routines.map(row).join('\n')}</ul>` : ''}++<details${a.routineError ? ' open' : ''}><summary>Add a routine</summary>++<form class="routine-form" method="post" action="/robots/${esc(robot.id)}/routines">++<label class="field"><span>Name</span><input name="name" maxlength="60" required value="${esc(v.name || '')}" placeholder="Patrol"></label>++<label class="field"><span>Steps, one per line</span><textarea name="steps" rows="6" required spellcheck="false" placeholder="${esc(help.slice(0, 5).join('\n'))}">${esc(v.steps || '')}</textarea></label>++<p class="setting-note">This robot takes: ${help.map((w) => `<code>${esc(w)}</code>`).join(' ')} (seconds after a motion; a line starting with # is a note).</p>++<fieldset class="trigger"><legend>When</legend>++<label class="check"><input type="radio" name="trigger" value="manual"${t === 'manual' ? ' checked' : ''}> When I run it</label>++<label class="check"><input type="radio" name="trigger" value="every"${t === 'every' ? ' checked' : ''}> Every <input type="number" name="every" min="5" max="10080" step="1" value="${esc(v.every || '60')}" aria-label="Minutes"> minutes</label>++<label class="check"><input type="radio" name="trigger" value="daily"${t === 'daily' ? ' checked' : ''}> At <input type="time" name="at" value="${esc(v.at || '09:00')}" aria-label="Time (UTC)"> UTC on</label>++<span class="days">${DAYS.map((d, i) => `<label class="check"><input type="checkbox" name="days" value="${i}"${!v.days || v.days.includes(String(i)) ? ' checked' : ''}> ${d}</label>`).join(' ')}</span>++<label class="check"><input type="radio" name="trigger" value="online"${t === 'online' ? ' checked' : ''}> Each time it comes online</label>++</fieldset>++<button type="submit" class="primary">Save routine</button>++</form></details></section>`;++}++++/** The owner's webhooks: where the robot's events go, the add form, and a new hook's secret once. */++function webhooksCard(robot, a) {++ const row = (h) => `<li class="person"><span class="who"><b>${esc(h.url)}</b><small>${esc((h.events || []).map((e) => e.replace(/^bot\./, '')).join(', '))}${h.last ? ` · last delivery: ${esc(h.last)}` : ''}</small></span>`++ + `<form method="post" action="/robots/${esc(robot.id)}/webhooks/${esc(h.id)}/remove"><button type="submit" class="quiet">Remove</button></form></li>`;++ return `<section class="setting-card" id="webhooks" aria-labelledby="webhooks-h"><h2 id="webhooks-h">Webhooks</h2>++<p class="setting-note">Bot posts this robot's events to your https endpoint, signed like OpenVibe.Events deliveries (<code>X-OpenVibe-Signature-V2</code>, with the hook's own secret), and retries for about three hours.</p>++${a.webhookSecret ? `<p class="secret-once" role="status">The new hook's secret, shown this once: <code>${esc(a.webhookSecret)}</code></p>` : ''}++${a.webhookError ? `<p class="error" role="alert">${esc(a.webhookError)}</p>` : ''}++${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>` : ''}++<form class="webhook-form" method="post" action="/robots/${esc(robot.id)}/webhooks">++<label class="field"><span>Endpoint (https)</span><input type="url" name="url" required maxlength="300" placeholder="https://example.com/robot-events"></label>++<span class="days">${a.events.map((e) => `<label class="check"><input type="checkbox" name="events" value="${esc(e)}" checked> ${esc(e.replace(/^bot\./, ''))}</label>`).join(' ')}</span>++<button type="submit">Add webhook</button>++</form></section>`;++}++/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */function listingForm(robot) {return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>@@@ -373,7 +438,7 @@ function turnStrip(robot, role) {return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;}--function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false }) {++function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {const embed = mode === 'embed';const latched = !!(robot.estop && robot.estop.latched);const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))@@@ -400,7 +465,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv<div class="widgets">${widgets.join('\n')}</div>--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}</main>`;return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',@@@ -124,7 +124,7 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');--function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, releaseLastmod } = {}) {++function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = null, releaseLastmod } = {}) {const r = express.Router();// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',@@@ -427,6 +427,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot), phoneUrl: phoneUrl(robot.id, pairing) }));}));++ // ── Routines and webhooks (the owner's panel cards; the same rules as /api/v1) ──++ async function ownRobot(req) {++ const me = requireUser(req);++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');++ const robot = await domain.robots.get(req.params.id);++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner manages routines and webhooks');++ if (!routines || !webhooks) throw new BotError(503, 'bot.unavailable', 'automation is not available');++ return { me, robot };++ }++ const many = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);++ r.post('/robots/:id/routines', ...manage, express.urlencoded({ extended: false, limit: '16kb' }), page(async (req, res) => {++ const { me, robot } = await ownRobot(req);++ const b = req.body || {};++ const kind = String(b.trigger || 'manual');++ const trigger = kind === 'every' ? { type: 'schedule', every: b.every } : kind === 'daily' ? { type: 'schedule', at: b.at, days: many(b.days) } : { type: kind };++ try {++ await routines.create(robot, me, { name: b.name, source: b.steps, trigger }, await profileOf(robot));++ } catch (e) {++ if (e.status !== 422) throw e;++ res.status(422);++ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });++ }++ return res.redirect(303, `/panel/${robot.id}#routines`);++ }));++ r.post('/robots/:id/routines/:rid/run', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const { robot } = await ownRobot(req);++ const routine = await routines.get(req.params.rid);++ if (!routine || routine.robot_id !== robot.id) throw new BotError(404, 'bot.routine_not_found', 'no such routine');++ await routines.run(routine.id, { reason: 'owner' });++ return res.redirect(303, `/panel/${robot.id}#routines`);++ }));++ r.post('/robots/:id/routines/:rid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const { robot } = await ownRobot(req);++ await routines.remove(robot.id, req.params.rid);++ return res.redirect(303, `/panel/${robot.id}#routines`);++ }));++ r.post('/robots/:id/webhooks', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {++ const { me, robot } = await ownRobot(req);++ let made;++ try { made = await webhooks.create(robot, me, { url: req.body.url, events: many(req.body.events) }); } catch (e) {++ if (![409, 422].includes(e.status)) throw e;++ res.status(e.status);++ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });++ }++ // The secret is shown on this page alone (never in a URL), so the answer is the panel itself.++ res.status(201);++ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });++ }));++ r.post('/robots/:id/webhooks/:hid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {++ const { robot } = await ownRobot(req);++ await webhooks.remove(robot.id, req.params.hid);++ return res.redirect(303, `/panel/${robot.id}#webhooks`);++ }));++r.post('/robots/:id/listing', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');@@@ -549,9 +604,16 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi// The owner's People card: every member but the owner, with Network's name for each. A Network that// does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;++ // The owner's routines and webhooks (server/routines.js, server/webhooks.js), with each hook's last delivery.++ let automation = null;++ if (role === 'owner' && mode === 'page' && routines && webhooks) {++ const hooks = await webhooks.list(robot.id);++ for (const h of hooks) { const d = (await webhooks.recent(h.id))[0]; h.last = d ? `${d.state}${d.last_status ? ` (${d.last_status})` : ''}` : null; }++ automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };++ }res.type('html').send(renderPanel({robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,-- profileForm,++ profileForm, automation,allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),// A held control is re-sent well inside the device's deadline, so it never stops between two frames.holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++++/**++ * Robot webhooks (plan T15 "webhooks"): an owner points a robot's bot.* events at an https endpoint of theirs. Each hook++ * has its own secret (whsec_…, shown once when it is made), and every delivery is signed with it exactly like an++ * OpenVibe.Events delivery (openvibe-sdk/events signDeliveryHeaders: X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2), so++ * a receiver checks it with the same code. Deliveries are written in the transaction that emits the event++ * (server/domain emit → enqueueIn), so one exists exactly when its event does, and sent by a loop with backoff.++ *++ * EVENTS the bot.* events a hook may take++ * createWebhooks({ db, post, now, log })++ * list(robotId) / create(robot, person, { url, events }) → { hook, secret } / remove(robotId, id)++ * enqueueIn(t, event_type, robotId, payload) inside the emitting transaction++ * sendDue() → how many were tried; start() / stop()++ * recent(hookId) → the last deliveries (state, attempts, status)++ *++ * The body is { id, type, robot_id, occurred_at, data } with the event's own payload as data (ids, kinds and results++ * only). A 2xx answer delivers; anything else, a refused address or a network error retries after 30 s, 2, 10, 30 and++ * 120 minutes, then the delivery fails. Nothing a receiver answers is kept but its status code.++ */++const crypto = require('crypto');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { BotError, prefixedId, iso } = require('./util');++const { checkWebhookUrl, WebhookRefused } = require('./net/webhook-post');++++const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.estop.set', 'bot.estop.cleared', 'bot.command.refused'];++const BACKOFF_S = [30, 120, 600, 1800, 7200];++const MAX_HOOKS = 5;++const TICK_MS = 5000;++++const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;++++function createWebhooks({ db, post, now = () => Date.now(), log = console, tickMs = TICK_MS }) {++ let timer = null;++ let busy = false;++++ const list = (robotId) => db.many('SELECT id, robot_id, url, events, enabled, created_at FROM robot_webhooks WHERE robot_id = $1 ORDER BY created_at', [robotId]);++++ async function create(robot, person, { url, events }) {++ let u;++ try { u = checkWebhookUrl(url); } catch (e) {++ if (e instanceof WebhookRefused) throw new BotError(422, 'bot.webhook_refused', e.message);++ throw e;++ }++ const chosen = [...new Set((Array.isArray(events) ? events : [events]).filter(Boolean).map(String))];++ if (!chosen.length || chosen.some((e) => !EVENTS.includes(e))) throw new BotError(422, 'bot.invalid_input', `events are some of ${EVENTS.join(', ')}`);++ const count = Number(await db.value('SELECT count(*) FROM robot_webhooks WHERE robot_id = $1', [robot.id]));++ if (count >= MAX_HOOKS) throw new BotError(409, 'bot.too_many_webhooks', `a robot has at most ${MAX_HOOKS} webhooks`);++ const id = prefixedId('whk', now());++ const secret = mintSecret();++ await db.query('INSERT INTO robot_webhooks (id, robot_id, url, events, secret, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',++ [id, robot.id, u.href, chosen, secret, person.subject, iso(now())]);++ const hook = await db.maybe('SELECT id, robot_id, url, events, enabled, created_at FROM robot_webhooks WHERE id = $1', [id]);++ return { hook, secret };++ }++++ async function remove(robotId, id) {++ return (await db.exec('DELETE FROM robot_webhooks WHERE id = $1 AND robot_id = $2', [id, robotId])) > 0;++ }++++ /** Inside the emitting transaction: one delivery per enabled hook of the robot that takes this event. */++ async function enqueueIn(t, eventType, robotId, payload) {++ if (!robotId || !EVENTS.includes(eventType)) return 0;++ const hooks = await t.many('SELECT id FROM robot_webhooks WHERE robot_id = $1 AND enabled AND $2 = ANY(events)', [robotId, eventType]);++ if (!hooks.length) return 0;++ const at = iso(now());++ const body = JSON.stringify({ id: prefixedId('whe', now()), type: eventType, robot_id: robotId, occurred_at: at, data: payload || {} });++ for (const h of hooks) {++ await t.query(`INSERT INTO robot_webhook_deliveries (id, webhook_id, event_type, body, due_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $5, $5)`, [prefixedId('whd', now()), h.id, eventType, body, at]);++ }++ return hooks.length;++ }++++ async function sendOne(d) {++ const hook = await db.maybe('SELECT url, secret, enabled FROM robot_webhooks WHERE id = $1', [d.webhook_id]);++ const attempts = d.attempts + 1;++ const at = iso(now());++ if (!hook || !hook.enabled) {++ await db.query("UPDATE robot_webhook_deliveries SET state = 'failed', attempts = $2, last_error = $3, updated_at = $4 WHERE id = $1", [d.id, attempts, 'the webhook is gone or off', at]);++ return;++ }++ let status = null; let error = null;++ try {++ const r = await post(hook.url, { headers: { ...signDeliveryHeaders(d.body, hook.secret, { now: now() }), 'X-OpenVibe-Event': d.event_type, 'X-OpenVibe-Delivery': d.id }, body: d.body });++ status = r.status;++ } catch (e) { error = String(e.message || e).slice(0, 200); }++ if (status >= 200 && status < 300) {++ await db.query("UPDATE robot_webhook_deliveries SET state = 'delivered', attempts = $2, last_status = $3, last_error = NULL, updated_at = $4 WHERE id = $1", [d.id, attempts, status, at]);++ return;++ }++ const wait = BACKOFF_S[attempts - 1];++ const why = error || `answered ${status}`;++ if (wait == null) await db.query("UPDATE robot_webhook_deliveries SET state = 'failed', attempts = $2, last_status = $3, last_error = $4, updated_at = $5 WHERE id = $1", [d.id, attempts, status, why, at]);++ else await db.query('UPDATE robot_webhook_deliveries SET attempts = $2, last_status = $3, last_error = $4, due_at = $5, updated_at = $6 WHERE id = $1', [d.id, attempts, status, why, iso(now() + wait * 1000), at]);++ }++++ async function sendDue() {++ if (busy) return 0;++ busy = true;++ try {++ const due = await db.many("SELECT * FROM robot_webhook_deliveries WHERE state = 'pending' AND due_at <= $1 ORDER BY due_at LIMIT 25", [iso(now())]);++ for (const d of due) await sendOne(d).catch((e) => log.warn(`[Bot] webhook ${d.id}: ${e.message}`));++ return due.length;++ } finally { busy = false; }++ }++++ const recent = (hookId) => db.many('SELECT id, event_type, state, attempts, last_status, last_error, created_at, updated_at FROM robot_webhook_deliveries WHERE webhook_id = $1 ORDER BY created_at DESC LIMIT 10', [hookId]);++++ function start() {++ if (timer || !tickMs) return;++ timer = setInterval(() => { sendDue().catch((e) => log.warn(`[Bot] webhooks: ${e.message}`)); }, tickMs);++ if (timer.unref) timer.unref();++ }++ function stop() { if (timer) clearInterval(timer); timer = null; }++++ return { list, create, remove, enqueueIn, sendDue, recent, start, stop, EVENTS };++}++++module.exports = { createWebhooks, EVENTS, BACKOFF_S };nnew file mode 100644@@@ -0,0 +1,177 @@++'use strict';++// Routines and webhooks (plan T15 "schedules and automations … webhooks"): an owner's steps run through the same gate++// as a person's commands (on demand, on a schedule, when the robot comes online), stop at anything the robot refuses,++// and a robot's events reach the owner's https endpoint signed like OpenVibe.Events deliveries, with retries.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { parseSteps, parseTrigger, nextRun } = require('../server/routines');++const { createWebhooks, BACKOFF_S } = require('../server/webhooks');++const { verifyDeliveryV2 } = require('openvibe-sdk/events');++const { loadProfiles } = require('../server/profiles');++const world = require('../public/sim-world');++++(async () => {++ const profiles = loadProfiles();++++ await check('steps parse against the robot\'s own profile, with the line that is wrong', () => {++ const sim = profiles.get('sim.rover');++ const { steps } = parseSteps('# patrol\nforward 2\nright 0.5 0.4\nheadlights #ff8800\nhorn C5 0.3\nwait 1\nback 1\nstop', sim);++ assert.deepStrictEqual(steps.map((s) => s.kind), ['drive', 'drive', 'actuator', 'actuator', 'wait', 'drive', 'drive']);++ assert.deepStrictEqual(steps[1].value, { steer: 0.4 });++ assert.deepStrictEqual(steps[2].value, { name: 'headlights', value: { r: 255, g: 136, b: 0 } });++ assert.deepStrictEqual(steps[3].value, { name: 'horn', value: { note: 'C5' } });++ const cozmo = profiles.get('cozmo');++ const c = parseSteps('say Hello there\nface happy\nhead 0.5', cozmo).steps;++ assert.deepStrictEqual(c.map((s) => s.kind), ['say', 'display', 'actuator']);++ for (const [bad, why] of [['say hi', /does not speak/], ['fly 3', /line 1 \("fly 3"\): "fly"/], ['forward 120', /seconds/], ['', /at least one step/], ['headlights red', /colour/]]) {++ assert.throws(() => parseSteps(bad, sim), (e) => e.status === 422 && why.test(e.detail), bad);++ }++ assert.throws(() => parseSteps(Array(12).fill('forward 60').join('\n'), sim), /at most 10 minutes/);++ });++++ await check('triggers: every N minutes (at least 5), daily at a UTC time on chosen days, or each time it comes online', () => {++ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', every: 15 }), new Date('2026-10-14T10:00:00Z')).toISOString(), '2026-10-14T10:15:00.000Z');++ const wed = new Date('2026-10-14T10:00:00Z'); // a Wednesday++ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', at: '09:30', days: [1, 3] }), wed).toISOString(), '2026-10-19T09:30:00.000Z');++ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', at: '23:00' }), wed).toISOString(), '2026-10-14T23:00:00.000Z');++ assert.strictEqual(nextRun({ type: 'online' }, wed), null);++ assert.throws(() => parseTrigger({ type: 'schedule', every: 2 }), /at least 5/);++ assert.throws(() => parseTrigger({ type: 'schedule', at: '25:00' }), /09:30/);++ assert.throws(() => parseTrigger({ type: 'weekly' }), /manual, schedule or online/);++ });++++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const { routines } = t.app.locals;++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(50); } return null; };++ const { robot } = await t.robot(alex, { name: 'Patroller', profile_id: 'sim.rover' });++ assert.ok(await t.app.locals.sim.attach(robot.id));++ await poll(() => t.hub.isOnline(`dev_sim_${robot.id}`));++ const api = (method, path, user, body) => t.call(method, `/api/v1/robots/${robot.id}${path}`, { user, body });++ const routineOf = async (id) => (await api('GET', '/routines', alex)).json.routines.find((r) => r.id === id);++++ await check('the owner\'s routine runs through the gate: the simulated rover drives, lights up, and it ends done', async () => {++ const made = await api('POST', '/routines', alex, { name: 'Patrol', steps: 'forward 0.8\nheadlights #ff0000\nwait 0.2\nstop' });++ assert.strictEqual(made.status, 201, made.text);++ assert.deepStrictEqual([made.json.routine.trigger, made.json.routine.steps.split('\n').length], [{ type: 'manual' }, 4]);++ const run = await api('POST', `/routines/${made.json.routine.id}/run`, alex);++ assert.strictEqual(run.status, 202, run.text);++ assert.ok(await poll(async () => (await routineOf(made.json.routine.id)).last_result === 'done'), 'it finished');++ // Telemetry is sampled twice a second: wait for the sample after the last step.++ const tel = await poll(() => { const x = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return x && x.sim.headlights && x.sim.headlights.r === 255 && x.sim.pose.y > world.START.y + 0.1 ? x : null; });++ assert.ok(tel, 'the rover moved forward and its headlights are red');++ const audit = (await api('GET', '/audit', alex)).json.audit || [];++ assert.ok(audit.some((a) => a.kind === 'drive' && a.operator_subject === alex.subject && a.result === 'ack'), 'each step is on the record as the owner\'s');++ });++++ await check('one routine at a time; removing a running routine stops it; the e-stop stops one with its reason', async () => {++ const long = (await api('POST', '/routines', alex, { name: 'Long', steps: 'forward 5' })).json.routine;++ assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 202);++ assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 409, 'already running');++ assert.strictEqual((await api('DELETE', `/routines/${long.id}`, alex)).status, 204);++ assert.ok(await poll(() => !routines.isRunning(robot.id)), 'it stopped');++ const again = (await api('POST', '/routines', alex, { name: 'Again', steps: 'forward 5' })).json.routine;++ await api('POST', `/routines/${again.id}/run`, alex);++ await t.wait(400);++ assert.strictEqual((await api('POST', '/estop', alex)).status, 200);++ const stopped = await poll(async () => { const r = await routineOf(again.id); return r.last_result.startsWith('stopped') ? r : null; });++ assert.ok(stopped, 'the e-stop stopped it');++ assert.match(stopped.last_result, /stopped: .*(e-stop|estop|latched)/i);++ assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);++ });++++ await check('a schedule runs when due and moves on; an "each time it comes online" routine runs on the robot\'s return', async () => {++ const s = (await api('POST', '/routines', alex, { name: 'Every 5', steps: 'headlights #00ff00', trigger: { type: 'schedule', every: 5 } })).json.routine;++ assert.ok(Date.parse(s.next_run_at) > Date.now() + 4 * 60000, 'first run in 5 minutes');++ await t.db.query("UPDATE robot_routines SET next_run_at = now() - interval '1 second' WHERE id = $1", [s.id]);++ assert.strictEqual(await routines.tick(), 1);++ assert.ok(await poll(async () => (await routineOf(s.id)).last_result === 'done'));++ assert.ok(Date.parse((await routineOf(s.id)).next_run_at) > Date.now() + 4 * 60000, 'the next run is 5 minutes on');++ assert.strictEqual(await routines.tick(), 0, 'nothing else is due');++ const on = (await api('POST', '/routines', alex, { name: 'Hello', steps: 'headlights #0000ff', trigger: { type: 'online' } })).json.routine;++ await routines.onOnline(robot.id);++ assert.ok(await poll(async () => (await routineOf(on.id)).last_result === 'done'));++ assert.ok(await poll(() => { const x = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return x && x.sim.headlights && x.sim.headlights.b === 255; }), 'the online routine turned the lights blue');++ });++++ await check('someone else\'s robot: no routines, no webhooks, read or write', async () => {++ assert.strictEqual((await api('GET', '/routines', bob)).status, 403);++ assert.strictEqual((await api('POST', '/routines', bob, { name: 'x', steps: 'forward 1' })).status, 403);++ assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.estop.set'] })).status, 403);++ const bad = await api('POST', '/routines', alex, { name: 'Bad', steps: 'say hello' });++ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_routine']);++ });++++ let secret; let hookId;++ await check('webhooks: https and public only, at most five, the secret once', async () => {++ for (const url of ['http://hooks.example.com/x', 'https://localhost/x', 'https://10.1.2.3/x', 'https://user:[email protected]/x']) {++ assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.estop.set'] })).status, 422, url);++ }++ assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/x', events: ['bot.robot.exploded'] })).status, 422);++ const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.estop.set', 'bot.estop.cleared'] });++ assert.strictEqual(made.status, 201, made.text);++ secret = made.json.secret;++ hookId = made.json.webhook.id;++ assert.match(secret, /^whsec_[A-Za-z0-9_-]{43}$/);++ const list = await api('GET', '/webhooks', alex);++ assert.ok(!list.text.includes('whsec_'), 'a read never carries the secret');++ for (let i = 0; i < 4; i++) assert.strictEqual((await api('POST', '/webhooks', alex, { url: `https://hooks.example.com/${i}`, events: ['bot.robot.online'] })).status, 201);++ assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/6', events: ['bot.robot.online'] })).status, 409);++ });++++ await check('an event writes a delivery with it; the sender signs it like Events and retries a failing receiver', async () => {++ await t.db.query("DELETE FROM robot_webhook_deliveries");++ assert.strictEqual((await api('POST', '/estop', alex)).status, 200);++ const rows = await t.db.many("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1", [hookId]);++ assert.strictEqual(rows.length, 1, 'one delivery, written with the event');++ const got = [];++ let answer = 500;++ const sender = createWebhooks({ db: t.db, post: async (url, { headers, body }) => { got.push({ url, headers, body }); return { status: answer }; }, tickMs: 0 });++ assert.strictEqual(await sender.sendDue(), 1);++ const first = got[0];++ assert.strictEqual(first.url, 'https://hooks.example.com/robot');++ const body = JSON.parse(first.body);++ assert.deepStrictEqual([body.type, body.robot_id], ['bot.estop.set', robot.id]);++ assert.ok(verifyDeliveryV2(first.body, first.headers, secret), 'the signature verifies with the hook\'s secret');++ assert.ok(!verifyDeliveryV2(first.body, first.headers, 'whsec_wrong'), 'and with nothing else');++ assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.estop.set');++ let row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);++ assert.deepStrictEqual([row.state, row.attempts, row.last_status], ['pending', 1, 500]);++ assert.ok(Date.parse(row.due_at) > Date.now() + (BACKOFF_S[0] - 2) * 1000, 'retried after 30 s');++ for (let i = 0; i < BACKOFF_S.length; i++) {++ await t.db.query("UPDATE robot_webhook_deliveries SET due_at = now() - interval '1 minute' WHERE id = $1", [row.id]); // now() keeps microseconds the sender's clock does not++ await sender.sendDue();++ }++ row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);++ assert.deepStrictEqual([row.state, row.attempts], ['failed', BACKOFF_S.length + 1], 'it gives up');++ answer = 204;++ assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);++ await sender.sendDue();++ const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.estop.cleared'", [hookId]);++ assert.deepStrictEqual([cleared.state, cleared.last_status], ['delivered', 204]);++ });++++ await check('the owner\'s panel has both cards; a bad routine answers 422 with the form; a new hook\'s secret shows once', async () => {++ const cookie = { Cookie: `ov_token=${t.network.signUser(alex)}` };++ const html = await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie })).text();++ assert.ok(html.includes('id="routines"') && html.includes('Patrol') && html.includes('id="webhooks"') && html.includes('hooks.example.com/robot'));++ assert.ok(html.includes('<code>headlights #ff8800</code>'), 'the help line lists this robot\'s own steps');++ assert.ok(!html.includes(secret), 'the secret is not on the panel again');++ const form = (path, body) => fetch(`${t.base}${path}`, { method: 'POST', redirect: 'manual', body: new URLSearchParams(body).toString(), headers: { ...cookie, 'Content-Type': 'application/x-www-form-urlencoded' } });++ const bad = await form(`/robots/${robot.id}/routines`, { name: 'Nope', steps: 'jump 2', trigger: 'manual' });++ assert.strictEqual(bad.status, 422);++ assert.match(await bad.text(), /"jump" is not a step this robot knows/);++ const ok = await form(`/robots/${robot.id}/routines`, { name: 'Weekdays', steps: 'forward 1', trigger: 'daily', at: '08:15', days: '1' });++ assert.strictEqual(ok.status, 303);++ await t.db.query('DELETE FROM robot_webhooks WHERE robot_id = $1', [robot.id]);++ const hook = await form(`/robots/${robot.id}/webhooks`, { url: 'https://hooks.example.com/panel', events: 'bot.robot.online' });++ assert.strictEqual(hook.status, 201);++ assert.match(await hook.text(), /shown this once: <code>whsec_/);++ });++++ t.app.locals.sim.stopAll();++ routines.stop();++ await t.close();++ done();++})();
@@ -16,6 +16,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -16,6 +16,8 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The front page, `GET /` (`server/web/home.js`): the OpenVibe Frame (`openvibe-shared/shell`) around `openvibe-shared/showcase` sections that say only what works today (pairing, the panel's controls, the gate and the e-stop, people and the queue, the read-only embed, the simulator and the kits with drivers), with its own CSP for the Frame's calls to the Network; the rest of the site keeps `default-src 'self'`. `/shared/` is openvibe-shared's serve handler.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
+- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
+- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
- `/try`, public and without an account: the same panel on a rover that runs in the visitor's own browser (`public/try-device.js` answers the panel's `/control` frames with the gate's shaping, deadman and latched e-stop), so anyone can drive before owning hardware. Nothing after the page reaches the server. The panel (`public/panel.js`, no build step) drives with a touch joystick, the keyboard or a gamepad, shows a latency meter and one camera tile per camera the profile lists; a tile plays the robot's OpenRestream WebRTC session when one is live (the mediasoup-client viewer flow OpenVibe.Live runs, against the session's playback descriptor), and stays the placeholder when there is none.
@@ -46,6 +46,8 @@
@@ -46,6 +46,8 @@
"the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",
"the robot directory (Frame pages, server/web/directory.js): public /robots (listed robots with online state, model and owner; every model; a signed-in person's own robots), /models and /models/:id (how it connects, setup steps, guide, panel controls), /r/:id (a listed robot's public page with its live embeddable panel), /robots/new (name, model choice grouped by connection kind, who may drive); owners list from the panel (POST /robots/:id/listing, robots.listed, migration 0009), listing turns on embed_public and unwatching unlists; listed robots and every model are in the sitemap",
"a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
"a phone as the robot (profile phone.camera, driver phone, Contracts 0.157.0): the pairing page's QR code (server/web/qr.js, qrcode-generator) opens /device/phone with the robot and code in the fragment; the phone pairs over POST /api/v1/pair and its browser is the device (public/phone-device.js): /device with the credential as the subprotocol ov.device.<credential>, the camera over WHIP to OpenRestream, say (speech synthesis), display (faces, text), torch, chime, battery and tilt telemetry, a local Stop (estop_state local_stop); /device/phone has its own CSP (the WHIP ingest origin) and Permissions-Policy camera=(self)",
"community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
"community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
+ "routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",
+ "webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+-- phase: expand
+--
+-- Robot routines and webhooks (plan T15 "schedules and automations … webhooks"). Additive only.
+--
+-- robot_routines: an owner's named sequence of steps (server/routines.js parses its text form against the robot's
+-- profile), run on demand, on a schedule (every N minutes, or daily at a UTC time on chosen weekdays) or when the robot
+-- comes online. Each step passes the same gate as a person's command, as the owner.
+--
+-- robot_webhooks / robot_webhook_deliveries: an owner's https endpoint for the robot's bot.* events. A delivery row is
+-- written in the transaction that emits the event (server/domain emit), so it exists exactly when the event does; the
+-- sender (server/webhooks.js) signs each with the hook's secret (X-OpenVibe-Signature-V2, as Events deliveries are) and
+-- retries with backoff. The secret is shown once when the hook is made; deleting a robot deletes its hooks.
+
+CREATE TABLE IF NOT EXISTS robot_routines (
+ id text PRIMARY KEY, -- rtn_…
+ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
+ name text NOT NULL,
+ source text NOT NULL, -- the steps as the owner wrote them
+ steps jsonb NOT NULL, -- the parsed steps
+ trigger jsonb NOT NULL, -- { type: manual | schedule | online, … }
+ enabled boolean NOT NULL DEFAULT true,
+ next_run_at timestamptz, -- schedule only
+ last_run_at timestamptz,
+ last_result text, -- running | done | stopped: <why>
+ created_by text NOT NULL,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS robot_routines_robot_idx ON robot_routines (robot_id);
+CREATE INDEX IF NOT EXISTS robot_routines_due_idx ON robot_routines (next_run_at) WHERE enabled AND next_run_at IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS robot_webhooks (
+ id text PRIMARY KEY, -- whk_…
+ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
+ url text NOT NULL,
+ events text[] NOT NULL,
+ secret text NOT NULL, -- whsec_…, shown once; signs deliveries
+ enabled boolean NOT NULL DEFAULT true,
+ created_by text NOT NULL,
+ created_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS robot_webhooks_robot_idx ON robot_webhooks (robot_id);
+
+CREATE TABLE IF NOT EXISTS robot_webhook_deliveries (
+ id text PRIMARY KEY, -- whd_…
+ webhook_id text NOT NULL REFERENCES robot_webhooks(id) ON DELETE CASCADE,
+ event_type text NOT NULL,
+ body text NOT NULL, -- the exact JSON that is signed and sent
+ state text NOT NULL DEFAULT 'pending' CHECK (state IN ('pending', 'delivered', 'failed')),
+ attempts integer NOT NULL DEFAULT 0,
+ due_at timestamptz NOT NULL,
+ last_status integer,
+ last_error text,
+ created_at timestamptz NOT NULL,
+ updated_at timestamptz NOT NULL
+);
+CREATE INDEX IF NOT EXISTS robot_webhook_deliveries_due_idx ON robot_webhook_deliveries (due_at) WHERE state = 'pending';
+CREATE INDEX IF NOT EXISTS robot_webhook_deliveries_hook_idx ON robot_webhook_deliveries (webhook_id, created_at DESC);
@@ -332,3 +332,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -332,3 +332,14 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.phone-foot { display: flex; gap: 0.5rem; padding: 0.75rem; padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); }
.phone-foot { display: flex; gap: 0.5rem; padding: 0.75rem; padding-bottom: max(0.75rem, env(safe-area-inset-bottom)); }
.phone-stop { flex: 1; min-height: 56px; font-size: 1.15rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--danger); border-color: var(--danger); color: #fff; }
.phone-stop { flex: 1; min-height: 56px; font-size: 1.15rem; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; background: var(--danger); border-color: var(--danger); color: #fff; }
.phone-stop.resume { background: var(--ok); border-color: var(--ok); color: #04130a; }
.phone-stop.resume { background: var(--ok); border-color: var(--ok); color: #04130a; }
+
+/* Routines and webhooks (the owner's panel cards). */
+.routine-actions { display: inline-flex; gap: 0.4rem; }
+.routine-form, .webhook-form { display: grid; gap: 0.6rem; margin-top: 0.6rem; }
+.routine-form textarea { width: 100%; box-sizing: border-box; font: 0.85rem/1.5 ui-monospace, monospace; padding: 0.6rem; border-radius: 10px; border: 1px solid var(--line); background: var(--bg); color: var(--text); }
+.routine-form .trigger { border: 1px solid var(--line); border-radius: 10px; padding: 0.6rem 0.8rem; display: grid; gap: 0.35rem; }
+.routine-form .trigger input[type="number"] { width: 5.5rem; min-height: 36px; }
+.routine-form .trigger input[type="time"] { min-height: 36px; }
+.days { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; }
+.secret-once { padding: 0.6rem 0.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); overflow-wrap: anywhere; }
+.setting-note code { font-size: 0.8em; }
@@ -37,7 +37,13 @@ const TABLES = [
@@ -37,7 +37,13 @@ const TABLES = [
async function extraExport(db, subject) {
async function extraExport(db, subject) {
const robots = await db.many(`SELECT id, name, profile_id, profile_version, access_policy, limits, created_at, updated_at FROM robots
const robots = await db.many(`SELECT id, name, profile_id, profile_version, access_policy, limits, created_at, updated_at FROM robots
WHERE owner_subject = $1 ORDER BY created_at DESC`, [subject]);
WHERE owner_subject = $1 ORDER BY created_at DESC`, [subject]);
- return robots.length ? [{ name: 'robots.json', content: robots }] : [];
+ if (!robots.length) return [];
+ const ids = robots.map((r) => r.id);
+ const routines = await db.many('SELECT robot_id, name, source, trigger, enabled, created_at FROM robot_routines WHERE robot_id = ANY($1::text[]) ORDER BY created_at', [ids]);
+ const hooks = await db.many('SELECT robot_id, url, events, created_at FROM robot_webhooks WHERE robot_id = ANY($1::text[]) ORDER BY created_at', [ids]);
+ return [{ name: 'robots.json', content: robots },
+ ...(routines.length ? [{ name: 'robot-routines.json', content: routines }] : []),
+ ...(hooks.length ? [{ name: 'robot-webhooks.json', content: hooks }] : [])];
}
}
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
/** The account-data handle over Bot's domain (server/domain: robots.remove, devices.revoke). */
@@ -49,7 +49,7 @@ const CAP = {
@@ -49,7 +49,7 @@ const CAP = {
dispatch: 'bot.job.dispatch',
dispatch: 'bot.job.dispatch',
};
};
-function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null }) {
+function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
r.use(limits.reads('bot.read'));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
@@ -201,6 +201,60 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -201,6 +201,60 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.status(201).json(await domain.pairing.create(req.params.id, me(req)));
res.status(201).json(await domain.pairing.create(req.params.id, me(req)));
}));
}));
+ // ── Routines and webhooks (server/routines.js, server/webhooks.js): the owner's, or a service's with manage ──
+ const presentRoutine = (r) => ({
+ id: r.id, robot_id: r.robot_id, name: r.name, steps: r.source, trigger: typeof r.trigger === 'string' ? JSON.parse(r.trigger) : r.trigger,
+ enabled: r.enabled, next_run_at: r.next_run_at ? new Date(r.next_run_at).toISOString() : null,
+ last_run_at: r.last_run_at ? new Date(r.last_run_at).toISOString() : null, last_result: r.last_result || null,
+ });
+ const needAutomation = () => { if (!routines || !webhooks) fail(503, 'bot.unavailable', 'automation is not available'); };
+ r.get('/robots/:id/routines', wrap(async (req, res) => {
+ needAutomation();
+ await ownerOrRead(req, req.params.id);
+ res.json({ routines: (await routines.list(req.params.id)).map(presentRoutine) });
+ }));
+ r.post('/robots/:id/routines', manage, wrap(async (req, res) => {
+ needAutomation();
+ const robot = await owner(req, req.params.id);
+ const profile = await getProfile(domain.db, robot.profile_id, robot.profile_version);
+ const b = req.body || {};
+ const made = await routines.create(robot, { subject: req.principal.kind === 'service' ? actedFor(req) : me(req) },
+ { name: b.name, source: b.steps, trigger: b.trigger || { type: 'manual' }, enabled: b.enabled !== false }, profile && profile.profile);
+ res.status(201).json({ routine: presentRoutine(made) });
+ }));
+ r.post('/robots/:id/routines/:rid/run', manage, wrap(async (req, res) => {
+ needAutomation();
+ const robot = await owner(req, req.params.id);
+ const routine = await routines.get(req.params.rid);
+ if (!routine || routine.robot_id !== robot.id) fail(404, 'bot.routine_not_found', 'no such routine');
+ if (!await routines.run(routine.id, { reason: 'api' })) fail(409, 'bot.routine_busy', 'a routine is already running on this robot');
+ res.status(202).json({ routine: presentRoutine(await routines.get(routine.id)) });
+ }));
+ r.delete('/robots/:id/routines/:rid', manage, wrap(async (req, res) => {
+ needAutomation();
+ const robot = await owner(req, req.params.id);
+ if (!await routines.remove(robot.id, req.params.rid)) fail(404, 'bot.routine_not_found', 'no such routine');
+ res.status(204).end();
+ }));
+ r.get('/robots/:id/webhooks', wrap(async (req, res) => {
+ needAutomation();
+ await ownerOrRead(req, req.params.id);
+ res.json({ webhooks: await webhooks.list(req.params.id), events: webhooks.EVENTS });
+ }));
+ // The secret is in this answer only: a hook's later reads never carry it.
+ r.post('/robots/:id/webhooks', manage, wrap(async (req, res) => {
+ needAutomation();
+ const robot = await owner(req, req.params.id);
+ const made = await webhooks.create(robot, { subject: req.principal.kind === 'service' ? actedFor(req) : me(req) }, { url: req.body?.url, events: req.body?.events });
+ res.status(201).json({ webhook: made.hook, secret: made.secret });
+ }));
+ r.delete('/robots/:id/webhooks/:hid', manage, wrap(async (req, res) => {
+ needAutomation();
+ const robot = await owner(req, req.params.id);
+ if (!await webhooks.remove(robot.id, req.params.hid)) fail(404, 'bot.webhook_not_found', 'no such webhook');
+ res.status(204).end();
+ }));
+
// ── Operators ─────────────────────────────────────────────────────────────────────────────────
// ── Operators ─────────────────────────────────────────────────────────────────────────────────
r.get('/robots/:id/operators', wrap(async (req, res) => {
r.get('/robots/:id/operators', wrap(async (req, res) => {
await member(req, req.params.id, ['owner', 'operator']);
await member(req, req.params.id, ['owner', 'operator']);
@@ -42,6 +42,9 @@ const { createSessionRoutes, viewerMiddleware } = require('./web/session');
@@ -42,6 +42,9 @@ const { createSessionRoutes, viewerMiddleware } = require('./web/session');
const { createWebRoutes } = require('./web/routes');
const { createWebRoutes } = require('./web/routes');
const { HOME_CSP } = require('./web/home');
const { HOME_CSP } = require('./web/home');
const { createSubmissions } = require('./submissions');
const { createSubmissions } = require('./submissions');
+const { createRoutines } = require('./routines');
+const { createWebhooks } = require('./webhooks');
+const { createWebhookPoster } = require('./net/webhook-post');
const { inputError } = require('./util');
const { inputError } = require('./util');
const { createBotReadiness, registerBotGauges } = require('./observability');
const { createBotReadiness, registerBotGauges } = require('./observability');
@@ -75,7 +78,12 @@ function createApp(opts = {}) {
@@ -75,7 +78,12 @@ function createApp(opts = {}) {
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
const nodes = opts.nodes || createNodePrincipals(config, { fetchImpl });
const identity = opts.identity || createIdentity(config, { fetchImpl });
const identity = opts.identity || createIdentity(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
const openre = opts.openre !== undefined ? opts.openre : createOpenRe(config, { fetchImpl });
- const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log });
+ // Webhooks are made first: the domain writes their deliveries in the transaction that emits each event.
+ const webhooks = createWebhooks({ db, post: createWebhookPoster({ userAgent: 'OpenVibe.Bot' }), now, log, tickMs: config.automation.webhookTickMs });
+ let routines = null;
+ const domain = createDomain({ db, config, outbox, link: hub, nodes, openre, now, log, webhooks,
+ onEvent: (type, robotId) => { if (type === 'bot.robot.online' && routines) routines.onOnline(robotId).catch((e) => log.warn(`[Bot] online routines: ${e.message}`)); } });
+ routines = createRoutines({ db, domain, hub, now, log, tickMs: config.automation.routineTickMs });
const submissions = createSubmissions({ db, config, now, log });
const submissions = createSubmissions({ db, config, now, log });
hub.bindDomain(domain);
hub.bindDomain(domain);
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
@@ -129,7 +137,7 @@ function createApp(opts = {}) {
@@ -129,7 +137,7 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
release.mount(app, { registry: metrics.registry });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
- app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions }));
+ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
// The document carries its own inline theme and OpenVibe Frame boot, which the strict site-wide CSP
@@ -138,7 +146,7 @@ function createApp(opts = {}) {
@@ -138,7 +146,7 @@ function createApp(opts = {}) {
{ const legal = require('openvibe-shared/legal');
{ const legal = require('openvibe-shared/legal');
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.get(legal.PATHS, (req, res, next) => { res.setHeader('Content-Security-Policy', HOME_CSP); next(); }, legal.handler({ id: 'bot', service: 'bot', host: 'openvibe.bot', name: 'OpenVibe.Bot', profile: 'games' })); }
app.use(viewerMiddleware(userAuth));
app.use(viewerMiddleware(userAuth));
- app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
+ app.use(createWebRoutes(config, { domain, sim, onvif, limits, identity, hub, log, submissions, routines, webhooks, releaseLastmod: release.full().released_at === release.full().booted_at ? undefined : release.full().released_at }));
app.use((req, res) => {
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
@@ -157,7 +165,7 @@ function createApp(opts = {}) {
@@ -157,7 +165,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions });
+ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
return app;
return app;
}
}
@@ -213,6 +213,11 @@ function loadConfig(env = process.env) {
@@ -213,6 +213,11 @@ function loadConfig(env = process.env) {
profiles: {
profiles: {
signingKey: env.BOT_PROFILE_SIGNING_KEY || '',
signingKey: env.BOT_PROFILE_SIGNING_KEY || '',
},
},
+ // Routines' scheduler and the webhook sender (server/routines.js, server/webhooks.js); 0 stops a loop (tests).
+ automation: {
+ routineTickMs: int(env.BOT_ROUTINE_TICK_MS, 30000),
+ webhookTickMs: int(env.BOT_WEBHOOK_TICK_MS, 5000),
+ },
// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by
// Server-side ONVIF cameras (driver `onvif`, server/onvif): the hosts Bot may connect to, keyed by
// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret
// robot id (one camera per robot), plus the timeout each request gets. Credentials are secret
// references only (username_ref/password_ref), resolved when the request is built; the config never
// references only (username_ref/password_ref), resolved when the request is built; the config never
@@ -65,7 +65,7 @@ function noteToHz(note) {
@@ -65,7 +65,7 @@ function noteToHz(note) {
}
}
-function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, now = () => Date.now(), log = console }) {
+function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@ -88,6 +88,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -88,6 +88,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function emit(t, event_type, subject, payload) {
async function emit(t, event_type, subject, payload) {
await outbox.emitIn(t, { ...ENVELOPE, event_type, subject, payload });
await outbox.emitIn(t, { ...ENVELOPE, event_type, subject, payload });
+ // The robot's webhooks get it in the same transaction (server/webhooks.js); listeners (the routines' "each time
+ // it comes online") hear of it once the transaction has had time to commit.
+ const robotId = subject && subject.type === 'robot' ? subject.id : null;
+ if (webhooks && robotId) await webhooks.enqueueIn(t, event_type, robotId, payload);
+ if (onEvent && robotId) { const tm = setTimeout(() => { try { onEvent(event_type, robotId, payload); } catch (e) { log.warn(`[Bot] event listener: ${e.message}`); } }, 300); if (tm.unref) tm.unref(); }
}
}
// ── Membership and roles ──────────────────────────────────────────────────────────────────────
// ── Membership and roles ──────────────────────────────────────────────────────────────────────
@@ -33,7 +33,7 @@ async function main() {
@@ -33,7 +33,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { domain, keys, outbox, usage, hub, sim, onvif } = app.locals;
+ const { domain, keys, outbox, usage, hub, sim, onvif, routines, webhooks } = app.locals;
keys.start().catch(() => { /* logged by the JWKS client */ });
keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const timers = [];
@@ -44,6 +44,8 @@ async function main() {
@@ -44,6 +44,8 @@ async function main() {
every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
outbox.start();
outbox.start();
usage.start();
usage.start();
+ routines.start(); // scheduled routines (server/routines.js)
+ webhooks.start(); // robot webhook deliveries (server/webhooks.js)
}
}
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
@@ -78,6 +80,8 @@ async function main() {
@@ -78,6 +80,8 @@ async function main() {
deadlineExitCode: 0,
deadlineExitCode: 0,
stop: [
stop: [
() => timers.forEach(clearInterval),
() => timers.forEach(clearInterval),
+ () => routines.stop(),
+ () => webhooks.stop(),
() => keys.stop(),
() => keys.stop(),
() => sim.stopAll(),
() => sim.stopAll(),
() => onvif.stopAll(),
() => onvif.stopAll(),
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The one place Bot connects to an address a caller chose: a robot's webhook (plan T15; the same poster as
+ * OpenVibe.Actor's). Everything else Bot fetches goes to a configured base.
+ *
+ * checkWebhookUrl(url) → URL, or throws WebhookRefused: https only, port 443 or 8443, no credentials, not a name
+ * that can only mean this machine or a private network, not a non-public IP literal
+ * createWebhookPoster({ userAgent, timeoutMs, lookup }) → post(url, { headers, body }) → { status }
+ *
+ * The poster checks the URL again at send time and connects through openvibe-shared/egress safeLookup, which fails
+ * unless every address the name resolves to is public, so a DNS answer cannot change between the check and the
+ * connect. A redirect is never followed (a 3xx is the receiver's answer like any other), and at most a few KiB of the
+ * answer are read and discarded: nothing a receiver says is stored.
+ */
+const https = require('https');
+const net = require('net');
+const egress = require('openvibe-shared/egress');
+
+const PORTS = new Set(['', '443', '8443']);
+const MAX_RESPONSE_BYTES = 4096;
+
+class WebhookRefused extends Error {
+ constructor(detail) { super(detail); this.code = 'bot.webhook.refused'; }
+}
+
+function checkWebhookUrl(input) {
+ let u;
+ try { u = new URL(String(input)); } catch { throw new WebhookRefused('not a URL'); }
+ if (u.protocol !== 'https:') throw new WebhookRefused('a webhook URL is https');
+ if (u.username || u.password) throw new WebhookRefused('credentials in the URL are not allowed');
+ if (!PORTS.has(u.port)) throw new WebhookRefused('a webhook URL uses port 443 or 8443');
+ const host = egress.normalizeHost(u.hostname);
+ if (net.isIP(host) ? !egress.isPublicAddress(host) : egress.isInternalName(host)) throw new WebhookRefused(`${host} is not a public address`);
+ return u;
+}
+
+function createWebhookPoster({ userAgent = 'OpenVibe.Bot', timeoutMs = 10_000, lookup = egress.safeLookup } = {}) {
+ return async function post(url, { headers = {}, body = '' } = {}) {
+ const u = checkWebhookUrl(url);
+ const payload = Buffer.from(String(body));
+ return await new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (fn, v) => { if (!settled) { settled = true; fn(v); } };
+ const req = https.request(u, {
+ method: 'POST', lookup, timeout: timeoutMs,
+ headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length, 'User-Agent': userAgent, ...headers },
+ }, (res) => {
+ let seen = 0;
+ res.on('data', (chunk) => { seen += chunk.length; if (seen > MAX_RESPONSE_BYTES) res.destroy(); });
+ const done = () => finish(resolve, { status: res.statusCode });
+ res.on('end', done);
+ res.on('close', done);
+ res.on('error', done);
+ });
+ req.on('timeout', () => req.destroy(Object.assign(new Error(`no answer within ${timeoutMs} ms`), { code: 'ETIMEDOUT' })));
+ req.on('error', (err) => finish(reject, err));
+ req.end(payload);
+ });
+ };
+}
+
+module.exports = { checkWebhookUrl, createWebhookPoster, WebhookRefused };
new file mode 100644
new file mode 100644
@@ -0,0 +1,278 @@
@@ -0,0 +1,278 @@
+'use strict';
+
+/**
+ * Robot routines (plan T15 "schedules and automations"): an owner writes a robot's steps in plain lines, Bot runs them
+ * on demand, on a schedule, or when the robot comes online. Every step passes the same gate as a person's command, as
+ * the robot's owner (hub.operatorCommand): the robot's limits, cooldowns and e-stop apply, and the audit has each one.
+ *
+ * parseSteps(text, profile) → { steps } or throws BotError 422 with the line that is wrong. One step per line:
+ * forward [seconds] [speed] back [s] [speed] left [s] [speed] right [s] [speed] stop
+ * wait <seconds> say <text> face <name> show <text> press <button>
+ * <actuator> <value> [seconds] (number, #rrggbb or off, a note like A4 or off, on/off; held for the seconds)
+ * pan|tilt|zoom <value> [seconds]
+ * parseTrigger({ type, every, at, days }) → { type: 'manual' } | { type: 'schedule', every?|at+days } | { type: 'online' }
+ * nextRun(trigger, from) → Date | null
+ * createRoutines({ db, domain, hub, now, log }) → { list, get, create, remove, run, tick, onOnline, stop }
+ *
+ * A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero
+ * drive ends it. A refused or failed command stops the routine there (an e-stop, the robot going offline, a cooldown
+ * the owner set too long); the reason is kept in last_result. One routine runs per robot at a time.
+ */
+const { BotError, prefixedId, iso, text } = require('./util');
+
+const HOLD_MS = 150;
+const MAX_STEPS = 60;
+const MAX_STEP_S = 60;
+const MAX_TOTAL_S = 600;
+const MIN_EVERY_MIN = 5;
+const TICK_MS = 30000;
+
+const fail = (detail) => { throw new BotError(422, 'bot.invalid_routine', detail); };
+const num = (s, what, lo, hi) => {
+ const n = Number(s);
+ if (!Number.isFinite(n) || n < lo || n > hi) fail(`${what} must be a number from ${lo} to ${hi}`);
+ return n;
+};
+
+/** The drive axes for forward/back and left/right on this profile: [forward axis, turn axis]. */
+function driveAxes(profile) {
+ const axes = (profile.commands && profile.commands.drive && profile.commands.drive.axes) || null;
+ if (!axes) return null;
+ const fwd = ['throttle', 'y'].find((a) => axes[a]);
+ const turn = ['steer', 'rotation', 'x'].find((a) => axes[a]);
+ return { fwd, turn };
+}
+
+function parseSteps(source, profile) {
+ const commands = (profile && profile.commands) || {};
+ const lines = String(source || '').split('\n');
+ const steps = [];
+ let total = 0;
+ lines.forEach((raw, i) => {
+ const line = raw.trim();
+ if (!line || line.startsWith('#')) return; // a line starting with # is a note (colours keep their #)
+ const at = `line ${i + 1} ("${line.slice(0, 40)}")`;
+ const [word, ...rest] = line.split(/\s+/);
+ const w = word.toLowerCase();
+ const secs = (v, d) => (v == null ? d : num(v, `${at}: the seconds`, 0.05, MAX_STEP_S));
+ const step = (() => {
+ if (['forward', 'back', 'backward', 'left', 'right'].includes(w)) {
+ const d = driveAxes(profile);
+ if (!d) fail(`${at}: this robot does not drive`);
+ const turning = w === 'left' || w === 'right';
+ const axis = turning ? d.turn : d.fwd;
+ if (!axis) fail(`${at}: this robot cannot ${turning ? 'turn' : 'drive forward'}`);
+ const speed = rest[1] == null ? (turning ? 0.7 : 0.6) : num(rest[1], `${at}: the speed`, 0.05, 1);
+ const sign = w === 'back' || w === 'backward' || w === 'left' ? -1 : 1;
+ return { kind: 'drive', value: { [axis]: Math.round(sign * speed * 100) / 100 }, seconds: secs(rest[0], 1), hold: true };
+ }
+ if (w === 'stop') { if (!commands.drive) fail(`${at}: this robot does not drive`); return { kind: 'drive', value: {}, seconds: 0 }; }
+ if (w === 'wait') return { kind: 'wait', seconds: secs(rest[0], 1) };
+ if (w === 'say') {
+ if (!commands.say) fail(`${at}: this robot does not speak`);
+ const t = rest.join(' ');
+ if (!t) fail(`${at}: say what?`);
+ return { kind: 'say', value: { text: t.slice(0, (commands.say.max_chars || 200)) }, seconds: 0 };
+ }
+ if (w === 'face' || w === 'show') {
+ const d = commands.display;
+ if (!d) fail(`${at}: this robot has no display`);
+ if (w === 'face') {
+ const f = String(rest[0] || '').toLowerCase();
+ if (!(d.faces || []).includes(f)) fail(`${at}: faces are ${(d.faces || []).join(', ') || 'not shown here'}`);
+ return { kind: 'display', value: { face: f }, seconds: 0 };
+ }
+ if (!d.modes.includes('text')) fail(`${at}: this display shows no text`);
+ return { kind: 'display', value: { text: rest.join(' ').slice(0, d.max_chars || 200) }, seconds: 0 };
+ }
+ if (w === 'press') {
+ const names = (commands.button && commands.button.names) || {};
+ if (!names[rest[0]]) fail(`${at}: this robot has no button ${rest[0] || ''}`.trim());
+ return names[rest[0]].hold
+ ? { kind: 'button', value: { name: rest[0], state: 'down' }, seconds: secs(rest[1], 0.5), hold: true, release: { name: rest[0], state: 'up' } }
+ : { kind: 'button', value: { name: rest[0] }, seconds: 0 };
+ }
+ if (['pan', 'tilt', 'zoom'].includes(w) && commands.ptz && commands.ptz.axes[w]) {
+ return { kind: 'ptz', value: { [w]: num(rest[0], `${at}: the ${w}`, -1, 1) }, seconds: secs(rest[1], 0.5), hold: true, release: { [w]: 0 } };
+ }
+ const act = commands.actuator && commands.actuator.names[w];
+ if (act) {
+ const v = String(rest[0] == null ? '' : rest[0]);
+ let value;
+ if (act.type === 'number') value = num(v, `${at}: the value`, act.range[0], act.range[1]);
+ else if (act.type === 'bool') { if (!['on', 'off'].includes(v)) fail(`${at}: on or off`); value = v === 'on'; }
+ else if (act.type === 'rgb') {
+ if (v === 'off') value = null;
+ else { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(v); if (!m) fail(`${at}: a colour like #ff8800, or off`); value = { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) }; }
+ } else {
+ if (v === 'off') value = null;
+ else if (/^[A-Ga-g][#b]?-?\d$/.test(v)) value = { note: v[0].toUpperCase() + v.slice(1) };
+ else value = { hz: num(v, `${at}: a note like A4 or a frequency`, act.hz[0], act.hz[1]) };
+ const s = secs(rest[1], 0.5);
+ return { kind: 'actuator', value: { name: w, value }, seconds: value ? s : 0, hold: !!value, release: value ? { name: w, value: null } : null };
+ }
+ return { kind: 'actuator', value: { name: w, value }, seconds: 0 };
+ }
+ fail(`${at}: "${word}" is not a step this robot knows`);
+ return null;
+ })();
+ if (step.kind !== 'wait' && !commands[step.kind]) fail(`${at}: this robot does not take ${step.kind} commands`);
+ total += step.seconds || 0;
+ steps.push(step);
+ });
+ if (!steps.length) fail('write at least one step, one per line');
+ if (steps.length > MAX_STEPS) fail(`at most ${MAX_STEPS} steps`);
+ if (total > MAX_TOTAL_S) fail(`a routine runs at most ${MAX_TOTAL_S / 60} minutes`);
+ return { steps };
+}
+
+const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
+function parseTrigger(t = {}) {
+ const type = String(t.type || 'manual');
+ if (type === 'manual' || type === 'online') return { type };
+ if (type !== 'schedule') throw new BotError(422, 'bot.invalid_routine', 'the trigger is manual, schedule or online');
+ if (t.every != null && t.every !== '') {
+ const every = Number(t.every);
+ if (!Number.isInteger(every) || every < MIN_EVERY_MIN || every > 7 * 24 * 60) throw new BotError(422, 'bot.invalid_routine', `every is a whole number of minutes, at least ${MIN_EVERY_MIN}`);
+ return { type, every };
+ }
+ if (!TIME.test(String(t.at || ''))) throw new BotError(422, 'bot.invalid_routine', 'a schedule runs every N minutes, or at a time like 09:30 (UTC)');
+ const days = Array.isArray(t.days) && t.days.length ? [...new Set(t.days.map(Number))].filter((d) => Number.isInteger(d) && d >= 0 && d <= 6).sort() : [0, 1, 2, 3, 4, 5, 6];
+ if (!days.length) throw new BotError(422, 'bot.invalid_routine', 'choose at least one day');
+ return { type, at: String(t.at), days };
+}
+
+/** When a schedule next runs after `from` (a Date), or null for a trigger that is not a schedule. */
+function nextRun(trigger, from) {
+ if (!trigger || trigger.type !== 'schedule') return null;
+ if (trigger.every) return new Date(from.getTime() + trigger.every * 60000);
+ const [h, m] = trigger.at.split(':').map(Number);
+ for (let i = 0; i <= 7; i++) {
+ const d = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), from.getUTCDate() + i, h, m));
+ if (d > from && trigger.days.includes(d.getUTCDay())) return d;
+ }
+ return null;
+}
+
+function describeTrigger(t) {
+ if (!t || t.type === 'manual') return 'When you run it';
+ if (t.type === 'online') return 'Each time it comes online';
+ if (t.every) return `Every ${t.every} minutes`;
+ const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+ return `At ${t.at} UTC${t.days.length === 7 ? ' daily' : ` on ${t.days.map((d) => names[d]).join(', ')}`}`;
+}
+
+function createRoutines({ db, domain, hub, now = () => Date.now(), log = console, tickMs = TICK_MS }) {
+ const running = new Map(); // robot id → { routineId, stop: bool }
+ const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); if (t.unref) t.unref(); });
+ let timer = null;
+
+ const list = (robotId) => db.many('SELECT * FROM robot_routines WHERE robot_id = $1 ORDER BY created_at', [robotId]);
+ const get = (id) => db.maybe('SELECT * FROM robot_routines WHERE id = $1', [id]);
+
+ async function create(robot, person, { name, source, trigger, enabled = true }, profile) {
+ const cleanName = text(name, 'name', 60);
+ if (!cleanName) throw new BotError(422, 'bot.invalid_routine', 'name the routine');
+ const { steps } = parseSteps(source, profile);
+ const tr = parseTrigger(trigger);
+ const id = prefixedId('rtn', now());
+ const at = new Date(now());
+ await db.query(`INSERT INTO robot_routines (id, robot_id, name, source, steps, trigger, enabled, next_run_at, created_by, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, $10)`,
+ [id, robot.id, cleanName, String(source).slice(0, 4000), JSON.stringify(steps), JSON.stringify(tr), !!enabled, enabled ? (nextRun(tr, at) ? iso(nextRun(tr, at).getTime()) : null) : null, person.subject, iso(at.getTime())]);
+ return get(id);
+ }
+
+ async function remove(robotId, id) {
+ const r = running.get(robotId);
+ if (r && r.routineId === id) r.stop = true;
+ return (await db.exec('DELETE FROM robot_routines WHERE id = $1 AND robot_id = $2', [id, robotId])) > 0;
+ }
+
+ async function finish(id, result) {
+ await db.query('UPDATE robot_routines SET last_result = $2, updated_at = $3 WHERE id = $1', [id, String(result).slice(0, 200), iso(now())]);
+ }
+
+ /** Send one command as the owner; resolves true when the device ran it, else false with the reason kept. */
+ async function send(robot, step, value, ms) {
+ const principal = { kind: 'user', subject: robot.owner_subject, role: 'user', via: 'routine' };
+ const r = await hub.operatorCommand(robot.id, principal, robot.owner_subject, { id: prefixedId('rcmd', now()), kind: step.kind, value, ms });
+ if (r.result === 'ack') return { ok: true };
+ return { ok: false, why: r.reason || r.code || r.result };
+ }
+
+ /** Run a routine now (in the background); false when one is already running on the robot. */
+ async function run(id, { reason = 'run' } = {}) {
+ const routine = await get(id);
+ if (!routine) throw new BotError(404, 'bot.routine_not_found', 'no such routine');
+ if (running.has(routine.robot_id)) return false;
+ const robot = await domain.robots.get(routine.robot_id);
+ if (!robot) return false;
+ const state = { routineId: id, stop: false };
+ running.set(robot.id, state);
+ await db.query('UPDATE robot_routines SET last_run_at = $2, last_result = $3, updated_at = $2 WHERE id = $1', [id, iso(now()), 'running']);
+ (async () => {
+ const steps = typeof routine.steps === 'string' ? JSON.parse(routine.steps) : routine.steps;
+ let result = 'done';
+ try {
+ for (const step of steps) {
+ if (state.stop) { result = 'stopped: removed'; break; }
+ if (step.kind === 'wait') { await sleep(step.seconds * 1000); continue; }
+ if (!step.hold) {
+ const r = await send(robot, step, step.value, null);
+ if (!r.ok) { result = `stopped: ${r.why}`; break; }
+ continue;
+ }
+ // Held: re-sent within the deadman window until the step's time is up, then released.
+ const end = now() + step.seconds * 1000;
+ let ok = true;
+ while (now() < end && !state.stop) {
+ const r = await send(robot, step, step.value, Math.min(300, end - now() + HOLD_MS));
+ if (!r.ok) { result = `stopped: ${r.why}`; ok = false; break; }
+ await sleep(Math.min(HOLD_MS, Math.max(0, end - now())));
+ }
+ const release = step.kind === 'drive' ? {} : step.release;
+ if (release) await send(robot, step, release, null).catch(() => {});
+ if (!ok) break;
+ }
+ } catch (e) {
+ result = `stopped: ${e.message}`;
+ log.warn(`[Bot] routine ${id}: ${e.message}`);
+ } finally {
+ running.delete(robot.id);
+ await finish(id, result).catch(() => {});
+ }
+ })();
+ log.log && log.log(`[Bot] routine ${id} on ${robot.id} started (${reason})`);
+ return true;
+ }
+
+ /** The scheduler: each due, enabled scheduled routine runs (if its robot is free) and gets its next time. */
+ async function tick() {
+ const due = await db.many("SELECT * FROM robot_routines WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= $1 ORDER BY next_run_at LIMIT 50", [iso(now())]);
+ for (const r of due) {
+ const trigger = typeof r.trigger === 'string' ? JSON.parse(r.trigger) : r.trigger;
+ const next = nextRun(trigger, new Date(now()));
+ await db.query('UPDATE robot_routines SET next_run_at = $2 WHERE id = $1', [r.id, next ? iso(next.getTime()) : null]);
+ await run(r.id, { reason: 'schedule' }).catch((e) => log.warn(`[Bot] routine ${r.id}: ${e.message}`));
+ }
+ return due.length;
+ }
+
+ /** A robot came online: its "each time it comes online" routines run (the first enabled one, one at a time). */
+ async function onOnline(robotId) {
+ const rows = await db.many("SELECT id FROM robot_routines WHERE robot_id = $1 AND enabled AND trigger->>'type' = 'online' ORDER BY created_at", [robotId]);
+ for (const r of rows) if (await run(r.id, { reason: 'online' }).catch(() => false)) break;
+ }
+
+ function start() {
+ if (timer || !tickMs) return;
+ timer = setInterval(() => { tick().catch((e) => log.warn(`[Bot] routines: ${e.message}`)); }, tickMs);
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; for (const s of running.values()) s.stop = true; }
+
+ return { list, get, create, remove, run, tick, onOnline, start, stop, isRunning: (robotId) => running.has(robotId), describeTrigger };
+}
+
+module.exports = { createRoutines, parseSteps, parseTrigger, nextRun, describeTrigger, HOLD_MS };
@@ -253,6 +253,71 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -253,6 +253,71 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
+/** The step words this robot takes (server/routines.js parseSteps), for the routine form's help line. */
+function stepHelp(profile) {
+ const c = profile.commands || {};
+ const words = [];
+ if (c.drive) words.push('forward 2', 'back 1', 'left 0.5', 'right 0.5', 'stop');
+ words.push('wait 1');
+ if (c.say) words.push('say Hello');
+ if (c.display && c.display.faces) words.push(`face ${c.display.faces[0]}`);
+ if (c.display && c.display.modes.includes('text')) words.push('show Hi');
+ if (c.ptz) words.push(...Object.keys(c.ptz.axes).map((a) => `${a} 0.5 1`));
+ for (const [name, a] of Object.entries((c.actuator && c.actuator.names) || {})) {
+ words.push(a.type === 'rgb' ? `${name} #ff8800` : a.type === 'tone' ? `${name} A4 0.3` : a.type === 'bool' ? `${name} on` : `${name} 0.5`);
+ }
+ for (const name of Object.keys((c.button && c.button.names) || {})) words.push(`press ${name}`);
+ return words;
+}
+const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
+
+/** The owner's routines: what exists, Run and Remove, and the add form (plain markup, works without JavaScript). */
+function routinesCard(robot, profile, a) {
+ const help = stepHelp(profile);
+ const v = a.values || {};
+ const row = (r) => {
+ const last = r.last_result ? `${r.last_result === 'running' ? 'running now' : `last: ${r.last_result}`}${r.last_run_at ? ` (${new Date(r.last_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC)` : ''}` : 'not run yet';
+ return `<li class="person"><span class="who"><b>${esc(r.name)}</b><small>${esc(a.describe(r.trigger))}${r.enabled ? '' : ' · off'} · ${esc(last)}</small></span>`
+ + `<span class="routine-actions"><form method="post" action="/robots/${esc(robot.id)}/routines/${esc(r.id)}/run"><button type="submit">Run</button></form>`
+ + `<form method="post" action="/robots/${esc(robot.id)}/routines/${esc(r.id)}/remove"><button type="submit" class="quiet">Remove</button></form></span></li>`;
+ };
+ const t = v.trigger || 'manual';
+ return `<section class="setting-card" id="routines" aria-labelledby="routines-h"><h2 id="routines-h">Routines</h2>
+<p class="setting-note">Steps the robot runs by itself, through the same limits, cooldowns and e-stop as you. Run one now, on a schedule, or each time the robot comes online; anything the robot refuses stops it there.</p>
+${a.routineError ? `<p class="error" role="alert">${esc(a.routineError)}</p>` : ''}
+${a.routines.length ? `<ul class="people-list">${a.routines.map(row).join('\n')}</ul>` : ''}
+<details${a.routineError ? ' open' : ''}><summary>Add a routine</summary>
+<form class="routine-form" method="post" action="/robots/${esc(robot.id)}/routines">
+<label class="field"><span>Name</span><input name="name" maxlength="60" required value="${esc(v.name || '')}" placeholder="Patrol"></label>
+<label class="field"><span>Steps, one per line</span><textarea name="steps" rows="6" required spellcheck="false" placeholder="${esc(help.slice(0, 5).join('\n'))}">${esc(v.steps || '')}</textarea></label>
+<p class="setting-note">This robot takes: ${help.map((w) => `<code>${esc(w)}</code>`).join(' ')} (seconds after a motion; a line starting with # is a note).</p>
+<fieldset class="trigger"><legend>When</legend>
+<label class="check"><input type="radio" name="trigger" value="manual"${t === 'manual' ? ' checked' : ''}> When I run it</label>
+<label class="check"><input type="radio" name="trigger" value="every"${t === 'every' ? ' checked' : ''}> Every <input type="number" name="every" min="5" max="10080" step="1" value="${esc(v.every || '60')}" aria-label="Minutes"> minutes</label>
+<label class="check"><input type="radio" name="trigger" value="daily"${t === 'daily' ? ' checked' : ''}> At <input type="time" name="at" value="${esc(v.at || '09:00')}" aria-label="Time (UTC)"> UTC on</label>
+<span class="days">${DAYS.map((d, i) => `<label class="check"><input type="checkbox" name="days" value="${i}"${!v.days || v.days.includes(String(i)) ? ' checked' : ''}> ${d}</label>`).join(' ')}</span>
+<label class="check"><input type="radio" name="trigger" value="online"${t === 'online' ? ' checked' : ''}> Each time it comes online</label>
+</fieldset>
+<button type="submit" class="primary">Save routine</button>
+</form></details></section>`;
+}
+
+/** The owner's webhooks: where the robot's events go, the add form, and a new hook's secret once. */
+function webhooksCard(robot, a) {
+ const row = (h) => `<li class="person"><span class="who"><b>${esc(h.url)}</b><small>${esc((h.events || []).map((e) => e.replace(/^bot\./, '')).join(', '))}${h.last ? ` · last delivery: ${esc(h.last)}` : ''}</small></span>`
+ + `<form method="post" action="/robots/${esc(robot.id)}/webhooks/${esc(h.id)}/remove"><button type="submit" class="quiet">Remove</button></form></li>`;
+ return `<section class="setting-card" id="webhooks" aria-labelledby="webhooks-h"><h2 id="webhooks-h">Webhooks</h2>
+<p class="setting-note">Bot posts this robot's events to your https endpoint, signed like OpenVibe.Events deliveries (<code>X-OpenVibe-Signature-V2</code>, with the hook's own secret), and retries for about three hours.</p>
+${a.webhookSecret ? `<p class="secret-once" role="status">The new hook's secret, shown this once: <code>${esc(a.webhookSecret)}</code></p>` : ''}
+${a.webhookError ? `<p class="error" role="alert">${esc(a.webhookError)}</p>` : ''}
+${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>` : ''}
+<form class="webhook-form" method="post" action="/robots/${esc(robot.id)}/webhooks">
+<label class="field"><span>Endpoint (https)</span><input type="url" name="url" required maxlength="300" placeholder="https://example.com/robot-events"></label>
+<span class="days">${a.events.map((e) => `<label class="check"><input type="checkbox" name="events" value="${esc(e)}" checked> ${esc(e.replace(/^bot\./, ''))}</label>`).join(' ')}</span>
+<button type="submit">Add webhook</button>
+</form></section>`;
+}
+
/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */
/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */
function listingForm(robot) {
function listingForm(robot) {
return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>
return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>
@@ -373,7 +438,7 @@ function turnStrip(robot, role) {
@@ -373,7 +438,7 @@ function turnStrip(robot, role) {
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
return `<div class="turn" data-turn hidden><span class="turn-state" data-turn-state></span><span class="turn-meta" data-turn-meta></span><form method="post" action="/robots/${esc(robot.id)}/queue/leave" data-turn-leave><button type="submit" class="quiet">Leave the queue</button></form></div>`;
}
}
-function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false }) {
+function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150, mode = 'page', signedIn = true, streaming = null, people = null, signaling_url = null, profileForm = {}, tryIt = false, automation = null }) {
const embed = mode === 'embed';
const embed = mode === 'embed';
const latched = !!(robot.estop && robot.estop.latched);
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@ -400,7 +465,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
@@ -400,7 +465,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</div>
-${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
+${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -124,7 +124,7 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
@@ -124,7 +124,7 @@ const PANEL_CSP = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
`frame-ancestors ${frameAncestors(config)}`, "object-src 'none'", "base-uri 'self'"].join('; ');
-function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, releaseLastmod } = {}) {
+function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = null, releaseLastmod } = {}) {
const r = express.Router();
const r = express.Router();
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
@@ -427,6 +427,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -427,6 +427,61 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot), phoneUrl: phoneUrl(robot.id, pairing) }));
res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot), phoneUrl: phoneUrl(robot.id, pairing) }));
}));
}));
+ // ── Routines and webhooks (the owner's panel cards; the same rules as /api/v1) ──
+ async function ownRobot(req) {
+ const me = requireUser(req);
+ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
+ const robot = await domain.robots.get(req.params.id);
+ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
+ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner manages routines and webhooks');
+ if (!routines || !webhooks) throw new BotError(503, 'bot.unavailable', 'automation is not available');
+ return { me, robot };
+ }
+ const many = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]);
+ r.post('/robots/:id/routines', ...manage, express.urlencoded({ extended: false, limit: '16kb' }), page(async (req, res) => {
+ const { me, robot } = await ownRobot(req);
+ const b = req.body || {};
+ const kind = String(b.trigger || 'manual');
+ const trigger = kind === 'every' ? { type: 'schedule', every: b.every } : kind === 'daily' ? { type: 'schedule', at: b.at, days: many(b.days) } : { type: kind };
+ try {
+ await routines.create(robot, me, { name: b.name, source: b.steps, trigger }, await profileOf(robot));
+ } catch (e) {
+ if (e.status !== 422) throw e;
+ res.status(422);
+ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { routineError: e.detail || e.message, values: { name: b.name, steps: b.steps, trigger: kind, every: b.every, at: b.at, days: many(b.days) } } });
+ }
+ return res.redirect(303, `/panel/${robot.id}#routines`);
+ }));
+ r.post('/robots/:id/routines/:rid/run', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const { robot } = await ownRobot(req);
+ const routine = await routines.get(req.params.rid);
+ if (!routine || routine.robot_id !== robot.id) throw new BotError(404, 'bot.routine_not_found', 'no such routine');
+ await routines.run(routine.id, { reason: 'owner' });
+ return res.redirect(303, `/panel/${robot.id}#routines`);
+ }));
+ r.post('/robots/:id/routines/:rid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const { robot } = await ownRobot(req);
+ await routines.remove(robot.id, req.params.rid);
+ return res.redirect(303, `/panel/${robot.id}#routines`);
+ }));
+ r.post('/robots/:id/webhooks', ...manage, express.urlencoded({ extended: false, limit: '4kb' }), page(async (req, res) => {
+ const { me, robot } = await ownRobot(req);
+ let made;
+ try { made = await webhooks.create(robot, me, { url: req.body.url, events: many(req.body.events) }); } catch (e) {
+ if (![409, 422].includes(e.status)) throw e;
+ res.status(e.status);
+ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookError: e.detail || e.message } });
+ }
+ // The secret is shown on this page alone (never in a URL), so the answer is the panel itself.
+ res.status(201);
+ return sendPanel(res, robot, await profileOf(robot), 'owner', 'page', true, { automation: { webhookSecret: made.secret } });
+ }));
+ r.post('/robots/:id/webhooks/:hid/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
+ const { robot } = await ownRobot(req);
+ await webhooks.remove(robot.id, req.params.hid);
+ return res.redirect(303, `/panel/${robot.id}#webhooks`);
+ }));
+
r.post('/robots/:id/listing', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/robots/:id/listing', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
@@ -549,9 +604,16 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -549,9 +604,16 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
// The owner's People card: every member but the owner, with Network's name for each. A Network that
// The owner's People card: every member but the owner, with Network's name for each. A Network that
// does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.
// does not answer leaves the subject id and a note (see renderPanel) instead of failing the panel.
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
const people = role === 'owner' && mode === 'page' ? await peopleFor(robot) : null;
+ // The owner's routines and webhooks (server/routines.js, server/webhooks.js), with each hook's last delivery.
+ let automation = null;
+ if (role === 'owner' && mode === 'page' && routines && webhooks) {
+ const hooks = await webhooks.list(robot.id);
+ for (const h of hooks) { const d = (await webhooks.recent(h.id))[0]; h.last = d ? `${d.state}${d.last_status ? ` (${d.last_status})` : ''}` : null; }
+ automation = { routines: await routines.list(robot.id), hooks, events: webhooks.EVENTS, describe: (t) => routines.describeTrigger(typeof t === 'string' ? JSON.parse(t) : t), ...(profileForm.automation || {}) };
+ }
res.type('html').send(renderPanel({
res.type('html').send(renderPanel({
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
robot: presentForPage(robot), profile, role, mode, signedIn, streaming, people, signaling_url,
- profileForm,
+ profileForm, automation,
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
allowed_commands: role === 'watcher' ? [] : domain.control.allowedFor(robot, role, profile),
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+/**
+ * Robot webhooks (plan T15 "webhooks"): an owner points a robot's bot.* events at an https endpoint of theirs. Each hook
+ * has its own secret (whsec_…, shown once when it is made), and every delivery is signed with it exactly like an
+ * OpenVibe.Events delivery (openvibe-sdk/events signDeliveryHeaders: X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2), so
+ * a receiver checks it with the same code. Deliveries are written in the transaction that emits the event
+ * (server/domain emit → enqueueIn), so one exists exactly when its event does, and sent by a loop with backoff.
+ *
+ * EVENTS the bot.* events a hook may take
+ * createWebhooks({ db, post, now, log })
+ * list(robotId) / create(robot, person, { url, events }) → { hook, secret } / remove(robotId, id)
+ * enqueueIn(t, event_type, robotId, payload) inside the emitting transaction
+ * sendDue() → how many were tried; start() / stop()
+ * recent(hookId) → the last deliveries (state, attempts, status)
+ *
+ * The body is { id, type, robot_id, occurred_at, data } with the event's own payload as data (ids, kinds and results
+ * only). A 2xx answer delivers; anything else, a refused address or a network error retries after 30 s, 2, 10, 30 and
+ * 120 minutes, then the delivery fails. Nothing a receiver answers is kept but its status code.
+ */
+const crypto = require('crypto');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { BotError, prefixedId, iso } = require('./util');
+const { checkWebhookUrl, WebhookRefused } = require('./net/webhook-post');
+
+const EVENTS = ['bot.robot.online', 'bot.robot.offline', 'bot.estop.set', 'bot.estop.cleared', 'bot.command.refused'];
+const BACKOFF_S = [30, 120, 600, 1800, 7200];
+const MAX_HOOKS = 5;
+const TICK_MS = 5000;
+
+const mintSecret = () => `whsec_${crypto.randomBytes(32).toString('base64url')}`;
+
+function createWebhooks({ db, post, now = () => Date.now(), log = console, tickMs = TICK_MS }) {
+ let timer = null;
+ let busy = false;
+
+ const list = (robotId) => db.many('SELECT id, robot_id, url, events, enabled, created_at FROM robot_webhooks WHERE robot_id = $1 ORDER BY created_at', [robotId]);
+
+ async function create(robot, person, { url, events }) {
+ let u;
+ try { u = checkWebhookUrl(url); } catch (e) {
+ if (e instanceof WebhookRefused) throw new BotError(422, 'bot.webhook_refused', e.message);
+ throw e;
+ }
+ const chosen = [...new Set((Array.isArray(events) ? events : [events]).filter(Boolean).map(String))];
+ if (!chosen.length || chosen.some((e) => !EVENTS.includes(e))) throw new BotError(422, 'bot.invalid_input', `events are some of ${EVENTS.join(', ')}`);
+ const count = Number(await db.value('SELECT count(*) FROM robot_webhooks WHERE robot_id = $1', [robot.id]));
+ if (count >= MAX_HOOKS) throw new BotError(409, 'bot.too_many_webhooks', `a robot has at most ${MAX_HOOKS} webhooks`);
+ const id = prefixedId('whk', now());
+ const secret = mintSecret();
+ await db.query('INSERT INTO robot_webhooks (id, robot_id, url, events, secret, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7)',
+ [id, robot.id, u.href, chosen, secret, person.subject, iso(now())]);
+ const hook = await db.maybe('SELECT id, robot_id, url, events, enabled, created_at FROM robot_webhooks WHERE id = $1', [id]);
+ return { hook, secret };
+ }
+
+ async function remove(robotId, id) {
+ return (await db.exec('DELETE FROM robot_webhooks WHERE id = $1 AND robot_id = $2', [id, robotId])) > 0;
+ }
+
+ /** Inside the emitting transaction: one delivery per enabled hook of the robot that takes this event. */
+ async function enqueueIn(t, eventType, robotId, payload) {
+ if (!robotId || !EVENTS.includes(eventType)) return 0;
+ const hooks = await t.many('SELECT id FROM robot_webhooks WHERE robot_id = $1 AND enabled AND $2 = ANY(events)', [robotId, eventType]);
+ if (!hooks.length) return 0;
+ const at = iso(now());
+ const body = JSON.stringify({ id: prefixedId('whe', now()), type: eventType, robot_id: robotId, occurred_at: at, data: payload || {} });
+ for (const h of hooks) {
+ await t.query(`INSERT INTO robot_webhook_deliveries (id, webhook_id, event_type, body, due_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $5, $5)`, [prefixedId('whd', now()), h.id, eventType, body, at]);
+ }
+ return hooks.length;
+ }
+
+ async function sendOne(d) {
+ const hook = await db.maybe('SELECT url, secret, enabled FROM robot_webhooks WHERE id = $1', [d.webhook_id]);
+ const attempts = d.attempts + 1;
+ const at = iso(now());
+ if (!hook || !hook.enabled) {
+ await db.query("UPDATE robot_webhook_deliveries SET state = 'failed', attempts = $2, last_error = $3, updated_at = $4 WHERE id = $1", [d.id, attempts, 'the webhook is gone or off', at]);
+ return;
+ }
+ let status = null; let error = null;
+ try {
+ const r = await post(hook.url, { headers: { ...signDeliveryHeaders(d.body, hook.secret, { now: now() }), 'X-OpenVibe-Event': d.event_type, 'X-OpenVibe-Delivery': d.id }, body: d.body });
+ status = r.status;
+ } catch (e) { error = String(e.message || e).slice(0, 200); }
+ if (status >= 200 && status < 300) {
+ await db.query("UPDATE robot_webhook_deliveries SET state = 'delivered', attempts = $2, last_status = $3, last_error = NULL, updated_at = $4 WHERE id = $1", [d.id, attempts, status, at]);
+ return;
+ }
+ const wait = BACKOFF_S[attempts - 1];
+ const why = error || `answered ${status}`;
+ if (wait == null) await db.query("UPDATE robot_webhook_deliveries SET state = 'failed', attempts = $2, last_status = $3, last_error = $4, updated_at = $5 WHERE id = $1", [d.id, attempts, status, why, at]);
+ else await db.query('UPDATE robot_webhook_deliveries SET attempts = $2, last_status = $3, last_error = $4, due_at = $5, updated_at = $6 WHERE id = $1', [d.id, attempts, status, why, iso(now() + wait * 1000), at]);
+ }
+
+ async function sendDue() {
+ if (busy) return 0;
+ busy = true;
+ try {
+ const due = await db.many("SELECT * FROM robot_webhook_deliveries WHERE state = 'pending' AND due_at <= $1 ORDER BY due_at LIMIT 25", [iso(now())]);
+ for (const d of due) await sendOne(d).catch((e) => log.warn(`[Bot] webhook ${d.id}: ${e.message}`));
+ return due.length;
+ } finally { busy = false; }
+ }
+
+ const recent = (hookId) => db.many('SELECT id, event_type, state, attempts, last_status, last_error, created_at, updated_at FROM robot_webhook_deliveries WHERE webhook_id = $1 ORDER BY created_at DESC LIMIT 10', [hookId]);
+
+ function start() {
+ if (timer || !tickMs) return;
+ timer = setInterval(() => { sendDue().catch((e) => log.warn(`[Bot] webhooks: ${e.message}`)); }, tickMs);
+ if (timer.unref) timer.unref();
+ }
+ function stop() { if (timer) clearInterval(timer); timer = null; }
+
+ return { list, create, remove, enqueueIn, sendDue, recent, start, stop, EVENTS };
+}
+
+module.exports = { createWebhooks, EVENTS, BACKOFF_S };
new file mode 100644
new file mode 100644
@@ -0,0 +1,177 @@
@@ -0,0 +1,177 @@
+'use strict';
+// Routines and webhooks (plan T15 "schedules and automations … webhooks"): an owner's steps run through the same gate
+// as a person's commands (on demand, on a schedule, when the robot comes online), stop at anything the robot refuses,
+// and a robot's events reach the owner's https endpoint signed like OpenVibe.Events deliveries, with retries.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { parseSteps, parseTrigger, nextRun } = require('../server/routines');
+const { createWebhooks, BACKOFF_S } = require('../server/webhooks');
+const { verifyDeliveryV2 } = require('openvibe-sdk/events');
+const { loadProfiles } = require('../server/profiles');
+const world = require('../public/sim-world');
+
+(async () => {
+ const profiles = loadProfiles();
+
+ await check('steps parse against the robot\'s own profile, with the line that is wrong', () => {
+ const sim = profiles.get('sim.rover');
+ const { steps } = parseSteps('# patrol\nforward 2\nright 0.5 0.4\nheadlights #ff8800\nhorn C5 0.3\nwait 1\nback 1\nstop', sim);
+ assert.deepStrictEqual(steps.map((s) => s.kind), ['drive', 'drive', 'actuator', 'actuator', 'wait', 'drive', 'drive']);
+ assert.deepStrictEqual(steps[1].value, { steer: 0.4 });
+ assert.deepStrictEqual(steps[2].value, { name: 'headlights', value: { r: 255, g: 136, b: 0 } });
+ assert.deepStrictEqual(steps[3].value, { name: 'horn', value: { note: 'C5' } });
+ const cozmo = profiles.get('cozmo');
+ const c = parseSteps('say Hello there\nface happy\nhead 0.5', cozmo).steps;
+ assert.deepStrictEqual(c.map((s) => s.kind), ['say', 'display', 'actuator']);
+ for (const [bad, why] of [['say hi', /does not speak/], ['fly 3', /line 1 \("fly 3"\): "fly"/], ['forward 120', /seconds/], ['', /at least one step/], ['headlights red', /colour/]]) {
+ assert.throws(() => parseSteps(bad, sim), (e) => e.status === 422 && why.test(e.detail), bad);
+ }
+ assert.throws(() => parseSteps(Array(12).fill('forward 60').join('\n'), sim), /at most 10 minutes/);
+ });
+
+ await check('triggers: every N minutes (at least 5), daily at a UTC time on chosen days, or each time it comes online', () => {
+ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', every: 15 }), new Date('2026-10-14T10:00:00Z')).toISOString(), '2026-10-14T10:15:00.000Z');
+ const wed = new Date('2026-10-14T10:00:00Z'); // a Wednesday
+ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', at: '09:30', days: [1, 3] }), wed).toISOString(), '2026-10-19T09:30:00.000Z');
+ assert.strictEqual(nextRun(parseTrigger({ type: 'schedule', at: '23:00' }), wed).toISOString(), '2026-10-14T23:00:00.000Z');
+ assert.strictEqual(nextRun({ type: 'online' }, wed), null);
+ assert.throws(() => parseTrigger({ type: 'schedule', every: 2 }), /at least 5/);
+ assert.throws(() => parseTrigger({ type: 'schedule', at: '25:00' }), /09:30/);
+ assert.throws(() => parseTrigger({ type: 'weekly' }), /manual, schedule or online/);
+ });
+
+ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const { routines } = t.app.locals;
+ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(50); } return null; };
+ const { robot } = await t.robot(alex, { name: 'Patroller', profile_id: 'sim.rover' });
+ assert.ok(await t.app.locals.sim.attach(robot.id));
+ await poll(() => t.hub.isOnline(`dev_sim_${robot.id}`));
+ const api = (method, path, user, body) => t.call(method, `/api/v1/robots/${robot.id}${path}`, { user, body });
+ const routineOf = async (id) => (await api('GET', '/routines', alex)).json.routines.find((r) => r.id === id);
+
+ await check('the owner\'s routine runs through the gate: the simulated rover drives, lights up, and it ends done', async () => {
+ const made = await api('POST', '/routines', alex, { name: 'Patrol', steps: 'forward 0.8\nheadlights #ff0000\nwait 0.2\nstop' });
+ assert.strictEqual(made.status, 201, made.text);
+ assert.deepStrictEqual([made.json.routine.trigger, made.json.routine.steps.split('\n').length], [{ type: 'manual' }, 4]);
+ const run = await api('POST', `/routines/${made.json.routine.id}/run`, alex);
+ assert.strictEqual(run.status, 202, run.text);
+ assert.ok(await poll(async () => (await routineOf(made.json.routine.id)).last_result === 'done'), 'it finished');
+ // Telemetry is sampled twice a second: wait for the sample after the last step.
+ const tel = await poll(() => { const x = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return x && x.sim.headlights && x.sim.headlights.r === 255 && x.sim.pose.y > world.START.y + 0.1 ? x : null; });
+ assert.ok(tel, 'the rover moved forward and its headlights are red');
+ const audit = (await api('GET', '/audit', alex)).json.audit || [];
+ assert.ok(audit.some((a) => a.kind === 'drive' && a.operator_subject === alex.subject && a.result === 'ack'), 'each step is on the record as the owner\'s');
+ });
+
+ await check('one routine at a time; removing a running routine stops it; the e-stop stops one with its reason', async () => {
+ const long = (await api('POST', '/routines', alex, { name: 'Long', steps: 'forward 5' })).json.routine;
+ assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 202);
+ assert.strictEqual((await api('POST', `/routines/${long.id}/run`, alex)).status, 409, 'already running');
+ assert.strictEqual((await api('DELETE', `/routines/${long.id}`, alex)).status, 204);
+ assert.ok(await poll(() => !routines.isRunning(robot.id)), 'it stopped');
+ const again = (await api('POST', '/routines', alex, { name: 'Again', steps: 'forward 5' })).json.routine;
+ await api('POST', `/routines/${again.id}/run`, alex);
+ await t.wait(400);
+ assert.strictEqual((await api('POST', '/estop', alex)).status, 200);
+ const stopped = await poll(async () => { const r = await routineOf(again.id); return r.last_result.startsWith('stopped') ? r : null; });
+ assert.ok(stopped, 'the e-stop stopped it');
+ assert.match(stopped.last_result, /stopped: .*(e-stop|estop|latched)/i);
+ assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);
+ });
+
+ await check('a schedule runs when due and moves on; an "each time it comes online" routine runs on the robot\'s return', async () => {
+ const s = (await api('POST', '/routines', alex, { name: 'Every 5', steps: 'headlights #00ff00', trigger: { type: 'schedule', every: 5 } })).json.routine;
+ assert.ok(Date.parse(s.next_run_at) > Date.now() + 4 * 60000, 'first run in 5 minutes');
+ await t.db.query("UPDATE robot_routines SET next_run_at = now() - interval '1 second' WHERE id = $1", [s.id]);
+ assert.strictEqual(await routines.tick(), 1);
+ assert.ok(await poll(async () => (await routineOf(s.id)).last_result === 'done'));
+ assert.ok(Date.parse((await routineOf(s.id)).next_run_at) > Date.now() + 4 * 60000, 'the next run is 5 minutes on');
+ assert.strictEqual(await routines.tick(), 0, 'nothing else is due');
+ const on = (await api('POST', '/routines', alex, { name: 'Hello', steps: 'headlights #0000ff', trigger: { type: 'online' } })).json.routine;
+ await routines.onOnline(robot.id);
+ assert.ok(await poll(async () => (await routineOf(on.id)).last_result === 'done'));
+ assert.ok(await poll(() => { const x = t.hub.deviceState(`dev_sim_${robot.id}`).telemetry; return x && x.sim.headlights && x.sim.headlights.b === 255; }), 'the online routine turned the lights blue');
+ });
+
+ await check('someone else\'s robot: no routines, no webhooks, read or write', async () => {
+ assert.strictEqual((await api('GET', '/routines', bob)).status, 403);
+ assert.strictEqual((await api('POST', '/routines', bob, { name: 'x', steps: 'forward 1' })).status, 403);
+ assert.strictEqual((await api('POST', '/webhooks', bob, { url: 'https://hooks.example.com/x', events: ['bot.estop.set'] })).status, 403);
+ const bad = await api('POST', '/routines', alex, { name: 'Bad', steps: 'say hello' });
+ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_routine']);
+ });
+
+ let secret; let hookId;
+ await check('webhooks: https and public only, at most five, the secret once', async () => {
+ for (const url of ['http://hooks.example.com/x', 'https://localhost/x', 'https://10.1.2.3/x', 'https://user:[email protected]/x']) {
+ assert.strictEqual((await api('POST', '/webhooks', alex, { url, events: ['bot.estop.set'] })).status, 422, url);
+ }
+ assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/x', events: ['bot.robot.exploded'] })).status, 422);
+ const made = await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/robot', events: ['bot.estop.set', 'bot.estop.cleared'] });
+ assert.strictEqual(made.status, 201, made.text);
+ secret = made.json.secret;
+ hookId = made.json.webhook.id;
+ assert.match(secret, /^whsec_[A-Za-z0-9_-]{43}$/);
+ const list = await api('GET', '/webhooks', alex);
+ assert.ok(!list.text.includes('whsec_'), 'a read never carries the secret');
+ for (let i = 0; i < 4; i++) assert.strictEqual((await api('POST', '/webhooks', alex, { url: `https://hooks.example.com/${i}`, events: ['bot.robot.online'] })).status, 201);
+ assert.strictEqual((await api('POST', '/webhooks', alex, { url: 'https://hooks.example.com/6', events: ['bot.robot.online'] })).status, 409);
+ });
+
+ await check('an event writes a delivery with it; the sender signs it like Events and retries a failing receiver', async () => {
+ await t.db.query("DELETE FROM robot_webhook_deliveries");
+ assert.strictEqual((await api('POST', '/estop', alex)).status, 200);
+ const rows = await t.db.many("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1", [hookId]);
+ assert.strictEqual(rows.length, 1, 'one delivery, written with the event');
+ const got = [];
+ let answer = 500;
+ const sender = createWebhooks({ db: t.db, post: async (url, { headers, body }) => { got.push({ url, headers, body }); return { status: answer }; }, tickMs: 0 });
+ assert.strictEqual(await sender.sendDue(), 1);
+ const first = got[0];
+ assert.strictEqual(first.url, 'https://hooks.example.com/robot');
+ const body = JSON.parse(first.body);
+ assert.deepStrictEqual([body.type, body.robot_id], ['bot.estop.set', robot.id]);
+ assert.ok(verifyDeliveryV2(first.body, first.headers, secret), 'the signature verifies with the hook\'s secret');
+ assert.ok(!verifyDeliveryV2(first.body, first.headers, 'whsec_wrong'), 'and with nothing else');
+ assert.strictEqual(first.headers['X-OpenVibe-Event'], 'bot.estop.set');
+ let row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
+ assert.deepStrictEqual([row.state, row.attempts, row.last_status], ['pending', 1, 500]);
+ assert.ok(Date.parse(row.due_at) > Date.now() + (BACKOFF_S[0] - 2) * 1000, 'retried after 30 s');
+ for (let i = 0; i < BACKOFF_S.length; i++) {
+ await t.db.query("UPDATE robot_webhook_deliveries SET due_at = now() - interval '1 minute' WHERE id = $1", [row.id]); // now() keeps microseconds the sender's clock does not
+ await sender.sendDue();
+ }
+ row = await t.db.maybe('SELECT * FROM robot_webhook_deliveries WHERE id = $1', [rows[0].id]);
+ assert.deepStrictEqual([row.state, row.attempts], ['failed', BACKOFF_S.length + 1], 'it gives up');
+ answer = 204;
+ assert.strictEqual((await api('POST', '/estop/clear', alex)).status, 200);
+ await sender.sendDue();
+ const cleared = await t.db.maybe("SELECT * FROM robot_webhook_deliveries WHERE webhook_id = $1 AND event_type = 'bot.estop.cleared'", [hookId]);
+ assert.deepStrictEqual([cleared.state, cleared.last_status], ['delivered', 204]);
+ });
+
+ await check('the owner\'s panel has both cards; a bad routine answers 422 with the form; a new hook\'s secret shows once', async () => {
+ const cookie = { Cookie: `ov_token=${t.network.signUser(alex)}` };
+ const html = await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie })).text();
+ assert.ok(html.includes('id="routines"') && html.includes('Patrol') && html.includes('id="webhooks"') && html.includes('hooks.example.com/robot'));
+ assert.ok(html.includes('<code>headlights #ff8800</code>'), 'the help line lists this robot\'s own steps');
+ assert.ok(!html.includes(secret), 'the secret is not on the panel again');
+ const form = (path, body) => fetch(`${t.base}${path}`, { method: 'POST', redirect: 'manual', body: new URLSearchParams(body).toString(), headers: { ...cookie, 'Content-Type': 'application/x-www-form-urlencoded' } });
+ const bad = await form(`/robots/${robot.id}/routines`, { name: 'Nope', steps: 'jump 2', trigger: 'manual' });
+ assert.strictEqual(bad.status, 422);
+ assert.match(await bad.text(), /"jump" is not a step this robot knows/);
+ const ok = await form(`/robots/${robot.id}/routines`, { name: 'Weekdays', steps: 'forward 1', trigger: 'daily', at: '08:15', days: '1' });
+ assert.strictEqual(ok.status, 303);
+ await t.db.query('DELETE FROM robot_webhooks WHERE robot_id = $1', [robot.id]);
+ const hook = await form(`/robots/${robot.id}/webhooks`, { url: 'https://hooks.example.com/panel', events: 'bot.robot.online' });
+ assert.strictEqual(hook.status, 201);
+ assert.match(await hook.text(), /shown this once: <code>whsec_/);
+ });
+
+ t.app.locals.sim.stopAll();
+ routines.stop();
+ await t.close();
+ done();
+})();