@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
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { disconnectStatusCode, WA_DISCONNECT } from './socket.js';
3
+
4
+ describe('disconnectStatusCode', () => {
5
+ it('extracts a Boom-style statusCode', () => {
6
+ expect(disconnectStatusCode({ output: { statusCode: WA_DISCONNECT.loggedOut } })).toBe(401);
7
+ expect(disconnectStatusCode({ output: { statusCode: 515 } })).toBe(515);
8
+ });
9
+
10
+ it('returns null when no code is present', () => {
11
+ expect(disconnectStatusCode(undefined)).toBeNull();
12
+ expect(disconnectStatusCode(null)).toBeNull();
13
+ expect(disconnectStatusCode(new Error('boom'))).toBeNull();
14
+ expect(disconnectStatusCode({ output: {} })).toBeNull();
15
+ expect(disconnectStatusCode({ output: { statusCode: 'nope' } })).toBeNull();
16
+ });
17
+ });
package/src/socket.ts ADDED
@@ -0,0 +1,89 @@
1
+ import type { WhatsAppAuthStorage } from './auth-state.js';
2
+
3
+ /**
4
+ * The narrow transport contract the channel is written against — the slice of a
5
+ * Baileys socket it actually touches, adapted in `baileys-socket.ts`. Tests
6
+ * inject a fake implementing THIS, so no test ever opens a WhatsApp connection
7
+ * (and the heavyweight Baileys import stays out of unit tests entirely).
8
+ */
9
+
10
+ export interface WaMessageKey {
11
+ readonly remoteJid?: string | null;
12
+ readonly fromMe?: boolean | null;
13
+ readonly id?: string | null;
14
+ readonly participant?: string | null;
15
+ }
16
+
17
+ /** The consumed subset of a Baileys `WAMessage` (validated in message-gate). */
18
+ export interface WaInboundMessage {
19
+ readonly key?: WaMessageKey | null;
20
+ readonly message?: Record<string, unknown> | null;
21
+ }
22
+
23
+ export interface WaMessagesUpsert {
24
+ /** 'notify' = fresh inbound; 'append'/'history' = sync artifacts (dropped). */
25
+ readonly type: string;
26
+ readonly messages: ReadonlyArray<WaInboundMessage>;
27
+ }
28
+
29
+ export interface WaConnectionUpdate {
30
+ readonly connection?: 'close' | 'connecting' | 'open' | undefined;
31
+ /** Fresh QR pairing payload — rotates every ~20-60s while unlinked. */
32
+ readonly qr?: string | undefined;
33
+ readonly lastDisconnect?: { readonly error?: unknown } | undefined;
34
+ }
35
+
36
+ export interface WaSentMessage {
37
+ readonly key: WaMessageKey;
38
+ }
39
+
40
+ export interface WhatsAppSocket {
41
+ /** The linked account's raw JID once the connection is open; null before. */
42
+ userJid(): string | null;
43
+ onConnectionUpdate(cb: (update: WaConnectionUpdate) => void): void;
44
+ onMessages(cb: (upsert: WaMessagesUpsert) => void): void;
45
+ sendText(jid: string, text: string): Promise<WaSentMessage | null>;
46
+ /** Edit a previously sent message in place (WhatsApp MESSAGE_EDIT). */
47
+ editText(jid: string, key: WaMessageKey, text: string): Promise<void>;
48
+ /** Download a media message's bytes (voice notes). */
49
+ downloadMedia(message: WaInboundMessage): Promise<Uint8Array>;
50
+ /** Tear the connection down WITHOUT logging out (creds stay valid). */
51
+ end(): void;
52
+ }
53
+
54
+ export interface WhatsAppSocketLogger {
55
+ debug?(msg: string, meta?: Record<string, unknown>): void;
56
+ info?(msg: string, meta?: Record<string, unknown>): void;
57
+ warn?(msg: string, meta?: Record<string, unknown>): void;
58
+ }
59
+
60
+ export interface WhatsAppSocketFactoryOptions {
61
+ readonly storage: WhatsAppAuthStorage;
62
+ readonly logger?: WhatsAppSocketLogger;
63
+ }
64
+
65
+ /** Opens one connection attempt; the channel owns the reconnect loop. */
66
+ export type WhatsAppSocketFactory = (
67
+ opts: WhatsAppSocketFactoryOptions,
68
+ ) => Promise<WhatsAppSocket>;
69
+
70
+ /**
71
+ * Extract the WhatsApp disconnect status code from a close error (Baileys
72
+ * throws Boom errors: `err.output.statusCode`). Pure so the reconnect policy is
73
+ * unit-testable. Returns null when no code can be found (treated as retriable).
74
+ */
75
+ export function disconnectStatusCode(err: unknown): number | null {
76
+ if (typeof err !== 'object' || err === null) return null;
77
+ const output = (err as { output?: unknown }).output;
78
+ if (typeof output !== 'object' || output === null) return null;
79
+ const code = (output as { statusCode?: unknown }).statusCode;
80
+ return typeof code === 'number' && Number.isFinite(code) ? code : null;
81
+ }
82
+
83
+ /** WhatsApp disconnect reasons the channel branches on (Baileys' DisconnectReason). */
84
+ export const WA_DISCONNECT = {
85
+ loggedOut: 401,
86
+ forbidden: 403,
87
+ connectionReplaced: 440,
88
+ restartRequired: 515,
89
+ } as const;
@@ -0,0 +1,198 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
6
+ import type { ChannelDef } from '@moxxy/sdk';
7
+ import { buildWhatsAppPlugin } from './index.js';
8
+ import {
9
+ WHATSAPP_ALLOWED_JIDS_KEY,
10
+ WHATSAPP_CONSENT_ENV,
11
+ WHATSAPP_CONSENT_KEY,
12
+ WHATSAPP_OWNER_JID_KEY,
13
+ } from './keys.js';
14
+
15
+ let tmp: string;
16
+ let vault: VaultStore;
17
+ let def: ChannelDef;
18
+ let writeOut: string[];
19
+ let writeErr: string[];
20
+ let origOut: typeof process.stdout.write;
21
+ let origErr: typeof process.stderr.write;
22
+ let origHome: string | undefined;
23
+
24
+ beforeEach(async () => {
25
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-wa-sub-'));
26
+ // Point the moxxy home at the tmp dir so `hasStoredCreds` reads an empty dir.
27
+ origHome = process.env.MOXXY_HOME;
28
+ process.env.MOXXY_HOME = tmp;
29
+ vault = new VaultStore({
30
+ filePath: path.join(tmp, 'vault.json'),
31
+ keySource: createStaticKeySource(deriveKey('test', generateSalt())),
32
+ });
33
+ def = buildWhatsAppPlugin({ vault }).channels![0]!;
34
+ writeOut = [];
35
+ writeErr = [];
36
+ origOut = process.stdout.write.bind(process.stdout);
37
+ origErr = process.stderr.write.bind(process.stderr);
38
+ process.stdout.write = ((c: string | Uint8Array) => {
39
+ writeOut.push(typeof c === 'string' ? c : Buffer.from(c).toString());
40
+ return true;
41
+ }) as typeof process.stdout.write;
42
+ process.stderr.write = ((c: string | Uint8Array) => {
43
+ writeErr.push(typeof c === 'string' ? c : Buffer.from(c).toString());
44
+ return true;
45
+ }) as typeof process.stderr.write;
46
+ });
47
+
48
+ afterEach(async () => {
49
+ await fs.rm(tmp, { recursive: true, force: true });
50
+ process.stdout.write = origOut;
51
+ process.stderr.write = origErr;
52
+ if (origHome === undefined) delete process.env.MOXXY_HOME;
53
+ else process.env.MOXXY_HOME = origHome;
54
+ delete process.env[WHATSAPP_CONSENT_ENV];
55
+ });
56
+
57
+ function ctx(overrides: { startChannel?: () => Promise<number> } = {}) {
58
+ return {
59
+ deps: { cwd: tmp, vault, logger: undefined, options: {} },
60
+ args: { positional: [], flags: {} },
61
+ startChannel: overrides.startChannel ?? (async () => 0),
62
+ session: { setPermissionResolver: () => {} },
63
+ } as never;
64
+ }
65
+
66
+ describe('whatsapp channel def', () => {
67
+ it('declares dedicatedRunner + whatsapp session source + qr connect', () => {
68
+ expect(def.name).toBe('whatsapp');
69
+ expect(def.dedicatedRunner).toBe(true);
70
+ expect(def.sessionSource).toBe('whatsapp');
71
+ expect(def.config?.connect?.kind).toBe('qr');
72
+ expect(def.config?.hasRequestUrl).toBe(false);
73
+ expect(def.interactiveCommand).toBe('setup');
74
+ });
75
+
76
+ it('exposes setup, pair, status, unpair subcommands', () => {
77
+ expect(Object.keys(def.subcommands!)).toEqual(
78
+ expect.arrayContaining(['setup', 'pair', 'status', 'unpair']),
79
+ );
80
+ });
81
+
82
+ it('description carries the unofficial-API warning', () => {
83
+ expect(def.description).toMatch(/UNOFFICIAL/i);
84
+ expect(def.description).toMatch(/ban/i);
85
+ });
86
+ });
87
+
88
+ describe('isAvailable (consent gate + link state)', () => {
89
+ it('is unavailable without consent, naming the ToS risk', async () => {
90
+ const avail = await def.isAvailable!({ cwd: tmp, vault });
91
+ expect(avail.ok).toBe(false);
92
+ expect(avail.reason).toMatch(/ToS|acknowledg|ban/i);
93
+ });
94
+
95
+ it('is unavailable-but-needs-pairing once consent is given', async () => {
96
+ process.env[WHATSAPP_CONSENT_ENV] = 'yes';
97
+ const avail = await def.isAvailable!({ cwd: tmp, vault });
98
+ expect(avail.ok).toBe(false);
99
+ expect(avail.reason).toMatch(/pair/i);
100
+ });
101
+ });
102
+
103
+ describe('status subcommand', () => {
104
+ it('reports needs-consent when nothing configured', async () => {
105
+ const code = await def.subcommands!.status!.run(ctx());
106
+ expect(code).toBe(0);
107
+ const parsed = JSON.parse(writeOut.join(''));
108
+ expect(parsed.state).toBe('needs-consent');
109
+ expect(parsed.consentAcknowledged).toBe(false);
110
+ expect(parsed.linked).toBe(false);
111
+ });
112
+
113
+ it('reports needs-pairing after consent but before linking', async () => {
114
+ await vault.set(WHATSAPP_CONSENT_KEY, 'acknowledged@2026-01-01T00:00:00.000Z');
115
+ const code = await def.subcommands!.status!.run(ctx());
116
+ expect(code).toBe(0);
117
+ const parsed = JSON.parse(writeOut.join(''));
118
+ expect(parsed.state).toBe('needs-pairing');
119
+ expect(parsed.guidance).toMatch(/pair/i);
120
+ });
121
+
122
+ it('surfaces stored allow-list + owner jid', async () => {
123
+ await vault.set(WHATSAPP_CONSENT_KEY, 'yes');
124
+ await vault.set(WHATSAPP_OWNER_JID_KEY, '15550000000@s.whatsapp.net');
125
+ await vault.set(WHATSAPP_ALLOWED_JIDS_KEY, JSON.stringify(['15551111111@s.whatsapp.net']));
126
+ const code = await def.subcommands!.status!.run(ctx());
127
+ expect(code).toBe(0);
128
+ const parsed = JSON.parse(writeOut.join(''));
129
+ expect(parsed.ownerJid).toBe('15550000000@s.whatsapp.net');
130
+ expect(parsed.allowedJids).toEqual(['15551111111@s.whatsapp.net']);
131
+ });
132
+
133
+ it('returns 1 when the vault is unavailable', async () => {
134
+ const badCtx = {
135
+ deps: { cwd: tmp, vault: undefined, logger: undefined, options: {} },
136
+ args: { positional: [], flags: {} },
137
+ startChannel: async () => 0,
138
+ session: { setPermissionResolver: () => {} },
139
+ } as never;
140
+ const code = await def.subcommands!.status!.run(badCtx);
141
+ expect(code).toBe(1);
142
+ expect(writeErr.join('')).toContain('vault unavailable');
143
+ });
144
+ });
145
+
146
+ describe('unpair subcommand', () => {
147
+ it('is a no-op when nothing is linked', async () => {
148
+ const code = await def.subcommands!.unpair!.run(ctx());
149
+ expect(code).toBe(0);
150
+ expect(writeOut.join('')).toContain('no linked account');
151
+ });
152
+ });
153
+
154
+ describe('pair subcommand', () => {
155
+ it('refuses without a TTY (QR needs a terminal)', async () => {
156
+ const original = process.stdin.isTTY;
157
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
158
+ try {
159
+ const startChannel = vi.fn(async () => 0);
160
+ const code = await def.subcommands!.pair!.run(ctx({ startChannel }));
161
+ expect(code).toBe(1);
162
+ expect(startChannel).not.toHaveBeenCalled();
163
+ expect(writeErr.join('')).toMatch(/TTY/);
164
+ } finally {
165
+ Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true });
166
+ }
167
+ });
168
+ });
169
+
170
+ describe('setup subcommand (headless)', () => {
171
+ it('refuses to start headless without consent', async () => {
172
+ const original = process.stdin.isTTY;
173
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
174
+ try {
175
+ const startChannel = vi.fn(async () => 0);
176
+ const code = await def.subcommands!.setup!.run(ctx({ startChannel }));
177
+ expect(code).toBe(1);
178
+ expect(startChannel).not.toHaveBeenCalled();
179
+ expect(writeErr.join('')).toMatch(/acknowledg|ToS|ban/i);
180
+ } finally {
181
+ Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true });
182
+ }
183
+ });
184
+
185
+ it('starts the channel headless once consent is acknowledged', async () => {
186
+ process.env[WHATSAPP_CONSENT_ENV] = 'yes';
187
+ const original = process.stdin.isTTY;
188
+ Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
189
+ try {
190
+ const startChannel = vi.fn(async () => 0);
191
+ const code = await def.subcommands!.setup!.run(ctx({ startChannel }));
192
+ expect(code).toBe(0);
193
+ expect(startChannel).toHaveBeenCalledTimes(1);
194
+ } finally {
195
+ Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true });
196
+ }
197
+ });
198
+ });