@ezychat/wa 0.1.49 → 0.1.51

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 WaSendFile, type WaSendOptions } from '../types.js';
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';
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). */
@@ -87,6 +87,7 @@ export declare class BaileysAdapter extends EventEmitter implements WaAdapter {
87
87
  private send;
88
88
  sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
89
89
  sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
90
+ sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
90
91
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
91
92
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
92
93
  downloadMedia(messageId: string): Promise<Buffer | null>;
@@ -5,7 +5,7 @@ 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 { jidType, mapWAMessage, toMs } from './mapping.js';
8
+ import { jidType, mapStoredReactions, mapWAMessage, mapWAReaction, toMs } from './mapping.js';
9
9
  import { quotedForChat, quotedFromRef } from './quoted.js';
10
10
  import { contactAliasPair, normalizeContactJid } from './contact-aliases.js';
11
11
  import { checkedNumberJid } from './number-check.js';
@@ -429,8 +429,22 @@ export class BaileysAdapter extends EventEmitter {
429
429
  const id = m.key?.id;
430
430
  if (!id)
431
431
  return;
432
+ // A reaction arrives as its own message in messages.upsert (live) and in history batches.
433
+ // Baileys also emits messages.reaction for the same live reaction: that event is not
434
+ // subscribed, so a reaction is counted once.
435
+ const reaction = mapWAReaction(m);
436
+ if (reaction) {
437
+ if (source === 'history' && reaction.timestamp < this.historyCutoff())
438
+ return;
439
+ this.emitTyped('reaction', reaction, { source });
440
+ return;
441
+ }
432
442
  if (m.message)
433
443
  this.raw.set(id, m);
444
+ // History sync keeps earlier reactions on the reacted message itself.
445
+ if (source === 'history')
446
+ for (const r of mapStoredReactions(m))
447
+ this.emitTyped('reaction', r, { source });
434
448
  const mapped = mapWAMessage(m, (raw) => this.download(raw));
435
449
  if (!mapped)
436
450
  return;
@@ -585,7 +599,9 @@ export class BaileysAdapter extends EventEmitter {
585
599
  throw new WaUnavailableError();
586
600
  return this.sock;
587
601
  }
588
- async send(chatJid, content, opts) {
602
+ async send(chatJid, content, opts,
603
+ /** keep the sent message for quotes and media (not for reactions) */
604
+ cache = true) {
589
605
  const sock = this.requireOpen();
590
606
  // The live cache is small and cleared on reconnect; rebuild older quotes from the saved copy.
591
607
  // Either way the quote points at the chat the reply goes to (phone number or WhatsApp ID).
@@ -609,7 +625,8 @@ export class BaileysAdapter extends EventEmitter {
609
625
  const id = res?.key?.id;
610
626
  if (!res || !id)
611
627
  throw new Error('send returned no message id');
612
- this.raw.set(id, res);
628
+ if (cache)
629
+ this.raw.set(id, res);
613
630
  return { id, timestamp: toMs(res.messageTimestamp) };
614
631
  }
615
632
  sendText(chatJid, text, opts) {
@@ -647,6 +664,17 @@ export class BaileysAdapter extends EventEmitter {
647
664
  }
648
665
  return this.send(chatJid, content, opts);
649
666
  }
667
+ sendReaction(chatJid, target, emoji) {
668
+ // The cached key carries the group author exactly as WhatsApp delivered it.
669
+ const participant = this.raw.get(target.id)?.key?.participant ?? target.participant;
670
+ const key = {
671
+ remoteJid: chatJid,
672
+ id: target.id,
673
+ fromMe: target.fromMe,
674
+ ...(participant && chatJid.endsWith('@g.us') ? { participant } : {}),
675
+ };
676
+ return this.send(chatJid, { react: { text: emoji, key } }, undefined, false);
677
+ }
650
678
  async markRead(chatJid, messageIds) {
651
679
  const sock = this.requireOpen();
652
680
  const keys = messageIds.map((id) => {
@@ -1,5 +1,5 @@
1
1
  import { type proto, type WAMessage } from 'baileys';
2
- import type { WaIncomingMessage } from '../types.js';
2
+ import type { WaIncomingMessage, WaReaction } from '../types.js';
3
3
  export type JidKind = 'dm' | 'group' | 'other';
4
4
  export declare function jidType(jid: string): JidKind;
5
5
  type LongLike = {
@@ -10,12 +10,24 @@ type LongLike = {
10
10
  };
11
11
  /** Convert a protobuf timestamp (number | Long | null) in seconds to epoch ms. */
12
12
  export declare function toMs(ts: number | LongLike | null | undefined, fallback?: number): number;
13
+ /**
14
+ * A live or history reaction message (`reactionMessage`): who reacted with which emoji to which
15
+ * message. Empty text = the reaction was removed. Null for anything else, for reactions without a
16
+ * target or reactor, and for status/broadcast/newsletter chats. Encrypted reactions
17
+ * (`encReactionMessage`, community announcements) are not read.
18
+ */
19
+ export declare function mapWAReaction(msg: proto.IWebMessageInfo): WaReaction | null;
20
+ /**
21
+ * Reactions WhatsApp attached to a stored message (history sync keeps them on the message itself,
22
+ * `WebMessageInfo.reactions`, instead of separate reaction messages). Removed ones are skipped.
23
+ */
24
+ export declare function mapStoredReactions(msg: proto.IWebMessageInfo): WaReaction[];
13
25
  /** Optional hook for media downloads that need a live socket (reupload of expired media). */
14
26
  export type MediaDownloader = (msg: WAMessage) => Promise<Buffer>;
15
27
  /**
16
28
  * 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).
29
+ * that should not appear in the inbox (reactions — read by mapWAReaction —, protocol, key
30
+ * distribution, status/newsletter/broadcast, empty).
19
31
  */
20
32
  export declare function mapWAMessage(msg: proto.IWebMessageInfo, download?: MediaDownloader): WaIncomingMessage | null;
21
33
  export {};
@@ -20,11 +20,95 @@ export function toMs(ts, fallback = Date.now()) {
20
20
  return ((ts.high >>> 0) * 2 ** 32 + (ts.low >>> 0)) * 1000;
21
21
  return fallback;
22
22
  }
23
+ /** A protobuf millisecond timestamp (number | Long | null) as epoch ms, or `fallback`. */
24
+ function msToNumber(ts, fallback) {
25
+ if (ts == null)
26
+ return fallback;
27
+ if (typeof ts === 'number')
28
+ return ts;
29
+ if ('toNumber' in ts && typeof ts.toNumber === 'function')
30
+ return ts.toNumber();
31
+ if ('low' in ts)
32
+ return (ts.high >>> 0) * 2 ** 32 + (ts.low >>> 0);
33
+ return fallback;
34
+ }
35
+ /** Who reacted, from the reaction's own message key (group participant, DM partner or us). */
36
+ function reactor(key, chatJid) {
37
+ const fromMe = !!key.fromMe;
38
+ if (fromMe)
39
+ return { senderJid: null, senderJidAlt: null, fromMe };
40
+ if (jidType(chatJid) !== 'group')
41
+ return { senderJid: normalizeContactJid(chatJid) ?? chatJid, senderJidAlt: null, fromMe };
42
+ return {
43
+ senderJid: normalizeContactJid(key.participant),
44
+ senderJidAlt: normalizeContactJid(key.participantAlt),
45
+ fromMe,
46
+ };
47
+ }
48
+ /**
49
+ * A live or history reaction message (`reactionMessage`): who reacted with which emoji to which
50
+ * message. Empty text = the reaction was removed. Null for anything else, for reactions without a
51
+ * target or reactor, and for status/broadcast/newsletter chats. Encrypted reactions
52
+ * (`encReactionMessage`, community announcements) are not read.
53
+ */
54
+ export function mapWAReaction(msg) {
55
+ const key = msg.key;
56
+ const chatJid = key?.remoteJid;
57
+ if (!key || !chatJid || jidType(chatJid) === 'other')
58
+ return null;
59
+ const reaction = normalizeMessageContent(msg.message)?.reactionMessage;
60
+ const targetId = reaction?.key?.id;
61
+ if (!reaction || !targetId)
62
+ return null;
63
+ const who = reactor(key, chatJid);
64
+ if (!who.fromMe && !who.senderJid)
65
+ return null;
66
+ const isGroup = jidType(chatJid) === 'group';
67
+ const alt = isGroup ? null : normalizeContactJid(key.remoteJidAlt);
68
+ return {
69
+ targetId,
70
+ chatJid,
71
+ chatJidAlt: alt && alt.endsWith('@lid') !== chatJid.endsWith('@lid') ? alt : null,
72
+ ...who,
73
+ senderName: who.fromMe ? null : (msg.pushName ?? null),
74
+ emoji: reaction.text ?? '',
75
+ timestamp: msToNumber(reaction.senderTimestampMs, toMs(msg.messageTimestamp)),
76
+ };
77
+ }
78
+ /**
79
+ * Reactions WhatsApp attached to a stored message (history sync keeps them on the message itself,
80
+ * `WebMessageInfo.reactions`, instead of separate reaction messages). Removed ones are skipped.
81
+ */
82
+ export function mapStoredReactions(msg) {
83
+ const chatJid = msg.key?.remoteJid;
84
+ const targetId = msg.key?.id;
85
+ if (!chatJid || !targetId || jidType(chatJid) === 'other' || !msg.reactions?.length)
86
+ return [];
87
+ const fallback = toMs(msg.messageTimestamp);
88
+ return msg.reactions.flatMap((r) => {
89
+ if (!r.key || !r.text)
90
+ return [];
91
+ const who = reactor(r.key, chatJid);
92
+ if (!who.fromMe && !who.senderJid)
93
+ return [];
94
+ return [
95
+ {
96
+ targetId,
97
+ chatJid,
98
+ chatJidAlt: null,
99
+ ...who,
100
+ senderName: null,
101
+ emoji: r.text,
102
+ timestamp: msToNumber(r.senderTimestampMs, fallback),
103
+ },
104
+ ];
105
+ });
106
+ }
23
107
  const defaultDownloader = async (msg) => (await downloadMediaMessage(msg, 'buffer', {}));
24
108
  /**
25
109
  * 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).
110
+ * that should not appear in the inbox (reactions — read by mapWAReaction —, protocol, key
111
+ * distribution, status/newsletter/broadcast, empty).
28
112
  */
29
113
  export function mapWAMessage(msg, download = defaultDownloader) {
30
114
  const key = msg.key;
@@ -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 WaContactAlias, type WaGroupInfo, type WaIncomingMessage, type WaSendFile, type WaSendOptions } from '../types.js';
3
+ import { type SendResult, type WaAdapter, type WaAdapterEvents, type WaContactAlias, type WaGroupInfo, type WaIncomingMessage, type WaReaction, type WaReactionTarget, type WaSendFile, type WaSendOptions } from '../types.js';
4
4
  export interface FakeSentRecord {
5
5
  chatJid: string;
6
6
  text?: string;
@@ -31,6 +31,11 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
31
31
  readonly groupInfoCalls: string[];
32
32
  readonly aboutCalls: string[];
33
33
  readonly sent: FakeSentRecord[];
34
+ readonly reactions: Array<{
35
+ chatJid: string;
36
+ target: WaReactionTarget;
37
+ emoji: string;
38
+ }>;
34
39
  readonly reads: Array<{
35
40
  chatJid: string;
36
41
  messageIds: string[];
@@ -70,6 +75,8 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
70
75
  private afterSend;
71
76
  sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
72
77
  sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
78
+ /** Like WhatsApp, the linked number's own reaction comes back as a `reaction` event first. */
79
+ sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
73
80
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
74
81
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
75
82
  downloadMedia(messageId: string): Promise<Buffer | null>;
@@ -97,6 +104,15 @@ export declare class FakeWaAdapter extends EventEmitter implements WaAdapter {
97
104
  chatJid: string;
98
105
  body: string;
99
106
  }): WaIncomingMessage;
107
+ /**
108
+ * Emit a reaction (live unless `source` says history): `emoji` '' removes the person's reaction.
109
+ * In a DM the reactor defaults to the chat partner; `fromMe` means the linked number reacted.
110
+ */
111
+ simulateReaction(p: Partial<WaReaction> & {
112
+ chatJid: string;
113
+ targetId: string;
114
+ emoji: string;
115
+ }, source?: 'live' | 'history'): WaReaction;
100
116
  simulateStatus(s: Partial<WaStatus>): void;
101
117
  failNextSend(err?: Error): void;
102
118
  setConnected(connected: boolean): void;
@@ -23,6 +23,7 @@ export class FakeWaAdapter extends EventEmitter {
23
23
  groupInfoCalls = [];
24
24
  aboutCalls = [];
25
25
  sent = [];
26
+ reactions = [];
26
27
  reads = [];
27
28
  presences = [];
28
29
  /**
@@ -111,6 +112,15 @@ export class FakeWaAdapter extends EventEmitter {
111
112
  this.media.set(id, file.buffer);
112
113
  return this.afterSend(chatJid, id);
113
114
  }
115
+ /** Like WhatsApp, the linked number's own reaction comes back as a `reaction` event first. */
116
+ async sendReaction(chatJid, target, emoji) {
117
+ this.beforeSend();
118
+ const id = `${this.idPrefix}-REACT-${FAKE_RUN}-${++this.outCounter}`;
119
+ const timestamp = Date.now();
120
+ this.reactions.push({ chatJid, target: { ...target }, emoji });
121
+ this.simulateReaction({ chatJid, targetId: target.id, emoji, fromMe: true, timestamp });
122
+ return { id, timestamp };
123
+ }
114
124
  async markRead(chatJid, messageIds) {
115
125
  this.reads.push({ chatJid, messageIds: [...messageIds] });
116
126
  }
@@ -209,6 +219,33 @@ export class FakeWaAdapter extends EventEmitter {
209
219
  this.emitTyped('message', msg, { source: 'live' });
210
220
  return msg;
211
221
  }
222
+ /**
223
+ * Emit a reaction (live unless `source` says history): `emoji` '' removes the person's reaction.
224
+ * In a DM the reactor defaults to the chat partner; `fromMe` means the linked number reacted.
225
+ */
226
+ simulateReaction(p, source = 'live') {
227
+ const isGroup = p.chatJid.endsWith('@g.us');
228
+ const fromMe = p.fromMe ?? false;
229
+ const reaction = {
230
+ targetId: p.targetId,
231
+ chatJid: p.chatJid,
232
+ chatJidAlt: p.chatJidAlt ?? null,
233
+ senderJid: fromMe
234
+ ? null
235
+ : p.senderJid !== undefined
236
+ ? p.senderJid
237
+ : isGroup
238
+ ? null
239
+ : p.chatJid,
240
+ senderJidAlt: p.senderJidAlt ?? null,
241
+ senderName: p.senderName ?? null,
242
+ fromMe,
243
+ emoji: p.emoji,
244
+ timestamp: p.timestamp ?? Date.now(),
245
+ };
246
+ this.emitTyped('reaction', reaction, { source });
247
+ return reaction;
248
+ }
212
249
  simulateStatus(s) {
213
250
  this.setStatus(s);
214
251
  }
package/dist/types.d.ts CHANGED
@@ -26,6 +26,30 @@ export interface WaIncomingMessage {
26
26
  download: () => Promise<Buffer>;
27
27
  };
28
28
  }
29
+ /**
30
+ * One person's emoji reaction to a message. WhatsApp keeps one reaction per person and message: a
31
+ * new emoji replaces the previous one, an empty `emoji` removes it.
32
+ */
33
+ export interface WaReaction {
34
+ /** WhatsApp id of the message that was reacted to. */
35
+ targetId: string;
36
+ /** The chat of both the reaction and the reacted message (as WhatsApp delivered it). */
37
+ chatJid: string;
38
+ /** DM only: the same person's other address (`key.remoteJidAlt`), as for messages. */
39
+ chatJidAlt?: string | null;
40
+ /** Who reacted: the group participant or the DM partner; null when `fromMe`. */
41
+ senderJid: string | null;
42
+ /** Group reactor's explicitly associated alternate phone/LID address. */
43
+ senderJidAlt?: string | null;
44
+ /** WhatsApp profile name delivered with the reaction (never inferred). */
45
+ senderName: string | null;
46
+ /** Reacted from the linked number (its phone or another linked device). */
47
+ fromMe: boolean;
48
+ /** The emoji; '' = the reaction was removed. */
49
+ emoji: string;
50
+ /** epoch ms */
51
+ timestamp: number;
52
+ }
29
53
  export interface WaChatInfo {
30
54
  jid: string;
31
55
  type: 'dm' | 'group';
@@ -79,6 +103,10 @@ export interface WaAdapterEvents {
79
103
  message: [WaIncomingMessage, {
80
104
  source: 'live' | 'history';
81
105
  }];
106
+ /** Someone reacted to a message, changed their reaction or removed it (live or history). */
107
+ reaction: [WaReaction, {
108
+ source: 'live' | 'history';
109
+ }];
82
110
  messageStatus: [WaMessageStatusUpdate];
83
111
  chats: [WaChatInfo[]];
84
112
  contacts: [WaContactInfo[]];
@@ -107,6 +135,15 @@ export interface WaSendOptions {
107
135
  /** Used when the quoted message is no longer in the adapter's live cache. */
108
136
  quoted?: WaQuotedRef;
109
137
  }
138
+ /** The message a reaction points at: WhatsApp matches it by chat, id, side and group author. */
139
+ export interface WaReactionTarget {
140
+ /** WhatsApp id of the message. */
141
+ id: string;
142
+ /** The message was sent from the linked number. */
143
+ fromMe: boolean;
144
+ /** Group messages from someone else: who wrote it. */
145
+ participant?: string | null;
146
+ }
110
147
  export interface SendResult {
111
148
  id: string;
112
149
  timestamp: number;
@@ -138,6 +175,11 @@ export interface WaAdapter {
138
175
  requestPairingCode(phone: string): Promise<string>;
139
176
  sendText(chatJid: string, text: string, opts?: WaSendOptions): Promise<SendResult>;
140
177
  sendMedia(chatJid: string, file: WaSendFile, opts?: WaSendOptions): Promise<SendResult>;
178
+ /**
179
+ * React to a message as the linked number (`emoji` '' removes the reaction). WhatsApp keeps one
180
+ * reaction per number, so this replaces one made on the phone. Throws WaUnavailableError offline.
181
+ */
182
+ sendReaction(chatJid: string, target: WaReactionTarget, emoji: string): Promise<SendResult>;
141
183
  markRead(chatJid: string, messageIds: string[]): Promise<void>;
142
184
  sendPresence(chatJid: string, presence: 'composing' | 'paused'): Promise<void>;
143
185
  /** re-download by id if still cached */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ezychat/wa",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
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.49",
40
+ "@ezychat/shared": "0.1.51",
41
41
  "baileys": "^7.0.0-rc14",
42
42
  "pino": "^10.4.0",
43
43
  "qrcode": "^1.5.4"