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)

OP OpenVibers committed 14 Oct 2026 · 96c58b7235 · 16 files, +931 −12

* 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

Plan T15, "More than streaming": "schedules and automations … webhooks". A robot only moved while someone held a control, and nothing outside Bot heard when it went online, stopped or refused a command.

**Routines** (`server/routines.js`)
- The owner writes steps one per line, checked against the robot's own profile: `forward 2`, `back 1 0.4`, `left 0.5`, `right 0.5`, `stop`, `wait 1`, `say Hello`, `face happy`, `show Hi`, `press horn`, `headlights #ff8800`, `horn A4 0.3`, `pan 0.5 1`. A wrong line is refused, naming the line.
- Limits: at most 60 steps, 60 s per step, 10 minutes in all.
- Triggers:
  - when the owner runs it;
  - every N minutes (at least 5);
  - daily at a UTC time on chosen weekdays;
  - each time the robot comes online (`bot.robot.online`).
- **Every step passes the same gate as a person's command**, as the owner (`hub.operatorCommand`): limits, cooldowns, the e-stop and the audit.
- A held step (a motion or a tone) is re-sent every 150 ms, inside the device's deadman window, and released at its end.
- Anything refused stops the routine there, and the reason is kept in `last_result` (for example "stopped: the e-stop is latched").
- One routine runs per robot at a time. Removing a running routine stops it.
- The scheduler runs every 30 s (`BOT_ROUTINE_TICK_MS`).

**Webhooks** (`server/webhooks.js`, with `server/net/webhook-post.js` ported from OpenVibe.Actor)
- Up to five https endpoints per robot, for `bot.robot.online`/`offline`, `bot.estop.set`/`cleared` and `bot.command.refused`.
- Each has its own `whsec_…` secret, shown once at creation and never in a read.
- A delivery row is written in the transaction that emits the event (`domain.emit`), so it exists exactly when the event does.
- Each delivery is signed like an OpenVibe.Events delivery (`X-OpenVibe-Timestamp`, `X-OpenVibe-Signature-V2`), so a receiver checks it with `verifyDeliveryV2`.
- Body: `{ id, type, robot_id, occurred_at, data }`.
- Posted only to public addresses (checked again at connect time through `openvibe-shared/egress`), never following a redirect.
- Retried after 30 s, 2, 10, 30 and 120 minutes, then marked failed. Only the status code is kept.

**Where**
- The owner's panel has two new cards:
  - **Routines**: list, Run, Remove, and an add form whose help line names this robot's own steps;
  - **Webhooks**: list with the last delivery, Remove, add; the new secret is shown once on the answer page, never in a URL.
- API, owner or a service with `bot.robot.manage`:
  - `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run` (202, or 409 while another runs), `DELETE …/routines/:rid`;
  - `GET|POST /api/v1/robots/:id/webhooks` (the POST answers the secret), `DELETE …/webhooks/:hid`.
- Data:
  - migration `0011_routines_webhooks` (expand). Rows go with their robot (`ON DELETE CASCADE`), so account deletion removes them with the person's robots.
  - The export carries routines and webhook URLs, never a secret.

**Tests** (new `test/automation.test.js`)
- Steps parse per profile (sim rover, Cozmo), with the errors named. Triggers and next run times.
- A routine drives the simulated rover and sets its lights, on record as the owner's.
- One at a time; removing stops it; the e-stop stops it with its reason.
- A due schedule runs and moves on. An online routine runs.
- Someone else's robot is refused.
- Webhooks: https and public only, a cap of five, the secret once. An event writes its delivery; the signature verifies with the secret and nothing else; the backoff ends in failed; a 204 delivers.
- The panel cards; a bad routine answers 422 with the form; a new hook's secret shows once.
- The full suite passes: 37/37 test files.

* Bot: the webhook retry test makes a delivery due a minute back (now() keeps microseconds the sender's clock does not)

Changes

ddiff --git a/README.md b/README.md
iindex e45bef0..f0ddec2 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 7b76dfb..77d790b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0011_routines_webhooks.sql b/migrations/0011_routines_webhooks.sql
nnew file mode 100644
iindex 0000000..d619fdc
---- /dev/null
++++ b/migrations/0011_routines_webhooks.sql
@@@ -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);
ddiff --git a/public/panel.css b/public/panel.css
iindex 005e14c..f81894d 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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; }
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 152eda0..9d517b2 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -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 robots
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). */
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 9847ddc..0b653c7 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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, subm
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 ─────────────────────────────────────────────────────────────────────────────────
r.get('/robots/:id/operators', wrap(async (req, res) => {
await member(req, req.params.id, ['owner', 'operator']);
ddiff --git a/server/app.js b/server/app.js
iindex 02717e2..70f6361 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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;
}
ddiff --git a/server/config.js b/server/config.js
iindex 87f5248..d256bec 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 0989511..c3fbe5e 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 ──────────────────────────────────────────────────────────────────────
ddiff --git a/server/index.js b/server/index.js
iindex 09f0bdf..902180c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(),
ddiff --git a/server/net/webhook-post.js b/server/net/webhook-post.js
nnew file mode 100644
iindex 0000000..562ae02
---- /dev/null
++++ b/server/net/webhook-post.js
@@@ -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 };
ddiff --git a/server/routines.js b/server/routines.js
nnew file mode 100644
iindex 0000000..4b47259
---- /dev/null
++++ b/server/routines.js
@@@ -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 };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 19a2fb3..15d7919 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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',
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex b1f3fb3..f6d3e51 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
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) => {
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))),
ddiff --git a/server/webhooks.js b/server/webhooks.js
nnew file mode 100644
iindex 0000000..fa5dd28
---- /dev/null
++++ b/server/webhooks.js
@@@ -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 };
ddiff --git a/test/automation.test.js b/test/automation.test.js
nnew file mode 100644
iindex 0000000..5acd371
---- /dev/null
++++ b/test/automation.test.js
@@@ -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(), /&quot;jump&quot; 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();
++})();