aboutsummaryrefslogtreecommitdiffstats
path: root/src/hooks.js
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-09-26 19:26:33 -0300
commitd80640d01eeb84907e36ff4af33add9081eda17c (patch)
tree27698b34e06e28d111c2196700015d9c82f78274 /src/hooks.js
downloadwa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.tar.gz
wa-cli-d80640d01eeb84907e36ff4af33add9081eda17c.zip
wa-cli: WhatsApp from the terminal, with a neomutt Maildir bridgeHEADmain
Diffstat (limited to 'src/hooks.js')
-rw-r--r--src/hooks.js85
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();
+ });
+ });
+ }
+}