aboutsummaryrefslogtreecommitdiffstats
path: root/src/api/index.js
blob: 3fdf4304db7620bfdae2b9cd79377366f3f29a21 (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
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// Registry of `resource.method` methods.
//
// Each module exports an object { 'resource.method': definition }. A definition:
//   description  text for `wa schema` / --help
//   params       { name: { type, required?, default?, description, enum? } }
//                type: string | integer | number | boolean | array | object | path | chat
//                  path -> the CLI resolves it to an absolute path (relative to the CLI cwd)
//                  chat -> the daemon resolves it to a chatId (JID, number or exact name)
//   columns      default columns for --format table/csv
//   ready        false if it works without a ready session (default true)
//   stream       true if it emits events until the client disconnects
//   handler      async (ctx, params, conn) => result   (only used by the daemon)
//
// Adding a method only takes adding it here: the daemon exposes it on the socket and
// the CLI lists it in the help and accepts it as `wa <resource> <method>`.

import path from 'node:path';
import { WaError } from '../rpc.js';
import chats from './chats.js';
import contacts from './contacts.js';
import messages from './messages.js';
import session from './session.js';

export const METHODS = { ...session, ...chats, ...messages, ...contacts };

export function resources() {
    const out = {};
    for (const [name, def] of Object.entries(METHODS)) {
        const [res, meth] = name.split('.');
        (out[res] ||= {})[meth] = def;
    }
    return out;
}

export function getMethod(name) {
    const def = METHODS[name];
    if (!def) {
        throw new WaError('EINVAL', `unknown method: ${name} (see \`wa schema\`)`);
    }
    return def;
}

// Serializable description (no handler), for `wa schema`.
export function schema(name) {
    const { handler, ...rest } = getMethod(name);
    return { method: name, ready: rest.ready !== false, ...rest };
}

// Spanish yes (si/sí) is accepted as well.
const TRUE = new Set(['true', '1', 'yes', 'si', 'sí']);
const FALSE = new Set(['false', '0', 'no']);

// Validates and normalizes parameters. `cwd` is used to resolve path-typed ones.
export function validate(name, params = {}, { cwd } = {}) {
    const def = getMethod(name);
    const spec = def.params || {};
    if (params === null || typeof params !== 'object' || Array.isArray(params)) {
        throw new WaError('EINVAL', `${name}: --params must be a JSON object`);
    }
    const out = {};
    for (const key of Object.keys(params)) {
        if (!spec[key]) {
            const valid = Object.keys(spec);
            throw new WaError('EINVAL', `${name}: unknown parameter "${key}"` +
                (valid.length ? ` (valid: ${valid.join(', ')})` : ' (takes no parameters)'));
        }
    }
    for (const [key, p] of Object.entries(spec)) {
        let v = params[key];
        if (v === undefined || v === null) {
            if (p.required) throw new WaError('EINVAL', `${name}: missing parameter "${key}"`);
            if (p.default !== undefined) out[key] = p.default;
            continue;
        }
        const bad = () => new WaError('EINVAL', `${name}: "${key}" must be ${p.type}`);
        switch (p.type) {
            case 'string':
            case 'chat':
                if (typeof v === 'number') v = String(v);
                if (typeof v !== 'string' || v === '') throw bad();
                break;
            case 'path':
                if (typeof v !== 'string' || v === '') throw bad();
                if (cwd) v = path.resolve(cwd, v.replace(/^~(?=$|\/)/, process.env.HOME));
                break;
            case 'integer':
            case 'number':
                if (typeof v === 'string' && v.trim() !== '') v = Number(v);
                if (typeof v !== 'number' || Number.isNaN(v)) throw bad();
                if (p.type === 'integer' && !Number.isInteger(v)) throw bad();
                break;
            case 'boolean':
                if (typeof v === 'string' && TRUE.has(v.toLowerCase())) v = true;
                else if (typeof v === 'string' && FALSE.has(v.toLowerCase())) v = false;
                if (typeof v !== 'boolean') throw bad();
                break;
            case 'array':
                if (typeof v === 'string') v = v.split(',').map((s) => s.trim()).filter(Boolean);
                if (!Array.isArray(v)) throw bad();
                break;
            case 'object':
                if (typeof v !== 'object' || Array.isArray(v)) throw bad();
                break;
        }
        if (p.enum && !p.enum.includes(v)) {
            throw new WaError('EINVAL', `${name}: "${key}" must be one of: ${p.enum.join(', ')}`);
        }
        out[key] = v;
    }
    return out;
}