@moxxy/plugin-channel-whatsapp 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth-state.d.ts +66 -0
  3. package/dist/auth-state.d.ts.map +1 -0
  4. package/dist/auth-state.js +99 -0
  5. package/dist/auth-state.js.map +1 -0
  6. package/dist/baileys-socket.d.ts +10 -0
  7. package/dist/baileys-socket.d.ts.map +1 -0
  8. package/dist/baileys-socket.js +93 -0
  9. package/dist/baileys-socket.js.map +1 -0
  10. package/dist/channel/turn-runner.d.ts +58 -0
  11. package/dist/channel/turn-runner.d.ts.map +1 -0
  12. package/dist/channel/turn-runner.js +130 -0
  13. package/dist/channel/turn-runner.js.map +1 -0
  14. package/dist/channel/voice-handler.d.ts +27 -0
  15. package/dist/channel/voice-handler.d.ts.map +1 -0
  16. package/dist/channel/voice-handler.js +55 -0
  17. package/dist/channel/voice-handler.js.map +1 -0
  18. package/dist/channel.d.ts +105 -0
  19. package/dist/channel.d.ts.map +1 -0
  20. package/dist/channel.js +459 -0
  21. package/dist/channel.js.map +1 -0
  22. package/dist/consent-prompt.d.ts +9 -0
  23. package/dist/consent-prompt.d.ts.map +1 -0
  24. package/dist/consent-prompt.js +28 -0
  25. package/dist/consent-prompt.js.map +1 -0
  26. package/dist/consent.d.ts +22 -0
  27. package/dist/consent.d.ts.map +1 -0
  28. package/dist/consent.js +40 -0
  29. package/dist/consent.js.map +1 -0
  30. package/dist/index.d.ts +26 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +211 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/keys.d.ts +39 -0
  35. package/dist/keys.d.ts.map +1 -0
  36. package/dist/keys.js +86 -0
  37. package/dist/keys.js.map +1 -0
  38. package/dist/message-gate.d.ts +50 -0
  39. package/dist/message-gate.d.ts.map +1 -0
  40. package/dist/message-gate.js +150 -0
  41. package/dist/message-gate.js.map +1 -0
  42. package/dist/pair-flow.d.ts +12 -0
  43. package/dist/pair-flow.d.ts.map +1 -0
  44. package/dist/pair-flow.js +120 -0
  45. package/dist/pair-flow.js.map +1 -0
  46. package/dist/permission.d.ts +29 -0
  47. package/dist/permission.d.ts.map +1 -0
  48. package/dist/permission.js +78 -0
  49. package/dist/permission.js.map +1 -0
  50. package/dist/setup-wizard.d.ts +13 -0
  51. package/dist/setup-wizard.d.ts.map +1 -0
  52. package/dist/setup-wizard.js +127 -0
  53. package/dist/setup-wizard.js.map +1 -0
  54. package/dist/socket.d.ts +72 -0
  55. package/dist/socket.d.ts.map +1 -0
  56. package/dist/socket.js +22 -0
  57. package/dist/socket.js.map +1 -0
  58. package/package.json +99 -0
  59. package/src/auth-state.test.ts +103 -0
  60. package/src/auth-state.ts +161 -0
  61. package/src/baileys-socket.ts +154 -0
  62. package/src/channel/turn-runner.test.ts +31 -0
  63. package/src/channel/turn-runner.ts +156 -0
  64. package/src/channel/voice-handler.ts +83 -0
  65. package/src/channel.test.ts +344 -0
  66. package/src/channel.ts +571 -0
  67. package/src/consent-prompt.ts +28 -0
  68. package/src/consent.test.ts +56 -0
  69. package/src/consent.ts +48 -0
  70. package/src/index.ts +284 -0
  71. package/src/keys.test.ts +59 -0
  72. package/src/keys.ts +78 -0
  73. package/src/message-gate.test.ts +121 -0
  74. package/src/message-gate.ts +184 -0
  75. package/src/pair-flow.ts +133 -0
  76. package/src/permission.test.ts +94 -0
  77. package/src/permission.ts +114 -0
  78. package/src/setup-wizard.ts +162 -0
  79. package/src/socket.test.ts +17 -0
  80. package/src/socket.ts +89 -0
  81. package/src/subcommands.test.ts +198 -0
@@ -0,0 +1,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
+ });