@oxidezap/baileyrs 0.2.11 → 0.2.13

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.
@@ -8,5 +8,5 @@
8
8
  */
9
9
  export { adaptBridgeEvent, adaptBridgeMessageWire, KNOWN_BRIDGE_EVENT_TYPES } from './adapt.js';
10
10
  export * from './types.js';
11
- export { asBool, asBoolOr, asJidString, asNumber, asString, bridgeJidToString, isBridgeJid, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
11
+ export { asBool, asBoolOr, asJidString, asNumber, asString, asUnixSeconds, bridgeJidToString, isBridgeJid, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
12
12
  //# sourceMappingURL=index.d.ts.map
@@ -8,5 +8,5 @@
8
8
  */
9
9
  export { adaptBridgeEvent, adaptBridgeMessageWire, KNOWN_BRIDGE_EVENT_TYPES } from './adapt.js';
10
10
  export * from './types.js';
11
- export { asBool, asBoolOr, asJidString, asNumber, asString, bridgeJidToString, isBridgeJid, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
11
+ export { asBool, asBoolOr, asJidString, asNumber, asString, asUnixSeconds, bridgeJidToString, isBridgeJid, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
12
12
  //# sourceMappingURL=index.js.map
@@ -50,16 +50,28 @@ export declare const asJidAddressString: (x: unknown) => string | undefined;
50
50
  * strings — the bridge serializes `DateTime<Utc>` as a string unless the field
51
51
  * names one of chrono's `ts_*` modules, so being lenient about which of the two
52
52
  * arrives insulates us from drift.
53
+ *
54
+ * Absence and unparseable input stay absent (`undefined`): a missing,
55
+ * malformed, or non-finite timestamp must never become the epoch, which
56
+ * would place the event at 1970 and corrupt timeline ordering. Callers
57
+ * apply the per-field policy explicitly — required timestamps (message,
58
+ * receipt, call, group action) drop the event, optional ones (presence
59
+ * `last_seen`, server-ack time, pin/mute time) omit the field.
53
60
  */
54
- export declare const toUnixSeconds: (raw: unknown) => number;
61
+ export declare const asUnixSeconds: (raw: unknown) => number | undefined;
55
62
  /**
56
- * Same as `toUnixSeconds`, but absence stays absent.
63
+ * Validated coercion to unix seconds for external consumers that need a
64
+ * plain number (no `undefined` branch).
57
65
  *
58
- * The optional timestamps `last_seen`, a server ack's `t`, an app-state
59
- * mutation's own time mean "the server did not say" when missing, which is
60
- * not the same claim as the epoch.
66
+ * Accepts the same inputs as `asUnixSeconds` a finite number or an RFC
67
+ * 3339 stringand rejects everything else by throwing `RangeError`
68
+ * instead of fabricating the epoch: a missing, malformed, or non-finite
69
+ * timestamp must never become 1970-01-01 and corrupt timeline ordering.
70
+ * Internal adapters do not use this; they take `asUnixSeconds` with an
71
+ * explicit per-field policy (required timestamps drop the event, optional
72
+ * ones omit the field).
61
73
  */
62
- export declare const asUnixSeconds: (raw: unknown) => number | undefined;
74
+ export declare const toUnixSeconds: (raw: unknown) => number;
63
75
  /**
64
76
  * A 64-bit proto field, however the bridge chose to carry it.
65
77
  *
@@ -82,20 +82,13 @@ const parseRfc3339Seconds = (raw) => {
82
82
  * strings — the bridge serializes `DateTime<Utc>` as a string unless the field
83
83
  * names one of chrono's `ts_*` modules, so being lenient about which of the two
84
84
  * arrives insulates us from drift.
85
- */
86
- export const toUnixSeconds = (raw) => {
87
- if (typeof raw === 'number' && Number.isFinite(raw))
88
- return raw;
89
- if (typeof raw === 'string')
90
- return parseRfc3339Seconds(raw) ?? 0;
91
- return 0;
92
- };
93
- /**
94
- * Same as `toUnixSeconds`, but absence stays absent.
95
85
  *
96
- * The optional timestamps `last_seen`, a server ack's `t`, an app-state
97
- * mutation's own time mean "the server did not say" when missing, which is
98
- * not the same claim as the epoch.
86
+ * Absence and unparseable input stay absent (`undefined`): a missing,
87
+ * malformed, or non-finite timestamp must never become the epoch, which
88
+ * would place the event at 1970 and corrupt timeline ordering. Callers
89
+ * apply the per-field policy explicitly — required timestamps (message,
90
+ * receipt, call, group action) drop the event, optional ones (presence
91
+ * `last_seen`, server-ack time, pin/mute time) omit the field.
99
92
  */
100
93
  export const asUnixSeconds = (raw) => {
101
94
  if (typeof raw === 'number' && Number.isFinite(raw))
@@ -104,6 +97,26 @@ export const asUnixSeconds = (raw) => {
104
97
  return parseRfc3339Seconds(raw);
105
98
  return undefined;
106
99
  };
100
+ /**
101
+ * Validated coercion to unix seconds for external consumers that need a
102
+ * plain number (no `undefined` branch).
103
+ *
104
+ * Accepts the same inputs as `asUnixSeconds` — a finite number or an RFC
105
+ * 3339 string — and rejects everything else by throwing `RangeError`
106
+ * instead of fabricating the epoch: a missing, malformed, or non-finite
107
+ * timestamp must never become 1970-01-01 and corrupt timeline ordering.
108
+ * Internal adapters do not use this; they take `asUnixSeconds` with an
109
+ * explicit per-field policy (required timestamps drop the event, optional
110
+ * ones omit the field).
111
+ */
112
+ export const toUnixSeconds = (raw) => {
113
+ const parsed = asUnixSeconds(raw);
114
+ if (parsed === undefined) {
115
+ const preview = typeof raw === 'string' ? `: ${raw.slice(0, 80)}` : '';
116
+ throw new RangeError(`toUnixSeconds: cannot coerce ${typeof raw}${preview} to unix seconds`);
117
+ }
118
+ return parsed;
119
+ };
107
120
  /**
108
121
  * A 64-bit proto field, however the bridge chose to carry it.
109
122
  *
@@ -24,7 +24,7 @@
24
24
  */
25
25
  import { processHistoryMessage } from '../Utils/process-history-message.js';
26
26
  import { isJidGroup } from '../WABinary/jid-utils.js';
27
- import { absoluteFromDuration, asBool, asBoolOr, asStringArray, asDurationSeconds, asInt64, asJidAddressString, asJidString, asNumber, asString, asUnixSeconds, isObject, normalizeDiscriminator, toUnixSeconds } from './primitives.js';
27
+ import { absoluteFromDuration, asBool, asBoolOr, asStringArray, asDurationSeconds, asInt64, asJidAddressString, asJidString, asNumber, asString, asUnixSeconds, isObject, normalizeDiscriminator } from './primitives.js';
28
28
  /**
29
29
  * Bridge sync-action events (`pin_update`, `mute_update`, etc.) carry the
30
30
  * proto action under `data.action`, but the bridge `.d.ts` types it as
@@ -39,6 +39,17 @@ const extractAction = (data) => isObject(data?.action) ? data.action : undefined
39
39
  const resolveIsGroup = (wireValue, chatJid) => asBoolOr(wireValue, false) || isJidGroup(chatJid) === true;
40
40
  const resolveParticipantAlt = (senderAlt, isGroup) => isGroup ? senderAlt : undefined;
41
41
  const resolveRemoteJidAlt = (senderAlt, recipientAlt, isGroup, isFromMe) => (isGroup ? undefined : isFromMe ? recipientAlt : senderAlt);
42
+ /**
43
+ * Rejection diagnostic for a required timestamp that is missing or
44
+ * unparseable. Metadata only — never the payload: these objects can carry
45
+ * message bodies and JIDs.
46
+ */
47
+ const invalidTimestampDetail = (event, raw) => ({
48
+ event,
49
+ field: 'timestamp',
50
+ reason: raw === undefined || raw === null ? 'missing' : 'invalid',
51
+ receivedType: typeof raw
52
+ });
42
53
  const parseEditAttribute = (value) => {
43
54
  switch (value) {
44
55
  case '1':
@@ -141,7 +152,7 @@ const ADAPTERS = {
141
152
  error: asString(data?.error)
142
153
  };
143
154
  },
144
- undecryptable_message: data => {
155
+ undecryptable_message: (data, logger) => {
145
156
  // Bridge ships `{ info: MessageInfo, is_unavailable, unavailable_type,
146
157
  // decrypt_fail_mode }` — same MessageInfo shape as the regular
147
158
  // `message` event. Extract the chat / sender / id so the dispatcher
@@ -161,12 +172,17 @@ const ADAPTERS = {
161
172
  const isFromMe = asBoolOr(src.is_from_me, false);
162
173
  const senderAlt = asJidString(src.sender_alt);
163
174
  const recipientAlt = asJidString(src.recipient_alt);
175
+ const timestamp = asUnixSeconds(info.timestamp);
176
+ if (timestamp === undefined) {
177
+ logger?.debug(invalidTimestampDetail('undecryptable_message', info.timestamp), 'undecryptable_message adapter: missing or invalid timestamp');
178
+ return null;
179
+ }
164
180
  return {
165
181
  type: 'undecryptableMessage',
166
182
  chatJid: chat,
167
183
  senderJid: isGroup ? asJidString(src.sender) : undefined,
168
184
  id,
169
- timestamp: toUnixSeconds(info.timestamp),
185
+ timestamp,
170
186
  isFromMe,
171
187
  isGroup,
172
188
  pushName: asString(info.push_name),
@@ -386,28 +402,38 @@ const ADAPTERS = {
386
402
  call_log_sync: () => ({ type: 'noop', bridgeType: 'call_log_sync' }),
387
403
  // ── Calls ──
388
404
  incoming_call: (data, logger) => adaptIncomingCall(data, logger),
389
- missed_call: data => {
405
+ missed_call: (data, logger) => {
390
406
  const from = asJidString(data?.from);
391
407
  const callId = asString(data?.call_id);
392
408
  if (!from || !callId)
393
409
  return null;
410
+ const timestamp = asUnixSeconds(data?.timestamp);
411
+ if (timestamp === undefined) {
412
+ logger?.debug(invalidTimestampDetail('missed_call', data?.timestamp), 'missed_call adapter: missing or invalid timestamp');
413
+ return null;
414
+ }
394
415
  return {
395
416
  type: 'incomingCall',
396
417
  from,
397
- timestamp: toUnixSeconds(data?.timestamp),
418
+ timestamp,
398
419
  offline: data?.reason === 'offline',
399
420
  action: { type: 'timeout', callId }
400
421
  };
401
422
  },
402
- call_ended_elsewhere: data => {
423
+ call_ended_elsewhere: (data, logger) => {
403
424
  const from = asJidString(data?.from);
404
425
  const callId = asString(data?.call_id);
405
426
  if (!from || !callId)
406
427
  return null;
428
+ const timestamp = asUnixSeconds(data?.timestamp);
429
+ if (timestamp === undefined) {
430
+ logger?.debug(invalidTimestampDetail('call_ended_elsewhere', data?.timestamp), 'call_ended_elsewhere adapter: missing or invalid timestamp');
431
+ return null;
432
+ }
407
433
  return {
408
434
  type: 'incomingCall',
409
435
  from,
410
- timestamp: toUnixSeconds(data?.timestamp),
436
+ timestamp,
411
437
  offline: false,
412
438
  action: { type: data?.outcome === 'accepted' ? 'accept' : 'reject', callId }
413
439
  };
@@ -680,6 +706,15 @@ export const adaptBridgeMessageWire = (messageProto, info, logger) => {
680
706
  const isFromMe = asBoolOr(info.isFromMe, false);
681
707
  const senderAlt = asString(info.senderAlt);
682
708
  const recipientAlt = asString(info.recipientAlt);
709
+ // The packed envelope carries the timestamp as a numeric record, so the
710
+ // only invalid shapes here are a missing or non-finite value. Like the
711
+ // object route below, those drop the message rather than dating it at
712
+ // the epoch.
713
+ const timestamp = asNumber(info.timestamp);
714
+ if (timestamp === undefined) {
715
+ logger?.debug(invalidTimestampDetail('message_wire', info.timestamp), 'message wire adapter: missing or invalid timestamp');
716
+ return null;
717
+ }
683
718
  return {
684
719
  type: 'message',
685
720
  chatJid: chat,
@@ -687,7 +722,7 @@ export const adaptBridgeMessageWire = (messageProto, info, logger) => {
687
722
  isGroup,
688
723
  isFromMe,
689
724
  id,
690
- timestamp: asNumber(info.timestamp) ?? 0,
725
+ timestamp,
691
726
  pushName: asString(info.pushName),
692
727
  participantAlt: resolveParticipantAlt(senderAlt, isGroup),
693
728
  remoteJidAlt: resolveRemoteJidAlt(senderAlt, recipientAlt, isGroup, isFromMe),
@@ -712,6 +747,11 @@ const adaptMessageParts = (info, messageProto, logger) => {
712
747
  const senderJid = isGroup ? asJidString(senderRaw) : undefined;
713
748
  const senderAlt = asJidString(src.sender_alt);
714
749
  const recipientAlt = asJidString(src.recipient_alt);
750
+ const timestamp = asUnixSeconds(info.timestamp);
751
+ if (timestamp === undefined) {
752
+ logger?.debug(invalidTimestampDetail('message', info.timestamp), 'message adapter: missing or invalid timestamp');
753
+ return null;
754
+ }
715
755
  return {
716
756
  type: 'message',
717
757
  chatJid: chat,
@@ -719,7 +759,7 @@ const adaptMessageParts = (info, messageProto, logger) => {
719
759
  isGroup,
720
760
  isFromMe,
721
761
  id,
722
- timestamp: toUnixSeconds(info.timestamp),
762
+ timestamp,
723
763
  pushName: asString(info.push_name),
724
764
  participantAlt: resolveParticipantAlt(senderAlt, isGroup),
725
765
  remoteJidAlt: resolveRemoteJidAlt(senderAlt, recipientAlt, isGroup, isFromMe),
@@ -743,6 +783,16 @@ const adaptReceipt = (data, logger) => {
743
783
  return null;
744
784
  }
745
785
  const isGroup = resolveIsGroup(src.is_group, chat);
786
+ // A receipt without a trustworthy timestamp cannot be placed on any
787
+ // timeline slot (`readTimestamp` / `playedTimestamp` /
788
+ // `receiptTimestamp`), so the event is dropped rather than dated at the
789
+ // epoch. Optional timestamps elsewhere stay absent via `asUnixSeconds`.
790
+ const timestamp = asUnixSeconds(data.timestamp);
791
+ if (timestamp === undefined) {
792
+ logger?.debug(invalidTimestampDetail('receipt', data.timestamp), 'receipt adapter: missing or invalid timestamp');
793
+ return null;
794
+ }
795
+ const { receiptType, raw: receiptTypeRaw } = parseReceiptType(data.type);
746
796
  return {
747
797
  type: 'receipt',
748
798
  chatJid: chat,
@@ -750,8 +800,9 @@ const adaptReceipt = (data, logger) => {
750
800
  isGroup,
751
801
  isFromMe: asBoolOr(src.is_from_me, false),
752
802
  messageIds: ids,
753
- timestamp: toUnixSeconds(data.timestamp),
754
- receiptType: parseReceiptType(data.type)
803
+ timestamp,
804
+ receiptType,
805
+ ...(receiptTypeRaw !== undefined ? { receiptTypeRaw } : {})
755
806
  };
756
807
  };
757
808
  const adaptContactUpdate = (data) => {
@@ -772,7 +823,8 @@ const adaptContactUpdate = (data) => {
772
823
  };
773
824
  };
774
825
  /**
775
- * Bridge wire `ReceiptType` → canonical kebab-cased variant.
826
+ * Bridge wire `ReceiptType` → canonical kebab-cased variant, preserving the
827
+ * original value when it matches nothing known.
776
828
  *
777
829
  * The bridge's generated `.d.ts` advertises `ReceiptType` as
778
830
  * `{type: "delivered"} | …`, but `#[serde(from = "String")]` on the rust
@@ -780,6 +832,12 @@ const adaptContactUpdate = (data) => {
780
832
  * bare PascalCase variant name (`"Delivered"`, `"Read"`, `"PeerMsg"`).
781
833
  * Keep both spellings here so a future bridge bump that re-introduces
782
834
  * the snake_case wire form keeps working.
835
+ *
836
+ * Unrecognized wire values arrive wrapped as `{ Other: value }` (the packed
837
+ * `ReceiptWireData.type` contract) or, defensively, as a future bare
838
+ * string. Those map to `'other'` with the original value in `raw`. The
839
+ * wrapper is trusted over spelling: `{ Other: 'Read' }` is `other`, not
840
+ * `read` (see `parseReceiptType`).
783
841
  */
784
842
  const RECEIPT_TYPE_MAP = {
785
843
  Delivered: 'delivered',
@@ -810,10 +868,22 @@ const RECEIPT_TYPE_MAP = {
810
868
  server_error: 'server-error'
811
869
  };
812
870
  const parseReceiptType = (raw) => {
871
+ // The `Other` wrapper is authoritative: the packed codec round-trips
872
+ // `{ Other: value }` verbatim and never synthesizes it for a known
873
+ // variant, so even a payload whose spelling collides with a known
874
+ // variant name (e.g. `{ Other: 'Read' }`) stays category `other` with
875
+ // its exact payload preserved. Only bare strings and `{ type }` go
876
+ // through the known-variant lookup.
877
+ if (isObject(raw) && typeof raw.Other === 'string')
878
+ return { receiptType: 'other', raw: raw.Other };
813
879
  const norm = typeof raw === 'string' ? raw : isObject(raw) && typeof raw.type === 'string' ? raw.type : undefined;
814
880
  if (norm == null)
815
- return undefined;
816
- return RECEIPT_TYPE_MAP[norm] ?? 'other';
881
+ return { receiptType: undefined };
882
+ // Own-property lookup: the table is an ordinary object, so indexing an
883
+ // unknown bare name like 'constructor' or '__proto__' would otherwise
884
+ // return an inherited value instead of falling through to 'other'.
885
+ const mapped = Object.hasOwn(RECEIPT_TYPE_MAP, norm) ? RECEIPT_TYPE_MAP[norm] : undefined;
886
+ return mapped ? { receiptType: mapped } : { receiptType: 'other', raw: norm };
817
887
  };
818
888
  const adaptStarUpdate = (data) => {
819
889
  if (!isObject(data))
@@ -854,6 +924,13 @@ const adaptIncomingCall = (data, logger) => {
854
924
  logger?.debug({ data }, 'incoming_call adapter: missing action.type/call_id');
855
925
  return null;
856
926
  }
927
+ // The call offer is timeline-ordered by its timestamp downstream; an
928
+ // unparseable one drops the event rather than dating it at the epoch.
929
+ const timestamp = asUnixSeconds(data.timestamp);
930
+ if (timestamp === undefined) {
931
+ logger?.debug(invalidTimestampDetail('incoming_call', data.timestamp), 'incoming_call adapter: missing or invalid timestamp');
932
+ return null;
933
+ }
857
934
  const canonicalAction = {
858
935
  type: actionType,
859
936
  callId,
@@ -876,7 +953,7 @@ const adaptIncomingCall = (data, logger) => {
876
953
  return {
877
954
  type: 'incomingCall',
878
955
  from: asJidAddressString(data.from) ?? from,
879
- timestamp: toUnixSeconds(data.timestamp),
956
+ timestamp,
880
957
  offline: asBoolOr(data.offline, false),
881
958
  stanzaId: asString(data.stanza_id),
882
959
  notify: asString(data.notify),
@@ -1108,6 +1185,11 @@ const adaptGroupUpdate = (data, logger) => {
1108
1185
  logger?.warn({ data }, 'group_update adapter: action shape rejected');
1109
1186
  return null;
1110
1187
  }
1188
+ const timestamp = asUnixSeconds(data.timestamp);
1189
+ if (timestamp === undefined) {
1190
+ logger?.debug(invalidTimestampDetail('group_update', data.timestamp), 'group_update adapter: missing or invalid timestamp');
1191
+ return null;
1192
+ }
1111
1193
  return {
1112
1194
  type: 'groupUpdate',
1113
1195
  groupJid,
@@ -1117,7 +1199,7 @@ const adaptGroupUpdate = (data, logger) => {
1117
1199
  authorPn: asJidString(data.participant_pn),
1118
1200
  authorUsername: asString(data.participant_username),
1119
1201
  authorCountryCode: asString(data.participant_country_code),
1120
- timestamp: toUnixSeconds(data.timestamp),
1202
+ timestamp,
1121
1203
  isLidAddressingMode: asBoolOr(data.is_lid_addressing_mode, false),
1122
1204
  action
1123
1205
  };
@@ -172,6 +172,17 @@ export interface CanonicalReceipt {
172
172
  * `readTimestamp` / `playedTimestamp` slot in the emitted update.
173
173
  */
174
174
  receiptType?: 'delivered' | 'sent' | 'sender' | 'retry' | 'enc-rekey-retry' | 'read' | 'read-self' | 'played' | 'played-self' | 'inactive' | 'peer-msg' | 'history-sync' | 'server-error' | 'other';
175
+ /**
176
+ * Original wire value when `receiptType` is `'other'` — the bridge sends
177
+ * unrecognized variants as `{ Other: value }` (or a future bare string),
178
+ * and the category alone would lose which one it was. Always set for an
179
+ * `Other`-wrapped payload, even when its spelling collides with a known
180
+ * variant name: the wrapper means the producer did not recognize it.
181
+ * Absent whenever the value matched a known variant through a bare
182
+ * string or `{ type }` shape, and whenever `receiptType` itself is
183
+ * absent.
184
+ */
185
+ receiptTypeRaw?: string;
175
186
  }
176
187
  export interface CanonicalContactUpdate {
177
188
  type: 'contactUpdate';
@@ -1,12 +1,16 @@
1
1
  /**
2
- * `encodeProto`, with the two inputs the bridge codec stopped accepting put back.
2
+ * `encodeProto`, with the three inputs the bridge codec refuses put back.
3
3
  *
4
4
  * From 0.8.0 the codec refuses an empty string where the schema declares a
5
5
  * 64-bit integer, and an unpaired surrogate in a text field. Both were written
6
6
  * before — as `0` and as U+FFFD — and upstream Baileys still encodes both, so a
7
7
  * message that used to reach the server would now throw in the caller's face.
8
- * This is where that is absorbed, so the strict contract stays true of the
9
- * bridge and the tolerant one stays true of this library.
8
+ * The codec also refuses enum names where the schema declares an enum
9
+ * (issue #109: `"NONE"` where an int32 goes on the wire), which upstream's
10
+ * `fromObject` resolves and its direct `encode` coerces — that repair lives in
11
+ * `repairProtoMessage`, next to the other two. This is where all three are
12
+ * absorbed, so the strict contract stays true of the bridge and the tolerant
13
+ * one stays true of this library.
10
14
  *
11
15
  * Repair on failure rather than check on write: the ordinary encode is exactly
12
16
  * the call it was before, with no scan of any field, and the repair runs only
@@ -1,14 +1,18 @@
1
1
  import { encodeProto } from '@oxidezap/whatsapp-rust-bridge';
2
2
  import { repairProtoMessage } from './proto-runtime.js';
3
3
  /**
4
- * `encodeProto`, with the two inputs the bridge codec stopped accepting put back.
4
+ * `encodeProto`, with the three inputs the bridge codec refuses put back.
5
5
  *
6
6
  * From 0.8.0 the codec refuses an empty string where the schema declares a
7
7
  * 64-bit integer, and an unpaired surrogate in a text field. Both were written
8
8
  * before — as `0` and as U+FFFD — and upstream Baileys still encodes both, so a
9
9
  * message that used to reach the server would now throw in the caller's face.
10
- * This is where that is absorbed, so the strict contract stays true of the
11
- * bridge and the tolerant one stays true of this library.
10
+ * The codec also refuses enum names where the schema declares an enum
11
+ * (issue #109: `"NONE"` where an int32 goes on the wire), which upstream's
12
+ * `fromObject` resolves and its direct `encode` coerces — that repair lives in
13
+ * `repairProtoMessage`, next to the other two. This is where all three are
14
+ * absorbed, so the strict contract stays true of the bridge and the tolerant
15
+ * one stays true of this library.
12
16
  *
13
17
  * Repair on failure rather than check on write: the ordinary encode is exactly
14
18
  * the call it was before, with no scan of any field, and the repair runs only
@@ -22,8 +26,9 @@ export const encodeProtoCompat = (path, message) => {
22
26
  catch (error) {
23
27
  const repaired = repairProtoMessage(path, message);
24
28
  // Reference equality: nothing was coerced, so the failure is something this
25
- // does not explain — an unmodelled type, a number no int64 can hold — and
26
- // it has to keep propagating rather than be retried into a second throw.
29
+ // does not explain — an unmodelled type, a number no int64 can hold, an
30
+ // unknown enum name — and it has to keep propagating rather than be
31
+ // retried into a second throw.
27
32
  if (repaired === message)
28
33
  throw error;
29
34
  return encodeProto(path, repaired);
@@ -53,7 +53,7 @@ const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBF
53
53
  /**
54
54
  * Puts back what the codec used to do with an input it now refuses.
55
55
  *
56
- * Two cases, both measured against upstream Baileys, which still encodes both:
56
+ * Three cases, all measured against upstream Baileys, which still encodes them:
57
57
  *
58
58
  * - An empty string where the schema declares a 64-bit integer. Every one of the
59
59
  * 134 send-path failures this addresses carried exactly `''`; a string that is
@@ -62,6 +62,13 @@ const UNPAIRED_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBF
62
62
  * - An unpaired surrogate in a text field, replaced with U+FFFD. That is the
63
63
  * substitution `TextEncoder` used to make, so the bytes are unchanged from what
64
64
  * this library sent before.
65
+ * - An enum name where the schema declares an enum (issue #109: `"NONE"` where
66
+ * an int32 goes on the wire). Upstream's `fromObject` resolves names to numbers
67
+ * and its direct `encode` coerces any string with `| 0`, so a caller passing a
68
+ * name never sees a throw. The bridge codec accepts only numbers (numeric
69
+ * strings aside) and throws `invalid int32: "NONE"`. An unknown name is left to
70
+ * throw rather than silenced to `0`: upstream's direct encode would write `0`
71
+ * for it, but that puts a value on the wire nobody sent.
65
72
  *
66
73
  * Returns `item` itself when it has nothing to do, so the caller can tell a
67
74
  * repair from a failure it does not understand.
@@ -77,6 +84,36 @@ const repairScalar = (kind, item) => {
77
84
  const replaced = item.replace(UNPAIRED_SURROGATE, '\uFFFD');
78
85
  return replaced === item ? item : replaced;
79
86
  };
87
+ /**
88
+ * Enum name to wire number, built per enum on the first repair that needs it.
89
+ *
90
+ * Only the repair path reads this, and only for a field that actually holds a
91
+ * string — a message the codec accepts never reaches here, and a numeric enum
92
+ * never touches a map. Each table is built once from the generated entries and
93
+ * then reused; an importer that never sends a refused value allocates nothing.
94
+ * A `Map` (not a plain object) so names like `__proto__` are keys, not hazards.
95
+ */
96
+ let enumTablesById;
97
+ const enumValueFor = (enumId, name) => {
98
+ if (enumId < 0 || enumId >= PROTO_ENUM_SCHEMAS.length)
99
+ return undefined;
100
+ enumTablesById ?? (enumTablesById = []);
101
+ let byName = enumTablesById[enumId];
102
+ if (byName === undefined) {
103
+ const entries = PROTO_ENUM_SCHEMAS[enumId]?.[1];
104
+ if (!entries)
105
+ return undefined;
106
+ byName = new Map();
107
+ for (let index = 0; index < entries.length; index += 2) {
108
+ const entryName = entries[index];
109
+ const entryValue = entries[index + 1];
110
+ if (typeof entryName === 'string' && typeof entryValue === 'number')
111
+ byName.set(entryName, entryValue);
112
+ }
113
+ enumTablesById[enumId] = byName;
114
+ }
115
+ return byName.get(name);
116
+ };
80
117
  const longFromWords = (low, high, unsigned) => LongRuntime.fromBits(low, high, unsigned);
81
118
  /**
82
119
  * Split a decoded 64-bit value into the low/high words `longFromWords` takes.
@@ -254,12 +291,13 @@ const schemaIdFor = (path) => {
254
291
  return schemaIdsByPath.get(path);
255
292
  };
256
293
  /**
257
- * Coerces the two inputs the bridge codec stopped accepting back to what it
258
- * used to write, and returns `value` itself when there was nothing to coerce.
294
+ * Coerces the three inputs the bridge codec refuses back to what upstream
295
+ * Baileys writes, and returns `value` itself when there was nothing to coerce.
259
296
  *
260
297
  * Reference equality is the signal: the caller only reaches here after an
261
298
  * encode threw, and an unchanged result means the failure was something else
262
- * — a genuinely invalid number, a missing codec — which must keep propagating.
299
+ * — a genuinely invalid number, an unknown enum name, a missing codec — which
300
+ * must keep propagating.
263
301
  *
264
302
  * Copy-on-write throughout, like `projectForEncode`: a branch with nothing to
265
303
  * fix is shared, not rebuilt.
@@ -286,7 +324,16 @@ const repairMessage = (schemaId, value, ancestors) => {
286
324
  const current = value[field[0]];
287
325
  if (current === null || current === undefined)
288
326
  continue;
289
- const repair = (item) => field[1] === PROTO_FIELD_KIND.message ? repairMessage(field[2], item, seen) : repairScalar(field[1], item);
327
+ const repair = (item) => {
328
+ if (field[1] === PROTO_FIELD_KIND.message)
329
+ return repairMessage(field[2], item, seen);
330
+ // `?? item`, not `|| item`: 0 is a valid wire value (e.g. `"NONE"`).
331
+ // Unknown names stay strings, so the retry still throws and the
332
+ // original failure keeps propagating instead of becoming a silent 0.
333
+ if (field[1] === PROTO_FIELD_KIND.enum && typeof item === 'string')
334
+ return enumValueFor(field[2], item) ?? item;
335
+ return repairScalar(field[1], item);
336
+ };
290
337
  let converted = current;
291
338
  if (field[3] & PROTO_FIELD_FLAG.repeated) {
292
339
  if (Array.isArray(current)) {
@@ -457,8 +504,8 @@ class ProtoCompatibilityRuntime {
457
504
  throw new Error(`protobuf codec unavailable for ${path}`);
458
505
  const projected = this.projectForEncode(schemaId, message);
459
506
  const encoded = sourceCodec.encode(projected);
460
- // The bridge refuses two inputs it used to accept silently, and upstream
461
- // Baileys still encodes both. Repairing on failure rather than checking
507
+ // The bridge refuses three inputs it used to accept silently, and upstream
508
+ // Baileys still encodes all three. Repairing on failure rather than checking
462
509
  // every field on the way in is what keeps the ordinary encode free: a
463
510
  // message the codec accepts never reaches the repair, and one that does
464
511
  // not was already going to throw.
@@ -22,7 +22,7 @@ import { buildGroupCreateStubMessage, buildGroupJoinRequestEvents, buildGroupNot
22
22
  import { emitMessageUpsert } from '../Compatibility/message-upsert.js';
23
23
  import { extractMessageCappingPayload } from './message-capping.js';
24
24
  import { mapReachoutTimelock } from './reachout.js';
25
- import { isReconnectableConnectFailure } from './terminal-close.js';
25
+ import { isReconnectableConnectFailure, mapConnectFailureToDisconnect } from './terminal-close.js';
26
26
  const CANONICAL_MESSAGE_EVENT = 'message';
27
27
  const MESSAGE_UPSERT_APPEND = 'append';
28
28
  const MESSAGE_UPSERT_NOTIFY = 'notify';
@@ -191,41 +191,6 @@ const emitRetrying = (ctx) => ctx.ev.emit('connection.update', {
191
191
  * dispatcher for why `badSession` was the wrong home.
192
192
  */
193
193
  const CLIENT_OUTDATED_STATUS = 405;
194
- /**
195
- * Map bridge `ConnectFailureReason` wire codes (per the bridge's
196
- * `.d.ts` annotation) onto upstream Baileys' `DisconnectReason`.
197
- * Unknown codes fall through to `connectionClosed` so existing
198
- * reconnect heuristics keep working.
199
- *
200
- * Several cases here are belt-and-braces: the engine dispatches its own event
201
- * for `is_logged_out()` reasons (401/403/406) and for 405, so those never
202
- * reach `connectFailure` in practice. Kept because they cost nothing and the
203
- * engine's routing is not ours to depend on.
204
- */
205
- const mapConnectFailureToDisconnect = (reason) => {
206
- switch (reason) {
207
- case 401: // LoggedOut
208
- case 403: // MainDeviceGone
209
- case 406: // UnknownLogout
210
- return DisconnectReason.loggedOut;
211
- case 402: // TempBanned
212
- return DisconnectReason.forbidden;
213
- case 405: // ClientOutdated
214
- return CLIENT_OUTDATED_STATUS;
215
- case 411: // MultideviceMismatch (legacy alias)
216
- return DisconnectReason.multideviceMismatch;
217
- case 503: // ServiceUnavailable
218
- case 501: // Experimental
219
- return DisconnectReason.unavailableService;
220
- case 408: // Timed out
221
- return DisconnectReason.timedOut;
222
- case 515: // RestartRequired
223
- return DisconnectReason.restartRequired;
224
- // 400, 409, 413, 414, 415, 418, 500, undefined → generic close
225
- default:
226
- return DisconnectReason.connectionClosed;
227
- }
228
- };
229
194
  const describeTempBan = (code) => {
230
195
  switch (code) {
231
196
  case 101:
@@ -20,6 +20,27 @@ export const JOIN_APPROVAL_MODES = ['on', 'off'];
20
20
  // exact as a double, and that shape carries no methods. The helper reads both
21
21
  // forms, and reconstructs the high word instead of dropping it.
22
22
  const inviteExpirationNumber = (value) => toNumber(value);
23
+ // The core's V4 join parser only accepts a `<group>`, `<community>` or
24
+ // `<membership_approval_request>` child, but the server also answers a
25
+ // successful join with a bare `<iq type="result">` (WA Web's own
26
+ // `AcceptGroupAddResponseSuccess` variant requires no child at all — only the
27
+ // result envelope whose `from` echoes the request's `to`). The core reports
28
+ // that shape as an `IqError::ParseError`, which the bridge surfaces as
29
+ // `kind: 'internal'`. Error stanzas never reach the parser (they become
30
+ // `kind: 'server'` one layer below), so this substring can only mean the join
31
+ // was accepted and the JID carrier is missing — never a rejection.
32
+ const BARE_JOIN_SUCCESS_FRAGMENT = 'expected <group>, <community>, or <membership_approval_request> in join response';
33
+ // A bare `<iq type="result">` join success, as described above. Anything else
34
+ // (server rejections, timeouts, transport loss, protocol violations) must keep
35
+ // propagating.
36
+ const isBareJoinSuccess = (error) => {
37
+ if (!(error instanceof Error) || error.name !== 'WhatsAppError')
38
+ return false;
39
+ const kind = error.kind;
40
+ if (kind !== 'internal')
41
+ return false;
42
+ return typeof error.message === 'string' && error.message.includes(BARE_JOIN_SUCCESS_FRAGMENT);
43
+ };
23
44
  export const makeGroupMethods = (ctx) => {
24
45
  const groupMetadata = async (jid) => {
25
46
  const metadata = await (await ctx.getClient()).getGroupMetadata(jid);
@@ -34,10 +55,22 @@ export const makeGroupMethods = (ctx) => {
34
55
  // oxlint-disable-next-line typescript/no-explicit-any -- the established public contract returns Promise<any>.
35
56
  ) => {
36
57
  const messageKey = typeof key === 'string' ? { remoteJid: key } : key;
37
- if (!inviteMessage.groupJid || !inviteMessage.inviteCode || !messageKey.remoteJid) {
58
+ const groupJid = inviteMessage.groupJid;
59
+ if (!groupJid || !inviteMessage.inviteCode || !messageKey.remoteJid) {
38
60
  throw new TypeError('groupAcceptInviteV4 requires groupJid, inviteCode and inviter JID');
39
61
  }
40
- const joinedJid = await (await ctx.getClient()).groupAcceptInviteV4(inviteMessage.groupJid, inviteMessage.inviteCode, inviteExpirationNumber(inviteMessage.inviteExpiration), messageKey.remoteJid);
62
+ let joinedJid;
63
+ try {
64
+ joinedJid = await (await ctx.getClient()).groupAcceptInviteV4(groupJid, inviteMessage.inviteCode, inviteExpirationNumber(inviteMessage.inviteExpiration), messageKey.remoteJid);
65
+ }
66
+ catch (error) {
67
+ // The join was accepted but the response carried no JID node.
68
+ // Baileys returns the envelope's `from` here, which echoes the
69
+ // request's `to` — the group JID we already hold.
70
+ if (!isBareJoinSuccess(error))
71
+ throw error;
72
+ joinedJid = groupJid;
73
+ }
41
74
  if (messageKey.id) {
42
75
  const expiredInvite = proto.Message.GroupInviteMessage.fromObject(inviteMessage);
43
76
  expiredInvite.inviteExpiration = 0;