@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.
- package/LICENSE +21 -0
- package/dist/auth-state.d.ts +66 -0
- package/dist/auth-state.d.ts.map +1 -0
- package/dist/auth-state.js +99 -0
- package/dist/auth-state.js.map +1 -0
- package/dist/baileys-socket.d.ts +10 -0
- package/dist/baileys-socket.d.ts.map +1 -0
- package/dist/baileys-socket.js +93 -0
- package/dist/baileys-socket.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +58 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +130 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel/voice-handler.d.ts +27 -0
- package/dist/channel/voice-handler.d.ts.map +1 -0
- package/dist/channel/voice-handler.js +55 -0
- package/dist/channel/voice-handler.js.map +1 -0
- package/dist/channel.d.ts +105 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +459 -0
- package/dist/channel.js.map +1 -0
- package/dist/consent-prompt.d.ts +9 -0
- package/dist/consent-prompt.d.ts.map +1 -0
- package/dist/consent-prompt.js +28 -0
- package/dist/consent-prompt.js.map +1 -0
- package/dist/consent.d.ts +22 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/consent.js +40 -0
- package/dist/consent.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +211 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +39 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +86 -0
- package/dist/keys.js.map +1 -0
- package/dist/message-gate.d.ts +50 -0
- package/dist/message-gate.d.ts.map +1 -0
- package/dist/message-gate.js +150 -0
- package/dist/message-gate.js.map +1 -0
- package/dist/pair-flow.d.ts +12 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +120 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +29 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +78 -0
- package/dist/permission.js.map +1 -0
- package/dist/setup-wizard.d.ts +13 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +127 -0
- package/dist/setup-wizard.js.map +1 -0
- package/dist/socket.d.ts +72 -0
- package/dist/socket.d.ts.map +1 -0
- package/dist/socket.js +22 -0
- package/dist/socket.js.map +1 -0
- package/package.json +99 -0
- package/src/auth-state.test.ts +103 -0
- package/src/auth-state.ts +161 -0
- package/src/baileys-socket.ts +154 -0
- package/src/channel/turn-runner.test.ts +31 -0
- package/src/channel/turn-runner.ts +156 -0
- package/src/channel/voice-handler.ts +83 -0
- package/src/channel.test.ts +344 -0
- package/src/channel.ts +571 -0
- package/src/consent-prompt.ts +28 -0
- package/src/consent.test.ts +56 -0
- package/src/consent.ts +48 -0
- package/src/index.ts +284 -0
- package/src/keys.test.ts +59 -0
- package/src/keys.ts +78 -0
- package/src/message-gate.test.ts +121 -0
- package/src/message-gate.ts +184 -0
- package/src/pair-flow.ts +133 -0
- package/src/permission.test.ts +94 -0
- package/src/permission.ts +114 -0
- package/src/setup-wizard.ts +162 -0
- package/src/socket.test.ts +17 -0
- package/src/socket.ts +89 -0
- package/src/subcommands.test.ts +198 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { defineChannel, definePlugin, type LifecycleHooks, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
3
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
4
|
+
import { createFileAuthStorage, hasStoredCreds } from './auth-state.js';
|
|
5
|
+
import { WhatsAppChannel, type WhatsAppChannelOptions } from './channel.js';
|
|
6
|
+
import { CONSENT_REQUIRED_MESSAGE, hasConsent } from './consent.js';
|
|
7
|
+
import {
|
|
8
|
+
WHATSAPP_ALLOWED_JIDS_KEY,
|
|
9
|
+
WHATSAPP_AUTH_DIR,
|
|
10
|
+
WHATSAPP_CONSENT_KEY,
|
|
11
|
+
WHATSAPP_OWNER_JID_KEY,
|
|
12
|
+
parseAllowedJids,
|
|
13
|
+
} from './keys.js';
|
|
14
|
+
import { runWhatsAppWizard, unpairLocal } from './setup-wizard.js';
|
|
15
|
+
import { runWhatsAppPairFlow } from './pair-flow.js';
|
|
16
|
+
|
|
17
|
+
export { WhatsAppChannel, type WhatsAppChannelOptions, type WhatsAppStartOpts } from './channel.js';
|
|
18
|
+
export {
|
|
19
|
+
createWhatsAppPermissionController,
|
|
20
|
+
parsePermissionReply,
|
|
21
|
+
formatPermissionPrompt,
|
|
22
|
+
type WhatsAppPermissionController,
|
|
23
|
+
} from './permission.js';
|
|
24
|
+
export {
|
|
25
|
+
createFileAuthStorage,
|
|
26
|
+
createWhatsAppAuthState,
|
|
27
|
+
hasStoredCreds,
|
|
28
|
+
sanitizeAuthKey,
|
|
29
|
+
type WhatsAppAuthStorage,
|
|
30
|
+
type BaileysAuthBridge,
|
|
31
|
+
} from './auth-state.js';
|
|
32
|
+
export {
|
|
33
|
+
gateInboundMessage,
|
|
34
|
+
MAX_AUDIO_BYTES,
|
|
35
|
+
MAX_TEXT_CHARS,
|
|
36
|
+
type GateVerdict,
|
|
37
|
+
} from './message-gate.js';
|
|
38
|
+
export {
|
|
39
|
+
runWhatsAppTurn,
|
|
40
|
+
splitWhatsAppText,
|
|
41
|
+
WHATSAPP_MAX_MESSAGE_CHARS,
|
|
42
|
+
} from './channel/turn-runner.js';
|
|
43
|
+
export { CONSENT_WARNING, CONSENT_REQUIRED_MESSAGE, hasConsent, recordConsent } from './consent.js';
|
|
44
|
+
export {
|
|
45
|
+
WHATSAPP_CONSENT_KEY,
|
|
46
|
+
WHATSAPP_OWNER_JID_KEY,
|
|
47
|
+
WHATSAPP_ALLOWED_JIDS_KEY,
|
|
48
|
+
WHATSAPP_CONSENT_ENV,
|
|
49
|
+
WHATSAPP_AUTH_DIR,
|
|
50
|
+
normalizeJid,
|
|
51
|
+
parseAllowedJids,
|
|
52
|
+
isConsentValue,
|
|
53
|
+
} from './keys.js';
|
|
54
|
+
export {
|
|
55
|
+
disconnectStatusCode,
|
|
56
|
+
WA_DISCONNECT,
|
|
57
|
+
type WhatsAppSocket,
|
|
58
|
+
type WhatsAppSocketFactory,
|
|
59
|
+
} from './socket.js';
|
|
60
|
+
|
|
61
|
+
export interface BuildWhatsAppPluginOptions {
|
|
62
|
+
/** Host-injected encrypted secret store (available immediately). */
|
|
63
|
+
readonly vault: VaultStore;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build the WhatsApp channel plugin with a host-injected vault (mirrors the
|
|
67
|
+
* Telegram/Slack plugins' dual-export pattern). */
|
|
68
|
+
export function buildWhatsAppPlugin(opts: BuildWhatsAppPluginOptions): Plugin {
|
|
69
|
+
return makeWhatsAppPlugin(() => opts.vault);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Discovery-loadable default export: resolves the vault from the inter-plugin
|
|
74
|
+
* service registry in `onInit` (the vault plugin publishes `'vault'`). Requires
|
|
75
|
+
* `@moxxy/plugin-vault` to load first (declared in `package.json`
|
|
76
|
+
* `moxxy.requirements`).
|
|
77
|
+
*/
|
|
78
|
+
export const whatsappPlugin: Plugin = (() => {
|
|
79
|
+
let resolved: VaultStore | null = null;
|
|
80
|
+
const getVault = (): VaultStore => {
|
|
81
|
+
if (!resolved) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
'@moxxy/plugin-channel-whatsapp: the "vault" service is unavailable — @moxxy/plugin-vault must load first',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return resolved;
|
|
87
|
+
};
|
|
88
|
+
const hooks: LifecycleHooks = {
|
|
89
|
+
onInit: (ctx) => {
|
|
90
|
+
resolved = ctx.services.require<VaultStore>('vault');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
return makeWhatsAppPlugin(getVault, hooks);
|
|
94
|
+
})();
|
|
95
|
+
|
|
96
|
+
export default whatsappPlugin;
|
|
97
|
+
|
|
98
|
+
function readAllowedJids(options: Record<string, unknown> | undefined): string[] | undefined {
|
|
99
|
+
const raw = options?.['allowedJids'];
|
|
100
|
+
if (Array.isArray(raw)) return raw.map((x) => String(x));
|
|
101
|
+
if (typeof raw === 'string' && raw.trim().length > 0) return parseAllowedJids(raw);
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function makeWhatsAppPlugin(getVault: () => VaultStore, hooks?: LifecycleHooks): Plugin {
|
|
106
|
+
return definePlugin({
|
|
107
|
+
name: '@moxxy/plugin-channel-whatsapp',
|
|
108
|
+
version: '0.0.0',
|
|
109
|
+
...(hooks ? { hooks } : {}),
|
|
110
|
+
channels: [
|
|
111
|
+
defineChannel({
|
|
112
|
+
name: 'whatsapp',
|
|
113
|
+
description:
|
|
114
|
+
'WhatsApp channel via Baileys — UNOFFICIAL API (violates WhatsApp ToS; the number ' +
|
|
115
|
+
'can be banned — use a secondary number). QR device-link pairing, JID allow-list.',
|
|
116
|
+
// Like Slack/Telegram: its own dedicated, isolated runner (separate
|
|
117
|
+
// socket + sticky session) so the bot keeps a persistent history apart
|
|
118
|
+
// from the user's desktop/TUI work. Baileys is an outbound WebSocket —
|
|
119
|
+
// no tunnel, no tunnelProvider registration.
|
|
120
|
+
dedicatedRunner: true,
|
|
121
|
+
sessionSource: 'whatsapp',
|
|
122
|
+
config: {
|
|
123
|
+
fields: [
|
|
124
|
+
{
|
|
125
|
+
name: 'tosAcknowledged',
|
|
126
|
+
label: "Risk acknowledgment — type 'yes'",
|
|
127
|
+
vaultKey: WHATSAPP_CONSENT_KEY,
|
|
128
|
+
required: true,
|
|
129
|
+
help:
|
|
130
|
+
'Unofficial WhatsApp API: violates the ToS and can get the number banned. ' +
|
|
131
|
+
"Typing anything other than 'yes' keeps the channel disarmed.",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
name: 'allowedJids',
|
|
135
|
+
label: 'Extra allowed JIDs (comma-separated)',
|
|
136
|
+
vaultKey: WHATSAPP_ALLOWED_JIDS_KEY,
|
|
137
|
+
required: false,
|
|
138
|
+
placeholder: '15551234567@s.whatsapp.net',
|
|
139
|
+
help: 'Your own Note-to-Self chat is always allowed once linked.',
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
hasRequestUrl: false,
|
|
143
|
+
runHint:
|
|
144
|
+
'On your phone: WhatsApp -> Settings -> Linked devices -> Link a device, then scan the QR.',
|
|
145
|
+
connect: {
|
|
146
|
+
kind: 'qr',
|
|
147
|
+
title: 'Link WhatsApp',
|
|
148
|
+
hint:
|
|
149
|
+
'Scan with the phone that owns the account: WhatsApp -> Settings -> Linked devices ' +
|
|
150
|
+
'-> Link a device. The QR refreshes periodically until scanned.',
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
create: (deps) => {
|
|
154
|
+
const options = deps.options;
|
|
155
|
+
const allowedJids = readAllowedJids(options);
|
|
156
|
+
const editFrameMs = options?.['editFrameMs'];
|
|
157
|
+
const channelOpts: WhatsAppChannelOptions = {
|
|
158
|
+
vault: getVault(),
|
|
159
|
+
...(allowedJids ? { allowedJids } : {}),
|
|
160
|
+
...(typeof editFrameMs === 'number' ? { editFrameMs } : {}),
|
|
161
|
+
logger: deps.logger as never,
|
|
162
|
+
};
|
|
163
|
+
return new WhatsAppChannel(channelOpts);
|
|
164
|
+
},
|
|
165
|
+
isAvailable: async () => {
|
|
166
|
+
// Cheap probes only: one vault read + one stat-shaped file read.
|
|
167
|
+
let consent = false;
|
|
168
|
+
try {
|
|
169
|
+
consent = await hasConsent(getVault());
|
|
170
|
+
} catch {
|
|
171
|
+
// Vault unavailable in a probe/listing context; the env override
|
|
172
|
+
// inside hasConsent(undefined) still counts.
|
|
173
|
+
consent = await hasConsent(undefined);
|
|
174
|
+
}
|
|
175
|
+
if (!consent) return { ok: false, reason: CONSENT_REQUIRED_MESSAGE };
|
|
176
|
+
const linked = await hasStoredCreds(
|
|
177
|
+
createFileAuthStorage(moxxyPath(WHATSAPP_AUTH_DIR)),
|
|
178
|
+
);
|
|
179
|
+
if (!linked) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
reason:
|
|
183
|
+
'No WhatsApp account linked (or the phone logged this device out). Run ' +
|
|
184
|
+
'`moxxy channels whatsapp pair` to scan the QR.',
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { ok: true };
|
|
188
|
+
},
|
|
189
|
+
interactiveCommand: 'setup',
|
|
190
|
+
subcommands: {
|
|
191
|
+
setup: {
|
|
192
|
+
description:
|
|
193
|
+
'Interactive setup: acknowledge the unofficial-API risk (required first step), ' +
|
|
194
|
+
'link via QR, manage the allow-list, then start. Shown by default for ' +
|
|
195
|
+
'`moxxy whatsapp` on a TTY.',
|
|
196
|
+
run: async (ctx) => {
|
|
197
|
+
if (process.stdin.isTTY !== true) {
|
|
198
|
+
// Headless: the consent gate still holds — start refuses without
|
|
199
|
+
// an acknowledgment (vault receipt or MOXXY_WHATSAPP_TOS_ACK).
|
|
200
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
201
|
+
if (!(await hasConsent(vault))) {
|
|
202
|
+
process.stderr.write(`${CONSENT_REQUIRED_MESSAGE}\n`);
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
return ctx.startChannel();
|
|
206
|
+
}
|
|
207
|
+
return runWhatsAppWizard(ctx);
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
pair: {
|
|
211
|
+
description:
|
|
212
|
+
'Link a WhatsApp account: renders the rotating QR in the terminal; scan it from ' +
|
|
213
|
+
'WhatsApp -> Settings -> Linked devices. Requires the typed risk acknowledgment.',
|
|
214
|
+
run: async (ctx) => {
|
|
215
|
+
if (process.stdin.isTTY !== true) {
|
|
216
|
+
process.stderr.write(
|
|
217
|
+
'Pairing needs a TTY to show the QR. Run `moxxy channels whatsapp pair` on a ' +
|
|
218
|
+
'workstation, or pair from the desktop Channels panel.\n',
|
|
219
|
+
);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
return runWhatsAppPairFlow(ctx);
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
status: {
|
|
226
|
+
description:
|
|
227
|
+
'Report consent/link/allow-list state as JSON (with re-pair guidance after a logout).',
|
|
228
|
+
run: async (ctx) => {
|
|
229
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
230
|
+
if (!vault) {
|
|
231
|
+
process.stderr.write('vault unavailable\n');
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
const consentAcknowledged = await hasConsent(vault);
|
|
235
|
+
const linked = await hasStoredCreds(
|
|
236
|
+
createFileAuthStorage(moxxyPath(WHATSAPP_AUTH_DIR)),
|
|
237
|
+
);
|
|
238
|
+
const ownerJid = await vault.get(WHATSAPP_OWNER_JID_KEY);
|
|
239
|
+
const allowedJids = parseAllowedJids(await vault.get(WHATSAPP_ALLOWED_JIDS_KEY));
|
|
240
|
+
const state = !consentAcknowledged
|
|
241
|
+
? 'needs-consent'
|
|
242
|
+
: !linked
|
|
243
|
+
? 'needs-pairing'
|
|
244
|
+
: 'ready';
|
|
245
|
+
const guidance = !consentAcknowledged
|
|
246
|
+
? 'Run `moxxy whatsapp setup` and type yes to acknowledge the unofficial-API risk.'
|
|
247
|
+
: !linked
|
|
248
|
+
? ownerJid
|
|
249
|
+
? 'The phone logged this device out (or credentials were cleared) — run `moxxy channels whatsapp pair` to re-link.'
|
|
250
|
+
: 'Run `moxxy channels whatsapp pair` to link an account.'
|
|
251
|
+
: null;
|
|
252
|
+
process.stdout.write(
|
|
253
|
+
JSON.stringify(
|
|
254
|
+
{ state, consentAcknowledged, linked, ownerJid, allowedJids, guidance },
|
|
255
|
+
null,
|
|
256
|
+
2,
|
|
257
|
+
) + '\n',
|
|
258
|
+
);
|
|
259
|
+
return 0;
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
unpair: {
|
|
263
|
+
description:
|
|
264
|
+
'Forget the local WhatsApp credentials (also remove the device on the phone under Linked devices).',
|
|
265
|
+
run: async (ctx) => {
|
|
266
|
+
const vault = ctx.deps.vault as VaultStore | undefined;
|
|
267
|
+
if (!vault) {
|
|
268
|
+
process.stderr.write('vault unavailable\n');
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
const removed = await unpairLocal(vault);
|
|
272
|
+
process.stdout.write(
|
|
273
|
+
removed
|
|
274
|
+
? 'unpaired — also remove the device on your phone: WhatsApp -> Settings -> Linked devices\n'
|
|
275
|
+
: 'no linked account was stored\n',
|
|
276
|
+
);
|
|
277
|
+
return 0;
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
}),
|
|
282
|
+
],
|
|
283
|
+
});
|
|
284
|
+
}
|
package/src/keys.test.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { isConsentValue, normalizeJid, parseAllowedJids } from './keys.js';
|
|
3
|
+
|
|
4
|
+
describe('normalizeJid', () => {
|
|
5
|
+
it('strips device + agent suffixes off the user part', () => {
|
|
6
|
+
expect(normalizeJid('15551234567:12@s.whatsapp.net')).toBe('15551234567@s.whatsapp.net');
|
|
7
|
+
expect(normalizeJid('15551234567_1:3@s.whatsapp.net')).toBe('15551234567@s.whatsapp.net');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('lowercases the server and keeps a bare jid', () => {
|
|
11
|
+
expect(normalizeJid('15551234567@S.Whatsapp.Net')).toBe('15551234567@s.whatsapp.net');
|
|
12
|
+
expect(normalizeJid('12345@g.us')).toBe('12345@g.us');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects garbage and malformed values', () => {
|
|
16
|
+
expect(normalizeJid('')).toBeNull();
|
|
17
|
+
expect(normalizeJid(' ')).toBeNull();
|
|
18
|
+
expect(normalizeJid('nope')).toBeNull();
|
|
19
|
+
expect(normalizeJid('@s.whatsapp.net')).toBeNull();
|
|
20
|
+
expect(normalizeJid('a@@b')).toBeNull();
|
|
21
|
+
expect(normalizeJid('u@bad server')).toBeNull();
|
|
22
|
+
expect(normalizeJid(null)).toBeNull();
|
|
23
|
+
expect(normalizeJid(undefined)).toBeNull();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('parseAllowedJids', () => {
|
|
28
|
+
it('parses comma/space-separated JIDs and de-dupes/normalizes', () => {
|
|
29
|
+
expect(parseAllowedJids('15551234567@s.whatsapp.net, 15551234567:9@s.whatsapp.net')).toEqual([
|
|
30
|
+
'15551234567@s.whatsapp.net',
|
|
31
|
+
]);
|
|
32
|
+
expect(parseAllowedJids('a@g.us b@g.us')).toEqual(['a@g.us', 'b@g.us']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('parses a JSON array and drops non-JIDs', () => {
|
|
36
|
+
expect(parseAllowedJids('["1@s.whatsapp.net", "garbage", 42]')).toEqual(['1@s.whatsapp.net']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns [] for empty / broken input', () => {
|
|
40
|
+
expect(parseAllowedJids(null)).toEqual([]);
|
|
41
|
+
expect(parseAllowedJids('')).toEqual([]);
|
|
42
|
+
expect(parseAllowedJids('[not json')).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('isConsentValue', () => {
|
|
47
|
+
it('accepts an explicit yes or a dated receipt', () => {
|
|
48
|
+
expect(isConsentValue('yes')).toBe(true);
|
|
49
|
+
expect(isConsentValue('YES')).toBe(true);
|
|
50
|
+
expect(isConsentValue('acknowledged@2026-07-03T00:00:00.000Z')).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects anything else', () => {
|
|
54
|
+
expect(isConsentValue('no')).toBe(false);
|
|
55
|
+
expect(isConsentValue('')).toBe(false);
|
|
56
|
+
expect(isConsentValue(null)).toBe(false);
|
|
57
|
+
expect(isConsentValue('maybe')).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault keys + pure JID helpers shared by the channel, its subcommands, and the
|
|
3
|
+
* interactive setup wizard. Kept in their own module so the wizard / pair-flow
|
|
4
|
+
* helpers can import them without pulling in the plugin's full index (or, worse,
|
|
5
|
+
* Baileys — which stays behind a lazy import in `baileys-socket.ts`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Vault key holding the user's typed acknowledgment of the ToS/ban risk. */
|
|
9
|
+
export const WHATSAPP_CONSENT_KEY = 'whatsapp_tos_acknowledged';
|
|
10
|
+
/** Vault key holding the linked account's own (normalized) JID. */
|
|
11
|
+
export const WHATSAPP_OWNER_JID_KEY = 'whatsapp_owner_jid';
|
|
12
|
+
/** Vault key holding extra allow-listed JIDs (JSON array or comma-separated). */
|
|
13
|
+
export const WHATSAPP_ALLOWED_JIDS_KEY = 'whatsapp_allowed_jids';
|
|
14
|
+
/** Env override for the consent gate (headless/dedicated runners). */
|
|
15
|
+
export const WHATSAPP_CONSENT_ENV = 'MOXXY_WHATSAPP_TOS_ACK';
|
|
16
|
+
/** Directory (under the moxxy home) holding the rotating Baileys auth state. */
|
|
17
|
+
export const WHATSAPP_AUTH_DIR = 'whatsapp-auth';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a WhatsApp JID for identity comparison: the user part of a
|
|
21
|
+
* multi-device JID carries `:device` (and historically `_agent`) suffixes that
|
|
22
|
+
* vary per login (`1234567890:12@s.whatsapp.net`), so equality checks must
|
|
23
|
+
* compare the bare `user@server` form. Returns null for values that don't look
|
|
24
|
+
* like a JID at all, so callers treat garbage as "no identity" instead of
|
|
25
|
+
* silently allow-listing it.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeJid(raw: string | null | undefined): string | null {
|
|
28
|
+
if (typeof raw !== 'string') return null;
|
|
29
|
+
const trimmed = raw.trim();
|
|
30
|
+
if (trimmed.length === 0 || trimmed.length > 256) return null;
|
|
31
|
+
const at = trimmed.indexOf('@');
|
|
32
|
+
if (at <= 0 || at !== trimmed.lastIndexOf('@')) return null;
|
|
33
|
+
const server = trimmed.slice(at + 1).toLowerCase();
|
|
34
|
+
if (!/^[a-z0-9.-]+$/.test(server)) return null;
|
|
35
|
+
// Strip the device (`:NN`) and legacy agent (`_N`) suffixes off the user part.
|
|
36
|
+
const user = trimmed.slice(0, at).split(':')[0]!.split('_')[0]!;
|
|
37
|
+
if (user.length === 0) return null;
|
|
38
|
+
return `${user}@${server}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a stored/configured allow-list. Accepts a JSON string array or a
|
|
43
|
+
* comma/whitespace-separated string; every entry is normalized and non-JIDs are
|
|
44
|
+
* dropped (never silently allow-listed). Returns a de-duplicated array.
|
|
45
|
+
*/
|
|
46
|
+
export function parseAllowedJids(raw: string | null | undefined): string[] {
|
|
47
|
+
if (raw == null) return [];
|
|
48
|
+
let entries: unknown[] = [];
|
|
49
|
+
const trimmed = raw.trim();
|
|
50
|
+
if (trimmed.startsWith('[')) {
|
|
51
|
+
try {
|
|
52
|
+
const parsed: unknown = JSON.parse(trimmed);
|
|
53
|
+
if (Array.isArray(parsed)) entries = parsed;
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
entries = trimmed.split(/[,\s]+/);
|
|
59
|
+
}
|
|
60
|
+
const out = new Set<string>();
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
const jid = normalizeJid(typeof entry === 'string' ? entry : null);
|
|
63
|
+
if (jid) out.add(jid);
|
|
64
|
+
}
|
|
65
|
+
return [...out];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether a stored consent value counts as an actual acknowledgment. The setup
|
|
70
|
+
* wizard stores `acknowledged@<ISO date>`; the desktop panel / `moxxy init`
|
|
71
|
+
* declarative field stores whatever the user typed — only an affirmative "yes"
|
|
72
|
+
* counts, so typing "no" into the form does NOT arm the channel.
|
|
73
|
+
*/
|
|
74
|
+
export function isConsentValue(raw: string | null | undefined): boolean {
|
|
75
|
+
if (typeof raw !== 'string') return false;
|
|
76
|
+
const v = raw.trim().toLowerCase();
|
|
77
|
+
return v === 'yes' || v.startsWith('acknowledged@');
|
|
78
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { gateInboundMessage, MAX_TEXT_CHARS, type GateState } from './message-gate.js';
|
|
3
|
+
import type { WaInboundMessage } from './socket.js';
|
|
4
|
+
|
|
5
|
+
const OWNER = '15550000000@s.whatsapp.net';
|
|
6
|
+
const FRIEND = '15551111111@s.whatsapp.net';
|
|
7
|
+
const STRANGER = '15559999999@s.whatsapp.net';
|
|
8
|
+
|
|
9
|
+
function state(overrides: Partial<GateState> = {}): GateState {
|
|
10
|
+
return {
|
|
11
|
+
ownJid: OWNER,
|
|
12
|
+
allowedJids: new Set([OWNER, FRIEND]),
|
|
13
|
+
isOwnSend: () => false,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function textMsg(remoteJid: string, text: string, fromMe = false, id = 'm1'): WaInboundMessage {
|
|
19
|
+
return { key: { remoteJid, fromMe, id }, message: { conversation: text } };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('gateInboundMessage', () => {
|
|
23
|
+
it('accepts an allow-listed sender text message', () => {
|
|
24
|
+
const v = gateInboundMessage(state(), 'notify', textMsg(FRIEND, 'hi there'));
|
|
25
|
+
expect(v).toEqual({ ok: true, kind: 'text', jid: FRIEND, text: 'hi there' });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts the owner's own Note-to-Self message (fromMe in own chat)", () => {
|
|
29
|
+
const v = gateInboundMessage(state(), 'notify', textMsg(OWNER, 'note to self', true));
|
|
30
|
+
expect(v.ok && v.kind === 'text' && v.jid).toBe(OWNER);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('drops a fromMe message in a FOREIGN chat (owner talking to others)', () => {
|
|
34
|
+
const v = gateInboundMessage(state(), 'notify', textMsg(FRIEND, 'private reply', true));
|
|
35
|
+
expect(v).toEqual({ ok: false, reason: 'own message in a foreign chat' });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("drops the bot's OWN outbound echo by message id (loop protection)", () => {
|
|
39
|
+
const st = state({ isOwnSend: (id) => id === 'echo-1' });
|
|
40
|
+
const v = gateInboundMessage(st, 'notify', textMsg(FRIEND, 'my own send', false, 'echo-1'));
|
|
41
|
+
expect(v).toEqual({ ok: false, reason: 'own outbound echo' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('drops an un-allow-listed sender', () => {
|
|
45
|
+
const v = gateInboundMessage(state(), 'notify', textMsg(STRANGER, 'let me in'));
|
|
46
|
+
expect(v).toEqual({ ok: false, reason: 'sender not allow-listed' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('drops non-notify upserts (history sync / append)', () => {
|
|
50
|
+
expect(gateInboundMessage(state(), 'append', textMsg(FRIEND, 'x')).ok).toBe(false);
|
|
51
|
+
expect(gateInboundMessage(state(), 'history', textMsg(FRIEND, 'x')).ok).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('drops status broadcasts', () => {
|
|
55
|
+
const v = gateInboundMessage(state(), 'notify', textMsg('status@broadcast', 'x'));
|
|
56
|
+
expect(v.ok).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('validates the key shape (missing remoteJid rejected)', () => {
|
|
60
|
+
const v = gateInboundMessage(state(), 'notify', { key: { fromMe: false }, message: { conversation: 'x' } });
|
|
61
|
+
expect(v).toEqual({ ok: false, reason: 'invalid message key shape' });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('caps oversized text', () => {
|
|
65
|
+
const big = 'a'.repeat(MAX_TEXT_CHARS + 1);
|
|
66
|
+
const v = gateInboundMessage(state(), 'notify', textMsg(FRIEND, big));
|
|
67
|
+
expect(v).toEqual({ ok: false, reason: 'text over size cap' });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('unwraps ephemeral + extendedText messages', () => {
|
|
71
|
+
const msg: WaInboundMessage = {
|
|
72
|
+
key: { remoteJid: FRIEND, fromMe: false, id: 'e1' },
|
|
73
|
+
message: {
|
|
74
|
+
ephemeralMessage: { message: { extendedTextMessage: { text: 'ephemeral hi' } } },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
const v = gateInboundMessage(state(), 'notify', msg);
|
|
78
|
+
expect(v).toEqual({ ok: true, kind: 'text', jid: FRIEND, text: 'ephemeral hi' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accepts a voice/audio message and reports mime + declared size', () => {
|
|
82
|
+
const msg: WaInboundMessage = {
|
|
83
|
+
key: { remoteJid: FRIEND, fromMe: false, id: 'a1' },
|
|
84
|
+
message: { audioMessage: { mimetype: 'audio/ogg; codecs=opus', fileLength: '2048', ptt: true } },
|
|
85
|
+
};
|
|
86
|
+
const v = gateInboundMessage(state(), 'notify', msg);
|
|
87
|
+
expect(v).toEqual({
|
|
88
|
+
ok: true,
|
|
89
|
+
kind: 'audio',
|
|
90
|
+
jid: FRIEND,
|
|
91
|
+
mimeType: 'audio/ogg; codecs=opus',
|
|
92
|
+
declaredBytes: 2048,
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('rejects an audio message whose declared size exceeds the cap', () => {
|
|
97
|
+
const msg: WaInboundMessage = {
|
|
98
|
+
key: { remoteJid: FRIEND, fromMe: false, id: 'a2' },
|
|
99
|
+
message: { audioMessage: { mimetype: 'audio/ogg', fileLength: 999_999_999 } },
|
|
100
|
+
};
|
|
101
|
+
const v = gateInboundMessage(state(), 'notify', msg);
|
|
102
|
+
expect(v).toEqual({ ok: false, reason: 'audio over size cap' });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('drops empty/unsupported content', () => {
|
|
106
|
+
expect(gateInboundMessage(state(), 'notify', textMsg(FRIEND, ' ')).ok).toBe(false);
|
|
107
|
+
const image: WaInboundMessage = {
|
|
108
|
+
key: { remoteJid: FRIEND, fromMe: false, id: 'i1' },
|
|
109
|
+
message: { imageMessage: { url: 'x' } },
|
|
110
|
+
};
|
|
111
|
+
expect(gateInboundMessage(state(), 'notify', image)).toEqual({
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: 'unsupported message type',
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('drops a fromMe self-chat message when ownJid is not yet known', () => {
|
|
118
|
+
const v = gateInboundMessage(state({ ownJid: null }), 'notify', textMsg(OWNER, 'x', true));
|
|
119
|
+
expect(v).toEqual({ ok: false, reason: 'own message in a foreign chat' });
|
|
120
|
+
});
|
|
121
|
+
});
|