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 = `
Little rules that run for you. Each can run up to 20 times a day.
+