Compare commits

...

10 Commits

Author SHA1 Message Date
Renato Alcara 20b5a4703d fix: skip biz node for carousel messages (fixes WhatsApp Web rendering)
Carousel messages must NOT include the biz XML node in the stanza.
The biz node causes WhatsApp Web to render only the header instead
of the full carousel with cards. Android and iOS are unaffected.

Confirmed via Frida capture of working implementation (Pastorini):
carousel stanza has NO biz node, NO bot node.

Change: add `&& !isCarousel` to biz node injection condition.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:25:52 -03:00
Renato Alcara 6c904484e4 chore: update WhatsApp Web version to v2.3000.1034641024 (#264)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-06 06:09:40 -03:00
github-actions[bot] 5382d03f93 chore: update proto/version to v2.3000.1034621774 (#263)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-06 00:44:39 -03:00
Renato Alcara 5e602ebbf8 chore: update WhatsApp Web version to v2.3000.1034553229 (#262)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-05 06:12:30 -03:00
github-actions[bot] 12c400cf1e chore: update proto/version to v2.3000.1034530777 (#261)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-05 00:45:05 -03:00
Renato Alcara 92085d283a fix(sticker): align sticker pack with WABA Android (#260)
fix(sticker): align sticker pack with WABA Android (#260)
2026-03-04 10:19:25 -03:00
Renato Alcara 38aa34ffb0 chore: update WhatsApp Web version to v2.3000.1034439448 (#259)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-04 06:10:49 -03:00
Renato Alcara 52aa6402c0 fix(signal): align Signal Protocol handling with WABA Android behavior (#257)
7 corrections based on reverse-engineering of WhatsApp Business Android:

1. resolveCanonicalJid: PN→LID resolution for transaction lock keys (prevent race conditions)
2. Retain PN session during LID migration (avoid No Session errors)
3. Delayed PreKey deletion with 5-min grace period (prevent Invalid PreKey ID races)
4. Surgical session cleanup: only delete the specific corrupted device, not all devices
5. Identity dual storage: save identity key in both LID and PN addresses
6. MAC error cooldown reduced 10s→1s (faster recovery, aligned with WABA)
7. Allow session recreation on first retry (retryCount >= 1 instead of > 1)

* fix: remove unused jidDecode import from decode-wa-message.ts
* fix: add try/catch to delayed PreKey deletion to prevent unhandled rejection

The setTimeout async callback in removePreKey could cause unhandled promise
rejection if the keystore was destroyed (connection closed) before the
5-min grace period expired.
2026-03-04 00:51:20 -03:00
github-actions[bot] 8ea7f5434e chore: update proto/version to v2.3000.1034427372 (#258)
Co-authored-by: rsalcara <rsalcara@users.noreply.github.com>
2026-03-04 00:43:59 -03:00
Renato Alcara cf72068b6c fix(tctoken): align lifecycle with WABA Android behavior (#256)
- Error 463 (MissingTcToken): add getPrivacyTokens() re-fetch before retry
- Error 479 (SmaxInvalid): add getPrivacyTokens() re-fetch (fire-and-forget)
- Add realIssueTimestamp field matching wa_trusted_contacts_send schema
- Session refresh reissue: store IQ result + persist senderTimestamp/realIssueTimestamp
2026-03-03 22:03:02 -03:00
13 changed files with 1568 additions and 123 deletions
+57 -2
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1034302344
/// WhatsApp Version: 2.3000.1034621774
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -74,6 +74,7 @@ message AIMediaCollectionMessage {
message AIMediaCollectionMetadata {
optional string collectionId = 1;
optional uint32 uploadOrderIndex = 2;
}
message AIQueryFanout {
@@ -251,10 +252,12 @@ message AIThreadInfo {
optional AIThreadClientInfo clientInfo = 2;
message AIThreadClientInfo {
optional AIThreadType type = 1;
optional string sourceChatJid = 2;
enum AIThreadType {
UNKNOWN = 0;
DEFAULT = 1;
INCOGNITO = 2;
SIDE_CHAT = 3;
}
}
@@ -403,6 +406,7 @@ message BotCapabilityMetadata {
RICH_RESPONSE_UR_BLOKS_ENABLED = 55;
RICH_RESPONSE_INLINE_LINKS_ENABLED = 56;
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57;
JSON_PATCH_STREAMING = 58;
}
}
@@ -687,6 +691,7 @@ enum BotMetricsEntryPoint {
WEB_INTRO_PANEL = 46;
WEB_NAVIGATION_BAR = 47;
GROUP_MEMBER = 54;
CHATLIST_SEARCH = 55;
}
message BotMetricsMetadata {
optional string destinationId = 1;
@@ -1106,6 +1111,7 @@ message ClientPayload {
optional bool paaLink = 44;
optional int32 preacksCount = 45;
optional int32 processingQueueSize = 46;
repeated string pairedPeripherals = 47;
enum AccountType {
DEFAULT = 0;
GUEST = 1;
@@ -1628,6 +1634,7 @@ message DeviceCapabilities {
message BusinessBroadcast {
optional bool importListEnabled = 1;
optional bool companionSupportEnabled = 2;
}
enum ChatLockSupportLevel {
@@ -1702,6 +1709,7 @@ message DeviceProps {
optional uint32 thumbnailSyncDaysLimit = 19;
optional uint32 initialSyncMaxMessagesPerChat = 20;
optional bool supportManusHistory = 21;
optional bool supportHatchHistory = 22;
}
enum PlatformType {
@@ -2798,6 +2806,13 @@ message Message {
optional bool securityNotificationEnabled = 1;
}
enum InsightDeliveryState {
SENT = 0;
DELIVERED = 1;
READ = 2;
REPLIED = 3;
QUICK_REPLIED = 4;
}
message InteractiveMessage {
optional Header header = 1;
optional Body body = 2;
@@ -3170,6 +3185,16 @@ message Message {
optional HistorySyncChunkRetryRequest historySyncChunkRetryRequest = 8;
optional GalaxyFlowAction galaxyFlowAction = 9;
optional CompanionCanonicalUserNonceFetchRequest companionCanonicalUserNonceFetchRequest = 10;
optional BizBroadcastInsightsContactListRequest bizBroadcastInsightsContactListRequest = 11;
optional BizBroadcastInsightsRefreshRequest bizBroadcastInsightsRefreshRequest = 12;
message BizBroadcastInsightsContactListRequest {
optional string campaignId = 1;
}
message BizBroadcastInsightsRefreshRequest {
optional string campaignId = 1;
}
message CompanionCanonicalUserNonceFetchRequest {
optional string registrationTraceId = 1;
}
@@ -3244,6 +3269,18 @@ message Message {
optional CompanionCanonicalUserNonceFetchResponse companionCanonicalUserNonceFetchRequestResponse = 9;
optional HistorySyncChunkRetryResponse historySyncChunkRetryResponse = 10;
optional FlowResponsesCsvBundle flowResponsesCsvBundle = 11;
optional BizBroadcastInsightsContactListResponse bizBroadcastInsightsContactListResponse = 12;
message BizBroadcastInsightsContactListResponse {
optional string campaignId = 1;
optional int64 timestampMs = 2;
repeated Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState contacts = 3;
}
message BizBroadcastInsightsContactState {
optional string contactJid = 1;
optional Message.InsightDeliveryState state = 2;
}
message CompanionCanonicalUserNonceFetchResponse {
optional string nonce = 1;
optional string waFbid = 2;
@@ -3357,6 +3394,8 @@ message Message {
COMPANION_CANONICAL_USER_NONCE_FETCH = 9;
HISTORY_SYNC_CHUNK_RETRY = 10;
GALAXY_FLOW_ACTION = 11;
BUSINESS_BROADCAST_INSIGHTS_DELIVERED_TO = 12;
BUSINESS_BROADCAST_INSIGHTS_REFRESH = 13;
}
message PinInChatMessage {
optional MessageKey key = 1;
@@ -3967,6 +4006,7 @@ message MsgOpaqueData {
optional string quarantineExtractedText = 48;
optional int64 pollEndTime = 49;
optional bool pollHideVoterNames = 50;
optional bool pollAllowAddOption = 52;
message EventLocation {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
@@ -4083,6 +4123,7 @@ enum MutationProps {
NCT_SALT_SYNC_ACTION = 80;
BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81;
BUSINESS_BROADCAST_INSIGHTS_ACTION = 82;
CUSTOMER_DATA_ACTION = 83;
SHARE_OWN_PN = 10001;
BUSINESS_BROADCAST_ACTION = 10002;
AI_THREAD_DELETE_ACTION = 10003;
@@ -4718,6 +4759,7 @@ message SyncActionValue {
optional NctSaltSyncAction nctSaltSyncAction = 80;
optional BusinessBroadcastCampaignAction businessBroadcastCampaignAction = 81;
optional BusinessBroadcastInsightsAction businessBroadcastInsightsAction = 82;
optional CustomerDataAction customerDataAction = 83;
message AgentAction {
optional string name = 1;
optional int32 deviceID = 2;
@@ -4839,6 +4881,20 @@ message SyncActionValue {
repeated SyncActionValue.CustomPaymentMethod customPaymentMethods = 1;
}
message CustomerDataAction {
optional string chatJid = 1;
optional int32 contactType = 2;
optional string email = 3;
optional string altPhoneNumbers = 4;
optional int64 birthday = 5;
optional string address = 6;
optional int32 acquisitionSource = 7;
optional int32 leadStage = 8;
optional int64 lastOrder = 9;
optional int64 createdAt = 10;
optional int64 modifiedAt = 11;
}
message DeleteChatAction {
optional SyncActionValue.SyncActionMessageRange messageRange = 1;
}
@@ -5351,7 +5407,6 @@ message TemplateButton {
message ThreadID {
optional ThreadType threadType = 1;
optional MessageKey threadKey = 2;
optional string sourceChatJid = 3;
enum ThreadType {
UNKNOWN = 0;
VIEW_REPLIES = 1;
+144 -6
View File
@@ -203,11 +203,13 @@ export namespace proto {
interface IAIMediaCollectionMetadata {
collectionId?: (string|null);
uploadOrderIndex?: (number|null);
}
class AIMediaCollectionMetadata implements IAIMediaCollectionMetadata {
constructor(p?: proto.IAIMediaCollectionMetadata);
public collectionId?: (string|null);
public uploadOrderIndex?: (number|null);
public static create(properties?: proto.IAIMediaCollectionMetadata): proto.AIMediaCollectionMetadata;
public static encode(m: proto.IAIMediaCollectionMetadata, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.AIMediaCollectionMetadata;
@@ -725,11 +727,13 @@ export namespace proto {
interface IAIThreadClientInfo {
type?: (proto.AIThreadInfo.AIThreadClientInfo.AIThreadType|null);
sourceChatJid?: (string|null);
}
class AIThreadClientInfo implements IAIThreadClientInfo {
constructor(p?: proto.AIThreadInfo.IAIThreadClientInfo);
public type?: (proto.AIThreadInfo.AIThreadClientInfo.AIThreadType|null);
public sourceChatJid?: (string|null);
public static create(properties?: proto.AIThreadInfo.IAIThreadClientInfo): proto.AIThreadInfo.AIThreadClientInfo;
public static encode(m: proto.AIThreadInfo.IAIThreadClientInfo, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.AIThreadInfo.AIThreadClientInfo;
@@ -744,7 +748,8 @@ export namespace proto {
enum AIThreadType {
UNKNOWN = 0,
DEFAULT = 1,
INCOGNITO = 2
INCOGNITO = 2,
SIDE_CHAT = 3
}
}
@@ -1052,7 +1057,8 @@ export namespace proto {
AI_IMAGINE_UR_TO_NATIVE_LOADING_INDICATOR = 54,
RICH_RESPONSE_UR_BLOKS_ENABLED = 55,
RICH_RESPONSE_INLINE_LINKS_ENABLED = 56,
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57,
JSON_PATCH_STREAMING = 58
}
}
@@ -1738,7 +1744,8 @@ export namespace proto {
META_AI_SETTINGS = 45,
WEB_INTRO_PANEL = 46,
WEB_NAVIGATION_BAR = 47,
GROUP_MEMBER = 54
GROUP_MEMBER = 54,
CHATLIST_SEARCH = 55
}
interface IBotMetricsMetadata {
@@ -2814,6 +2821,7 @@ export namespace proto {
paaLink?: (boolean|null);
preacksCount?: (number|null);
processingQueueSize?: (number|null);
pairedPeripherals?: (string[]|null);
}
class ClientPayload implements IClientPayload {
@@ -2852,6 +2860,7 @@ export namespace proto {
public paaLink?: (boolean|null);
public preacksCount?: (number|null);
public processingQueueSize?: (number|null);
public pairedPeripherals: string[];
public static create(properties?: proto.IClientPayload): proto.ClientPayload;
public static encode(m: proto.IClientPayload, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.ClientPayload;
@@ -3973,11 +3982,13 @@ export namespace proto {
interface IBusinessBroadcast {
importListEnabled?: (boolean|null);
companionSupportEnabled?: (boolean|null);
}
class BusinessBroadcast implements IBusinessBroadcast {
constructor(p?: proto.DeviceCapabilities.IBusinessBroadcast);
public importListEnabled?: (boolean|null);
public companionSupportEnabled?: (boolean|null);
public static create(properties?: proto.DeviceCapabilities.IBusinessBroadcast): proto.DeviceCapabilities.BusinessBroadcast;
public static encode(m: proto.DeviceCapabilities.IBusinessBroadcast, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.DeviceCapabilities.BusinessBroadcast;
@@ -4152,6 +4163,7 @@ export namespace proto {
thumbnailSyncDaysLimit?: (number|null);
initialSyncMaxMessagesPerChat?: (number|null);
supportManusHistory?: (boolean|null);
supportHatchHistory?: (boolean|null);
}
class HistorySyncConfig implements IHistorySyncConfig {
@@ -4177,6 +4189,7 @@ export namespace proto {
public thumbnailSyncDaysLimit?: (number|null);
public initialSyncMaxMessagesPerChat?: (number|null);
public supportManusHistory?: (boolean|null);
public supportHatchHistory?: (boolean|null);
public static create(properties?: proto.DeviceProps.IHistorySyncConfig): proto.DeviceProps.HistorySyncConfig;
public static encode(m: proto.DeviceProps.IHistorySyncConfig, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.DeviceProps.HistorySyncConfig;
@@ -7042,6 +7055,14 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
enum InsightDeliveryState {
SENT = 0,
DELIVERED = 1,
READ = 2,
REPLIED = 3,
QUICK_REPLIED = 4
}
interface IInteractiveMessage {
header?: (proto.Message.InteractiveMessage.IHeader|null);
body?: (proto.Message.InteractiveMessage.IBody|null);
@@ -8082,6 +8103,8 @@ export namespace proto {
historySyncChunkRetryRequest?: (proto.Message.PeerDataOperationRequestMessage.IHistorySyncChunkRetryRequest|null);
galaxyFlowAction?: (proto.Message.PeerDataOperationRequestMessage.IGalaxyFlowAction|null);
companionCanonicalUserNonceFetchRequest?: (proto.Message.PeerDataOperationRequestMessage.ICompanionCanonicalUserNonceFetchRequest|null);
bizBroadcastInsightsContactListRequest?: (proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsContactListRequest|null);
bizBroadcastInsightsRefreshRequest?: (proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsRefreshRequest|null);
}
class PeerDataOperationRequestMessage implements IPeerDataOperationRequestMessage {
@@ -8096,6 +8119,8 @@ export namespace proto {
public historySyncChunkRetryRequest?: (proto.Message.PeerDataOperationRequestMessage.IHistorySyncChunkRetryRequest|null);
public galaxyFlowAction?: (proto.Message.PeerDataOperationRequestMessage.IGalaxyFlowAction|null);
public companionCanonicalUserNonceFetchRequest?: (proto.Message.PeerDataOperationRequestMessage.ICompanionCanonicalUserNonceFetchRequest|null);
public bizBroadcastInsightsContactListRequest?: (proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsContactListRequest|null);
public bizBroadcastInsightsRefreshRequest?: (proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsRefreshRequest|null);
public static create(properties?: proto.Message.IPeerDataOperationRequestMessage): proto.Message.PeerDataOperationRequestMessage;
public static encode(m: proto.Message.IPeerDataOperationRequestMessage, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestMessage;
@@ -8107,6 +8132,38 @@ export namespace proto {
namespace PeerDataOperationRequestMessage {
interface IBizBroadcastInsightsContactListRequest {
campaignId?: (string|null);
}
class BizBroadcastInsightsContactListRequest implements IBizBroadcastInsightsContactListRequest {
constructor(p?: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsContactListRequest);
public campaignId?: (string|null);
public static create(properties?: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsContactListRequest): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsContactListRequest;
public static encode(m: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsContactListRequest, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsContactListRequest;
public static fromObject(d: { [k: string]: any }): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsContactListRequest;
public static toObject(m: proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsContactListRequest, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IBizBroadcastInsightsRefreshRequest {
campaignId?: (string|null);
}
class BizBroadcastInsightsRefreshRequest implements IBizBroadcastInsightsRefreshRequest {
constructor(p?: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsRefreshRequest);
public campaignId?: (string|null);
public static create(properties?: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsRefreshRequest): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsRefreshRequest;
public static encode(m: proto.Message.PeerDataOperationRequestMessage.IBizBroadcastInsightsRefreshRequest, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsRefreshRequest;
public static fromObject(d: { [k: string]: any }): proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsRefreshRequest;
public static toObject(m: proto.Message.PeerDataOperationRequestMessage.BizBroadcastInsightsRefreshRequest, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface ICompanionCanonicalUserNonceFetchRequest {
registrationTraceId?: (string|null);
}
@@ -8326,6 +8383,7 @@ export namespace proto {
companionCanonicalUserNonceFetchRequestResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.ICompanionCanonicalUserNonceFetchResponse|null);
historySyncChunkRetryResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IHistorySyncChunkRetryResponse|null);
flowResponsesCsvBundle?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IFlowResponsesCsvBundle|null);
bizBroadcastInsightsContactListResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactListResponse|null);
}
class PeerDataOperationResult implements IPeerDataOperationResult {
@@ -8341,6 +8399,7 @@ export namespace proto {
public companionCanonicalUserNonceFetchRequestResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.ICompanionCanonicalUserNonceFetchResponse|null);
public historySyncChunkRetryResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IHistorySyncChunkRetryResponse|null);
public flowResponsesCsvBundle?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IFlowResponsesCsvBundle|null);
public bizBroadcastInsightsContactListResponse?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactListResponse|null);
public static create(properties?: proto.Message.PeerDataOperationRequestResponseMessage.IPeerDataOperationResult): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult;
public static encode(m: proto.Message.PeerDataOperationRequestResponseMessage.IPeerDataOperationResult, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult;
@@ -8352,6 +8411,44 @@ export namespace proto {
namespace PeerDataOperationResult {
interface IBizBroadcastInsightsContactListResponse {
campaignId?: (string|null);
timestampMs?: (number|Long|null);
contacts?: (proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactState[]|null);
}
class BizBroadcastInsightsContactListResponse implements IBizBroadcastInsightsContactListResponse {
constructor(p?: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactListResponse);
public campaignId?: (string|null);
public timestampMs?: (number|Long|null);
public contacts: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactState[];
public static create(properties?: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactListResponse): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactListResponse;
public static encode(m: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactListResponse, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactListResponse;
public static fromObject(d: { [k: string]: any }): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactListResponse;
public static toObject(m: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactListResponse, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IBizBroadcastInsightsContactState {
contactJid?: (string|null);
state?: (proto.Message.InsightDeliveryState|null);
}
class BizBroadcastInsightsContactState implements IBizBroadcastInsightsContactState {
constructor(p?: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactState);
public contactJid?: (string|null);
public state?: (proto.Message.InsightDeliveryState|null);
public static create(properties?: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactState): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState;
public static encode(m: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.IBizBroadcastInsightsContactState, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState;
public static fromObject(d: { [k: string]: any }): proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState;
public static toObject(m: proto.Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface ICompanionCanonicalUserNonceFetchResponse {
nonce?: (string|null);
waFbid?: (string|null);
@@ -8634,7 +8731,9 @@ export namespace proto {
COMPANION_SYNCD_SNAPSHOT_FATAL_RECOVERY = 8,
COMPANION_CANONICAL_USER_NONCE_FETCH = 9,
HISTORY_SYNC_CHUNK_RETRY = 10,
GALAXY_FLOW_ACTION = 11
GALAXY_FLOW_ACTION = 11,
BUSINESS_BROADCAST_INSIGHTS_DELIVERED_TO = 12,
BUSINESS_BROADCAST_INSIGHTS_REFRESH = 13
}
interface IPinInChatMessage {
@@ -10114,6 +10213,7 @@ export namespace proto {
quarantineExtractedText?: (string|null);
pollEndTime?: (number|Long|null);
pollHideVoterNames?: (boolean|null);
pollAllowAddOption?: (boolean|null);
}
class MsgOpaqueData implements IMsgOpaqueData {
@@ -10164,6 +10264,7 @@ export namespace proto {
public quarantineExtractedText?: (string|null);
public pollEndTime?: (number|Long|null);
public pollHideVoterNames?: (boolean|null);
public pollAllowAddOption?: (boolean|null);
public static create(properties?: proto.IMsgOpaqueData): proto.MsgOpaqueData;
public static encode(m: proto.IMsgOpaqueData, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.MsgOpaqueData;
@@ -10360,6 +10461,7 @@ export namespace proto {
NCT_SALT_SYNC_ACTION = 80,
BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81,
BUSINESS_BROADCAST_INSIGHTS_ACTION = 82,
CUSTOMER_DATA_ACTION = 83,
SHARE_OWN_PN = 10001,
BUSINESS_BROADCAST_ACTION = 10002,
AI_THREAD_DELETE_ACTION = 10003
@@ -11953,6 +12055,7 @@ export namespace proto {
nctSaltSyncAction?: (proto.SyncActionValue.INctSaltSyncAction|null);
businessBroadcastCampaignAction?: (proto.SyncActionValue.IBusinessBroadcastCampaignAction|null);
businessBroadcastInsightsAction?: (proto.SyncActionValue.IBusinessBroadcastInsightsAction|null);
customerDataAction?: (proto.SyncActionValue.ICustomerDataAction|null);
}
class SyncActionValue implements ISyncActionValue {
@@ -12030,6 +12133,7 @@ export namespace proto {
public nctSaltSyncAction?: (proto.SyncActionValue.INctSaltSyncAction|null);
public businessBroadcastCampaignAction?: (proto.SyncActionValue.IBusinessBroadcastCampaignAction|null);
public businessBroadcastInsightsAction?: (proto.SyncActionValue.IBusinessBroadcastInsightsAction|null);
public customerDataAction?: (proto.SyncActionValue.ICustomerDataAction|null);
public static create(properties?: proto.ISyncActionValue): proto.SyncActionValue;
public static encode(m: proto.ISyncActionValue, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue;
@@ -12436,6 +12540,42 @@ export namespace proto {
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface ICustomerDataAction {
chatJid?: (string|null);
contactType?: (number|null);
email?: (string|null);
altPhoneNumbers?: (string|null);
birthday?: (number|Long|null);
address?: (string|null);
acquisitionSource?: (number|null);
leadStage?: (number|null);
lastOrder?: (number|Long|null);
createdAt?: (number|Long|null);
modifiedAt?: (number|Long|null);
}
class CustomerDataAction implements ICustomerDataAction {
constructor(p?: proto.SyncActionValue.ICustomerDataAction);
public chatJid?: (string|null);
public contactType?: (number|null);
public email?: (string|null);
public altPhoneNumbers?: (string|null);
public birthday?: (number|Long|null);
public address?: (string|null);
public acquisitionSource?: (number|null);
public leadStage?: (number|null);
public lastOrder?: (number|Long|null);
public createdAt?: (number|Long|null);
public modifiedAt?: (number|Long|null);
public static create(properties?: proto.SyncActionValue.ICustomerDataAction): proto.SyncActionValue.CustomerDataAction;
public static encode(m: proto.SyncActionValue.ICustomerDataAction, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.SyncActionValue.CustomerDataAction;
public static fromObject(d: { [k: string]: any }): proto.SyncActionValue.CustomerDataAction;
public static toObject(m: proto.SyncActionValue.CustomerDataAction, o?: $protobuf.IConversionOptions): { [k: string]: any };
public toJSON(): { [k: string]: any };
public static getTypeUrl(typeUrlPrefix?: string): string;
}
interface IDeleteChatAction {
messageRange?: (proto.SyncActionValue.ISyncActionMessageRange|null);
}
@@ -13959,14 +14099,12 @@ export namespace proto {
interface IThreadID {
threadType?: (proto.ThreadID.ThreadType|null);
threadKey?: (proto.IMessageKey|null);
sourceChatJid?: (string|null);
}
class ThreadID implements IThreadID {
constructor(p?: proto.IThreadID);
public threadType?: (proto.ThreadID.ThreadType|null);
public threadKey?: (proto.IMessageKey|null);
public sourceChatJid?: (string|null);
public static create(properties?: proto.IThreadID): proto.ThreadID;
public static encode(m: proto.IThreadID, w?: $protobuf.Writer): $protobuf.Writer;
public static decode(r: ($protobuf.Reader|Uint8Array), l?: number): proto.ThreadID;
+1070 -21
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1034382497]}
{"version":[2,3000,1034641024]}
+79 -13
View File
@@ -302,6 +302,25 @@ export function makeLibSignalRepository(
// Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
// Resolve PN JID to its canonical LID JID for transaction locking.
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
if (isAnyPnUser(jid)) {
const lid = await lidMapping.getLIDForPN(jid)
if (lid) {
return lid
}
}
return jid
}
const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -396,23 +415,24 @@ export function makeLibSignalRepository(
return result
}
// If it's not a sync message, we need to ensure atomicity
// For regular messages, we use a transaction to ensure atomicity
// Use canonical JID (PN→LID resolved) as transaction key to prevent
// PN/LID race conditions on the same logical session.
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => {
return await doDecrypt()
}, jid)
}, canonicalJid)
},
async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr)
// Use transaction to ensure atomicity
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') }
}, jid)
}, canonicalJid)
},
async encryptGroupMessage({ group, meId, data }) {
@@ -654,9 +674,10 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, delete from PN
// Queue for bulk update: copy to LID, retain PN session.
// WABA retains both PN and LID sessions during migration to avoid
// No Session errors if messages arrive via PN before migration completes.
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
@@ -776,6 +797,13 @@ function signalStorage(
return id
}
// Delayed PreKey deletion: grace period to handle race conditions
// where two pkmsg with the same preKeyId arrive nearly simultaneously.
// WABA deletes immediately (33ms), but we add a 5-min grace period
// because we can't handle "Invalid PreKey ID" errors at the native level.
const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes
const pendingPreKeyDeletions = new Map<string, ReturnType<typeof setTimeout>>()
return {
loadSession: async (id: string) => {
try {
@@ -808,7 +836,26 @@ function signalStorage(
}
}
},
removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
removePreKey: (id: number) => {
const keyId = id.toString()
// Clear any existing timer for this key
const existing = pendingPreKeyDeletions.get(keyId)
if (existing) {
clearTimeout(existing)
}
// Schedule deletion after grace period
const timer = setTimeout(async () => {
pendingPreKeyDeletions.delete(keyId)
try {
await keys.set({ 'pre-key': { [id]: null } })
} catch {
// Keystore may be destroyed if connection closed — safe to ignore
}
}, PREKEY_GRACE_PERIOD_MS)
pendingPreKeyDeletions.set(keyId, timer)
},
loadSignedPreKey: () => {
const key = creds.signedPreKey
return {
@@ -898,14 +945,24 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey)
// Delete old session and save new identity key atomically
// Delete old session and save new identity key atomically.
// Store identity in BOTH LID and PN addresses (WABA stores in both
// recipient_account_type=0 and type=1 with CONFLICT_REPLACE).
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({
session: { [wireJid]: null },
'identity-key': { [wireJid]: identityKey }
'identity-key': identityUpdates
})
// Update cache
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -941,10 +998,19 @@ function signalStorage(
if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU)
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
// Store in both LID and PN addresses (aligned with WABA dual identity storage)
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
// Update cache
await keys.set({ 'identity-key': identityUpdates })
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' })
+74 -8
View File
@@ -69,7 +69,7 @@ import {
recordMessageReceived,
recordMessageRetry
} from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils'
import {
areJidsSameUser,
type BinaryNode,
@@ -1314,7 +1314,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false
let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try {
// Check if we have a session with this JID
const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
@@ -1453,6 +1453,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
})
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
// WABA Android: reissue stores senderTimestamp + realIssueTimestamp after IQ success
if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN)
@@ -1462,9 +1463,36 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs).catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
getPrivacyTokens([normalizedJid], senderTs)
.then(async (iqResult) => {
await storeTcTokensFromIqResult({
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
// Persist senderTimestamp + realIssueTimestamp after IQ success
const currentData = await authState.keys.get('tctoken', [tcJid])
const currentEntry = currentData[tcJid]
await authState.keys.set({
tctoken: {
[tcJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: senderTs,
realIssueTimestamp: 0
}
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'session_refreshed' })
})
.catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
}
})
.catch(() => {
@@ -1912,7 +1940,10 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
[storageJid]: {
...existing,
token: Buffer.from(content),
timestamp
timestamp,
// WABA Android: resets real_issue_timestamp when a new incoming token arrives
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
}
}
})
@@ -2002,7 +2033,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false
let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) {
try {
const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
@@ -2823,6 +2854,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const jid = jidNormalizedUser(attrs.from)
logTcToken('error_463', { jid, msgId })
// WABA Android: error 463 triggers getPrivacyTokens() fire-and-forget
// to ensure token is available for the retry below
getPrivacyTokens([jid])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid, reason: 'error_463' })
})
.catch(() => { /* fire-and-forget */ })
// Single-retry: wait 1.5s for the server's tctoken notification to arrive,
// then resend. A Set prevents infinite retry loops.
// Composite key (jid:msgId) ensures retries are isolated per destination.
@@ -2858,7 +2907,24 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return
}
} else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
const jid479 = jidNormalizedUser(attrs.from)
logTcToken('error_479', { jid: jid479, msgId: attrs.id })
// WABA Android: error 479 (SmaxInvalid) also triggers token re-fetch
getPrivacyTokens([jid479])
.then(async (result) => {
await storeTcTokensFromIqResult({
result,
fallbackJid: jid479,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('fetched', { jid: jid479, reason: 'error_479' })
})
.catch(() => { /* fire-and-forget */ })
} else {
logger.warn({ attrs }, 'received error in ack')
}
+8 -5
View File
@@ -1253,12 +1253,12 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const isCarousel = isCarouselMessage(message)
// Collect biz/bot nodes to append AFTER device-identity and tctoken
// Stanza order: participants → device-identity → tctoken → biz
// The biz node MUST be last for WhatsApp Web carousel rendering
// Stanza order: participants → device-identity → tctoken → biz (when applicable)
// Carousel messages must NOT have biz node (breaks WhatsApp Web rendering)
const deferredNodes: BinaryNode[] = []
// Inject biz node for interactive messages (including carousel)
if (buttonType && enableInteractiveMessages) {
// Inject biz node for interactive messages (skip carousel — biz node breaks Web rendering)
if (buttonType && enableInteractiveMessages && !isCarousel) {
const startTime = Date.now()
// Debug: Log message structure to diagnose list detection
@@ -1611,6 +1611,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present.
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
await authState.keys.set({
@@ -1618,7 +1620,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
[tcTokenJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp
senderTimestamp: issueTimestamp,
realIssueTimestamp: 0
}
}
})
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array
}
+3 -1
View File
@@ -54,12 +54,14 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack
*/
export type Sticker = {
/** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
/** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */
data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
}
/**
+6 -44
View File
@@ -14,7 +14,6 @@ import {
isJidStatusBroadcast,
isLidUser,
isPnUser,
jidDecode
// transferDevice
} from '../WABinary'
import { unpadRandomMax16 } from './generics'
@@ -488,8 +487,9 @@ export function isCorruptedSessionError(error: any): boolean {
}
/**
* Clean up corrupted session by deleting all device sessions for a JID.
* Signal Protocol will automatically recreate the session on next message.
* Clean up corrupted session for a specific device JID.
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=?
* Only deletes the exact device that was corrupted, not all devices.
*
* NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
@@ -500,45 +500,7 @@ export async function cleanupCorruptedSession(
repository: SignalRepositoryWithLIDStore,
logger: ILogger
): Promise<number> {
const { user, device } = jidDecode(jid) || {}
if (!user) {
logger.warn({ jid }, 'Cannot cleanup session - invalid JID')
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
await repository.deleteSession([jid])
logger.info({ jid }, 'Cleaned up corrupted session for specific device')
return 1
}
+1 -1
View File
@@ -218,7 +218,7 @@ export class MessageRetryManager {
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
+120 -19
View File
@@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto'
import { zipSync } from 'fflate'
import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -101,6 +102,49 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false
}
/**
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
*
* WABA usa mimetype `application/was` para stickers Lottie.
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
*
* Detecção:
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
*
* @param buffer - Buffer to check
* @returns true if buffer is Lottie/WAS format
*/
export const isLottieBuffer = (buffer: Buffer): boolean => {
if (buffer.length < 2) return false
let jsonBuffer: Buffer
// Check if gzip-compressed (WAS format)
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
try {
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
} catch {
return false
}
} else if (buffer[0] === 0x7b) {
// Starts with '{' - could be raw Lottie JSON
jsonBuffer = buffer
} else {
return false
}
// Validate Lottie JSON structure: must have v, ip, op, AND layers
try {
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
// Quick check for ALL required Lottie fields
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
} catch {
return false
}
}
/**
* Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações
@@ -114,12 +158,25 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
const convertToWebP = async (
buffer: Buffer,
logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
if (isLottieBuffer(buffer)) {
let wasBuffer = buffer
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
if (buffer[0] === 0x7b) {
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
wasBuffer = gzipSync(buffer) as Buffer
}
logger?.trace('Input is Lottie/WAS format')
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
}
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated }
return { webpBuffer: buffer, isAnimated, isLottie: false }
}
// Tenta usar Sharp para converter
@@ -135,7 +192,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false }
return { webpBuffer, isAnimated: false, isLottie: false }
}
/**
@@ -338,9 +395,11 @@ export type PrepareStickerPackMessageOptions = {
*
* **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial)
* - WebP obrigatório
* - WebP ou Lottie/WAS (application/was)
* - Stickers: 512x512 pixels (auto-resize)
* - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 252x252 pixels
* - Tray icon: 96x96 pixels (PNG no ZIP)
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
*
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger
@@ -464,9 +523,36 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Converte para WebP
// Detecta formato e converte se necessário
// eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
// Validate explicit isLottie flag — must match detected format
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
throw new Boom(
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
{ statusCode: 400 }
)
}
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
if (!isLottie && isWebPBuffer(webpBuffer)) {
const lib = await getImageProcessingLibrary()
if (lib?.sharp) {
const metadata = await lib.sharp.default(webpBuffer).metadata()
if (metadata.width !== 512 || metadata.height !== 512) {
logger?.trace(
{ index: i, width: metadata.width, height: metadata.height },
'Resizing sticker to 512x512 (WABA standard)'
)
webpBuffer = await lib.sharp
.default(webpBuffer)
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.webp()
.toBuffer()
}
}
}
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -483,7 +569,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) {
// Try quality 70
try {
const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -498,7 +584,7 @@ export const prepareStickerPackMessage = async (
)
} else {
// Try quality 50
const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer()
// eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) {
@@ -547,12 +633,13 @@ export const prepareStickerPackMessage = async (
)
}
// Gera nome do arquivo: hash.webp (deduplicação automática)
// Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext)
const sha256Hash = generateSha256Hash(webpBuffer)
const fileName = `${sha256Hash}.webp`
const extension = isLottie ? 'was' : 'webp'
const fileName = `${sha256Hash}.${extension}`
logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
'Sticker processed successfully'
)
@@ -560,6 +647,7 @@ export const prepareStickerPackMessage = async (
fileName,
webpBuffer,
isAnimated,
isLottie,
emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel
}
@@ -578,7 +666,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) {
if (!result) continue
const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName)
@@ -605,13 +693,14 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName,
isAnimated,
emojis,
accessibilityLabel,
isLottie: false,
mimetype: 'image/webp'
isLottie,
mimetype: isLottie ? 'application/was' : 'image/webp'
}
metadataByHash.set(fileName, metadata)
@@ -636,10 +725,22 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image')
// Converte cover para WebP e adiciona ao ZIP
const result = await convertToWebP(coverBuffer, logger)
coverWebP = result.webpBuffer
coverFileName = `${stickerPackId}.webp`
// Tray icon uses PNG format, 96x96 pixels (official client standard)
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom(
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
coverWebP = await lib.sharp
.default(coverBuffer)
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer()
coverFileName = `${stickerPackId}.png`
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
+4 -1
View File
@@ -159,7 +159,10 @@ export async function storeTcTokensFromIqResult({
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
timestamp: tokenNode.attrs.t,
// 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
}
}
})