Files
hub/automations.js
T

77 lines
6.3 KiB
JavaScript

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