@nodium.io/cli 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/listen.js ADDED
@@ -0,0 +1,200 @@
1
+ import { NodiumError, WEBHOOK_EVENTS } from '@nodium.io/whatsapp';
2
+ import { signWebhook } from '@nodium.io/whatsapp/server';
3
+ import { UsageError } from './args.js';
4
+ import { clock, paint } from './output.js';
5
+ /**
6
+ * `nodium listen` : les événements de votre compte, rejoués vers une adresse
7
+ * locale, signés comme en production. Aucun tunnel : c'est la commande qui
8
+ * interroge Nodium, jamais l'inverse.
9
+ *
10
+ * Elle lit le JOURNAL D'ÉVÉNEMENTS (`GET /events`, depuis un curseur) : chaque
11
+ * ligne est l'enveloppe exacte d'un webhook, plus son `seq`. Le direct du
12
+ * journal de la console (`GET /events/stream`) porte d'autres lignes (messages,
13
+ * livraisons, appels) et non ces enveloppes : il ne sert pas ici.
14
+ *
15
+ * ⚠️ Le corps envoyé est l'enveloppe SANS `seq`, sérialisée une fois ; la
16
+ * signature est calculée sur ces octets (`t=<s>,v1=hmac-sha256(secret, "<t>.<corps>")`,
17
+ * la même fonction que `signWebhook` de `@nodium.io/whatsapp/server`). Un récepteur qui vérifie sur
18
+ * le corps brut accepte l'appel comme en production.
19
+ * ⚠️ Le curseur avance après chaque page rejouée, que l'adresse locale ait
20
+ * répondu ou non : un échec local n'est pas rejoué (production le fait ;
21
+ * ici, relancez avec `--from <seq>` ou rejouez depuis la console).
22
+ * ⚠️ `message.imported` ne sonne jamais en production : il n'est rejoué que si
23
+ * `--events` le nomme.
24
+ */
25
+ /** Le délai d'attente de l'adresse locale : celui de Nodium en production. */
26
+ export const FORWARD_TIMEOUT_MS = 10_000;
27
+ /** Événements lus par appel (le défaut de l'API est 100, son plafond 500). */
28
+ const PAGE = 100;
29
+ /** Attente entre deux lectures du journal quand il n'y a rien de neuf, en millisecondes. */
30
+ export const DEFAULT_INTERVAL_MS = 1000;
31
+ const MIN_INTERVAL_MS = 250;
32
+ /** L'attente maximale entre deux essais quand Nodium est injoignable. */
33
+ const BACKOFF_CAP_MS = 15_000;
34
+ /**
35
+ * L'appel que Nodium ferait : le corps (l'enveloppe, sans `seq`) et les
36
+ * en-têtes. Sans `secret`, pas d'en-tête de signature.
37
+ */
38
+ export function buildDelivery(entry, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
39
+ const envelope = Object.fromEntries(Object.entries(entry).filter(([key]) => key !== 'seq'));
40
+ const body = JSON.stringify(envelope);
41
+ return {
42
+ body,
43
+ headers: {
44
+ 'Content-Type': 'application/json',
45
+ 'User-Agent': 'Nodium-Webhooks/1',
46
+ 'X-Nodium-Event': entry.event,
47
+ 'X-Nodium-Delivery': `local-${entry.id}`,
48
+ ...(secret ? { 'X-Nodium-Signature': signWebhook(secret, nowSeconds, body) } : {})
49
+ }
50
+ };
51
+ }
52
+ /**
53
+ * `--events message.received,message.failed` → l'ensemble voulu ; `null` pour
54
+ * « tous ceux qui sonnent ». `*` veut dire tous, comme dans un abonnement.
55
+ */
56
+ export function parseEventFilter(text) {
57
+ if (text === undefined)
58
+ return null;
59
+ const names = text.split(',').map(name => name.trim()).filter(Boolean);
60
+ if (!names.length)
61
+ throw new UsageError('--events needs at least one event name.');
62
+ if (names.includes('*'))
63
+ return null;
64
+ const known = new Set(WEBHOOK_EVENTS);
65
+ const unknown = names.filter(name => !known.has(name));
66
+ if (unknown.length)
67
+ throw new UsageError(`Unknown event ${unknown.map(n => `"${n}"`).join(', ')}. Known: ${WEBHOOK_EVENTS.join(', ')}.`);
68
+ return new Set(names);
69
+ }
70
+ /** Cet événement est-il rejoué ? Sans filtre : tous, sauf l'historique importé. */
71
+ export function wanted(filter, event) {
72
+ return filter ? filter.has(event) : event !== 'message.imported';
73
+ }
74
+ /** L'adresse locale : http ou https, rien d'autre. */
75
+ export function parseForwardUrl(text) {
76
+ if (!text)
77
+ throw new UsageError('--forward <url> is required, for example --forward http://localhost:3000/webhook.');
78
+ let url;
79
+ try {
80
+ url = new URL(text);
81
+ }
82
+ catch {
83
+ throw new UsageError(`--forward "${text}" is not a valid URL. Example: http://localhost:3000/webhook.`);
84
+ }
85
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
86
+ throw new UsageError('--forward must be an http:// or https:// URL.');
87
+ return url.toString();
88
+ }
89
+ async function forwardOne(url, delivery, fetchImpl) {
90
+ const started = performance.now();
91
+ try {
92
+ const response = await fetchImpl(url, {
93
+ method: 'POST',
94
+ headers: delivery.headers,
95
+ body: delivery.body,
96
+ redirect: 'manual',
97
+ signal: AbortSignal.timeout(FORWARD_TIMEOUT_MS)
98
+ });
99
+ await response.arrayBuffer().catch(() => { });
100
+ return { status: response.status, ms: Math.round(performance.now() - started), error: null };
101
+ }
102
+ catch (cause) {
103
+ const ms = Math.round(performance.now() - started);
104
+ if (cause?.name === 'TimeoutError' || cause?.name === 'AbortError') {
105
+ return { status: null, ms, error: `no answer within ${FORWARD_TIMEOUT_MS / 1000} s (Nodium waits as long in production)` };
106
+ }
107
+ const code = cause?.cause?.code ?? cause?.code;
108
+ if (code === 'ECONNREFUSED')
109
+ return { status: null, ms, error: `connection refused: is your server listening on ${new URL(url).host}?` };
110
+ return { status: null, ms, error: String(cause?.cause?.message ?? cause?.message ?? cause) };
111
+ }
112
+ }
113
+ /** Une attente que l'arrêt interrompt. */
114
+ export function pause(ms, signal) {
115
+ return new Promise((resolve) => {
116
+ if (signal.aborted)
117
+ return resolve();
118
+ const done = () => {
119
+ clearTimeout(timer);
120
+ signal.removeEventListener('abort', done);
121
+ resolve();
122
+ };
123
+ const timer = setTimeout(done, ms);
124
+ signal.addEventListener('abort', done, { once: true });
125
+ });
126
+ }
127
+ /** Ce qui ne se réessaie pas : la clé, un appel interdit, le client visé. */
128
+ function isFatal(error) {
129
+ return error instanceof NodiumError && (error.status === 401 || error.status === 403 || error.status === 404);
130
+ }
131
+ export async function listen(options) {
132
+ const { scope, signal, output } = options;
133
+ const fetchImpl = options.fetch ?? globalThis.fetch;
134
+ const sleep = options.sleep ?? pause;
135
+ const intervalMs = Math.max(MIN_INTERVAL_MS, options.intervalMs);
136
+ const summary = { forwarded: 0, failed: 0, after: 0 };
137
+ // Un premier appel sans `after` rend la tête du journal : le curseur du « maintenant ».
138
+ summary.after = options.from ?? (await scope.events.list()).next_after;
139
+ output.out(`${paint(output, 'bold', 'Listening')} ${options.from === null ? 'from now' : `after event #${options.from}`} → ${options.forward}`);
140
+ output.out(paint(output, 'dim', options.secret
141
+ ? 'Calls are signed with your secret: verify X-Nodium-Signature as in production.'
142
+ : 'No --secret: calls carry no X-Nodium-Signature header.'));
143
+ output.out(paint(output, 'dim', 'Press Ctrl+C to stop.'));
144
+ let failures = 0;
145
+ while (!signal.aborted) {
146
+ let full;
147
+ try {
148
+ const page = await scope.events.list(summary.after, PAGE);
149
+ if (failures) {
150
+ output.out(paint(output, 'green', 'Connection with Nodium restored.'));
151
+ failures = 0;
152
+ }
153
+ for (const entry of page.data) {
154
+ if (signal.aborted)
155
+ break;
156
+ if (wanted(options.events, entry.event))
157
+ await deliver(entry, options, fetchImpl, summary);
158
+ summary.after = entry.seq;
159
+ }
160
+ // Une page rejouée en entier : tout ce qui précède `next_after` est rendu ou n'est pas à vous.
161
+ if (!signal.aborted)
162
+ summary.after = page.next_after;
163
+ full = page.data.length >= PAGE;
164
+ }
165
+ catch (error) {
166
+ if (error instanceof NodiumError && error.code === 'cursor_expired') {
167
+ // Le journal garde 30 jours : un curseur plus vieux est repris à la tête.
168
+ summary.after = (await scope.events.list()).next_after;
169
+ output.err(paint(output, 'yellow', 'The cursor is older than the event log (30 days): restarting from now.'));
170
+ continue;
171
+ }
172
+ if (isFatal(error))
173
+ throw error;
174
+ failures++;
175
+ const wait = Math.min(BACKOFF_CAP_MS, intervalMs * 2 ** failures);
176
+ output.err(paint(output, 'yellow', `Nodium is unreachable (${error instanceof NodiumError ? error.code : 'error'}); retrying in ${Math.round(wait / 1000)} s from event #${summary.after}.`));
177
+ await sleep(wait, signal);
178
+ continue;
179
+ }
180
+ if (!full)
181
+ await sleep(intervalMs, signal);
182
+ }
183
+ return summary;
184
+ }
185
+ /** Rejoue un événement et en écrit la ligne. */
186
+ async function deliver(entry, options, fetchImpl, summary) {
187
+ const { output } = options;
188
+ const result = await forwardOne(options.forward, buildDelivery(entry, options.secret), fetchImpl);
189
+ summary.forwarded++;
190
+ const ok = result.status !== null && result.status >= 200 && result.status < 300;
191
+ if (!ok)
192
+ summary.failed++;
193
+ const client = entry.external_id ? ` [${entry.external_id}]` : '';
194
+ const status = paint(output, ok ? 'green' : 'red', result.status === null ? 'ERR' : String(result.status));
195
+ output.out(`${paint(output, 'dim', clock())} ${entry.event.padEnd(24)} ${status} ${String(result.ms).padStart(5)} ms ${paint(output, 'dim', entry.id + client)}`);
196
+ if (result.error)
197
+ output.out(` ${paint(output, 'red', result.error)}`);
198
+ if (options.verbose)
199
+ output.out(paint(output, 'dim', JSON.stringify(entry.data, null, 2).replace(/^/gm, ' ')));
200
+ }
package/dist/output.js ADDED
@@ -0,0 +1,65 @@
1
+ import { NodiumError } from '@nodium.io/whatsapp';
2
+ import { UsageError } from './args.js';
3
+ const CODES = { dim: 2, red: 31, green: 32, yellow: 33, cyan: 36, bold: 1 };
4
+ export function paint(output, style, text) {
5
+ return output.color ? `\u001b[${CODES[style]}m${text}\u001b[0m` : text;
6
+ }
7
+ /** L'heure locale, `HH:MM:SS`. */
8
+ export function clock(date = new Date()) {
9
+ return date.toTimeString().slice(0, 8);
10
+ }
11
+ /** Les codes qui n'ont pas de ligne dans le guide des erreurs. */
12
+ const NO_GUIDE = new Set(['unknown_error', 'network_error', 'timeout']);
13
+ /**
14
+ * Une erreur en quelques lignes : le statut et le `code` (stable, c'est lui
15
+ * qu'on cherche dans la documentation), le texte, l'identifiant à donner au
16
+ * support, et la ligne du guide des erreurs.
17
+ */
18
+ export function describeError(error, baseUrl) {
19
+ if (error instanceof UsageError)
20
+ return error.message;
21
+ if (!(error instanceof NodiumError))
22
+ return error instanceof Error ? error.message : String(error);
23
+ const lines = [`${error.status ? `Error ${error.status}` : 'Error'} ${error.code}: ${error.message}`];
24
+ if (error.status === 401)
25
+ lines.push('The key is missing, malformed, expired or revoked: check --key, NODIUM_KEY, or run `nodium login` again.');
26
+ if (error.retryAfter !== null)
27
+ lines.push(`Retry in ${error.retryAfter} s.`);
28
+ if (error.requestId)
29
+ lines.push(`Request id: ${error.requestId}`);
30
+ if (error.status > 0 && !NO_GUIDE.has(error.code)) {
31
+ try {
32
+ lines.push(`More: ${new URL(baseUrl).origin}/en/docs/api/guide/responses#${error.code}`);
33
+ }
34
+ catch { /* adresse illisible : pas de lien */ }
35
+ }
36
+ return lines.join('\n');
37
+ }
38
+ /**
39
+ * Le refus en JSON, sur la sortie d'erreur : `{ "error": { status, code,
40
+ * message, requestId } }`. Une faute d'usage a `status` 0 et le code `usage_error`.
41
+ */
42
+ export function errorJson(error) {
43
+ const body = error instanceof NodiumError
44
+ ? { status: error.status, code: error.code, message: error.message, requestId: error.requestId }
45
+ : { status: 0, code: error instanceof UsageError ? 'usage_error' : 'cli_error', message: error instanceof Error ? error.message : String(error), requestId: null };
46
+ return JSON.stringify({ error: body }, null, 2);
47
+ }
48
+ /** Des colonnes alignées, une ligne par entrée ; la dernière colonne n'est pas complétée. */
49
+ export function table(rows) {
50
+ const widths = [];
51
+ for (const row of rows)
52
+ row.forEach((cell, i) => { widths[i] = Math.max(widths[i] ?? 0, cell.length); });
53
+ return rows.map(row => row.map((cell, i) => (i === row.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ').trimEnd());
54
+ }
55
+ /** Une date ISO en `AAAA-MM-JJ HH:MM`, ou `—`. */
56
+ export function when(iso) {
57
+ return iso ? iso.slice(0, 16).replace('T', ' ') : '—';
58
+ }
59
+ /** Un objet en lignes `clé valeur` ; ce qui est imbriqué s'écrit en JSON compact. */
60
+ export function details(object) {
61
+ return table(Object.entries(object).map(([key, value]) => [
62
+ key,
63
+ value === null || value === undefined ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value)
64
+ ]));
65
+ }
package/dist/run.js ADDED
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { DEFAULT_BASE_URL, Nodium } from '@nodium.io/whatsapp';
4
+ import { UsageError, flag, hasFlag, parseArgs, rejectUnknown } from './args.js';
5
+ import { COMMANDS, findCommands } from './commands/index.js';
6
+ import { configPath as defaultConfigPath, readConfig, resolveKey } from './config.js';
7
+ import { confirmWrite, outputMode } from './context.js';
8
+ import { pause } from './listen.js';
9
+ import { describeError, errorJson, paint, table } from './output.js';
10
+ /**
11
+ * La commande `nodium` : lit la ligne de commande, cherche la commande dans la
12
+ * table (`commands/index.ts`), branche le client, rend le code de sortie
13
+ * (0 réussi, 1 refusé par Nodium ou par Meta, 2 mal appelé).
14
+ *
15
+ * ⚠️ La clé se donne de préférence par `nodium login` ou `NODIUM_KEY` :
16
+ * `--key` reste dans l'historique du terminal.
17
+ */
18
+ /** Les drapeaux qu'accepte toute commande. */
19
+ const COMMON = ['key', 'tenant', 'base-url', 'output', 'yes', 'no-color', 'help'];
20
+ const COMMON_HELP = `Options for every command
21
+ --key <nod_…> API key. Default: $NODIUM_KEY, then the key saved by \`nodium login\`.
22
+ --tenant <id> Act for one client: its id or your own externalId.
23
+ --output <json|human> json: the API's object, for scripts and agents. Default: human in a
24
+ terminal, json when piped.
25
+ -y, --yes Approve a command that sends or changes something, without asking.
26
+ Required when not in a terminal.
27
+ --base-url <url> API address. Default: ${DEFAULT_BASE_URL} ($NODIUM_BASE_URL).
28
+ --no-color No colors (also: $NO_COLOR).
29
+ -h, --help This help, or the help of a command.
30
+ -v, --version The version.`;
31
+ function usage(command) {
32
+ return ['nodium', ...command.path, ...(command.args ?? [])].join(' ');
33
+ }
34
+ export function globalHelp() {
35
+ return `nodium: the Nodium WhatsApp API from your terminal.
36
+
37
+ Commands
38
+ ${table(COMMANDS.map(c => [` ${usage(c)}`, c.summary])).join('\n')}
39
+
40
+ ${COMMON_HELP}
41
+
42
+ Exit codes: 0 done, 1 refused by Nodium or Meta, 2 wrong command line.
43
+ More: nodium help <command>, for example \`nodium help templates wait\`.
44
+ `;
45
+ }
46
+ export function commandHelp(command) {
47
+ return `${usage(command)}: ${command.summary}
48
+
49
+ Usage
50
+ ${usage(command)}${command.flags?.length ? ' [options]' : ''}
51
+ ${command.help ? `\nOptions\n${command.help}\n` : ''}
52
+ Options for every command: \`nodium --help\`.
53
+ `;
54
+ }
55
+ function groupHelp(noun, commands) {
56
+ return `nodium ${noun}
57
+
58
+ ${table(commands.map(c => [` ${usage(c)}`, c.summary])).join('\n')}
59
+
60
+ More: nodium help ${noun} <${commands.map(c => c.path[1]).join('|')}>
61
+ `;
62
+ }
63
+ function packageVersion() {
64
+ try {
65
+ const file = fileURLToPath(new URL('../package.json', import.meta.url));
66
+ return String(JSON.parse(readFileSync(file, 'utf8')).version);
67
+ }
68
+ catch {
69
+ return 'unknown';
70
+ }
71
+ }
72
+ const NO_IO = {
73
+ ask: async () => '',
74
+ readSecret: async () => '',
75
+ readStdin: async () => ''
76
+ };
77
+ /**
78
+ * La commande nommée par les mots de la ligne : `templates wait` ou `send`.
79
+ * Rend aussi les mots qui restent (ses arguments).
80
+ */
81
+ export function resolveCommand(words) {
82
+ const [noun, verb, ...more] = words;
83
+ if (!noun)
84
+ return { command: null, rest: [], group: [] };
85
+ const group = findCommands([noun]);
86
+ const exact = group.find(c => c.path.length === 1);
87
+ if (exact)
88
+ return { command: exact, rest: words.slice(1), group };
89
+ const command = verb ? group.find(c => c.path[1] === verb) ?? null : null;
90
+ return { command, rest: command ? more : [], group };
91
+ }
92
+ export async function run(argv, context) {
93
+ const { output, env } = context;
94
+ let baseUrl = env.NODIUM_BASE_URL || DEFAULT_BASE_URL;
95
+ let json = false;
96
+ try {
97
+ const args = parseArgs(argv);
98
+ json = outputMode(flag(args, 'output'), Boolean(context.stdoutIsTty)) === 'json';
99
+ if (hasFlag(args, 'version')) {
100
+ output.out(packageVersion());
101
+ return 0;
102
+ }
103
+ // `nodium help templates wait` vaut `nodium templates wait --help`.
104
+ const asksHelp = args.command === 'help' || hasFlag(args, 'help');
105
+ const words = args.command === 'help' ? args.positionals : [args.command ?? '', ...args.positionals].filter(Boolean);
106
+ if (!words.length) {
107
+ output.out(globalHelp());
108
+ return asksHelp ? 0 : 2;
109
+ }
110
+ const { command, rest, group } = resolveCommand(words);
111
+ if (!command) {
112
+ if (!group.length)
113
+ throw new UsageError(`Unknown command "${words[0]}". Try \`nodium --help\`.`);
114
+ if (asksHelp || words.length === 1) {
115
+ output.out(groupHelp(words[0], group));
116
+ return asksHelp ? 0 : 2;
117
+ }
118
+ throw new UsageError(`Unknown command "${words.slice(0, 2).join(' ')}". Try \`nodium help ${words[0]}\`.`);
119
+ }
120
+ if (asksHelp) {
121
+ output.out(commandHelp(command));
122
+ return 0;
123
+ }
124
+ const name = command.path.join(' ');
125
+ const commandArgs = { ...args, command: name, positionals: rest };
126
+ rejectUnknown(commandArgs, [...COMMON, ...(command.flags ?? [])], name, command.args?.length ?? 0);
127
+ const path = context.configPath ?? defaultConfigPath(env);
128
+ const config = readConfig(path);
129
+ baseUrl = flag(args, 'base-url') ?? (env.NODIUM_BASE_URL || config.baseUrl || DEFAULT_BASE_URL);
130
+ let client = null;
131
+ const nodium = () => {
132
+ if (!client) {
133
+ const { key } = resolveKey(flag(args, 'key'), env, config);
134
+ client = new Nodium(key, { baseUrl, ...(context.fetch ? { fetch: context.fetch } : {}) });
135
+ }
136
+ return client;
137
+ };
138
+ const io = context.io ?? NO_IO;
139
+ const interactive = Boolean(context.interactive);
140
+ const ctx = {
141
+ args: commandArgs, output, json, interactive, env, signal: context.signal, io,
142
+ ...(context.fetch ? { fetch: context.fetch } : {}),
143
+ sleep: context.sleep ?? pause,
144
+ baseUrl, configPath: path, config,
145
+ nodium,
146
+ scope: () => {
147
+ const tenant = flag(args, 'tenant');
148
+ return tenant ? nodium().as(tenant) : nodium();
149
+ },
150
+ print: (data, human) => (json ? output.out(JSON.stringify(data, null, 2)) : human()),
151
+ confirm: what => confirmWrite(what, { yes: hasFlag(args, 'yes'), interactive, ask: io.ask })
152
+ };
153
+ if (command.needsKey !== false)
154
+ nodium();
155
+ return await command.run(ctx);
156
+ }
157
+ catch (error) {
158
+ output.err(json ? errorJson(error) : paint(output, 'red', describeError(error, baseUrl)));
159
+ return error instanceof UsageError ? 2 : 1;
160
+ }
161
+ }
package/dist/send.js ADDED
@@ -0,0 +1,113 @@
1
+ import { UsageError, flag, flagAll, hasFlag } from './args.js';
2
+ import { readJsonInput } from './input.js';
3
+ import { paint } from './output.js';
4
+ /**
5
+ * `nodium send` : un message. Du texte libre (dans les 24 h qui suivent le
6
+ * dernier message de la personne) ou un modèle approuvé (le seul envoi
7
+ * possible pour écrire en premier) par drapeaux ; tout autre message (fichier,
8
+ * boutons, liste…) en JSON, par `--input` ou `--stdin`.
9
+ *
10
+ * ⚠️ Un envoi refusé par Meta répond HTTP 200 : la commande lit
11
+ * `message.status` et sort en erreur (code 1) quand il vaut `failed`.
12
+ */
13
+ /** Ce que la commande a lu de la ligne de commande, vérifié. */
14
+ export function readSendInput(args) {
15
+ const channelId = flag(args, 'channel');
16
+ if (!channelId)
17
+ throw new UsageError('--channel <id> is required: the number to send from (`GET /channels`).');
18
+ const to = flag(args, 'to');
19
+ const recipient = flag(args, 'recipient');
20
+ if (!to && !recipient)
21
+ throw new UsageError('--to <+33612345678> is required (or --recipient wa:… for a contact without a number).');
22
+ if (to && recipient)
23
+ throw new UsageError('Give --to or --recipient, never both.');
24
+ const address = to ? { to } : { recipient: recipient };
25
+ const text = flag(args, 'text');
26
+ const template = flag(args, 'template');
27
+ if (text !== undefined && template)
28
+ throw new UsageError('Give --text or --template, not both.');
29
+ if (text === undefined && !template)
30
+ throw new UsageError('Nothing to send: give --text "…" or --template <name>.');
31
+ const idempotencyKey = flag(args, 'idempotency-key');
32
+ const base = { channelId, ...(idempotencyKey ? { idempotencyKey } : {}) };
33
+ if (text !== undefined)
34
+ return { ...base, ...address, type: 'text', text };
35
+ const variables = {};
36
+ for (const pair of flagAll(args, 'var')) {
37
+ const eq = pair.indexOf('=');
38
+ if (eq <= 0)
39
+ throw new UsageError(`--var "${pair}" must look like 1=Karim (the number of the {{variable}}, then its value).`);
40
+ variables[pair.slice(0, eq)] = pair.slice(eq + 1);
41
+ }
42
+ const language = flag(args, 'language');
43
+ return {
44
+ ...base, ...address, type: 'template', templateName: template,
45
+ ...(language ? { templateLanguage: language } : {}),
46
+ ...(Object.keys(variables).length ? { variables } : {})
47
+ };
48
+ }
49
+ /** Les drapeaux qui décrivent le message ; avec `--input` ou `--stdin`, aucun ne se donne. */
50
+ const MESSAGE_FLAGS = ['channel', 'to', 'recipient', 'text', 'template', 'language', 'var', 'idempotency-key'];
51
+ /** Le message à envoyer : le JSON de `--input`/`--stdin` (un `SendInput`), sinon les drapeaux. */
52
+ async function readMessage(ctx) {
53
+ const json = await readJsonInput(ctx);
54
+ if (!json)
55
+ return readSendInput(ctx.args);
56
+ const mixed = MESSAGE_FLAGS.find(name => hasFlag(ctx.args, name));
57
+ if (mixed)
58
+ throw new UsageError(`--${mixed} cannot be mixed with --input or --stdin: put everything in the JSON.`);
59
+ if (typeof json.channelId !== 'string' || (typeof json.to !== 'string' && typeof json.recipient !== 'string')) {
60
+ throw new UsageError('The JSON needs "channelId" and "to" (or "recipient"), plus "type" and its fields.');
61
+ }
62
+ return json;
63
+ }
64
+ /** Envoie, écrit le résultat, rend le code de sortie. */
65
+ async function send(ctx) {
66
+ const input = await readMessage(ctx);
67
+ const target = input.to ?? input.recipient;
68
+ await ctx.confirm(`Send a ${input.type ?? 'message'} to ${target} from number ${input.channelId}`);
69
+ const result = await ctx.scope().messages.send(input);
70
+ const message = result.message;
71
+ const failed = message?.status === 'failed';
72
+ ctx.print(result, () => {
73
+ const { output } = ctx;
74
+ if (!message)
75
+ return output.out(`Sent to ${target}.`);
76
+ if (failed) {
77
+ output.err(paint(output, 'red', `Message ${message.id} to ${target} failed.`));
78
+ if (message.errorInfo) {
79
+ output.err(`${message.errorInfo.title}`);
80
+ output.err(`What to do (${message.errorInfo.actor}): ${message.errorInfo.action}`);
81
+ }
82
+ else if (message.error) {
83
+ output.err(message.error);
84
+ }
85
+ return;
86
+ }
87
+ output.out(`${paint(output, 'green', message.status)} message ${message.id} to ${target}${result.duplicate ? ' (already sent with this idempotency key: nothing left again)' : ''}`);
88
+ output.out(paint(output, 'dim', `Follow it: nodium messages get ${message.id}`));
89
+ });
90
+ return failed ? 1 : 0;
91
+ }
92
+ export const SEND_COMMAND = {
93
+ path: ['send'], summary: 'Send a WhatsApp message. Asks first.',
94
+ flags: [...MESSAGE_FLAGS, 'input', 'stdin'],
95
+ help: ` --channel <id> The number to send from (\`nodium numbers list\`). Required.
96
+ --to <number> The recipient, in international format. Or --recipient wa:… for a
97
+ contact who shows no number.
98
+ --text <text> Free text: only within 24 hours of the person's last message.
99
+ --template <name> An approved template: the only way to write first.
100
+ --language <code> The template language, for example en or fr.
101
+ --var <n=value> A template variable (repeat it): --var 1=Karim --var 2=Friday.
102
+ --idempotency-key <k> Your own anti-duplicate key (8 to 200 characters).
103
+ --input <file.json> Any message as JSON (image, buttons, list, location…), or --stdin.
104
+ The fields of POST /messages: channelId, to, type and its fields.
105
+
106
+ Examples
107
+ nodium send --channel <id> --to +33612345678 --text "Hello"
108
+ nodium send --channel <id> --to +33612345678 --template order_ready --language en --var 1=Karim
109
+ echo '{"channelId":"<id>","to":"+33612345678","type":"image","mediaId":"<id>"}' | nodium send --stdin --yes
110
+
111
+ A send Meta refuses answers HTTP 200: the command reads the message status and exits 1.`,
112
+ run: send
113
+ };
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@nodium.io/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "The nodium command: the Nodium WhatsApp API from your terminal — numbers, conversations, templates, webhooks, clients, Flows — and your webhooks replayed to your local server, signed like production.",
5
+ "keywords": ["nodium", "cli", "whatsapp", "whatsapp-api", "whatsapp-cloud-api", "webhooks", "local-development"],
6
+ "homepage": "https://nodium.io/en/docs/api",
7
+ "license": "MIT",
8
+ "author": "By-Tech.io",
9
+ "type": "module",
10
+ "engines": { "node": ">=20" },
11
+ "bin": { "nodium": "./dist/index.js" },
12
+ "files": ["dist", "README.md", "LICENSE"],
13
+ "publishConfig": { "access": "public" },
14
+ "scripts": {
15
+ "build": "node scripts/build.mjs",
16
+ "typecheck": "node scripts/build.mjs --check",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "dependencies": {
20
+ "@nodium.io/whatsapp": "^0.3.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^26.4.0",
24
+ "typescript": "^5.9.2"
25
+ }
26
+ }