Compare commits

...

7 Commits

Author SHA1 Message Date
Renato Alcara d25c5055a2 chore: bump WhatsApp Web version to v2.3000.1038585534 on Mar/18 base
Restoring master to commit d04a3e1af8 ("fix: restore working carousel
send/render path (#297)") which is the LAST KNOWN GOOD state before the
inbound latency regression. PR #303 (pastParticipants HistorySync LID)
and subsequent commits introduced the per-message LID/PN session drift
that locked the inbound pipeline behind Bad MAC retry storms in
production.

WHAT IS PRESERVED:
- Tag `archive/master-pre-restore-2026-05-01` points at the previous
  master tip (d03b625a89, "chore: update WhatsApp Web version (#404)")
- Branch `master-archive-pre-restore-2026-05-01` mirrors that tip for
  easy browsing
- All 137 commits between Mar/18 and the previous master tip remain in
  git history; downstream forks lose nothing

WHAT IS LOST FROM MASTER (preserved in archive):
- View-once features (#310, #316, #322, #355)
- USync username inbound (#382)
- Early-ignore JIDs (#383)
- History sync memory/CPU optimisation (#385)
- tcToken full lifecycle (#386, #387)
- Async LID mapping restore (#390)
- Decrypt-error noise reduction (#391)
- Detach post-upsert work (#392)
- Bounded-retry replacing CircuitBreaker (#393)
- wire address lock (#396)
- Decryption fail-fast (#402)
- ~30 WhatsApp Web/proto version updates (only the latest is reapplied)

Re-add features one at a time on top of this base, testing each so the
inbound regression doesn't silently sneak back in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 22:53:29 -03:00
Renato Alcara d04a3e1af8 fix: restore working carousel send/render path (#297)
fix: restore working carousel send/render path (#297)
2026-03-18 20:30:18 -03:00
Renato Alcara 21462c26d5 chore: update WhatsApp Web version to v2.3000.1035406580 (#299)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-18 06:42:44 -03:00
github-actions[bot] fc2122b11a chore: update proto/version to v2.3000.1035370989 (#298)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-18 01:49:58 -03:00
Renato Alcara 11eed3aedc chore: update WhatsApp Web version to v2.3000.1035317910 (#296)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-17 06:16:35 -03:00
github-actions[bot] dbc6dd6aed chore: update proto/version to v2.3000.1035302375 (#295)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-17 00:46:29 -03:00
Renato Alcara e152aee740 Fix/pn lid session reuse (#294)
* fix: unify PN and LID session reuse for 1:1 sends

* fix: limit PN/LID reuse changes to session cache
2026-03-16 23:22:05 -03:00
8 changed files with 1273 additions and 314 deletions
+13 -1
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1035216863
/// WhatsApp Version: 2.3000.1035370989
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -1933,8 +1933,20 @@ message HandshakeMessage {
optional bytes paddedBytes = 6;
optional bool sendServerHelloPaddedBytes = 7;
optional bool simulateXxkemFs = 8;
optional HandshakeMessage.HandshakePqMode pqMode = 9;
}
enum HandshakePqMode {
HANDSHAKE_PQ_MODE_UNKNOWN = 0;
XXKEM = 1;
XXKEM_FS = 2;
WA_CLASSICAL = 3;
WA_PQ = 4;
IKKEM = 5;
IKKEM_FS = 6;
XXKEM_2 = 7;
IKKEM_2 = 8;
}
message ServerHello {
optional bytes ephemeral = 1;
optional bytes static = 2;
+14
View File
@@ -4759,6 +4759,7 @@ export namespace proto {
paddedBytes?: (Uint8Array|null);
sendServerHelloPaddedBytes?: (boolean|null);
simulateXxkemFs?: (boolean|null);
pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
}
class ClientHello implements IClientHello {
@@ -4771,6 +4772,7 @@ export namespace proto {
public paddedBytes?: (Uint8Array|null);
public sendServerHelloPaddedBytes?: (boolean|null);
public simulateXxkemFs?: (boolean|null);
public pqMode?: (proto.HandshakeMessage.HandshakePqMode|null);
public static create(properties?: proto.HandshakeMessage.IClientHello): proto.HandshakeMessage.ClientHello;
public static encode(m: proto.HandshakeMessage.IClientHello, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.HandshakeMessage.ClientHello;
@@ -4780,6 +4782,18 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
enum HandshakePqMode {
HANDSHAKE_PQ_MODE_UNKNOWN = 0,
XXKEM = 1,
XXKEM_FS = 2,
WA_CLASSICAL = 3,
WA_PQ = 4,
IKKEM = 5,
IKKEM_FS = 6,
XXKEM_2 = 7,
IKKEM_2 = 8
}
interface IServerHello {
ephemeral?: (Uint8Array|null);
"static"?: (Uint8Array|null);
+76
View File
@@ -32585,6 +32585,7 @@ export const proto = $root.proto = (() => {
ClientHello.prototype.paddedBytes = null;
ClientHello.prototype.sendServerHelloPaddedBytes = null;
ClientHello.prototype.simulateXxkemFs = null;
ClientHello.prototype.pqMode = null;
let $oneOfFields;
@@ -32636,6 +32637,12 @@ export const proto = $root.proto = (() => {
set: $util.oneOfSetter($oneOfFields)
});
// Virtual OneOf for proto3 optional field
Object.defineProperty(ClientHello.prototype, "_pqMode", {
get: $util.oneOfGetter($oneOfFields = ["pqMode"]),
set: $util.oneOfSetter($oneOfFields)
});
ClientHello.create = function create(properties) {
return new ClientHello(properties);
};
@@ -32659,6 +32666,8 @@ export const proto = $root.proto = (() => {
w.uint32(56).bool(m.sendServerHelloPaddedBytes);
if (m.simulateXxkemFs != null && Object.hasOwnProperty.call(m, "simulateXxkemFs"))
w.uint32(64).bool(m.simulateXxkemFs);
if (m.pqMode != null && Object.hasOwnProperty.call(m, "pqMode"))
w.uint32(72).int32(m.pqMode);
return w;
};
@@ -32703,6 +32712,10 @@ export const proto = $root.proto = (() => {
m.simulateXxkemFs = r.bool();
break;
}
case 9: {
m.pqMode = r.int32();
break;
}
default:
r.skipType(t & 7);
break;
@@ -32754,6 +32767,50 @@ export const proto = $root.proto = (() => {
if (d.simulateXxkemFs != null) {
m.simulateXxkemFs = Boolean(d.simulateXxkemFs);
}
switch (d.pqMode) {
default:
if (typeof d.pqMode === "number") {
m.pqMode = d.pqMode;
break;
}
break;
case "HANDSHAKE_PQ_MODE_UNKNOWN":
case 0:
m.pqMode = 0;
break;
case "XXKEM":
case 1:
m.pqMode = 1;
break;
case "XXKEM_FS":
case 2:
m.pqMode = 2;
break;
case "WA_CLASSICAL":
case 3:
m.pqMode = 3;
break;
case "WA_PQ":
case 4:
m.pqMode = 4;
break;
case "IKKEM":
case 5:
m.pqMode = 5;
break;
case "IKKEM_FS":
case 6:
m.pqMode = 6;
break;
case "XXKEM_2":
case 7:
m.pqMode = 7;
break;
case "IKKEM_2":
case 8:
m.pqMode = 8;
break;
}
return m;
};
@@ -32801,6 +32858,11 @@ export const proto = $root.proto = (() => {
if (o.oneofs)
d._simulateXxkemFs = "simulateXxkemFs";
}
if (m.pqMode != null && m.hasOwnProperty("pqMode")) {
d.pqMode = o.enums === String ? $root.proto.HandshakeMessage.HandshakePqMode[m.pqMode] === undefined ? m.pqMode : $root.proto.HandshakeMessage.HandshakePqMode[m.pqMode] : m.pqMode;
if (o.oneofs)
d._pqMode = "pqMode";
}
return d;
};
@@ -32818,6 +32880,20 @@ export const proto = $root.proto = (() => {
return ClientHello;
})();
HandshakeMessage.HandshakePqMode = (function() {
const valuesById = {}, values = Object.create(valuesById);
values[valuesById[0] = "HANDSHAKE_PQ_MODE_UNKNOWN"] = 0;
values[valuesById[1] = "XXKEM"] = 1;
values[valuesById[2] = "XXKEM_FS"] = 2;
values[valuesById[3] = "WA_CLASSICAL"] = 3;
values[valuesById[4] = "WA_PQ"] = 4;
values[valuesById[5] = "IKKEM"] = 5;
values[valuesById[6] = "IKKEM_FS"] = 6;
values[valuesById[7] = "XXKEM_2"] = 7;
values[valuesById[8] = "IKKEM_2"] = 8;
return values;
})();
HandshakeMessage.ServerHello = (function() {
function ServerHello(p) {
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1035223526]}
{"version":[2,3000,1038585534]}
+1095 -216
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -160,7 +160,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
height: dimensions.height
}
}
} else if ('jimp' in lib && (typeof lib.jimp?.Jimp === 'object' || typeof lib.jimp?.Jimp === 'function')) {
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'function') {
const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath)
const dimensions = {
width: jimp.width,
+70 -94
View File
@@ -45,8 +45,8 @@ import {
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getRawMediaUploadData,
getStream,
getRawMediaUploadData,
type MediaDownloadOptions
} from './messages-media'
import { shouldIncludeReportingToken } from './reporting-utils'
@@ -475,48 +475,6 @@ export const formatNativeFlowButton = (button: NativeButton): NativeFlowButton =
}
}
const recoverCarouselImageMetadata = async (
card: CarouselMessageOptions['cards'][number],
imageMessage: proto.Message.IImageMessage,
mediaOptions: MessageContentGenerationOptions
) => {
if (imageMessage.jpegThumbnail && imageMessage.height && imageMessage.width) {
return
}
try {
const { stream } = await getStream(card.image!, mediaOptions.options)
const thumb = await extractImageThumb(stream)
if (!imageMessage.jpegThumbnail) {
imageMessage.jpegThumbnail = thumb.buffer
}
if (!imageMessage.width && thumb.original.width) {
imageMessage.width = thumb.original.width
}
if (!imageMessage.height && thumb.original.height) {
imageMessage.height = thumb.original.height
}
mediaOptions.logger?.info(
{
cardTitle: card.title,
hasJpegThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image thumbnail/dimensions from source media'
)
} catch (error) {
mediaOptions.logger?.warn(
{ cardTitle: card.title, error },
'[CAROUSEL] Failed to recover image thumbnail/dimensions from source media'
)
}
}
/**
* Generates a button message using Native Flow format wrapped in viewOnceMessage
* This is the modern approach for button messages that works on iOS and Android
@@ -677,6 +635,59 @@ export const generateCarouselMessage = async (
)
}
const recoverCarouselImageMetadata = async (
imageMessage: proto.Message.IImageMessage | null | undefined,
cardImage: WAMediaUpload,
cardTitle: string | undefined,
uploadOptions: MessageContentGenerationOptions
) => {
if (!imageMessage) {
return
}
const missingThumbnail = !imageMessage.jpegThumbnail
const missingWidth = !imageMessage.width
const missingHeight = !imageMessage.height
if (!missingThumbnail && !missingWidth && !missingHeight) {
return
}
try {
const { stream } = await getStream(cardImage, uploadOptions.options)
const { buffer, original } = await extractImageThumb(stream)
if (missingThumbnail) {
imageMessage.jpegThumbnail = buffer.toString('base64')
}
if (missingWidth && original.width) {
imageMessage.width = original.width
}
if (missingHeight && original.height) {
imageMessage.height = original.height
}
uploadOptions.logger?.info(
{
cardTitle,
recoveredThumbnail: !!imageMessage.jpegThumbnail,
width: imageMessage.width,
height: imageMessage.height
},
'[CAROUSEL] Recovered image metadata from source media'
)
} catch (error) {
uploadOptions.logger?.warn(
{
cardTitle,
trace: error instanceof Error ? error.stack : String(error)
},
'[CAROUSEL] Failed source-media thumbnail fallback'
)
}
}
// Map cards to the carousel format (processing media)
const carouselCards = await Promise.all(
cards.map(async card => {
@@ -692,10 +703,12 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) {
if (card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions)
if (imageMessage) {
await recoverCarouselImageMetadata(card, imageMessage, mediaOptions)
}
// Mirror the working Pastorini-style result: every carousel image card should
// carry a jpegThumbnail and dimensions before it reaches the Web live renderer.
await recoverCarouselImageMetadata(imageMessage, card.image, card.title, mediaOptions)
// Validate image fields needed for WhatsApp rendering
if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn(
{ cardTitle: card.title },
@@ -1275,57 +1288,20 @@ export const generateWAMessageContent = async (
options.logger?.info('Sending CTA buttons as nativeFlowMessage with viewOnceMessage wrapper')
}
}
// Check for nativeCarousel — inline handler (validated on Android, iOS, Web)
// Direct interactiveMessage at root (field 45), NO viewOnceMessage wrapper,
// NO messageContextInfo, NO biz/bot stanza nodes needed
// Check for nativeCarousel
else if (hasNonNullishProperty(message, 'nativeCarousel')) {
const carouselMsg = message as any
const cards = carouselMsg.nativeCarousel.cards || []
const title = carouselMsg.nativeCarousel.title || carouselMsg.title
const text = carouselMsg.text
const footer = carouselMsg.footer
const carouselCards = await Promise.all(
cards.map(async (card: any) => {
const hasMedia = !!(card.image || card.video)
const header: any = {
title: card.title || '',
subtitle: card.footer || '',
hasMediaAttachment: hasMedia
}
if (hasMedia && card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, options)
if (imageMessage && !imageMessage.height) imageMessage.height = 500
if (imageMessage && !imageMessage.width) imageMessage.width = 500
header.imageMessage = imageMessage
}
return {
header,
body: { text: card.body || '' },
footer: card.footer ? { text: card.footer } : undefined,
nativeFlowMessage: {
buttons: (card.buttons || []).map((btn: any) => {
switch (btn.type) {
case 'url': return { name: 'cta_url', buttonParamsJson: JSON.stringify({ display_text: btn.text, url: btn.url, merchant_url: btn.url }) }
case 'copy': return { name: 'cta_copy', buttonParamsJson: JSON.stringify({ display_text: btn.text, copy_code: btn.copyText }) }
case 'call': return { name: 'cta_call', buttonParamsJson: JSON.stringify({ display_text: btn.text, phone_number: btn.phoneNumber }) }
default: return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.text, id: btn.id }) }
}
})
}
}
})
)
m.interactiveMessage = {
header: { title: title || ' ', hasMediaAttachment: false },
body: { text: text || '' },
footer: footer ? { text: footer } : undefined,
carouselMessage: {
cards: carouselCards,
messageVersion: 1
}
const carouselOptions: CarouselMessageOptions = {
cards: carouselMsg.nativeCarousel.cards,
title: carouselMsg.nativeCarousel.title || carouselMsg.title,
text: carouselMsg.text,
footer: carouselMsg.footer
}
// Pass options for media processing if cards have images/videos
const generated = await generateCarouselMessage(carouselOptions, options)
// Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper
// Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza
m.interactiveMessage = generated.interactiveMessage
return m
}
// Check for nativeList
+3 -1
View File
@@ -158,11 +158,13 @@ export async function storeTcTokensFromIqResult({
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// Resets real_issue_timestamp to null when storing a new token
// WABA Android: resets real_issue_timestamp to null when storing a new token
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
}
// Store under resolved storageJid AND under fallbackJid (PN) for reliable lookup
// The read path may resolve to a different LID than the store path
const normalizedFallback = jidNormalizedUser(fallbackJid)
const keysToStore: Record<string, typeof tokenEntry | null> = {
[storageJid]: tokenEntry