@ezychat/wa 0.1.68 → 0.1.70

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.
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import { type BaileysEventMap, type MessageUpsertType, type MessageUserReceiptUpdate, type WAMessageUpdate, type WAMessage } from 'baileys';
3
3
  import type { WaStatus } from '@ezychat/shared';
4
- import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaAdapterOptions, type WaContactAlias, type WaGroupInfo, type WaMessageStatusUpdate, type WaReactionTarget, type WaSendFile, type WaSendOptions } from '../types.js';
4
+ import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaAdapterOptions, type WaContactAlias, type WaGroupInfo, type WaMessageStatusUpdate, type WaMessageTarget, type WaReactionTarget, type WaSendFile, type WaSendOptions } from '../types.js';
5
5
  /** True when a send error means "the connection went away" (retry later) rather than a real rejection. */
6
6
  export declare function isConnectionError(err: unknown): boolean;
7
7
  /** Per-participant group receipt → our status (read wins over delivered). */
@@ -25,6 +25,11 @@ export declare class BaileysAdapter extends EventEmitter implements WaAdapter {
25
25
  private readonly logger;
26
26
  private readonly historyDaysOpt;
27
27
  private raw;
28
+ /**
29
+ * Target ids of edits and deletes this adapter sent → when: WhatsApp echoes them back like the
30
+ * phone's own, and the core already recorded them with the teammate who made them.
31
+ */
32
+ private readonly ownChanges;
28
33
  private attempt;
29
34
  private reconnectTimer;
30
35
  /** true when the socket was closed on purpose (disconnect/logout/replaced/blocked). */
@@ -87,7 +92,17 @@ export declare class BaileysAdapter extends EventEmitter implements WaAdapter {
87
92
  private send;
88
93
  sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
89
94
  sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
95
+ /** The key WhatsApp matches a message by; the cached key carries the group author as delivered. */
96
+ private targetKey;
90
97
  sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
98
+ /** Remember an edit/delete we send, so its echo is not reported as the phone's. */
99
+ private markOwnChange;
100
+ /** True (and forgotten) when a fromMe change of `targetId` is the echo of our own. */
101
+ private takeOwnChange;
102
+ private sendChange;
103
+ editText(chatJid: string, target: WaMessageTarget, text: string): Promise<SendResult>;
104
+ deleteForEveryone(chatJid: string, target: WaMessageTarget): Promise<SendResult>;
105
+ deleteForMe(chatJid: string, target: WaMessageTarget, timestamp: number): Promise<void>;
91
106
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
92
107
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
93
108
  downloadMedia(messageId: string): Promise<Buffer | null>;
@@ -5,14 +5,22 @@ import { WaUnavailableError, } from '../types.js';
5
5
  import { createAuthStore } from './auth-store.js';
6
6
  import { backoffMs, classifyDisconnect } from './disconnect.js';
7
7
  import { Lru } from './lru.js';
8
+ import { createWaVersionSource } from './wa-version.js';
8
9
  import { jidType, mapStoredReactions, mapWAMessage, mapWAReaction, toMs } from './mapping.js';
9
10
  import { quotedForChat, quotedFromRef } from './quoted.js';
11
+ import { mapWAProtocol } from './protocol.js';
10
12
  import { contactAliasPair, normalizeContactJid } from './contact-aliases.js';
11
13
  import { checkedNumberJid } from './number-check.js';
12
14
  import { profilePhoto } from './photos.js';
13
15
  import { aboutFromStatusResult, mapGroupMetadata } from './groups.js';
14
16
  const RAW_CACHE_MAX = 2000;
17
+ /** How long an edit or delete this adapter sent waits for WhatsApp's echo of it. */
18
+ const OWN_CHANGE_TTL_MS = 60_000;
15
19
  const DAY_MS = 86_400_000;
20
+ /** One per process: every inbox and reconnect shares the looked-up WhatsApp Web version. */
21
+ const waVersion = createWaVersionSource({
22
+ fetchLatest: () => fetchLatestBaileysVersion(),
23
+ });
16
24
  /** Boom status codes Baileys uses when the socket is closed / lost / replaced / unavailable. */
17
25
  const CONNECTION_STATUS_CODES = new Set([408, 428, 440, 503]);
18
26
  /** True when a send error means "the connection went away" (retry later) rather than a real rejection. */
@@ -75,6 +83,11 @@ export class BaileysAdapter extends EventEmitter {
75
83
  logger;
76
84
  historyDaysOpt;
77
85
  raw = new Lru(RAW_CACHE_MAX);
86
+ /**
87
+ * Target ids of edits and deletes this adapter sent → when: WhatsApp echoes them back like the
88
+ * phone's own, and the core already recorded them with the teammate who made them.
89
+ */
90
+ ownChanges = new Map();
78
91
  attempt = 0;
79
92
  reconnectTimer = null;
80
93
  /** true when the socket was closed on purpose (disconnect/logout/replaced/blocked). */
@@ -231,14 +244,12 @@ export class BaileysAdapter extends EventEmitter {
231
244
  async open() {
232
245
  this.setStatus({ state: 'connecting', qr: null });
233
246
  const { state, saveCreds } = await this.auth.load();
234
- let version;
235
- try {
236
- const latest = await fetchLatestBaileysVersion();
237
- version = latest.version;
238
- }
239
- catch (err) {
240
- this.logger.warn({ err }, 'could not fetch latest WA version; using bundled default');
241
- }
247
+ // Never wait for the GitHub lookup: connect with the version we have, refresh it for next time.
248
+ const version = waVersion.current();
249
+ void waVersion.refresh().then(({ error }) => {
250
+ if (error)
251
+ this.logger.warn({ err: error }, 'could not fetch latest WA version');
252
+ });
242
253
  if (this.stopped)
243
254
  return; // disconnect() was called while loading
244
255
  const blog = this.logger.child({ module: 'baileys' });
@@ -407,8 +418,15 @@ export class BaileysAdapter extends EventEmitter {
407
418
  if (!key.id || !key.remoteJid || !key.fromMe)
408
419
  continue;
409
420
  const status = mapAck(update.status);
410
- if (status)
411
- this.emitTyped('messageStatus', { id: key.id, chatJid: key.remoteJid, status });
421
+ if (!status)
422
+ continue;
423
+ const code = status === 'failed' ? update.messageStubParameters?.[0] : undefined;
424
+ this.emitTyped('messageStatus', {
425
+ id: key.id,
426
+ chatJid: key.remoteJid,
427
+ status,
428
+ ...(typeof code === 'string' && code ? { error: code } : {}),
429
+ });
412
430
  }
413
431
  }
414
432
  /** @internal exposed for tests */
@@ -439,6 +457,21 @@ export class BaileysAdapter extends EventEmitter {
439
457
  this.emitTyped('reaction', reaction, { source });
440
458
  return;
441
459
  }
460
+ // Edits and deletes arrive as protocol messages naming the changed message. Baileys also
461
+ // reports them as messages.update; only this path is read, so each change counts once.
462
+ const change = mapWAProtocol(m);
463
+ if (change) {
464
+ const { targetId, fromMe, timestamp } = change.kind === 'edit' ? change.edit : change.revoke;
465
+ if (source === 'history' && timestamp < this.historyCutoff())
466
+ return;
467
+ if (fromMe && this.takeOwnChange(targetId))
468
+ return;
469
+ if (change.kind === 'edit')
470
+ this.emitTyped('messageEdited', change.edit, { source });
471
+ else
472
+ this.emitTyped('messageDeleted', change.revoke, { source });
473
+ return;
474
+ }
442
475
  if (m.message)
443
476
  this.raw.set(id, m);
444
477
  // History sync keeps earlier reactions on the reacted message itself.
@@ -613,7 +646,11 @@ export class BaileysAdapter extends EventEmitter {
613
646
  : undefined;
614
647
  let res;
615
648
  try {
616
- res = await sock.sendMessage(chatJid, content, quoted ? { quoted } : undefined);
649
+ const options = {
650
+ ...(quoted ? { quoted } : {}),
651
+ ...(opts?.messageId ? { messageId: opts.messageId } : {}),
652
+ };
653
+ res = await sock.sendMessage(chatJid, content, Object.keys(options).length ? options : undefined);
617
654
  }
618
655
  catch (err) {
619
656
  // connection dropped mid-send: report as unavailable so the queue keeps the job pending
@@ -666,16 +703,77 @@ export class BaileysAdapter extends EventEmitter {
666
703
  }
667
704
  return this.send(chatJid, content, opts);
668
705
  }
669
- sendReaction(chatJid, target, emoji) {
670
- // The cached key carries the group author exactly as WhatsApp delivered it.
706
+ /** The key WhatsApp matches a message by; the cached key carries the group author as delivered. */
707
+ targetKey(chatJid, target) {
671
708
  const participant = this.raw.get(target.id)?.key?.participant ?? target.participant;
672
- const key = {
709
+ return {
673
710
  remoteJid: chatJid,
674
711
  id: target.id,
675
712
  fromMe: target.fromMe,
676
713
  ...(participant && chatJid.endsWith('@g.us') ? { participant } : {}),
677
714
  };
678
- return this.send(chatJid, { react: { text: emoji, key } }, undefined, false);
715
+ }
716
+ sendReaction(chatJid, target, emoji) {
717
+ return this.send(chatJid, { react: { text: emoji, key: this.targetKey(chatJid, target) } }, undefined, false);
718
+ }
719
+ /** Remember an edit/delete we send, so its echo is not reported as the phone's. */
720
+ markOwnChange(targetId) {
721
+ const t = Date.now();
722
+ for (const [id, at] of this.ownChanges)
723
+ if (t - at > OWN_CHANGE_TTL_MS)
724
+ this.ownChanges.delete(id);
725
+ this.ownChanges.set(targetId, t);
726
+ }
727
+ /** True (and forgotten) when a fromMe change of `targetId` is the echo of our own. */
728
+ takeOwnChange(targetId) {
729
+ const at = this.ownChanges.get(targetId);
730
+ if (at === undefined)
731
+ return false;
732
+ this.ownChanges.delete(targetId);
733
+ return Date.now() - at <= OWN_CHANGE_TTL_MS;
734
+ }
735
+ async sendChange(chatJid, target, content) {
736
+ this.markOwnChange(target.id);
737
+ try {
738
+ return await this.send(chatJid, content, undefined, false);
739
+ }
740
+ catch (err) {
741
+ this.ownChanges.delete(target.id);
742
+ throw err;
743
+ }
744
+ }
745
+ editText(chatJid, target, text) {
746
+ const key = this.targetKey(chatJid, target);
747
+ const cached = this.raw.get(target.id);
748
+ return this.sendChange(chatJid, target, { text, edit: key }).then((r) => {
749
+ // Later quotes of the message show the new text.
750
+ const m = cached?.message;
751
+ if (m?.conversation != null)
752
+ m.conversation = text;
753
+ else if (m?.extendedTextMessage)
754
+ m.extendedTextMessage.text = text;
755
+ return r;
756
+ });
757
+ }
758
+ deleteForEveryone(chatJid, target) {
759
+ return this.sendChange(chatJid, target, { delete: this.targetKey(chatJid, target) });
760
+ }
761
+ async deleteForMe(chatJid, target, timestamp) {
762
+ const sock = this.requireOpen();
763
+ try {
764
+ await sock.chatModify({
765
+ deleteForMe: {
766
+ deleteMedia: true,
767
+ key: this.targetKey(chatJid, target),
768
+ timestamp: Math.floor(timestamp / 1000),
769
+ },
770
+ }, chatJid);
771
+ }
772
+ catch (err) {
773
+ if (isConnectionError(err) || this.sock !== sock || this._status.state !== 'open')
774
+ throw new WaUnavailableError(`WhatsApp connection lost: ${String(err?.message ?? err)}`);
775
+ throw err;
776
+ }
679
777
  }
680
778
  async markRead(chatJid, messageIds) {
681
779
  const sock = this.requireOpen();
@@ -0,0 +1,16 @@
1
+ import { proto } from 'baileys';
2
+ import type { WaMessageEdit, WaMessageRevoke } from '../types.js';
3
+ export type WaProtocolChange = {
4
+ kind: 'edit';
5
+ edit: WaMessageEdit;
6
+ } | {
7
+ kind: 'revoke';
8
+ revoke: WaMessageRevoke;
9
+ };
10
+ /**
11
+ * An edit (`MESSAGE_EDIT`) or a delete for everyone (`REVOKE`) as WhatsApp delivers it: a
12
+ * `protocolMessage` whose `key` names the changed message, sent by whoever changed it (the outer
13
+ * key: the DM partner, a group participant or the linked number). Null for every other message,
14
+ * for changes without a target or author, and for status/broadcast/newsletter chats.
15
+ */
16
+ export declare function mapWAProtocol(msg: proto.IWebMessageInfo): WaProtocolChange | null;
@@ -0,0 +1,67 @@
1
+ import { normalizeMessageContent, proto } from 'baileys';
2
+ import { normalizeContactJid } from './contact-aliases.js';
3
+ import { jidType, toMs } from './mapping.js';
4
+ /** The text or caption of an edited message (what `MESSAGE_EDIT` carries), or null. */
5
+ function editedText(message) {
6
+ const m = normalizeMessageContent(message);
7
+ if (!m)
8
+ return null;
9
+ if (m.conversation != null)
10
+ return m.conversation;
11
+ return (m.extendedTextMessage?.text ??
12
+ m.imageMessage?.caption ??
13
+ m.videoMessage?.caption ??
14
+ m.documentMessage?.caption ??
15
+ null);
16
+ }
17
+ function millis(ts) {
18
+ if (ts == null)
19
+ return null;
20
+ if (typeof ts === 'number')
21
+ return ts;
22
+ if ('toNumber' in ts && typeof ts.toNumber === 'function')
23
+ return ts.toNumber();
24
+ if ('low' in ts)
25
+ return (ts.high >>> 0) * 2 ** 32 + (ts.low >>> 0);
26
+ return null;
27
+ }
28
+ /**
29
+ * An edit (`MESSAGE_EDIT`) or a delete for everyone (`REVOKE`) as WhatsApp delivers it: a
30
+ * `protocolMessage` whose `key` names the changed message, sent by whoever changed it (the outer
31
+ * key: the DM partner, a group participant or the linked number). Null for every other message,
32
+ * for changes without a target or author, and for status/broadcast/newsletter chats.
33
+ */
34
+ export function mapWAProtocol(msg) {
35
+ const key = msg.key;
36
+ const chatJid = key?.remoteJid;
37
+ if (!key || !chatJid || jidType(chatJid) === 'other')
38
+ return null;
39
+ const protocol = normalizeMessageContent(msg.message)?.protocolMessage;
40
+ const targetId = protocol?.key?.id;
41
+ if (!protocol || !targetId)
42
+ return null;
43
+ const isGroup = jidType(chatJid) === 'group';
44
+ const fromMe = !!key.fromMe;
45
+ const senderJid = fromMe
46
+ ? null
47
+ : isGroup
48
+ ? normalizeContactJid(key.participant)
49
+ : (normalizeContactJid(chatJid) ?? chatJid);
50
+ if (!fromMe && !senderJid)
51
+ return null;
52
+ const senderJidAlt = !fromMe && isGroup ? normalizeContactJid(key.participantAlt) : null;
53
+ const alt = isGroup ? null : normalizeContactJid(key.remoteJidAlt);
54
+ const chatJidAlt = alt && alt.endsWith('@lid') !== chatJid.endsWith('@lid') ? alt : null;
55
+ const sent = toMs(msg.messageTimestamp);
56
+ const base = { targetId, chatJid, chatJidAlt, senderJid, senderJidAlt, fromMe };
57
+ if (protocol.type === proto.Message.ProtocolMessage.Type.REVOKE)
58
+ return { kind: 'revoke', revoke: { ...base, timestamp: sent } };
59
+ if (protocol.type === proto.Message.ProtocolMessage.Type.MESSAGE_EDIT) {
60
+ const body = editedText(protocol.editedMessage);
61
+ if (body === null)
62
+ return null;
63
+ const at = millis(protocol.timestampMs) ?? sent;
64
+ return { kind: 'edit', edit: { ...base, body, timestamp: at } };
65
+ }
66
+ return null;
67
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The WhatsApp Web version to announce. WhatsApp refuses clients whose version is too old, so Baileys
3
+ * suggests looking up the newest one from its GitHub copy on every connect. That lookup has no
4
+ * timeout, and awaiting it before the socket held every connect (and every new QR) at "connecting"
5
+ * while GitHub was slow or blocked. A connect never waits for it now: it uses the last version
6
+ * looked up (or Baileys' bundled one) at once, and the lookup refreshes in the background, bounded
7
+ * by `timeoutMs` and cached, so a connect WhatsApp refused for an old version retries with the new one.
8
+ */
9
+ export type WaVersion = [number, number, number];
10
+ export interface WaVersionSourceOptions {
11
+ fetchLatest: () => Promise<{
12
+ version: WaVersion;
13
+ }>;
14
+ timeoutMs?: number;
15
+ /** How long a fetched version is reused before the next lookup. */
16
+ ttlMs?: number;
17
+ /** How long to skip the lookup after a failure or timeout. */
18
+ failureTtlMs?: number;
19
+ now?: () => number;
20
+ }
21
+ export declare const WA_VERSION_TIMEOUT_MS = 10000;
22
+ export interface WaVersionRefresh {
23
+ version: WaVersion | undefined;
24
+ /** Set when this refresh tried the lookup and it failed or timed out. */
25
+ error?: unknown;
26
+ }
27
+ export interface WaVersionSource {
28
+ /** The version to connect with now: the last one looked up, else undefined (Baileys' bundled one). */
29
+ current(): WaVersion | undefined;
30
+ /** Looks the version up unless a recent result is cached; never rejects, one lookup at a time. */
31
+ refresh(): Promise<WaVersionRefresh>;
32
+ }
33
+ export declare function createWaVersionSource(opts: WaVersionSourceOptions): WaVersionSource;
@@ -0,0 +1,44 @@
1
+ export const WA_VERSION_TIMEOUT_MS = 10_000;
2
+ const WA_VERSION_TTL_MS = 6 * 60 * 60_000;
3
+ const WA_VERSION_FAILURE_TTL_MS = 10 * 60_000;
4
+ export function createWaVersionSource(opts) {
5
+ const timeoutMs = opts.timeoutMs ?? WA_VERSION_TIMEOUT_MS;
6
+ const ttlMs = opts.ttlMs ?? WA_VERSION_TTL_MS;
7
+ const failureTtlMs = opts.failureTtlMs ?? WA_VERSION_FAILURE_TTL_MS;
8
+ const now = opts.now ?? Date.now;
9
+ let version;
10
+ let nextLookupAt = 0;
11
+ let inflight = null;
12
+ const lookup = async () => {
13
+ let timer;
14
+ try {
15
+ const timeout = new Promise((_, reject) => {
16
+ timer = setTimeout(() => reject(new Error(`WA version lookup timed out after ${timeoutMs} ms`)), timeoutMs);
17
+ timer.unref?.();
18
+ });
19
+ ({ version } = await Promise.race([opts.fetchLatest(), timeout]));
20
+ nextLookupAt = now() + ttlMs;
21
+ return { version };
22
+ }
23
+ catch (error) {
24
+ nextLookupAt = now() + failureTtlMs;
25
+ return { version, error };
26
+ }
27
+ finally {
28
+ clearTimeout(timer);
29
+ }
30
+ };
31
+ return {
32
+ current: () => version,
33
+ refresh() {
34
+ if (inflight)
35
+ return inflight;
36
+ if (now() < nextLookupAt)
37
+ return Promise.resolve({ version });
38
+ inflight = lookup().finally(() => {
39
+ inflight = null;
40
+ });
41
+ return inflight;
42
+ },
43
+ };
44
+ }
@@ -1,6 +1,6 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { WaStatus } from '@ezychat/shared';
3
- import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaCapabilities, type WaContactAlias, type WaGroupInfo, type WaIncomingMessage, type WaReaction, type WaReactionTarget, type WaSendFile, type WaSendOptions, type WaTemplate, type WaTemplateSend } from '../types.js';
3
+ import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaCapabilities, type WaContactAlias, type WaGroupInfo, type WaIncomingMessage, type WaMessageEdit, type WaMessageRevoke, type WaMessageTarget, type WaReaction, type WaReactionTarget, type WaSendFile, type WaSendOptions, type WaTemplate, type WaTemplateSend } from '../types.js';
4
4
  export interface FakeSentRecord {
5
5
  chatJid: string;
6
6
  text?: string;
@@ -25,6 +25,7 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
25
25
  private inCounter;
26
26
  private outCounter;
27
27
  private nextSendError;
28
+ private skipDelivery;
28
29
  private readonly media;
29
30
  private readonly groups;
30
31
  private readonly abouts;
@@ -38,6 +39,22 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
38
39
  target: WaReactionTarget;
39
40
  emoji: string;
40
41
  }>;
42
+ /** Every edit, delete for everyone and delete for me sent (`editText`, `deleteFor…`). */
43
+ readonly changes: Array<{
44
+ kind: 'edit';
45
+ chatJid: string;
46
+ target: WaMessageTarget;
47
+ text: string;
48
+ } | {
49
+ kind: 'deleteForEveryone';
50
+ chatJid: string;
51
+ target: WaMessageTarget;
52
+ } | {
53
+ kind: 'deleteForMe';
54
+ chatJid: string;
55
+ target: WaMessageTarget;
56
+ timestamp: number;
57
+ }>;
41
58
  readonly reads: Array<{
42
59
  chatJid: string;
43
60
  messageIds: string[];
@@ -101,6 +118,10 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
101
118
  sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
102
119
  /** Like WhatsApp, the linked number's own reaction comes back as a `reaction` event first. */
103
120
  sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
121
+ /** Unlike reactions, WhatsApp's echo of our own edit or delete is never reported (as Baileys). */
122
+ editText(chatJid: string, target: WaMessageTarget, text: string): Promise<SendResult>;
123
+ deleteForEveryone(chatJid: string, target: WaMessageTarget): Promise<SendResult>;
124
+ deleteForMe(chatJid: string, target: WaMessageTarget, timestamp: number): Promise<void>;
104
125
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
105
126
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
106
127
  downloadMedia(messageId: string): Promise<Buffer | null>;
@@ -140,7 +161,24 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
140
161
  targetId: string;
141
162
  emoji: string;
142
163
  }, source?: 'live' | 'history'): WaReaction;
164
+ /**
165
+ * Emit an edit (live unless `source` says history). In a DM the editor defaults to the chat
166
+ * partner; `fromMe` means the linked phone edited.
167
+ */
168
+ simulateEdit(p: Partial<WaMessageEdit> & {
169
+ chatJid: string;
170
+ targetId: string;
171
+ body: string;
172
+ }, source?: 'live' | 'history'): WaMessageEdit;
173
+ /** Emit a delete for everyone (live unless `source` says history), as `simulateEdit`. */
174
+ simulateRevoke(p: Partial<WaMessageRevoke> & {
175
+ chatJid: string;
176
+ targetId: string;
177
+ }, source?: 'live' | 'history'): WaMessageRevoke;
178
+ private changeAuthor;
143
179
  simulateStatus(s: Partial<WaStatus>): void;
180
+ /** The next send gets its server ack but no delivered receipt (no phone took it). */
181
+ skipNextDelivery(): void;
144
182
  failNextSend(err?: Error): void;
145
183
  setConnected(connected: boolean): void;
146
184
  }
@@ -15,6 +15,7 @@ export class FakeWaAdapter extends EventEmitter {
15
15
  inCounter = 0;
16
16
  outCounter = 0;
17
17
  nextSendError = null;
18
+ skipDelivery = false;
18
19
  media = new Map();
19
20
  groups = new Map();
20
21
  abouts = new Map();
@@ -24,6 +25,8 @@ export class FakeWaAdapter extends EventEmitter {
24
25
  aboutCalls = [];
25
26
  sent = [];
26
27
  reactions = [];
28
+ /** Every edit, delete for everyone and delete for me sent (`editText`, `deleteFor…`). */
29
+ changes = [];
27
30
  reads = [];
28
31
  presences = [];
29
32
  /** Every template sent (adapters given `templates`). */
@@ -132,12 +135,15 @@ export class FakeWaAdapter extends EventEmitter {
132
135
  afterSend(chatJid, id) {
133
136
  const timestamp = Date.now();
134
137
  this.emitTyped('messageStatus', { id, chatJid, status: 'sent' });
135
- setImmediate(() => this.emitTyped('messageStatus', { id, chatJid, status: 'delivered' }));
138
+ if (this.skipDelivery)
139
+ this.skipDelivery = false;
140
+ else
141
+ setImmediate(() => this.emitTyped('messageStatus', { id, chatJid, status: 'delivered' }));
136
142
  return { id, timestamp };
137
143
  }
138
144
  async sendText(chatJid, text, opts) {
139
145
  this.beforeSend();
140
- const id = `${this.idPrefix}-OUT-${FAKE_RUN}-${++this.outCounter}`;
146
+ const id = opts?.messageId ?? `${this.idPrefix}-OUT-${FAKE_RUN}-${++this.outCounter}`;
141
147
  this.sent.push({
142
148
  chatJid,
143
149
  text,
@@ -149,7 +155,7 @@ export class FakeWaAdapter extends EventEmitter {
149
155
  }
150
156
  async sendMedia(chatJid, file, opts) {
151
157
  this.beforeSend();
152
- const id = `${this.idPrefix}-OUT-${FAKE_RUN}-${++this.outCounter}`;
158
+ const id = opts?.messageId ?? `${this.idPrefix}-OUT-${FAKE_RUN}-${++this.outCounter}`;
153
159
  this.sent.push({ chatJid, file, id, ...(opts?.quotedId ? { reply: opts } : {}) });
154
160
  this.media.set(id, file.buffer);
155
161
  return this.afterSend(chatJid, id);
@@ -163,6 +169,24 @@ export class FakeWaAdapter extends EventEmitter {
163
169
  this.simulateReaction({ chatJid, targetId: target.id, emoji, fromMe: true, timestamp });
164
170
  return { id, timestamp };
165
171
  }
172
+ /** Unlike reactions, WhatsApp's echo of our own edit or delete is never reported (as Baileys). */
173
+ async editText(chatJid, target, text) {
174
+ this.beforeSend();
175
+ this.changes.push({ kind: 'edit', chatJid, target: { ...target }, text });
176
+ return { id: `${this.idPrefix}-EDIT-${FAKE_RUN}-${++this.outCounter}`, timestamp: Date.now() };
177
+ }
178
+ async deleteForEveryone(chatJid, target) {
179
+ this.beforeSend();
180
+ this.changes.push({ kind: 'deleteForEveryone', chatJid, target: { ...target } });
181
+ return {
182
+ id: `${this.idPrefix}-REVOKE-${FAKE_RUN}-${++this.outCounter}`,
183
+ timestamp: Date.now(),
184
+ };
185
+ }
186
+ async deleteForMe(chatJid, target, timestamp) {
187
+ this.beforeSend();
188
+ this.changes.push({ kind: 'deleteForMe', chatJid, target: { ...target }, timestamp });
189
+ }
166
190
  async markRead(chatJid, messageIds) {
167
191
  this.reads.push({ chatJid, messageIds: [...messageIds] });
168
192
  }
@@ -298,9 +322,47 @@ export class FakeWaAdapter extends EventEmitter {
298
322
  this.emitTyped('reaction', reaction, { source });
299
323
  return reaction;
300
324
  }
325
+ /**
326
+ * Emit an edit (live unless `source` says history). In a DM the editor defaults to the chat
327
+ * partner; `fromMe` means the linked phone edited.
328
+ */
329
+ simulateEdit(p, source = 'live') {
330
+ const edit = { ...this.changeAuthor(p), body: p.body };
331
+ this.emitTyped('messageEdited', edit, { source });
332
+ return edit;
333
+ }
334
+ /** Emit a delete for everyone (live unless `source` says history), as `simulateEdit`. */
335
+ simulateRevoke(p, source = 'live') {
336
+ const revoke = this.changeAuthor(p);
337
+ this.emitTyped('messageDeleted', revoke, { source });
338
+ return revoke;
339
+ }
340
+ changeAuthor(p) {
341
+ const isGroup = p.chatJid.endsWith('@g.us');
342
+ const fromMe = p.fromMe ?? false;
343
+ return {
344
+ targetId: p.targetId,
345
+ chatJid: p.chatJid,
346
+ chatJidAlt: p.chatJidAlt ?? null,
347
+ senderJid: fromMe
348
+ ? null
349
+ : p.senderJid !== undefined
350
+ ? p.senderJid
351
+ : isGroup
352
+ ? null
353
+ : p.chatJid,
354
+ senderJidAlt: p.senderJidAlt ?? null,
355
+ fromMe,
356
+ timestamp: p.timestamp ?? Date.now(),
357
+ };
358
+ }
301
359
  simulateStatus(s) {
302
360
  this.setStatus(s);
303
361
  }
362
+ /** The next send gets its server ack but no delivered receipt (no phone took it). */
363
+ skipNextDelivery() {
364
+ this.skipDelivery = true;
365
+ }
304
366
  failNextSend(err = new Error('Fake send failure')) {
305
367
  this.nextSendError = err;
306
368
  }
package/dist/types.d.ts CHANGED
@@ -50,6 +50,43 @@ export interface WaReaction {
50
50
  /** epoch ms */
51
51
  timestamp: number;
52
52
  }
53
+ /**
54
+ * Someone edited a message they sent (WhatsApp `MESSAGE_EDIT`): the customer, a group member or the
55
+ * linked number itself (its phone or another linked device). Only text and captions are edited.
56
+ */
57
+ export interface WaMessageEdit {
58
+ /** WhatsApp id of the edited message. */
59
+ targetId: string;
60
+ /** The chat of the message (as WhatsApp delivered the edit). */
61
+ chatJid: string;
62
+ /** DM only: the same person's other address (`key.remoteJidAlt`), as for messages. */
63
+ chatJidAlt?: string | null;
64
+ /** Who edited: the group participant or the DM partner; null when `fromMe`. */
65
+ senderJid: string | null;
66
+ senderJidAlt?: string | null;
67
+ /** Edited from the linked number (its phone or another linked device). */
68
+ fromMe: boolean;
69
+ /** The new text or caption. */
70
+ body: string;
71
+ /** epoch ms of the edit */
72
+ timestamp: number;
73
+ }
74
+ /**
75
+ * Someone deleted a message for everyone (WhatsApp `REVOKE`): its sender, the linked number, or in
76
+ * a group an admin deleting a member's message.
77
+ */
78
+ export interface WaMessageRevoke {
79
+ /** WhatsApp id of the deleted message. */
80
+ targetId: string;
81
+ chatJid: string;
82
+ chatJidAlt?: string | null;
83
+ /** Who deleted it: the group participant or the DM partner; null when `fromMe`. */
84
+ senderJid: string | null;
85
+ senderJidAlt?: string | null;
86
+ fromMe: boolean;
87
+ /** epoch ms of the deletion */
88
+ timestamp: number;
89
+ }
53
90
  export interface WaChatInfo {
54
91
  jid: string;
55
92
  type: 'dm' | 'group';
@@ -97,6 +134,8 @@ export interface WaMessageStatusUpdate {
97
134
  id: string;
98
135
  chatJid: string;
99
136
  status: 'sent' | 'delivered' | 'read' | 'failed';
137
+ /** `failed` only: the error code WhatsApp gave, when it gave one (for the log). */
138
+ error?: string;
100
139
  }
101
140
  export interface WaAdapterEvents {
102
141
  status: [WaStatus];
@@ -107,6 +146,14 @@ export interface WaAdapterEvents {
107
146
  reaction: [WaReaction, {
108
147
  source: 'live' | 'history';
109
148
  }];
149
+ /** A message was edited (live or history); never an echo of an edit this adapter sent. */
150
+ messageEdited: [WaMessageEdit, {
151
+ source: 'live' | 'history';
152
+ }];
153
+ /** A message was deleted for everyone (live or history); never an echo of this adapter's own. */
154
+ messageDeleted: [WaMessageRevoke, {
155
+ source: 'live' | 'history';
156
+ }];
110
157
  messageStatus: [WaMessageStatusUpdate];
111
158
  chats: [WaChatInfo[]];
112
159
  contacts: [WaContactInfo[]];
@@ -139,8 +186,17 @@ export interface WaSendOptions {
139
186
  * Only for adapters with `capabilities.mentions`.
140
187
  */
141
188
  mentions?: string[];
189
+ /**
190
+ * Send under this WhatsApp message id instead of a new one (Retry of a message WhatsApp already
191
+ * took): phones that already have that id ignore the copy, so no one sees it twice. An adapter
192
+ * that cannot choose the id ignores it.
193
+ */
194
+ messageId?: string;
142
195
  }
143
- /** The message a reaction points at: WhatsApp matches it by chat, id, side and group author. */
196
+ /**
197
+ * The message a reaction, an edit or a delete points at: WhatsApp matches it by chat, id, side and
198
+ * group author.
199
+ */
144
200
  export interface WaReactionTarget {
145
201
  /** WhatsApp id of the message. */
146
202
  id: string;
@@ -149,6 +205,7 @@ export interface WaReactionTarget {
149
205
  /** Group messages from someone else: who wrote it. */
150
206
  participant?: string | null;
151
207
  }
208
+ export type WaMessageTarget = WaReactionTarget;
152
209
  export interface SendResult {
153
210
  id: string;
154
211
  timestamp: number;
@@ -196,6 +253,12 @@ export interface WaCapabilities {
196
253
  blockWindowHours?: number | null;
197
254
  /** Sends WhatsApp mentions (`WaSendOptions.mentions`); absent: no. */
198
255
  mentions?: boolean;
256
+ /** Edits a sent text or caption (`editText`). Absent: no. */
257
+ editMessage?: boolean;
258
+ /** Deletes a sent message for everyone (`deleteForEveryone`). Absent: no. */
259
+ deleteForEveryone?: boolean;
260
+ /** Deletes a message on the linked device itself (`deleteForMe`). Absent: no. */
261
+ deleteForMe?: boolean;
199
262
  }
200
263
  /** The capabilities of an adapter that declares none: a phone linked by QR (Baileys, the fake). */
201
264
  export declare const LINKED_PHONE_CAPABILITIES: WaCapabilities;
@@ -246,6 +309,18 @@ export interface WaAdapter {
246
309
  * reaction per number, so this replaces one made on the phone. Throws WaUnavailableError offline.
247
310
  */
248
311
  sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
312
+ /**
313
+ * Replaces the text (or caption) of a message the linked number sent (`capabilities.editMessage`).
314
+ * WhatsApp shows the customer "Edited". Throws WaUnavailableError offline.
315
+ */
316
+ editText?(chatJid: string, target: WaMessageTarget, text: string): Promise<SendResult>;
317
+ /** Deletes a message for everyone (`capabilities.deleteForEveryone`). Throws WaUnavailableError offline. */
318
+ deleteForEveryone?(chatJid: string, target: WaMessageTarget): Promise<SendResult>;
319
+ /**
320
+ * Deletes a message on the linked phone only ("Delete for me", `capabilities.deleteForMe`);
321
+ * `timestamp` is the message's (epoch ms). Throws WaUnavailableError offline.
322
+ */
323
+ deleteForMe?(chatJid: string, target: WaMessageTarget, timestamp: number): Promise<void>;
249
324
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
250
325
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
251
326
  /** re-download by id if still cached */
package/dist/types.js CHANGED
@@ -9,6 +9,9 @@ export const LINKED_PHONE_CAPABILITIES = Object.freeze({
9
9
  block: true,
10
10
  blockWindowHours: null,
11
11
  mentions: true,
12
+ editMessage: true,
13
+ deleteForEveryone: true,
14
+ deleteForMe: true,
12
15
  });
13
16
  export class WaUnavailableError extends Error {
14
17
  code = 'wa_unavailable';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezychat/wa",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "type": "module",
5
5
  "description": "EzyChat core: WhatsApp adapter interface, Baileys adapter and fake adapter.",
6
6
  "license": "MIT",
@@ -37,7 +37,7 @@
37
37
  "typecheck": "tsc --noEmit"
38
38
  },
39
39
  "dependencies": {
40
- "@ezychat/shared": "0.1.68",
40
+ "@ezychat/shared": "0.1.70",
41
41
  "baileys": "^7.0.0-rc14",
42
42
  "pino": "^10.4.0",
43
43
  "qrcode": "^1.5.4"