From 94d13b82d7974b778cfae5e791889ebcd9f7b24e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 27 Oct 2026 10:29:52 -0400 Subject: [PATCH] Automations: when this happens, do that (daily time, task finished, new mail) Co-Authored-By: Claude Sonnet 5.5 --- .gitea/workflows/build.yml | 1 + automations.js | 76 ++++++++++++++++++++++++++++++++++++++ public/automations-view.js | 36 ++++++++++++++++++ public/index.html | 1 + public/settings.js | 3 +- public/sw.js | 2 +- push.js | 2 + server.js | 2 + tasks.js | 10 ++++- tools/automations-test.js | 36 ++++++++++++++++++ 10 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 automations.js create mode 100644 public/automations-view.js create mode 100644 tools/automations-test.js diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b26d192..e6ae74b 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -33,6 +33,7 @@ jobs: - run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export - run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books - run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote + - run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates - run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server diff --git a/automations.js b/automations.js new file mode 100644 index 0000000..0485f77 --- /dev/null +++ b/automations.js @@ -0,0 +1,76 @@ +// Automations: "when this happens, do that" rules, one set per person. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to the Shopping +// list, or notify me. Rules only ever act on the person's own things, a rule runs at most 20 times a day, and what a rule does never starts another rule. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); + +const WHEN = ['time', 'task.done', 'mail.new'], THEN = ['task', 'shopping', 'notify'], MAX_RULES = 30, DAILY_CAP = 20; +const file = () => path.join(users.dir(), 'automations.json'); +const load = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } }; +const save = l => atomicWrite(file(), JSON.stringify(l, null, 1), { mode: 0o600 }); +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, ' ').trim().slice(0, n); +const today = () => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }; + +/** A rule from what the browser sent, or an error that says what is missing. */ +function clean(b, old) { + const wt = String(b.when && b.when.type), tt = String(b.then && b.then.type); + if (!WHEN.includes(wt)) throw fail(400, 'Choose when it should happen'); if (!THEN.includes(tt)) throw fail(400, 'Choose what it should do'); + const when = { type: wt }; if (wt === 'time') { if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(String(b.when.at || ''))) throw fail(400, 'Pick a time of day'); when.at = b.when.at; } else when.match = str(b.when.match, 80); + if (wt === 'mail.new' && !when.match) throw fail(400, 'Say what the sender or subject should contain (so it is not every email)'); + const text = str(b.then.text, 200); if (!text) throw fail(400, tt === 'notify' ? 'Write the message' : 'Write the task'); + return { id: old ? old.id : crypto.randomBytes(5).toString('hex'), name: str(b.name, 60) || (wt === 'time' ? 'Every day at ' + when.at : wt === 'task.done' ? 'When a task is finished' : 'When mail arrives'), on: b.on !== false, when, then: { type: tt, text }, runs: old ? old.runs || 0 : 0, last: old ? old.last || 0 : 0, day: old ? old.day : '', today: old ? old.today || 0 : 0 }; +} +const fill = (text, ctx) => text.replace(/\{(title|from|subject)\}/g, (_, k) => str(ctx[k] || ctx.title || '', 120)); +/** Do a rule's action for the person it belongs to (run inside their context). */ +async function act(rule, ctx) { + const me = users.current(), text = fill(rule.then.text, ctx); + if (rule.then.type === 'task' || rule.then.type === 'shopping') { require('./tasks').addLocalTask(rule.then.type === 'shopping' ? 'Shopping' : '', text); return; } + require('./inbox').add(me.id, { kind: 'info', title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); + try { await require('./push').notify(me.id, { title: rule.name, body: text, url: '#/home', tag: 'auto:' + rule.id }); } catch { /* the Notifications list has it */ } +} +async function run(rule, ctx) { + const l = load(), r = l.find(x => x.id === rule.id); if (!r) return false; + if (r.day !== today()) { r.day = today(); r.today = 0; } if (r.today >= DAILY_CAP) return false; + r.today++; r.runs = (r.runs || 0) + 1; r.last = Date.now(); save(l); + try { await act(r, ctx); return true; } catch (e) { console.warn('[automations]', r.name, e.message); return false; } +} +/** Something happened for a person: run their matching rules. type: 'task.done' ({ title }) or 'mail.new' ({ from, subject }). */ +async function fire(uid, type, ctx) { + const u = users.get(uid); if (!u || u.disabled) return; + await users.runAs(u, async () => { + for (const r of load()) { + if (!r.on || r.when.type !== type) continue; const m = r.when.match.toLowerCase(), hay = (type === 'mail.new' ? (ctx.from || '') + ' ' + (ctx.subject || '') : ctx.title || '').toLowerCase(); + if (m && !hay.includes(m)) continue; await run(r, ctx); + } + }); +} +/** Daily rules whose time has come, for one person; `lt` is their own { day, hm }. Run once a minute. */ +async function tickUser(u, lt) { + await users.runAs(u, async () => { + for (const r of load()) if (r.on && r.when.type === 'time' && r.when.at <= lt.hm && r.doneDay !== lt.day) { + const l = load(), x = l.find(y => y.id === r.id); if (!x) continue; x.doneDay = lt.day; save(l); // (marked first, so a slow action is never run twice) + if (lt.hm >= r.when.at && minutes(lt.hm) - minutes(r.when.at) <= 180) await run(x, { title: x.name }); // (not for a time that passed hours ago, such as a rule made in the evening) + } + }); +} +const minutes = hm => +hm.slice(0, 2) * 60 + +hm.slice(3); + +async function handle(req, res) { + const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/auto'.length) || '/'; + try { + if (!me) throw fail(401, 'Sign in'); + if (p === '/' && req.method === 'GET') return json(res, 200, { rules: load().map(({ day, doneDay, ...r }) => r) }); + if (req.method !== 'POST') throw fail(404, 'Not found'); + const b = JSON.parse(await readBody(req, 2e4) || '{}'), l = load(); + if (p === '/') { const i = b.id ? l.findIndex(x => x.id === b.id) : -1; if (b.id && i < 0) throw fail(404, 'That rule is gone'); if (i < 0 && l.length >= MAX_RULES) throw fail(400, 'That is the most rules (' + MAX_RULES + ')'); const r = clean(b, i >= 0 ? l[i] : null); if (i >= 0) l[i] = r; else l.push(r); save(l); return json(res, 200, { rules: l }); } + const i = l.findIndex(x => x.id === b.id); if (i < 0) throw fail(404, 'That rule is gone'); + if (p === '/toggle') { l[i].on = !!b.on; save(l); return json(res, 200, { rules: l }); } + if (p === '/delete') { l.splice(i, 1); save(l); return json(res, 200, { rules: l }); } + if (p === '/test') { const ok = await run(l[i], { title: 'a test', from: 'Test', subject: 'a test' }); return json(res, 200, { ok }); } + throw fail(404, 'Not found'); + } catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); } +} +module.exports = { handle, fire, tickUser }; diff --git a/public/automations-view.js b/public/automations-view.js new file mode 100644 index 0000000..28f2cc6 --- /dev/null +++ b/public/automations-view.js @@ -0,0 +1,36 @@ +'use strict'; +// ---- Automations (Settings): "when this happens, do that" rules for you. When: every day at a time, a task is finished, or new mail arrives. Then: add a task, add to +// the Shopping list, or send yourself a notification. {title}, {from} and {subject} in the text are filled in from what happened. +const auApi = (p, o) => jfetch('/api/auto' + p, o), auOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) }); +const AU_WHEN = [['time', 'Every day at a time'], ['task.done', 'A task is finished'], ['mail.new', 'New mail arrives']], AU_THEN = [['task', 'Add a task'], ['shopping', 'Add to the Shopping list'], ['notify', 'Notify me']]; +const auSummary = r => `${r.when.type === 'time' ? 'Every day at ' + r.when.at : r.when.type === 'task.done' ? 'When a task' + (r.when.match ? ' with “' + r.when.match + '”' : '') + ' is finished' : 'When mail from or about “' + r.when.match + '” arrives'} → ${r.then.type === 'task' ? 'add the task' : r.then.type === 'shopping' ? 'add to Shopping' : 'notify me'} “${r.then.text}”`; +function autoSettings(el) { + if (!el) return; + const draw = rules => { + el.innerHTML = `

Automations

Little rules that run for you. Each can run up to 20 times a day.

+
${rules.map(r => `
+
`).join('') || '
No automations yet.
'}
+
`; + el.querySelector('#auadd').onclick = () => edit(null); + el.querySelectorAll('[data-on]').forEach(c => c.onchange = async () => { try { draw((await auApi('/toggle', auOpt({ id: c.dataset.on, on: c.checked }))).rules); } catch (e) { toast(e.message); c.checked = !c.checked; } }); + el.querySelectorAll('[data-e]').forEach(b => b.onclick = () => edit(rules.find(r => r.id === b.dataset.e))); + }; + const edit = r => { + const w = r ? r.when : { type: 'time', at: '08:00', match: '' }, t = r ? r.then : { type: 'task', text: '' }; + const sh = openSheet(r ? 'Edit automation' : 'New automation', '', `
+ +
+

You can use {title} (the task or message) and {from}.

+
${r ? '' : ''}
`); + const label = () => { sh.querySelector('#auxl').textContent = sh.querySelector('#aut').value === 'notify' ? 'Message' : 'Task'; }; + const detail = () => { const ty = sh.querySelector('#auw').value; sh.querySelector('#auwd').innerHTML = ty === 'time' ? `` : ``; label(); }; + detail(); label(); sh.querySelector('#auw').onchange = detail; sh.querySelector('#aut').onchange = label; + const body = () => { const ty = sh.querySelector('#auw').value; return { id: r && r.id, name: sh.querySelector('#aun').value, on: r ? r.on : true, when: { type: ty, at: ty === 'time' ? sh.querySelector('#aua').value : undefined, match: ty === 'time' ? '' : sh.querySelector('#aum').value }, then: { type: sh.querySelector('#aut').value, text: sh.querySelector('#aux').value } }; }; + sh.querySelector('#auf').onsubmit = async e => { e.preventDefault(); try { const l = (await auApi('/', auOpt(body()))).rules; closeSheet(); draw(l); toast('Saved'); } catch (er) { sh.querySelector('#aue').textContent = er.message; } }; + if (r) { + sh.querySelector('#autest').onclick = async () => { try { const o = await auApi('/test', auOpt({ id: r.id })); toast(o.ok ? 'Ran it once' : 'Did not run (the daily limit?)'); } catch (e) { toast(e.message); } }; + sh.querySelector('#aude').onclick = () => confirmSheet('Delete this automation?', r.name, 'Delete', async () => { try { draw((await auApi('/delete', auOpt({ id: r.id }))).rules); } catch (e) { toast(e.message); } }); + } + }; + auApi('/').then(r => draw(r.rules)).catch(() => {}); +} diff --git a/public/index.html b/public/index.html index 3a498b9..78d4a09 100644 --- a/public/index.html +++ b/public/index.html @@ -107,6 +107,7 @@ + diff --git a/public/settings.js b/public/settings.js index 349f5bb..6c1bc28 100644 --- a/public/settings.js +++ b/public/settings.js @@ -86,7 +86,7 @@ function viewSettings(args) {

Signal alerts

How this computer gets your attention when a Signal message arrives (not while you are looking at that chat). Saved on this device only.

-
${remindersHtml()}

Your data

A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.

Download my data

Install

${installHtml()} +
${remindersHtml()}

Your data

A copy of your own tasks, contacts, notes, Bible notes and settings. Sign-in tokens are left out.

Download my data

Install

${installHtml()}

Connections

Seafile${esc(cfg.seafile)} · ${signed ? 'Checking…' : 'signed out'}
PlexChecking…
@@ -202,6 +202,7 @@ function viewSettings(args) { }).catch(() => { const el = $('#audit'); if (el) el.innerHTML = '
Could not load
'; }); if (typeof famSettings === 'function') famSettings($('#famset')); if (typeof themeSettings === 'function') themeSettings($('#themeset')); + if (typeof autoSettings === 'function') autoSettings($('#autoset')); const bkf = $('#bkfile'); if (bkf) bkf.onchange = () => { const f = bkf.files[0]; bkf.value = ''; if (!f) return; confirmSheet('Restore this backup?', `${f.name} replaces the accounts and data on this hub. The current data is kept aside, and the hub restarts.`, 'Restore', async () => { const m = $('#bkmsg'); m.textContent = 'Uploading and restoring…'; try { const r = await fetch('/api/backup/restore', { method: 'POST', body: f }), j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || 'Could not restore'); m.textContent = 'Restored. The hub is restarting; this page reloads in a moment.'; setTimeout(() => location.reload(), 6000); } catch (e) { m.textContent = e.message; toast(e.message); } }); }; diff --git a/public/sw.js b/public/sw.js index f87b5a1..8b431f0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v113'; +const CACHE = 'hub-v115'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/push.js b/push.js index d81f73e..d6af6d8 100644 --- a/push.js +++ b/push.js @@ -66,6 +66,7 @@ async function tick(now = new Date(), force = false) { if (u.disabled) continue; const dir = users.dirOf(u.id), st = load(dir); await goalReminder(u, dir, st, now).catch(e => console.warn('[push] goal reminder failed:', e.message)); + await require('./automations').tickUser(u, localNow(st.subs.length ? st.tz : Intl.DateTimeFormat().resolvedOptions().timeZone, now)).catch(e => console.warn('[push] automations failed:', e.message)); // (daily rules) if (!st.subs.length) continue; await users.runAs(u, async () => { const lt = localNow(st.tz, now); let dirty = false; @@ -118,6 +119,7 @@ async function tick(now = new Date(), force = false) { const fresh = now2.filter(m => !seen.includes(m.id)); if (fresh.length) { st.mailSeen = seen.concat(fresh.map(m => m.id)).slice(-200); dirty = true; + for (const m of fresh.slice(0, 10)) require('./automations').fire(u.id, 'mail.new', { from: m.from || '', subject: m.subject || '' }).catch(() => {}); await send(dir, st, u.id, { title: fresh.length === 1 ? (fresh[0].from || 'New mail') : fresh.length + ' new messages', body: fresh.length === 1 ? fresh[0].subject : fresh.slice(0, 3).map(m => m.from || m.subject).join(', '), url: '#/mail', tag: 'mail', ref: { mail: fresh.map(m => ({ a: m.acct, id: m.mid })).filter(r => r.a && r.id) } }); } } diff --git a/server.js b/server.js index 6cf1fd7..b3964cb 100644 --- a/server.js +++ b/server.js @@ -48,6 +48,7 @@ const contacts = require('./contacts'); // each person's address book (mail su const activity = require('./activity'), alertParents = activity.alertParents; const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through +const automations = require('./automations'); // when this happens, do that (per person) const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list const chores = require('./chores'); // chores for the family, with points const notes = require('./notes'); // shared and private notes and checklists @@ -709,6 +710,7 @@ async function handleRequest(req, res) { if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user); if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents }); if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user); + if (pathname === '/api/auto' || pathname.startsWith('/api/auto/')) return automations.handle(req, res); if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res); if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res); if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res); diff --git a/tasks.js b/tasks.js index 569afc0..eb3ee0f 100644 --- a/tasks.js +++ b/tasks.js @@ -146,7 +146,8 @@ async function handle(req, res) { const b = JSON.parse(await readBody(req)); if (isLocal(b.list)) { const d = load(), t = d.tasks.find(x => x.id === b.id); if (!t) throw new Error('Task not found'); - const rolled = finishLocal(t, !!b.done); save(d); return json(res, 200, { ok: true, rolled }); + const was = !!t.done, rolled = finishLocal(t, !!b.done); save(d); if (b.done && !was) require('./automations').fire(users.current().id, 'task.done', { title: t.title }).catch(() => {}); + return json(res, 200, { ok: true, rolled }); } const cur = await caldav.getResource(b.list, b.href); const t = parseTodos(cur.ics)[0]; if (!t) throw new Error('Could not read that task'); @@ -190,4 +191,9 @@ async function allTasks() { /** Open tasks on the shared Family list, soonest first, for the family board. */ const familyOpen = () => { const t = readJ(famFile(), { tasks: [] }).tasks || []; return t.filter(x => !x.done && !x.parent).sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.title.localeCompare(b.title)).slice(0, 40).map(x => ({ id: x.id, title: x.title, due: x.due || '', by: x.by || '' })); }; -module.exports = { familyOpen, handle, parseTodos, allTasks, nextDue, stepDate }; +/** Add a task to one of the current person's own lists by name (the first list when none is given or found). Used by automations. */ +function addLocalTask(listName, title) { + const d = load(), n = String(listName || '').toLowerCase(), l = (n && d.lists.find(x => x.id !== FAMILY && x.name.toLowerCase() === n)) || d.lists[0]; + d.tasks.push({ id: crypto.randomUUID(), list: l.id, title: String(title).slice(0, 200), notes: 'From an automation', due: '', done: false, completedAt: null, repeat: 'none', created: Date.now(), updated: Date.now(), by: users.current().name, byId: users.current().id }); save(d); +} +module.exports = { addLocalTask, familyOpen, handle, parseTodos, allTasks, nextDue, stepDate }; diff --git a/tools/automations-test.js b/tools/automations-test.js new file mode 100644 index 0000000..05f6696 --- /dev/null +++ b/tools/automations-test.js @@ -0,0 +1,36 @@ +// Automations: rules are checked when saved, run when their trigger happens (and only then), keep to a daily cap, and belong to one person. +const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http'); +process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-auto-')); +const users = require('../users'), auto = require('../automations'); +const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['tasks'], handle: 'ZP' }); +const tasksOf = u => { try { return JSON.parse(fs.readFileSync(path.join(users.dirOf(u.id), 'tasks.json'), 'utf8')).tasks; } catch { return []; } }; +const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => auto.handle(req, res))).listen(0, async () => { + const base = 'http://127.0.0.1:' + hub.address().port + '/api/auto', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = who => fetch(base + '/', { headers: who ? { 'x-user': who } : {} }); + try { + // validation + assert.equal((await post('/', { when: { type: 'nope' }, then: { type: 'task', text: 'x' } })).status, 400); assert.equal((await post('/', { when: { type: 'time', at: '25:00' }, then: { type: 'task', text: 'x' } })).status, 400, 'a real time of day'); + assert.equal((await post('/', { when: { type: 'mail.new', match: '' }, then: { type: 'notify', text: 'x' } })).status, 400, 'mail rules need something to match, so it is not every email'); + assert.equal((await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: ' ' } })).status, 400, 'and something to do'); + // a daily rule runs once a day, at its time + let r = await j(await post('/', { when: { type: 'time', at: '08:00' }, then: { type: 'task', text: 'Water the plants' } })); assert.equal(r.rules.length, 1); const daily = r.rules[0]; + await auto.tickUser(ann, { day: '2026-11-02', hm: '07:59' }); assert.equal(tasksOf(ann).length, 0, 'not before its time'); + await auto.tickUser(ann, { day: '2026-11-02', hm: '08:00' }); assert.deepEqual(tasksOf(ann).map(t => t.title), ['Water the plants']); + await auto.tickUser(ann, { day: '2026-11-02', hm: '08:01' }); assert.equal(tasksOf(ann).length, 1, 'once a day'); await auto.tickUser(ann, { day: '2026-11-03', hm: '08:00' }); assert.equal(tasksOf(ann).length, 2, 'and again tomorrow'); + await auto.tickUser(ann, { day: '2026-11-04', hm: '15:00' }); assert.equal(tasksOf(ann).length, 2, 'a time that passed hours ago is not caught up'); + // a finished task starts matching rules, with the title filled in; the shopping action goes to the Shopping list + await post('/', { when: { type: 'task.done', match: 'milk' }, then: { type: 'task', text: 'Buy more after {title}' } }); await post('/', { when: { type: 'task.done', match: '' }, then: { type: 'notify', text: 'Nice, {title} is done' }, name: 'Cheer' }); + await auto.fire(ann.id, 'task.done', { title: 'Buy bread' }); assert.ok(!tasksOf(ann).some(t => /Buy more/.test(t.title)), 'a rule that does not match does nothing'); + await auto.fire(ann.id, 'task.done', { title: 'Buy milk' }); assert.ok(tasksOf(ann).some(t => t.title === 'Buy more after Buy milk')); + const inbox = require('../inbox').load(ann.id).filter(x => x.title === 'Cheer'); assert.ok(inbox.length >= 1 && /Nice, Buy (bread|milk) is done/.test(inbox[0].body), 'notify lands in the Notifications list'); + // mail + await post('/', { when: { type: 'mail.new', match: 'invoice' }, then: { type: 'shopping', text: 'Pay {subject}' } }); await auto.fire(ann.id, 'mail.new', { from: 'Bob', subject: 'Lunch?' }); assert.ok(!tasksOf(ann).some(t => /^Pay /.test(t.title))); + await auto.fire(ann.id, 'mail.new', { from: 'Acme', subject: 'Your invoice 42' }); assert.ok(tasksOf(ann).some(t => t.title === 'Pay Your invoice 42')); + // off, private, capped + r = await j(await get()); assert.equal(r.rules.length, 4); assert.equal((await j(await get('bo'))).rules.length, 0, 'rules are personal'); assert.equal((await post('/delete', { id: daily.id }, 'bo')).status, 404, 'and cannot be touched by someone else'); + const cheer = r.rules.find(x => x.name === 'Cheer'); await post('/toggle', { id: cheer.id, on: false }); const n0 = tasksOf(ann).length; await auto.fire(ann.id, 'task.done', { title: 'x' }); assert.equal(tasksOf(ann).length, n0, 'a rule that is off does not run'); + await post('/', { when: { type: 'task.done', match: 'spam' }, then: { type: 'task', text: 'Again' } }); for (let i = 0; i < 30; i++) await auto.fire(ann.id, 'task.done', { title: 'spam' }); assert.equal(tasksOf(ann).filter(t => t.title === 'Again').length, 20, 'a rule runs at most 20 times a day'); + assert.equal((await j(await post('/delete', { id: daily.id }))).rules.some(x => x.id === daily.id), false); + console.log('automations test ok'); + } catch (e) { console.error(e); process.exitCode = 1; } + hub.close(); +});