aboutsummaryrefslogtreecommitdiffstats
path: root/src/hooks.js
blob: 7b9b1e79a983f667b5c707fd022390b117a839a8 (plain)
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();
            });
        });
    }
}