diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-09-26 19:26:33 -0300 |
| commit | d80640d01eeb84907e36ff4af33add9081eda17c (patch) | |
| tree | 27698b34e06e28d111c2196700015d9c82f78274 /src/hooks.js | |
| download | wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip | |
Diffstat (limited to 'src/hooks.js')
| -rw-r--r-- | src/hooks.js | 85 |
1 files changed, 85 insertions, 0 deletions
diff --git a/src/hooks.js b/src/hooks.js new file mode 100644 index 0000000..7b9b1e7 --- /dev/null +++ b/src/hooks.js @@ -0,0 +1,85 @@ +// Hooks: executables the daemon runs for every event (like procmail for WhatsApp). +// +// ~/.config/wa-cli/hooks/<event> executable +// ~/.config/wa-cli/hooks/<event>.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(); + }); + }); + } +} |