@melandlabs/integrations-whatsapp 0.1.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.
@@ -0,0 +1,354 @@
1
+ import { MessagePlatformAdapter, Messages, MessageTarget, MessageEvent } from '@melandlabs/integrations-channels';
2
+ import { ExtractedMessageInfo } from '@melandlabs/integrations-channels/sources/types';
3
+ import { BaileysAuthStateProvider, FileIngester, ClientRegistry, ConfigProvider } from '@melandlabs/integrations/core';
4
+ import { WASocket, WAMessage } from '@whiskeysockets/baileys';
5
+ export { WhatsAppConversationStore } from './conversation-store.js';
6
+ export { WhatsAppClientRegistry } from './client-registry.js';
7
+ export { markdownToWhatsApp } from './markdown.js';
8
+
9
+ /**
10
+ * WhatsApp Adapter using @whiskeysockets/baileys (WebSocket protocol)
11
+ *
12
+ * Replaces whatsapp-web.js (Puppeteer/Chromium) with Baileys WebSocket.
13
+ * No browser = no automation detection surface.
14
+ *
15
+ * This adapter is platform-agnostic - web-specific dependencies are provided
16
+ * via interfaces (CredentialStore, FileIngester, ClientRegistry, etc.)
17
+ */
18
+
19
+ type WhatsAppDialogInfo = {
20
+ id: string;
21
+ name: string;
22
+ type: "private" | "group";
23
+ };
24
+ type WhatsAppUserInfo = {
25
+ wid: string;
26
+ pushName?: string;
27
+ formattedNumber?: string;
28
+ };
29
+ type WhatsAppLoginCallbacks = {
30
+ onQr?: (qr: string) => Promise<void> | void;
31
+ onCode?: (code: string) => Promise<void> | void;
32
+ onSession?: (session: unknown) => Promise<void> | void;
33
+ onReady?: (info: WhatsAppUserInfo) => Promise<void> | void;
34
+ onError?: (error: Error) => Promise<void> | void;
35
+ };
36
+ /**
37
+ * Module-level registry of active WhatsAppAdapters by sessionId.
38
+ * Used by QR login to expose the adapter for socket re-registration by accountId.
39
+ */
40
+ declare const activeAdapters: Map<string, WhatsAppAdapter>;
41
+
42
+ declare class WhatsAppAdapter extends MessagePlatformAdapter {
43
+ sock: WASocket | null;
44
+ botId: string;
45
+ messages: Messages;
46
+ name: string;
47
+ private sessionId;
48
+ private authStateProvider;
49
+ private authState;
50
+ private isReady;
51
+ /** In-memory chat list (replaces makeInMemoryStore in v7). Updated via chats.upsert events. */
52
+ private chats;
53
+ private asyncIteratorState;
54
+ /** On-demand history backfill budget, reset per getChatsByChunk cycle. */
55
+ private backfillRequestsThisCycle;
56
+ /** Persistent message store; created here when no listener attached one. */
57
+ private messageStore;
58
+ /** Override for the message store base dir (tests). */
59
+ private historyStoreDir?;
60
+ /** False when sessionId fell back to a random uuid (no botId/sessionKey). */
61
+ private hasStableSessionId;
62
+ private isAuthenticated;
63
+ private initializationPromise;
64
+ /** Exposed so other adapters (e.g. insights bot) can await socket readiness. */
65
+ get pendingInitialization(): Promise<void> | null;
66
+ private loginDeferred;
67
+ private ownerUserId?;
68
+ private ownerUserType?;
69
+ private eventCleanup;
70
+ /** Pending reconnect socket creation, used to prevent concurrent reconnects */
71
+ private _pendingReconnect;
72
+ private reconnectTimer;
73
+ private isClosed;
74
+ /** Cache of recently sent messages for msgRetry requests (max 256 entries). */
75
+ private sentMessageCache;
76
+ private fileIngester?;
77
+ private clientRegistry?;
78
+ private configProvider?;
79
+ constructor(opts?: {
80
+ botId?: string;
81
+ ownerUserId?: string;
82
+ ownerUserType?: string;
83
+ /** Override sessionId — used by insight bot to reuse QR login's session */
84
+ sessionKey?: string;
85
+ authStateProvider: BaileysAuthStateProvider;
86
+ fileIngester?: FileIngester;
87
+ clientRegistry?: ClientRegistry;
88
+ configProvider?: ConfigProvider;
89
+ /** Override the persistent message store base dir (used by tests) */
90
+ historyStoreDir?: string;
91
+ });
92
+ /**
93
+ * Register the socket under an additional key (e.g. account.id).
94
+ * Call this after QR login succeeds and integration account is created,
95
+ * so self-listener and insight bot can find the socket by accountId.
96
+ */
97
+ setRegisterSocketAs(key: string): void;
98
+ /**
99
+ * Find an active adapter by sessionId and register its socket under accountId.
100
+ * Called from guest-guide after integration account is created.
101
+ */
102
+ static registerSocketByAccountId(sessionId: string, accountId: string): void;
103
+ private ensureAuthState;
104
+ /**
105
+ * Create and connect the Baileys socket with in-memory store.
106
+ * Auth state must be loaded (await ensureAuthState()) before calling this.
107
+ */
108
+ private createSocket;
109
+ private registerInternalEvents;
110
+ private saveAuthState;
111
+ /**
112
+ * Register connection/creds listeners on an existing socket.
113
+ * Called when a socket is reused from the registry.
114
+ */
115
+ setupListenersOnSocket(sock: WASocket): void;
116
+ private handleLoginReady;
117
+ private isTauriMode;
118
+ private timeBeforeHours;
119
+ /**
120
+ * Wait for the initial history sync to populate this.chats.
121
+ * History arrives via phone-pushed messaging-history.set events
122
+ * (syncFullHistory: true); there is no server API to request it —
123
+ * resyncAppState only syncs app state (mute/archive/contacts) and
124
+ * gets rate-limited (rate-overlimit) when called repeatedly.
125
+ */
126
+ private waitForInitialHistorySync;
127
+ /**
128
+ * Make sure the socket carries a persistent message history store. The
129
+ * phone pushes the full history exactly once, seconds after pairing — so
130
+ * the store must be attached when the socket is created, not later by the
131
+ * self-message listener (whose frontend-triggered init loses that race).
132
+ * Keyed by sessionId so every adapter instance for the same WhatsApp
133
+ * session reads the same persisted data.
134
+ */
135
+ private ensureMessageStore;
136
+ /**
137
+ * Restore this.chats from the persisted message history store (attached to
138
+ * the socket by the self-message listener). Lets insight refreshes work
139
+ * right after a restart, before any new history sync events arrive.
140
+ */
141
+ private hydrateChatsFromStore;
142
+ /**
143
+ * On-demand history backfill via the official Baileys API: when local
144
+ * coverage for a chat does not reach the requested window start, page
145
+ * backwards from the oldest locally stored message (the required anchor)
146
+ * with sock.fetchMessageHistory. The phone responds asynchronously through
147
+ * an ON_DEMAND messaging-history.set, which the attached store persists —
148
+ * so arrival is detected by polling the store's oldest message.
149
+ */
150
+ private backfillChatHistory;
151
+ /**
152
+ * Wait for the phone's answer to a fetchMessageHistory request: either the
153
+ * store's oldest message gets older (new history arrived), or an ON_DEMAND
154
+ * messaging-history.set lands without older messages (end of history).
155
+ */
156
+ private waitForBackfillResponse;
157
+ private ensureReady;
158
+ startQrLogin(callbacks?: WhatsAppLoginCallbacks): Promise<string>;
159
+ startPairingCodeLogin(phoneNumber: string, callbacks?: WhatsAppLoginCallbacks): Promise<string>;
160
+ getUserInfo(): WhatsAppUserInfo;
161
+ sendMessages(target: MessageTarget, id: string, messages: Messages): Promise<void>;
162
+ sendMessage(target: MessageTarget, id: string, message: string): Promise<void>;
163
+ replyMessages(event: MessageEvent, messages: Messages, quoteOrigin?: boolean): Promise<void>;
164
+ run(): Promise<void>;
165
+ isUsingLocalAuth(): boolean;
166
+ /**
167
+ * Start the socket without waiting for connection (for self-listener use).
168
+ * Creates the socket and registers it in the client registry.
169
+ * Always sets up listeners (even when socket was already connected)
170
+ * so callers receive events.
171
+ */
172
+ startSocket(): Promise<WASocket>;
173
+ /**
174
+ * Attach this adapter to an already-connected socket (from registry).
175
+ * Used by QR login to reuse the self-listener's socket instead of creating a new one.
176
+ */
177
+ attachToSocket(sock: WASocket): void;
178
+ /**
179
+ * Kill the socket. If targetSock is provided, only kill that socket's listeners
180
+ * (used during reconnect to avoid killing the new socket's listeners).
181
+ * If no targetSock, kills the current socket.
182
+ */
183
+ kill(targetSock?: WASocket): Promise<boolean>;
184
+ getSessionIdentifier(): string;
185
+ getDialogs(): Promise<WhatsAppDialogInfo[]>;
186
+ getChatsByChunk(since: number, chunkSize?: number): Promise<{
187
+ messages: ExtractedMessageInfo[];
188
+ hasMore: boolean;
189
+ }>;
190
+ getChatsByChunkHours(hours?: number): Promise<{
191
+ messages: ExtractedMessageInfo[];
192
+ hasMore: boolean;
193
+ }>;
194
+ getChatsByTime(cutoffDate: number): Promise<ExtractedMessageInfo[]>;
195
+ getChatsByHours(hours?: number): Promise<ExtractedMessageInfo[]>;
196
+ getChatsByDays(days?: number): Promise<ExtractedMessageInfo[]>;
197
+ resetChunkIterator(): void;
198
+ private extractMessageInfo;
199
+ private getMessageText;
200
+ private resolveChatId;
201
+ private prepareMediaMessage;
202
+ private prepareFileMessage;
203
+ private guessMimeType;
204
+ authStateExists(): Promise<boolean>;
205
+ /**
206
+ * @deprecated Use authStateExists instead
207
+ */
208
+ sessionExists(): Promise<boolean>;
209
+ forceSaveSession(): Promise<void>;
210
+ /** Save session to disk. Call after QR login completes. */
211
+ saveSession(): Promise<void>;
212
+ checkSessionStatus(): Promise<{
213
+ exists: boolean;
214
+ ready: boolean;
215
+ authenticated: boolean;
216
+ }>;
217
+ restoreSession(): Promise<boolean>;
218
+ ingestMessageAttachments(msg: WAMessage): Promise<any[]>;
219
+ getSocket(): WASocket | null;
220
+ }
221
+
222
+ /**
223
+ * WhatsApp Message History Store
224
+ *
225
+ * File-backed replacement for the minimal in-memory Baileys store (Baileys v7
226
+ * removed makeInMemoryStore). Full WAMessage objects stay in memory for
227
+ * real-time consumers; a sanitized copy (binary payloads dropped, Long
228
+ * timestamps normalized to numbers) is persisted per chat so message history
229
+ * and the chat list survive process restarts. Persisted messages also provide
230
+ * the anchor (oldest message key + timestamp) required by Baileys'
231
+ * sock.fetchMessageHistory on-demand backfill API.
232
+ *
233
+ * Layout:
234
+ * <baseDir>/<accountId>/chats.json
235
+ * <baseDir>/<accountId>/messages/<base64url(jid)>.json
236
+ *
237
+ * ARCHITECTURE EXCEPTION (per repo rule: exceptions must be explicit).
238
+ * Data is stored as plain JSON, unencrypted.
239
+ * - Why: matches the Baileys auth state in local/Tauri mode, which already
240
+ * sits unencrypted in the same data dir and grants full account access.
241
+ * - Impact scope: in cloud deployments the auth state lives in Redis, so this
242
+ * directory is an additional at-rest exposure of message content on the
243
+ * server filesystem.
244
+ * - Temporary: yes — the follow-up is to encrypt at rest in cloud mode using
245
+ * packages/security (key management decision pending), or scope this store
246
+ * to local/Tauri mode only.
247
+ * - Mitigation today: per-chat cap (500 msgs), binary payloads stripped, and
248
+ * mandatory lifecycle cleanup — callers must invoke
249
+ * WhatsAppMessageHistoryStore.purgeAccountData() when the integration
250
+ * account is disconnected so no message content outlives the account
251
+ * (purge also destroys live in-process instances; see tests in
252
+ * message-history-store.test.ts).
253
+ * - Known gaps for the encryption follow-up: whole-user account deletion
254
+ * paths that bypass integration disconnect do not purge; in multi-instance
255
+ * cloud deployments purge only clears the current instance's filesystem.
256
+ *
257
+ * Limitation: sanitization strips media keys/thumbnails, so messages
258
+ * hydrated from disk cannot be passed to downloadMediaMessage — only text
259
+ * extraction and backfill anchoring work on persisted history.
260
+ */
261
+
262
+ type PersistedChatInfo = {
263
+ id: string;
264
+ name?: string;
265
+ };
266
+ declare class WhatsAppMessageHistoryStore {
267
+ private readonly accountDir;
268
+ private readonly messagesDir;
269
+ private messages;
270
+ /** Per-chat id sets mirroring `messages`, for O(1) dedup on insert. */
271
+ private messageIds;
272
+ private hydratedJids;
273
+ private chats;
274
+ private chatsHydrated;
275
+ private dirtyJids;
276
+ private chatsDirty;
277
+ private flushTimer;
278
+ private consecutiveFlushFailures;
279
+ private closed;
280
+ private readonly persistEnabled;
281
+ /** ON_DEMAND history responses seen — backfill's "phone answered" signal. */
282
+ private onDemandResponses;
283
+ /**
284
+ * Delete all persisted history for an account. Must be called when the
285
+ * integration account is disconnected/removed so message content does not
286
+ * outlive the account on disk. Live store instances for the account are
287
+ * destroyed first — otherwise their pending debounced flushes (or messages
288
+ * still arriving on an undisposed socket) would resurrect the files.
289
+ */
290
+ static purgeAccountData(accountId: string, baseDir?: string): void;
291
+ constructor(opts: {
292
+ accountId: string;
293
+ baseDir?: string;
294
+ persist?: boolean;
295
+ });
296
+ /**
297
+ * Permanently disable this store: stop pending flushes, drop in-memory
298
+ * data, and turn all writes into no-ops. Called by purgeAccountData so a
299
+ * disconnected account's messages cannot be re-persisted.
300
+ */
301
+ /** True once destroy() has run — writes are no-ops; do not attach/reuse. */
302
+ get isClosed(): boolean;
303
+ destroy(): void;
304
+ /**
305
+ * Attach event listeners to a WASocket to populate the store. History
306
+ * arrives via phone-pushed messaging-history.set (initial pairing and
307
+ * ON_DEMAND backfill responses); real-time messages via messages.upsert.
308
+ */
309
+ attach(sock: WASocket): void;
310
+ addMessages(msgs: WAMessage[]): void;
311
+ addChats(chats: Array<{
312
+ id?: string | null;
313
+ name?: string | null;
314
+ }>): void;
315
+ /** Same interface as the old in-memory store (used by Baileys consumers). */
316
+ loadMessages(jid: string, count: number, _opts: object): Promise<WAMessage[]>;
317
+ /** Chronologically oldest stored message — the fetchMessageHistory anchor. */
318
+ getOldestMessage(jid: string): WAMessage | undefined;
319
+ /**
320
+ * Number of ON_DEMAND history sync responses received since this store
321
+ * instance was created. Used by backfill to distinguish "phone answered
322
+ * with nothing older" (end of history) from "phone never answered".
323
+ */
324
+ getOnDemandResponseCount(): number;
325
+ /**
326
+ * False once the per-chat cap is reached: addMessages trims the oldest
327
+ * entries, so backfilled history would be discarded immediately and the
328
+ * oldest anchor would never move — requesting more is pointless.
329
+ */
330
+ canStoreOlderMessages(jid: string): boolean;
331
+ /** Persisted chat list, used to rebuild adapter state after a restart. */
332
+ loadChats(): PersistedChatInfo[];
333
+ /** Write all pending changes to disk immediately. */
334
+ flush(): void;
335
+ /**
336
+ * Write up to maxFiles dirty chats. The initial full history sync can dirty
337
+ * hundreds of chats at once; writing them all synchronously in one tick
338
+ * would stall the event loop right in the middle of onboarding, so the
339
+ * debounced timer writes in batches and reschedules for the remainder.
340
+ * Explicit flush() still drains everything (teardown must not lose data).
341
+ */
342
+ private writePending;
343
+ /**
344
+ * Write via temp file + rename so a crash can't leave truncated JSON.
345
+ * The pid suffix keeps multi-worker processes from racing on the same
346
+ * temp path (each still last-write-wins on the final file).
347
+ */
348
+ private writeFileAtomic;
349
+ private scheduleFlush;
350
+ private hydrate;
351
+ private hydrateChats;
352
+ }
353
+
354
+ export { type PersistedChatInfo, WhatsAppAdapter, type WhatsAppDialogInfo, WhatsAppMessageHistoryStore, type WhatsAppUserInfo, activeAdapters };