90 lines
7.7 KiB
JavaScript
90 lines
7.7 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', 'pantry.low', 'podcast.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)');
|
|
if (wt === 'podcast.new' && !when.match) throw fail(400, 'Say which show (part of its name)');
|
|
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' : wt === 'pantry.low' ? 'When the pantry runs low' : wt === 'podcast.new' ? 'When a new episode comes out' : '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|show)\}/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 || '') : type === 'podcast.new' ? (ctx.show || '') + ' ' + (ctx.title || '') : ctx.title || '').toLowerCase();
|
|
if (m && !hay.includes(m)) continue; await run(r, ctx);
|
|
}
|
|
});
|
|
}
|
|
/** Something happened that concerns everyone (the pantry went low, a new episode): run every person's matching rules. */
|
|
async function fireAll(type, ctx) { for (const u of users.all()) await fire(u.id, type, ctx).catch(() => {}); }
|
|
/** 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)
|
|
}
|
|
});
|
|
}
|
|
/** New podcast episodes since the last look (not the first look, which only notes what is already there): run the "new episode" rules. Checked every half hour by push.js. */
|
|
const SEEN = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'podcast-seen.json');
|
|
async function checkEpisodes(getRecent) {
|
|
let seen = null; try { seen = JSON.parse(fs.readFileSync(SEEN(), 'utf8')); } catch { /* the first look */ }
|
|
const eps = await getRecent(5); if (!Array.isArray(eps)) return 0;
|
|
const ids = new Set(Array.isArray(seen) ? seen : []), fresh = Array.isArray(seen) && seen.length ? eps.filter(e => e.id && !ids.has(e.id)) : [];
|
|
atomicWrite(SEEN(), JSON.stringify([...new Set([...eps.map(e => e.id), ...ids])].slice(-600)), { mode: 0o600 });
|
|
for (const e of fresh.slice(0, 10)) await fireAll('podcast.new', { title: e.title, show: e.show });
|
|
return fresh.length;
|
|
}
|
|
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, fireAll, tickUser, checkEpisodes };
|