@ezychat/wa 0.0.0-stage → 0.1.39
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/dist/baileys/adapter.d.ts +96 -0
- package/dist/baileys/adapter.js +710 -0
- package/dist/baileys/auth-store.d.ts +14 -0
- package/dist/baileys/auth-store.js +22 -0
- package/dist/baileys/contact-aliases.d.ts +5 -0
- package/dist/baileys/contact-aliases.js +15 -0
- package/dist/baileys/disconnect.d.ts +20 -0
- package/dist/baileys/disconnect.js +32 -0
- package/dist/baileys/groups.d.ts +6 -0
- package/dist/baileys/groups.js +45 -0
- package/dist/baileys/index.d.ts +6 -0
- package/dist/baileys/index.js +8 -0
- package/dist/baileys/lru.d.ts +9 -0
- package/dist/baileys/lru.js +30 -0
- package/dist/baileys/mapping.d.ts +21 -0
- package/dist/baileys/mapping.js +128 -0
- package/dist/baileys/photos.d.ts +6 -0
- package/dist/baileys/photos.js +20 -0
- package/dist/baileys/quoted.d.ts +15 -0
- package/dist/baileys/quoted.js +36 -0
- package/dist/baileys/stored-lid-mappings.d.ts +8 -0
- package/dist/baileys/stored-lid-mappings.js +96 -0
- package/dist/fake/fake-adapter.d.ts +84 -0
- package/dist/fake/fake-adapter.js +198 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/types.d.ts +163 -0
- package/dist/types.js +7 -0
- package/package.json +46 -4
- package/README.md +0 -3
|
@@ -0,0 +1,710 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { Browsers, downloadMediaMessage, fetchLatestBaileysVersion, jidNormalizedUser, makeCacheableSignalKeyStore, makeWASocket, proto, } from 'baileys';
|
|
3
|
+
import { pino } from 'pino';
|
|
4
|
+
import { WaUnavailableError, } from '../types.js';
|
|
5
|
+
import { createAuthStore } from './auth-store.js';
|
|
6
|
+
import { backoffMs, classifyDisconnect } from './disconnect.js';
|
|
7
|
+
import { Lru } from './lru.js';
|
|
8
|
+
import { jidType, mapWAMessage, toMs } from './mapping.js';
|
|
9
|
+
import { quotedForChat, quotedFromRef } from './quoted.js';
|
|
10
|
+
import { contactAliasPair, normalizeContactJid } from './contact-aliases.js';
|
|
11
|
+
import { profilePhoto } from './photos.js';
|
|
12
|
+
import { aboutFromStatusResult, mapGroupMetadata } from './groups.js';
|
|
13
|
+
const RAW_CACHE_MAX = 2000;
|
|
14
|
+
const DAY_MS = 86_400_000;
|
|
15
|
+
/** Boom status codes Baileys uses when the socket is closed / lost / replaced / unavailable. */
|
|
16
|
+
const CONNECTION_STATUS_CODES = new Set([408, 428, 440, 503]);
|
|
17
|
+
/** True when a send error means "the connection went away" (retry later) rather than a real rejection. */
|
|
18
|
+
export function isConnectionError(err) {
|
|
19
|
+
if (!err || typeof err !== 'object')
|
|
20
|
+
return false;
|
|
21
|
+
const e = err;
|
|
22
|
+
const code = e.output?.statusCode;
|
|
23
|
+
if (code !== undefined && CONNECTION_STATUS_CODES.has(code))
|
|
24
|
+
return true;
|
|
25
|
+
return /connection (closed|lost|terminated)|socket (closed|hang up)|not open/i.test(String(e.message ?? ''));
|
|
26
|
+
}
|
|
27
|
+
/** Per-participant group receipt → our status (read wins over delivered). */
|
|
28
|
+
export function receiptStatus(r) {
|
|
29
|
+
if (r.readTimestamp || r.playedTimestamp)
|
|
30
|
+
return 'read';
|
|
31
|
+
if (r.receiptTimestamp)
|
|
32
|
+
return 'delivered';
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
/** proto.WebMessageInfo.Status → our status. */
|
|
36
|
+
function mapAck(status) {
|
|
37
|
+
switch (status) {
|
|
38
|
+
case proto.WebMessageInfo.Status.ERROR:
|
|
39
|
+
return 'failed';
|
|
40
|
+
case proto.WebMessageInfo.Status.SERVER_ACK:
|
|
41
|
+
return 'sent';
|
|
42
|
+
case proto.WebMessageInfo.Status.DELIVERY_ACK:
|
|
43
|
+
return 'delivered';
|
|
44
|
+
case proto.WebMessageInfo.Status.READ:
|
|
45
|
+
case proto.WebMessageInfo.Status.PLAYED:
|
|
46
|
+
return 'read';
|
|
47
|
+
default:
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Real WhatsApp connection via Baileys (WhatsApp Web multi-device protocol).
|
|
53
|
+
* This is the only module in the repo allowed to import `baileys`.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* Browser identity sent at registration. WhatsApp terminates the registration (428 before any QR)
|
|
57
|
+
* for 'Desktop' identities such as Browsers.appropriate('Desktop'); a Chrome web client is accepted
|
|
58
|
+
* and is also required for phone-number pairing codes.
|
|
59
|
+
*/
|
|
60
|
+
export const WA_BROWSER = Browsers.ubuntu('Chrome');
|
|
61
|
+
/** Digits only, with country code (8–15 digits, E.164 without '+'). */
|
|
62
|
+
export function normalizePairingPhone(phone) {
|
|
63
|
+
const digits = phone.replace(/\D/g, '');
|
|
64
|
+
if (digits.length < 8 || digits.length > 15) {
|
|
65
|
+
throw new Error('Enter the phone number with country code, e.g. +60 12-345 6789');
|
|
66
|
+
}
|
|
67
|
+
return digits;
|
|
68
|
+
}
|
|
69
|
+
const PAIRING_READY_TIMEOUT_MS = 20_000;
|
|
70
|
+
export class BaileysAdapter extends EventEmitter {
|
|
71
|
+
_status = { state: 'disconnected', me: null, qr: null, lastError: null };
|
|
72
|
+
sock = null;
|
|
73
|
+
auth;
|
|
74
|
+
logger;
|
|
75
|
+
historyDaysOpt;
|
|
76
|
+
raw = new Lru(RAW_CACHE_MAX);
|
|
77
|
+
attempt = 0;
|
|
78
|
+
reconnectTimer = null;
|
|
79
|
+
/** true when the socket was closed on purpose (disconnect/logout/replaced/blocked). */
|
|
80
|
+
stopped = true;
|
|
81
|
+
connecting = null;
|
|
82
|
+
signalKeys = null;
|
|
83
|
+
contactAliases = new Map();
|
|
84
|
+
/** Invalidates local-key reads when a socket closes or the linked account changes. */
|
|
85
|
+
aliasGeneration = 0;
|
|
86
|
+
constructor(opts) {
|
|
87
|
+
super();
|
|
88
|
+
this.auth = createAuthStore(opts.authDir);
|
|
89
|
+
this.historyDaysOpt = opts.historyDays;
|
|
90
|
+
this.logger = opts.logger ?? pino({ level: 'silent' });
|
|
91
|
+
}
|
|
92
|
+
/** current history_days (re-read each time so a settings change applies on the next relink) */
|
|
93
|
+
get historyDays() {
|
|
94
|
+
const v = typeof this.historyDaysOpt === 'function' ? this.historyDaysOpt() : this.historyDaysOpt;
|
|
95
|
+
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0;
|
|
96
|
+
}
|
|
97
|
+
get status() {
|
|
98
|
+
return this._status;
|
|
99
|
+
}
|
|
100
|
+
on(ev, fn) {
|
|
101
|
+
return super.on(ev, fn);
|
|
102
|
+
}
|
|
103
|
+
off(ev, fn) {
|
|
104
|
+
return super.off(ev, fn);
|
|
105
|
+
}
|
|
106
|
+
emitTyped(ev, ...args) {
|
|
107
|
+
try {
|
|
108
|
+
this.emit(ev, ...args);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
this.logger.error({ err, ev }, 'wa listener threw');
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setStatus(patch) {
|
|
115
|
+
this._status = { ...this._status, ...patch };
|
|
116
|
+
this.emitTyped('status', this._status);
|
|
117
|
+
}
|
|
118
|
+
// ---------------------------------------------------------------- lifecycle
|
|
119
|
+
async connect() {
|
|
120
|
+
this.stopped = false;
|
|
121
|
+
if (this.sock || this.connecting)
|
|
122
|
+
return this.connecting ?? undefined;
|
|
123
|
+
this.clearReconnect();
|
|
124
|
+
this.connecting = this.open().finally(() => {
|
|
125
|
+
this.connecting = null;
|
|
126
|
+
});
|
|
127
|
+
return this.connecting;
|
|
128
|
+
}
|
|
129
|
+
async disconnect() {
|
|
130
|
+
this.stopped = true;
|
|
131
|
+
this.clearReconnect();
|
|
132
|
+
await this.closeSocket();
|
|
133
|
+
this.setStatus({ state: 'disconnected', qr: null });
|
|
134
|
+
}
|
|
135
|
+
async logout() {
|
|
136
|
+
this.stopped = true;
|
|
137
|
+
this.clearContactAliases();
|
|
138
|
+
this.clearReconnect();
|
|
139
|
+
const sock = this.sock;
|
|
140
|
+
if (sock) {
|
|
141
|
+
try {
|
|
142
|
+
await sock.logout();
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
this.logger.warn({ err }, 'wa logout request failed; wiping auth anyway');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
await this.closeSocket();
|
|
149
|
+
await this.auth.wipe();
|
|
150
|
+
this.setStatus({ state: 'logged_out', me: null, qr: null });
|
|
151
|
+
}
|
|
152
|
+
async takeover() {
|
|
153
|
+
this.attempt = 0;
|
|
154
|
+
await this.closeSocket();
|
|
155
|
+
await this.connect();
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Link by phone number instead of QR: returns the 8-character code the user types in
|
|
159
|
+
* WhatsApp → Linked devices → Link with phone number instead.
|
|
160
|
+
*/
|
|
161
|
+
async requestPairingCode(phone) {
|
|
162
|
+
const digits = normalizePairingPhone(phone);
|
|
163
|
+
if (this._status.state === 'open')
|
|
164
|
+
throw new Error('A WhatsApp number is already linked');
|
|
165
|
+
await this.connect();
|
|
166
|
+
await this.waitForPairingReady();
|
|
167
|
+
const sock = this.sock;
|
|
168
|
+
if (!sock)
|
|
169
|
+
throw new WaUnavailableError();
|
|
170
|
+
return sock.requestPairingCode(digits);
|
|
171
|
+
}
|
|
172
|
+
/** The socket can request a code once the server has offered a QR (i.e. registration started). */
|
|
173
|
+
waitForPairingReady() {
|
|
174
|
+
if (this.sock && this._status.state === 'qr')
|
|
175
|
+
return Promise.resolve();
|
|
176
|
+
return new Promise((resolve, reject) => {
|
|
177
|
+
const timer = setTimeout(() => {
|
|
178
|
+
this.off('status', onStatus);
|
|
179
|
+
reject(new WaUnavailableError());
|
|
180
|
+
}, PAIRING_READY_TIMEOUT_MS);
|
|
181
|
+
const onStatus = (s) => {
|
|
182
|
+
if (s.state === 'qr' && this.sock) {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
this.off('status', onStatus);
|
|
185
|
+
resolve();
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
this.on('status', onStatus);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
clearReconnect() {
|
|
192
|
+
if (this.reconnectTimer)
|
|
193
|
+
clearTimeout(this.reconnectTimer);
|
|
194
|
+
this.reconnectTimer = null;
|
|
195
|
+
}
|
|
196
|
+
async closeSocket() {
|
|
197
|
+
this.signalKeys = null;
|
|
198
|
+
this.aliasGeneration++;
|
|
199
|
+
const sock = this.sock;
|
|
200
|
+
this.sock = null;
|
|
201
|
+
if (!sock)
|
|
202
|
+
return;
|
|
203
|
+
sock.ev.removeAllListeners('connection.update');
|
|
204
|
+
try {
|
|
205
|
+
await sock.end(undefined);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
/* already closed */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
scheduleReconnect(immediate = false) {
|
|
212
|
+
if (this.stopped)
|
|
213
|
+
return;
|
|
214
|
+
this.clearReconnect();
|
|
215
|
+
const delay = immediate ? 0 : backoffMs(this.attempt++);
|
|
216
|
+
this.logger.info({ delay, attempt: this.attempt }, 'wa reconnect scheduled');
|
|
217
|
+
this.reconnectTimer = setTimeout(() => {
|
|
218
|
+
this.reconnectTimer = null;
|
|
219
|
+
this.connect().catch((err) => {
|
|
220
|
+
this.logger.error({ err }, 'wa reconnect failed');
|
|
221
|
+
this.setStatus({
|
|
222
|
+
state: 'disconnected',
|
|
223
|
+
lastError: String(err?.message ?? err),
|
|
224
|
+
});
|
|
225
|
+
this.scheduleReconnect();
|
|
226
|
+
});
|
|
227
|
+
}, delay);
|
|
228
|
+
this.reconnectTimer.unref?.();
|
|
229
|
+
}
|
|
230
|
+
async open() {
|
|
231
|
+
this.setStatus({ state: 'connecting', qr: null });
|
|
232
|
+
const { state, saveCreds } = await this.auth.load();
|
|
233
|
+
let version;
|
|
234
|
+
try {
|
|
235
|
+
const latest = await fetchLatestBaileysVersion();
|
|
236
|
+
version = latest.version;
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
this.logger.warn({ err }, 'could not fetch latest WA version; using bundled default');
|
|
240
|
+
}
|
|
241
|
+
if (this.stopped)
|
|
242
|
+
return; // disconnect() was called while loading
|
|
243
|
+
const blog = this.logger.child({ module: 'baileys' });
|
|
244
|
+
const keys = makeCacheableSignalKeyStore(state.keys, blog);
|
|
245
|
+
const sock = makeWASocket({
|
|
246
|
+
...(version ? { version } : {}),
|
|
247
|
+
auth: { creds: state.creds, keys },
|
|
248
|
+
logger: blog,
|
|
249
|
+
browser: WA_BROWSER,
|
|
250
|
+
syncFullHistory: this.historyDays > 0,
|
|
251
|
+
markOnlineOnConnect: false,
|
|
252
|
+
// Per-message filtering by historyDays happens in onHistory/ingest.
|
|
253
|
+
shouldSyncHistoryMessage: () => this.historyDays > 0,
|
|
254
|
+
getMessage: async (key) => key.id ? (this.raw.get(key.id)?.message ?? undefined) : undefined,
|
|
255
|
+
});
|
|
256
|
+
this.sock = sock;
|
|
257
|
+
this.signalKeys = keys;
|
|
258
|
+
this.aliasGeneration++;
|
|
259
|
+
const alive = () => !this.stopped && this.sock === sock;
|
|
260
|
+
sock.ev.on('creds.update', () => {
|
|
261
|
+
if (!alive())
|
|
262
|
+
return;
|
|
263
|
+
saveCreds().catch((err) => this.logger.error({ err }, 'saveCreds failed'));
|
|
264
|
+
});
|
|
265
|
+
sock.ev.on('connection.update', (u) => {
|
|
266
|
+
if (!alive())
|
|
267
|
+
return;
|
|
268
|
+
this.onConnectionUpdate(sock, u);
|
|
269
|
+
});
|
|
270
|
+
sock.ev.on('messages.upsert', ({ messages, type }) => {
|
|
271
|
+
if (!alive())
|
|
272
|
+
return;
|
|
273
|
+
this.handleUpsert(messages, type);
|
|
274
|
+
});
|
|
275
|
+
sock.ev.on('messaging-history.set', (h) => {
|
|
276
|
+
if (!alive())
|
|
277
|
+
return;
|
|
278
|
+
this.onHistory(h);
|
|
279
|
+
});
|
|
280
|
+
sock.ev.on('messages.update', (updates) => {
|
|
281
|
+
if (!alive())
|
|
282
|
+
return;
|
|
283
|
+
this.handleMessageUpdates(updates);
|
|
284
|
+
});
|
|
285
|
+
// Group chats: delivered/read arrive as per-participant receipts, not messages.update.
|
|
286
|
+
sock.ev.on('message-receipt.update', (updates) => {
|
|
287
|
+
if (!alive())
|
|
288
|
+
return;
|
|
289
|
+
this.handleReceipts(updates);
|
|
290
|
+
});
|
|
291
|
+
sock.ev.on('contacts.upsert', (cs) => alive() && this.emitContacts(cs));
|
|
292
|
+
sock.ev.on('contacts.update', (cs) => alive() && this.emitContacts(cs));
|
|
293
|
+
sock.ev.on('lid-mapping.update', ({ lid, pn }) => {
|
|
294
|
+
if (alive())
|
|
295
|
+
this.emitAliasPairs([[lid, pn]], 'lid-mapping');
|
|
296
|
+
});
|
|
297
|
+
sock.ev.on('chats.upsert', (cs) => alive() && this.emitChats(cs));
|
|
298
|
+
sock.ev.on('groups.upsert', (gs) => alive() && this.emitGroups(gs));
|
|
299
|
+
sock.ev.on('groups.update', (gs) => {
|
|
300
|
+
if (!alive())
|
|
301
|
+
return;
|
|
302
|
+
this.emitGroups(gs);
|
|
303
|
+
for (const g of gs)
|
|
304
|
+
if (g.id)
|
|
305
|
+
this.emitTyped('groupChanged', { groupJid: g.id });
|
|
306
|
+
});
|
|
307
|
+
sock.ev.on('group-participants.update', ({ id }) => {
|
|
308
|
+
if (alive() && id)
|
|
309
|
+
this.emitTyped('groupChanged', { groupJid: id });
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
onConnectionUpdate(sock, u) {
|
|
313
|
+
if (u.qr) {
|
|
314
|
+
// The server is talking to us again; don't let earlier failures stretch the next retry.
|
|
315
|
+
this.attempt = 0;
|
|
316
|
+
this.setStatus({ state: 'qr', qr: u.qr });
|
|
317
|
+
}
|
|
318
|
+
if (u.connection === 'connecting' && this._status.state !== 'qr') {
|
|
319
|
+
this.setStatus({ state: 'connecting' });
|
|
320
|
+
}
|
|
321
|
+
if (u.connection === 'open') {
|
|
322
|
+
this.attempt = 0;
|
|
323
|
+
const user = sock.user;
|
|
324
|
+
this.setStatus({
|
|
325
|
+
state: 'open',
|
|
326
|
+
qr: null,
|
|
327
|
+
lastError: null,
|
|
328
|
+
me: user
|
|
329
|
+
? { jid: jidNormalizedUser(user.id), name: user.name ?? user.notify ?? null }
|
|
330
|
+
: null,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (u.connection === 'close') {
|
|
334
|
+
const err = u.lastDisconnect?.error;
|
|
335
|
+
const code = err?.output?.statusCode;
|
|
336
|
+
const message = err?.message ?? (code ? `closed (${code})` : 'connection closed');
|
|
337
|
+
this.sock = null;
|
|
338
|
+
this.signalKeys = null;
|
|
339
|
+
this.aliasGeneration++;
|
|
340
|
+
void this.handleClose(code, message);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async handleClose(code, message) {
|
|
344
|
+
if (this.stopped) {
|
|
345
|
+
this.setStatus({ state: 'disconnected', qr: null });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const action = classifyDisconnect(code);
|
|
349
|
+
this.logger.warn({ code, action, message }, 'wa connection closed');
|
|
350
|
+
switch (action) {
|
|
351
|
+
case 'reconnect':
|
|
352
|
+
this.setStatus({ state: 'connecting', lastError: message });
|
|
353
|
+
// 515 restartRequired is expected right after pairing: reconnect at once.
|
|
354
|
+
this.scheduleReconnect(code === 515);
|
|
355
|
+
return;
|
|
356
|
+
case 'logged_out':
|
|
357
|
+
this.stopped = true;
|
|
358
|
+
this.clearContactAliases();
|
|
359
|
+
await this.auth.wipe().catch((e) => this.logger.error({ err: e }, 'auth wipe failed'));
|
|
360
|
+
this.setStatus({ state: 'logged_out', me: null, qr: null, lastError: message });
|
|
361
|
+
return;
|
|
362
|
+
case 'replaced':
|
|
363
|
+
this.stopped = true;
|
|
364
|
+
this.setStatus({ state: 'replaced', qr: null, lastError: message });
|
|
365
|
+
return;
|
|
366
|
+
case 'bad_session':
|
|
367
|
+
this.clearContactAliases();
|
|
368
|
+
try {
|
|
369
|
+
const bak = await this.auth.backup();
|
|
370
|
+
this.logger.warn({ bak }, 'bad session: auth backed up, wiping to request relink');
|
|
371
|
+
await this.auth.wipe();
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
this.logger.error({ err: e }, 'bad session backup/wipe failed');
|
|
375
|
+
}
|
|
376
|
+
this.setStatus({ state: 'connecting', me: null, qr: null, lastError: message });
|
|
377
|
+
this.scheduleReconnect(true);
|
|
378
|
+
return;
|
|
379
|
+
case 'blocked':
|
|
380
|
+
this.stopped = true;
|
|
381
|
+
this.setStatus({ state: 'blocked', qr: null, lastError: message });
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------- inbound
|
|
386
|
+
historyCutoff() {
|
|
387
|
+
return this.historyDays > 0 ? Date.now() - this.historyDays * DAY_MS : Number.POSITIVE_INFINITY;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* messages.upsert. 'notify' = live; 'append' = messages queued while we were offline (or our own
|
|
391
|
+
* sends / notifications) — also live: they are new to us, must bump unread / reopen / notify, and
|
|
392
|
+
* must never be dropped by the history cutoff. Only messaging-history.set is history.
|
|
393
|
+
* @internal exposed for tests
|
|
394
|
+
*/
|
|
395
|
+
handleUpsert(messages, _type) {
|
|
396
|
+
for (const m of messages)
|
|
397
|
+
this.ingest(m, 'live');
|
|
398
|
+
}
|
|
399
|
+
/** @internal exposed for tests */
|
|
400
|
+
handleMessageUpdates(updates) {
|
|
401
|
+
for (const { key, update } of updates) {
|
|
402
|
+
if (!key.id || !key.remoteJid || !key.fromMe)
|
|
403
|
+
continue;
|
|
404
|
+
const status = mapAck(update.status);
|
|
405
|
+
if (status)
|
|
406
|
+
this.emitTyped('messageStatus', { id: key.id, chatJid: key.remoteJid, status });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/** @internal exposed for tests */
|
|
410
|
+
handleReceipts(updates) {
|
|
411
|
+
for (const { key, receipt } of updates) {
|
|
412
|
+
if (!key.id || !key.remoteJid || !key.fromMe)
|
|
413
|
+
continue;
|
|
414
|
+
const status = receiptStatus(receipt);
|
|
415
|
+
if (status)
|
|
416
|
+
this.emitTyped('messageStatus', { id: key.id, chatJid: key.remoteJid, status });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
ingest(m, source) {
|
|
420
|
+
this.emitAliasPairs([
|
|
421
|
+
[m.key?.remoteJid, m.key?.remoteJidAlt],
|
|
422
|
+
[m.key?.participant, m.key?.participantAlt],
|
|
423
|
+
], source === 'history' ? 'history' : 'message');
|
|
424
|
+
const id = m.key?.id;
|
|
425
|
+
if (!id)
|
|
426
|
+
return;
|
|
427
|
+
if (m.message)
|
|
428
|
+
this.raw.set(id, m);
|
|
429
|
+
const mapped = mapWAMessage(m, (raw) => this.download(raw));
|
|
430
|
+
if (!mapped)
|
|
431
|
+
return;
|
|
432
|
+
if (source === 'history' && mapped.timestamp < this.historyCutoff())
|
|
433
|
+
return;
|
|
434
|
+
this.emitTyped('message', mapped, { source });
|
|
435
|
+
}
|
|
436
|
+
/** @internal exposed for tests */
|
|
437
|
+
onHistory(h) {
|
|
438
|
+
this.emitAliasPairs((h.lidPnMappings ?? []).map(({ lid, pn }) => [lid, pn]), 'history');
|
|
439
|
+
if (h.contacts?.length)
|
|
440
|
+
this.emitContacts(h.contacts);
|
|
441
|
+
if (h.chats?.length)
|
|
442
|
+
this.emitChats(h.chats);
|
|
443
|
+
const cutoff = this.historyCutoff();
|
|
444
|
+
for (const m of h.messages ?? []) {
|
|
445
|
+
if (toMs(m.messageTimestamp, 0) < cutoff)
|
|
446
|
+
continue;
|
|
447
|
+
this.ingest(m, 'history');
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
emitContacts(cs) {
|
|
451
|
+
this.emitAliasPairs(cs.flatMap((c) => [
|
|
452
|
+
[c.id, c.lid],
|
|
453
|
+
[c.id, c.phoneNumber],
|
|
454
|
+
[c.lid, c.phoneNumber],
|
|
455
|
+
]), 'contacts');
|
|
456
|
+
const out = [];
|
|
457
|
+
for (const c of cs) {
|
|
458
|
+
const jid = normalizeContactJid(c.id);
|
|
459
|
+
if (!jid)
|
|
460
|
+
continue;
|
|
461
|
+
const alias = this.contactAliases.get(jid);
|
|
462
|
+
out.push({
|
|
463
|
+
jid,
|
|
464
|
+
pushName: c.notify?.trim() || c.verifiedName?.trim() || null,
|
|
465
|
+
savedName: c.name?.trim() || null,
|
|
466
|
+
...(alias ? { aliases: [alias] } : {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
if (out.length)
|
|
470
|
+
this.emitTyped('contacts', out);
|
|
471
|
+
// The synchronous name event is stored first; late local mappings then connect it to the chat.
|
|
472
|
+
const generation = this.aliasGeneration;
|
|
473
|
+
void this.getContactAliases(out.map((c) => c.jid))
|
|
474
|
+
.then((aliases) => {
|
|
475
|
+
if (generation === this.aliasGeneration && aliases.length)
|
|
476
|
+
this.emitTyped('contactAliases', aliases);
|
|
477
|
+
})
|
|
478
|
+
.catch(() => {
|
|
479
|
+
this.logger.warn({ contactCount: out.length }, 'local contact alias lookup failed');
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
clearContactAliases() {
|
|
483
|
+
this.contactAliases.clear();
|
|
484
|
+
this.raw = new Lru(RAW_CACHE_MAX);
|
|
485
|
+
this.signalKeys = null;
|
|
486
|
+
this.aliasGeneration++;
|
|
487
|
+
}
|
|
488
|
+
/** Cache and emit only explicit PN/LID associations; reject contradictory associations. */
|
|
489
|
+
emitAliasPairs(pairs, source) {
|
|
490
|
+
const out = [];
|
|
491
|
+
for (const [first, second] of pairs) {
|
|
492
|
+
const pair = contactAliasPair(first, second);
|
|
493
|
+
if (pair && this.rememberAlias(pair))
|
|
494
|
+
out.push({ ...pair, source });
|
|
495
|
+
}
|
|
496
|
+
if (out.length) {
|
|
497
|
+
this.logger.debug({ mappingCount: out.length, source }, 'contact aliases received');
|
|
498
|
+
this.emitTyped('contactAliases', out);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
rememberAlias({ jid, alias }) {
|
|
502
|
+
const oldAlias = this.contactAliases.get(jid);
|
|
503
|
+
const oldJid = this.contactAliases.get(alias);
|
|
504
|
+
if ((oldAlias && oldAlias !== alias) || (oldJid && oldJid !== jid))
|
|
505
|
+
return false;
|
|
506
|
+
if (oldAlias === alias && oldJid === jid)
|
|
507
|
+
return false;
|
|
508
|
+
this.contactAliases.set(jid, alias);
|
|
509
|
+
this.contactAliases.set(alias, jid);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
async getContactAliases(jids) {
|
|
513
|
+
const normalized = [
|
|
514
|
+
...new Set(jids.map(normalizeContactJid).filter((jid) => !!jid)),
|
|
515
|
+
];
|
|
516
|
+
const keys = this.signalKeys;
|
|
517
|
+
const generation = this.aliasGeneration;
|
|
518
|
+
const pending = normalized.filter((jid) => !this.contactAliases.has(jid));
|
|
519
|
+
// keys.get reads files concurrently: bound each batch for large imported address books.
|
|
520
|
+
if (keys) {
|
|
521
|
+
for (let start = 0; start < pending.length; start += 256) {
|
|
522
|
+
const batch = pending.slice(start, start + 256);
|
|
523
|
+
const ids = batch.map((jid) => jid.endsWith('@lid') ? `${jid.split('@')[0]}_reverse` : jid.split('@')[0]);
|
|
524
|
+
const stored = await keys.get('lid-mapping', ids);
|
|
525
|
+
if (generation !== this.aliasGeneration || keys !== this.signalKeys)
|
|
526
|
+
return [];
|
|
527
|
+
for (let i = 0; i < batch.length; i++) {
|
|
528
|
+
const jid = batch[i];
|
|
529
|
+
const user = stored[ids[i]];
|
|
530
|
+
if (typeof user !== 'string' || !/^\d+$/.test(user))
|
|
531
|
+
continue;
|
|
532
|
+
const pair = contactAliasPair(jid, `${user}@${jid.endsWith('@lid') ? 's.whatsapp.net' : 'lid'}`);
|
|
533
|
+
if (pair)
|
|
534
|
+
this.rememberAlias(pair);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
const out = new Map();
|
|
539
|
+
for (const jid of normalized) {
|
|
540
|
+
const alias = this.contactAliases.get(jid);
|
|
541
|
+
const pair = contactAliasPair(jid, alias);
|
|
542
|
+
if (pair)
|
|
543
|
+
out.set(pair.jid, { ...pair, source: 'keystore' });
|
|
544
|
+
}
|
|
545
|
+
return [...out.values()];
|
|
546
|
+
}
|
|
547
|
+
emitChats(cs) {
|
|
548
|
+
const out = [];
|
|
549
|
+
for (const c of cs) {
|
|
550
|
+
if (!c.id)
|
|
551
|
+
continue;
|
|
552
|
+
const t = jidType(c.id);
|
|
553
|
+
if (t === 'other')
|
|
554
|
+
continue;
|
|
555
|
+
out.push({ jid: c.id, type: t, name: c.name ?? null });
|
|
556
|
+
}
|
|
557
|
+
if (out.length)
|
|
558
|
+
this.emitTyped('chats', out);
|
|
559
|
+
}
|
|
560
|
+
emitGroups(gs) {
|
|
561
|
+
const out = [];
|
|
562
|
+
for (const g of gs) {
|
|
563
|
+
if (!g.id || jidType(g.id) !== 'group')
|
|
564
|
+
continue;
|
|
565
|
+
out.push({ jid: g.id, type: 'group', name: g.subject ?? null });
|
|
566
|
+
}
|
|
567
|
+
if (out.length)
|
|
568
|
+
this.emitTyped('chats', out);
|
|
569
|
+
}
|
|
570
|
+
async download(raw) {
|
|
571
|
+
const sock = this.sock;
|
|
572
|
+
const buf = await downloadMediaMessage(raw, 'buffer', {}, sock
|
|
573
|
+
? { reuploadRequest: sock.updateMediaMessage, logger: this.logger }
|
|
574
|
+
: undefined);
|
|
575
|
+
return buf;
|
|
576
|
+
}
|
|
577
|
+
// ---------------------------------------------------------------- outbound
|
|
578
|
+
requireOpen() {
|
|
579
|
+
if (!this.sock || this._status.state !== 'open')
|
|
580
|
+
throw new WaUnavailableError();
|
|
581
|
+
return this.sock;
|
|
582
|
+
}
|
|
583
|
+
async send(chatJid, content, opts) {
|
|
584
|
+
const sock = this.requireOpen();
|
|
585
|
+
// The live cache is small and cleared on reconnect; rebuild older quotes from the saved copy.
|
|
586
|
+
// Either way the quote points at the chat the reply goes to (phone number or WhatsApp ID).
|
|
587
|
+
const cached = opts?.quotedId ? this.raw.get(opts.quotedId) : undefined;
|
|
588
|
+
const quoted = cached
|
|
589
|
+
? quotedForChat(cached, chatJid)
|
|
590
|
+
: opts?.quotedId && opts.quoted
|
|
591
|
+
? quotedFromRef(opts.quoted, chatJid)
|
|
592
|
+
: undefined;
|
|
593
|
+
let res;
|
|
594
|
+
try {
|
|
595
|
+
res = await sock.sendMessage(chatJid, content, quoted ? { quoted } : undefined);
|
|
596
|
+
}
|
|
597
|
+
catch (err) {
|
|
598
|
+
// connection dropped mid-send: report as unavailable so the queue keeps the job pending
|
|
599
|
+
if (isConnectionError(err) || this.sock !== sock || this._status.state !== 'open') {
|
|
600
|
+
throw new WaUnavailableError(`WhatsApp connection lost: ${String(err?.message ?? err)}`);
|
|
601
|
+
}
|
|
602
|
+
throw err;
|
|
603
|
+
}
|
|
604
|
+
const id = res?.key?.id;
|
|
605
|
+
if (!res || !id)
|
|
606
|
+
throw new Error('send returned no message id');
|
|
607
|
+
this.raw.set(id, res);
|
|
608
|
+
return { id, timestamp: toMs(res.messageTimestamp) };
|
|
609
|
+
}
|
|
610
|
+
sendText(chatJid, text, opts) {
|
|
611
|
+
return this.send(chatJid, { text }, opts);
|
|
612
|
+
}
|
|
613
|
+
sendMedia(chatJid, file, opts) {
|
|
614
|
+
const { buffer, mime, fileName, caption } = file;
|
|
615
|
+
let content;
|
|
616
|
+
if (mime.startsWith('image/') && mime !== 'image/webp') {
|
|
617
|
+
content = { image: buffer, mimetype: mime, ...(caption ? { caption } : {}) };
|
|
618
|
+
}
|
|
619
|
+
else if (mime === 'image/webp') {
|
|
620
|
+
content = { sticker: buffer, mimetype: mime };
|
|
621
|
+
}
|
|
622
|
+
else if (mime.startsWith('video/')) {
|
|
623
|
+
content = { video: buffer, mimetype: mime, ...(caption ? { caption } : {}) };
|
|
624
|
+
}
|
|
625
|
+
else if (file.voiceNote) {
|
|
626
|
+
// Seconds and waveform are given, so Baileys needs neither music-metadata nor audio-decode.
|
|
627
|
+
// Baileys copies `waveform` into the audio message but leaves it out of its content type.
|
|
628
|
+
const voice = {
|
|
629
|
+
audio: buffer,
|
|
630
|
+
mimetype: 'audio/ogg; codecs=opus',
|
|
631
|
+
ptt: true,
|
|
632
|
+
seconds: file.voiceNote.seconds,
|
|
633
|
+
waveform: file.voiceNote.waveform,
|
|
634
|
+
};
|
|
635
|
+
content = voice;
|
|
636
|
+
}
|
|
637
|
+
else if (mime.startsWith('audio/')) {
|
|
638
|
+
content = { audio: buffer, mimetype: mime };
|
|
639
|
+
}
|
|
640
|
+
else {
|
|
641
|
+
content = { document: buffer, mimetype: mime, fileName, ...(caption ? { caption } : {}) };
|
|
642
|
+
}
|
|
643
|
+
return this.send(chatJid, content, opts);
|
|
644
|
+
}
|
|
645
|
+
async markRead(chatJid, messageIds) {
|
|
646
|
+
const sock = this.requireOpen();
|
|
647
|
+
const keys = messageIds.map((id) => {
|
|
648
|
+
const raw = this.raw.get(id);
|
|
649
|
+
return raw?.key
|
|
650
|
+
? { remoteJid: chatJid, id, fromMe: false, participant: raw.key.participant ?? undefined }
|
|
651
|
+
: { remoteJid: chatJid, id, fromMe: false };
|
|
652
|
+
});
|
|
653
|
+
if (keys.length)
|
|
654
|
+
await sock.readMessages(keys);
|
|
655
|
+
}
|
|
656
|
+
async sendPresence(chatJid, presence) {
|
|
657
|
+
const sock = this.requireOpen();
|
|
658
|
+
await sock.sendPresenceUpdate(presence, chatJid);
|
|
659
|
+
}
|
|
660
|
+
async downloadMedia(messageId) {
|
|
661
|
+
const raw = this.raw.get(messageId);
|
|
662
|
+
if (!raw)
|
|
663
|
+
return null;
|
|
664
|
+
try {
|
|
665
|
+
return await this.download(raw);
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
this.logger.warn({ err, messageId }, 'media download failed');
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async getGroupInfo(groupJid) {
|
|
673
|
+
const sock = this.requireOpen();
|
|
674
|
+
return mapGroupMetadata(await sock.groupMetadata(groupJid));
|
|
675
|
+
}
|
|
676
|
+
async getAbout(jid) {
|
|
677
|
+
if (!this.sock || this._status.state !== 'open')
|
|
678
|
+
throw new WaUnavailableError();
|
|
679
|
+
const kind = jid.endsWith('@lid') ? 'lid' : 'pn';
|
|
680
|
+
try {
|
|
681
|
+
const list = await this.sock.fetchStatus(jid);
|
|
682
|
+
const about = aboutFromStatusResult(list);
|
|
683
|
+
// Shape only, never the text: tells "WhatsApp returned nothing" apart from a parsing problem.
|
|
684
|
+
const first = Array.isArray(list)
|
|
685
|
+
? list[0]
|
|
686
|
+
: undefined;
|
|
687
|
+
const inner = first?.status?.status;
|
|
688
|
+
this.logger.info({
|
|
689
|
+
kind,
|
|
690
|
+
entries: Array.isArray(list) ? list.length : null,
|
|
691
|
+
// '' = hidden by the person's privacy settings (code 401), null = none set
|
|
692
|
+
status: inner === '' ? 'private' : inner === null ? 'none' : typeof inner,
|
|
693
|
+
found: about !== null,
|
|
694
|
+
}, 'about read');
|
|
695
|
+
return about;
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
// Thrown, not null: a failed lookup must never be remembered as "no About".
|
|
699
|
+
this.logger.warn({ err, kind }, 'about unavailable');
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
async getProfilePicture(jid) {
|
|
704
|
+
// Offline is a failed lookup, never "no photo".
|
|
705
|
+
if (!this.sock || this._status.state !== 'open')
|
|
706
|
+
throw new WaUnavailableError();
|
|
707
|
+
const sock = this.sock;
|
|
708
|
+
return profilePhoto(() => sock.profilePictureUrl(jid, 'preview'));
|
|
709
|
+
}
|
|
710
|
+
}
|