@moxxy/plugin-channel-whatsapp 0.27.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth-state.d.ts +66 -0
  3. package/dist/auth-state.d.ts.map +1 -0
  4. package/dist/auth-state.js +99 -0
  5. package/dist/auth-state.js.map +1 -0
  6. package/dist/baileys-socket.d.ts +10 -0
  7. package/dist/baileys-socket.d.ts.map +1 -0
  8. package/dist/baileys-socket.js +93 -0
  9. package/dist/baileys-socket.js.map +1 -0
  10. package/dist/channel/turn-runner.d.ts +58 -0
  11. package/dist/channel/turn-runner.d.ts.map +1 -0
  12. package/dist/channel/turn-runner.js +130 -0
  13. package/dist/channel/turn-runner.js.map +1 -0
  14. package/dist/channel/voice-handler.d.ts +27 -0
  15. package/dist/channel/voice-handler.d.ts.map +1 -0
  16. package/dist/channel/voice-handler.js +55 -0
  17. package/dist/channel/voice-handler.js.map +1 -0
  18. package/dist/channel.d.ts +105 -0
  19. package/dist/channel.d.ts.map +1 -0
  20. package/dist/channel.js +459 -0
  21. package/dist/channel.js.map +1 -0
  22. package/dist/consent-prompt.d.ts +9 -0
  23. package/dist/consent-prompt.d.ts.map +1 -0
  24. package/dist/consent-prompt.js +28 -0
  25. package/dist/consent-prompt.js.map +1 -0
  26. package/dist/consent.d.ts +22 -0
  27. package/dist/consent.d.ts.map +1 -0
  28. package/dist/consent.js +40 -0
  29. package/dist/consent.js.map +1 -0
  30. package/dist/index.d.ts +26 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +211 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/keys.d.ts +39 -0
  35. package/dist/keys.d.ts.map +1 -0
  36. package/dist/keys.js +86 -0
  37. package/dist/keys.js.map +1 -0
  38. package/dist/message-gate.d.ts +50 -0
  39. package/dist/message-gate.d.ts.map +1 -0
  40. package/dist/message-gate.js +150 -0
  41. package/dist/message-gate.js.map +1 -0
  42. package/dist/pair-flow.d.ts +12 -0
  43. package/dist/pair-flow.d.ts.map +1 -0
  44. package/dist/pair-flow.js +120 -0
  45. package/dist/pair-flow.js.map +1 -0
  46. package/dist/permission.d.ts +29 -0
  47. package/dist/permission.d.ts.map +1 -0
  48. package/dist/permission.js +78 -0
  49. package/dist/permission.js.map +1 -0
  50. package/dist/setup-wizard.d.ts +13 -0
  51. package/dist/setup-wizard.d.ts.map +1 -0
  52. package/dist/setup-wizard.js +127 -0
  53. package/dist/setup-wizard.js.map +1 -0
  54. package/dist/socket.d.ts +72 -0
  55. package/dist/socket.d.ts.map +1 -0
  56. package/dist/socket.js +22 -0
  57. package/dist/socket.js.map +1 -0
  58. package/package.json +99 -0
  59. package/src/auth-state.test.ts +103 -0
  60. package/src/auth-state.ts +161 -0
  61. package/src/baileys-socket.ts +154 -0
  62. package/src/channel/turn-runner.test.ts +31 -0
  63. package/src/channel/turn-runner.ts +156 -0
  64. package/src/channel/voice-handler.ts +83 -0
  65. package/src/channel.test.ts +344 -0
  66. package/src/channel.ts +571 -0
  67. package/src/consent-prompt.ts +28 -0
  68. package/src/consent.test.ts +56 -0
  69. package/src/consent.ts +48 -0
  70. package/src/index.ts +284 -0
  71. package/src/keys.test.ts +59 -0
  72. package/src/keys.ts +78 -0
  73. package/src/message-gate.test.ts +121 -0
  74. package/src/message-gate.ts +184 -0
  75. package/src/pair-flow.ts +133 -0
  76. package/src/permission.test.ts +94 -0
  77. package/src/permission.ts +114 -0
  78. package/src/setup-wizard.ts +162 -0
  79. package/src/socket.test.ts +17 -0
  80. package/src/socket.ts +89 -0
  81. package/src/subcommands.test.ts +198 -0
package/src/channel.ts ADDED
@@ -0,0 +1,571 @@
1
+ import { newTurnId } from '@moxxy/core';
2
+ import { TurnCoordinator } from '@moxxy/channel-kit';
3
+ import type { ClientSession as Session } from '@moxxy/sdk';
4
+ import type { Channel, ChannelHandle, ChannelStartOptsBase, MoxxyEvent } from '@moxxy/sdk';
5
+ import { moxxyPath } from '@moxxy/sdk/server';
6
+ import type { VaultStore } from '@moxxy/plugin-vault';
7
+ import {
8
+ createFileAuthStorage,
9
+ hasStoredCreds,
10
+ type WhatsAppAuthStorage,
11
+ } from './auth-state.js';
12
+ import { CONSENT_REQUIRED_MESSAGE, hasConsent } from './consent.js';
13
+ import {
14
+ WHATSAPP_ALLOWED_JIDS_KEY,
15
+ WHATSAPP_AUTH_DIR,
16
+ WHATSAPP_OWNER_JID_KEY,
17
+ normalizeJid,
18
+ parseAllowedJids,
19
+ } from './keys.js';
20
+ import { gateInboundMessage, type GateVerdict } from './message-gate.js';
21
+ import {
22
+ createWhatsAppPermissionController,
23
+ type WhatsAppPermissionController,
24
+ } from './permission.js';
25
+ import { openBaileysSocket } from './baileys-socket.js';
26
+ import {
27
+ WA_DISCONNECT,
28
+ disconnectStatusCode,
29
+ type WaConnectionUpdate,
30
+ type WaInboundMessage,
31
+ type WaMessageKey,
32
+ type WhatsAppSocket,
33
+ type WhatsAppSocketFactory,
34
+ } from './socket.js';
35
+ import { DEFAULT_EDIT_FRAME_MS, runWhatsAppTurn } from './channel/turn-runner.js';
36
+ import { transcribeVoiceMessage } from './channel/voice-handler.js';
37
+
38
+ /** Bound on remembered own-send message ids (echo/loop protection). */
39
+ const MAX_SENT_IDS = 512;
40
+
41
+ export interface WhatsAppChannelLogger {
42
+ debug?(msg: string, meta?: Record<string, unknown>): void;
43
+ info?(msg: string, meta?: Record<string, unknown>): void;
44
+ warn?(msg: string, meta?: Record<string, unknown>): void;
45
+ }
46
+
47
+ export interface WhatsAppChannelOptions {
48
+ readonly vault: VaultStore;
49
+ readonly logger?: WhatsAppChannelLogger;
50
+ /** Debounce window for streaming edits (ms). Default 3000 — deliberately
51
+ * slower than Telegram/Slack; see the turn-runner rationale. */
52
+ readonly editFrameMs?: number;
53
+ /** Injectable transport (tests). Defaults to the real Baileys socket. */
54
+ readonly socketFactory?: WhatsAppSocketFactory;
55
+ /** Injectable auth-state backend. Defaults to `~/.moxxy/whatsapp-auth`. */
56
+ readonly authStorage?: WhatsAppAuthStorage;
57
+ /** Extra allow-listed JIDs from channel config/flags. */
58
+ readonly allowedJids?: ReadonlyArray<string>;
59
+ /** Consecutive reconnect attempts before giving up. Default 5. */
60
+ readonly maxReconnectAttempts?: number;
61
+ /** Reconnect backoff base (ms). Default 2000; tests shrink it. */
62
+ readonly reconnectBaseMs?: number;
63
+ }
64
+
65
+ export interface WhatsAppStartOpts extends ChannelStartOptsBase {
66
+ readonly session: Session;
67
+ /** Open in pairing mode: an unlinked start publishes QR payloads instead of
68
+ * refusing. Set by `moxxy channels whatsapp pair` / the setup wizard. */
69
+ readonly pair?: boolean;
70
+ /** Running GUI-supervised on a dedicated runner (desktop Channels panel) —
71
+ * equivalent to `pair` for the unlinked case, so the desktop can render the
72
+ * QR from the status file instead of a terminal. */
73
+ readonly dedicated?: boolean;
74
+ }
75
+
76
+ export class WhatsAppChannel implements Channel<WhatsAppStartOpts> {
77
+ readonly name = 'whatsapp';
78
+ private readonly permission: WhatsAppPermissionController;
79
+ private readonly opts: WhatsAppChannelOptions;
80
+ private readonly turns = new TurnCoordinator();
81
+
82
+ private session: Session | null = null;
83
+ private model: string | undefined;
84
+ private socket: WhatsAppSocket | null = null;
85
+ private storage: WhatsAppAuthStorage | null = null;
86
+ private handle: ChannelHandle | null = null;
87
+ private logUnsub: (() => void) | null = null;
88
+
89
+ private stopping = false;
90
+ private resolveRunning: (() => void) | null = null;
91
+ private rejectRunning: ((err: Error) => void) | null = null;
92
+ private reconnectAttempts = 0;
93
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
94
+
95
+ private pairMode = false;
96
+ private pairedAnnounced = false;
97
+ private qrPayload: string | null = null;
98
+ private socketOpen = false;
99
+ private ownerJid: string | null = null;
100
+ private readonly allowSet = new Set<string>();
101
+
102
+ private currentJid: string | null = null;
103
+ private lastJid: string | null = null;
104
+
105
+ /** Recent OWN outbound message ids — Baileys echoes our sends back via
106
+ * `messages.upsert`; without this the bot would converse with itself. */
107
+ private readonly sentIds = new Set<string>();
108
+
109
+ private readonly connectListeners = new Set<() => void>();
110
+ private readonly pairedListeners = new Set<(ownerJid: string) => void>();
111
+
112
+ constructor(opts: WhatsAppChannelOptions) {
113
+ this.opts = opts;
114
+ this.permission = createWhatsAppPermissionController();
115
+ }
116
+
117
+ get permissionResolver(): WhatsAppPermissionController['resolver'] {
118
+ return this.permission.resolver;
119
+ }
120
+
121
+ /** Connect value published to control surfaces (`connect.kind: 'qr'`): the
122
+ * CURRENT Baileys QR pairing payload while unlinked (it rotates; each
123
+ * rotation re-publishes via onConnectChange), null once linked. */
124
+ get requestUrl(): string | null {
125
+ return this.qrPayload;
126
+ }
127
+
128
+ /** Linked + socket open — control surfaces swap the QR for "Connected". */
129
+ get connected(): boolean {
130
+ return this.socketOpen && this.ownerJid != null;
131
+ }
132
+
133
+ /** Fires once when a fresh QR link completes (the pair flow waits on this). */
134
+ onPaired(listener: (ownerJid: string) => void): () => void {
135
+ this.pairedListeners.add(listener);
136
+ return () => this.pairedListeners.delete(listener);
137
+ }
138
+
139
+ async start(startOpts: WhatsAppStartOpts): Promise<ChannelHandle> {
140
+ if (this.handle) return this.handle;
141
+
142
+ // CONSENT GATE — defense in depth: the wizard/pair/subcommand paths check
143
+ // too, but start() is reachable headlessly (`moxxy whatsapp` with vault
144
+ // state, desktop supervisor), so the channel itself refuses un-acknowledged.
145
+ if (!(await hasConsent(this.opts.vault))) {
146
+ throw new Error(CONSENT_REQUIRED_MESSAGE);
147
+ }
148
+
149
+ this.session = startOpts.session;
150
+ this.model = startOpts.model;
151
+ this.storage =
152
+ this.opts.authStorage ?? createFileAuthStorage(moxxyPath(WHATSAPP_AUTH_DIR));
153
+
154
+ const dedicated =
155
+ startOpts.dedicated === true || process.env.MOXXY_DEDICATED_RUNNER === '1';
156
+ this.pairMode = startOpts.pair === true || dedicated;
157
+
158
+ const hadCreds = await hasStoredCreds(this.storage);
159
+ if (!hadCreds && !this.pairMode) {
160
+ throw new Error(
161
+ 'No WhatsApp account is linked yet. Run `moxxy channels whatsapp pair` to scan ' +
162
+ 'the QR, or start it from the desktop Channels panel.',
163
+ );
164
+ }
165
+ this.pairedAnnounced = hadCreds;
166
+
167
+ // Allow-list: the owner's own JID (Note to Self) is seeded on link; extra
168
+ // JIDs come from the vault and channel options. Persisted owner JID lets
169
+ // the gate work from the first inbound even before `open` resolves it.
170
+ this.ownerJid = normalizeJid(await this.opts.vault.get(WHATSAPP_OWNER_JID_KEY));
171
+ this.rebuildAllowList(
172
+ parseAllowedJids(await this.opts.vault.get(WHATSAPP_ALLOWED_JIDS_KEY)),
173
+ );
174
+
175
+ this.permission.setSender((text) => this.sendPermissionPrompt(text));
176
+
177
+ // Mirror-to-chat: post assistant prose for turns this channel did NOT
178
+ // initiate (co-attached surface) into the last chat served (invariant #8:
179
+ // own turns are filtered by turnId inside TurnCoordinator.mirrorText).
180
+ this.logUnsub = this.session.log.subscribe((event) => this.mirrorForeignTurn(event));
181
+
182
+ const running = new Promise<void>((resolve, reject) => {
183
+ this.resolveRunning = resolve;
184
+ this.rejectRunning = reject;
185
+ });
186
+
187
+ await this.connect();
188
+
189
+ this.opts.logger?.info?.('whatsapp channel starting', {
190
+ linked: hadCreds,
191
+ pairMode: this.pairMode,
192
+ });
193
+
194
+ this.handle = {
195
+ running,
196
+ onConnectChange: (listener) => {
197
+ this.connectListeners.add(listener);
198
+ return () => this.connectListeners.delete(listener);
199
+ },
200
+ stop: async (reason = 'shutdown') => {
201
+ this.teardown(reason);
202
+ this.resolveRunning?.();
203
+ this.resolveRunning = null;
204
+ this.rejectRunning = null;
205
+ },
206
+ };
207
+ return this.handle;
208
+ }
209
+
210
+ // ---- connection lifecycle -------------------------------------------------
211
+
212
+ private async connect(): Promise<void> {
213
+ if (this.stopping || !this.storage) return;
214
+ const factory = this.opts.socketFactory ?? openBaileysSocket;
215
+ const socket = await factory({
216
+ storage: this.storage,
217
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
218
+ });
219
+ this.socket = socket;
220
+ socket.onConnectionUpdate((update) => this.handleConnectionUpdate(update));
221
+ socket.onMessages((upsert) => {
222
+ for (const message of upsert.messages) {
223
+ this.dispatchInBackground(this.handleInbound(upsert.type, message), 'message');
224
+ }
225
+ });
226
+ }
227
+
228
+ private handleConnectionUpdate(update: WaConnectionUpdate): void {
229
+ if (update.qr) {
230
+ this.qrPayload = update.qr;
231
+ this.notifyConnectChange();
232
+ }
233
+ if (update.connection === 'open') {
234
+ this.reconnectAttempts = 0;
235
+ this.socketOpen = true;
236
+ this.qrPayload = null;
237
+ const owner = normalizeJid(this.socket?.userJid() ?? null);
238
+ if (owner) {
239
+ this.ownerJid = owner;
240
+ this.allowSet.add(owner);
241
+ void this.opts.vault
242
+ .set(WHATSAPP_OWNER_JID_KEY, owner, ['whatsapp'])
243
+ .catch((err: unknown) => {
244
+ this.opts.logger?.warn?.('whatsapp: could not persist owner jid', {
245
+ err: String(err),
246
+ });
247
+ });
248
+ if (!this.pairedAnnounced) {
249
+ this.pairedAnnounced = true;
250
+ this.emitPaired(owner);
251
+ void this.send(
252
+ owner,
253
+ 'Linked with moxxy. Message yourself in this chat (Note to Self) to talk to ' +
254
+ 'the agent. Commands: /cancel aborts the current turn, /new resets the session.',
255
+ );
256
+ }
257
+ }
258
+ this.notifyConnectChange();
259
+ return;
260
+ }
261
+ if (update.connection === 'close') {
262
+ this.socketOpen = false;
263
+ this.notifyConnectChange();
264
+ if (this.stopping) return;
265
+ this.handleClose(disconnectStatusCode(update.lastDisconnect?.error));
266
+ }
267
+ }
268
+
269
+ private handleClose(code: number | null): void {
270
+ if (code === WA_DISCONNECT.loggedOut) {
271
+ // The phone unlinked this device — the stored creds are dead. Clear them
272
+ // (standard Baileys practice) and either re-open a QR pairing window
273
+ // (pair/dedicated mode) or stop with re-pair guidance.
274
+ this.opts.logger?.warn?.('whatsapp: logged out by the phone — clearing local credentials');
275
+ this.pairedAnnounced = false;
276
+ void this.storage
277
+ ?.clear()
278
+ .catch(() => undefined)
279
+ .then(() => this.opts.vault.delete(WHATSAPP_OWNER_JID_KEY).catch(() => undefined))
280
+ .then(() => {
281
+ if (this.pairMode) this.scheduleReconnect(0);
282
+ else {
283
+ this.fail(
284
+ 'WhatsApp logged this device out. Run `moxxy channels whatsapp pair` to re-link ' +
285
+ '(check `moxxy channels whatsapp status`).',
286
+ );
287
+ }
288
+ });
289
+ return;
290
+ }
291
+ if (code === WA_DISCONNECT.connectionReplaced) {
292
+ this.fail(
293
+ 'Another WhatsApp Web session replaced this one (440). Stop the other client, then restart the channel.',
294
+ );
295
+ return;
296
+ }
297
+ if (code === WA_DISCONNECT.forbidden) {
298
+ this.fail(
299
+ 'WhatsApp refused the connection (403). The number may have been blocked or banned — ' +
300
+ 'this is the documented risk of the unofficial API.',
301
+ );
302
+ return;
303
+ }
304
+ // restartRequired (515) is the NORMAL post-QR-scan handoff: reconnect
305
+ // immediately. Everything else retries with exponential backoff.
306
+ if (code === WA_DISCONNECT.restartRequired) {
307
+ this.scheduleReconnect(0);
308
+ return;
309
+ }
310
+ this.reconnectAttempts += 1;
311
+ const max = this.opts.maxReconnectAttempts ?? 5;
312
+ if (this.reconnectAttempts > max) {
313
+ this.fail(`whatsapp: giving up after ${max} consecutive reconnect attempts (last code: ${code ?? 'unknown'})`);
314
+ return;
315
+ }
316
+ const base = this.opts.reconnectBaseMs ?? 2000;
317
+ this.scheduleReconnect(base * 2 ** (this.reconnectAttempts - 1));
318
+ }
319
+
320
+ private scheduleReconnect(delayMs: number): void {
321
+ if (this.stopping) return;
322
+ this.socket = null;
323
+ this.reconnectTimer = setTimeout(() => {
324
+ this.reconnectTimer = null;
325
+ void this.connect().catch((err: unknown) => {
326
+ this.fail(
327
+ `whatsapp: reconnect failed: ${err instanceof Error ? err.message : String(err)}`,
328
+ );
329
+ });
330
+ }, delayMs);
331
+ this.reconnectTimer.unref?.();
332
+ }
333
+
334
+ private fail(message: string): void {
335
+ if (this.stopping) return;
336
+ this.opts.logger?.warn?.(message);
337
+ this.teardown(message);
338
+ this.rejectRunning?.(new Error(message));
339
+ this.resolveRunning = null;
340
+ this.rejectRunning = null;
341
+ }
342
+
343
+ private teardown(reason: string): void {
344
+ this.stopping = true;
345
+ if (this.reconnectTimer) {
346
+ clearTimeout(this.reconnectTimer);
347
+ this.reconnectTimer = null;
348
+ }
349
+ // Abort the in-flight turn FIRST so the model loop stops spending the
350
+ // moment the operator asks to shut down; then deny pending permission
351
+ // prompts so no caller hangs (the audit's TuiChannel.stop lesson).
352
+ this.turns.abort(reason);
353
+ this.permission.abortAll(reason);
354
+ this.permission.setSender(null);
355
+ this.logUnsub?.();
356
+ this.logUnsub = null;
357
+ this.qrPayload = null;
358
+ this.socketOpen = false;
359
+ this.socket?.end();
360
+ this.socket = null;
361
+ }
362
+
363
+ // ---- inbound --------------------------------------------------------------
364
+
365
+ private async handleInbound(upsertType: string, raw: WaInboundMessage): Promise<void> {
366
+ const verdict: GateVerdict = gateInboundMessage(
367
+ {
368
+ ownJid: this.ownerJid,
369
+ allowedJids: this.allowSet,
370
+ isOwnSend: (id) => this.sentIds.has(id),
371
+ },
372
+ upsertType,
373
+ raw,
374
+ );
375
+ if (!verdict.ok) {
376
+ // Silent drop (no reply): replying to unauthorized senders would leak
377
+ // the bot's existence and look like spam — both ban vectors.
378
+ this.opts.logger?.debug?.('whatsapp: dropped inbound', { reason: verdict.reason });
379
+ return;
380
+ }
381
+
382
+ if (verdict.kind === 'text') {
383
+ await this.handleText(verdict.jid, verdict.text);
384
+ return;
385
+ }
386
+ await this.handleAudio(verdict.jid, verdict.mimeType, raw);
387
+ }
388
+
389
+ private async handleText(jid: string, text: string): Promise<void> {
390
+ // A pending permission prompt captures the next short reply — BEFORE the
391
+ // busy guard, because prompts happen mid-turn by construction.
392
+ if (this.permission.hasPending() && this.permission.offerReply(text)) return;
393
+
394
+ if (text === '/cancel') {
395
+ const controller = this.turns.controller;
396
+ if (controller && !controller.signal.aborted) {
397
+ controller.abort('user cancel');
398
+ await this.send(jid, 'cancelling current turn…');
399
+ } else {
400
+ await this.send(jid, 'nothing to cancel.');
401
+ }
402
+ return;
403
+ }
404
+ if (text === '/new') {
405
+ await this.resetSession(jid);
406
+ return;
407
+ }
408
+ if (this.turns.busy) {
409
+ await this.send(jid, 'I am still working on the previous prompt. Send /cancel to abort it.');
410
+ return;
411
+ }
412
+ await this.runUserTurn(jid, text);
413
+ }
414
+
415
+ private async handleAudio(
416
+ jid: string,
417
+ mimeType: string,
418
+ raw: WaInboundMessage,
419
+ ): Promise<void> {
420
+ if (!this.session || !this.socket) return;
421
+ if (this.turns.busy) {
422
+ await this.send(jid, 'I am still working on the previous prompt. Send /cancel to abort it.');
423
+ return;
424
+ }
425
+ const transcript = await transcribeVoiceMessage(
426
+ {
427
+ session: this.session,
428
+ socket: this.socket,
429
+ reply: (j, t) => this.send(j, t),
430
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
431
+ },
432
+ { jid, mimeType, raw },
433
+ );
434
+ if (transcript) await this.runUserTurn(jid, transcript);
435
+ }
436
+
437
+ private async resetSession(jid: string): Promise<void> {
438
+ if (!this.session) return;
439
+ const controller = this.turns.controller;
440
+ if (controller && !controller.signal.aborted) controller.abort('user reset');
441
+ this.permission.abortAll('session reset');
442
+ // Wipe history at its source; a mirror-only clear would desync (A10).
443
+ try {
444
+ if (typeof this.session.reset === 'function') await this.session.reset();
445
+ else this.session.log.clear();
446
+ } catch (err) {
447
+ await this.send(
448
+ jid,
449
+ `/new failed: ${err instanceof Error ? err.message : String(err)} — history NOT cleared`,
450
+ );
451
+ return;
452
+ }
453
+ await this.send(jid, 'new session — conversation history cleared');
454
+ }
455
+
456
+ private async runUserTurn(jid: string, text: string): Promise<void> {
457
+ if (!this.session || !this.socket) return;
458
+ // Atomic single-flight: `begin` claims the slot synchronously so two
459
+ // concurrently dispatched messages can't interleave per-turn state. The
460
+ // turnId is minted here so the coordinator records it as an own-turn id
461
+ // (mirrorForeignTurn filters on those, invariant #8).
462
+ const lease = this.turns.begin(newTurnId());
463
+ if (!lease) {
464
+ await this.send(jid, 'I am still working on the previous prompt. Send /cancel to abort it.');
465
+ return;
466
+ }
467
+ this.currentJid = jid;
468
+ this.lastJid = jid;
469
+ try {
470
+ await runWhatsAppTurn(
471
+ {
472
+ session: this.session,
473
+ socket: this.socket,
474
+ editFrameMs: this.opts.editFrameMs ?? DEFAULT_EDIT_FRAME_MS,
475
+ recordSentId: (key) => this.recordSentId(key),
476
+ ...(this.opts.logger ? { logger: this.opts.logger } : {}),
477
+ },
478
+ {
479
+ jid,
480
+ text,
481
+ model: this.model,
482
+ controller: lease.controller,
483
+ turnId: lease.turnId,
484
+ },
485
+ );
486
+ } finally {
487
+ lease.end();
488
+ this.currentJid = null;
489
+ }
490
+ }
491
+
492
+ // ---- outbound -------------------------------------------------------------
493
+
494
+ /** Every outbound send goes through here so its id lands in the echo set. */
495
+ private async send(jid: string, text: string): Promise<void> {
496
+ if (!this.socket) return;
497
+ try {
498
+ const sent = await this.socket.sendText(jid, text);
499
+ this.recordSentId(sent?.key);
500
+ } catch (err) {
501
+ this.opts.logger?.warn?.('whatsapp send failed', { err: String(err) });
502
+ }
503
+ }
504
+
505
+ private sendPermissionPrompt(text: string): Promise<void> {
506
+ const target = this.currentJid ?? this.lastJid ?? this.ownerJid;
507
+ if (!target) return Promise.reject(new Error('no chat to prompt in'));
508
+ return this.send(target, text);
509
+ }
510
+
511
+ private recordSentId(key: WaMessageKey | null | undefined): void {
512
+ const id = key?.id;
513
+ if (!id) return;
514
+ this.sentIds.add(id);
515
+ if (this.sentIds.size > MAX_SENT_IDS) {
516
+ const oldest = this.sentIds.values().next().value;
517
+ if (oldest !== undefined) this.sentIds.delete(oldest);
518
+ }
519
+ }
520
+
521
+ private mirrorForeignTurn(event: MoxxyEvent): void {
522
+ const text = this.turns.mirrorText(event);
523
+ if (text == null) return;
524
+ const target = this.lastJid ?? this.ownerJid;
525
+ if (!target) return;
526
+ void this.send(target, text);
527
+ }
528
+
529
+ // ---- listeners ------------------------------------------------------------
530
+
531
+ private notifyConnectChange(): void {
532
+ for (const listener of this.connectListeners) {
533
+ try {
534
+ listener();
535
+ } catch (err) {
536
+ this.opts.logger?.warn?.('whatsapp connect-change listener threw', { err: String(err) });
537
+ }
538
+ }
539
+ }
540
+
541
+ private emitPaired(ownerJid: string): void {
542
+ for (const listener of this.pairedListeners) {
543
+ try {
544
+ listener(ownerJid);
545
+ } catch (err) {
546
+ this.opts.logger?.warn?.('whatsapp paired listener threw', { err: String(err) });
547
+ }
548
+ }
549
+ }
550
+
551
+ private rebuildAllowList(extra: ReadonlyArray<string>): void {
552
+ this.allowSet.clear();
553
+ if (this.ownerJid) this.allowSet.add(this.ownerJid);
554
+ for (const jid of this.opts.allowedJids ?? []) {
555
+ const normalized = normalizeJid(jid);
556
+ if (normalized) this.allowSet.add(normalized);
557
+ }
558
+ for (const jid of extra) this.allowSet.add(jid);
559
+ }
560
+
561
+ /**
562
+ * Run a handler detached from the socket's event dispatch (a whole user turn
563
+ * must not block Baileys' event loop). Errors are logged here — nothing above
564
+ * awaits the promise.
565
+ */
566
+ private dispatchInBackground(work: Promise<void>, kind: string): void {
567
+ void work.catch((err: unknown) => {
568
+ this.opts.logger?.warn?.('whatsapp handler failed', { kind, err: String(err) });
569
+ });
570
+ }
571
+ }
@@ -0,0 +1,28 @@
1
+ import { isCancel, log, note, text } from '@clack/prompts';
2
+ import { CONSENT_WARNING, hasConsent, recordConsent, type ConsentVault } from './consent.js';
3
+
4
+ /**
5
+ * The interactive consent gate — the FIRST step of every setup surface. Shows
6
+ * the unofficial-API warning and requires a literally typed "yes" before
7
+ * anything else may happen; anything less refuses. Returns true only when the
8
+ * risk is (or already was) acknowledged.
9
+ */
10
+ export async function ensureConsentInteractive(vault: ConsentVault): Promise<boolean> {
11
+ if (await hasConsent(vault)) {
12
+ // Already acknowledged — keep the reminder visible but don't re-demand typing.
13
+ log.warn('Reminder: unofficial WhatsApp API — the linked number can be banned.');
14
+ return true;
15
+ }
16
+ note(CONSENT_WARNING, 'READ THIS FIRST');
17
+ const answer = await text({
18
+ message: "Type 'yes' to acknowledge the risk and continue (anything else aborts)",
19
+ placeholder: 'yes',
20
+ });
21
+ if (isCancel(answer) || String(answer).trim().toLowerCase() !== 'yes') {
22
+ log.error('Not acknowledged — leaving the WhatsApp channel disarmed.');
23
+ return false;
24
+ }
25
+ await recordConsent(vault);
26
+ log.success('Acknowledgment recorded.');
27
+ return true;
28
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { WHATSAPP_CONSENT_ENV, WHATSAPP_CONSENT_KEY } from './keys.js';
3
+ import { hasConsent, recordConsent, type ConsentVault } from './consent.js';
4
+
5
+ function fakeVault(initial: Record<string, string> = {}): ConsentVault & {
6
+ store: Record<string, string>;
7
+ } {
8
+ const store = { ...initial };
9
+ return {
10
+ store,
11
+ async get(name) {
12
+ return store[name] ?? null;
13
+ },
14
+ async set(name, value) {
15
+ store[name] = value;
16
+ },
17
+ };
18
+ }
19
+
20
+ afterEach(() => {
21
+ delete process.env[WHATSAPP_CONSENT_ENV];
22
+ });
23
+
24
+ describe('consent gate', () => {
25
+ it('is false with no receipt and no env override', async () => {
26
+ expect(await hasConsent(fakeVault())).toBe(false);
27
+ expect(await hasConsent(undefined)).toBe(false);
28
+ });
29
+
30
+ it('honors the env override even without a vault', async () => {
31
+ process.env[WHATSAPP_CONSENT_ENV] = 'yes';
32
+ expect(await hasConsent(undefined)).toBe(true);
33
+ });
34
+
35
+ it('does NOT accept a "no" env value', async () => {
36
+ process.env[WHATSAPP_CONSENT_ENV] = 'no';
37
+ expect(await hasConsent(fakeVault())).toBe(false);
38
+ });
39
+
40
+ it('accepts a recorded vault receipt', async () => {
41
+ const vault = fakeVault();
42
+ await recordConsent(vault);
43
+ expect(vault.store[WHATSAPP_CONSENT_KEY]).toMatch(/^acknowledged@/);
44
+ expect(await hasConsent(vault)).toBe(true);
45
+ });
46
+
47
+ it('treats a vault read error as no consent', async () => {
48
+ const vault: ConsentVault = {
49
+ async get() {
50
+ throw new Error('vault locked');
51
+ },
52
+ async set() {},
53
+ };
54
+ expect(await hasConsent(vault)).toBe(false);
55
+ });
56
+ });
package/src/consent.ts ADDED
@@ -0,0 +1,48 @@
1
+ import { WHATSAPP_CONSENT_ENV, WHATSAPP_CONSENT_KEY, isConsentValue } from './keys.js';
2
+
3
+ /** The read/write vault slice the consent gate needs. */
4
+ export interface ConsentVault {
5
+ get(name: string): Promise<string | null>;
6
+ set(name: string, value: string, tags?: ReadonlyArray<string>): Promise<void>;
7
+ }
8
+
9
+ /**
10
+ * The non-negotiable warning shown as the FIRST step of every setup path.
11
+ * Baileys speaks the WhatsApp Web protocol without WhatsApp's blessing;
12
+ * users must opt in to that risk explicitly before anything else happens.
13
+ */
14
+ export const CONSENT_WARNING =
15
+ 'This channel uses Baileys, an UNOFFICIAL WhatsApp Web client.\n' +
16
+ '\n' +
17
+ ' - Automating an account this way violates WhatsApp\'s Terms of Service.\n' +
18
+ ' - WhatsApp actively detects unofficial clients and CAN PERMANENTLY BAN\n' +
19
+ ' the phone number — without warning and without appeal.\n' +
20
+ ' - Strongly consider linking a SECONDARY number, not your personal one.\n' +
21
+ '\n' +
22
+ 'moxxy cannot make this safe; it can only make it explicit.';
23
+
24
+ /** One-line refusal used by every path that hits the gate un-acknowledged. */
25
+ export const CONSENT_REQUIRED_MESSAGE =
26
+ 'WhatsApp channel not acknowledged: it relies on an unofficial API that violates ' +
27
+ "WhatsApp's ToS and can get the number banned. Run `moxxy whatsapp setup` and type " +
28
+ `'yes' to accept that risk (or set ${WHATSAPP_CONSENT_ENV}=yes for headless runs).`;
29
+
30
+ /**
31
+ * Whether the operator has acknowledged the ToS/ban risk. Env override first
32
+ * (headless/dedicated runners), then the vault receipt written by the wizard or
33
+ * the desktop config panel. Vault errors (locked/unavailable) count as "no".
34
+ */
35
+ export async function hasConsent(vault: ConsentVault | undefined): Promise<boolean> {
36
+ if (isConsentValue(process.env[WHATSAPP_CONSENT_ENV])) return true;
37
+ if (!vault) return false;
38
+ try {
39
+ return isConsentValue(await vault.get(WHATSAPP_CONSENT_KEY));
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Persist the wizard's typed acknowledgment as a dated receipt. */
46
+ export async function recordConsent(vault: ConsentVault): Promise<void> {
47
+ await vault.set(WHATSAPP_CONSENT_KEY, `acknowledged@${new Date().toISOString()}`, ['whatsapp']);
48
+ }