1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
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();
});
});
}
}
|