@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,156 @@
|
|
|
1
|
+
import type { newTurnId } from '@moxxy/core';
|
|
2
|
+
import { FramePump, PlainTurnRenderer, driveTurn, subscribeTurn } from '@moxxy/channel-kit';
|
|
3
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
4
|
+
import type { WaMessageKey, WhatsAppSocket } from '../socket.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Streaming strategy (justified against the Baileys API):
|
|
8
|
+
*
|
|
9
|
+
* WhatsApp supports true message edits (a MESSAGE_EDIT protocol send —
|
|
10
|
+
* `sendMessage(jid, { text, edit: key })`, stable in Baileys since 6.5), so the
|
|
11
|
+
* channel streams the way Telegram/Slack do: send ONE message on the first
|
|
12
|
+
* frame, then edit it in place — the user watches a single message grow instead
|
|
13
|
+
* of a flood of partials that can't be assembled. Two WhatsApp-specific
|
|
14
|
+
* adjustments:
|
|
15
|
+
*
|
|
16
|
+
* - `editFrameMs` defaults to 3000 (vs 1000 elsewhere): every edit is a full
|
|
17
|
+
* outbound protocol message, and high-frequency automated edits are exactly
|
|
18
|
+
* the anomalous traffic that gets numbers flagged on an UNOFFICIAL client.
|
|
19
|
+
* Fewer, chunkier edits keep the visible behavior close to a human editing
|
|
20
|
+
* a message. (WhatsApp's ~15-minute edit window is irrelevant at turn scale.)
|
|
21
|
+
*
|
|
22
|
+
* - overflow splitting happens ONLY on the final frame: while streaming, the
|
|
23
|
+
* frame is truncated to {@link WHATSAPP_MAX_MESSAGE_CHARS}; the final flush
|
|
24
|
+
* edits the streamed message to the first chunk and sends the tail chunks as
|
|
25
|
+
* follow-up messages (mirrors Telegram's split-tails). If a FINAL edit fails
|
|
26
|
+
* (edit rejected / message gone), the sink falls back to sending the chunks
|
|
27
|
+
* as new messages so the reply is never silently lost.
|
|
28
|
+
*/
|
|
29
|
+
export const WHATSAPP_MAX_MESSAGE_CHARS = 4000;
|
|
30
|
+
export const DEFAULT_EDIT_FRAME_MS = 3000;
|
|
31
|
+
|
|
32
|
+
/** Split text into ≤maxLen chunks, preferring newline then space boundaries. */
|
|
33
|
+
export function splitWhatsAppText(
|
|
34
|
+
text: string,
|
|
35
|
+
maxLen: number = WHATSAPP_MAX_MESSAGE_CHARS,
|
|
36
|
+
): string[] {
|
|
37
|
+
if (text.length <= maxLen) return [text];
|
|
38
|
+
const chunks: string[] = [];
|
|
39
|
+
let rest = text;
|
|
40
|
+
while (rest.length > maxLen) {
|
|
41
|
+
const window = rest.slice(0, maxLen);
|
|
42
|
+
let cut = window.lastIndexOf('\n');
|
|
43
|
+
if (cut < maxLen * 0.5) cut = window.lastIndexOf(' ');
|
|
44
|
+
if (cut < maxLen * 0.5) cut = maxLen;
|
|
45
|
+
chunks.push(rest.slice(0, cut));
|
|
46
|
+
rest = rest.slice(cut).replace(/^[\n ]/, '');
|
|
47
|
+
}
|
|
48
|
+
if (rest.length > 0) chunks.push(rest);
|
|
49
|
+
return chunks;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface TurnRunnerLogger {
|
|
53
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunWhatsAppTurnDeps {
|
|
57
|
+
readonly session: Session;
|
|
58
|
+
readonly socket: WhatsAppSocket;
|
|
59
|
+
readonly editFrameMs: number;
|
|
60
|
+
/** Record an outbound message id (echo/loop protection in the gate). */
|
|
61
|
+
readonly recordSentId: (key: WaMessageKey | null | undefined) => void;
|
|
62
|
+
readonly logger?: TurnRunnerLogger;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RunWhatsAppTurnOptions {
|
|
66
|
+
readonly jid: string;
|
|
67
|
+
readonly text: string;
|
|
68
|
+
readonly model?: string | undefined;
|
|
69
|
+
readonly controller: AbortController;
|
|
70
|
+
/** Pre-minted turn id; the channel records it as an own-turn id (#8). */
|
|
71
|
+
readonly turnId: ReturnType<typeof newTurnId>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Drive a single WhatsApp turn end-to-end: subscribe the frame pump to THIS
|
|
76
|
+
* turn's events (turnId-filtered — `session.log` fans out to every listener, so
|
|
77
|
+
* a concurrent turn on the same Session would otherwise stream into this chat,
|
|
78
|
+
* AGENTS.md invariant #8), run the turn, flush the final frame, unwind in
|
|
79
|
+
* `finally`.
|
|
80
|
+
*/
|
|
81
|
+
export async function runWhatsAppTurn(
|
|
82
|
+
deps: RunWhatsAppTurnDeps,
|
|
83
|
+
opts: RunWhatsAppTurnOptions,
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const { session, socket, editFrameMs, recordSentId, logger } = deps;
|
|
86
|
+
const { jid, text, model, controller, turnId } = opts;
|
|
87
|
+
|
|
88
|
+
const renderer = new PlainTurnRenderer();
|
|
89
|
+
|
|
90
|
+
const sendChunks = async (chunks: ReadonlyArray<string>): Promise<WaMessageKey | null> => {
|
|
91
|
+
let firstKey: WaMessageKey | null = null;
|
|
92
|
+
for (const chunk of chunks) {
|
|
93
|
+
try {
|
|
94
|
+
const sent = await socket.sendText(jid, chunk);
|
|
95
|
+
recordSentId(sent?.key);
|
|
96
|
+
if (!firstKey && sent?.key) firstKey = sent.key;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
logger?.warn?.('whatsapp send failed', { err: String(err) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return firstKey;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const pump = new FramePump<WaMessageKey>({
|
|
105
|
+
editFrameMs,
|
|
106
|
+
frame: (final) => {
|
|
107
|
+
const snapshot = renderer.snapshot();
|
|
108
|
+
if (final || snapshot.length <= WHATSAPP_MAX_MESSAGE_CHARS) return snapshot;
|
|
109
|
+
// Streaming frame: hold to one editable message; overflow waits for the
|
|
110
|
+
// final flush, whose full text differs from this so a last flush is
|
|
111
|
+
// guaranteed to deliver the tails.
|
|
112
|
+
return `${snapshot.slice(0, WHATSAPP_MAX_MESSAGE_CHARS - 1)}…`;
|
|
113
|
+
},
|
|
114
|
+
emptyFinalText: '(no output)',
|
|
115
|
+
sink: {
|
|
116
|
+
send: async (t) => sendChunks(splitWhatsAppText(t)),
|
|
117
|
+
edit: async (key, t, final) => {
|
|
118
|
+
const chunks = splitWhatsAppText(t);
|
|
119
|
+
try {
|
|
120
|
+
await socket.editText(jid, key, chunks[0]!);
|
|
121
|
+
} catch (err) {
|
|
122
|
+
logger?.warn?.('whatsapp edit failed', { err: String(err), final });
|
|
123
|
+
if (final) {
|
|
124
|
+
// Never lose the final reply to a failed edit — resend it whole.
|
|
125
|
+
await sendChunks(chunks);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (final && chunks.length > 1) await sendChunks(chunks.slice(1));
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const unsubscribe = subscribeTurn(session, turnId, (event) => {
|
|
136
|
+
if (renderer.accept(event)) pump.scheduleEdit();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await driveTurn(session, {
|
|
141
|
+
turnId,
|
|
142
|
+
prompt: text,
|
|
143
|
+
...(model ? { model } : {}),
|
|
144
|
+
signal: controller.signal,
|
|
145
|
+
});
|
|
146
|
+
await pump.flush(true);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger?.warn?.('whatsapp turn failed', {
|
|
149
|
+
err: err instanceof Error ? err.message : String(err),
|
|
150
|
+
});
|
|
151
|
+
await sendChunks([`Turn failed: ${err instanceof Error ? err.message : String(err)}`]);
|
|
152
|
+
} finally {
|
|
153
|
+
unsubscribe();
|
|
154
|
+
pump.dispose();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ClientSession as Session } from '@moxxy/sdk';
|
|
2
|
+
import { MAX_AUDIO_BYTES } from '../message-gate.js';
|
|
3
|
+
import type { WaInboundMessage, WhatsAppSocket } from '../socket.js';
|
|
4
|
+
|
|
5
|
+
export interface VoiceHandlerDeps {
|
|
6
|
+
readonly session: Session;
|
|
7
|
+
readonly socket: WhatsAppSocket;
|
|
8
|
+
/** Reply into the originating chat (records sent ids for echo protection). */
|
|
9
|
+
readonly reply: (jid: string, text: string) => Promise<void>;
|
|
10
|
+
readonly logger?: { warn?(msg: string, meta?: Record<string, unknown>): void };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface VoiceHandlerInput {
|
|
14
|
+
readonly jid: string;
|
|
15
|
+
readonly mimeType: string;
|
|
16
|
+
/** The raw upsert message — Baileys needs it to decrypt/download the media. */
|
|
17
|
+
readonly raw: WaInboundMessage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Handle an inbound voice note / audio message that already passed the gate
|
|
22
|
+
* (pairing + allow-list + declared-size cap): require an active Transcriber
|
|
23
|
+
* (guidance reply when none — mirror of the Telegram voice handler), download
|
|
24
|
+
* via Baileys' media pipeline, re-check the size cap on the REAL bytes, then
|
|
25
|
+
* transcribe. Returns the transcript for the caller to run as a normal user
|
|
26
|
+
* turn, or null when the message was fully handled (guidance/error reply sent).
|
|
27
|
+
*/
|
|
28
|
+
export async function transcribeVoiceMessage(
|
|
29
|
+
deps: VoiceHandlerDeps,
|
|
30
|
+
input: VoiceHandlerInput,
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
const { session, socket, reply, logger } = deps;
|
|
33
|
+
const { jid, mimeType, raw } = input;
|
|
34
|
+
|
|
35
|
+
const transcriber = session.transcribers.tryGetActive();
|
|
36
|
+
if (!transcriber) {
|
|
37
|
+
await reply(
|
|
38
|
+
jid,
|
|
39
|
+
'Heard a voice note, but no speech-to-text backend is configured. Install ' +
|
|
40
|
+
'@moxxy/plugin-stt-whisper and run `moxxy login openai` (or set OPENAI_API_KEY) ' +
|
|
41
|
+
'to enable voice input.',
|
|
42
|
+
);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let bytes: Uint8Array;
|
|
47
|
+
try {
|
|
48
|
+
bytes = await socket.downloadMedia(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
logger?.warn?.('whatsapp voice download failed', {
|
|
51
|
+
err: err instanceof Error ? err.message : String(err),
|
|
52
|
+
});
|
|
53
|
+
await reply(jid, 'Could not download that voice note.');
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (bytes.byteLength > MAX_AUDIO_BYTES) {
|
|
57
|
+
await reply(
|
|
58
|
+
jid,
|
|
59
|
+
`That audio is too large to transcribe (limit ${MAX_AUDIO_BYTES / (1024 * 1024)}MB).`,
|
|
60
|
+
);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let transcript: string;
|
|
65
|
+
try {
|
|
66
|
+
transcript = (await transcriber.transcribe(bytes, { mimeType })).text.trim();
|
|
67
|
+
} catch (err) {
|
|
68
|
+
logger?.warn?.('whatsapp voice transcription failed', {
|
|
69
|
+
err: err instanceof Error ? err.message : String(err),
|
|
70
|
+
});
|
|
71
|
+
await reply(jid, `Transcription failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (!transcript) {
|
|
75
|
+
await reply(jid, 'Could not transcribe the voice note (got empty text).');
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Echo what was heard so the user can spot misrecognitions before the agent
|
|
80
|
+
// acts on it (same contract as the Telegram voice handler).
|
|
81
|
+
await reply(jid, `heard: ${transcript}`);
|
|
82
|
+
return transcript;
|
|
83
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
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 { Session, autoAllowResolver, silentLogger } from '@moxxy/core';
|
|
6
|
+
import { FakeProvider, streamingTextReply } from '@moxxy/testing';
|
|
7
|
+
import { definePlugin, defineProvider, defineTranscriber } from '@moxxy/sdk';
|
|
8
|
+
import { defaultModePlugin } from '@moxxy/mode-default';
|
|
9
|
+
import { VaultStore, createStaticKeySource, deriveKey, generateSalt } from '@moxxy/plugin-vault';
|
|
10
|
+
import { WhatsAppChannel } from './channel.js';
|
|
11
|
+
import type {
|
|
12
|
+
WaConnectionUpdate,
|
|
13
|
+
WaInboundMessage,
|
|
14
|
+
WaMessageKey,
|
|
15
|
+
WaMessagesUpsert,
|
|
16
|
+
WhatsAppSocket,
|
|
17
|
+
WhatsAppSocketFactoryOptions,
|
|
18
|
+
} from './socket.js';
|
|
19
|
+
import { createFileAuthStorage } from './auth-state.js';
|
|
20
|
+
import { WHATSAPP_CONSENT_ENV } from './keys.js';
|
|
21
|
+
|
|
22
|
+
const OWNER = '15550000000@s.whatsapp.net';
|
|
23
|
+
const FRIEND = '15551111111@s.whatsapp.net';
|
|
24
|
+
const STRANGER = '15559999999@s.whatsapp.net';
|
|
25
|
+
|
|
26
|
+
interface SentRecord {
|
|
27
|
+
jid: string;
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Scriptable in-memory WhatsApp socket — no network, no Baileys. */
|
|
32
|
+
class FakeSocket implements WhatsAppSocket {
|
|
33
|
+
connCb: ((u: WaConnectionUpdate) => void) | null = null;
|
|
34
|
+
msgCb: ((u: WaMessagesUpsert) => void) | null = null;
|
|
35
|
+
readonly sent: SentRecord[] = [];
|
|
36
|
+
readonly edits: Array<{ jid: string; id: string; text: string }> = [];
|
|
37
|
+
private jid: string | null = null;
|
|
38
|
+
private seq = 0;
|
|
39
|
+
downloadImpl: (m: WaInboundMessage) => Promise<Uint8Array> = async () => new Uint8Array();
|
|
40
|
+
ended = false;
|
|
41
|
+
|
|
42
|
+
userJid(): string | null {
|
|
43
|
+
return this.jid;
|
|
44
|
+
}
|
|
45
|
+
onConnectionUpdate(cb: (u: WaConnectionUpdate) => void): void {
|
|
46
|
+
this.connCb = cb;
|
|
47
|
+
}
|
|
48
|
+
onMessages(cb: (u: WaMessagesUpsert) => void): void {
|
|
49
|
+
this.msgCb = cb;
|
|
50
|
+
}
|
|
51
|
+
async sendText(jid: string, text: string): Promise<{ key: WaMessageKey } | null> {
|
|
52
|
+
const id = `out-${++this.seq}`;
|
|
53
|
+
this.sent.push({ jid, text });
|
|
54
|
+
return { key: { remoteJid: jid, fromMe: true, id } };
|
|
55
|
+
}
|
|
56
|
+
async editText(jid: string, key: WaMessageKey, text: string): Promise<void> {
|
|
57
|
+
this.edits.push({ jid, id: key.id ?? '', text });
|
|
58
|
+
}
|
|
59
|
+
async downloadMedia(m: WaInboundMessage): Promise<Uint8Array> {
|
|
60
|
+
return this.downloadImpl(m);
|
|
61
|
+
}
|
|
62
|
+
end(): void {
|
|
63
|
+
this.ended = true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- test drivers ----
|
|
67
|
+
open(jid: string): void {
|
|
68
|
+
this.jid = jid;
|
|
69
|
+
this.connCb?.({ connection: 'open' });
|
|
70
|
+
}
|
|
71
|
+
qr(payload: string): void {
|
|
72
|
+
this.connCb?.({ qr: payload });
|
|
73
|
+
}
|
|
74
|
+
close(err?: unknown): void {
|
|
75
|
+
this.connCb?.({ connection: 'close', lastDisconnect: err ? { error: err } : undefined });
|
|
76
|
+
}
|
|
77
|
+
inbound(message: WaInboundMessage, type = 'notify'): void {
|
|
78
|
+
this.msgCb?.({ type, messages: [message] });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function textMsg(remoteJid: string, text: string, fromMe = false, id = `in-${Math.random()}`): WaInboundMessage {
|
|
83
|
+
return { key: { remoteJid, fromMe, id }, message: { conversation: text } };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let tmp: string;
|
|
87
|
+
let vault: VaultStore;
|
|
88
|
+
let session: Session;
|
|
89
|
+
let provider: FakeProvider;
|
|
90
|
+
|
|
91
|
+
beforeEach(async () => {
|
|
92
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-wa-chan-'));
|
|
93
|
+
process.env[WHATSAPP_CONSENT_ENV] = 'yes';
|
|
94
|
+
vault = new VaultStore({
|
|
95
|
+
filePath: path.join(tmp, 'vault.json'),
|
|
96
|
+
keySource: createStaticKeySource(deriveKey('test', generateSalt())),
|
|
97
|
+
});
|
|
98
|
+
provider = new FakeProvider({ script: [streamingTextReply(['Hello ', 'world'])] });
|
|
99
|
+
session = new Session({ cwd: tmp, logger: silentLogger, permissionResolver: autoAllowResolver });
|
|
100
|
+
session.pluginHost.registerStatic(
|
|
101
|
+
definePlugin({
|
|
102
|
+
name: 'shim',
|
|
103
|
+
providers: [
|
|
104
|
+
defineProvider({ name: provider.name, models: [...provider.models], createClient: () => provider }),
|
|
105
|
+
],
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
session.providers.setActive(provider.name);
|
|
109
|
+
session.pluginHost.registerStatic(defaultModePlugin);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterEach(async () => {
|
|
113
|
+
await session.close('test').catch(() => undefined);
|
|
114
|
+
// The Baileys auth dir may still be mid-write from a socket that just closed;
|
|
115
|
+
// a single rm can race it (ENOTEMPTY). Retry briefly, then give up.
|
|
116
|
+
for (let i = 0; i < 5; i++) {
|
|
117
|
+
try {
|
|
118
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
119
|
+
break;
|
|
120
|
+
} catch {
|
|
121
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
delete process.env[WHATSAPP_CONSENT_ENV];
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
function makeChannel(fake: FakeSocket, opts: Partial<{ allowedJids: string[] }> = {}): WhatsAppChannel {
|
|
128
|
+
return new WhatsAppChannel({
|
|
129
|
+
vault,
|
|
130
|
+
editFrameMs: 5,
|
|
131
|
+
socketFactory: async (_o: WhatsAppSocketFactoryOptions) => fake,
|
|
132
|
+
// Pre-seeded creds file so start() doesn't demand pair mode.
|
|
133
|
+
authStorage: seededStorage(),
|
|
134
|
+
...(opts.allowedJids ? { allowedJids: opts.allowedJids } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A storage with a creds record present (simulates an already-linked account). */
|
|
139
|
+
function seededStorage() {
|
|
140
|
+
const storage = createFileAuthStorage(path.join(tmp, 'auth'));
|
|
141
|
+
return {
|
|
142
|
+
...storage,
|
|
143
|
+
read: async (k: string) => (k === 'creds' ? '{"me":1}' : storage.read(k)),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe('WhatsAppChannel.start', () => {
|
|
148
|
+
it('refuses to start without consent', async () => {
|
|
149
|
+
delete process.env[WHATSAPP_CONSENT_ENV];
|
|
150
|
+
const channel = makeChannel(new FakeSocket());
|
|
151
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
152
|
+
await expect(channel.start({ session, dedicated: true })).rejects.toThrow(/ToS|acknowledg|ban/i);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('refuses to start unlinked outside pair mode', async () => {
|
|
156
|
+
const fake = new FakeSocket();
|
|
157
|
+
const channel = new WhatsAppChannel({
|
|
158
|
+
vault,
|
|
159
|
+
socketFactory: async () => fake,
|
|
160
|
+
authStorage: createFileAuthStorage(path.join(tmp, 'empty-auth')),
|
|
161
|
+
});
|
|
162
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
163
|
+
await expect(channel.start({ session })).rejects.toThrow(/No WhatsApp account is linked/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('drives a full turn for the owner Note-to-Self chat and streams via edits', async () => {
|
|
167
|
+
const fake = new FakeSocket();
|
|
168
|
+
const channel = makeChannel(fake);
|
|
169
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
170
|
+
const handle = await channel.start({ session });
|
|
171
|
+
fake.open(OWNER);
|
|
172
|
+
|
|
173
|
+
fake.inbound(textMsg(OWNER, 'hi', true));
|
|
174
|
+
await vi.waitFor(() => {
|
|
175
|
+
const all = [...fake.sent.map((s) => s.text), ...fake.edits.map((e) => e.text)];
|
|
176
|
+
expect(all.join('')).toContain('Hello world');
|
|
177
|
+
});
|
|
178
|
+
expect(channel.connected).toBe(true);
|
|
179
|
+
await handle.stop('test done');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('runs a turn for an allow-listed friend', async () => {
|
|
183
|
+
const fake = new FakeSocket();
|
|
184
|
+
const channel = makeChannel(fake, { allowedJids: [FRIEND] });
|
|
185
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
186
|
+
const handle = await channel.start({ session });
|
|
187
|
+
fake.open(OWNER);
|
|
188
|
+
|
|
189
|
+
fake.inbound(textMsg(FRIEND, 'hey bot'));
|
|
190
|
+
await vi.waitFor(() => expect(provider.received.length).toBeGreaterThan(0));
|
|
191
|
+
await handle.stop('test done');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('drops an un-allow-listed stranger with no reply', async () => {
|
|
195
|
+
const fake = new FakeSocket();
|
|
196
|
+
const channel = makeChannel(fake);
|
|
197
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
198
|
+
const handle = await channel.start({ session });
|
|
199
|
+
fake.open(OWNER);
|
|
200
|
+
|
|
201
|
+
fake.inbound(textMsg(STRANGER, 'let me in'));
|
|
202
|
+
// Give the async dispatch a tick; nothing should be sent, no turn should run.
|
|
203
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
204
|
+
expect(provider.received.length).toBe(0);
|
|
205
|
+
expect(fake.sent).toHaveLength(0);
|
|
206
|
+
await handle.stop('test done');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('does NOT reprocess its own outbound echo (loop protection)', async () => {
|
|
210
|
+
const fake = new FakeSocket();
|
|
211
|
+
const channel = makeChannel(fake);
|
|
212
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
213
|
+
const handle = await channel.start({ session });
|
|
214
|
+
fake.open(OWNER);
|
|
215
|
+
|
|
216
|
+
fake.inbound(textMsg(OWNER, 'hi', true));
|
|
217
|
+
await vi.waitFor(() => expect(fake.sent.length + fake.edits.length).toBeGreaterThan(0));
|
|
218
|
+
const turnsBefore = provider.received.length;
|
|
219
|
+
|
|
220
|
+
// Feed the channel's own last send back as an inbound (fromMe echo).
|
|
221
|
+
const lastOut = fake.sent[fake.sent.length - 1]!;
|
|
222
|
+
// Reconstruct the outbound key id: the fake numbers them out-N; grab it via
|
|
223
|
+
// a fresh send to learn the id shape isn't needed — echo by a known own id.
|
|
224
|
+
fake.inbound({ key: { remoteJid: OWNER, fromMe: true, id: 'out-1' }, message: { conversation: lastOut.text } });
|
|
225
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
226
|
+
expect(provider.received.length).toBe(turnsBefore);
|
|
227
|
+
await handle.stop('test done');
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('transcribes a voice note through the active transcriber, then runs a turn', async () => {
|
|
231
|
+
// Register a stub transcriber on the session, then activate it.
|
|
232
|
+
session.pluginHost.registerStatic(
|
|
233
|
+
definePlugin({
|
|
234
|
+
name: 'stub-stt',
|
|
235
|
+
transcribers: [
|
|
236
|
+
defineTranscriber({
|
|
237
|
+
name: 'stub',
|
|
238
|
+
createClient: () => ({
|
|
239
|
+
name: 'stub',
|
|
240
|
+
transcribe: async () => ({ text: 'transcribed prompt' }),
|
|
241
|
+
}),
|
|
242
|
+
}),
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
session.transcribers.setActive('stub');
|
|
247
|
+
|
|
248
|
+
const fake = new FakeSocket();
|
|
249
|
+
fake.downloadImpl = async () => new Uint8Array([1, 2, 3]);
|
|
250
|
+
const channel = makeChannel(fake);
|
|
251
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
252
|
+
const handle = await channel.start({ session });
|
|
253
|
+
fake.open(OWNER);
|
|
254
|
+
|
|
255
|
+
fake.inbound({
|
|
256
|
+
key: { remoteJid: OWNER, fromMe: true, id: 'v1' },
|
|
257
|
+
message: { audioMessage: { mimetype: 'audio/ogg', fileLength: 3, ptt: true } },
|
|
258
|
+
});
|
|
259
|
+
await vi.waitFor(() => {
|
|
260
|
+
expect(fake.sent.some((s) => s.text.includes('heard: transcribed prompt'))).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
await vi.waitFor(() => expect(provider.received.length).toBeGreaterThan(0));
|
|
263
|
+
await handle.stop('test done');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('guides the user when a voice note arrives with no transcriber', async () => {
|
|
267
|
+
const fake = new FakeSocket();
|
|
268
|
+
fake.downloadImpl = async () => new Uint8Array([1]);
|
|
269
|
+
const channel = makeChannel(fake);
|
|
270
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
271
|
+
const handle = await channel.start({ session });
|
|
272
|
+
fake.open(OWNER);
|
|
273
|
+
|
|
274
|
+
fake.inbound({
|
|
275
|
+
key: { remoteJid: OWNER, fromMe: true, id: 'v2' },
|
|
276
|
+
message: { audioMessage: { mimetype: 'audio/ogg', fileLength: 1 } },
|
|
277
|
+
});
|
|
278
|
+
await vi.waitFor(() => {
|
|
279
|
+
expect(fake.sent.some((s) => /speech-to-text/i.test(s.text))).toBe(true);
|
|
280
|
+
});
|
|
281
|
+
expect(provider.received.length).toBe(0);
|
|
282
|
+
await handle.stop('test done');
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('publishes rotating QR payloads as requestUrl in pair mode', async () => {
|
|
286
|
+
const fake = new FakeSocket();
|
|
287
|
+
const channel = new WhatsAppChannel({
|
|
288
|
+
vault,
|
|
289
|
+
socketFactory: async () => fake,
|
|
290
|
+
authStorage: createFileAuthStorage(path.join(tmp, 'pair-auth')),
|
|
291
|
+
});
|
|
292
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
293
|
+
const handle = await channel.start({ session, pair: true });
|
|
294
|
+
expect(channel.connected).toBe(false);
|
|
295
|
+
|
|
296
|
+
let changes = 0;
|
|
297
|
+
handle.onConnectChange?.(() => changes++);
|
|
298
|
+
fake.qr('qr-payload-1');
|
|
299
|
+
expect(channel.requestUrl).toBe('qr-payload-1');
|
|
300
|
+
expect(changes).toBe(1);
|
|
301
|
+
|
|
302
|
+
// Linking clears the QR and flips connected.
|
|
303
|
+
fake.open(OWNER);
|
|
304
|
+
expect(channel.requestUrl).toBeNull();
|
|
305
|
+
expect(channel.connected).toBe(true);
|
|
306
|
+
await handle.stop('test done');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('stop() aborts pending permission prompts (no hang)', async () => {
|
|
310
|
+
const fake = new FakeSocket();
|
|
311
|
+
const channel = makeChannel(fake);
|
|
312
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
313
|
+
const handle = await channel.start({ session });
|
|
314
|
+
fake.open(OWNER);
|
|
315
|
+
// Set a chat as "last served" so the prompt has a target.
|
|
316
|
+
fake.inbound(textMsg(OWNER, 'hi', true));
|
|
317
|
+
await vi.waitFor(() => expect(fake.sent.length + fake.edits.length).toBeGreaterThan(0));
|
|
318
|
+
|
|
319
|
+
const pending = channel.permissionResolver.check(
|
|
320
|
+
{ callId: 'c1', name: 'write_file', input: {} } as never,
|
|
321
|
+
{} as never,
|
|
322
|
+
);
|
|
323
|
+
await vi.waitFor(() =>
|
|
324
|
+
expect(fake.sent.some((s) => /Permission needed/.test(s.text))).toBe(true),
|
|
325
|
+
);
|
|
326
|
+
await handle.stop('shutdown');
|
|
327
|
+
expect((await pending).mode).toBe('deny');
|
|
328
|
+
expect(fake.ended).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('re-pair guidance: a logout in non-pair mode fails the running promise', async () => {
|
|
332
|
+
const fake = new FakeSocket();
|
|
333
|
+
const channel = makeChannel(fake);
|
|
334
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
335
|
+
const handle = await channel.start({ session });
|
|
336
|
+
fake.open(OWNER);
|
|
337
|
+
const failed = handle.running.then(
|
|
338
|
+
() => 'resolved',
|
|
339
|
+
(err: Error) => err.message,
|
|
340
|
+
);
|
|
341
|
+
fake.close({ output: { statusCode: 401 } });
|
|
342
|
+
expect(await failed).toMatch(/logged this device out|re-link/i);
|
|
343
|
+
});
|
|
344
|
+
});
|