@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.
@@ -0,0 +1,14 @@
1
+ import { type AuthenticationState } from 'baileys';
2
+ export interface AuthStore {
3
+ readonly dir: string;
4
+ /** Load (or create) the multi-file auth state. */
5
+ load(): Promise<{
6
+ state: AuthenticationState;
7
+ saveCreds: () => Promise<void>;
8
+ }>;
9
+ /** Remove all auth/session files. */
10
+ wipe(): Promise<void>;
11
+ /** Copy the auth dir to `${dir}.bak-<ts>`; returns the backup path. */
12
+ backup(): Promise<string>;
13
+ }
14
+ export declare function createAuthStore(dir: string): AuthStore;
@@ -0,0 +1,22 @@
1
+ import { cp, mkdir, rm } from 'node:fs/promises';
2
+ import { useMultiFileAuthState } from 'baileys';
3
+ export function createAuthStore(dir) {
4
+ return {
5
+ dir,
6
+ async load() {
7
+ await mkdir(dir, { recursive: true });
8
+ return useMultiFileAuthState(dir);
9
+ },
10
+ async wipe() {
11
+ await rm(dir, { recursive: true, force: true });
12
+ },
13
+ async backup() {
14
+ const target = `${dir}.bak-${Date.now()}`;
15
+ await cp(dir, target, { recursive: true, force: true }).catch((err) => {
16
+ if (err.code !== 'ENOENT')
17
+ throw err;
18
+ });
19
+ return target;
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,5 @@
1
+ import type { WaContactAlias } from '../types.js';
2
+ /** Accept only numeric direct identities; normalize device IDs and legacy PN domains. */
3
+ export declare function normalizeContactJid(jid: unknown): string | null;
4
+ /** Canonical orientation is PN -> LID, regardless of the event field orientation. */
5
+ export declare function contactAliasPair(first: unknown, second: unknown): WaContactAlias | null;
@@ -0,0 +1,15 @@
1
+ /** Accept only numeric direct identities; normalize device IDs and legacy PN domains. */
2
+ export function normalizeContactJid(jid) {
3
+ if (typeof jid !== 'string')
4
+ return null;
5
+ const match = /^(\d+)(?::\d+)?@(s\.whatsapp\.net|c\.us|lid)$/.exec(jid);
6
+ return match ? `${match[1]}@${match[2] === 'lid' ? 'lid' : 's.whatsapp.net'}` : null;
7
+ }
8
+ /** Canonical orientation is PN -> LID, regardless of the event field orientation. */
9
+ export function contactAliasPair(first, second) {
10
+ const a = normalizeContactJid(first);
11
+ const b = normalizeContactJid(second);
12
+ if (!a || !b || a.endsWith('@lid') === b.endsWith('@lid'))
13
+ return null;
14
+ return a.endsWith('@lid') ? { jid: b, alias: a } : { jid: a, alias: b };
15
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Classification of Baileys disconnect status codes (spec §8).
3
+ * Numeric values mirror Baileys' `DisconnectReason` enum; kept as literals so
4
+ * this module stays pure and cheap to test.
5
+ */
6
+ export type DisconnectAction = 'reconnect' | 'logged_out' | 'replaced' | 'bad_session' | 'blocked';
7
+ export declare const DisconnectCode: {
8
+ readonly loggedOut: 401;
9
+ readonly forbidden: 403;
10
+ readonly timedOut: 408;
11
+ readonly multideviceMismatch: 411;
12
+ readonly connectionClosed: 428;
13
+ readonly connectionReplaced: 440;
14
+ readonly badSession: 500;
15
+ readonly unavailableService: 503;
16
+ readonly restartRequired: 515;
17
+ };
18
+ export declare function classifyDisconnect(statusCode: number | undefined): DisconnectAction;
19
+ /** Exponential backoff 2s -> 60s with +/-20% jitter. */
20
+ export declare function backoffMs(attempt: number, rand?: () => number): number;
@@ -0,0 +1,32 @@
1
+ export const DisconnectCode = {
2
+ loggedOut: 401,
3
+ forbidden: 403,
4
+ timedOut: 408, // also connectionLost
5
+ multideviceMismatch: 411,
6
+ connectionClosed: 428,
7
+ connectionReplaced: 440,
8
+ badSession: 500,
9
+ unavailableService: 503,
10
+ restartRequired: 515,
11
+ };
12
+ export function classifyDisconnect(statusCode) {
13
+ switch (statusCode) {
14
+ case DisconnectCode.loggedOut:
15
+ return 'logged_out';
16
+ case DisconnectCode.connectionReplaced:
17
+ return 'replaced';
18
+ case DisconnectCode.badSession:
19
+ return 'bad_session';
20
+ case DisconnectCode.forbidden:
21
+ case DisconnectCode.multideviceMismatch:
22
+ return 'blocked';
23
+ // restartRequired, timedOut/connectionLost, connectionClosed, unavailableService, unknown
24
+ default:
25
+ return 'reconnect';
26
+ }
27
+ }
28
+ /** Exponential backoff 2s -> 60s with +/-20% jitter. */
29
+ export function backoffMs(attempt, rand = Math.random) {
30
+ const base = Math.min(60_000, 2000 * 2 ** Math.max(0, attempt));
31
+ return Math.round(base * (0.8 + 0.4 * rand()));
32
+ }
@@ -0,0 +1,6 @@
1
+ import type { GroupMetadata } from 'baileys';
2
+ import type { WaGroupInfo } from '../types.js';
3
+ /** Map Baileys group metadata to our shape. Members carry no names; only what WhatsApp sent. */
4
+ export declare function mapGroupMetadata(meta: GroupMetadata): WaGroupInfo;
5
+ /** The About text from a Baileys `fetchStatus` result; null when hidden or unset. */
6
+ export declare function aboutFromStatusResult(list: unknown): string | null;
@@ -0,0 +1,45 @@
1
+ import { normalizeContactJid } from './contact-aliases.js';
2
+ const ROLE = {
3
+ superadmin: 'superadmin',
4
+ admin: 'admin',
5
+ };
6
+ /** Map Baileys group metadata to our shape. Members carry no names; only what WhatsApp sent. */
7
+ export function mapGroupMetadata(meta) {
8
+ const participants = [];
9
+ const seen = new Set();
10
+ for (const p of meta.participants ?? []) {
11
+ const id = normalizeContactJid(p.id);
12
+ if (!id || seen.has(id))
13
+ continue;
14
+ seen.add(id);
15
+ const isLid = id.endsWith('@lid');
16
+ const phoneJid = isLid ? normalizeContactJid(p.phoneNumber) : id;
17
+ const lidJid = isLid ? id : normalizeContactJid(p.lid);
18
+ participants.push({
19
+ jid: id,
20
+ phoneJid: phoneJid && !phoneJid.endsWith('@lid') ? phoneJid : null,
21
+ lidJid: lidJid && lidJid.endsWith('@lid') ? lidJid : null,
22
+ username: typeof p.username === 'string' && p.username ? p.username : null,
23
+ role: ROLE[String(p.admin ?? '')] ?? 'member',
24
+ });
25
+ }
26
+ const created = Number(meta.creation);
27
+ return {
28
+ jid: meta.id,
29
+ name: meta.subject || null,
30
+ description: meta.desc || null,
31
+ createdAt: Number.isFinite(created) && created > 0 ? created * 1000 : null,
32
+ // The WhatsApp ID first: the creator's number may later belong to someone else.
33
+ ownerJid: normalizeContactJid(meta.owner) ?? normalizeContactJid(meta.ownerPn),
34
+ size: Math.max(Number(meta.size) || 0, participants.length),
35
+ participants,
36
+ };
37
+ }
38
+ /** The About text from a Baileys `fetchStatus` result; null when hidden or unset. */
39
+ export function aboutFromStatusResult(list) {
40
+ // A timed-out query resolves to undefined instead of throwing: a failure, never "no About".
41
+ if (!Array.isArray(list))
42
+ throw new Error('About query returned no result');
43
+ const status = list[0]?.status?.status;
44
+ return typeof status === 'string' && status.trim() ? status : null;
45
+ }
@@ -0,0 +1,6 @@
1
+ import type { WaAdapter, WaAdapterOptions } from '../types.js';
2
+ export declare function createBaileysAdapter(opts: WaAdapterOptions): WaAdapter;
3
+ export { BaileysAdapter } from './adapter.js';
4
+ export { classifyDisconnect, backoffMs, type DisconnectAction } from './disconnect.js';
5
+ export { mapWAMessage, jidType } from './mapping.js';
6
+ export { createAuthStore, type AuthStore } from './auth-store.js';
@@ -0,0 +1,8 @@
1
+ import { BaileysAdapter } from './adapter.js';
2
+ export function createBaileysAdapter(opts) {
3
+ return new BaileysAdapter(opts);
4
+ }
5
+ export { BaileysAdapter } from './adapter.js';
6
+ export { classifyDisconnect, backoffMs } from './disconnect.js';
7
+ export { mapWAMessage, jidType } from './mapping.js';
8
+ export { createAuthStore } from './auth-store.js';
@@ -0,0 +1,9 @@
1
+ /** Minimal insertion-ordered LRU cache. */
2
+ export declare class Lru<K, V> {
3
+ private readonly max;
4
+ private readonly map;
5
+ constructor(max: number);
6
+ get(key: K): V | undefined;
7
+ set(key: K, value: V): void;
8
+ get size(): number;
9
+ }
@@ -0,0 +1,30 @@
1
+ /** Minimal insertion-ordered LRU cache. */
2
+ export class Lru {
3
+ max;
4
+ map = new Map();
5
+ constructor(max) {
6
+ this.max = max;
7
+ }
8
+ get(key) {
9
+ const v = this.map.get(key);
10
+ if (v !== undefined) {
11
+ this.map.delete(key);
12
+ this.map.set(key, v);
13
+ }
14
+ return v;
15
+ }
16
+ set(key, value) {
17
+ if (this.map.has(key))
18
+ this.map.delete(key);
19
+ this.map.set(key, value);
20
+ while (this.map.size > this.max) {
21
+ const oldest = this.map.keys().next();
22
+ if (oldest.done)
23
+ break;
24
+ this.map.delete(oldest.value);
25
+ }
26
+ }
27
+ get size() {
28
+ return this.map.size;
29
+ }
30
+ }
@@ -0,0 +1,21 @@
1
+ import { type proto, type WAMessage } from 'baileys';
2
+ import type { WaIncomingMessage } from '../types.js';
3
+ export type JidKind = 'dm' | 'group' | 'other';
4
+ export declare function jidType(jid: string): JidKind;
5
+ type LongLike = {
6
+ toNumber(): number;
7
+ } | {
8
+ low: number;
9
+ high: number;
10
+ };
11
+ /** Convert a protobuf timestamp (number | Long | null) in seconds to epoch ms. */
12
+ export declare function toMs(ts: number | LongLike | null | undefined, fallback?: number): number;
13
+ /** Optional hook for media downloads that need a live socket (reupload of expired media). */
14
+ export type MediaDownloader = (msg: WAMessage) => Promise<Buffer>;
15
+ /**
16
+ * Map a raw Baileys message to our adapter shape. Returns null for messages
17
+ * that should not appear in the inbox (reactions, protocol, key distribution,
18
+ * status/newsletter/broadcast, empty).
19
+ */
20
+ export declare function mapWAMessage(msg: proto.IWebMessageInfo, download?: MediaDownloader): WaIncomingMessage | null;
21
+ export {};
@@ -0,0 +1,128 @@
1
+ import { downloadMediaMessage, normalizeMessageContent } from 'baileys';
2
+ import { normalizeContactJid } from './contact-aliases.js';
3
+ export function jidType(jid) {
4
+ if (jid.endsWith('@g.us'))
5
+ return 'group';
6
+ if (jid.endsWith('@s.whatsapp.net') || jid.endsWith('@lid'))
7
+ return 'dm';
8
+ // status@broadcast, @broadcast lists, @newsletter channels, anything unknown
9
+ return 'other';
10
+ }
11
+ /** Convert a protobuf timestamp (number | Long | null) in seconds to epoch ms. */
12
+ export function toMs(ts, fallback = Date.now()) {
13
+ if (ts == null)
14
+ return fallback;
15
+ if (typeof ts === 'number')
16
+ return ts * 1000;
17
+ if ('toNumber' in ts && typeof ts.toNumber === 'function')
18
+ return ts.toNumber() * 1000;
19
+ if ('low' in ts)
20
+ return ((ts.high >>> 0) * 2 ** 32 + (ts.low >>> 0)) * 1000;
21
+ return fallback;
22
+ }
23
+ const defaultDownloader = async (msg) => (await downloadMediaMessage(msg, 'buffer', {}));
24
+ /**
25
+ * Map a raw Baileys message to our adapter shape. Returns null for messages
26
+ * that should not appear in the inbox (reactions, protocol, key distribution,
27
+ * status/newsletter/broadcast, empty).
28
+ */
29
+ export function mapWAMessage(msg, download = defaultDownloader) {
30
+ const key = msg.key;
31
+ const chatJid = key?.remoteJid;
32
+ const id = key?.id;
33
+ if (!key || !chatJid || !id)
34
+ return null;
35
+ if (jidType(chatJid) === 'other')
36
+ return null;
37
+ const content = normalizeMessageContent(msg.message);
38
+ if (!content)
39
+ return null;
40
+ const fromMe = !!key.fromMe;
41
+ const isGroup = jidType(chatJid) === 'group';
42
+ const senderJid = fromMe ? null : isGroup ? normalizeContactJid(key.participant) : chatJid;
43
+ const senderJidAlt = !fromMe && isGroup ? normalizeContactJid(key.participantAlt) : null;
44
+ const senderName = fromMe ? null : (msg.pushName ?? null);
45
+ const timestamp = toMs(msg.messageTimestamp);
46
+ let type;
47
+ let body = null;
48
+ let ctx;
49
+ let media = null;
50
+ if (content.conversation != null) {
51
+ type = 'text';
52
+ body = content.conversation;
53
+ }
54
+ else if (content.extendedTextMessage) {
55
+ type = 'text';
56
+ body = content.extendedTextMessage.text ?? '';
57
+ ctx = content.extendedTextMessage;
58
+ }
59
+ else if (content.imageMessage) {
60
+ const m = content.imageMessage;
61
+ type = 'image';
62
+ body = m.caption ?? null;
63
+ ctx = m;
64
+ media = { mime: m.mimetype ?? 'image/jpeg', fileName: null };
65
+ }
66
+ else if (content.videoMessage) {
67
+ const m = content.videoMessage;
68
+ type = 'video';
69
+ body = m.caption ?? null;
70
+ ctx = m;
71
+ media = { mime: m.mimetype ?? 'video/mp4', fileName: null };
72
+ }
73
+ else if (content.audioMessage) {
74
+ const m = content.audioMessage;
75
+ type = 'audio';
76
+ ctx = m;
77
+ media = {
78
+ mime: m.mimetype ?? (m.ptt ? 'audio/ogg; codecs=opus' : 'audio/mpeg'),
79
+ fileName: null,
80
+ };
81
+ }
82
+ else if (content.documentMessage) {
83
+ const m = content.documentMessage;
84
+ type = 'document';
85
+ body = m.caption ?? null;
86
+ ctx = m;
87
+ media = {
88
+ mime: m.mimetype ?? 'application/octet-stream',
89
+ fileName: m.fileName ?? m.title ?? null,
90
+ };
91
+ }
92
+ else if (content.stickerMessage) {
93
+ const m = content.stickerMessage;
94
+ type = 'sticker';
95
+ ctx = m;
96
+ media = { mime: m.mimetype ?? 'image/webp', fileName: null };
97
+ }
98
+ else {
99
+ // reactionMessage, protocolMessage, senderKeyDistributionMessage, polls, calls, etc.
100
+ return null;
101
+ }
102
+ const quotedId = ctx?.contextInfo?.stanzaId ?? null;
103
+ const raw = msg;
104
+ // The same person's other address (PN for a LID chat or the reverse); only for direct chats.
105
+ // remoteJidAlt is on Baileys' WAMessageKey, not on proto.IMessageKey.
106
+ const alt = isGroup ? null : normalizeContactJid(key.remoteJidAlt);
107
+ const chatJidAlt = alt && alt.endsWith('@lid') !== chatJid.endsWith('@lid') ? alt : null;
108
+ return {
109
+ id,
110
+ chatJid,
111
+ chatJidAlt,
112
+ senderJid,
113
+ senderJidAlt,
114
+ senderName,
115
+ fromMe,
116
+ type,
117
+ body,
118
+ mentionedJids: [
119
+ ...new Set((ctx?.contextInfo?.mentionedJid ?? []).flatMap((jid) => {
120
+ const normalized = normalizeContactJid(jid);
121
+ return normalized ? [normalized] : [];
122
+ })),
123
+ ],
124
+ quotedId,
125
+ timestamp,
126
+ media: media ? { ...media, download: () => download(raw) } : null,
127
+ };
128
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Turns a Baileys `profilePictureUrl` call into an answer: a URL, `null` for a real "no photo"
3
+ * (WhatsApp error 404) or "private" (401), or a thrown error for a failed lookup, so it is never
4
+ * remembered as "no photo". Baileys resolves a timed-out query to `undefined`: that is a failure too.
5
+ */
6
+ export declare function profilePhoto(lookup: () => Promise<string | undefined>): Promise<string | null>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Turns a Baileys `profilePictureUrl` call into an answer: a URL, `null` for a real "no photo"
3
+ * (WhatsApp error 404) or "private" (401), or a thrown error for a failed lookup, so it is never
4
+ * remembered as "no photo". Baileys resolves a timed-out query to `undefined`: that is a failure too.
5
+ */
6
+ export async function profilePhoto(lookup) {
7
+ let url;
8
+ try {
9
+ url = await lookup();
10
+ }
11
+ catch (err) {
12
+ const code = err.data;
13
+ if (code === 404 || code === 401)
14
+ return null;
15
+ throw err;
16
+ }
17
+ if (!url)
18
+ throw new Error('Profile picture query returned no result');
19
+ return url;
20
+ }
@@ -0,0 +1,15 @@
1
+ import type { WAMessage } from 'baileys';
2
+ import type { WaQuotedRef } from '../types.js';
3
+ /**
4
+ * A cached message re-pointed at the chat the reply goes to. One person can be reached by a phone
5
+ * number and a WhatsApp ID (LID); a message cached under the old address would otherwise make
6
+ * Baileys mark the quote as coming from another chat (`contextInfo.remoteJid`).
7
+ */
8
+ export declare function quotedForChat(cached: WAMessage, chatJid: string): WAMessage;
9
+ /**
10
+ * A minimal WAMessage for Baileys' `quoted` option, rebuilt from the saved message when the live one
11
+ * is no longer cached. Baileys reads `key.id` (stanzaId), `key.fromMe`, `key.remoteJid` (must be the
12
+ * chat the reply goes to, or it adds a foreign remoteJid), `key.participant` (group sender) and the
13
+ * message content (the preview recipients see).
14
+ */
15
+ export declare function quotedFromRef(ref: WaQuotedRef, chatJid: string): WAMessage;
@@ -0,0 +1,36 @@
1
+ /** Shown in the quote when the quoted media had no caption (WhatsApp still links it by id). */
2
+ const MEDIA_LABEL = {
3
+ image: '📷 Photo',
4
+ video: '🎥 Video',
5
+ audio: '🎤 Voice message',
6
+ document: '📄 Document',
7
+ sticker: 'Sticker',
8
+ };
9
+ /**
10
+ * A cached message re-pointed at the chat the reply goes to. One person can be reached by a phone
11
+ * number and a WhatsApp ID (LID); a message cached under the old address would otherwise make
12
+ * Baileys mark the quote as coming from another chat (`contextInfo.remoteJid`).
13
+ */
14
+ export function quotedForChat(cached, chatJid) {
15
+ if (cached.key.remoteJid === chatJid)
16
+ return cached;
17
+ return { ...cached, key: { ...cached.key, remoteJid: chatJid } };
18
+ }
19
+ /**
20
+ * A minimal WAMessage for Baileys' `quoted` option, rebuilt from the saved message when the live one
21
+ * is no longer cached. Baileys reads `key.id` (stanzaId), `key.fromMe`, `key.remoteJid` (must be the
22
+ * chat the reply goes to, or it adds a foreign remoteJid), `key.participant` (group sender) and the
23
+ * message content (the preview recipients see).
24
+ */
25
+ export function quotedFromRef(ref, chatJid) {
26
+ const isGroup = chatJid.endsWith('@g.us');
27
+ return {
28
+ key: {
29
+ remoteJid: chatJid,
30
+ id: ref.id,
31
+ fromMe: ref.fromMe,
32
+ ...(isGroup && !ref.fromMe && ref.senderJid ? { participant: ref.senderJid } : {}),
33
+ },
34
+ message: { conversation: ref.text?.trim() ? ref.text : (MEDIA_LABEL[ref.type] ?? '') },
35
+ };
36
+ }
@@ -0,0 +1,8 @@
1
+ import type { WaContactAlias } from '../types.js';
2
+ /**
3
+ * PN-LID pairs WhatsApp already gave this account, read offline from the multi-file auth store
4
+ * (no socket, no `baileys` import) so the server can merge chats at startup. A number's forward file
5
+ * is its current WhatsApp ID; a reverse file only fills numbers without one (an old ID's reverse file
6
+ * can outlive a number that moved). Missing dir returns []; any other directory error throws.
7
+ */
8
+ export declare function readStoredLidMappings(authDir: string): WaContactAlias[];
@@ -0,0 +1,96 @@
1
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { contactAliasPair, normalizeContactJid } from './contact-aliases.js';
4
+ // Baileys' useMultiFileAuthState stores key `<id>` of type `lid-mapping` as `lid-mapping-<id>.json`.
5
+ // LIDMappingStore writes `<pnUser>` -> "<lidUser>" and `<lidUser>_reverse` -> "<pnUser>".
6
+ const FORWARD = /^lid-mapping-(\d+)\.json$/;
7
+ const REVERSE = /^lid-mapping-(\d+)_reverse\.json$/;
8
+ // A mapping file holds one short JSON string; anything bigger is not one.
9
+ const MAX_FILE_BYTES = 1024;
10
+ function readUser(file) {
11
+ try {
12
+ const stat = statSync(file);
13
+ if (!stat.isFile() || stat.size > MAX_FILE_BYTES)
14
+ return null;
15
+ const value = JSON.parse(readFileSync(file, 'utf8'));
16
+ return typeof value === 'string' && /^\d+$/.test(value) ? value : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ /**
23
+ * The linked account's own PN and LID (never aliased). Fails closed: without a readable creds.json
24
+ * carrying `me.id` the account's own identities cannot be excluded, so refuse to guess.
25
+ */
26
+ function ownJids(authDir) {
27
+ let me;
28
+ try {
29
+ me = JSON.parse(readFileSync(join(authDir, 'creds.json'), 'utf8')).me;
30
+ }
31
+ catch {
32
+ me = undefined;
33
+ }
34
+ const own = normalizeContactJid(me?.id);
35
+ if (!own)
36
+ throw new Error('wa-auth creds.json missing or has no account id');
37
+ const set = new Set([own]);
38
+ const lid = normalizeContactJid(me?.lid);
39
+ if (lid)
40
+ set.add(lid);
41
+ return set;
42
+ }
43
+ /**
44
+ * PN-LID pairs WhatsApp already gave this account, read offline from the multi-file auth store
45
+ * (no socket, no `baileys` import) so the server can merge chats at startup. A number's forward file
46
+ * is its current WhatsApp ID; a reverse file only fills numbers without one (an old ID's reverse file
47
+ * can outlive a number that moved). Missing dir returns []; any other directory error throws.
48
+ */
49
+ export function readStoredLidMappings(authDir) {
50
+ let names;
51
+ try {
52
+ names = readdirSync(authDir);
53
+ }
54
+ catch (err) {
55
+ if (err.code === 'ENOENT')
56
+ return [];
57
+ throw err;
58
+ }
59
+ const lidOf = new Map();
60
+ const reverse = new Map();
61
+ for (const name of names) {
62
+ const forward = FORWARD.exec(name);
63
+ if (forward) {
64
+ const lid = readUser(join(authDir, name));
65
+ if (lid)
66
+ lidOf.set(forward[1], lid);
67
+ continue;
68
+ }
69
+ const back = REVERSE.exec(name);
70
+ if (back) {
71
+ const pn = readUser(join(authDir, name));
72
+ if (pn)
73
+ reverse.set(pn, (reverse.get(pn) ?? new Set()).add(back[1]));
74
+ }
75
+ }
76
+ for (const [pn, lids] of reverse) {
77
+ if (!lidOf.has(pn) && lids.size === 1)
78
+ lidOf.set(pn, [...lids][0]);
79
+ }
80
+ if (lidOf.size === 0)
81
+ return [];
82
+ const own = ownJids(authDir);
83
+ // A LID claimed by more than one number is ambiguous: emitting it would join two people's chats.
84
+ const claims = new Map();
85
+ for (const lid of lidOf.values())
86
+ claims.set(lid, (claims.get(lid) ?? 0) + 1);
87
+ const out = [];
88
+ for (const [pn, lid] of [...lidOf].sort(([a], [b]) => a.localeCompare(b))) {
89
+ if (claims.get(lid) !== 1)
90
+ continue;
91
+ const pair = contactAliasPair(`${pn}@s.whatsapp.net`, `${lid}@lid`);
92
+ if (pair && !own.has(pair.jid) && !own.has(pair.alias))
93
+ out.push({ ...pair, source: 'keystore' });
94
+ }
95
+ return out;
96
+ }
@@ -0,0 +1,84 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { WaStatus } from '@ezychat/shared';
3
+ import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaContactAlias, type WaGroupInfo, type WaIncomingMessage, type WaSendFile, type WaSendOptions } from '../types.js';
4
+ export interface FakeSentRecord {
5
+ chatJid: string;
6
+ text?: string;
7
+ file?: WaSendFile;
8
+ id: string;
9
+ /** Reply options as the real adapter would receive them. */
10
+ reply?: WaSendOptions;
11
+ }
12
+ /**
13
+ * In-memory WaAdapter for tests and `--fake-wa` dev mode. Never touches the network.
14
+ */
15
+ /** Per-process id prefix, so a restarted dev server never reuses message ids stored in app.db. */
16
+ export declare const FAKE_RUN: string;
17
+ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
18
+ private _status;
19
+ private readonly autoOpen;
20
+ private inCounter;
21
+ private outCounter;
22
+ private nextSendError;
23
+ private readonly media;
24
+ private readonly groups;
25
+ private readonly abouts;
26
+ /** Every getGroupInfo / getAbout call, for tests that check caching. */
27
+ readonly groupInfoCalls: string[];
28
+ readonly aboutCalls: string[];
29
+ readonly sent: FakeSentRecord[];
30
+ readonly reads: Array<{
31
+ chatJid: string;
32
+ messageIds: string[];
33
+ }>;
34
+ readonly presences: Array<{
35
+ chatJid: string;
36
+ presence: 'composing' | 'paused';
37
+ }>;
38
+ constructor(opts?: {
39
+ autoOpen?: boolean;
40
+ });
41
+ get status(): WaStatus;
42
+ on<K extends keyof WaAdapterEvents>(ev: K, fn: (...a: WaAdapterEvents[K]) => void): this;
43
+ off<K extends keyof WaAdapterEvents>(ev: K, fn: (...a: WaAdapterEvents[K]) => void): this;
44
+ private emitTyped;
45
+ private setStatus;
46
+ connect(): Promise<void>;
47
+ disconnect(): Promise<void>;
48
+ logout(): Promise<void>;
49
+ takeover(): Promise<void>;
50
+ /** test helper: last phone number a pairing code was requested for */
51
+ pairingRequests: string[];
52
+ requestPairingCode(phone: string): Promise<string>;
53
+ private beforeSend;
54
+ private afterSend;
55
+ sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
56
+ sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
57
+ markRead(chatJid: string, messageIds: string[]): Promise<void>;
58
+ sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
59
+ downloadMedia(messageId: string): Promise<Buffer | null>;
60
+ getProfilePicture(_jid: string): Promise<string | null>;
61
+ getContactAliases(_jids: string[]): Promise<WaContactAlias[]>;
62
+ getGroupInfo(groupJid: string): Promise<WaGroupInfo>;
63
+ getAbout(jid: string): Promise<string | null>;
64
+ /** Make the next getAbout calls fail, as a WhatsApp request error would. */
65
+ aboutFails: boolean;
66
+ /** What getGroupInfo returns for this group (members as WhatsApp lists them, no names). */
67
+ setGroupInfo(info: WaGroupInfo): void;
68
+ /** What getAbout returns for this person. */
69
+ setAbout(jid: string, about: string): void;
70
+ /** Emit `groupChanged`, as WhatsApp does when members are added, removed, promoted or demoted. */
71
+ simulateGroupChanged(groupJid: string): void;
72
+ /** makes downloadMedia(messageId) return `buffer` */
73
+ setMedia(messageId: string, buffer: Buffer): void;
74
+ /** Emit explicit PN/LID pairs as the adapter does when WhatsApp reveals them. */
75
+ simulateContactAliases(pairs: WaContactAlias[]): void;
76
+ /** Emit an incoming live message. Generates id `FAKE-<run>-<n>` unless given. */
77
+ simulateIncoming(p: Partial<WaIncomingMessage> & {
78
+ chatJid: string;
79
+ body: string;
80
+ }): WaIncomingMessage;
81
+ simulateStatus(s: Partial<WaStatus>): void;
82
+ failNextSend(err?: Error): void;
83
+ setConnected(connected: boolean): void;
84
+ }