@ezychat/wa 0.0.0-stage → 0.1.38

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,198 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { WaUnavailableError, } from '../types.js';
3
+ const FAKE_ME = { jid: '60000000000@s.whatsapp.net', name: 'Fake' };
4
+ /**
5
+ * In-memory WaAdapter for tests and `--fake-wa` dev mode. Never touches the network.
6
+ */
7
+ /** Per-process id prefix, so a restarted dev server never reuses message ids stored in app.db. */
8
+ export const FAKE_RUN = Date.now().toString(36);
9
+ export class FakeWaAdapter extends EventEmitter {
10
+ _status = { state: 'disconnected', me: null, qr: null, lastError: null };
11
+ autoOpen;
12
+ inCounter = 0;
13
+ outCounter = 0;
14
+ nextSendError = null;
15
+ media = new Map();
16
+ groups = new Map();
17
+ abouts = new Map();
18
+ /** Every getGroupInfo / getAbout call, for tests that check caching. */
19
+ groupInfoCalls = [];
20
+ aboutCalls = [];
21
+ sent = [];
22
+ reads = [];
23
+ presences = [];
24
+ constructor(opts) {
25
+ super();
26
+ this.autoOpen = opts?.autoOpen ?? true;
27
+ }
28
+ get status() {
29
+ return this._status;
30
+ }
31
+ on(ev, fn) {
32
+ return super.on(ev, fn);
33
+ }
34
+ off(ev, fn) {
35
+ return super.off(ev, fn);
36
+ }
37
+ emitTyped(ev, ...args) {
38
+ this.emit(ev, ...args);
39
+ }
40
+ setStatus(patch) {
41
+ this._status = { ...this._status, ...patch };
42
+ this.emitTyped('status', this._status);
43
+ }
44
+ async connect() {
45
+ if (this._status.state === 'open' || this._status.state === 'connecting')
46
+ return;
47
+ this.setStatus({ state: 'connecting', qr: null, lastError: null });
48
+ if (this.autoOpen)
49
+ this.setStatus({ state: 'open', me: { ...FAKE_ME }, qr: null });
50
+ }
51
+ async disconnect() {
52
+ if (this._status.state === 'disconnected')
53
+ return;
54
+ this.setStatus({ state: 'disconnected', qr: null });
55
+ }
56
+ async logout() {
57
+ this.setStatus({ state: 'logged_out', me: null, qr: null });
58
+ }
59
+ async takeover() {
60
+ this.setStatus({ state: 'connecting', qr: null, lastError: null });
61
+ this.setStatus({ state: 'open', me: { ...FAKE_ME }, qr: null });
62
+ }
63
+ /** test helper: last phone number a pairing code was requested for */
64
+ pairingRequests = [];
65
+ async requestPairingCode(phone) {
66
+ if (this._status.state === 'open')
67
+ throw new Error('A WhatsApp number is already linked');
68
+ this.pairingRequests.push(phone.replace(/\D/g, ''));
69
+ return 'FAKE1234';
70
+ }
71
+ beforeSend() {
72
+ if (this._status.state !== 'open')
73
+ throw new WaUnavailableError();
74
+ if (this.nextSendError) {
75
+ const err = this.nextSendError;
76
+ this.nextSendError = null;
77
+ throw err;
78
+ }
79
+ }
80
+ afterSend(chatJid, id) {
81
+ const timestamp = Date.now();
82
+ this.emitTyped('messageStatus', { id, chatJid, status: 'sent' });
83
+ setImmediate(() => this.emitTyped('messageStatus', { id, chatJid, status: 'delivered' }));
84
+ return { id, timestamp };
85
+ }
86
+ async sendText(chatJid, text, opts) {
87
+ this.beforeSend();
88
+ const id = `FAKE-OUT-${FAKE_RUN}-${++this.outCounter}`;
89
+ this.sent.push({ chatJid, text, id, ...(opts?.quotedId ? { reply: opts } : {}) });
90
+ return this.afterSend(chatJid, id);
91
+ }
92
+ async sendMedia(chatJid, file, opts) {
93
+ this.beforeSend();
94
+ const id = `FAKE-OUT-${FAKE_RUN}-${++this.outCounter}`;
95
+ this.sent.push({ chatJid, file, id, ...(opts?.quotedId ? { reply: opts } : {}) });
96
+ this.media.set(id, file.buffer);
97
+ return this.afterSend(chatJid, id);
98
+ }
99
+ async markRead(chatJid, messageIds) {
100
+ this.reads.push({ chatJid, messageIds: [...messageIds] });
101
+ }
102
+ async sendPresence(chatJid, presence) {
103
+ this.presences.push({ chatJid, presence });
104
+ }
105
+ async downloadMedia(messageId) {
106
+ return this.media.get(messageId) ?? null;
107
+ }
108
+ async getProfilePicture(_jid) {
109
+ if (this._status.state !== 'open')
110
+ throw new WaUnavailableError();
111
+ return null;
112
+ }
113
+ async getContactAliases(_jids) {
114
+ return [];
115
+ }
116
+ async getGroupInfo(groupJid) {
117
+ if (this._status.state !== 'open')
118
+ throw new WaUnavailableError();
119
+ this.groupInfoCalls.push(groupJid);
120
+ const g = this.groups.get(groupJid);
121
+ const info = g ?? {
122
+ jid: groupJid,
123
+ name: null,
124
+ description: null,
125
+ createdAt: null,
126
+ ownerJid: null,
127
+ size: 0,
128
+ participants: [],
129
+ };
130
+ return structuredClone(info);
131
+ }
132
+ async getAbout(jid) {
133
+ if (this._status.state !== 'open')
134
+ throw new WaUnavailableError();
135
+ this.aboutCalls.push(jid);
136
+ if (this.aboutFails)
137
+ throw new Error('About lookup failed');
138
+ return this.abouts.get(jid) ?? null;
139
+ }
140
+ /** Make the next getAbout calls fail, as a WhatsApp request error would. */
141
+ aboutFails = false;
142
+ // ---- test helpers ----
143
+ /** What getGroupInfo returns for this group (members as WhatsApp lists them, no names). */
144
+ setGroupInfo(info) {
145
+ this.groups.set(info.jid, structuredClone(info));
146
+ }
147
+ /** What getAbout returns for this person. */
148
+ setAbout(jid, about) {
149
+ this.abouts.set(jid, about);
150
+ }
151
+ /** Emit `groupChanged`, as WhatsApp does when members are added, removed, promoted or demoted. */
152
+ simulateGroupChanged(groupJid) {
153
+ this.emitTyped('groupChanged', { groupJid });
154
+ }
155
+ /** makes downloadMedia(messageId) return `buffer` */
156
+ setMedia(messageId, buffer) {
157
+ this.media.set(messageId, buffer);
158
+ }
159
+ /** Emit explicit PN/LID pairs as the adapter does when WhatsApp reveals them. */
160
+ simulateContactAliases(pairs) {
161
+ this.emitTyped('contactAliases', pairs.map((p) => ({ ...p })));
162
+ }
163
+ /** Emit an incoming live message. Generates id `FAKE-<run>-<n>` unless given. */
164
+ simulateIncoming(p) {
165
+ // Unique per process start: a restarted dev server must not reuse ids already stored in app.db.
166
+ const id = p.id ?? `FAKE-${FAKE_RUN}-${++this.inCounter}`;
167
+ const isGroup = p.chatJid.endsWith('@g.us');
168
+ const msg = {
169
+ id,
170
+ chatJid: p.chatJid,
171
+ chatJidAlt: p.chatJidAlt ?? null,
172
+ senderJid: p.senderJid !== undefined ? p.senderJid : isGroup ? null : p.chatJid,
173
+ senderJidAlt: p.senderJidAlt ?? null,
174
+ senderName: p.senderName ?? null,
175
+ fromMe: p.fromMe ?? false,
176
+ type: p.type ?? 'text',
177
+ body: p.body,
178
+ ...(p.mentionedJids !== undefined ? { mentionedJids: [...p.mentionedJids] } : {}),
179
+ quotedId: p.quotedId ?? null,
180
+ timestamp: p.timestamp ?? Date.now(),
181
+ media: p.media ?? null,
182
+ };
183
+ this.emitTyped('message', msg, { source: 'live' });
184
+ return msg;
185
+ }
186
+ simulateStatus(s) {
187
+ this.setStatus(s);
188
+ }
189
+ failNextSend(err = new Error('Fake send failure')) {
190
+ this.nextSendError = err;
191
+ }
192
+ setConnected(connected) {
193
+ if (connected)
194
+ this.setStatus({ state: 'open', me: { ...FAKE_ME }, qr: null, lastError: null });
195
+ else
196
+ this.setStatus({ state: 'disconnected', qr: null });
197
+ }
198
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export { FAKE_RUN, FakeWaAdapter, type FakeSentRecord } from './fake/fake-adapter.js';
3
+ export { createBaileysAdapter } from './baileys/index.js';
4
+ export { readStoredLidMappings } from './baileys/stored-lid-mappings.js';
5
+ export { contactAliasPair, normalizeContactJid } from './baileys/contact-aliases.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export { FAKE_RUN, FakeWaAdapter } from './fake/fake-adapter.js';
3
+ export { createBaileysAdapter } from './baileys/index.js';
4
+ export { readStoredLidMappings } from './baileys/stored-lid-mappings.js';
5
+ export { contactAliasPair, normalizeContactJid } from './baileys/contact-aliases.js';
@@ -0,0 +1,163 @@
1
+ import type { Logger } from 'pino';
2
+ import type { MessageType, WaStatus } from '@ezychat/shared';
3
+ export interface WaIncomingMessage {
4
+ id: string;
5
+ chatJid: string;
6
+ /**
7
+ * DM only: the same person's other address as WhatsApp delivered it (`key.remoteJidAlt`), PN for a
8
+ * LID chat or the reverse, device suffix stripped. Null/absent for groups or when not provided.
9
+ */
10
+ chatJidAlt?: string | null;
11
+ senderJid: string | null;
12
+ /** Group sender's explicitly associated alternate phone/LID address. */
13
+ senderJidAlt?: string | null;
14
+ senderName: string | null;
15
+ fromMe: boolean;
16
+ type: MessageType;
17
+ body: string | null;
18
+ /** Explicit mentions from contextInfo; absent only for older adapters/imports. */
19
+ mentionedJids?: string[];
20
+ quotedId: string | null;
21
+ /** epoch ms */
22
+ timestamp: number;
23
+ media: null | {
24
+ mime: string;
25
+ fileName: string | null;
26
+ download: () => Promise<Buffer>;
27
+ };
28
+ }
29
+ export interface WaChatInfo {
30
+ jid: string;
31
+ type: 'dm' | 'group';
32
+ name: string | null;
33
+ }
34
+ export interface WaContactInfo {
35
+ jid: string;
36
+ pushName: string | null;
37
+ savedName: string | null;
38
+ /** Explicitly associated phone/LID identities, never inferred from names. */
39
+ aliases?: string[];
40
+ }
41
+ /** Where an explicit PN/LID association was learned (stored in jid_aliases.source). */
42
+ export type WaAliasSource = 'message' | 'history' | 'contacts' | 'lid-mapping' | 'keystore';
43
+ export interface WaContactAlias {
44
+ jid: string;
45
+ alias: string;
46
+ source?: WaAliasSource;
47
+ }
48
+ /** A member of a WhatsApp group, as WhatsApp lists it (names are not part of the list). */
49
+ export interface WaGroupParticipant {
50
+ /** The member's address as the group lists it: a phone-number JID or a WhatsApp ID (LID). */
51
+ jid: string;
52
+ /** Phone-number JID when WhatsApp shares it (`…@s.whatsapp.net`), else null (number hidden). */
53
+ phoneJid: string | null;
54
+ /** WhatsApp ID (`…@lid`) when known, else null. */
55
+ lidJid: string | null;
56
+ /** WhatsApp username, only when WhatsApp sends it. */
57
+ username: string | null;
58
+ role: 'superadmin' | 'admin' | 'member';
59
+ }
60
+ export interface WaGroupInfo {
61
+ jid: string;
62
+ name: string | null;
63
+ description: string | null;
64
+ /** epoch ms, null when unknown */
65
+ createdAt: number | null;
66
+ /** Creator's phone-number JID or WhatsApp ID, when known. */
67
+ ownerJid: string | null;
68
+ /** Member count reported by WhatsApp (can exceed participants.length for very large groups). */
69
+ size: number;
70
+ participants: WaGroupParticipant[];
71
+ }
72
+ export interface WaMessageStatusUpdate {
73
+ id: string;
74
+ chatJid: string;
75
+ status: 'sent' | 'delivered' | 'read' | 'failed';
76
+ }
77
+ export interface WaAdapterEvents {
78
+ status: [WaStatus];
79
+ message: [WaIncomingMessage, {
80
+ source: 'live' | 'history';
81
+ }];
82
+ messageStatus: [WaMessageStatusUpdate];
83
+ chats: [WaChatInfo[]];
84
+ contacts: [WaContactInfo[]];
85
+ contactAliases: [WaContactAlias[]];
86
+ /** A group's members or settings changed; cached member lists for it are stale. */
87
+ groupChanged: [{
88
+ groupJid: string;
89
+ }];
90
+ }
91
+ /**
92
+ * Saved copy of a quoted message (from the database). The adapter's live message cache is small and
93
+ * cleared on reconnect, so a reply to an older message rebuilds the quote from this instead.
94
+ */
95
+ export interface WaQuotedRef {
96
+ id: string;
97
+ fromMe: boolean;
98
+ /** Group messages: who wrote it (needed so WhatsApp attributes the quote). */
99
+ senderJid: string | null;
100
+ type: MessageType;
101
+ /** Text or caption; null for media without a caption. */
102
+ text: string | null;
103
+ }
104
+ export interface WaSendOptions {
105
+ /** Reply to this WhatsApp message id. */
106
+ quotedId?: string;
107
+ /** Used when the quoted message is no longer in the adapter's live cache. */
108
+ quoted?: WaQuotedRef;
109
+ }
110
+ export interface SendResult {
111
+ id: string;
112
+ timestamp: number;
113
+ }
114
+ export interface WaSendFile {
115
+ buffer: Buffer;
116
+ mime: string;
117
+ fileName: string;
118
+ caption?: string;
119
+ /** Send OGG/Opus audio as a voice note (push-to-talk), with its length and 64-bar waveform (0–100). */
120
+ voiceNote?: {
121
+ seconds: number;
122
+ waveform: Uint8Array;
123
+ };
124
+ }
125
+ export interface WaAdapter {
126
+ readonly status: WaStatus;
127
+ on<K extends keyof WaAdapterEvents>(ev: K, fn: (...a: WaAdapterEvents[K]) => void): this;
128
+ off<K extends keyof WaAdapterEvents>(ev: K, fn: (...a: WaAdapterEvents[K]) => void): this;
129
+ /** idempotent */
130
+ connect(): Promise<void>;
131
+ /** close socket, keep auth */
132
+ disconnect(): Promise<void>;
133
+ /** unlink device + wipe auth */
134
+ logout(): Promise<void>;
135
+ /** reconnect after 'replaced' */
136
+ takeover(): Promise<void>;
137
+ /** link by phone number: returns the 8-character pairing code to enter on the phone */
138
+ requestPairingCode(phone: string): Promise<string>;
139
+ sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
140
+ sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
141
+ markRead(chatJid: string, messageIds: string[]): Promise<void>;
142
+ sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
143
+ /** re-download by id if still cached */
144
+ downloadMedia(messageId: string): Promise<Buffer | null>;
145
+ /** Photo URL; null when there is none or it is private. Throws when it cannot be read. */
146
+ getProfilePicture(jid: string): Promise<string | null>;
147
+ /** Members and details of a group this number belongs to. Throws WaUnavailableError when offline. */
148
+ getGroupInfo(groupJid: string): Promise<WaGroupInfo>;
149
+ /** A person's WhatsApp "About" text; null when hidden or unset. Throws when it cannot be read. */
150
+ getAbout(jid: string): Promise<string | null>;
151
+ /** Resolve existing local phone/LID mappings only; must not query WhatsApp. */
152
+ getContactAliases(jids: string[]): Promise<WaContactAlias[]>;
153
+ }
154
+ export interface WaAdapterOptions {
155
+ authDir: string;
156
+ /** days of history to import; a getter is re-read on every (re)connect / history batch */
157
+ historyDays: number | (() => number);
158
+ logger?: Logger;
159
+ }
160
+ export declare class WaUnavailableError extends Error {
161
+ readonly code: "wa_unavailable";
162
+ constructor(message?: string);
163
+ }
package/dist/types.js ADDED
@@ -0,0 +1,7 @@
1
+ export class WaUnavailableError extends Error {
2
+ code = 'wa_unavailable';
3
+ constructor(message = 'WhatsApp is not connected') {
4
+ super(message);
5
+ this.name = 'WaUnavailableError';
6
+ }
7
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@ezychat/wa",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.38",
4
+ "type": "module",
5
+ "description": "EzyChat core: WhatsApp adapter interface, Baileys adapter and fake adapter.",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ossmalaysia/ezychat-lite.git",
10
+ "directory": "packages/wa"
11
+ },
12
+ "homepage": "https://ezychat.ai/",
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "exports": {
17
+ ".": {
18
+ "ezychat-source": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "files": [
27
+ "dist",
28
+ "!dist/**/*.test.*",
29
+ "!dist/**/*.tsbuildinfo"
30
+ ],
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "scripts": {
35
+ "build": "node ../../scripts/build-package.mjs",
36
+ "test": "vitest run --passWithNoTests",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@ezychat/shared": "0.1.38",
41
+ "baileys": "^7.0.0-rc14",
42
+ "pino": "^10.3.1",
43
+ "qrcode": "^1.5.4"
44
+ },
45
+ "devDependencies": {
46
+ "@types/qrcode": "^1.5.6"
47
+ }
48
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.