@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { normalizeJid } from './keys.js';
|
|
3
|
+
import type { WaInboundMessage } from './socket.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The single inbound gate every session-reaching path goes through (AGENTS.md:
|
|
7
|
+
* gate EVERY session-reaching path behind pairing; A8: zod-validate inbound
|
|
8
|
+
* before it touches the session). Pure — the channel feeds it the message plus
|
|
9
|
+
* its identity/allow-list state and branches on the verdict.
|
|
10
|
+
*
|
|
11
|
+
* Order of checks (each is load-bearing):
|
|
12
|
+
* 1. only `notify` upserts — history syncs / appends are not fresh input.
|
|
13
|
+
* 2. shape-validate + size-cap the consumed fields (zod).
|
|
14
|
+
* 3. drop own echoes: Baileys receives this client's OWN outbound sends back
|
|
15
|
+
* via `messages.upsert`; without the sent-id check the bot replies to
|
|
16
|
+
* itself in a loop.
|
|
17
|
+
* 4. drop `fromMe` messages outside the owner's self-chat ("Note to Self"):
|
|
18
|
+
* those are the owner talking to OTHER people from their phone — the bot
|
|
19
|
+
* must never treat someone's private conversation as a prompt.
|
|
20
|
+
* 5. allow-list by normalized JID: the owner's self-chat is allowed by
|
|
21
|
+
* default (seeded by the channel); everything else must be explicitly
|
|
22
|
+
* allow-listed. Unauthorized senders are dropped SILENTLY — replying would
|
|
23
|
+
* both leak the bot's existence and look like spam (ban risk).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Cap on inbound prompt text (WhatsApp itself allows ~65k). */
|
|
27
|
+
export const MAX_TEXT_CHARS = 16_000;
|
|
28
|
+
/** Cap on a voice note / audio file we will download + buffer for transcription. */
|
|
29
|
+
export const MAX_AUDIO_BYTES = 20 * 1024 * 1024;
|
|
30
|
+
|
|
31
|
+
const keySchema = z.object({
|
|
32
|
+
remoteJid: z.string().min(1).max(256),
|
|
33
|
+
fromMe: z.boolean().nullish(),
|
|
34
|
+
id: z.string().min(1).max(256).nullish(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const audioMessageSchema = z.object({
|
|
38
|
+
mimetype: z.string().max(256).nullish(),
|
|
39
|
+
fileLength: z.union([z.number(), z.string(), z.object({}).passthrough()]).nullish(),
|
|
40
|
+
seconds: z.number().nullish(),
|
|
41
|
+
ptt: z.boolean().nullish(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type GateVerdict =
|
|
45
|
+
| { readonly ok: false; readonly reason: string }
|
|
46
|
+
| { readonly ok: true; readonly kind: 'text'; readonly jid: string; readonly text: string }
|
|
47
|
+
| {
|
|
48
|
+
readonly ok: true;
|
|
49
|
+
readonly kind: 'audio';
|
|
50
|
+
readonly jid: string;
|
|
51
|
+
readonly mimeType: string;
|
|
52
|
+
readonly declaredBytes: number | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface GateState {
|
|
56
|
+
/** The linked account's own normalized JID (null until the socket opens). */
|
|
57
|
+
readonly ownJid: string | null;
|
|
58
|
+
/** Normalized JIDs allowed to drive the session (includes ownJid). */
|
|
59
|
+
readonly allowedJids: ReadonlySet<string>;
|
|
60
|
+
/** Message ids of THIS client's own recent sends (echo/loop protection). */
|
|
61
|
+
readonly isOwnSend: (messageId: string) => boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function gateInboundMessage(
|
|
65
|
+
state: GateState,
|
|
66
|
+
upsertType: string,
|
|
67
|
+
raw: WaInboundMessage,
|
|
68
|
+
): GateVerdict {
|
|
69
|
+
if (upsertType !== 'notify') return drop('not a live notify upsert');
|
|
70
|
+
|
|
71
|
+
const key = keySchema.safeParse(raw?.key ?? {});
|
|
72
|
+
if (!key.success) return drop('invalid message key shape');
|
|
73
|
+
const { remoteJid, fromMe, id } = key.data;
|
|
74
|
+
if (remoteJid === 'status@broadcast') return drop('status broadcast');
|
|
75
|
+
|
|
76
|
+
const jid = normalizeJid(remoteJid);
|
|
77
|
+
if (!jid) return drop('unparseable chat JID');
|
|
78
|
+
|
|
79
|
+
if (id && state.isOwnSend(id)) return drop('own outbound echo');
|
|
80
|
+
|
|
81
|
+
if (fromMe) {
|
|
82
|
+
// Same-account messages: only the owner's self-chat is a prompt surface.
|
|
83
|
+
if (state.ownJid == null || jid !== state.ownJid) {
|
|
84
|
+
return drop('own message in a foreign chat');
|
|
85
|
+
}
|
|
86
|
+
} else if (!state.allowedJids.has(jid)) {
|
|
87
|
+
return drop('sender not allow-listed');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const content = unwrapMessageContent(raw?.message ?? null);
|
|
91
|
+
if (!content) return drop('no message content');
|
|
92
|
+
|
|
93
|
+
const text = extractText(content);
|
|
94
|
+
if (text != null) {
|
|
95
|
+
const trimmed = text.trim();
|
|
96
|
+
if (trimmed.length === 0) return drop('empty text');
|
|
97
|
+
if (trimmed.length > MAX_TEXT_CHARS) return drop('text over size cap');
|
|
98
|
+
return { ok: true, kind: 'text', jid, text: trimmed };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const audioRaw = content['audioMessage'];
|
|
102
|
+
if (audioRaw != null && typeof audioRaw === 'object') {
|
|
103
|
+
const audio = audioMessageSchema.safeParse(audioRaw);
|
|
104
|
+
if (!audio.success) return drop('invalid audio message shape');
|
|
105
|
+
const declaredBytes = toFiniteNumber(audio.data.fileLength);
|
|
106
|
+
if (declaredBytes != null && declaredBytes > MAX_AUDIO_BYTES) {
|
|
107
|
+
return drop('audio over size cap');
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
kind: 'audio',
|
|
112
|
+
jid,
|
|
113
|
+
mimeType: normalizeMime(audio.data.mimetype),
|
|
114
|
+
declaredBytes,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return drop('unsupported message type');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function drop(reason: string): GateVerdict {
|
|
122
|
+
return { ok: false, reason };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Unwrap the containers WhatsApp nests real content in (disappearing-message
|
|
127
|
+
* chats wrap everything in `ephemeralMessage`, view-once in `viewOnceMessage*`).
|
|
128
|
+
* Bounded depth — malformed nesting must not recurse forever.
|
|
129
|
+
*/
|
|
130
|
+
function unwrapMessageContent(
|
|
131
|
+
message: Record<string, unknown> | null,
|
|
132
|
+
): Record<string, unknown> | null {
|
|
133
|
+
let current = message;
|
|
134
|
+
for (let depth = 0; current != null && depth < 4; depth++) {
|
|
135
|
+
const wrapper =
|
|
136
|
+
pickInner(current, 'ephemeralMessage') ??
|
|
137
|
+
pickInner(current, 'viewOnceMessage') ??
|
|
138
|
+
pickInner(current, 'viewOnceMessageV2') ??
|
|
139
|
+
pickInner(current, 'documentWithCaptionMessage');
|
|
140
|
+
if (!wrapper) return current;
|
|
141
|
+
current = wrapper;
|
|
142
|
+
}
|
|
143
|
+
return current;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function pickInner(
|
|
147
|
+
container: Record<string, unknown>,
|
|
148
|
+
field: string,
|
|
149
|
+
): Record<string, unknown> | null {
|
|
150
|
+
const wrapper = container[field];
|
|
151
|
+
if (wrapper == null || typeof wrapper !== 'object') return null;
|
|
152
|
+
const inner = (wrapper as { message?: unknown }).message;
|
|
153
|
+
return inner != null && typeof inner === 'object' ? (inner as Record<string, unknown>) : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractText(content: Record<string, unknown>): string | null {
|
|
157
|
+
const conversation = content['conversation'];
|
|
158
|
+
if (typeof conversation === 'string') return conversation;
|
|
159
|
+
const extended = content['extendedTextMessage'];
|
|
160
|
+
if (extended != null && typeof extended === 'object') {
|
|
161
|
+
const text = (extended as { text?: unknown }).text;
|
|
162
|
+
if (typeof text === 'string') return text;
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** `fileLength` arrives as number, decimal string, or a Long-like object. */
|
|
168
|
+
function toFiniteNumber(value: unknown): number | null {
|
|
169
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
170
|
+
if (typeof value === 'string') {
|
|
171
|
+
const n = Number(value);
|
|
172
|
+
return Number.isFinite(n) ? n : null;
|
|
173
|
+
}
|
|
174
|
+
if (value != null && typeof value === 'object') {
|
|
175
|
+
const n = Number((value as { toString(): string }).toString());
|
|
176
|
+
return Number.isFinite(n) ? n : null;
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeMime(mime: string | null | undefined): string {
|
|
182
|
+
const trimmed = mime?.trim();
|
|
183
|
+
return trimmed && trimmed.length > 0 ? trimmed : 'audio/ogg; codecs=opus';
|
|
184
|
+
}
|
package/src/pair-flow.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { log, outro, spinner } from '@clack/prompts';
|
|
2
|
+
import QRCode from 'qrcode';
|
|
3
|
+
import { exitAfterPairRequested, type ChannelSubcommandContext } from '@moxxy/sdk';
|
|
4
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
5
|
+
import { WhatsAppChannel } from './channel.js';
|
|
6
|
+
import { CONSENT_REQUIRED_MESSAGE } from './consent.js';
|
|
7
|
+
import { ensureConsentInteractive } from './consent-prompt.js';
|
|
8
|
+
|
|
9
|
+
// Tiny zero-dep ANSI dim helper, so this flow stays inside the plugin.
|
|
10
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
11
|
+
const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Drive the QR device-link pairing flow end-to-end in a terminal — the SAME
|
|
15
|
+
* mechanism the desktop Channels panel uses (it renders `channel.requestUrl`
|
|
16
|
+
* from the status file), just rendered inline here.
|
|
17
|
+
*
|
|
18
|
+
* Unlike Telegram's one-shot deep link, the Baileys QR payload ROTATES every
|
|
19
|
+
* ~20-60s while unlinked, so the flow re-renders on every connect-change until
|
|
20
|
+
* the scan lands.
|
|
21
|
+
*/
|
|
22
|
+
export async function runWhatsAppPairFlow(ctx: ChannelSubcommandContext): Promise<number> {
|
|
23
|
+
const vault = ctx.deps.vault as VaultStore;
|
|
24
|
+
|
|
25
|
+
// Consent gate FIRST — pairing is the moment the account actually links.
|
|
26
|
+
if (!(await ensureConsentInteractive(vault))) {
|
|
27
|
+
process.stderr.write(`${CONSENT_REQUIRED_MESSAGE}\n`);
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const session = ctx.session;
|
|
32
|
+
const channel = new WhatsAppChannel({
|
|
33
|
+
vault,
|
|
34
|
+
logger: ctx.deps.logger as never,
|
|
35
|
+
});
|
|
36
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
37
|
+
|
|
38
|
+
// Subscribe BEFORE start so a fast scan can't race us.
|
|
39
|
+
let pairedResolve: ((ownerJid: string) => void) | null = null;
|
|
40
|
+
const paired = new Promise<string>((resolve) => {
|
|
41
|
+
pairedResolve = resolve;
|
|
42
|
+
});
|
|
43
|
+
const unsubscribePaired = channel.onPaired((ownerJid) => {
|
|
44
|
+
pairedResolve?.(ownerJid);
|
|
45
|
+
pairedResolve = null;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
outro(dim('opening WhatsApp pairing window...'));
|
|
49
|
+
const handle = await channel.start({ session, pair: true });
|
|
50
|
+
|
|
51
|
+
const stopChannel = async (): Promise<void> => {
|
|
52
|
+
unsubscribePaired();
|
|
53
|
+
try {
|
|
54
|
+
await handle.stop('pair flow');
|
|
55
|
+
} catch {
|
|
56
|
+
/* ignore */
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
if (channel.connected) {
|
|
61
|
+
log.info(
|
|
62
|
+
'A WhatsApp account is already linked. Run `moxxy channels whatsapp unpair` first to link a different one.',
|
|
63
|
+
);
|
|
64
|
+
await stopChannel();
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Render every FRESH QR payload (they rotate while unlinked).
|
|
69
|
+
let lastQr: string | null = null;
|
|
70
|
+
const renderQr = async (): Promise<void> => {
|
|
71
|
+
const qr = channel.requestUrl;
|
|
72
|
+
if (!qr || qr === lastQr) return;
|
|
73
|
+
lastQr = qr;
|
|
74
|
+
await printPairQr(qr);
|
|
75
|
+
log.info(
|
|
76
|
+
'On your phone: WhatsApp -> Settings -> Linked devices -> Link a device, then scan the QR.',
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
const unsubscribeConnect = handle.onConnectChange?.(() => void renderQr()) ?? (() => undefined);
|
|
80
|
+
await renderQr();
|
|
81
|
+
|
|
82
|
+
let stopping = false;
|
|
83
|
+
const shutdown = async (): Promise<void> => {
|
|
84
|
+
if (stopping) return;
|
|
85
|
+
stopping = true;
|
|
86
|
+
unsubscribeConnect();
|
|
87
|
+
await stopChannel();
|
|
88
|
+
await session.close('SIGINT').catch(() => undefined);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
};
|
|
91
|
+
const onSignal = (): void => void shutdown();
|
|
92
|
+
process.once('SIGINT', onSignal);
|
|
93
|
+
process.once('SIGTERM', onSignal);
|
|
94
|
+
|
|
95
|
+
const spin = spinner();
|
|
96
|
+
spin.start('Waiting for the scan (the QR refreshes periodically)...');
|
|
97
|
+
const ownerJid = await paired;
|
|
98
|
+
spin.stop(`Linked as ${ownerJid}. Your Note-to-Self chat now talks to moxxy.`);
|
|
99
|
+
|
|
100
|
+
if (exitAfterPairRequested(ctx)) {
|
|
101
|
+
// Orchestrated pairing (`moxxy onboard`): hand control back — the caller
|
|
102
|
+
// starts the channel under its own service afterwards. Our SIGINT
|
|
103
|
+
// handlers would `process.exit` the orchestrator, so drop them first.
|
|
104
|
+
unsubscribeConnect();
|
|
105
|
+
process.removeListener('SIGINT', onSignal);
|
|
106
|
+
process.removeListener('SIGTERM', onSignal);
|
|
107
|
+
await stopChannel();
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
log.info('Channel is running. Press Ctrl+C to stop.');
|
|
112
|
+
try {
|
|
113
|
+
await handle.running;
|
|
114
|
+
return 0;
|
|
115
|
+
} finally {
|
|
116
|
+
unsubscribeConnect();
|
|
117
|
+
process.removeListener('SIGINT', onSignal);
|
|
118
|
+
process.removeListener('SIGTERM', onSignal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Render the rotating pairing payload as a scannable terminal QR. */
|
|
123
|
+
async function printPairQr(payload: string): Promise<void> {
|
|
124
|
+
let qr = '';
|
|
125
|
+
try {
|
|
126
|
+
qr = await QRCode.toString(payload, { type: 'terminal', small: true });
|
|
127
|
+
} catch {
|
|
128
|
+
qr = '';
|
|
129
|
+
}
|
|
130
|
+
// CLI surface — intentional stdout. (The payload itself is an opaque pairing
|
|
131
|
+
// blob, not a URL, so there is no "open the link" fallback to print.)
|
|
132
|
+
console.log(['', qr].join('\n'));
|
|
133
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { PendingToolCall, PermissionContext } from '@moxxy/sdk';
|
|
3
|
+
import {
|
|
4
|
+
createWhatsAppPermissionController,
|
|
5
|
+
formatPermissionPrompt,
|
|
6
|
+
parsePermissionReply,
|
|
7
|
+
} from './permission.js';
|
|
8
|
+
|
|
9
|
+
const call: PendingToolCall = {
|
|
10
|
+
callId: 'c1',
|
|
11
|
+
name: 'write_file',
|
|
12
|
+
input: { path: '/tmp/x', content: 'hi' },
|
|
13
|
+
} as PendingToolCall;
|
|
14
|
+
const ctx = {} as PermissionContext;
|
|
15
|
+
|
|
16
|
+
describe('parsePermissionReply', () => {
|
|
17
|
+
it('maps allow/session/deny synonyms', () => {
|
|
18
|
+
expect(parsePermissionReply('1')?.mode).toBe('allow');
|
|
19
|
+
expect(parsePermissionReply('yes')?.mode).toBe('allow');
|
|
20
|
+
expect(parsePermissionReply('2')?.mode).toBe('allow_session');
|
|
21
|
+
expect(parsePermissionReply('always')?.mode).toBe('allow_session');
|
|
22
|
+
expect(parsePermissionReply('3')?.mode).toBe('deny');
|
|
23
|
+
expect(parsePermissionReply('no')?.mode).toBe('deny');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns null for unrecognized replies', () => {
|
|
27
|
+
expect(parsePermissionReply('maybe later')).toBeNull();
|
|
28
|
+
expect(parsePermissionReply('')).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('formatPermissionPrompt', () => {
|
|
33
|
+
it('names the tool and lists the numbered options', () => {
|
|
34
|
+
const text = formatPermissionPrompt(call);
|
|
35
|
+
expect(text).toContain('write_file');
|
|
36
|
+
expect(text).toContain('1 = allow once');
|
|
37
|
+
expect(text).toContain('3 = deny');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('WhatsAppPermissionController', () => {
|
|
42
|
+
it('denies immediately when no sender is wired', async () => {
|
|
43
|
+
const ctrl = createWhatsAppPermissionController();
|
|
44
|
+
const decision = await ctrl.resolver.check(call, ctx);
|
|
45
|
+
expect(decision.mode).toBe('deny');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('prompts, then resolves on the operator reply', async () => {
|
|
49
|
+
const ctrl = createWhatsAppPermissionController();
|
|
50
|
+
const sent: string[] = [];
|
|
51
|
+
ctrl.setSender(async (t) => void sent.push(t));
|
|
52
|
+
|
|
53
|
+
const pending = ctrl.resolver.check(call, ctx);
|
|
54
|
+
await vi.waitFor(() => expect(ctrl.hasPending()).toBe(true));
|
|
55
|
+
expect(sent[0]).toContain('write_file');
|
|
56
|
+
|
|
57
|
+
expect(ctrl.offerReply('1')).toBe(true);
|
|
58
|
+
expect((await pending).mode).toBe('allow');
|
|
59
|
+
expect(ctrl.hasPending()).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('allow_session skips subsequent prompts for the same tool', async () => {
|
|
63
|
+
const ctrl = createWhatsAppPermissionController();
|
|
64
|
+
ctrl.setSender(async () => {});
|
|
65
|
+
const first = ctrl.resolver.check(call, ctx);
|
|
66
|
+
await vi.waitFor(() => expect(ctrl.hasPending()).toBe(true));
|
|
67
|
+
ctrl.offerReply('2');
|
|
68
|
+
expect((await first).mode).toBe('allow_session');
|
|
69
|
+
|
|
70
|
+
// Second call for the same tool name resolves WITHOUT a new prompt.
|
|
71
|
+
const second = await ctrl.resolver.check(call, ctx);
|
|
72
|
+
expect(second.mode).toBe('allow_session');
|
|
73
|
+
expect(ctrl.hasPending()).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('ignores an unrecognized reply (keeps the prompt pending)', async () => {
|
|
77
|
+
const ctrl = createWhatsAppPermissionController();
|
|
78
|
+
ctrl.setSender(async () => {});
|
|
79
|
+
void ctrl.resolver.check(call, ctx);
|
|
80
|
+
await vi.waitFor(() => expect(ctrl.hasPending()).toBe(true));
|
|
81
|
+
expect(ctrl.offerReply('huh?')).toBe(false);
|
|
82
|
+
expect(ctrl.hasPending()).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('abortAll denies in-flight prompts so callers never hang', async () => {
|
|
86
|
+
const ctrl = createWhatsAppPermissionController();
|
|
87
|
+
ctrl.setSender(async () => {});
|
|
88
|
+
const pending = ctrl.resolver.check(call, ctx);
|
|
89
|
+
await vi.waitFor(() => expect(ctrl.hasPending()).toBe(true));
|
|
90
|
+
ctrl.abortAll('channel closed');
|
|
91
|
+
expect((await pending).mode).toBe('deny');
|
|
92
|
+
expect(ctrl.hasPending()).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDeferredPermissionResolver,
|
|
3
|
+
type DeferredPermissionResolver,
|
|
4
|
+
type PendingToolCall,
|
|
5
|
+
type PermissionDecision,
|
|
6
|
+
} from '@moxxy/sdk';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Human-in-the-loop permissions over plain WhatsApp messages. Baileys has no
|
|
10
|
+
* reliable interactive buttons on multi-device, so the prompt is text: the
|
|
11
|
+
* channel sends a numbered question into the owner's chat and interprets the
|
|
12
|
+
* owner's NEXT short reply (`1`/`yes` allow once, `2`/`always` allow for the
|
|
13
|
+
* session, `3`/`no` deny) — the same capture-next-message mechanism Telegram
|
|
14
|
+
* uses for approval follow-up text.
|
|
15
|
+
*
|
|
16
|
+
* Built ON `createDeferredPermissionResolver` (never re-implement the pending
|
|
17
|
+
* tracking — the audit caught a TUI hang from exactly that): the sdk scaffold
|
|
18
|
+
* owns session-allows + abort-on-stop; this wrapper only owns the reply
|
|
19
|
+
* queue + parsing.
|
|
20
|
+
*/
|
|
21
|
+
export interface WhatsAppPermissionController {
|
|
22
|
+
readonly resolver: DeferredPermissionResolver;
|
|
23
|
+
/** Wire the outbound sender once the socket is up (null on teardown). */
|
|
24
|
+
setSender(send: ((text: string) => Promise<void>) | null): void;
|
|
25
|
+
/** True while a prompt is awaiting the owner's reply. */
|
|
26
|
+
hasPending(): boolean;
|
|
27
|
+
/** Offer an owner message as a reply; true when it resolved a prompt. */
|
|
28
|
+
offerReply(text: string): boolean;
|
|
29
|
+
/** Deny every in-flight prompt (channel stop / session reset). */
|
|
30
|
+
abortAll(reason?: string): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PendingPrompt {
|
|
34
|
+
readonly resolve: (decision: PermissionDecision) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parsePermissionReply(text: string): PermissionDecision | null {
|
|
38
|
+
const v = text.trim().toLowerCase();
|
|
39
|
+
if (v === '1' || v === 'yes' || v === 'y' || v === 'allow') {
|
|
40
|
+
return { mode: 'allow', reason: 'owner approved via WhatsApp' };
|
|
41
|
+
}
|
|
42
|
+
if (v === '2' || v === 'always') {
|
|
43
|
+
return { mode: 'allow_session', reason: 'owner approved for session via WhatsApp' };
|
|
44
|
+
}
|
|
45
|
+
if (v === '3' || v === 'no' || v === 'n' || v === 'deny') {
|
|
46
|
+
return { mode: 'deny', reason: 'owner denied via WhatsApp' };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatPermissionPrompt(call: PendingToolCall): string {
|
|
52
|
+
let input = '';
|
|
53
|
+
try {
|
|
54
|
+
input = JSON.stringify(call.input ?? {});
|
|
55
|
+
} catch {
|
|
56
|
+
input = '(unserializable input)';
|
|
57
|
+
}
|
|
58
|
+
if (input.length > 400) input = `${input.slice(0, 400)}…`;
|
|
59
|
+
return (
|
|
60
|
+
`Permission needed: tool *${call.name}*\n` +
|
|
61
|
+
`${input}\n\n` +
|
|
62
|
+
'Reply: 1 = allow once, 2 = allow for session, 3 = deny'
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createWhatsAppPermissionController(): WhatsAppPermissionController {
|
|
67
|
+
// FIFO of prompts awaiting a reply. Tool calls within a turn are sequential,
|
|
68
|
+
// so this is ~1 deep; the queue is defensive against overlap.
|
|
69
|
+
const pending: PendingPrompt[] = [];
|
|
70
|
+
let sender: ((text: string) => Promise<void>) | null = null;
|
|
71
|
+
|
|
72
|
+
const resolver = createDeferredPermissionResolver({
|
|
73
|
+
name: 'whatsapp',
|
|
74
|
+
prompt: (call) =>
|
|
75
|
+
new Promise<PermissionDecision>((resolve, reject) => {
|
|
76
|
+
if (!sender) {
|
|
77
|
+
resolve({ mode: 'deny', reason: 'whatsapp channel not connected' });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
pending.push({ resolve });
|
|
81
|
+
sender(formatPermissionPrompt(call)).catch((err: unknown) => {
|
|
82
|
+
const idx = pending.findIndex((p) => p.resolve === resolve);
|
|
83
|
+
if (idx >= 0) pending.splice(idx, 1);
|
|
84
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
85
|
+
});
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
resolver,
|
|
91
|
+
setSender(send) {
|
|
92
|
+
sender = send;
|
|
93
|
+
},
|
|
94
|
+
hasPending() {
|
|
95
|
+
return pending.length > 0;
|
|
96
|
+
},
|
|
97
|
+
offerReply(text) {
|
|
98
|
+
if (pending.length === 0) return false;
|
|
99
|
+
const decision = parsePermissionReply(text);
|
|
100
|
+
if (!decision) return false;
|
|
101
|
+
const prompt = pending.shift()!;
|
|
102
|
+
prompt.resolve(decision);
|
|
103
|
+
return true;
|
|
104
|
+
},
|
|
105
|
+
abortAll(reason = 'channel closed') {
|
|
106
|
+
// Resolve OUR pending prompt promises (deny) so the sdk scaffold's outer
|
|
107
|
+
// promises settle, then clear the scaffold's own in-flight set too.
|
|
108
|
+
for (const prompt of pending.splice(0)) {
|
|
109
|
+
prompt.resolve({ mode: 'deny', reason });
|
|
110
|
+
}
|
|
111
|
+
resolver.abortAll(reason);
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { cancel, intro, isCancel, log, note, outro, select, text } from '@clack/prompts';
|
|
2
|
+
import type { ChannelSubcommandContext } from '@moxxy/sdk';
|
|
3
|
+
import { moxxyPath } from '@moxxy/sdk/server';
|
|
4
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
5
|
+
import { createFileAuthStorage, hasStoredCreds } from './auth-state.js';
|
|
6
|
+
import { ensureConsentInteractive } from './consent-prompt.js';
|
|
7
|
+
import {
|
|
8
|
+
WHATSAPP_ALLOWED_JIDS_KEY,
|
|
9
|
+
WHATSAPP_AUTH_DIR,
|
|
10
|
+
WHATSAPP_OWNER_JID_KEY,
|
|
11
|
+
parseAllowedJids,
|
|
12
|
+
} from './keys.js';
|
|
13
|
+
import { runWhatsAppPairFlow } from './pair-flow.js';
|
|
14
|
+
|
|
15
|
+
// Tiny zero-dep ANSI helpers so the wizard stays inside the plugin.
|
|
16
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
17
|
+
const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
|
|
18
|
+
const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
19
|
+
|
|
20
|
+
interface State {
|
|
21
|
+
readonly linked: boolean;
|
|
22
|
+
readonly ownerJid: string | null;
|
|
23
|
+
readonly allowedJids: ReadonlyArray<string>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type Action = 'pair' | 'unpair' | 'allow-list' | 'start' | 'quit';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Interactive WhatsApp setup menu (the channel's `interactiveCommand`).
|
|
30
|
+
*
|
|
31
|
+
* The FIRST step — before any menu — is the consent gate: this channel rides an
|
|
32
|
+
* UNOFFICIAL WhatsApp client; the wizard states the ToS/ban risk plainly and
|
|
33
|
+
* requires a typed "yes" before anything else. No acknowledgment, no menu.
|
|
34
|
+
*/
|
|
35
|
+
export async function runWhatsAppWizard(ctx: ChannelSubcommandContext): Promise<number> {
|
|
36
|
+
const vault = ctx.deps.vault as VaultStore;
|
|
37
|
+
intro(bold('moxxy whatsapp setup'));
|
|
38
|
+
|
|
39
|
+
if (!(await ensureConsentInteractive(vault))) {
|
|
40
|
+
cancel('cancelled.');
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
while (true) {
|
|
45
|
+
const state = await readState(vault);
|
|
46
|
+
printStatus(state);
|
|
47
|
+
const action = await pickAction(state);
|
|
48
|
+
if (action === null) {
|
|
49
|
+
cancel('cancelled.');
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (action === 'quit') {
|
|
53
|
+
outro(dim('done.'));
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
if (action === 'pair') {
|
|
57
|
+
return await runWhatsAppPairFlow(ctx);
|
|
58
|
+
}
|
|
59
|
+
if (action === 'unpair') {
|
|
60
|
+
await unpairLocal(vault);
|
|
61
|
+
log.success(
|
|
62
|
+
'Local credentials cleared. Also remove the device on your phone: WhatsApp -> Settings -> Linked devices.',
|
|
63
|
+
);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (action === 'allow-list') {
|
|
67
|
+
await editAllowList(vault, state.allowedJids);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (action === 'start') {
|
|
71
|
+
log.info('Starting the channel. Press Ctrl+C to stop.');
|
|
72
|
+
outro(dim('handing off to the channel...'));
|
|
73
|
+
return ctx.startChannel();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Clear the rotating auth-state dir + the persisted owner identity. */
|
|
79
|
+
export async function unpairLocal(vault: VaultStore): Promise<boolean> {
|
|
80
|
+
const storage = createFileAuthStorage(moxxyPath(WHATSAPP_AUTH_DIR));
|
|
81
|
+
const had = await hasStoredCreds(storage);
|
|
82
|
+
await storage.clear();
|
|
83
|
+
await vault.delete(WHATSAPP_OWNER_JID_KEY).catch(() => undefined);
|
|
84
|
+
return had;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function readState(vault: VaultStore): Promise<State> {
|
|
88
|
+
const storage = createFileAuthStorage(moxxyPath(WHATSAPP_AUTH_DIR));
|
|
89
|
+
return {
|
|
90
|
+
linked: await hasStoredCreds(storage),
|
|
91
|
+
ownerJid: await vault.get(WHATSAPP_OWNER_JID_KEY),
|
|
92
|
+
allowedJids: parseAllowedJids(await vault.get(WHATSAPP_ALLOWED_JIDS_KEY)),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function printStatus(state: State): void {
|
|
97
|
+
const lines: string[] = [];
|
|
98
|
+
lines.push(`Linked ${state.linked ? bold(state.ownerJid ?? 'yes') : dim('no')}`);
|
|
99
|
+
lines.push(
|
|
100
|
+
`Allow-list ${
|
|
101
|
+
state.allowedJids.length > 0
|
|
102
|
+
? bold(state.allowedJids.join(', '))
|
|
103
|
+
: dim('only your own Note-to-Self chat')
|
|
104
|
+
}`,
|
|
105
|
+
);
|
|
106
|
+
note(lines.join('\n'), 'status');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function pickAction(state: State): Promise<Action | null> {
|
|
110
|
+
const options: Array<{ value: Action; label: string; hint?: string }> = [];
|
|
111
|
+
if (state.linked) {
|
|
112
|
+
options.push({
|
|
113
|
+
value: 'start',
|
|
114
|
+
label: 'Start the channel',
|
|
115
|
+
hint: 'runs forever - Ctrl+C to stop',
|
|
116
|
+
});
|
|
117
|
+
options.push({
|
|
118
|
+
value: 'unpair',
|
|
119
|
+
label: 'Unpair (forget local credentials)',
|
|
120
|
+
hint: 'also remove the device under Linked devices on the phone',
|
|
121
|
+
});
|
|
122
|
+
} else {
|
|
123
|
+
options.push({
|
|
124
|
+
value: 'pair',
|
|
125
|
+
label: 'Link a WhatsApp account (scan QR)',
|
|
126
|
+
hint: 'WhatsApp -> Settings -> Linked devices -> Link a device',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
options.push({
|
|
130
|
+
value: 'allow-list',
|
|
131
|
+
label: 'Edit the JID allow-list',
|
|
132
|
+
hint: 'who besides your own Note-to-Self chat may talk to the agent',
|
|
133
|
+
});
|
|
134
|
+
options.push({ value: 'quit', label: 'Quit' });
|
|
135
|
+
|
|
136
|
+
const choice = await select<Action>({ message: 'What do you want to do?', options });
|
|
137
|
+
if (isCancel(choice)) return null;
|
|
138
|
+
return choice as Action;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function editAllowList(
|
|
142
|
+
vault: VaultStore,
|
|
143
|
+
current: ReadonlyArray<string>,
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
note(
|
|
146
|
+
'Comma-separated JIDs, e.g. 15551234567@s.whatsapp.net (a group JID ends in\n' +
|
|
147
|
+
'@g.us — allow-listing a group trusts EVERYONE in it). Empty input clears the\n' +
|
|
148
|
+
'list; your own Note-to-Self chat is always allowed.',
|
|
149
|
+
'allow-list',
|
|
150
|
+
);
|
|
151
|
+
const answer = await text({
|
|
152
|
+
message: 'Allowed JIDs',
|
|
153
|
+
initialValue: current.join(', '),
|
|
154
|
+
placeholder: '15551234567@s.whatsapp.net',
|
|
155
|
+
});
|
|
156
|
+
if (isCancel(answer)) return;
|
|
157
|
+
const parsed = parseAllowedJids(String(answer));
|
|
158
|
+
await vault.set(WHATSAPP_ALLOWED_JIDS_KEY, JSON.stringify(parsed), ['whatsapp']);
|
|
159
|
+
log.success(
|
|
160
|
+
parsed.length > 0 ? `Allow-list saved (${parsed.length} JIDs).` : 'Allow-list cleared.',
|
|
161
|
+
);
|
|
162
|
+
}
|