@oxidezap/baileyrs 0.0.18 → 0.0.21

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.
Files changed (84) hide show
  1. package/lib/Bridge/__tests__/adapt.test.js +41 -9
  2. package/lib/Bridge/__tests__/adapt.test.js.map +1 -1
  3. package/lib/Bridge/adapt.d.ts +10 -16
  4. package/lib/Bridge/adapt.d.ts.map +1 -1
  5. package/lib/Bridge/adapt.js +12 -515
  6. package/lib/Bridge/adapt.js.map +1 -1
  7. package/lib/Bridge/schema.d.ts +36 -0
  8. package/lib/Bridge/schema.d.ts.map +1 -0
  9. package/lib/Bridge/schema.js +773 -0
  10. package/lib/Bridge/schema.js.map +1 -0
  11. package/lib/Bridge/types.d.ts +257 -3
  12. package/lib/Bridge/types.d.ts.map +1 -1
  13. package/lib/Socket/events.d.ts +18 -9
  14. package/lib/Socket/events.d.ts.map +1 -1
  15. package/lib/Socket/events.js +520 -204
  16. package/lib/Socket/events.js.map +1 -1
  17. package/lib/Socket/group-notifications.d.ts +6 -0
  18. package/lib/Socket/group-notifications.d.ts.map +1 -1
  19. package/lib/Socket/group-notifications.js +123 -16
  20. package/lib/Socket/group-notifications.js.map +1 -1
  21. package/lib/Socket/groups.d.ts +10 -2
  22. package/lib/Socket/groups.d.ts.map +1 -1
  23. package/lib/Socket/groups.js +24 -30
  24. package/lib/Socket/groups.js.map +1 -1
  25. package/lib/Socket/index.d.ts +31 -8
  26. package/lib/Socket/index.d.ts.map +1 -1
  27. package/lib/Socket/index.js +178 -13
  28. package/lib/Socket/index.js.map +1 -1
  29. package/lib/Socket/messages.d.ts +13 -2
  30. package/lib/Socket/messages.d.ts.map +1 -1
  31. package/lib/Socket/messages.js +27 -6
  32. package/lib/Socket/messages.js.map +1 -1
  33. package/lib/Types/Call.d.ts +20 -0
  34. package/lib/Types/Call.d.ts.map +1 -1
  35. package/lib/Types/Contact.d.ts +3 -1
  36. package/lib/Types/Contact.d.ts.map +1 -1
  37. package/lib/Types/Events.d.ts +2 -0
  38. package/lib/Types/Events.d.ts.map +1 -1
  39. package/lib/Utils/generics.d.ts +17 -0
  40. package/lib/Utils/generics.d.ts.map +1 -1
  41. package/lib/Utils/generics.js +31 -0
  42. package/lib/Utils/generics.js.map +1 -1
  43. package/lib/Utils/group-stub-params.d.ts +29 -0
  44. package/lib/Utils/group-stub-params.d.ts.map +1 -0
  45. package/lib/Utils/group-stub-params.js +45 -0
  46. package/lib/Utils/group-stub-params.js.map +1 -0
  47. package/lib/Utils/messages-media.d.ts +1 -1
  48. package/lib/Utils/messages-media.d.ts.map +1 -1
  49. package/lib/Utils/messages-media.js +1 -1
  50. package/lib/Utils/messages-media.js.map +1 -1
  51. package/lib/Utils/messages.d.ts.map +1 -1
  52. package/lib/Utils/messages.js +4 -6
  53. package/lib/Utils/messages.js.map +1 -1
  54. package/lib/Utils/process-history-message.d.ts +30 -0
  55. package/lib/Utils/process-history-message.d.ts.map +1 -0
  56. package/lib/Utils/process-history-message.js +148 -0
  57. package/lib/Utils/process-history-message.js.map +1 -0
  58. package/lib/Utils/use-bridge-store.d.ts.map +1 -1
  59. package/lib/Utils/use-bridge-store.js +50 -6
  60. package/lib/Utils/use-bridge-store.js.map +1 -1
  61. package/lib/Utils/use-multi-file-auth-state.d.ts +7 -0
  62. package/lib/Utils/use-multi-file-auth-state.d.ts.map +1 -1
  63. package/lib/Utils/use-multi-file-auth-state.js +11 -1
  64. package/lib/Utils/use-multi-file-auth-state.js.map +1 -1
  65. package/lib/Utils/wrap-legacy-store.d.ts.map +1 -1
  66. package/lib/Utils/wrap-legacy-store.js +110 -90
  67. package/lib/Utils/wrap-legacy-store.js.map +1 -1
  68. package/lib/WABinary/generic-utils.d.ts +17 -0
  69. package/lib/WABinary/generic-utils.d.ts.map +1 -0
  70. package/lib/WABinary/generic-utils.js +44 -0
  71. package/lib/WABinary/generic-utils.js.map +1 -0
  72. package/lib/WABinary/index.d.ts +1 -0
  73. package/lib/WABinary/index.d.ts.map +1 -1
  74. package/lib/WABinary/index.js +1 -0
  75. package/lib/WABinary/index.js.map +1 -1
  76. package/lib/WABinary/jid-utils.d.ts +4 -0
  77. package/lib/WABinary/jid-utils.d.ts.map +1 -1
  78. package/lib/WABinary/jid-utils.js +4 -0
  79. package/lib/WABinary/jid-utils.js.map +1 -1
  80. package/lib/__tests__/expect.d.ts +14 -0
  81. package/lib/__tests__/expect.d.ts.map +1 -1
  82. package/lib/__tests__/expect.js +42 -2
  83. package/lib/__tests__/expect.js.map +1 -1
  84. package/package.json +2 -2
@@ -1,8 +1,16 @@
1
+ /**
2
+ * Canonical → Baileys event dispatcher.
3
+ *
4
+ * `adaptBridgeEvent` (Bridge/schema.ts) yields a `CanonicalEvent`
5
+ * discriminated union; `DISPATCHERS` is a `{ [K in CanonicalEvent['type']]: … }`
6
+ * mapped type so TS forces a handler per variant — a missing handler
7
+ * is a compile error.
8
+ */
1
9
  import { adaptBridgeEvent } from "../Bridge/index.js";
2
10
  import { DisconnectReason, WAProto } from "../Types/index.js";
3
11
  import { Boom } from "../Utils/boom.js";
4
12
  import { isJidGroup } from "../WABinary/jid-utils.js";
5
- import { buildGroupNotificationDomainEvent, buildGroupNotificationStubMessages } from "./group-notifications.js";
13
+ import { buildGroupJoinRequestEvents, buildGroupNotificationDomainEvent, buildGroupNotificationStubMessages } from "./group-notifications.js";
6
14
  import { mapReachoutTimelock } from "./reachout.js";
7
15
  const DEF_CALLBACK_PREFIX = 'CB:';
8
16
  const DEF_TAG_PREFIX = 'TAG:';
@@ -19,6 +27,11 @@ const emitCBEvents = (ctx, node) => {
19
27
  if (l2)
20
28
  ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${val},${l2}`, node);
21
29
  ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}:${val}`, node);
30
+ // Bare-key pattern (no value): upstream `socket.ts:610` emits this
31
+ // so consumers wiring `ws.on('CB:iq,id', …)` (any id) catch every
32
+ // stanza carrying that attr. Missing here meant id-pattern matchers
33
+ // silently never fired.
34
+ ws.emit(`${DEF_CALLBACK_PREFIX}${l0},${key}`, node);
22
35
  }
23
36
  if (l2)
24
37
  ws.emit(`${DEF_CALLBACK_PREFIX}${l0},,${l2}`, node);
@@ -50,232 +63,535 @@ const canonicalMessageToWAMessage = (m) => {
50
63
  wm.key.remoteJidAlt = m.remoteJidAlt;
51
64
  if (m.isViewOnce)
52
65
  wm.key.isViewOnce = true;
66
+ // Surface the raw EditAttribute so consumers that dedupe by `key.id`
67
+ // can branch on edits ("1") vs originals (""). Mirrors upstream's
68
+ // `decodeMessageNode` which sets `key.editAttribute` from the same
69
+ // stanza attribute.
70
+ if (m.editAttribute)
71
+ wm.key.editAttribute = m.editAttribute;
53
72
  return wm;
54
73
  };
74
+ const emitClose = (ctx, reason, statusCode) => ctx.ev.emit('connection.update', {
75
+ connection: 'close',
76
+ lastDisconnect: { error: new Boom(reason, { statusCode }), date: new Date() }
77
+ });
55
78
  /**
56
- * Create the event handler that translates canonical events into Baileys
57
- * events on `ctx.ev`. The handler does NO bridge-shape inspection — that's
58
- * the adapter's job. Any field-access here references the canonical types
59
- * exclusively, which means a `tsc` failure if a downstream rename ever
60
- * leaks into the canonical contract.
79
+ * Map bridge `ConnectFailureReason` wire codes (per the bridge's
80
+ * `.d.ts` annotation) onto upstream Baileys' `DisconnectReason`.
81
+ * Unknown codes fall through to `connectionClosed` so existing
82
+ * reconnect heuristics keep working.
61
83
  */
62
- export const makeEventHandler = (ctx, callbacks) => {
63
- const { ev } = ctx;
64
- const emitClose = (reason, statusCode) => ev.emit('connection.update', {
84
+ const mapConnectFailureToDisconnect = (reason) => {
85
+ switch (reason) {
86
+ case 401: // LoggedOut
87
+ case 403: // MainDeviceGone
88
+ case 406: // UnknownLogout
89
+ return DisconnectReason.loggedOut;
90
+ case 402: // TempBanned
91
+ return DisconnectReason.forbidden;
92
+ case 405: // ClientOutdated
93
+ return DisconnectReason.badSession;
94
+ case 411: // MultideviceMismatch (legacy alias)
95
+ return DisconnectReason.multideviceMismatch;
96
+ case 503: // ServiceUnavailable
97
+ case 501: // Experimental
98
+ return DisconnectReason.unavailableService;
99
+ case 408: // Timed out
100
+ return DisconnectReason.timedOut;
101
+ case 515: // RestartRequired
102
+ return DisconnectReason.restartRequired;
103
+ // 400, 409, 413, 414, 415, 418, 500, undefined → generic close
104
+ default:
105
+ return DisconnectReason.connectionClosed;
106
+ }
107
+ };
108
+ const describeTempBan = (code) => {
109
+ switch (code) {
110
+ case 101:
111
+ return 'sent_to_too_many_people';
112
+ case 102:
113
+ return 'blocked_by_users';
114
+ case 103:
115
+ return 'created_too_many_groups';
116
+ case 104:
117
+ return 'sent_too_many_same_message';
118
+ case 106:
119
+ return 'broadcast_list';
120
+ default:
121
+ return code != null ? `code_${code}` : 'unknown';
122
+ }
123
+ };
124
+ /**
125
+ * Sole `as` cast in the dispatch path. The bridge runtime's
126
+ * `connection.update` slot accepts the full ConnectionState union, but we
127
+ * only ever emit partials shaped as `{ connection, … }`. The Baileys event
128
+ * map types `connection.update` as the full state which is unsafe to
129
+ * widen; centralizing the cast here keeps every dispatcher clean.
130
+ */
131
+ const emitConnectionUpdate = (ctx, update) => ctx.ev.emit('connection.update', update);
132
+ const DISPATCHERS = {
133
+ // ── Connection lifecycle ──
134
+ connected: (_, { ctx }) => emitConnectionUpdate(ctx, { connection: 'open' }),
135
+ disconnected: (_, { ctx }) => emitClose(ctx, 'Connection closed', DisconnectReason.connectionClosed),
136
+ qr: (evt, { ctx }) => emitConnectionUpdate(ctx, { qr: evt.code }),
137
+ pairSuccess: (evt, { ctx, callbacks }) => {
138
+ const { id, lid, businessName, platform } = evt;
139
+ ctx.setUser({ id, lid });
140
+ callbacks?.onPairSuccess?.({ platform, businessName });
141
+ // Synthetic `creds.update` so upstream-style auth code
142
+ // (`ev.on('creds.update', saveCreds)`) gets a single tick to persist
143
+ // post-pair state. The bridge owns the real creds; this emission is
144
+ // a compat hook for upstream's lifecycle.
145
+ ctx.ev.emit('creds.update', { registered: true, me: { id, lid, name: businessName }, platform });
146
+ },
147
+ pairError: (evt, { ctx }) => emitClose(ctx, 'Pairing failed: ' + evt.error, DisconnectReason.connectionClosed),
148
+ loggedOut: (evt, { ctx }) => emitClose(ctx, evt.reason ? `Logged out: ${evt.reason}` : 'Logged out', DisconnectReason.loggedOut),
149
+ connectFailure: (evt, { ctx }) => {
150
+ // Map bridge `ConnectFailureReason` wire codes onto Baileys'
151
+ // DisconnectReason. Defaults to connectionClosed for unknown codes.
152
+ // LoggedOut paths (401/403/406) drive bots' "should I re-pair?"
153
+ // branch — folding them into connectionClosed kept that broken.
154
+ const status = mapConnectFailureToDisconnect(evt.reason);
155
+ emitClose(ctx, evt.message ?? 'Connection failure', status);
156
+ },
157
+ streamError: (evt, { ctx }) => emitClose(ctx, 'Stream error: ' + evt.code, DisconnectReason.badSession),
158
+ streamReplaced: (_, { ctx }) => emitConnectionUpdate(ctx, {
65
159
  connection: 'close',
66
- lastDisconnect: { error: new Boom(reason, { statusCode }), date: new Date() }
67
- });
68
- const dispatch = (evt) => {
69
- switch (evt.type) {
70
- // ── Connection lifecycle ──
71
- case 'connected':
72
- ev.emit('connection.update', { connection: 'open' });
73
- return;
74
- case 'disconnected':
75
- emitClose('Connection closed', DisconnectReason.connectionClosed);
76
- return;
77
- case 'qr':
78
- ev.emit('connection.update', { qr: evt.code });
79
- return;
80
- case 'pairSuccess': {
81
- const { id, lid, businessName, platform } = evt;
82
- ctx.setUser({ id, lid });
83
- callbacks?.onPairSuccess?.({ platform, businessName });
84
- // Synthetic `creds.update` so upstream-style auth code
85
- // (`ev.on('creds.update', saveCreds)` /
86
- // `eventManager.register(...)`) gets a single tick to persist
87
- // its post-pair state. The bridge owns the real creds; this
88
- // emission is a compat hook for upstream Baileys' lifecycle.
89
- ev.emit('creds.update', {
90
- registered: true,
91
- me: { id, lid, name: businessName },
92
- platform
93
- });
94
- return;
160
+ lastDisconnect: {
161
+ error: new Boom('Connection replaced', { statusCode: DisconnectReason.connectionReplaced }),
162
+ date: new Date()
163
+ }
164
+ }),
165
+ clientOutdated: (_, { ctx }) => emitClose(ctx, 'Client outdated', DisconnectReason.badSession),
166
+ temporaryBan: (evt, { ctx }) => {
167
+ // Surface the wire code + expire on the Boom so consumers reading
168
+ // `lastDisconnect.error.data` can act on the specific reason.
169
+ const reason = describeTempBan(evt.code);
170
+ const message = evt.expire
171
+ ? `Temporary ban (${reason}); expires at ${new Date(evt.expire * 1000).toISOString()}`
172
+ : `Temporary ban (${reason})`;
173
+ ctx.ev.emit('connection.update', {
174
+ connection: 'close',
175
+ lastDisconnect: {
176
+ error: new Boom(message, {
177
+ statusCode: DisconnectReason.forbidden,
178
+ data: { code: evt.code, expire: evt.expire }
179
+ }),
180
+ date: new Date()
95
181
  }
96
- case 'pairError':
97
- emitClose('Pairing failed: ' + evt.error, DisconnectReason.connectionClosed);
98
- return;
99
- case 'loggedOut':
100
- emitClose('Logged out', DisconnectReason.loggedOut);
101
- return;
102
- case 'connectFailure':
103
- emitClose(evt.message ?? 'Connection failure', DisconnectReason.connectionClosed);
104
- return;
105
- case 'streamError':
106
- emitClose('Stream error: ' + evt.code, DisconnectReason.badSession);
107
- return;
108
- case 'streamReplaced':
109
- ev.emit('connection.update', {
110
- connection: 'close',
111
- lastDisconnect: {
112
- error: new Boom('Connection replaced', { statusCode: DisconnectReason.connectionReplaced }),
113
- date: new Date()
114
- }
182
+ });
183
+ },
184
+ qrScannedWithoutMultidevice: (_, { ctx }) => ctx.logger.warn('QR scanned but multi-device not enabled on phone'),
185
+ // ── Messages ──
186
+ message: (evt, { ctx }) => {
187
+ if (ctx.fullConfig.shouldIgnoreJid?.(evt.chatJid))
188
+ return;
189
+ // Note: `emitOwnEvents=false` is NOT applied here. Upstream Baileys
190
+ // uses that flag to suppress the local echo when `sendMessage()`
191
+ // succeeds, not to drop inbound `fromMe` messages from other linked
192
+ // devices.
193
+ const waMsg = canonicalMessageToWAMessage(evt);
194
+ const upsertPayload = {
195
+ messages: [waMsg],
196
+ type: evt.isOffline ? 'append' : 'notify'
197
+ };
198
+ if (evt.unavailableRequestId)
199
+ upsertPayload.requestId = evt.unavailableRequestId;
200
+ ctx.ev.emit('messages.upsert', upsertPayload);
201
+ // Mirror upstream `process-message.ts:523-533`: when the inbound
202
+ // proto carries a `reactionMessage`, surface it on the dedicated
203
+ // `messages.reaction` channel as well. The `key` in the event
204
+ // payload is the TARGET (the message being reacted to), and
205
+ // `reaction.key` is the reaction's own envelope key — that's the
206
+ // exact shape upstream consumers index by.
207
+ const reactionMessage = evt.messageProto.reactionMessage;
208
+ if (reactionMessage?.key) {
209
+ ctx.ev.emit('messages.reaction', [
210
+ {
211
+ key: reactionMessage.key,
212
+ reaction: { ...reactionMessage, key: waMsg.key }
213
+ }
214
+ ]);
215
+ }
216
+ // Mirror upstream `process-message.ts:404-414` (REVOKE) and
217
+ // `:474-490` (MESSAGE_EDIT). Both surface as `messages.update` with
218
+ // the TARGET key (id taken from protocolMessage.key).
219
+ const protocolMsg = evt.messageProto.protocolMessage;
220
+ // `group.member-tag.update` doesn't reuse `protocolMsg.key.id` — the
221
+ // label change applies to the participant directly, identified by
222
+ // the envelope key. Mirror upstream `process-message.ts:492-503`.
223
+ if (protocolMsg?.type === WAProto.Message.ProtocolMessage.Type.GROUP_MEMBER_LABEL_CHANGE &&
224
+ protocolMsg.memberLabel?.label) {
225
+ // `senderJid` is required for this event — it identifies the
226
+ // participant whose label changed. If the bridge ever surfaced a
227
+ // label-change without one (server bug / non-group context), an
228
+ // empty-string fallback would corrupt downstream consumer state.
229
+ // Skip with a warn instead.
230
+ if (!evt.senderJid) {
231
+ ctx.logger.warn({ chatJid: evt.chatJid, label: protocolMsg.memberLabel.label }, 'GROUP_MEMBER_LABEL_CHANGE without senderJid — dropping (no participant to attribute)');
232
+ }
233
+ else {
234
+ ctx.ev.emit('group.member-tag.update', {
235
+ groupId: evt.chatJid,
236
+ label: protocolMsg.memberLabel.label,
237
+ participant: evt.senderJid,
238
+ participantAlt: evt.participantAlt,
239
+ messageTimestamp: evt.timestamp
115
240
  });
116
- return;
117
- case 'clientOutdated':
118
- emitClose('Client outdated', DisconnectReason.badSession);
119
- return;
120
- case 'temporaryBan':
121
- emitClose('Temporary ban', DisconnectReason.forbidden);
122
- return;
123
- case 'qrScannedWithoutMultidevice':
124
- ctx.logger.warn('QR scanned but multi-device not enabled on phone');
125
- return;
126
- // ── Messages ──
127
- case 'message': {
128
- if (ctx.fullConfig.shouldIgnoreJid?.(evt.chatJid))
129
- return;
130
- // Note: `emitOwnEvents=false` is NOT applied here. Upstream
131
- // Baileys uses that flag to suppress the local echo when
132
- // `sendMessage()` succeeds, not to drop inbound `fromMe`
133
- // messages from other linked devices.
134
- const waMsg = canonicalMessageToWAMessage(evt);
135
- ev.emit('messages.upsert', { messages: [waMsg], type: 'notify' });
136
- return;
137
241
  }
138
- case 'receipt': {
139
- ev.emit('message-receipt.update', [
242
+ }
243
+ const protocolKeyId = protocolMsg?.key?.id;
244
+ if (protocolMsg && protocolKeyId) {
245
+ if (protocolMsg.type === WAProto.Message.ProtocolMessage.Type.REVOKE) {
246
+ ctx.ev.emit('messages.update', [
140
247
  {
141
- key: {
142
- remoteJid: evt.chatJid,
143
- id: evt.messageIds[0],
144
- fromMe: evt.isFromMe,
145
- participant: evt.isGroup ? evt.senderJid : undefined
146
- },
147
- receipt: { receiptTimestamp: evt.timestamp }
148
- }
149
- ]);
150
- return;
151
- }
152
- case 'undecryptableMessage':
153
- ctx.logger.warn({ event: evt.raw }, 'undecryptable message received');
154
- return;
155
- // ── Contacts ──
156
- case 'pushNameUpdate':
157
- ev.emit('contacts.update', [{ id: evt.jid, notify: evt.newPushName }]);
158
- return;
159
- case 'contactUpdate':
160
- ev.emit('contacts.update', [{ id: evt.jid }]);
161
- return;
162
- case 'pictureUpdate':
163
- ev.emit('contacts.update', [{ id: evt.jid, imgUrl: 'changed' }]);
164
- return;
165
- // ── Presence ──
166
- case 'presence':
167
- ev.emit('presence.update', {
168
- id: evt.from,
169
- presences: {
170
- [evt.from]: {
171
- lastKnownPresence: (evt.unavailable ? 'unavailable' : 'available'),
172
- lastSeen: evt.lastSeen
248
+ key: { ...waMsg.key, id: protocolKeyId },
249
+ update: {
250
+ message: null,
251
+ messageStubType: WAProto.WebMessageInfo.StubType.REVOKE,
252
+ key: waMsg.key
173
253
  }
174
254
  }
175
- });
176
- return;
177
- case 'chatPresence':
178
- ev.emit('presence.update', {
179
- id: evt.chatJid,
180
- presences: {
181
- [evt.senderJid]: { lastKnownPresence: evt.state }
182
- }
183
- });
184
- return;
185
- // ── Groups ──
186
- case 'groupUpdate': {
187
- const user = ctx.getUser();
188
- const fromMe = !!(evt.author && (evt.author === user?.id || evt.author === user?.lid));
189
- const domainEvent = buildGroupNotificationDomainEvent(evt);
190
- if (domainEvent)
191
- ev.emit(domainEvent.name, domainEvent.payload);
192
- const stubMessages = buildGroupNotificationStubMessages(evt, fromMe);
193
- if (stubMessages.length > 0) {
194
- ev.emit('messages.upsert', { messages: stubMessages, type: 'notify' });
195
- }
196
- return;
255
+ ]);
197
256
  }
198
- // ── Chat state ──
199
- case 'archiveUpdate':
200
- ev.emit('chats.update', [{ id: evt.jid, archived: true }]);
201
- return;
202
- case 'pinUpdate':
203
- ev.emit('chats.update', [{ id: evt.jid, pinned: evt.timestamp }]);
204
- return;
205
- case 'muteUpdate':
206
- ev.emit('chats.update', [{ id: evt.jid, muteEndTime: evt.timestamp }]);
207
- return;
208
- case 'starUpdate':
209
- ev.emit('messages.update', [
257
+ else if (protocolMsg.type === WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT && protocolMsg.editedMessage) {
258
+ const tsMs = protocolMsg.timestampMs;
259
+ const editedTs = tsMs != null ? Math.floor(Number(tsMs) / 1000) : (evt.timestamp ?? waMsg.messageTimestamp);
260
+ ctx.ev.emit('messages.update', [
210
261
  {
211
- key: {
212
- remoteJid: evt.chatJid,
213
- id: evt.messageId,
214
- fromMe: evt.fromMe,
215
- participant: evt.participantJid
216
- },
217
- update: { starred: evt.starred }
262
+ key: { ...waMsg.key, id: protocolKeyId },
263
+ update: {
264
+ message: { editedMessage: { message: protocolMsg.editedMessage } },
265
+ messageTimestamp: editedTs
266
+ }
218
267
  }
219
268
  ]);
220
- return;
221
- case 'markChatAsReadUpdate':
222
- ev.emit('chats.update', [{ id: evt.jid, unreadCount: 0 }]);
223
- return;
224
- // ── Calls ──
225
- case 'incomingCall': {
226
- const isGroup = isJidGroup(evt.from);
227
- // Map the canonical action onto upstream Baileys' `WACallUpdateType`.
228
- // `preAccept` is upstream's `'ringing'`; `'timeout'` has no core
229
- // equivalent (upstream synthesizes it from a separate signal).
230
- const status = evt.action.type === 'preAccept' ? 'ringing' : evt.action.type;
231
- const callEvt = {
232
- chatId: evt.from,
233
- from: evt.from,
234
- id: evt.action.callId,
235
- date: new Date(evt.timestamp * 1000),
236
- status,
237
- offline: evt.offline,
238
- isGroup,
239
- ...(isGroup ? { groupJid: evt.from } : {}),
240
- ...(evt.action.type === 'offer' && evt.action.callerPn ? { callerPn: evt.action.callerPn } : {}),
241
- ...(evt.action.type === 'offer' ? { isVideo: !!evt.action.isVideo } : {})
242
- };
243
- ev.emit('call', [callEvt]);
244
- return;
245
269
  }
246
- // ── Raw passthrough ──
247
- case 'rawNode':
248
- emitCBEvents(ctx, evt.node);
249
- return;
250
- case 'notification':
251
- ctx.logger.trace({ tag: evt.tag, attrs: evt.attrs }, 'bridge generic notification (no Baileys mapping)');
252
- return;
253
- case 'mexNotification': {
254
- // Route by op_name. Adding a new MEX-driven event means a new
255
- // case here no per-op_name plumbing in the Bridge layer.
256
- if (evt.opName === 'NotificationUserReachoutTimelockUpdate') {
257
- const state = mapReachoutTimelock(evt.payload);
258
- if (state) {
259
- ev.emit('connection.update', { reachoutTimeLock: state });
260
- }
261
- else {
262
- ctx.logger.warn({ payload: evt.payload }, 'reachout-timelock push: payload missing expected fields');
270
+ }
271
+ },
272
+ receipt: (evt, { ctx }) => {
273
+ // Fan out one MessageUserReceiptUpdate per id (upstream emits
274
+ // per-id) and pick the timestamp slot from the receipt type so
275
+ // consumers branching on `receipt.readTimestamp` /
276
+ // `receipt.playedTimestamp` see the right field populated.
277
+ const participant = evt.isGroup ? evt.senderJid : undefined;
278
+ const receipt = {};
279
+ if (evt.receiptType === 'read' || evt.receiptType === 'read-self') {
280
+ receipt.readTimestamp = evt.timestamp;
281
+ }
282
+ else if (evt.receiptType === 'played' || evt.receiptType === 'played-self') {
283
+ receipt.playedTimestamp = evt.timestamp;
284
+ }
285
+ else {
286
+ receipt.receiptTimestamp = evt.timestamp;
287
+ }
288
+ ctx.ev.emit('message-receipt.update', evt.messageIds.map(id => ({
289
+ key: { remoteJid: evt.chatJid, id, fromMe: evt.isFromMe, participant },
290
+ receipt
291
+ })));
292
+ },
293
+ undecryptableMessage: (evt, { ctx }) => {
294
+ // Mirror upstream `messages-recv.ts:1352-…`: surface a CIPHERTEXT
295
+ // stub on `messages.upsert` so consumers see the placeholder and
296
+ // can request a resend (PLACEHOLDER_MESSAGE_RESEND PDO). Logging
297
+ // happens at debug — upstream considers this routine.
298
+ ctx.logger.debug({ id: evt.id, chat: evt.chatJid, isUnavailable: evt.isUnavailable, fail: evt.decryptFailMode }, 'undecryptable message received');
299
+ // `decrypt_fail_mode === 'hide'` means the server told us to
300
+ // silently drop — match that by NOT emitting an upsert.
301
+ if (evt.decryptFailMode === 'hide')
302
+ return;
303
+ const stubMsg = WAProto.WebMessageInfo.fromObject({
304
+ key: {
305
+ remoteJid: evt.chatJid,
306
+ fromMe: evt.isFromMe,
307
+ id: evt.id,
308
+ participant: evt.senderJid
309
+ },
310
+ messageTimestamp: evt.timestamp,
311
+ pushName: evt.pushName,
312
+ messageStubType: WAProto.WebMessageInfo.StubType.CIPHERTEXT,
313
+ messageStubParameters: evt.unavailableType ? [evt.unavailableType] : []
314
+ });
315
+ if (evt.participantAlt)
316
+ stubMsg.key.participantAlt = evt.participantAlt;
317
+ if (evt.remoteJidAlt)
318
+ stubMsg.key.remoteJidAlt = evt.remoteJidAlt;
319
+ ctx.ev.emit('messages.upsert', { messages: [stubMsg], type: 'notify' });
320
+ },
321
+ // ── Contacts ──
322
+ pushNameUpdate: (evt, { ctx }) => ctx.ev.emit('contacts.update', [{ id: evt.jid, notify: evt.newPushName }]),
323
+ contactUpdate: (evt, { ctx }) => {
324
+ // Promote ContactAction fields into upstream's `Partial<Contact>`
325
+ // shape so consumers (sidebar UIs, contact pickers) see real names
326
+ // instead of bare ids. `verifiedName` is intentionally not set —
327
+ // that comes from a different notification path (business-level
328
+ // verification).
329
+ const update = { id: evt.jid };
330
+ const name = evt.fullName ?? evt.firstName;
331
+ if (name)
332
+ update.name = name;
333
+ if (evt.lidJid)
334
+ update.lid = evt.lidJid;
335
+ if (evt.pnJid)
336
+ update.phoneNumber = evt.pnJid;
337
+ ctx.ev.emit('contacts.update', [update]);
338
+ },
339
+ pictureUpdate: (evt, { ctx }) =>
340
+ // Upstream emits `imgUrl: null` on removal so consumers cache the
341
+ // absence instead of refetching forever. `'changed'` is the
342
+ // sentinel for "invalidate cache, refetch on demand".
343
+ ctx.ev.emit('contacts.update', [{ id: evt.jid, imgUrl: evt.removed ? null : 'changed' }]),
344
+ // ── Presence ──
345
+ presence: (evt, { ctx }) => ctx.ev.emit('presence.update', {
346
+ id: evt.from,
347
+ presences: {
348
+ [evt.from]: {
349
+ lastKnownPresence: (evt.unavailable ? 'unavailable' : 'available'),
350
+ lastSeen: evt.lastSeen
351
+ }
352
+ }
353
+ }),
354
+ chatPresence: (evt, { ctx }) => {
355
+ // Bridge `ChatPresenceUpdate.state` is `'composing' | 'paused'`.
356
+ // `recording` is signaled via `media === 'audio'` riding on top of
357
+ // `composing` — upstream Baileys' `WAPresence` collapses the two
358
+ // signals into 'composing' / 'recording' / 'paused'. Anything
359
+ // outside this set falls back to `'paused'` (the safe sentinel —
360
+ // "stopped doing whatever they were doing") rather than passing
361
+ // through unvalidated, which previously could leak unknown wire
362
+ // values onto consumer-facing `WAPresence`.
363
+ let mapped;
364
+ if (evt.state === 'composing')
365
+ mapped = evt.media === 'audio' ? 'recording' : 'composing';
366
+ else if (evt.state === 'paused')
367
+ mapped = 'paused';
368
+ else {
369
+ ctx.logger.debug({ state: evt.state, media: evt.media }, 'chat_presence: unknown state — falling back to paused');
370
+ mapped = 'paused';
371
+ }
372
+ ctx.ev.emit('presence.update', {
373
+ id: evt.chatJid,
374
+ presences: { [evt.senderJid]: { lastKnownPresence: mapped } }
375
+ });
376
+ },
377
+ // ── Groups ──
378
+ groupUpdate: (evt, { ctx }) => {
379
+ const user = ctx.getUser();
380
+ const fromMe = !!(evt.author && (evt.author === user?.id || evt.author === user?.lid));
381
+ // Dispatch via discriminator instead of `as never` so TS validates
382
+ // payload ↔ event-name pairing.
383
+ const domainEvent = buildGroupNotificationDomainEvent(evt);
384
+ if (domainEvent) {
385
+ if (domainEvent.name === 'groups.update')
386
+ ctx.ev.emit('groups.update', domainEvent.payload);
387
+ else
388
+ ctx.ev.emit('group-participants.update', domainEvent.payload);
389
+ }
390
+ // `groups.upsert` is intentionally NOT emitted from `<create>`. The
391
+ // bridge gives us only the creation marker — `participants` /
392
+ // `subject` / `creation` are unknown without an extra IQ. Upstream
393
+ // emits this only AFTER `groupMetadata` resolves (`messages-recv.ts:661`),
394
+ // so emitting `[{ id }]` here would feed consumers a broken
395
+ // `GroupMetadata` (participants undefined → `metadata.participants.map`
396
+ // throws). Bots that want the metadata should call
397
+ // `sock.groupMetadata(evt.groupJid)` from a `groups.update` listener
398
+ // or after the matching `messages.upsert` stub.
399
+ // Fan out `group.join-request` for membership_approval_request /
400
+ // created_membership_requests / revoked_membership_requests.
401
+ // Upstream emits one event per affected participant.
402
+ for (const joinReq of buildGroupJoinRequestEvents(evt)) {
403
+ ctx.ev.emit('group.join-request', joinReq);
404
+ }
405
+ const stubMessages = buildGroupNotificationStubMessages(evt, fromMe);
406
+ if (stubMessages.length > 0) {
407
+ ctx.ev.emit('messages.upsert', { messages: stubMessages, type: 'notify' });
408
+ }
409
+ },
410
+ // ── Chat state ──
411
+ archiveUpdate: (evt, { ctx }) => ctx.ev.emit('chats.update', [{ id: evt.jid, archived: evt.archived }]),
412
+ pinUpdate: (evt, { ctx }) =>
413
+ // Upstream Baileys' `chat-utils.ts:878` emits `null` (not
414
+ // `undefined`) on unpin so consumers using `'pinned' in update`
415
+ // can distinguish "not touched" from "explicitly unpinned" —
416
+ // matters for ChatUpdate-shaped diffs where the property's
417
+ // presence is the signal.
418
+ ctx.ev.emit('chats.update', [{ id: evt.jid, pinned: evt.pinned ? evt.timestamp : null }]),
419
+ muteUpdate: (evt, { ctx }) =>
420
+ // Mirrors upstream `chat-utils.ts:809`: when muted, surface
421
+ // `muteEndTimestamp` (0 = forever); on unmute, surface `null`.
422
+ ctx.ev.emit('chats.update', [{ id: evt.jid, muteEndTime: evt.muted ? (evt.muteEndTimestamp ?? 0) : null }]),
423
+ starUpdate: (evt, { ctx }) => ctx.ev.emit('messages.update', [
424
+ {
425
+ key: { remoteJid: evt.chatJid, id: evt.messageId, fromMe: evt.fromMe, participant: evt.participantJid },
426
+ update: { starred: evt.starred }
427
+ }
428
+ ]),
429
+ markChatAsReadUpdate: (evt, { ctx }) =>
430
+ // Mirrors upstream `chat-utils.ts:852`: read=true → unreadCount=0,
431
+ // read=false (mark as unread) → -1 sentinel.
432
+ ctx.ev.emit('chats.update', [{ id: evt.jid, unreadCount: evt.read ? 0 : -1 }]),
433
+ // ── Calls ──
434
+ incomingCall: (evt, { ctx }) => {
435
+ const isGroup = isJidGroup(evt.from);
436
+ // Map the canonical action onto upstream Baileys' WACallUpdateType.
437
+ // `preAccept` is upstream's `ringing`. `timeout` has no bridge
438
+ // counterpart yet (upstream synthesizes it from a separate signal).
439
+ const status = evt.action.type === 'preAccept' ? 'ringing' : evt.action.type;
440
+ const callEvt = {
441
+ chatId: evt.from,
442
+ from: evt.from,
443
+ id: evt.action.callId,
444
+ date: new Date(evt.timestamp * 1000),
445
+ status,
446
+ offline: evt.offline,
447
+ isGroup,
448
+ // Group JID = `evt.from` only when the stanza came from a group
449
+ // JID itself. The bridge offer payload doesn't carry a separate
450
+ // `group_jid` field today, so the group's JID IS the `from` for
451
+ // `<call>` stanzas in groups.
452
+ ...(isGroup ? { groupJid: evt.from } : {}),
453
+ ...(evt.action.callerPn ? { callerPn: evt.action.callerPn } : {}),
454
+ ...(evt.action.type === 'offer' ? { isVideo: !!evt.action.isVideo } : {}),
455
+ // Bridge auxiliary fields (offer-only / terminate-only / always)
456
+ ...(evt.action.callerCountryCode ? { callerCountryCode: evt.action.callerCountryCode } : {}),
457
+ ...(evt.action.deviceClass ? { deviceClass: evt.action.deviceClass } : {}),
458
+ ...(evt.action.joinable !== undefined ? { joinable: evt.action.joinable } : {}),
459
+ ...(evt.action.audio ? { audio: evt.action.audio } : {}),
460
+ ...(evt.action.duration !== undefined ? { duration: evt.action.duration } : {}),
461
+ ...(evt.action.audioDuration !== undefined ? { audioDuration: evt.action.audioDuration } : {}),
462
+ ...(evt.stanzaId ? { stanzaId: evt.stanzaId } : {}),
463
+ ...(evt.notify ? { notify: evt.notify } : {}),
464
+ ...(evt.platform ? { platform: evt.platform } : {}),
465
+ ...(evt.version ? { version: evt.version } : {})
466
+ };
467
+ ctx.ev.emit('call', [callEvt]);
468
+ },
469
+ // ── Raw passthrough ──
470
+ rawNode: (evt, { ctx }) => emitCBEvents(ctx, evt.node),
471
+ notification: (evt, { ctx }) => ctx.logger.trace({ tag: evt.tag, attrs: evt.attrs }, 'bridge generic notification (no Baileys mapping)'),
472
+ mexNotification: (evt, { ctx }) => {
473
+ // Route by op_name. Adding a new MEX-driven event means a new entry
474
+ // here — no per-op_name plumbing in the Bridge layer.
475
+ if (evt.opName === 'NotificationUserReachoutTimelockUpdate') {
476
+ const state = mapReachoutTimelock(evt.payload);
477
+ if (state)
478
+ emitConnectionUpdate(ctx, { reachoutTimeLock: state });
479
+ else
480
+ ctx.logger.warn({ payload: evt.payload }, 'reachout-timelock push: payload missing expected fields');
481
+ return;
482
+ }
483
+ ctx.logger.trace({ opName: evt.opName, offline: evt.offline }, 'bridge mex notification with no Baileys mapping (drop)');
484
+ },
485
+ noop: (evt, { ctx }) => ctx.logger.trace({ bridgeType: evt.bridgeType, detail: evt.detail }, 'bridge event acknowledged (no Baileys equivalent)'),
486
+ lidMappingUpdate: (evt, { ctx }) => {
487
+ // Mirror upstream `messages-recv.ts:287`: one `lid-mapping.update`
488
+ // event per pair, payload is the bare `{ lid, pn }` shape.
489
+ for (const mapping of evt.mappings) {
490
+ ctx.ev.emit('lid-mapping.update', mapping);
491
+ }
492
+ },
493
+ chatDelete: (evt, { ctx }) => ctx.ev.emit('chats.delete', [evt.jid]),
494
+ messageDelete: (evt, { ctx }) =>
495
+ // Upstream `chat-utils.ts:857` shape: `{ keys: WAMessageKey[] }` for
496
+ // per-message delete (not the `{ jid, all: true }` chat-clear case
497
+ // — that's a different sync action surface).
498
+ ctx.ev.emit('messages.delete', {
499
+ keys: [
500
+ {
501
+ remoteJid: evt.chatJid,
502
+ id: evt.messageId,
503
+ fromMe: evt.fromMe,
504
+ ...(evt.participantJid ? { participant: evt.participantJid } : {})
505
+ }
506
+ ]
507
+ }),
508
+ newsletterLiveUpdate: (evt, { ctx }) => {
509
+ // Fan out one `newsletter.reaction` per (message, reaction) pair —
510
+ // matches upstream `messages-recv.ts:320` semantics. Upstream sets
511
+ // `removed: true` when count is 0 (user removed their reaction);
512
+ // we mirror that.
513
+ for (const msg of evt.messages) {
514
+ for (const reaction of msg.reactions) {
515
+ ctx.ev.emit('newsletter.reaction', {
516
+ id: evt.newsletterJid,
517
+ server_id: msg.serverId,
518
+ reaction: {
519
+ code: reaction.code,
520
+ count: reaction.count,
521
+ removed: reaction.count === 0
263
522
  }
264
- return;
265
- }
266
- ctx.logger.trace({ opName: evt.opName, offline: evt.offline }, 'bridge mex notification with no Baileys mapping (drop)');
267
- return;
523
+ });
268
524
  }
269
- case 'noop':
270
- ctx.logger.trace({ bridgeType: evt.bridgeType, detail: evt.detail }, 'bridge event acknowledged (no Baileys equivalent)');
271
- return;
272
525
  }
273
- };
526
+ },
527
+ disappearingModeChanged: (evt, { ctx }) =>
528
+ // Upstream surfaces this through `chats.update.ephemeralExpiration`
529
+ // (set in `process-message.ts:417` for the protocolMessage path).
530
+ // Bridge gives us the same data via a dedicated event — emit on
531
+ // the same channel for parity.
532
+ ctx.ev.emit('chats.update', [
533
+ {
534
+ id: evt.jid,
535
+ ephemeralExpiration: evt.duration > 0 ? evt.duration : null,
536
+ ephemeralSettingTimestamp: evt.settingTimestamp
537
+ }
538
+ ]),
539
+ historySync: (evt, { ctx }) => {
540
+ // 1:1 with upstream `process-message.ts:371-376`. `isLatest` is true
541
+ // when this is the first history sync the bot has seen since
542
+ // pairing — upstream tracks it in `creds.processedHistoryMessages`,
543
+ // but in baileyrs the bridge owns creds, so we approximate with the
544
+ // `INITIAL_BOOTSTRAP` syncType which only fires once per pair. Bots
545
+ // gating "first hydrate" logic on `isLatest` see the same boolean.
546
+ const HSType = WAProto.HistorySync.HistorySyncType;
547
+ const isInitial = evt.syncType === HSType.INITIAL_BOOTSTRAP;
548
+ const payload = {
549
+ chats: evt.chats,
550
+ contacts: evt.contacts,
551
+ messages: evt.messages,
552
+ isLatest: evt.syncType === HSType.ON_DEMAND ? undefined : isInitial,
553
+ progress: evt.progress,
554
+ syncType: evt.syncType,
555
+ chunkOrder: evt.chunkOrder,
556
+ peerDataRequestSessionId: evt.peerDataRequestSessionId,
557
+ lidPnMappings: evt.lidPnMappings.length > 0 ? evt.lidPnMappings : undefined
558
+ };
559
+ ctx.ev.emit('messaging-history.set', payload);
560
+ // Also fan out to `chats.upsert` / `contacts.upsert` so bots that
561
+ // wire those (instead of, or in addition to, messaging-history.set)
562
+ // get hydrated. Upstream emits both channels from different code
563
+ // paths; we collapse them here so consumers picking either pattern
564
+ // see the same data on the first pair.
565
+ if (evt.chats.length > 0)
566
+ ctx.ev.emit('chats.upsert', evt.chats);
567
+ if (evt.contacts.length > 0)
568
+ ctx.ev.emit('contacts.upsert', evt.contacts);
569
+ }
570
+ };
571
+ /**
572
+ * Create the event handler that translates canonical events into Baileys
573
+ * events on `ctx.ev`. The handler does NO bridge-shape inspection — that's
574
+ * the schema's job. Field-access here references canonical types
575
+ * exclusively, so a downstream rename surfaces as a `tsc` failure.
576
+ */
577
+ export const makeEventHandler = (ctx, callbacks) => {
578
+ const dispatchCtx = { ctx, callbacks };
274
579
  return (event) => {
275
580
  const canonical = adaptBridgeEvent(event, ctx.logger);
276
581
  if (!canonical)
277
582
  return;
278
- dispatch(canonical);
583
+ // Type-safe table lookup. The runtime cast through `DispatcherFn`
584
+ // is a no-op TS-wise (the table is exhaustively typed) — needed
585
+ // only because TS can't follow the discriminator across the
586
+ // indexed-access into the mapped type.
587
+ const dispatcher = DISPATCHERS[canonical.type];
588
+ try {
589
+ dispatcher(canonical, dispatchCtx);
590
+ }
591
+ catch (err) {
592
+ // One bad event must not poison the rest of the pipeline.
593
+ ctx.logger.error({ err, type: canonical.type }, 'dispatcher threw — dropping event');
594
+ }
279
595
  };
280
596
  };
281
597
  //# sourceMappingURL=events.js.map