// Hooks: executables the daemon runs for every event (like procmail for WhatsApp). // // ~/.config/wa-cli/hooks/ executable // ~/.config/wa-cli/hooks/.d/* several executables, in alphabetical order // ~/.config/wa-cli/hooks/all.d/* every event // // Each hook gets the event as JSON (one line) on stdin and the event name as $1. // Environment: WA_CLI_EVENT, WA_CLI_ACCOUNT, WA_CLI_SOCKET (to call `wa` back). // Invocations of the same hook are serialized: they keep the event order. // The directory is rescanned on every event: no daemon restart needed. import { spawn } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; export class Hooks { constructor({ dir, account, socket, timeoutMs, enabled, log }) { Object.assign(this, { dir, account, socket, timeoutMs, enabled, log }); this.chains = new Map(); } find(event) { const out = []; const add = (p) => { try { const st = fs.statSync(p); if (st.isFile() && (st.mode & 0o111)) out.push(p); } catch { /* does not exist */ } }; const addDir = (d) => { let names; try { names = fs.readdirSync(d); } catch { return; } names.filter((n) => !n.startsWith('.') && !n.endsWith('~')).sort().forEach((n) => add(path.join(d, n))); }; add(path.join(this.dir, event)); addDir(path.join(this.dir, `${event}.d`)); addDir(path.join(this.dir, 'all.d')); return out; } run(event, payload) { if (!this.enabled) return; const line = JSON.stringify(payload) + '\n'; for (const file of this.find(event)) { const prev = this.chains.get(file) || Promise.resolve(); const next = prev.then(() => this.exec(file, event, line)); this.chains.set(file, next); next.finally(() => { if (this.chains.get(file) === next) this.chains.delete(file); }); } } exec(file, event, line) { return new Promise((resolve) => { const name = path.relative(this.dir, file); const child = spawn(file, [event], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, WA_CLI_EVENT: event, WA_CLI_ACCOUNT: this.account, WA_CLI_SOCKET: this.socket }, }); const timer = setTimeout(() => { this.log(`hook ${name}: timeout (${this.timeoutMs} ms), killing it`); child.kill('SIGKILL'); }, this.timeoutMs); const relay = (stream) => stream.on('data', (d) => { for (const l of String(d).split('\n').filter(Boolean)) this.log(`hook ${name}: ${l}`); }); relay(child.stdout); relay(child.stderr); child.stdin.on('error', () => {}); // the hook may not read stdin child.stdin.end(line); child.on('error', (e) => this.log(`hook ${name}: ${e.message}`)); child.on('close', (code, signal) => { clearTimeout(timer); if (code) this.log(`hook ${name} (${event}): exited with code ${code}`); else if (signal) this.log(`hook ${name} (${event}): killed by ${signal}`); resolve(); }); }); } }