// 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 };