Compare commits

..

2 Commits

Author SHA1 Message Date
Renato Alcara d28d8a4596 fix: address PR review — lint, dedup scope, and cleanup targeting
- Remove unused `autoCleanCorrupted` param from decryptMessageNode (lint fix)
- Scope retry dedup by participant JID in group chats (not group JID)
- Move dedup registration after early-return checks to avoid blocking
  subsequent messages when max retries reached
- Use participant JID for session cleanup in groups (Signal sessions
  are per-participant, not per-group)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:57:40 -03:00
Renato Alcara 17e3bd5d36 fix: break infinite Bad MAC + session recreation loop
Remove aggressive session cleanup from the decryption hot path that caused
cascading failures when multiple messages from the same contact arrived
simultaneously. The Signal Protocol naturally recovers via retry+pkmsg flow.

Changes:
- Remove cleanupCorruptedSession() from per-message error handler (hot path)
- Move session cleanup to retry-exhausted handler as safety net
- Add per-JID retry request deduplication (5s window)
- Add 10s cooldown on MAC error session recreation
- Export cleanupCorruptedSession/getDecryptionJid for use in messages-recv

Tested: corrupted session with fake keys → Bad MAC → retry receipt →
pkmsg recovery → all 7 messages delivered, zero infinite loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 20:29:58 -03:00
50 changed files with 1032 additions and 9549 deletions
+4 -235
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1038164556 /// WhatsApp Version: 2.3000.1034274421
message ADVDeviceIdentity { message ADVDeviceIdentity {
optional uint32 rawId = 1; optional uint32 rawId = 1;
@@ -74,7 +74,6 @@ message AIMediaCollectionMessage {
message AIMediaCollectionMetadata { message AIMediaCollectionMetadata {
optional string collectionId = 1; optional string collectionId = 1;
optional uint32 uploadOrderIndex = 2;
} }
message AIQueryFanout { message AIQueryFanout {
@@ -252,12 +251,10 @@ message AIThreadInfo {
optional AIThreadClientInfo clientInfo = 2; optional AIThreadClientInfo clientInfo = 2;
message AIThreadClientInfo { message AIThreadClientInfo {
optional AIThreadType type = 1; optional AIThreadType type = 1;
optional string sourceChatJid = 2;
enum AIThreadType { enum AIThreadType {
UNKNOWN = 0; UNKNOWN = 0;
DEFAULT = 1; DEFAULT = 1;
INCOGNITO = 2; INCOGNITO = 2;
SIDE_CHAT = 3;
} }
} }
@@ -345,14 +342,6 @@ message BotAgeCollectionMetadata {
} }
} }
message BotAgentDeepLinkMetadata {
optional string token = 1;
}
message BotAgentMetadata {
optional BotAgentDeepLinkMetadata deepLinkMetadata = 1;
}
message BotCapabilityMetadata { message BotCapabilityMetadata {
repeated BotCapabilityType capabilities = 1; repeated BotCapabilityType capabilities = 1;
enum BotCapabilityType { enum BotCapabilityType {
@@ -414,19 +403,9 @@ message BotCapabilityMetadata {
RICH_RESPONSE_UR_BLOKS_ENABLED = 55; RICH_RESPONSE_UR_BLOKS_ENABLED = 55;
RICH_RESPONSE_INLINE_LINKS_ENABLED = 56; RICH_RESPONSE_INLINE_LINKS_ENABLED = 56;
RICH_RESPONSE_UR_IMAGINE_VIDEO = 57; RICH_RESPONSE_UR_IMAGINE_VIDEO = 57;
JSON_PATCH_STREAMING = 58;
AI_TAB_FORCE_CLIPPY = 59;
UNIFIED_RESPONSE_EMBEDDED_SCREENS = 60;
AI_SUBSCRIPTION_ENABLED = 61;
} }
} }
message BotCommandMetadata {
optional string commandName = 1;
optional string commandDescription = 2;
optional string commandPrompt = 3;
}
message BotDocumentMessageMetadata { message BotDocumentMessageMetadata {
optional DocumentPluginType pluginType = 1; optional DocumentPluginType pluginType = 1;
enum DocumentPluginType { enum DocumentPluginType {
@@ -658,7 +637,6 @@ message BotMetadata {
optional BotRenderingConfigMetadata botRenderingConfigMetadata = 36; optional BotRenderingConfigMetadata botRenderingConfigMetadata = 36;
optional BotInfrastructureDiagnostics botInfrastructureDiagnostics = 37; optional BotInfrastructureDiagnostics botInfrastructureDiagnostics = 37;
optional AIMediaCollectionMetadata aiMediaCollectionMetadata = 38; optional AIMediaCollectionMetadata aiMediaCollectionMetadata = 38;
optional BotCommandMetadata commandMetadata = 39;
optional bytes internalMetadata = 999; optional bytes internalMetadata = 999;
} }
@@ -709,8 +687,6 @@ enum BotMetricsEntryPoint {
WEB_INTRO_PANEL = 46; WEB_INTRO_PANEL = 46;
WEB_NAVIGATION_BAR = 47; WEB_NAVIGATION_BAR = 47;
GROUP_MEMBER = 54; GROUP_MEMBER = 54;
CHATLIST_SEARCH = 55;
NEW_CHAT_LIST = 56;
} }
message BotMetricsMetadata { message BotMetricsMetadata {
optional string destinationId = 1; optional string destinationId = 1;
@@ -1093,7 +1069,6 @@ message ClientPairingProps {
optional bool isSyncdPureLidSession = 2; optional bool isSyncdPureLidSession = 2;
optional bool isSyncdSnapshotRecoveryEnabled = 3; optional bool isSyncdSnapshotRecoveryEnabled = 3;
optional bool isHsThumbnailSyncEnabled = 4; optional bool isHsThumbnailSyncEnabled = 4;
optional bytes subscriptionSyncPayload = 5;
} }
message ClientPayload { message ClientPayload {
@@ -1131,7 +1106,6 @@ message ClientPayload {
optional bool paaLink = 44; optional bool paaLink = 44;
optional int32 preacksCount = 45; optional int32 preacksCount = 45;
optional int32 processingQueueSize = 46; optional int32 processingQueueSize = 46;
repeated string pairedPeripherals = 47;
enum AccountType { enum AccountType {
DEFAULT = 0; DEFAULT = 0;
GUEST = 1; GUEST = 1;
@@ -1474,11 +1448,6 @@ message ContextInfo {
optional AdType adType = 25; optional AdType adType = 25;
optional string wtwaWebsiteUrl = 26; optional string wtwaWebsiteUrl = 26;
optional string adPreviewUrl = 27; optional string adPreviewUrl = 27;
optional bool containsCtwaFlowsAutoReply = 28;
optional int32 agmThumbnailStrategy = 29;
optional int32 agmTitleStrategy = 30;
optional int32 agmSubtitleStrategy = 31;
optional int32 agmHeaderInteractionStrategy = 32;
enum AdType { enum AdType {
CTWA = 0; CTWA = 0;
CAWC = 1; CAWC = 1;
@@ -1633,8 +1602,6 @@ message Conversation {
optional bool limitSharingInitiatedByMe = 53; optional bool limitSharingInitiatedByMe = 53;
optional bool maibaAiThreadEnabled = 54; optional bool maibaAiThreadEnabled = 54;
optional bool isMarketingMessageThread = 55; optional bool isMarketingMessageThread = 55;
optional bool isSenderNewAccount = 56;
optional uint32 afterReadDuration = 57;
enum EndOfHistoryTransferType { enum EndOfHistoryTransferType {
COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY = 0; COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY = 0;
COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY = 1; COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY = 1;
@@ -1661,10 +1628,6 @@ message DeviceCapabilities {
message BusinessBroadcast { message BusinessBroadcast {
optional bool importListEnabled = 1; optional bool importListEnabled = 1;
optional bool companionSupportEnabled = 2;
optional bool campaignSyncEnabled = 3;
optional bool insightsSyncEnabled = 4;
optional int32 recipientLimit = 5;
} }
enum ChatLockSupportLevel { enum ChatLockSupportLevel {
@@ -1739,9 +1702,6 @@ message DeviceProps {
optional uint32 thumbnailSyncDaysLimit = 19; optional uint32 thumbnailSyncDaysLimit = 19;
optional uint32 initialSyncMaxMessagesPerChat = 20; optional uint32 initialSyncMaxMessagesPerChat = 20;
optional bool supportManusHistory = 21; optional bool supportManusHistory = 21;
optional bool supportHatchHistory = 22;
repeated string supportedBotChannelFbids = 23;
optional bool supportInlineContacts = 24;
} }
enum PlatformType { enum PlatformType {
@@ -1928,16 +1888,6 @@ message GroupParticipant {
} }
} }
message GroupRootKeyShare {
repeated GroupRootKeyShareEntry keys = 1;
}
message GroupRootKeyShareEntry {
optional bytes groupRootKey = 1;
optional string keyId = 2;
optional int64 expiryTimestampMs = 3;
}
message HandshakeMessage { message HandshakeMessage {
optional ClientHello clientHello = 2; optional ClientHello clientHello = 2;
optional ServerHello serverHello = 3; optional ServerHello serverHello = 3;
@@ -1946,8 +1896,6 @@ message HandshakeMessage {
optional bytes static = 1; optional bytes static = 1;
optional bytes payload = 2; optional bytes payload = 2;
optional bytes extendedCiphertext = 3; optional bytes extendedCiphertext = 3;
optional bytes paddedBytes = 4;
optional bool simulateXxkemFs = 5;
} }
message ClientHello { message ClientHello {
@@ -1956,31 +1904,13 @@ message HandshakeMessage {
optional bytes payload = 3; optional bytes payload = 3;
optional bool useExtended = 4; optional bool useExtended = 4;
optional bytes extendedCiphertext = 5; optional bytes extendedCiphertext = 5;
optional bytes paddedBytes = 6;
optional bool sendServerHelloPaddedBytes = 7;
optional bool simulateXxkemFs = 8;
optional HandshakeMessage.HandshakePqMode pqMode = 9;
optional bytes extendedEphemeral = 10;
} }
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 { message ServerHello {
optional bytes ephemeral = 1; optional bytes ephemeral = 1;
optional bytes static = 2; optional bytes static = 2;
optional bytes payload = 3; optional bytes payload = 3;
optional bytes extendedStatic = 4; optional bytes extendedStatic = 4;
optional bytes paddingBytes = 5;
optional bytes extendedCiphertext = 6;
} }
} }
@@ -2004,8 +1934,6 @@ message HistorySync {
optional bytes shareableChatIdentifierEncryptionKey = 17; optional bytes shareableChatIdentifierEncryptionKey = 17;
repeated Account accounts = 18; repeated Account accounts = 18;
optional bytes nctSalt = 19; optional bytes nctSalt = 19;
repeated InlineContact inlineContacts = 20;
optional bool inlineContactsProvided = 21;
enum BotAIWaitListState { enum BotAIWaitListState {
IN_WAITLIST = 0; IN_WAITLIST = 0;
AI_AVAILABLE = 1; AI_AVAILABLE = 1;
@@ -2100,14 +2028,6 @@ message InThreadSurveyMetadata {
} }
message InlineContact {
optional string pnJid = 1;
optional string lidJid = 2;
optional string fullName = 3;
optional string firstName = 4;
optional string username = 5;
}
message InteractiveAnnotation { message InteractiveAnnotation {
repeated Point polygonVertices = 1; repeated Point polygonVertices = 1;
optional bool shouldSkipConfirmation = 4; optional bool shouldSkipConfirmation = 4;
@@ -2186,6 +2106,7 @@ message LimitSharing {
CHAT_SETTING = 1; CHAT_SETTING = 1;
BIZ_SUPPORTS_FB_HOSTING = 2; BIZ_SUPPORTS_FB_HOSTING = 2;
UNKNOWN_GROUP = 3; UNKNOWN_GROUP = 3;
DEPRECATION = 4;
} }
} }
@@ -2346,8 +2267,6 @@ message Message {
optional PollCreationMessage pollCreationMessageV6 = 119; optional PollCreationMessage pollCreationMessageV6 = 119;
optional ConditionalRevealMessage conditionalRevealMessage = 120; optional ConditionalRevealMessage conditionalRevealMessage = 120;
optional PollAddOptionMessage pollAddOptionMessage = 121; optional PollAddOptionMessage pollAddOptionMessage = 121;
optional EventInviteMessage eventInviteMessage = 122;
optional GroupRootKeyShare groupRootKeyShare = 123;
message AlbumMessage { message AlbumMessage {
optional uint32 expectedImageCount = 2; optional uint32 expectedImageCount = 2;
optional uint32 expectedVideoCount = 3; optional uint32 expectedVideoCount = 3;
@@ -2539,7 +2458,6 @@ message Message {
UNKNOWN = 0; UNKNOWN = 0;
CONTROL_PASSED = 1; CONTROL_PASSED = 1;
CONTROL_TAKEN = 2; CONTROL_TAKEN = 2;
INFO = 3;
} }
message CloudAPIThreadControlNotificationContent { message CloudAPIThreadControlNotificationContent {
optional string handoffNotificationText = 1; optional string handoffNotificationText = 1;
@@ -2629,16 +2547,6 @@ message Message {
optional bytes encIv = 3; optional bytes encIv = 3;
} }
message EventInviteMessage {
optional ContextInfo contextInfo = 1;
optional string eventId = 2;
optional string eventTitle = 3;
optional bytes jpegThumbnail = 4;
optional int64 startTime = 5;
optional string caption = 6;
optional bool isCanceled = 7;
}
message EventMessage { message EventMessage {
optional ContextInfo contextInfo = 1; optional ContextInfo contextInfo = 1;
optional bool isCanceled = 2; optional bool isCanceled = 2;
@@ -2890,13 +2798,6 @@ message Message {
optional bool securityNotificationEnabled = 1; optional bool securityNotificationEnabled = 1;
} }
enum InsightDeliveryState {
SENT = 0;
DELIVERED = 1;
READ = 2;
REPLIED = 3;
QUICK_REPLIED = 4;
}
message InteractiveMessage { message InteractiveMessage {
optional Header header = 1; optional Header header = 1;
optional Body body = 2; optional Body body = 2;
@@ -3165,10 +3066,9 @@ message Message {
message MessageHistoryMetadata { message MessageHistoryMetadata {
repeated string historyReceivers = 1; repeated string historyReceivers = 1;
optional int64 oldestMessageTimestampInWindow = 2; optional int64 oldestMessageTimestamp = 2;
optional int64 messageCount = 3; optional int64 messageCount = 3;
repeated string nonHistoryReceivers = 4; repeated string nonHistoryReceivers = 4;
optional int64 oldestMessageTimestampInBundle = 5;
} }
message MessageHistoryNotice { message MessageHistoryNotice {
@@ -3229,11 +3129,6 @@ message Message {
optional int64 expiryTimestamp = 2; optional int64 expiryTimestamp = 2;
optional bool incentiveEligible = 3; optional bool incentiveEligible = 3;
optional string referralId = 4; optional string referralId = 4;
optional InviteType inviteType = 5;
enum InviteType {
DEFAULT = 0;
MAPPER = 1;
}
enum ServiceType { enum ServiceType {
UNKNOWN = 0; UNKNOWN = 0;
FBPAY = 1; FBPAY = 1;
@@ -3275,16 +3170,6 @@ message Message {
optional HistorySyncChunkRetryRequest historySyncChunkRetryRequest = 8; optional HistorySyncChunkRetryRequest historySyncChunkRetryRequest = 8;
optional GalaxyFlowAction galaxyFlowAction = 9; optional GalaxyFlowAction galaxyFlowAction = 9;
optional CompanionCanonicalUserNonceFetchRequest companionCanonicalUserNonceFetchRequest = 10; 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 { message CompanionCanonicalUserNonceFetchRequest {
optional string registrationTraceId = 1; optional string registrationTraceId = 1;
} }
@@ -3321,7 +3206,6 @@ message Message {
optional int32 onDemandMsgCount = 4; optional int32 onDemandMsgCount = 4;
optional int64 oldestMsgTimestampMs = 5; optional int64 oldestMsgTimestampMs = 5;
optional string accountLid = 6; optional string accountLid = 6;
optional bool supportInlineResponse = 7;
} }
message PlaceholderMessageResendRequest { message PlaceholderMessageResendRequest {
@@ -3360,18 +3244,6 @@ message Message {
optional CompanionCanonicalUserNonceFetchResponse companionCanonicalUserNonceFetchRequestResponse = 9; optional CompanionCanonicalUserNonceFetchResponse companionCanonicalUserNonceFetchRequestResponse = 9;
optional HistorySyncChunkRetryResponse historySyncChunkRetryResponse = 10; optional HistorySyncChunkRetryResponse historySyncChunkRetryResponse = 10;
optional FlowResponsesCsvBundle flowResponsesCsvBundle = 11; 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 { message CompanionCanonicalUserNonceFetchResponse {
optional string nonce = 1; optional string nonce = 1;
optional string waFbid = 2; optional string waFbid = 2;
@@ -3408,7 +3280,6 @@ message Message {
ERROR_REQUEST_ON_NON_SMB_PRIMARY = 4; ERROR_REQUEST_ON_NON_SMB_PRIMARY = 4;
ERROR_HOSTED_DEVICE_NOT_CONNECTED = 5; ERROR_HOSTED_DEVICE_NOT_CONNECTED = 5;
ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6; ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6;
ERROR_MULTI_PROVIDER_NOT_CONFIGURED = 7;
} }
message HistorySyncChunkRetryResponse { message HistorySyncChunkRetryResponse {
optional Message.HistorySyncType syncType = 1; optional Message.HistorySyncType syncType = 1;
@@ -3486,8 +3357,6 @@ message Message {
COMPANION_CANONICAL_USER_NONCE_FETCH = 9; COMPANION_CANONICAL_USER_NONCE_FETCH = 9;
HISTORY_SYNC_CHUNK_RETRY = 10; HISTORY_SYNC_CHUNK_RETRY = 10;
GALAXY_FLOW_ACTION = 11; GALAXY_FLOW_ACTION = 11;
BUSINESS_BROADCAST_INSIGHTS_DELIVERED_TO = 12;
BUSINESS_BROADCAST_INSIGHTS_REFRESH = 13;
} }
message PinInChatMessage { message PinInChatMessage {
optional MessageKey key = 1; optional MessageKey key = 1;
@@ -3689,7 +3558,6 @@ message Message {
message RequestWelcomeMessageMetadata { message RequestWelcomeMessageMetadata {
optional LocalChatState localChatState = 1; optional LocalChatState localChatState = 1;
optional WelcomeTrigger welcomeTrigger = 2; optional WelcomeTrigger welcomeTrigger = 2;
optional BotAgentMetadata botAgentMetadata = 3;
enum LocalChatState { enum LocalChatState {
EMPTY = 0; EMPTY = 0;
NON_EMPTY = 1; NON_EMPTY = 1;
@@ -3807,7 +3675,6 @@ message Message {
optional bool isLottie = 21; optional bool isLottie = 21;
optional string accessibilityLabel = 22; optional string accessibilityLabel = 22;
optional int32 premium = 24; optional int32 premium = 24;
optional string emojis = 25;
} }
message StickerPackMessage { message StickerPackMessage {
@@ -4028,7 +3895,6 @@ message MessageContextInfo {
optional LimitSharing limitSharingV2 = 14; optional LimitSharing limitSharingV2 = 14;
repeated ThreadID threadId = 15; repeated ThreadID threadId = 15;
optional WebLinkRenderConfig weblinkRenderConfig = 16; optional WebLinkRenderConfig weblinkRenderConfig = 16;
optional bytes teeBotMetadata = 17;
enum MessageAddonExpiryType { enum MessageAddonExpiryType {
STATIC = 1; STATIC = 1;
DEPENDENT_ON_PARENT = 2; DEPENDENT_ON_PARENT = 2;
@@ -4101,7 +3967,6 @@ message MsgOpaqueData {
optional string quarantineExtractedText = 48; optional string quarantineExtractedText = 48;
optional int64 pollEndTime = 49; optional int64 pollEndTime = 49;
optional bool pollHideVoterNames = 50; optional bool pollHideVoterNames = 50;
optional bool pollAllowAddOption = 52;
message EventLocation { message EventLocation {
optional double degreesLatitude = 1; optional double degreesLatitude = 1;
optional double degreesLongitude = 2; optional double degreesLongitude = 2;
@@ -4218,11 +4083,6 @@ enum MutationProps {
NCT_SALT_SYNC_ACTION = 80; NCT_SALT_SYNC_ACTION = 80;
BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81; BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81;
BUSINESS_BROADCAST_INSIGHTS_ACTION = 82; BUSINESS_BROADCAST_INSIGHTS_ACTION = 82;
CUSTOMER_DATA_ACTION = 83;
SUBSCRIPTIONS_SYNC_V2_ACTION = 84;
THREAD_PIN_ACTION = 85;
AUTO_ORGANIZE_BUSINESS_CHAT_SETTING = 86;
BIZ_AI_SETTINGS_NUDGE_ACTION = 87;
SHARE_OWN_PN = 10001; SHARE_OWN_PN = 10001;
BUSINESS_BROADCAST_ACTION = 10002; BUSINESS_BROADCAST_ACTION = 10002;
AI_THREAD_DELETE_ACTION = 10003; AI_THREAD_DELETE_ACTION = 10003;
@@ -4536,12 +4396,6 @@ message ReportingTokenInfo {
optional bytes reportingTag = 1; optional bytes reportingTag = 1;
} }
message ScheduledMessageMetadata {
optional string revealKeyId = 1;
optional bytes revealKey = 2;
optional uint64 scheduledTime = 3;
}
message SenderKeyDistributionMessage { message SenderKeyDistributionMessage {
optional uint32 id = 1; optional uint32 id = 1;
optional uint32 iteration = 2; optional uint32 iteration = 2;
@@ -4697,7 +4551,6 @@ message StatusAttribution {
APPLE_MUSIC = 8; APPLE_MUSIC = 8;
SHARECHAT = 9; SHARECHAT = 9;
GOOGLE_PHOTOS = 10; GOOGLE_PHOTOS = 10;
SOUNDCLOUD = 11;
} }
} }
@@ -4865,11 +4718,6 @@ message SyncActionValue {
optional NctSaltSyncAction nctSaltSyncAction = 80; optional NctSaltSyncAction nctSaltSyncAction = 80;
optional BusinessBroadcastCampaignAction businessBroadcastCampaignAction = 81; optional BusinessBroadcastCampaignAction businessBroadcastCampaignAction = 81;
optional BusinessBroadcastInsightsAction businessBroadcastInsightsAction = 82; optional BusinessBroadcastInsightsAction businessBroadcastInsightsAction = 82;
optional CustomerDataAction customerDataAction = 83;
optional SubscriptionsSyncV2Action subscriptionsSyncV2Action = 84;
optional ThreadPinAction threadPinAction = 85;
optional AutoOrganizeBusinessChatSetting autoOrganizeBusinessChatSetting = 86;
optional BizAISettingsNudgeAction bizAiSettingsNudgeAction = 87;
message AgentAction { message AgentAction {
optional string name = 1; optional string name = 1;
optional int32 deviceID = 2; optional int32 deviceID = 2;
@@ -4889,10 +4737,6 @@ message SyncActionValue {
optional SyncActionValue.SyncActionMessageRange messageRange = 2; optional SyncActionValue.SyncActionMessageRange messageRange = 2;
} }
message AutoOrganizeBusinessChatSetting {
optional bool autoOrganize = 1;
}
message AvatarUpdatedAction { message AvatarUpdatedAction {
optional AvatarEventType eventType = 1; optional AvatarEventType eventType = 1;
repeated SyncActionValue.StickerAction recentAvatarStickers = 2; repeated SyncActionValue.StickerAction recentAvatarStickers = 2;
@@ -4903,20 +4747,6 @@ message SyncActionValue {
} }
} }
message BizAISettingsNudgeAction {
optional BizAISettingsCategory category = 1;
optional int64 version = 2;
optional int64 updatedAtMs = 3;
enum BizAISettingsCategory {
UNKNOWN = 0;
INSTRUCTIONS = 1;
RESPONSE_SETTINGS = 2;
EXAMPLE_RESPONSES = 3;
KNOWLEDGE = 4;
LEAD_GEN = 5;
}
}
message BotWelcomeRequestAction { message BotWelcomeRequestAction {
optional bool isSent = 1; optional bool isSent = 1;
} }
@@ -4962,7 +4792,6 @@ message SyncActionValue {
repeated SyncActionValue.BroadcastListParticipant participants = 2; repeated SyncActionValue.BroadcastListParticipant participants = 2;
optional string listName = 3; optional string listName = 3;
repeated string labelIds = 4; repeated string labelIds = 4;
optional string audienceExpression = 5;
} }
message CallLogAction { message CallLogAction {
@@ -5010,20 +4839,6 @@ message SyncActionValue {
repeated SyncActionValue.CustomPaymentMethod customPaymentMethods = 1; 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 { message DeleteChatAction {
optional SyncActionValue.SyncActionMessageRange messageRange = 1; optional SyncActionValue.SyncActionMessageRange messageRange = 1;
} }
@@ -5092,7 +4907,6 @@ message SyncActionValue {
DRAFTED = 8; DRAFTED = 8;
AI_HANDOFF = 9; AI_HANDOFF = 9;
CHANNELS = 10; CHANNELS = 10;
AI_RESPONDING = 11;
} }
} }
@@ -5308,8 +5122,6 @@ message SyncActionValue {
optional bool isStatusNotificationEnabled = 29; optional bool isStatusNotificationEnabled = 29;
optional int32 statusNotificationToneId = 30; optional int32 statusNotificationToneId = 30;
optional bool shouldPlaySoundForCallNotification = 31; optional bool shouldPlaySoundForCallNotification = 31;
optional string chatThemeId = 32;
optional string colorSchemeId = 33;
enum DisplayMode { enum DisplayMode {
DISPLAY_MODE_UNKNOWN = 0; DISPLAY_MODE_UNKNOWN = 0;
ALWAYS = 1; ALWAYS = 1;
@@ -5354,8 +5166,6 @@ message SyncActionValue {
IS_STATUS_NOTIFICATION_ENABLED = 29; IS_STATUS_NOTIFICATION_ENABLED = 29;
STATUS_NOTIFICATION_TONE_ID = 30; STATUS_NOTIFICATION_TONE_ID = 30;
SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31; SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31;
CHAT_THEME_ID = 32;
COLOR_SCHEME_ID = 33;
} }
enum SettingPlatform { enum SettingPlatform {
PLATFORM_UNKNOWN = 0; PLATFORM_UNKNOWN = 0;
@@ -5379,22 +5189,11 @@ message SyncActionValue {
repeated string userJid = 2; repeated string userJid = 2;
optional bool shareToFB = 3; optional bool shareToFB = 3;
optional bool shareToIG = 4; optional bool shareToIG = 4;
repeated CustomList customLists = 5;
repeated StatusDistributionMode modes = 6;
message CustomList {
optional string listId = 1;
optional string name = 2;
optional string emoji = 3;
optional bool isSelected = 4;
repeated string userJid = 5;
}
enum StatusDistributionMode { enum StatusDistributionMode {
ALLOW_LIST = 0; ALLOW_LIST = 0;
DENY_LIST = 1; DENY_LIST = 1;
CONTACTS = 2; CONTACTS = 2;
CLOSE_FRIENDS = 3; CLOSE_FRIENDS = 3;
CUSTOM_LIST = 4;
} }
} }
@@ -5420,29 +5219,6 @@ message SyncActionValue {
optional int64 expirationDate = 3; optional int64 expirationDate = 3;
} }
message SubscriptionsSyncV2Action {
repeated SubscriptionInfo subscriptions = 1;
repeated PaidFeature paidFeature = 2;
message PaidFeature {
optional string name = 1;
optional bool enabled = 2;
optional int32 limit = 3;
optional int64 expirationTime = 4;
}
message SubscriptionInfo {
optional string id = 1;
optional int32 tier = 2;
optional string status = 3;
optional int64 startTime = 4;
optional int64 endTime = 5;
optional bool isPlatformChanged = 6;
optional string source = 7;
optional int64 creationTime = 8;
}
}
message SyncActionMessage { message SyncActionMessage {
optional MessageKey key = 1; optional MessageKey key = 1;
optional int64 timestamp = 2; optional int64 timestamp = 2;
@@ -5454,10 +5230,6 @@ message SyncActionValue {
repeated SyncActionValue.SyncActionMessage messages = 3; repeated SyncActionValue.SyncActionMessage messages = 3;
} }
message ThreadPinAction {
optional bool pinned = 1;
}
message TimeFormatAction { message TimeFormatAction {
optional bool isTwentyFourHourFormatEnabled = 1; optional bool isTwentyFourHourFormatEnabled = 1;
} }
@@ -5579,6 +5351,7 @@ message TemplateButton {
message ThreadID { message ThreadID {
optional ThreadType threadType = 1; optional ThreadType threadType = 1;
optional MessageKey threadKey = 2; optional MessageKey threadKey = 2;
optional string sourceChatJid = 3;
enum ThreadType { enum ThreadType {
UNKNOWN = 0; UNKNOWN = 0;
VIEW_REPLIES = 1; VIEW_REPLIES = 1;
@@ -5781,8 +5554,6 @@ message WebMessageInfo {
optional QuarantinedMessage quarantinedMessage = 77; optional QuarantinedMessage quarantinedMessage = 77;
optional uint32 nonJidMentions = 78; optional uint32 nonJidMentions = 78;
optional string hsmTag = 79; optional string hsmTag = 79;
optional uint64 ephemeralExpirationTimestamp = 80;
optional ScheduledMessageMetadata scheduledMessageMetadata = 81;
enum BizPrivacyStatus { enum BizPrivacyStatus {
E2EE = 0; E2EE = 0;
FB = 2; FB = 2;
@@ -6022,8 +5793,6 @@ message WebMessageInfo {
GROUP_MEMBER_SHARE_GROUP_HISTORY_MODE = 221; GROUP_MEMBER_SHARE_GROUP_HISTORY_MODE = 221;
GROUP_OPEN_BOT_ADDED = 222; GROUP_OPEN_BOT_ADDED = 222;
GROUP_TEE_BOT_ADDED = 223; GROUP_TEE_BOT_ADDED = 223;
CONTACT_INFO = 224;
SCHEDULED_MESSAGE_CREATED = 225;
} }
} }
+18 -26
View File
@@ -18,21 +18,14 @@ try {
'\tif (typeof value === "number") {\n' + '\tif (typeof value === "number") {\n' +
'\t\treturn String(value);\n' + '\t\treturn String(value);\n' +
'\t}\n' + '\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' + '\tif (!$util.Long) {\n' +
'\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' + '\t\treturn String(value);\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' +
'\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn (combined - (1n << 64n)).toString();\n' +
'\t\t}\n' +
'\t\treturn combined.toString();\n' +
'\t}\n' + '\t}\n' +
'\treturn String(value);\n' + '\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: normalized;\n' +
'\treturn prepared.toString();\n' +
'}\n\n' '}\n\n'
const longToNumberHelper = const longToNumberHelper =
'function longToNumber(value, unsigned) {\n' + 'function longToNumber(value, unsigned) {\n' +
@@ -40,20 +33,19 @@ try {
'\t\treturn value;\n' + '\t\treturn value;\n' +
'\t}\n' + '\t}\n' +
'\tif (typeof value === "string") {\n' + '\tif (typeof value === "string") {\n' +
'\t\tconst numeric = Number(value);\n' +
'\t\treturn numeric;\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn Number(value);\n' + '\t\treturn Number(value);\n' +
'\t}\n' + '\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' + '\tconst normalized = $util.Long.fromValue(value);\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' + '\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\tconst high = value.high | 0;\n' + '\t\t? normalized.toUnsigned()\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' + '\t\t: typeof normalized.toSigned === "function"\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' + '\t\t\t? normalized.toSigned()\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' + '\t\t\t: normalized;\n' +
'\t\tif (!unsigned && high < 0) {\n' + '\treturn prepared.toNumber();\n' +
'\t\t\treturn Number(combined - (1n << 64n));\n' +
'\t\t}\n' +
'\t\treturn Number(combined);\n' +
'\t}\n' +
'\treturn Number(value);\n' +
'}\n\n' '}\n\n'
if (!content.includes('function longToString(')) { if (!content.includes('function longToString(')) {
+17 -597
View File
File diff suppressed because it is too large Load Diff
+67 -4489
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-531
View File
@@ -1,531 +0,0 @@
# Mensagens View-Once (Visualização Única) — Guia de Implementação
**Data:** 2026-03-23
**Versão:** 1.0
---
## 1. O que é View-Once
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
---
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
>
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
>
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
>
> **Como identificar se sua conta é hosted:**
> - A conta foi criada via Meta Business Cloud API (WABA)
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
>
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
---
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
| Dispositivo | O que aparece |
|---|---|
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
---
## 2. Como Enviar via API
### 2.1 Imagem
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Olha só isso!",
"imageUrl": "https://exemplo.com/foto.jpg",
"viewOnce": true
}'
```
**Resposta de sucesso:**
```json
{ "ok": true }
```
### 2.2 Vídeo
```bash
curl -X POST https://sua-api.com/v1/messages/send_video \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Assista uma vez",
"videoUrl": "https://exemplo.com/video.mp4",
"viewOnce": true
}'
```
### 2.3 Áudio
```bash
curl -X POST https://sua-api.com/v1/messages/send_audio \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"audioUrl": "https://exemplo.com/audio.ogg",
"ptt": false,
"viewOnce": true
}'
```
### 2.4 Usando Base64 (sem URL pública)
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
"viewOnce": true
}'
```
---
## 3. Como Receber via Webhook
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
### 3.1 Estrutura do evento recebido
```json
{
"event": "messages.upsert",
"instance": "nome-da-instancia",
"data": {
"messages": [
{
"key": {
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"id": "3EB0ABCDEF123456",
"isViewOnce": true
},
"message": {
"viewOnceMessageV2": {
"message": {
"imageMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "image/jpeg",
"mediaKey": "base64...",
"fileEncSha256": "base64...",
"directPath": "/v/...",
"viewOnce": true
}
}
}
},
"messageTimestamp": 1742700000,
"pushName": "João Silva"
}
],
"type": "notify"
}
}
```
### 3.2 Como identificar que é view-once
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
```javascript
function isViewOnce(msg) {
// Caminho 1: flag direta (mais simples)
if (msg.key?.isViewOnce) return true
// Caminho 2: verificar o wrapper da mensagem
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
return !!(
inner?.imageMessage?.viewOnce ||
inner?.videoMessage?.viewOnce ||
inner?.audioMessage?.viewOnce
)
}
```
### 3.3 Como acessar a mídia
```javascript
function getViewOnceMedia(msg) {
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
if (!inner) return null
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
return null
}
// Uso:
const media = getViewOnceMedia(msg)
if (media) {
console.log(`View-once ${media.type}`)
console.log('URL:', media.media.url)
console.log('DirectPath:', media.media.directPath)
console.log('MediaKey:', media.media.mediaKey) // necessário para download
}
```
---
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
### 4.1 Lógica de detecção e exibição
```javascript
function renderMessage(msg) {
if (!isViewOnce(msg)) {
// Renderizar mensagem normal
return renderNormalMessage(msg)
}
const media = getViewOnceMedia(msg)
if (!media) return
// Verificar se já foi aberta (controle local da ferramenta)
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
if (alreadyOpened) {
// Mostrar placeholder de "já visualizado"
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
}
if (msg.key.fromMe) {
// Mensagem enviada por mim — nunca mostrar conteúdo
return renderSentViewOnce(media.type)
}
// Mensagem recebida — mostrar botão para "abrir uma vez"
return renderViewOnceButton(media.type, msg)
}
```
### 4.2 Componente de exibição — mensagem recebida
```javascript
function renderViewOnceButton(type, msg) {
const icons = { image: '📷', video: '🎥', audio: '🎵' }
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
return `
<div class="view-once-bubble received">
<span class="view-once-icon">${icons[type]}</span>
<span class="view-once-label">${labels[type]} de visualização única</span>
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
Abrir
</button>
</div>
`
}
async function openViewOnce(msgId, type) {
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
localStorage.setItem(`vo_${msgId}`, '1')
// 2. Baixar e mostrar a mídia
const mediaUrl = await downloadViewOnceMedia(msgId)
showMedia(type, mediaUrl)
// 3. Após fechar/visualizar, substituir pelo placeholder
onMediaClosed(() => {
replaceWithPlaceholder(msgId, type)
})
}
```
### 4.3 Componente de exibição — mensagem enviada
```javascript
function renderSentViewOnce(type) {
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
return `
<div class="view-once-bubble sent">
<span class="view-once-icon">👁️</span>
<span>${labels[type]} de visualização única</span>
<span class="view-once-status">Enviada</span>
</div>
`
}
```
### 4.4 Placeholder após visualização
```javascript
function renderViewOncePlaceholder(type, fromMe) {
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
const text = fromMe
? `Você enviou uma ${labels[type]} de visualização única`
: `${labels[type]} de visualização única aberta`
return `
<div class="view-once-bubble placeholder">
<span class="view-once-icon">🚫</span>
<span>${text}</span>
</div>
`
}
```
### 4.5 CSS sugerido
```css
.view-once-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 12px;
max-width: 280px;
}
.view-once-bubble.received {
background: #f0f0f0;
color: #333;
}
.view-once-bubble.sent {
background: #dcf8c6;
color: #333;
align-self: flex-end;
}
.view-once-bubble.placeholder {
background: #e8e8e8;
color: #999;
font-style: italic;
}
.view-once-bubble button {
background: #25d366;
color: white;
border: none;
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
}
```
---
## 5. Código Implementado na API
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
```typescript
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Seta viewOnce=true na mensagem de mídia interna
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
m = { viewOnceMessageV2: { message: m } }
}
```
### 5.2 Envio — `src/Socket/messages-send.ts`
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
```typescript
// Detecta view-once real pela flag viewOnce na mídia interna.
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// Para view-once: DSM enviado apenas para o celular principal (device=0).
// Companions (device>0) são omitidos — o servidor WA gera
// <unavailable type="view_once"/> automaticamente para eles.
const viewOnceMeRecipients = isViewOnceMsg
? meRecipients.filter(jid => !jidDecode(jid)?.device)
: meRecipients
// assertSessions apenas para quem vai realmente receber
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
const [meNodes, otherNodes] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
// phash recalculado com os participantes reais
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...otherRecipients]
: [...meRecipients, ...otherRecipients]
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
```
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
```typescript
const getMediaType = (message: proto.IMessage) => {
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
// Sem isso, message.videoMessage e message.audioMessage são undefined
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) return 'image'
else if (message.videoMessage) return 'video'
// ...
}
```
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
O stanza 1 é detectado e marcado corretamente:
```typescript
// Detecta view-once na stanza 1 recebida por linked device.
// O wrapper viewOnceMessage também é usado por mensagens interativas
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
```
---
## 6. Fluxo Completo
```
API envia view-once para destinatário
├── Protobuf gerado:
│ viewOnceMessageV2 {
│ message {
│ imageMessage { viewOnce: true, url, mediaKey, ... }
│ }
│ }
├── Stanza enviado ao servidor WA:
│ <message to="destinatario@s.whatsapp.net" type="media">
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
│ <participants>
│ <to jid="meu-numero:0@lid"> ← celular principal
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
│ </to>
│ <!-- device>0 omitidos — servidor gera unavailable -->
│ </participants>
│ </message>
└── Distribuição pelo servidor:
├── Destinatário → recebe enc → abre uma vez ✅
├── Celular principal → recebe DSM → mostra "você enviou" ✅
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
Destinatário envia view-once de volta para a API
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
│ <message from="destinatario@s.whatsapp.net" type="media">
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
│ </message>
├── decode-wa-message.ts:
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
├── Stanza recebido (stanza 2 — unavailable, ignorado):
│ <message from="...">
│ <unavailable type="view_once"/>
│ </message>
└── Webhook emitido:
messages.upsert → msg.key.isViewOnce = true ✅
```
---
## 7. Limitações
| Situação | Comportamento | Motivo |
|---|---|---|
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
---
## 8. Checklist de Validação
**Envio:**
- [ ] Destinatário recebe com ícone de olhinho
- [ ] Destinatário consegue abrir a mídia
- [ ] Após abrir, a mídia some
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
- [ ] Funciona para imagem, vídeo e áudio
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
**Recepção:**
- [ ] Webhook recebe `key.isViewOnce = true`
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
- [ ] API não crasha ao receber view-once de outro dispositivo
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
+1 -1
View File
@@ -47,7 +47,7 @@
"fflate": "^0.8.2", "fflate": "^0.8.2",
"libsignal": "github:whiskeysockets/libsignal-node", "libsignal": "github:whiskeysockets/libsignal-node",
"lru-cache": "^11.2.6", "lru-cache": "^11.2.6",
"music-metadata": "^11.12.3", "music-metadata": "^11.12.0",
"p-queue": "^9.0.0", "p-queue": "^9.0.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"prom-client": "^15.1.3", "prom-client": "^15.1.3",
+1 -1
View File
@@ -1 +1 @@
{"version":[2,3000,1038167900]} {"version":[2,3000,1034279434]}
+8 -67
View File
@@ -31,26 +31,9 @@ export const STATUS_EXPIRY_SECONDS = 24 * 60 * 60
export const PLACEHOLDER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60 export const PLACEHOLDER_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0' export const NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
export const DICT_VERSION = 3
/**
* Protocol mode: 'web' (default) or 'native' (experimental)
* - web: WA\x06\x03 — standard WhatsApp Web protocol
* - native: WAM\x05 — native Android protocol (may enable view-once media on companions)
*
* Set via BAILEYS_PROTOCOL env var:
* BAILEYS_PROTOCOL=native
*/
const PROTOCOL_MODE = process.env.BAILEYS_PROTOCOL?.trim().toLowerCase() === 'native' ? 'native' : 'web'
export const DICT_VERSION = PROTOCOL_MODE === 'native' ? 5 : 3
export const KEY_BUNDLE_TYPE = Buffer.from([5]) export const KEY_BUNDLE_TYPE = Buffer.from([5])
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, DICT_VERSION]) // last is "DICT_VERSION"
// WA\x06\x03 = web protocol, WAM\x05 = native Android protocol
export const NOISE_WA_HEADER = PROTOCOL_MODE === 'native'
? Buffer.from([87, 65, 77, DICT_VERSION]) // WAM\x05
: Buffer.from([87, 65, 6, DICT_VERSION]) // WA\x06\x03
export { PROTOCOL_MODE }
/** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */ /** from: https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url */
export const URL_REGEX = /https:\/\/(?![^:@\/\s]+:[^:@\/\s]+@)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:\d+)?(\/[^\s]*)?/g export const URL_REGEX = /https:\/\/(?![^:@\/\s]+:[^:@\/\s]+@)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(:\d+)?(\/[^\s]*)?/g
@@ -73,33 +56,10 @@ export const PROCESSABLE_HISTORY_TYPES = [
// 6 hours in milliseconds // 6 hours in milliseconds
const SIX_HOURS_MS = 6 * 60 * 60 * 1000 const SIX_HOURS_MS = 6 * 60 * 60 * 1000
/**
* Resolves the default browser tuple from the BAILEYS_BROWSER env var.
* Default: Android companion (SMB_ANDROID) — matches upstream PR #2201.
* Pair code auto-detects Android and falls back to Chrome in socket.ts.
*
* unset / 'android' → Browsers.android('14')
* 'android:15' → Browsers.android('15')
* 'chrome' / 'macos' → Browsers.macOS('Chrome')
*/
const resolveDefaultBrowser = (): [string, string, string] => {
const env = process.env.BAILEYS_BROWSER?.trim().toLowerCase()
if (env === 'chrome' || env === 'macos') {
return Browsers.macOS('Chrome')
}
if (env?.startsWith('android:')) {
const apiLevel = env.split(':')[1] || '14'
return Browsers.android(apiLevel)
}
return Browsers.android('14')
}
export const DEFAULT_CONNECTION_CONFIG: SocketConfig = { export const DEFAULT_CONNECTION_CONFIG: SocketConfig = {
version: version as WAVersion, version: version as WAVersion,
versionCheckIntervalMs: SIX_HOURS_MS, versionCheckIntervalMs: SIX_HOURS_MS,
browser: resolveDefaultBrowser(), browser: Browsers.macOS('Chrome'),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat', waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000, connectTimeoutMs: 20_000,
keepAliveIntervalMs: 15_000, keepAliveIntervalMs: 15_000,
@@ -171,20 +131,7 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'md-msg-hist': '/mms/md-app-state', 'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo', 'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/sticker-pack', 'sticker-pack': '/mms/sticker-pack',
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack', 'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
// View-once types — same upload path as regular media (server CDN routing TBD)
'viewonce-image': '/mms/image',
'viewonce-video': '/mms/video',
'viewonce-audio': '/mms/audio'
}
export const NEWSLETTER_MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
image: '/newsletter/newsletter-image',
video: '/newsletter/newsletter-video',
document: '/newsletter/newsletter-document',
audio: '/newsletter/newsletter-audio',
sticker: '/newsletter/newsletter-image',
'thumbnail-link': '/newsletter/newsletter-image'
} }
export const MEDIA_HKDF_KEY_MAPPING = { export const MEDIA_HKDF_KEY_MAPPING = {
@@ -208,11 +155,7 @@ export const MEDIA_HKDF_KEY_MAPPING = {
ptv: 'Video', ptv: 'Video',
'biz-cover-photo': 'Image', 'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack', 'sticker-pack': 'Sticker Pack',
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail', 'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
// View-once types — same HKDF keys as regular types (recipients decrypt with normal keys)
'viewonce-image': 'Image',
'viewonce-video': 'Video',
'viewonce-audio': 'Audio'
} }
export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING export type MediaType = keyof typeof MEDIA_HKDF_KEY_MAPPING
@@ -222,12 +165,10 @@ export const MEDIA_KEYS = Object.keys(MEDIA_PATH_MAP) as MediaType[]
/** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */ /** 120s timeout for history sync stall detection, same as WA Web's handleChunkProgress / restartPausedTimer (g = 120) */
export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000 export const HISTORY_SYNC_PAUSED_TIMEOUT_MS = 120_000
// Replenishment threshold: when server count drops below this, top-up back to INITIAL_PREKEY_COUNT export const MIN_PREKEY_COUNT = 5
export const MIN_PREKEY_COUNT = 200
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration) // Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
// Rounded to 800 for cleanliness; replenishment always tops up to this value export const INITIAL_PREKEY_COUNT = 200
export const INITIAL_PREKEY_COUNT = 800
export const UPLOAD_TIMEOUT = 30000 // 30 seconds export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// Moderate upload interval to balance rate limiting and responsiveness (was 5000) // Moderate upload interval to balance rate limiting and responsiveness (was 5000)
+13 -79
View File
@@ -302,25 +302,6 @@ export function makeLibSignalRepository(
// Promise instead of each spawning their own DB transactions. // Promise instead of each spawning their own DB transactions.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>() 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 = { const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) { decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid) const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -415,24 +396,23 @@ export function makeLibSignalRepository(
return result return result
} }
// Use canonical JID (PN→LID resolved) as transaction key to prevent // If it's not a sync message, we need to ensure atomicity
// PN/LID race conditions on the same logical session. // For regular messages, we use a transaction to ensure atomicity
const canonicalJid = await resolveCanonicalJid(jid)
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
return await doDecrypt() return await doDecrypt()
}, canonicalJid) }, jid)
}, },
async encryptMessage({ jid, data }) { async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid) const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr) const cipher = new libsignal.SessionCipher(storage, addr)
const canonicalJid = await resolveCanonicalJid(jid) // Use transaction to ensure atomicity
return parsedKeys.transaction(async () => { return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data) const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg' const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') } return { type, ciphertext: Buffer.from(body, 'binary') }
}, canonicalJid) }, jid)
}, },
async encryptGroupMessage({ group, meId, data }) { async encryptGroupMessage({ group, meId, data }) {
@@ -674,10 +654,9 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery) // Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession) const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) { if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, retain PN session. // Queue for bulk update: copy to LID, delete from PN
// 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[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++ migratedCount++
} }
@@ -797,13 +776,6 @@ function signalStorage(
return id 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 { return {
loadSession: async (id: string) => { loadSession: async (id: string) => {
try { try {
@@ -836,26 +808,7 @@ function signalStorage(
} }
} }
}, },
removePreKey: (id: number) => { removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
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: () => { loadSignedPreKey: () => {
const key = creds.signedPreKey const key = creds.signedPreKey
return { return {
@@ -945,24 +898,14 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices // IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey) 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({ await keys.set({
session: { [wireJid]: null }, session: { [wireJid]: null },
'identity-key': identityUpdates 'identity-key': { [wireJid]: identityKey }
}) })
// Update cache for both addresses // Update cache
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' }) metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -998,19 +941,10 @@ function signalStorage(
if (!existingKey) { if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU) // NEW CONTACT - Trust On First Use (TOFU)
// Store in both LID and PN addresses (aligned with WABA dual identity storage) await keys.set({ 'identity-key': { [wireJid]: identityKey } })
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({ 'identity-key': identityUpdates }) // Update cache
// Update cache for both addresses
identityKeyCache.set(wireJid, identityKey) identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics // Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' }) metrics.signalIdentityChanges?.inc({ type: 'new' })
+30 -195
View File
@@ -54,7 +54,7 @@ import {
resolveLidToPn resolveLidToPn
} from '../Utils' } from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex' import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage, { getChatId } from '../Utils/process-message' import processMessage from '../Utils/process-message'
import { buildTcTokenFromJid } from '../Utils/tc-token-utils' import { buildTcTokenFromJid } from '../Utils/tc-token-utils'
import { import {
type BinaryNode, type BinaryNode,
@@ -98,24 +98,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
let privacySettings: { [_: string]: string } | undefined let privacySettings: { [_: string]: string } | undefined
/**
* Server-assigned AB props that gate tctoken-related protocol behavior.
* Defaults match WA Web (safe — avoids spurious error 463 if the prop never
* arrives). Populated from `fetchProps()` on connection.
*
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
* include tctoken in 1:1 messages.
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
* include tctoken in profile picture IQs.
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
* issue privacy tokens to the contact's LID instead of the PN.
*/
const serverProps = {
privacyTokenOn1to1: true,
profilePicPrivacyToken: true,
lidTrustedTokenIssueToLid: false
}
let syncState: SyncState = SyncState.Connecting let syncState: SyncState = SyncState.Connecting
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */ /** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
@@ -130,18 +112,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
/** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */ /** this mutex ensures that notifications from the same chat are processed in order, while allowing parallel processing across chats */
const notificationMutex = makeKeyedMutex() const notificationMutex = makeKeyedMutex()
/**
* Per-chat mutex dedicated to post-upsert work (history app-state sync +
* processMessage side effects). Kept separate from `messageMutex` because
* the inbound caller already holds `messageMutex(chatId)` while running
* decrypt + upsertMessage; sharing the same mutex would let a concurrently-
* arrived message N+1 enqueue *between* msg N's outer callback and msg N's
* post-upsert task, so msg N+1's processMessage could run before msg N's
* (breaking per-chat ordering of side effects). With a separate mutex,
* post-upsert tasks enqueue strictly in upsertMessage call order.
*/
const postUpsertMutex = makeKeyedMutex()
// Timeout for AwaitingInitialSync state // Timeout for AwaitingInitialSync state
let awaitingSyncTimeout: NodeJS.Timeout | undefined let awaitingSyncTimeout: NodeJS.Timeout | undefined
@@ -821,10 +791,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid))) me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent let content: BinaryNode[] | undefined = baseContent
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq). if (isUserJid && !isSelf) {
// WA Web defaults to true; if the server flips it off, we mirror that to
// avoid divergence with the spec-compliant client.
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
content = await buildTcTokenFromJid({ content = await buildTcTokenFromJid({
authState, authState,
jid: normalizedJid, jid: normalizedJid,
@@ -1093,21 +1060,22 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
} }
/** fetch AB props */ /** sending non-abt props may fix QR scan fail if server expects */
const fetchProps = async () => { const fetchProps = async () => {
//TODO: implement both protocol 1 and protocol 2 prop fetching, specially for abKey for WM
const resultNode = await query({ const resultNode = await query({
tag: 'iq', tag: 'iq',
attrs: { attrs: {
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
xmlns: 'abt', xmlns: 'w',
type: 'get' type: 'get'
}, },
content: [ content: [
{ {
tag: 'props', tag: 'props',
attrs: { attrs: {
protocol: '1', protocol: '2',
...(authState?.creds?.lastPropHash ? { hash: authState.creds.lastPropHash } : {}) hash: authState?.creds?.lastPropHash || ''
} }
} }
] ]
@@ -1126,25 +1094,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
props = reduceBinaryNodeToDictionary(propsNode, 'prop') props = reduceBinaryNodeToDictionary(propsNode, 'prop')
} }
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc). logger.debug('fetched props')
// We accept both numeric IDs and human-readable names so the parser is resilient
// to upstream renaming and to future-versioned WA Web servers.
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
if (privacyTokenProp !== undefined) {
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
}
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
if (profilePicProp !== undefined) {
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
}
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
if (lidIssueProp !== undefined) {
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
}
logger.debug({ serverProps }, 'fetched props')
return props return props
} }
@@ -1411,19 +1361,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
blockedCollections.clear() blockedCollections.clear()
logger.info('Doing app state sync') logger.info('Doing app state sync')
try { await resyncAppState(ALL_WA_PATCH_NAMES, true)
await resyncAppState(ALL_WA_PATCH_NAMES, true)
} catch (err) {
// Failure recovery: without this, syncState would stay at Syncing
// and ev.flush() would never run, leaving the event buffer pinned
// until the buffer's own safety timeout expires. Force the state
// machine forward so live inbound events can flow even if the
// app-state resync failed (collections are already cleared, so
// blocked patches will be retried on the next creds.update tick).
syncState = SyncState.Online
ev.flush()
throw err
}
// Sync is complete, go online and flush everything // Sync is complete, go online and flush everything
syncState = SyncState.Online syncState = SyncState.Online
@@ -1435,131 +1373,29 @@ export const makeChatsSocket = (config: SocketConfig) => {
} }
} }
// Post-upsert work: history app-state sync + processMessage side effects. await Promise.all([
// Awaiting here keeps `messages.upsert` pinned in the event buffer (async () => {
// (createBufferedFunction only schedules flush after work() resolves), so if (shouldProcessHistoryMsg) {
// the hot path detaches this work to release the emit on the next debounce
// tick.
//
// Use Promise.allSettled so the combined promise only settles after BOTH
// tasks finish. With a plain Promise.all, an early rejection from one task
// would release the keyed mutex while the other task is still mutating
// chat state — letting the next message of the same chat overtake it and
// break per-chat ordering.
//
// Returns the per-task settle status so the keyShare branch can know
// whether processMessage actually persisted the new app-state-sync key
// before triggering doAppStateSync (otherwise the sync would hit
// isMissingKeyError and park collections in blockedCollections).
const postUpsertTasks = async (): Promise<{ processMessageOk: boolean }> => {
const [historyResult, processResult] = await Promise.allSettled([
shouldProcessHistoryMsg ? doAppStateSync() : Promise.resolve(),
processMessage(msg, {
signalRepository,
shouldProcessHistoryMsg,
placeholderResendCache,
ev,
creds: authState.creds,
keyStore: authState.keys,
logger,
options: config.options,
getMessage
})
])
if (historyResult.status === 'rejected') {
logger?.warn(
{ err: historyResult.reason, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'history doAppStateSync failed'
)
}
if (processResult.status === 'rejected') {
logger?.warn(
{ err: processResult.reason, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'processMessage failed'
)
}
return { processMessageOk: processResult.status === 'fulfilled' }
}
// Use getChatId + jidNormalizedUser so the mutex key matches the chat-id
// scheme processMessage uses for chat updates (broadcasts target the
// participant). When getChatId/jidNormalizedUser yields nothing usable
// (missing or malformed JID), prefer a message-derived fallback over a
// single global 'unknown' bucket — that bucket would head-of-line block
// every malformed message behind a shared queue. msg.key.id is unique
// per message so unrelated malformed inputs no longer serialize together;
// for valid messages we still hit the normalized chat-id path, so the
// per-chat ordering guarantee is unchanged where it matters.
const rawChatId = getChatId(msg.key)
const normalizedChatId = rawChatId ? jidNormalizedUser(rawChatId) : ''
const postUpsertChatId = normalizedChatId || msg.key?.id || 'unknown'
// Wrap in `postUpsertMutex(chatId)` (a SEPARATE keyed mutex from the outer
// `messageMutex` held by the inbound caller) so per-chat ordering of
// processMessage side effects (chat.unreadCount, LID/PN mapping,
// messages.update, history downloads) is preserved across messages of the
// same chat.
//
// Why a separate mutex: if we re-used messageMutex, a concurrently-arrived
// message N+1 could enqueue on the outer mutex BEFORE msg N's post-upsert
// task gets enqueued (because N's outer callback yields on `await decrypt()`
// before reaching the inner enqueue site). The queue would then be
// [OuterN+1, InnerN, ...], so InnerN+1 would beat InnerN to processMessage.
// With its own mutex, post-upsert tasks enqueue strictly in upsertMessage
// call order (which IS message arrival order because the outer
// messageMutex serializes the upserts per-chat).
const postUpsertWork = postUpsertMutex.mutex(postUpsertChatId, postUpsertTasks)
const isKeyShareDuringSync =
!!msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing
if (isKeyShareDuringSync) {
// appStateSyncKeyShare path: processMessage persists the new app-state-sync
// key in its APP_STATE_SYNC_KEY_SHARE handler (via keyStore.transaction).
// The follow-up doAppStateSync() needs that key to decrypt patches, so
// we MUST wait for processMessage to actually succeed before kicking off
// the sync — otherwise it would hit isMissingKeyError and park
// collections in blockedCollections, regressing the very issue this
// branch was added to fix.
//
// No deadlock with the inbound caller's messageMutex because
// postUpsertMutex is a different mutex instance.
logger.info('App state sync key arrived, awaiting persistence before triggering sync')
const { processMessageOk } = await postUpsertWork
if (!processMessageOk) {
logger?.warn(
{ messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'processMessage failed during key-share — skipping doAppStateSync to avoid isMissingKeyError'
)
} else {
try {
await doAppStateSync() await doAppStateSync()
} catch (err) {
logger?.warn(
{ err, messageId: msg.key?.id, remoteJid: msg.key?.remoteJid },
'doAppStateSync failed after key-share persistence'
)
} }
} })(),
} else { processMessage(msg, {
// `postUpsertWork` is not expected to reject — `Promise.allSettled` signalRepository,
// inside `postUpsertTasks` never rejects, and per-task failures are shouldProcessHistoryMsg,
// already logged inline. The defensive catch routes any truly placeholderResendCache,
// unexpected rejection (e.g. `postUpsertMutex` internal corruption, ev,
// future synchronous throws inside processMessage) through creds: authState.creds,
// `onUnexpectedError` instead of letting it surface as an keyStore: authState.keys,
// UnhandledPromiseRejection — which on Node ≥15 can terminate the logger,
// long-running socket process. options: config.options,
postUpsertWork.catch(err => getMessage
onUnexpectedError( })
err, ])
`processing post-upsert work for message ${msg.key?.id || 'unknown'} on ${msg.key?.remoteJid || 'unknown chat'}`
) // If the app state key arrives and we are waiting to sync, trigger the sync now.
) if (msg.message?.protocolMessage?.appStateSyncKeyShare && syncState === SyncState.Syncing) {
logger.info('App state sync key arrived, triggering app state sync')
await doAppStateSync()
} }
}) })
@@ -1776,7 +1612,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
return { return {
...sock, ...sock,
serverProps,
createCallLink, createCallLink,
getBotListV2, getBotListV2,
messageMutex, messageMutex,
-6
View File
@@ -343,13 +343,11 @@ export const extractGroupMetadata = (result: BinaryNode) => {
let descId: string | undefined let descId: string | undefined
let descOwner: string | undefined let descOwner: string | undefined
let descOwnerPn: string | undefined let descOwnerPn: string | undefined
let descOwnerUsername: string | undefined
let descTime: number | undefined let descTime: number | undefined
if (descChild) { if (descChild) {
desc = getBinaryNodeChildString(descChild, 'body') desc = getBinaryNodeChildString(descChild, 'body')
descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined
descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined
descOwnerUsername = descChild.attrs.participant_username || undefined
descTime = +descChild.attrs.t! descTime = +descChild.attrs.t!
descId = descChild.attrs.id descId = descChild.attrs.id
} }
@@ -364,19 +362,16 @@ export const extractGroupMetadata = (result: BinaryNode) => {
subject: group.attrs.subject!, subject: group.attrs.subject!,
subjectOwner: group.attrs.s_o, subjectOwner: group.attrs.s_o,
subjectOwnerPn: group.attrs.s_o_pn, subjectOwnerPn: group.attrs.s_o_pn,
subjectOwnerUsername: group.attrs.s_o_username,
subjectTime: +(group.attrs.s_t ?? '0'), subjectTime: +(group.attrs.s_t ?? '0'),
size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length, size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length,
creation: +(group.attrs.creation ?? '0'), creation: +(group.attrs.creation ?? '0'),
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined, owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined, ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined,
ownerUsername: group.attrs.creator_username || undefined,
owner_country_code: group.attrs.creator_country_code, owner_country_code: group.attrs.creator_country_code,
desc, desc,
descId, descId,
descOwner, descOwner,
descOwnerPn, descOwnerPn,
descOwnerUsername,
descTime, descTime,
linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined, linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined,
restrict: !!getBinaryNodeChild(group, 'locked'), restrict: !!getBinaryNodeChild(group, 'locked'),
@@ -391,7 +386,6 @@ export const extractGroupMetadata = (result: BinaryNode) => {
id: attrs.jid!, id: attrs.jid!,
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined, lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
username: attrs.participant_username || attrs.username || undefined,
admin: (attrs.type || null) as GroupParticipant['admin'] admin: (attrs.type || null) as GroupParticipant['admin']
} }
}), }),
+128 -327
View File
@@ -7,7 +7,6 @@ import { proto } from '../../WAProto/index.js'
import { import {
DEFAULT_CACHE_TTLS, DEFAULT_CACHE_TTLS,
DEFAULT_SESSION_CLEANUP_CONFIG, DEFAULT_SESSION_CLEANUP_CONFIG,
INITIAL_PREKEY_COUNT,
KEY_BUNDLE_TYPE, KEY_BUNDLE_TYPE,
MIN_PREKEY_COUNT, MIN_PREKEY_COUNT,
PLACEHOLDER_MAX_AGE_SECONDS, PLACEHOLDER_MAX_AGE_SECONDS,
@@ -49,12 +48,9 @@ import {
getStatusFromReceiptType, getStatusFromReceiptType,
handleIdentityChange, handleIdentityChange,
hkdf, hkdf,
BAD_MAC_ERROR_TEXT,
DECRYPTION_RETRY_CONFIG,
MISSING_KEYS_ERROR_TEXT, MISSING_KEYS_ERROR_TEXT,
NACK_REASONS, NACK_REASONS,
NO_MESSAGE_FOUND_ERROR_TEXT, NO_MESSAGE_FOUND_ERROR_TEXT,
RetryReason,
normalizeKeyLidToPn, normalizeKeyLidToPn,
normalizeMessageJids, normalizeMessageJids,
resolveLidToPn, resolveLidToPn,
@@ -73,14 +69,7 @@ import {
recordMessageReceived, recordMessageReceived,
recordMessageRetry recordMessageRetry
} from '../Utils/prometheus-metrics.js' } from '../Utils/prometheus-metrics.js'
import { import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
buildMergedTcTokenIndexWrite,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../Utils/tc-token-utils'
import { import {
areJidsSameUser, areJidsSameUser,
type BinaryNode, type BinaryNode,
@@ -171,12 +160,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let sendActiveReceipts = false let sendActiveReceipts = false
// ======= tctoken index tracking for cross-session pruning ======= // ======= tctoken index tracking for cross-session pruning =======
// TC_TOKEN_INDEX_KEY is imported from tc-token-utils so the value stays in sync const TC_TOKEN_INDEX_KEY = '__index'
// with messages-send/process-message writes (avoids string drift on rename).
// Race note: this prune-driven index write may interleave with
// buildMergedTcTokenIndexWrite calls from issuance/history-sync paths. Worst
// case: a JID resurrected by a stale read gets pruned again on the next 24h
// sweep — no data loss, just one extra cycle.
const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts' const TC_TOKEN_PRUNE_TS_KEY = '__prune_ts'
const tcTokenKnownJids = new Set<string>() const tcTokenKnownJids = new Set<string>()
const tcTokenRetriedMsgIds = new Set<string>() const tcTokenRetriedMsgIds = new Set<string>()
@@ -210,25 +194,16 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
})() })()
/** /** Debounced save of the tctoken JID index (5s) */
* Debounced save of the tctoken JID index (5s).
*
* Merges with the persisted index instead of overwriting other layers
* (messages-send fire-and-forget issuance, process-message history sync) may
* write JIDs to the index via `buildMergedTcTokenIndexWrite` without updating
* `tcTokenKnownJids`. Without the merge those JIDs would be silently dropped
* the next time this debounced save fires.
*/
const scheduleTcTokenIndexSave = () => { const scheduleTcTokenIndexSave = () => {
if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer) if (tcTokenIndexSaveTimer) clearTimeout(tcTokenIndexSaveTimer)
tcTokenIndexSaveTimer = setTimeout(async () => { tcTokenIndexSaveTimer = setTimeout(async () => {
try { try {
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids) const arr = Array.from(tcTokenKnownJids)
await authState.keys.set({ await authState.keys.set({
tctoken: { tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: { [TC_TOKEN_INDEX_KEY]: {
...merged[TC_TOKEN_INDEX_KEY], token: Buffer.from(JSON.stringify(arr), 'utf8'),
timestamp: unixTimestampSeconds().toString() timestamp: unixTimestampSeconds().toString()
} }
} }
@@ -1237,7 +1212,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return await query(stanza) return await query(stanza)
} }
const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false, decryptionError?: string) => { const sendRetryRequest = async (node: BinaryNode, forceIncludeKeys = false) => {
const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '') const { fullMessage } = decodeMessageNode(node, authState.creds.me!.id, authState.creds.me!.lid || '')
const { key: msgKey } = fullMessage const { key: msgKey } = fullMessage
const msgId = msgKey.id! const msgId = msgKey.id!
@@ -1335,39 +1310,39 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
const fromJid = node.attrs.from! const fromJid = node.attrs.from!
// Derive the Signal error code from the actual decryption failure message. // Check if we should recreate the session
// Sent in the retry receipt so the peer (even another InfiniteAPI instance) let shouldRecreateSession = false
// knows the exact failure type and can recreate the session immediately let recreateReason = ''
// instead of falling back to the 1-hour timeout.
// if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
// Codes mirror RetryReason enum in message-retry-manager.ts: try {
// 0 = UnknownError | 1 = NoSession | 2 = InvalidKey // Check if we have a session with this JID
// 3 = InvalidKeyId | 4 = InvalidMessage | 7 = BadMac const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
// const hasSession = await signalRepository.validateSession(fromJid)
// Uses DECRYPTION_RETRY_CONFIG error lists (single source of truth in
// decode-wa-message.ts) so additions to those lists are picked up here // Extract error code from retry node if present (for MAC error detection)
// automatically. const retryNode = getBinaryNodeChild(node, 'retry')
// const errorAttr = retryNode?.attrs?.error
// NOTE: We do NOT delete the session here (receiver side). The Signal Protocol const errorCode = messageRetryManager.parseRetryErrorCode(errorAttr)
// recovers automatically when the sender's pkmsg arrives — it overwrites the
// corrupted session. Deleting prematurely creates a race window where no session const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists, errorCode)
// exists, which can cause "No Session" errors on concurrent messages. shouldRecreateSession = result.recreate
const retryErrorCode = (() => { recreateReason = result.reason
if (!decryptionError) return RetryReason.UnknownError
// Bad MAC must be checked first — it is also in corruptedSessionErrors if (shouldRecreateSession) {
// but warrants the more specific code 7 over the generic code 4. logger.debug({ fromJid, retryCount, reason: recreateReason, errorCode }, 'recreating session for retry')
if (decryptionError.includes(BAD_MAC_ERROR_TEXT)) return RetryReason.SignalErrorBadMac // Delete existing session to force recreation
// MessageCounterError and other corrupted-session variants // CRITICAL: Use same transaction key as encrypt/decrypt operations to prevent race
if (DECRYPTION_RETRY_CONFIG.corruptedSessionErrors.some(e => decryptionError.includes(e))) return RetryReason.SignalErrorInvalidMessage // Using meId ensures this delete serializes with sendMessage() and other session operations
// Missing / invalid session record await authState.keys.transaction(async () => {
if (DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(e => decryptionError.includes(e)) || await authState.keys.set({ session: { [sessionId]: null } })
/no\s+(open\s+)?sessions?/i.test(decryptionError)) return RetryReason.SignalErrorNoSession }, authState.creds.me?.id || 'session-operation')
// PreKey / key-id errors forceIncludeKeys = true
if (/pre\s*key/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKeyId }
// Identity / key errors } catch (error) {
if (/invalid\s*key|untrusted\s*identity/i.test(decryptionError)) return RetryReason.SignalErrorInvalidKey logger.warn({ error, fromJid }, 'failed to check session recreation')
return RetryReason.UnknownError }
})() }
if (retryCount <= 2) { if (retryCount <= 2) {
// Use new retry manager for phone requests if available // Use new retry manager for phone requests if available
@@ -1408,7 +1383,8 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
id: node.attrs.id!, id: node.attrs.id!,
t: node.attrs.t!, t: node.attrs.t!,
v: '1', v: '1',
error: retryErrorCode.toString() // ADD ERROR FIELD
error: '0'
} }
}, },
{ {
@@ -1427,7 +1403,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
receipt.attrs.participant = node.attrs.participant receipt.attrs.participant = node.attrs.participant
} }
if (retryCount > 1 || forceIncludeKeys) { if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
const { update, preKeys } = await getNextPreKeys(authState, 1) const { update, preKeys } = await getNextPreKeys(authState, 1)
const [keyId] = Object.keys(preKeys) const [keyId] = Object.keys(preKeys)
@@ -1455,58 +1431,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'sendRetryRequest') }, authState?.creds?.me?.id || 'sendRetryRequest')
} }
/**
* Fire-and-forget tctoken re-issuance after a peer's device identity changed.
* Mirrors WAWebSendTcTokenWhenDeviceIdentityChange runs in PARALLEL with the
* session refresh (not after it).
*
* Why parallel and not sequential:
* - WA Web invokes this BEFORE assertSessions to maximise the chance the contact
* has a fresh tctoken by the time the next outbound send executes.
* - Running after assertSessions (the fork's previous behaviour) races with the
* next send and risks error 463 when the contact reinstalls and the user replies
* immediately afterwards.
*
* Gated on `entry.senderTimestamp` (we previously issued a token to this peer in the
* current bucket window). Preserves the existing senderTimestamp instead of issuing
* a fresh one keeps WA Web's bucket coalescing semantics: same token, same window.
*/
const reissueTcTokenAfterIdentityChange = (from: string): void => {
void (async () => {
const normalizedJid = jidNormalizedUser(from)
const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
const tcTokenData = await authState.keys.get('tctoken', [tcJid])
const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
return
}
logTcToken('reissue', { jid: normalizedJid, reason: 'identity_changed', senderTimestamp: senderTs })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
const issueJid = await resolveIssuanceJid(
normalizedJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
const result = await getPrivacyTokens([issueJid], senderTs)
await storeTcTokensFromIqResult({
result,
fallbackJid: tcJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: storedJid => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'identity_changed' })
})().catch(err => {
logTcToken('reissue_fail', { jid: from, error: err?.message })
})
}
const handleEncryptNotification = async (node: BinaryNode) => { const handleEncryptNotification = async (node: BinaryNode) => {
const from = node.attrs.from const from = node.attrs.from
if (from === S_WHATSAPP_NET) { if (from === S_WHATSAPP_NET) {
@@ -1516,8 +1440,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count') logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
if (shouldUploadMorePreKeys) { if (shouldUploadMorePreKeys) {
// Top-up back to INITIAL_PREKEY_COUNT so the pool is always restored to full size await uploadPreKeys()
await uploadPreKeys(Math.max(1, INITIAL_PREKEY_COUNT - count))
} }
} else { } else {
const result = await handleIdentityChange(node, { const result = await handleIdentityChange(node, {
@@ -1526,12 +1449,29 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
validateSession: signalRepository.validateSession, validateSession: signalRepository.validateSession,
assertSessions, assertSessions,
debounceCache: identityAssertDebounce, debounceCache: identityAssertDebounce,
logger, logger
// Fire reissue in parallel with the session refresh, NOT after it.
// See reissueTcTokenAfterIdentityChange doc for the rationale.
onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
}) })
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget
if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN)
.then(async tcJid => {
const tcData = await authState.keys.get('tctoken', [tcJid])
const entry = tcData[tcJid]
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 })
})
}
})
.catch(() => {
/* ignore resolution errors */
})
}
if (result.action === 'no_identity_node') { if (result.action === 'no_identity_node') {
logger.info({ node }, 'unknown encrypt notification') logger.info({ node }, 'unknown encrypt notification')
} }
@@ -1972,10 +1912,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
[storageJid]: { [storageJid]: {
...existing, ...existing,
token: Buffer.from(content), 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
} }
} }
}) })
@@ -2065,7 +2002,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
let shouldRecreateSession = false let shouldRecreateSession = false
let recreateReason = '' let recreateReason = ''
if (enableAutoSessionRecreation && messageRetryManager && retryCount >= 1) { if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
try { try {
const sessionId = signalRepository.jidToSignalProtocolAddress(participant) const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
@@ -2152,6 +2089,12 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (resolvedRemoteJid) key.remoteJid = resolvedRemoteJid if (resolvedRemoteJid) key.remoteJid = resolvedRemoteJid
if (resolvedParticipant) key.participant = resolvedParticipant if (resolvedParticipant) key.participant = resolvedParticipant
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignoring receipt from jid')
await sendMessageAck(node)
return
}
const ids = [attrs.id!] const ids = [attrs.id!]
if (Array.isArray(content)) { if (Array.isArray(content)) {
const items = getBinaryNodeChildren(content[0], 'item') const items = getBinaryNodeChildren(content[0], 'item')
@@ -2227,6 +2170,11 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleNotification = async (node: BinaryNode) => { const handleNotification = async (node: BinaryNode) => {
const remoteJid = node.attrs.from const remoteJid = node.attrs.from
if (shouldIgnoreJid(remoteJid!) && remoteJid !== S_WHATSAPP_NET) {
logger.trace({ remoteJid }, 'ignored notification')
await sendMessageAck(node)
return
}
try { try {
await Promise.all([ await Promise.all([
@@ -2240,7 +2188,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
fromMe, fromMe,
participant: node.attrs.participant, participant: node.attrs.participant,
participantAlt, participantAlt,
participantUsername: node.attrs.participant_username,
addressingMode, addressingMode,
id: node.attrs.id, id: node.attrs.id,
...(msg.key || {}) ...(msg.key || {})
@@ -2249,10 +2196,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
msg.messageTimestamp = +node.attrs.t! msg.messageTimestamp = +node.attrs.t!
const fullMsg = proto.WebMessageInfo.fromObject(msg) as WAMessage const fullMsg = proto.WebMessageInfo.fromObject(msg) as WAMessage
// Preserve custom WAMessageKey fields (participantAlt, participantUsername,
// addressingMode) that proto.WebMessageInfo.fromObject strips because
// they aren't part of the proto.MessageKey schema.
fullMsg.key = msg.key
await upsertMessage(fullMsg, 'append') await upsertMessage(fullMsg, 'append')
} }
}) })
@@ -2263,59 +2206,23 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
const handleMessage = async (node: BinaryNode) => { const handleMessage = async (node: BinaryNode) => {
const encNode = getBinaryNodeChild(node, 'enc') if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
const unavailableNode = getBinaryNodeChild(node, 'unavailable') logger.trace({ from: node.attrs.from }, 'ignored message')
// Send a clean ACK (no error code) so the server considers the
// msmsg block removed — allow msmsg decryption (required for view-once on linked devices) // message delivered. Using error 500 (UnhandledError) previously
if (unavailableNode?.attrs?.type === 'view_once') { // caused the server to retry delivery, generating duplicate traffic.
const { fullMessage: voMsg } = decryptMessageNode(
node,
authState.creds.me!.id,
authState.creds.me!.lid || '',
signalRepository,
logger,
)
voMsg.key.isViewOnce = true
// Try to request the actual view-once content from the primary phone via PDO
// The phone may resend the full media (url, mediaKey, directPath, etc.)
console.log(`[VO_PDO] Requesting view-once content via PDO for ${voMsg.key.id} from ${voMsg.key.remoteJid}`)
try {
const requestId = await requestPlaceholderResend(voMsg.key, {
key: { ...voMsg.key },
pushName: voMsg.pushName,
messageTimestamp: voMsg.messageTimestamp,
participant: voMsg.key.participant,
participantAlt: voMsg.key.participantAlt
})
if (requestId && requestId !== 'RESOLVED') {
console.log(`[VO_PDO] PDO request sent! requestId=${requestId} — waiting for phone to resend`)
} else if (requestId === 'RESOLVED') {
console.log(`[VO_PDO] Message arrived during PDO delay!`)
} else {
console.log(`[VO_PDO] Already requested or no response`)
}
} catch (err: any) {
console.log(`[VO_PDO] PDO request failed: ${err.message} — falling back to placeholder`)
}
// Emit placeholder immediately so consumers see the notification
// If PDO succeeds, the real message will arrive via messages.upsert later
voMsg.message = {
viewOnceMessage: {
message: {
imageMessage: {
viewOnce: true,
}
}
}
}
const upsertType = node.attrs.offline ? 'append' : 'notify'
await upsertMessage(voMsg, upsertType)
await sendMessageAck(node) await sendMessageAck(node)
return return
} }
const encNode = getBinaryNodeChild(node, 'enc')
// TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
if (encNode?.attrs.type === 'msmsg') {
logger.debug({ key: node.attrs.key }, 'ignored msmsg')
await sendMessageAck(node, NACK_REASONS.MissingMessageSecret)
return
}
const { const {
fullMessage: msg, fullMessage: msg,
category, category,
@@ -2330,54 +2237,44 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
) )
const alt = msg.key.participantAlt || msg.key.remoteJidAlt const alt = msg.key.participantAlt || msg.key.remoteJidAlt
// Handle LID/PN mappings with optimized hot-path: // Handle LID/PN mappings with hybrid approach:
// - storeLIDPNMappings is fire-and-forget (background) — does NOT block decrypt // - Store mapping operation runs in background (non-critical for decrypt)
// - migrateSession is SYNC (await) — REQUIRED for decrypt to find session // - Session migration MUST complete before decrypt() to avoid "No session record" errors
// // This addresses Codex/Copilot review concerns about race conditions with decrypt()
// SAFETY: normalizeMessageJids has a fast-path that uses key.*Alt directly without
// hitting the store, so the just-arrived message normalizes correctly even before
// the background store completes. Subsequent messages in the same chat hit the
// store after the background write is done (ms later).
//
// Pre-check (getPNForLID/getLIDForPN) was removed — storeLIDPNMappings has internal
// LRU cache + dedup, the pre-check was a redundant store round-trip per inbound
// message that added latency under load.
//
// HISTORICAL: this restores the intent of d73cd28d39 (2026-02-03) which was
// partially reverted by c3fc792351 the same day due to a race-condition concern
// with migrateSession (kept sync here). storeLIDPNMappings was over-protected:
// it persists a mapping that downstream consumers can re-derive from key.*Alt,
// while migrateSession actually moves the Signal session record that decrypt()
// will load microseconds later — those two have very different criticality.
//
// DO NOT make migrateSession async — decrypt() depends on the session being at
// the correct identifier (LID vs PN) when it runs. Other code paths (USync
// device lookup in messages-send.ts) create LID/PN mappings without migrating
// the session, so we cannot skip migration even when the mapping already exists.
if (!!alt) { if (!!alt) {
const altServer = jidDecode(alt)?.server const altServer = jidDecode(alt)?.server
const primaryJid = msg.key.participant || msg.key.remoteJid! const primaryJid = msg.key.participant || msg.key.remoteJid!
if (altServer === 'lid') { if (altServer === 'lid') {
// Fire-and-forget: storeLIDPNMappings has internal cache+dedup, // Check if mapping already exists to avoid unnecessary storage operations
// pre-check (getPNForLID) was redundant. const existingMapping = await signalRepository.lidMapping.getPNForLID(alt)
signalRepository.lidMapping if (!existingMapping) {
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]) // MUST await: normalizeMessageJids() runs after this and needs the mapping
.catch(error => logger.warn({ error, alt, primaryJid }, 'background LID mapping store failed')) // in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
}
// CRITICAL: ALWAYS migrate session SYNC, even if mapping exists. // CRITICAL: ALWAYS migrate session, even if mapping exists
// Other code paths (e.g., USync device lookup in messages-send.ts) may create // Other code paths (e.g., USync device lookup in messages-send.ts:310-319)
// mappings via storeLIDPNMappings() without calling migrateSession(). This // may create mappings via storeLIDPNMappings() without calling migrateSession()
// leaves sessions under PN format while decrypt() expects LID format. // This leaves sessions under PN format while decrypt() expects LID format
// Skipping migration based on mapping existence causes "No session record" errors. // Skipping migration based on mapping existence causes "No session record" errors
await signalRepository.migrateSession(primaryJid, alt) await signalRepository.migrateSession(primaryJid, alt)
} else { } else {
// Fire-and-forget: same rationale as above. // Check if reverse mapping exists
signalRepository.lidMapping const existingMapping = await signalRepository.lidMapping.getLIDForPN(alt)
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]) if (!existingMapping) {
.catch(error => logger.warn({ error, alt, primaryJid }, 'background LID mapping store failed')) // MUST await: normalizeMessageJids() runs after this and needs the mapping
// in the LIDMappingStore to resolve LID→PN for events delivered to consumers
await signalRepository.lidMapping
.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
.catch(error => logger.warn({ error, alt, primaryJid }, 'LID mapping storage failed'))
}
// CRITICAL: ALWAYS migrate session SYNC. // CRITICAL: ALWAYS migrate session, even if mapping exists
// Same reasoning as above - mapping existence doesn't guarantee session migration
await signalRepository.migrateSession(alt, primaryJid) await signalRepository.migrateSession(alt, primaryJid)
} }
} }
@@ -2439,23 +2336,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
{ msgId: msg.key?.id, messageType }, { msgId: msg.key?.id, messageType },
'CTWA: Skipping placeholder resend for unavailable fanout type' 'CTWA: Skipping placeholder resend for unavailable fanout type'
) )
// For view-once: emit the message with a viewOnceMessage placeholder
// so consumers (zpro, etc.) can detect and display it.
// Include a message wrapper so isValidMsg accepts it.
if (messageType === 'view_once_unavailable_fanout') {
msg.key.isViewOnce = true
msg.message = {
viewOnceMessage: {
message: {
imageMessage: {
viewOnce: true,
}
}
}
}
const upsertType = node.attrs.offline ? 'append' : 'notify'
await upsertMessage(msg, upsertType)
}
metrics.ctwaRecoveryFailures.inc({ reason: 'unavailable_fanout' }) metrics.ctwaRecoveryFailures.inc({ reason: 'unavailable_fanout' })
return sendMessageAck(node) return sendMessageAck(node)
} }
@@ -2594,7 +2474,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
} }
const encNode = getBinaryNodeChild(node, 'enc') const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode, errorMessage) await sendRetryRequest(node, !encNode)
if (retryRequestDelayMs) { if (retryRequestDelayMs) {
await delay(retryRequestDelayMs) await delay(retryRequestDelayMs)
} }
@@ -2603,7 +2483,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Still attempt retry even if pre-key upload failed // Still attempt retry even if pre-key upload failed
try { try {
const encNode = getBinaryNodeChild(node, 'enc') const encNode = getBinaryNodeChild(node, 'enc')
await sendRetryRequest(node, !encNode, errorMessage) await sendRetryRequest(node, !encNode)
} catch (retryErr) { } catch (retryErr) {
logger.error({ retryErr }, 'Failed to send retry after error handling') logger.error({ retryErr }, 'Failed to send retry after error handling')
} }
@@ -2637,11 +2517,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type) await sendReceipt(msg.key.remoteJid!, participant!, [msg.key.id!], type)
// NOTE: do NOT send view_once_read from a linked device (API).
// WA shows view-once as view_once_unavailable_fanout on linked devices — only the
// primary phone sends view_once_read when the user actually opens it.
// Sending it from a linked device causes WA to flag the account and disables view-once.
// send ack for history message // send ack for history message
const isAnyHistoryMsg = getHistoryMsg(msg.message!) const isAnyHistoryMsg = getHistoryMsg(msg.message!)
if (isAnyHistoryMsg) { if (isAnyHistoryMsg) {
@@ -2948,24 +2823,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const jid = jidNormalizedUser(attrs.from) const jid = jidNormalizedUser(attrs.from)
logTcToken('error_463', { jid, msgId }) 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, // Single-retry: wait 1.5s for the server's tctoken notification to arrive,
// then resend. A Set prevents infinite retry loops. // then resend. A Set prevents infinite retry loops.
// Composite key (jid:msgId) ensures retries are isolated per destination. // Composite key (jid:msgId) ensures retries are isolated per destination.
@@ -3001,24 +2858,7 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
return return
} }
} else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) { } else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
const jid479 = jidNormalizedUser(attrs.from) logTcToken('error_479', { jid: attrs.from, msgId: attrs.id })
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 { } else {
logger.warn({ attrs }, 'received error in ack') logger.warn({ attrs }, 'received error in ack')
} }
@@ -3126,40 +2966,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
identifier: string, identifier: string,
exec: (node: BinaryNode) => Promise<void> exec: (node: BinaryNode) => Promise<void>
) => { ) => {
// Fast path: ack and drop ignored JIDs before entering the buffer/queue.
// Skips type='call' so call events are never silently dropped via
// shouldIgnoreJid (preserves InfiniteAPI's pre-existing behavior).
// Wrapped in try/catch so a throw from shouldIgnoreJid (user callback)
// or sendMessageAck (e.g. websocket closed) is routed through
// onUnexpectedError instead of becoming an unhandled rejection —
// matches the protection processNodeWithBuffer provides.
if (type !== 'call') {
try {
const from = node.attrs.from
let ignoreJid = from
if (type === 'receipt' && from) {
const attrs = node.attrs
const isLid = attrs.from!.includes('lid')
const isNodeFromMe = areJidsSameUser(
attrs.participant || attrs.from,
isLid ? authState.creds.me?.lid : authState.creds.me?.id
)
ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient
}
if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) {
// Plain ack (no NACK error code) preserves InfiniteAPI's prior
// behavior — ignored stanzas are an intentional drop, not a
// processing failure, so we don't want server-side retries.
await sendMessageAck(node)
return
}
} catch (error) {
onUnexpectedError(error as Error, identifier)
return
}
}
const isOffline = !!node.attrs.offline const isOffline = !!node.attrs.offline
if (isOffline) { if (isOffline) {
@@ -3258,24 +3064,19 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
// Await index load first — prevents overwriting a more complete persisted index // Await index load first — prevents overwriting a more complete persisted index
// if the connection closes before the initial load finishes. // if the connection closes before the initial load finishes.
tcTokenIndexLoaded tcTokenIndexLoaded
.then(async () => { .then(() => {
try { Promise.resolve(
// Same merge-with-persisted invariant as scheduleTcTokenIndexSave — authState.keys.set({
// other layers may have written cross-layer JIDs to the index since
// our in-memory set was last updated.
const merged = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
await authState.keys.set({
tctoken: { tctoken: {
...merged,
[TC_TOKEN_INDEX_KEY]: { [TC_TOKEN_INDEX_KEY]: {
...merged[TC_TOKEN_INDEX_KEY], token: Buffer.from(JSON.stringify([...tcTokenKnownJids]), 'utf8'),
timestamp: unixTimestampSeconds().toString() timestamp: unixTimestampSeconds().toString()
} }
} }
}) })
} catch { ).catch(() => {
/* non-critical */ /* non-critical */
} })
}) })
.catch(() => { .catch(() => {
/* non-critical */ /* non-critical */
+110 -395
View File
@@ -1,4 +1,3 @@
import { randomBytes } from 'crypto'
import NodeCache from '@cacheable/node-cache' import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom' import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
@@ -9,7 +8,6 @@ import type {
AlbumMessageOptions, AlbumMessageOptions,
AlbumSendResult, AlbumSendResult,
AnyMessageContent, AnyMessageContent,
LIDMapping,
MediaConnInfo, MediaConnInfo,
MessageReceiptType, MessageReceiptType,
MessageRelayOptions, MessageRelayOptions,
@@ -46,10 +44,7 @@ import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics' import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils' import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import { import {
buildMergedTcTokenIndexWrite,
isRegularUser,
isTcTokenExpired, isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid, resolveTcTokenJid,
shouldSendNewTcToken, shouldSendNewTcToken,
storeTcTokensFromIqResult storeTcTokensFromIqResult
@@ -128,15 +123,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches // Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches
const tcTokenFetchingJids = new Set<string>() const tcTokenFetchingJids = new Set<string>()
/**
* Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
* Distinct from `tcTokenFetchingJids` (which dedupes inbound *fetches* of the peer's
* token). Prevents duplicate IQs when a caller fires several rapid back-to-back sends
* to the same contact before `senderTimestamp` persists. Entries are always removed
* in `.finally()`, so the set is bounded by current concurrency.
*/
const inFlightTcTokenIssuance = new Set<string>()
let mediaConn: Promise<MediaConnInfo> let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async (forceGet = false) => { const refreshMediaConn = async (forceGet = false) => {
const media = await mediaConn const media = await mediaConn
@@ -351,33 +337,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
} }
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || '' const meLid = authState.creds.me?.lid || ''
@@ -548,38 +507,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return didFetchNewSession return didFetchNewSession
} }
const canonicalizeCarouselRecipients = async (recipientJids: string[]): Promise<string[]> => {
if (!recipientJids.length) {
return recipientJids
}
const pnRecipients = [...new Set(recipientJids.filter(jid => isAnyPnUser(jid)))]
if (!pnRecipients.length) {
return recipientJids
}
const mappings = (await signalRepository.lidMapping.getLIDsForPNs(pnRecipients)) || []
if (!mappings.length) {
logger.debug({ recipientCount: recipientJids.length }, '[CAROUSEL] No PN→LID recipient mappings found')
return recipientJids
}
const pnToLid = new Map(mappings.map(item => [item.pn, item.lid]))
const mapped = recipientJids.map(jid => pnToLid.get(jid) || jid)
const changed = mapped.filter((jid, index) => jid !== recipientJids[index]).length
logger.info(
{
recipientCount: recipientJids.length,
pnRecipients: pnRecipients.length,
mappedRecipients: changed
},
'[CAROUSEL] Canonicalized recipient addressing to LID before session assert/encrypt'
)
return mapped
}
const sendPeerDataOperationMessage = async ( const sendPeerDataOperationMessage = async (
pdoMessage: proto.Message.IPeerDataOperationRequestMessage pdoMessage: proto.Message.IPeerDataOperationRequestMessage
): Promise<string> => { ): Promise<string> => {
@@ -614,82 +541,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
return msgId return msgId
} }
const resolveDsmMessageForRecipient = (
jid: string,
patchedMessage: proto.IMessage,
dsmMessage: proto.IMessage | undefined,
meId: string,
meLid: string | undefined,
meLidUser: string | null | undefined
) => {
if (!dsmMessage) {
return patchedMessage
}
const { user: targetUser } = jidDecode(jid)!
const { user: ownPnUser } = jidDecode(meId)!
const isOwnUser = targetUser === ownPnUser || (meLidUser && targetUser === meLidUser)
const isExactSenderDevice = jid === meId || (meLid && jid === meLid)
if (isOwnUser && !isExactSenderDevice) {
logger.debug({ jid, targetUser }, 'Using DSM for own device')
return dsmMessage
}
return patchedMessage
}
const encryptPatchedMessageForRecipient = async ({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
}: {
jid: string
patchedMessage: proto.IMessage
dsmMessage: proto.IMessage | undefined
meId: string
meLid: string | undefined
meLidUser: string | null | undefined
extraAttrs: BinaryNode['attrs'] | undefined
onPkmsg: () => void
}) => {
if (!jid) return null
try {
const msgToEncrypt = resolveDsmMessageForRecipient(jid, patchedMessage, dsmMessage, meId, meLid, meLidUser)
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
return await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
onPkmsg()
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
} as BinaryNode
})
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
const createParticipantNodes = async ( const createParticipantNodes = async (
recipientJids: string[], recipientJids: string[],
message: proto.IMessage, message: proto.IMessage,
@@ -709,23 +560,58 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const meId = authState.creds.me?.id const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 }) if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid const meLid = authState.creds.me?.lid
const meLidUser = meLid ? (jidDecode(meLid)?.user ?? null) : null const meLidUser = meLid ? jidDecode(meLid)?.user : null
const onPkmsg = () => {
shouldIncludeDeviceIdentity = true
}
const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map( const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map(
({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => async ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => {
encryptPatchedMessageForRecipient({ try {
jid, if (!jid) return null
patchedMessage,
dsmMessage, let msgToEncrypt = patchedMessage
meId,
meLid, if (dsmMessage) {
meLidUser, const { user: targetUser } = jidDecode(jid)!
extraAttrs, const { user: ownPnUser } = jidDecode(meId)!
onPkmsg const ownLidUser = meLidUser
})
const isOwnUser = targetUser === ownPnUser || (ownLidUser && targetUser === ownLidUser)
const isExactSenderDevice = jid === meId || (meLid && jid === meLid)
if (isOwnUser && !isExactSenderDevice) {
msgToEncrypt = dsmMessage
logger.debug({ jid, targetUser }, 'Using DSM for own device')
}
}
const bytes = encodeWAMessage(msgToEncrypt)
const mutexKey = jid
const node = await encryptionMutex.mutex(mutexKey, async () => {
const { type, ciphertext } = await signalRepository.encryptMessage({ jid, data: bytes })
if (type === 'pkmsg') {
shouldIncludeDeviceIdentity = true
}
return {
tag: 'to',
attrs: { jid },
content: [
{
tag: 'enc',
attrs: { v: '2', type, ...(extraAttrs || {}) },
content: ciphertext
}
]
}
})
return node
} catch (err) {
logger.error({ jid, err }, 'Failed to encrypt for recipient')
return null
}
}
) )
const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[] const nodes = (await Promise.all(encryptionPromises)).filter(node => node !== null) as BinaryNode[]
@@ -1053,8 +939,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
await authState.keys.transaction(async () => { await authState.keys.transaction(async () => {
// normalizeMessageContent unwraps viewOnceMessage/viewOnceMessageV2 so getMediaType works correctly const mediaType = getMediaType(message)
const mediaType = getMediaType(normalizeMessageContent(message) || message)
if (mediaType) { if (mediaType) {
extraAttrs['mediatype'] = mediaType extraAttrs['mediatype'] = mediaType
} }
@@ -1064,7 +949,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage) const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({ binaryNodeContent.push({
tag: 'plaintext', tag: 'plaintext',
attrs: mediaType ? { mediatype: mediaType } : {}, attrs: {},
content: bytes content: bytes
}) })
const stanza: BinaryNode = { const stanza: BinaryNode = {
@@ -1086,8 +971,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types extraAttrs['decrypt-fail'] = 'hide' // todo: expand for reactions and other types
} }
// view_once_write disabled — causes server to drop message (validation fails for linked device sessions)
if (isGroupOrStatus && !isRetryResend) { if (isGroupOrStatus && !isRetryResend) {
const [groupData, senderKeyMap] = await Promise.all([ const [groupData, senderKeyMap] = await Promise.all([
(async () => { (async () => {
@@ -1283,57 +1166,21 @@ export const makeMessagesSocket = (config: SocketConfig) => {
allRecipients.push(jid) allRecipients.push(jid)
} }
const isCarouselFanout = isCarouselMessage(message) await assertSessions(allRecipients)
const effectiveMeRecipients = isCarouselFanout
? await canonicalizeCarouselRecipients(meRecipients)
: meRecipients
const effectiveOtherRecipients = isCarouselFanout
? await canonicalizeCarouselRecipients(otherRecipients)
: otherRecipients
const effectiveAllRecipients = [...effectiveMeRecipients, ...effectiveOtherRecipients]
// P2: detect actual view-once media by checking inner message's viewOnce flag.
// viewOnceMessage wrapper is also used for interactive messages (buttons, lists, etc)
// which do NOT carry viewOnce=true on the media — those must NOT be filtered.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// For view-once: only send DSM to primary phone (device=0).
// Companion devices (device>0) are omitted — WA server generates
// <unavailable type="view_once"/> for them automatically.
// Sending explicit <unavailable> from a companion is rejected by the server.
const viewOnceMeRecipients = isViewOnceMsg
? effectiveMeRecipients.filter(jid => !jidDecode(jid)?.device)
: effectiveMeRecipients
// P3: assert sessions only for recipients we actually encrypt for.
// For view-once, companions are omitted — asserting their sessions is wasteful
// and could block the send if a companion session is corrupted.
await assertSessions([...viewOnceMeRecipients, ...effectiveOtherRecipients])
const [ const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 }, { nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 } { nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
] = await Promise.all([ ] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs), // For own devices: use DSM (deviceSentMessage) wrapper
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs) createParticipantNodes(meRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
]) ])
participants.push(...meNodes) participants.push(...meNodes)
participants.push(...otherNodes) participants.push(...otherNodes)
// phash: para view-once inclui só celular principal + destinatários if (meRecipients.length > 0 || otherRecipients.length > 0) {
const phashRecipients = isViewOnceMsg extraAttrs['phash'] = generateParticipantHashV2([...meRecipients, ...otherRecipients])
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
: effectiveAllRecipients
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
} }
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2 shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
@@ -1406,15 +1253,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const isCarousel = isCarouselMessage(message) const isCarousel = isCarouselMessage(message)
// Collect biz/bot nodes to append AFTER device-identity and tctoken // Collect biz/bot nodes to append AFTER device-identity and tctoken
// Stanza order: participants → device-identity → tctoken → biz (when applicable) // Stanza order: participants → device-identity → tctoken → biz
// CDP capture confirms Pastorini INCLUDES biz node for carousel (native_flow v=9, name=mixed) // The biz node MUST be last for WhatsApp Web carousel rendering
const deferredNodes: BinaryNode[] = [] const deferredNodes: BinaryNode[] = []
// Inject biz node for interactive messages (including carousel — CDP evidence from Pastorini) // Inject biz node for interactive messages (including carousel)
if ((buttonType || isCarousel) && enableInteractiveMessages) { if (buttonType && enableInteractiveMessages) {
const startTime = Date.now() const startTime = Date.now()
// When entering via isCarousel, buttonType may be undefined — default to 'native_flow'
const effectiveButtonType = buttonType || 'native_flow'
// Debug: Log message structure to diagnose list detection // Debug: Log message structure to diagnose list detection
const interactiveMsg = const interactiveMsg =
@@ -1452,7 +1297,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
) )
// Track that we're sending an interactive message // Track that we're sending an interactive message
metrics.interactiveMessagesSent.inc({ type: effectiveButtonType }) metrics.interactiveMessagesSent.inc({ type: buttonType })
try { try {
// Classify button types for native_flow name and bot node decisions // Classify button types for native_flow name and bot node decisions
@@ -1495,49 +1340,27 @@ export const makeMessagesSocket = (config: SocketConfig) => {
) )
const interactiveType = 'native_flow' const interactiveType = 'native_flow'
const bizContent: BinaryNode[] = [
{
tag: 'interactive',
attrs: {
type: interactiveType,
v: '1'
},
content: [
{
tag: interactiveType,
attrs: {
v: '9',
name: nativeFlowName
}
}
]
}
]
// CDP capture shows Pastorini includes quality_control inside biz for carousel
if (isCarousel) {
const decisionId = randomBytes(20).toString('hex')
bizContent.push({
tag: 'quality_control',
attrs: {
decision_id: decisionId
},
content: [
{
tag: 'decision_source',
attrs: {
value: 'df'
}
}
]
})
logger.info({ msgId, decisionId }, '[BIZ NODE] Added quality_control for carousel')
}
deferredNodes.push({ deferredNodes.push({
tag: 'biz', tag: 'biz',
attrs: {}, attrs: {},
content: bizContent content: [
{
tag: 'interactive',
attrs: {
type: interactiveType,
v: '1'
},
content: [
{
tag: interactiveType,
attrs: {
v: '9',
name: nativeFlowName
}
}
]
}
]
}) })
} }
@@ -1563,11 +1386,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
// Track success and latency after message is sent // Track success and latency after message is sent
metrics.interactiveMessagesSuccess.inc({ type: effectiveButtonType }) metrics.interactiveMessagesSuccess.inc({ type: buttonType })
metrics.interactiveMessagesLatency.observe({ type: effectiveButtonType }, Date.now() - startTime) metrics.interactiveMessagesLatency.observe({ type: buttonType }, Date.now() - startTime)
} catch (error) { } catch (error) {
logger.error({ error, msgId, buttonType: effectiveButtonType }, '[BIZ NODE] Failed to inject biz node') logger.error({ error, msgId, buttonType }, '[BIZ NODE] Failed to inject biz node')
metrics.interactiveMessagesFailures.inc({ type: effectiveButtonType, reason: 'injection_failed' }) metrics.interactiveMessagesFailures.inc({ type: buttonType, reason: 'injection_failed' })
} }
} else if (buttonType && !enableInteractiveMessages) { } else if (buttonType && !enableInteractiveMessages) {
logger.warn( logger.warn(
@@ -1594,8 +1417,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
stanza.attrs.to = destinationJid stanza.attrs.to = destinationJid
} }
// Always include device-identity for carousel (Pastorini stanza always has it) if (shouldIncludeDeviceIdentity) {
if (shouldIncludeDeviceIdentity || isCarousel) {
;(stanza.content as BinaryNode[]).push({ ;(stanza.content as BinaryNode[]).push({
tag: 'device-identity', tag: 'device-identity',
attrs: {}, attrs: {},
@@ -1652,91 +1474,30 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
} }
// If tctoken is missing for a 1:1 send, fetch it // If tctoken is missing for a 1:1 send, fire-and-forget fetch so the
// CDP capture confirms Pastorini stanza includes tctoken for carousel // retry path (error 463 → handleBadAck) can pick it up on resend
if (!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) { if (!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) {
tcTokenFetchingJids.add(tcTokenJid) tcTokenFetchingJids.add(tcTokenJid)
logTcToken('fetch', { jid: destinationJid }) logTcToken('fetch', { jid: destinationJid })
getPrivacyTokens([destinationJid])
if (isCarousel) { .then(async fetchResult => {
// BLOCKING fetch for carousel — tctoken is required (Pastorini stanza has it)
try {
const fetchResult = await getPrivacyTokens([destinationJid])
// Direct extraction from IQ result — bypass store/read key mismatch
const tokensNode = getBinaryNodeChild(fetchResult, 'tokens')
if (tokensNode) {
const tokenNodes = getBinaryNodeChildren(tokensNode, 'token')
for (const tokenNode of tokenNodes) {
if (tokenNode.attrs.type === 'trusted_contact' && tokenNode.content instanceof Uint8Array) {
tcTokenBuffer = Buffer.from(tokenNode.content)
logger.info(
{
jid: destinationJid,
tokenLen: tcTokenBuffer.length,
tokenJid: tokenNode.attrs.jid,
timestamp: tokenNode.attrs.t
},
'[CAROUSEL] tctoken extracted directly from IQ result'
)
break
}
}
}
if (!tcTokenBuffer?.length) {
// Debug: dump the IQ result structure
const childTags = Array.isArray(fetchResult.content)
? (fetchResult.content as BinaryNode[]).map(n => `${n.tag}(${JSON.stringify(n.attrs)})`)
: []
logger.warn(
{ jid: destinationJid, resultTag: fetchResult.tag, resultAttrs: fetchResult.attrs, childTags },
'[CAROUSEL] tctoken fetch completed but NO valid token in IQ result'
)
}
// Also store for future use
await storeTcTokensFromIqResult({ await storeTcTokensFromIqResult({
result: fetchResult, result: fetchResult,
fallbackJid: destinationJid, fallbackJid: destinationJid,
keys: authState.keys, keys: authState.keys,
getLIDForPN getLIDForPN
}).catch(() => {}) // onNewJidStored not passed — the pruning index lives in messages-recv (higher layer)
} catch (err: any) { })
logger.warn({ jid: destinationJid, err: err?.message }, '[CAROUSEL] Blocking tctoken fetch failed') })
} finally { .catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
tcTokenFetchingJids.delete(tcTokenJid) tcTokenFetchingJids.delete(tcTokenJid)
} })
} else {
// Fire-and-forget for non-carousel
getPrivacyTokens([destinationJid])
.then(async fetchResult => {
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN
})
})
.catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
tcTokenFetchingJids.delete(tcTokenJid)
})
}
} }
// Gate inclusion on AB prop 10518 (privacy_token_sending_on_all_1_on_1_messages). if (tcTokenBuffer?.length) {
// WA Web defaults to true; if the server flips it off we mirror that to avoid
// divergence with the spec-compliant client.
//
// CARROUSEL EXCEPTION: Pastorini-validated carousel stanzas REQUIRE tctoken
// (CDP capture confirms it). If the AB prop ever flips off, dropping the
// tctoken from carousel would break rendering on Android. Carousel always
// includes the tctoken when one is available, regardless of the prop —
// matching the fork's existing behaviour pre-PR #2339.
if (tcTokenBuffer?.length && (sock.serverProps.privacyTokenOn1to1 || isCarousel)) {
;(stanza.content as BinaryNode[]).push({ ;(stanza.content as BinaryNode[]).push({
tag: 'tctoken', tag: 'tctoken',
attrs: {}, attrs: {},
@@ -1764,7 +1525,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.debug({ msgId }, `sending message to ${participants.length} devices`) logger.debug({ msgId }, `sending message to ${participants.length} devices`)
// ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel ======= // ======= PROTOBUF ROUNDTRIP TEST: Verify encoding preserves carousel =======
if (isCarousel) { // Only runs at debug level to avoid performance overhead in production
if (isCarousel && logger.level === 'debug') {
try { try {
const encoded = proto.Message.encode(message).finish() const encoded = proto.Message.encode(message).finish()
const decoded = proto.Message.decode(encoded) const decoded = proto.Message.decode(encoded)
@@ -1773,19 +1535,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const card0 = decodedInteractive?.carouselMessage?.cards?.[0] const card0 = decodedInteractive?.carouselMessage?.cards?.[0]
const card0Header = card0?.header const card0Header = card0?.header
logger.info( logger.debug(
{ {
msgId, msgId,
encodedSize: encoded.length, encodedSize: encoded.length,
messageKeys: Object.keys(message),
hasInteractive: !!message.interactiveMessage,
hasViewOnce: !!message.viewOnceMessage,
hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage, hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage,
cardsCount, cardsCount,
card0Title: card0Header?.title, card0Title: card0Header?.title,
card0HasImage: !!card0Header?.imageMessage, card0HasImage: !!card0Header?.imageMessage,
card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0, card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0
messageVersion: decodedInteractive?.carouselMessage?.messageVersion
}, },
'[ROUNDTRIP] Protobuf encode→decode verification' '[ROUNDTRIP] Protobuf encode→decode verification'
) )
@@ -1795,7 +1553,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
// ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging ======= // ======= PROTOCOL INTERCEPTOR: Dump complete stanza for debugging =======
if (buttonType || isCarousel) { // Only runs at debug level to avoid logging sensitive content in production
if ((buttonType || isCarousel) && logger.level === 'debug') {
const dumpBinaryNode = (node: BinaryNode, indent = 0): string => { const dumpBinaryNode = (node: BinaryNode, indent = 0): string => {
if (!node) return '' if (!node) return ''
const pad = ' '.repeat(indent) const pad = ' '.repeat(indent)
@@ -1836,39 +1595,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so // Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so
// issuance fires even when we don't yet hold a token (bucket boundary crossed). // issuance fires even when we don't yet hold a token (bucket boundary crossed).
// IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463. // IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463.
// if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
// WA Web also skips issuance for:
// - protocol messages (revoke, ephemeral settings, etc — see TcTokenChatAction)
// - PSA, bots, MetaAI (isRegularUser filter)
// and dedupes back-to-back issuances with `inFlightTcTokenIssuance` so a burst of
// rapid sends to the same contact only triggers a single IQ before senderTimestamp
// is persisted.
const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage
// Use isRegularUser (the same Wid.isRegularUser() port that gates the store
// path) so we filter PSA/bot/MetaAI consistently regardless of JID server
// (@c.us vs @s.whatsapp.net) and device suffix. The previous PSA_WID/isJidBot
// checks only matched @c.us forms — destinationJid arrives normalized.
const isBotOrPSA = !isRegularUser(destinationJid)
if (
is1on1Send &&
!isProtocolMsg &&
!isBotOrPSA &&
shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
!inFlightTcTokenIssuance.has(tcTokenJid)
) {
inFlightTcTokenIssuance.add(tcTokenJid)
const issueTimestamp = unixTimestampSeconds() const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid }) logTcToken('reissue', { jid: destinationJid })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping) getPrivacyTokens([destinationJid], issueTimestamp)
resolveIssuanceJid(
destinationJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
.then(issueJid => getPrivacyTokens([issueJid], issueTimestamp))
.then(async result => { .then(async result => {
// Store any tokens received in the IQ response. // Store any tokens received in the IQ response.
// onNewJidStored not passed — pruning index lives in messages-recv (higher layer).
await storeTcTokensFromIqResult({ await storeTcTokensFromIqResult({
result, result,
fallbackJid: tcTokenJid, fallbackJid: tcTokenJid,
@@ -1878,21 +1611,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table // Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present. // 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.
// Also bump the cross-session prune index so this JID is tracked persistently.
const currentData = await authState.keys.get('tctoken', [tcTokenJid]) const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid] const currentEntry = currentData[tcTokenJid]
const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
await authState.keys.set({ await authState.keys.set({
tctoken: { tctoken: {
[tcTokenJid]: { [tcTokenJid]: {
...currentEntry, ...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0), token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp, senderTimestamp: issueTimestamp
realIssueTimestamp: 0 }
},
...indexWrite
} }
}) })
@@ -1901,9 +1628,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
.catch(err => { .catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message }) logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
}) })
.finally(() => {
inFlightTcTokenIssuance.delete(tcTokenJid)
})
} }
// Log with [BAILEYS] prefix // Log with [BAILEYS] prefix
@@ -1980,15 +1704,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
} }
const getMediaType = (message: proto.IMessage) => { const getMediaType = (message: proto.IMessage) => {
// For view-once media, unwrap the viewOnceMessage wrapper before checking media type
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) { if (message.imageMessage) {
return 'image' return 'image'
} else if (message.videoMessage) { } else if (message.videoMessage) {
+2 -2
View File
@@ -123,11 +123,11 @@ export const makeNewsletterSocket = (config: SocketConfig) => {
}, },
newsletterMute: (jid: string) => { newsletterMute: (jid: string) => {
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'OFF' } }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2) return executeWMexQuery({ newsletter_id: jid }, QueryIds.MUTE, XWAPaths.xwa2_newsletter_mute_v2)
}, },
newsletterUnmute: (jid: string) => { newsletterUnmute: (jid: string) => {
return executeWMexQuery({ input: { newsletter_id: jid, type: 'MUTE_ADMIN_ACTIVITY', value: 'ON' } }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2) return executeWMexQuery({ newsletter_id: jid }, QueryIds.UNMUTE, XWAPaths.xwa2_newsletter_unmute_v2)
}, },
newsletterUpdateName: async (jid: string, name: string) => { newsletterUpdateName: async (jid: string, name: string) => {
+14 -53
View File
@@ -38,7 +38,7 @@ import {
signedKeyPair, signedKeyPair,
xmppSignedPreKey xmppSignedPreKey
} from '../Utils' } from '../Utils'
import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils' import { getPlatformId } from '../Utils/browser-utils'
import { import {
CircuitBreaker, CircuitBreaker,
CircuitOpenError, CircuitOpenError,
@@ -634,6 +634,8 @@ export const makeSocket = (config: SocketConfig) => {
} }
helloMsg = proto.HandshakeMessage.fromObject(helloMsg) helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish() const init = proto.HandshakeMessage.encode(helloMsg).finish()
const result = await awaitNextMessage<Uint8Array>(init) const result = await awaitNextMessage<Uint8Array>(init)
@@ -701,12 +703,10 @@ export const makeSocket = (config: SocketConfig) => {
} }
} }
// Prevent multiple concurrent uploads — if one is already running, wait for it and return: // Prevent multiple concurrent uploads
// the concurrent upload already replenished the pool, so there is nothing left to do.
if (uploadPreKeysPromise) { if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion') logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise await uploadPreKeysPromise
return
} }
const uploadLogic = async () => { const uploadLogic = async () => {
@@ -786,15 +786,14 @@ export const makeSocket = (config: SocketConfig) => {
try { try {
let count = 0 let count = 0
const preKeyCount = await getAvailablePreKeysOnServer() const preKeyCount = await getAvailablePreKeysOnServer()
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount) else count = MIN_PREKEY_COUNT
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists() const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`) logger.info(`${preKeyCount} pre-keys found on server`)
logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`) logger.info(`Current prekey ID: ${currentPreKeyId}, exists in storage: ${currentPreKeyExists}`)
// Trigger upload when below the replenishment threshold, not when count < topUp amount const lowServerCount = preKeyCount <= count
const lowServerCount = preKeyCount < MIN_PREKEY_COUNT
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0 const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1154,7 +1153,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections // Decrement active connections
decrementActiveConnections() decrementActiveConnections()
clearTimeout(keepAliveReq) clearInterval(keepAliveReq)
clearTimeout(qrTimer) clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush() // Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1265,16 +1264,8 @@ export const makeSocket = (config: SocketConfig) => {
}) })
} }
const startKeepAliveRequest = () => { const startKeepAliveRequest = () =>
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour (keepAliveReq = setInterval(() => {
// (WA Business Desktop: ~25-30s intervals with natural variance)
const scheduleNextKeepAlive = () => {
const jitter = keepAliveIntervalMs * 0.15
const delay = keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter)
keepAliveReq = setTimeout(onKeepAliveTick, delay)
}
const onKeepAliveTick = () => {
if (!lastDateRecv) { if (!lastDateRecv) {
lastDateRecv = new Date() lastDateRecv = new Date()
} }
@@ -1286,7 +1277,6 @@ export const makeSocket = (config: SocketConfig) => {
*/ */
if (diff > keepAliveIntervalMs + 5000) { if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost })) void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) { } else if (ws.isOpen) {
// Send keep-alive ping via sendNode() (fire-and-forget) instead of query(). // Send keep-alive ping via sendNode() (fire-and-forget) instead of query().
// query() wraps the ping in the query circuit breaker — when that breaker is // query() wraps the ping in the query circuit breaker — when that breaker is
@@ -1309,15 +1299,7 @@ export const makeSocket = (config: SocketConfig) => {
} else { } else {
logger.warn('keep alive called when WS not open') logger.warn('keep alive called when WS not open')
} }
}, keepAliveIntervalMs))
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
/** i have no idea why this exists. pls enlighten me */ /** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') => const sendPassiveIq = (tag: 'passive' | 'active') =>
query({ query({
@@ -1370,25 +1352,6 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'), id: jidEncode(phoneNumber, 's.whatsapp.net'),
name: '~' name: '~'
} }
// Pair code companion_platform_id must be Chrome (1) when using Android
// browser preset. ANDROID_PHONE (16) causes silent timeout (server ignores),
// UWP (21) causes "cannot connect device" rejection. Only Chrome (1) works
// for pair code via web protocol (WA\x06\x03). The device still appears as
// "Android" in linked devices because DeviceProps.platformType=ANDROID_PHONE
// is set separately in the registration node.
const isAndroid = isAndroidBrowser(browser)
const pairPlatformId = isAndroid ? getPlatformId('Chrome') : getPlatformId(browser[1])
const pairPlatformDisplay = isAndroid ? 'Chrome (Mac OS)' : `${browser[1]} (${browser[0]})`
logger.info({
pairCode: pairingCode,
jid: authState.creds.me.id,
companionPlatformId: pairPlatformId,
companionPlatformDisplay: pairPlatformDisplay,
isAndroid,
}, `pair code requested | companion: ${pairPlatformDisplay} | ${isAndroid ? 'android override -> Chrome' : 'native platform'}`)
ev.emit('creds.update', authState.creds) ev.emit('creds.update', authState.creds)
await sendNode({ await sendNode({
tag: 'iq', tag: 'iq',
@@ -1421,12 +1384,12 @@ export const makeSocket = (config: SocketConfig) => {
{ {
tag: 'companion_platform_id', tag: 'companion_platform_id',
attrs: {}, attrs: {},
content: pairPlatformId content: getPlatformId(browser[1])
}, },
{ {
tag: 'companion_platform_display', tag: 'companion_platform_display',
attrs: {}, attrs: {},
content: pairPlatformDisplay content: `${browser[1]} (${browser[0]})`
}, },
{ {
tag: 'link_code_pairing_nonce', tag: 'link_code_pairing_nonce',
@@ -1556,9 +1519,7 @@ export const makeSocket = (config: SocketConfig) => {
}) })
// login complete // login complete
ws.on('CB:success', async (node: BinaryNode) => { ws.on('CB:success', async (node: BinaryNode) => {
const isAndroid = isAndroidBrowser(browser) logger.info('opened connection to WA')
const phoneId = authState.creds.me?.id?.split(':')[0]?.split('@')[0] || 'new session'
logger.info(`${isAndroid ? '\uD83D\uDCF1' : '\uD83D\uDDA5\uFE0F'} Connected to WA | ${phoneId} | platform: ${isAndroid ? 'SMB_ANDROID' : 'MACOS'} | device: ${isAndroid ? 'Android' : 'Desktop'} | platformType: ${isAndroid ? 'ANDROID_PHONE' : 'CHROME'}`)
clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try clearTimeout(qrTimer) // will never happen in all likelyhood -- but just in case WA sends success on first try
ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } }) ev.emit('creds.update', { me: { ...authState.creds.me!, lid: node.attrs.lid } })
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState 'app-state-sync-version': LTHashState
'lid-mapping': string 'lid-mapping': string
'device-list': string[] 'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null } tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */ /** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array 'identity-key': Uint8Array
} }
-2
View File
@@ -9,8 +9,6 @@ export interface Contact {
name?: string name?: string
/** name of the contact, the contact has set on their own on WA */ /** name of the contact, the contact has set on their own on WA */
notify?: string notify?: string
/** username associated with this contact, when provided by WA */
username?: string
/** I have no idea */ /** I have no idea */
verifiedName?: string verifiedName?: string
// Baileys Added // Baileys Added
-6
View File
@@ -27,8 +27,6 @@ export type BaileysEventMap = {
chats: Chat[] chats: Chat[]
contacts: Contact[] contacts: Contact[]
messages: WAMessage[] messages: WAMessage[]
/** Past participants for group chats (people who left/were removed). userJid is always a phone number (PN), never a LID. */
pastParticipants?: proto.IPastParticipants[] | null
isLatest?: boolean isLatest?: boolean
progress?: number | null progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null syncType?: proto.HistorySync.HistorySyncType | null
@@ -81,7 +79,6 @@ export type BaileysEventMap = {
id: string id: string
author: string author: string
authorPn?: string authorPn?: string
authorUsername?: string
participants: GroupParticipant[] participants: GroupParticipant[]
action: ParticipantAction action: ParticipantAction
} }
@@ -89,7 +86,6 @@ export type BaileysEventMap = {
id: string id: string
author: string author: string
authorPn?: string authorPn?: string
authorUsername?: string
participant: string participant: string
participantPn?: string participantPn?: string
action: RequestJoinAction action: RequestJoinAction
@@ -189,8 +185,6 @@ export type BufferedEventData = {
chats: { [jid: string]: Chat } chats: { [jid: string]: Chat }
contacts: { [jid: string]: Contact } contacts: { [jid: string]: Contact }
messages: { [uqId: string]: WAMessage } messages: { [uqId: string]: WAMessage }
/** Keyed by groupJid for O(1) deduplication across chunks */
pastParticipants: { [groupJid: string]: proto.IPastParticipant[] }
empty: boolean empty: boolean
isLatest: boolean isLatest: boolean
progress?: number | null progress?: number | null
-4
View File
@@ -20,20 +20,17 @@ export interface GroupMetadata {
addressingMode?: WAMessageAddressingMode addressingMode?: WAMessageAddressingMode
owner: string | undefined owner: string | undefined
ownerPn?: string | undefined ownerPn?: string | undefined
ownerUsername?: string | undefined
owner_country_code?: string | undefined owner_country_code?: string | undefined
subject: string subject: string
/** group subject owner */ /** group subject owner */
subjectOwner?: string subjectOwner?: string
subjectOwnerPn?: string subjectOwnerPn?: string
subjectOwnerUsername?: string
/** group subject modification date */ /** group subject modification date */
subjectTime?: number subjectTime?: number
creation?: number creation?: number
desc?: string desc?: string
descOwner?: string descOwner?: string
descOwnerPn?: string descOwnerPn?: string
descOwnerUsername?: string
descId?: string descId?: string
descTime?: number descTime?: number
/** if this group is part of a community, it returns the jid of the community to which it belongs */ /** if this group is part of a community, it returns the jid of the community to which it belongs */
@@ -59,7 +56,6 @@ export interface GroupMetadata {
/** the person who added you to group or changed some setting in group */ /** the person who added you to group or changed some setting in group */
author?: string author?: string
authorPn?: string authorPn?: string
authorUsername?: string
} }
export interface WAGroupCreateResponse { export interface WAGroupCreateResponse {
+3 -18
View File
@@ -19,9 +19,7 @@ export type WAContactMessage = proto.Message.IContactMessage
export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage
export type WAMessageKey = proto.IMessageKey & { export type WAMessageKey = proto.IMessageKey & {
remoteJidAlt?: string remoteJidAlt?: string
remoteJidUsername?: string
participantAlt?: string participantAlt?: string
participantUsername?: string
server_id?: string server_id?: string
addressingMode?: string addressingMode?: string
isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo
@@ -56,14 +54,12 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack * Individual sticker in a sticker pack
*/ */
export type Sticker = { export type Sticker = {
/** Buffer, Stream or URL of the sticker image (WebP or Lottie/WAS format) */ /** Buffer, Stream or URL of the sticker image (will be converted to WebP if needed) */
data: WAMediaUpload data: WAMediaUpload
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */ /** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[] emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */ /** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
} }
/** /**
@@ -157,7 +153,6 @@ export type MessageReceiptType =
| 'sender' | 'sender'
| 'inactive' | 'inactive'
| 'played' | 'played'
| 'view_once_read'
| undefined | undefined
export type MediaConnInfo = { export type MediaConnInfo = {
@@ -181,8 +176,6 @@ export interface WAUrlInfo {
type Mentionable = { type Mentionable = {
/** list of jids that are mentioned in the accompanying text */ /** list of jids that are mentioned in the accompanying text */
mentions?: string[] mentions?: string[]
/** mention all participants in the group */
mentionAll?: boolean
} }
type Contextable = { type Contextable = {
/** add contextInfo to the message */ /** add contextInfo to the message */
@@ -995,16 +988,8 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = ( export type WAMediaUploadFunction = (
encFilePath: string, encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean } opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
) => Promise<{ ) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
mediaUrl: string | undefined
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}>
export type MediaGenerationOptions = { export type MediaGenerationOptions = {
logger?: ILogger logger?: ILogger
+8 -8
View File
@@ -4,10 +4,10 @@ export enum XWAPaths {
xwa2_newsletter_view = 'xwa2_newsletter_view', xwa2_newsletter_view = 'xwa2_newsletter_view',
xwa2_newsletter_metadata = 'xwa2_newsletter', xwa2_newsletter_metadata = 'xwa2_newsletter',
xwa2_newsletter_admin_count = 'xwa2_newsletter_admin', xwa2_newsletter_admin_count = 'xwa2_newsletter_admin',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_update_user_setting', xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_update_user_setting', xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2',
xwa2_newsletter_follow = 'xwa2_newsletter_join_v2', xwa2_newsletter_follow = 'xwa2_newsletter_follow',
xwa2_newsletter_unfollow = 'xwa2_newsletter_leave_v2', xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow',
xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner', xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner',
xwa2_newsletter_demote = 'xwa2_newsletter_demote', xwa2_newsletter_demote = 'xwa2_newsletter_demote',
xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2' xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2'
@@ -17,10 +17,10 @@ export enum QueryIds {
UPDATE_METADATA = '24250201037901610', UPDATE_METADATA = '24250201037901610',
METADATA = '6563316087068696', METADATA = '6563316087068696',
SUBSCRIBERS = '9783111038412085', SUBSCRIBERS = '9783111038412085',
FOLLOW = '24404358912487870', FOLLOW = '7871414976211147',
UNFOLLOW = '9767147403369991', UNFOLLOW = '7238632346214362',
MUTE = '31938993655691868', MUTE = '29766401636284406',
UNMUTE = '31938993655691868', UNMUTE = '9864994326891137',
ADMIN_COUNT = '7130823597031706', ADMIN_COUNT = '7130823597031706',
CHANGE_OWNER = '7341777602580933', CHANGE_OWNER = '7341777602580933',
DEMOTE = '6551828931592903', DEMOTE = '6551828931592903',
-1
View File
@@ -23,7 +23,6 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string] baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string] windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string] appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
} }
export enum DisconnectReason { export enum DisconnectReason {
+1 -12
View File
@@ -341,18 +341,7 @@ export const addTransactionCapability = (
return result return result
} catch (error) { } catch (error) {
// SessionError is part of the normal Bad MAC recovery flow logger.error({ error }, 'transaction failed, rolling back')
// (retry receipt → sender resends as pkmsg → new session within ~1.3s).
// Logging it as ERROR creates 2 noise lines per recoverable Bad MAC cycle.
// Downgrade to debug for SessionError; keep ERROR for everything else.
// The error is still re-thrown — recovery behavior is unchanged.
const errName = (error as { name?: string })?.name
if (errName === 'SessionError') {
logger.debug({ error }, 'transaction failed (SessionError — recoverable via retry receipt)')
} else {
logger.error({ error }, 'transaction failed, rolling back')
}
throw error throw error
} }
}) })
+3 -20
View File
@@ -94,13 +94,7 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
} }
} }
const map = new Map(entries) return new Map(entries)
// ANDROID → ANDROID_PHONE alias (DeviceProps.PlatformType has ANDROID_PHONE but not ANDROID)
if (!map.has('ANDROID') && map.has('ANDROID_PHONE')) {
map.set('ANDROID', map.get('ANDROID_PHONE')!)
}
return map
})() })()
// ============================================================ // ============================================================
@@ -337,8 +331,7 @@ export const Browsers: BrowsersMap = {
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS], macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows], windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys], baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()], appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()]
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
} as const } as const
/** /**
@@ -385,7 +378,7 @@ export const getPlatformId = (browser: unknown): string => {
* properties like 'toString' or 'constructor'. * properties like 'toString' or 'constructor'.
* *
* @param value - Value to check * @param value - Value to check
* @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate', 'android') * @returns True if value is a valid browser preset key ('ubuntu', 'macOS', 'windows', 'baileys', 'appropriate')
* *
* @example * @example
* isValidBrowserPreset('ubuntu') // true * isValidBrowserPreset('ubuntu') // true
@@ -398,16 +391,6 @@ export const getPlatformId = (browser: unknown): string => {
* const config = Browsers[userInput]('MyApp') * const config = Browsers[userInput]('MyApp')
* } * }
*/ */
/**
* Checks if the browser tuple represents an Android companion device.
*
* @param browser - Browser tuple [os, platform, version]
* @returns True if platform is 'Android' (case-insensitive)
*/
export const isAndroidBrowser = (browser: [string, string, string]): boolean => {
return browser[1]?.toUpperCase() === 'ANDROID'
}
export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => { export const isValidBrowserPreset = (value: unknown): value is keyof BrowsersMap => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value) return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
} }
-1
View File
@@ -1083,7 +1083,6 @@ export const processSyncAction = (
action.lidContactAction.firstName || action.lidContactAction.firstName ||
action.lidContactAction.username || action.lidContactAction.username ||
undefined, undefined,
username: action.lidContactAction.username || undefined,
lid: id!, lid: id!,
phoneNumber: undefined phoneNumber: undefined
} }
+46 -50
View File
@@ -14,6 +14,7 @@ import {
isJidStatusBroadcast, isJidStatusBroadcast,
isLidUser, isLidUser,
isPnUser, isPnUser,
jidDecode
// transferDevice // transferDevice
} from '../WABinary' } from '../WABinary'
import { unpadRandomMax16 } from './generics' import { unpadRandomMax16 } from './generics'
@@ -58,11 +59,7 @@ export const BAD_MAC_ERROR_TEXT = 'Bad MAC'
export const DECRYPTION_RETRY_CONFIG = { export const DECRYPTION_RETRY_CONFIG = {
maxRetries: 3, maxRetries: 3,
baseDelayMs: 100, baseDelayMs: 100,
// 'No matching sessions found' is the libsignal error when decryptWithSessions exhausts sessionRecordErrors: ['No session record', 'SessionError: No session record'],
// all stored sessions for a JID. Same recovery flow (retry receipt → pkmsg → new session)
// — categorise it as session-record so the caller logs DEBUG on retry, ERROR only when
// retries are exhausted (instead of dumping the full stack as an unknown error).
sessionRecordErrors: ['No session record', 'SessionError: No session record', 'No matching sessions found'],
corruptedSessionErrors: ['Bad MAC', 'MessageCounterError', MISSING_KEYS_ERROR_TEXT] corruptedSessionErrors: ['Bad MAC', 'MessageCounterError', MISSING_KEYS_ERROR_TEXT]
} }
@@ -246,14 +243,10 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
const key: WAMessageKey = { const key: WAMessageKey = {
remoteJid: chatId, remoteJid: chatId,
remoteJidAlt: !isJidGroup(chatId) ? addressingContext.senderAlt : undefined, remoteJidAlt: !isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
remoteJidUsername: !isJidGroup(chatId)
? stanza.attrs.peer_recipient_username || stanza.attrs.recipient_username
: undefined,
fromMe, fromMe,
id: msgId, id: msgId,
participant, participant,
participantAlt: isJidGroup(chatId) ? addressingContext.senderAlt : undefined, participantAlt: isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
participantUsername: stanza.attrs.participant ? stanza.attrs.participant_username : undefined,
addressingMode: addressingContext.addressingMode, addressingMode: addressingContext.addressingMode,
...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {}) ...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {})
} }
@@ -400,23 +393,6 @@ export const decryptMessageNode = (
} else { } else {
fullMessage.message = msg fullMessage.message = msg
} }
// Detect view-once media on stanza 1 received by linked device.
// viewOnceMessage wrapper is also used for interactive messages
// (interactiveMessage, listMessage, nativeFlowMessage) -- those do NOT have
// imageMessage.viewOnce / videoMessage.viewOnce / audioMessage.viewOnce = true.
// Only real view-once media carries viewOnce: true on the inner media message.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
} catch (err: any) { } catch (err: any) {
// Check if this is a final failure after all retries exhausted // Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError const isRetryExhausted = err instanceof RetryExhaustedError
@@ -425,26 +401,9 @@ export const decryptMessageNode = (
const isCorrupted = isCorruptedSessionError(originalError) const isCorrupted = isCorruptedSessionError(originalError)
const isSessionRecord = isSessionRecordError(originalError) const isSessionRecord = isSessionRecordError(originalError)
// Slim error projection — keep name/message/type for diagnosis,
// drop `stack` which adds 4-5 lines of node_modules paths per log
// for known-recoverable libsignal errors.
//
// CRITICAL: only slim for KNOWN-RECOVERABLE categories (corrupted /
// session-record). The unknown-error branch keeps the full Error so
// protobuf/parsing/runtime bugs still emit a stack trace where it
// matters most. Catches Copilot/Codex P2 review on PR #391.
const slimErr = originalError
? {
name: (originalError as { name?: string }).name,
message: (originalError as { message?: string }).message,
type: (originalError as { type?: string }).type
}
: undefined
const isRecoverableCategory = isCorrupted || isSessionRecord
const errorContext = { const errorContext = {
key: fullMessage.key, key: fullMessage.key,
err: isRecoverableCategory ? slimErr : originalError, err: originalError,
messageType: tag === 'plaintext' ? 'plaintext' : attrs.type, messageType: tag === 'plaintext' ? 'plaintext' : attrs.type,
sender, sender,
author, author,
@@ -529,9 +488,8 @@ export function isCorruptedSessionError(error: any): boolean {
} }
/** /**
* Clean up corrupted session for a specific device JID. * Clean up corrupted session by deleting all device sessions for a JID.
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=? * Signal Protocol will automatically recreate the session on next message.
* Only deletes the exact device that was corrupted, not all devices.
* *
* NOTE: This should NOT be called on every Bad MAC error (hot path). * 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). * Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
@@ -542,7 +500,45 @@ export async function cleanupCorruptedSession(
repository: SignalRepositoryWithLIDStore, repository: SignalRepositoryWithLIDStore,
logger: ILogger logger: ILogger
): Promise<number> { ): Promise<number> {
await repository.deleteSession([jid]) const { user, device } = jidDecode(jid) || {}
logger.info({ jid }, 'Cleaned up corrupted session for specific device') if (!user) {
return 1 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
} }
-28
View File
@@ -1,5 +1,4 @@
import EventEmitter from 'events' import EventEmitter from 'events'
import { proto } from '../../WAProto/index.js'
import type { import type {
BaileysEvent, BaileysEvent,
BaileysEventEmitter, BaileysEventEmitter,
@@ -891,7 +890,6 @@ const makeBufferData = (): BufferedEventData => {
chats: {}, chats: {},
messages: {}, messages: {},
contacts: {}, contacts: {},
pastParticipants: {},
isLatest: false, isLatest: false,
empty: true empty: true
}, },
@@ -969,28 +967,6 @@ function append<E extends BufferableEvent>(
} }
} }
// Merge pastParticipants with deduplication by groupJid and by userJid within each group.
// Multiple HistorySync chunks can carry the same group -- we merge rather than concatenate
// to avoid duplicate entries in the final event delivered to the consumer.
for (const group of (eventData.pastParticipants ?? []) as proto.IPastParticipants[]) {
const groupJid = group.groupJid
if (!groupJid) continue
if (!data.historySets.pastParticipants[groupJid]) {
data.historySets.pastParticipants[groupJid] = []
}
const existing = data.historySets.pastParticipants[groupJid]
const seenJids = new Set(existing.map(p => p.userJid).filter(Boolean))
for (const participant of (group.pastParticipants ?? []) as proto.IPastParticipant[]) {
if (participant.userJid && !seenJids.has(participant.userJid)) {
existing.push(participant)
seenJids.add(participant.userJid)
}
}
}
data.historySets.empty = false data.historySets.empty = false
data.historySets.syncType = eventData.syncType data.historySets.syncType = eventData.syncType
data.historySets.progress = eventData.progress data.historySets.progress = eventData.progress
@@ -1316,10 +1292,6 @@ function consolidateEvents(data: BufferedEventData) {
chats: Object.values(data.historySets.chats), chats: Object.values(data.historySets.chats),
messages: Object.values(data.historySets.messages), messages: Object.values(data.historySets.messages),
contacts: Object.values(data.historySets.contacts), contacts: Object.values(data.historySets.contacts),
// Convert dedup map back to array. Each entry has groupJid + participants (all unique userJids).
pastParticipants: Object.entries(data.historySets.pastParticipants).map(
([groupJid, participants]) => ({ groupJid, pastParticipants: participants })
),
syncType: data.historySets.syncType, syncType: data.historySets.syncType,
progress: data.historySets.progress, progress: data.historySets.progress,
isLatest: data.historySets.isLatest, isLatest: data.historySets.isLatest,
+13 -46
View File
@@ -1,6 +1,5 @@
import { pipeline } from 'stream/promises'
import { promisify } from 'util' import { promisify } from 'util'
import { createInflate, inflate } from 'zlib' import { inflate } from 'zlib'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types' import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
import { WAMessageStubType } from '../Types' import { WAMessageStubType } from '../Types'
@@ -16,8 +15,7 @@ import {
import { toNumber } from './generics' import { toNumber } from './generics'
import type { ILogger } from './logger.js' import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages' import { normalizeMessageContent } from './messages'
import { DEFAULT_ORIGIN } from '../Defaults' import { downloadContentFromMessage } from './messages-media'
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
const inflatePromise = promisify(inflate) const inflatePromise = promisify(inflate)
@@ -30,15 +28,16 @@ const inflatePromise = promisify(inflate)
*/ */
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => { export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options }) const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
// Pipe decrypted stream directly through zlib inflate. const bufferArray: Buffer[] = []
// Avoids allocating an intermediate buffer for the compressed payload — for await (const chunk of stream) {
// memory peaks during 50MB history syncs drop ~50% (PR upstream #2333). bufferArray.push(chunk)
const inflater = createInflate() }
const chunks: Buffer[] = []
inflater.on('data', (chunk: Buffer) => chunks.push(chunk)) let buffer: Buffer = Buffer.concat(bufferArray)
await pipeline(stream, inflater)
// decompress buffer
buffer = await inflatePromise(buffer)
const buffer = Buffer.concat(chunks)
const syncData = proto.HistorySync.decode(buffer) const syncData = proto.HistorySync.decode(buffer)
return syncData return syncData
} }
@@ -315,7 +314,6 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
contacts.push({ contacts.push({
id: chatId, id: chatId,
name: chat.displayName || chat.name || chat.username || undefined, name: chat.displayName || chat.name || chat.username || undefined,
username: chat.username || undefined,
lid: chat.lidJid || chat.accountLid || undefined, lid: chat.lidJid || chat.accountLid || undefined,
phoneNumber: chat.pnJid || undefined phoneNumber: chat.pnJid || undefined
}) })
@@ -361,7 +359,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
} }
} }
chats.push(chat) chats.push({ ...chat })
} }
break break
@@ -376,25 +374,10 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
// Convert Map back to array for return // Convert Map back to array for return
const lidPnMappings = Array.from(lidPnMap.values()) const lidPnMappings = Array.from(lidPnMap.values())
// Normalize pastParticipants: resolve LID userJids → PN so the consumer always
// receives a phone number, never an opaque LID identifier.
// Uses the lidPnMap built above (populated from phoneNumberToLidMappings + conversations).
// If a LID cannot be resolved, the original value is kept rather than dropping the participant.
const pastParticipants: proto.IPastParticipants[] = (item.pastParticipants ?? []).map(group => ({
groupJid: group.groupJid,
pastParticipants: (group.pastParticipants ?? []).map(participant => {
const userJid = participant.userJid
if (!userJid || !isAnyLidUser(userJid)) return participant
const mapping = lidPnMap.get(jidNormalizedUser(userJid))
return mapping?.pn ? { ...participant, userJid: mapping.pn } : participant
})
}))
return { return {
chats, chats,
contacts, contacts,
messages, messages,
pastParticipants,
lidPnMappings, lidPnMappings,
syncType: item.syncType, syncType: item.syncType,
progress: item.progress progress: item.progress
@@ -425,23 +408,7 @@ export const downloadAndProcessHistorySyncNotification = async (
historyMsg = await downloadHistory(msg, options) historyMsg = await downloadHistory(msg, options)
} }
const result = processHistoryMessage(historyMsg, logger) return processHistoryMessage(historyMsg, logger)
// Mirror WA Desktop behaviour: DELETE the CDN blob only after processing succeeds.
// Doing this earlier (e.g. inside downloadHistory) risks permanent history loss if
// processing throws — the server copy would be gone and retry after reconnect would fail.
if (msg.directPath) {
const cdnUrl = getUrlFromDirectPath(msg.directPath)
fetch(cdnUrl, {
...options,
method: 'DELETE',
headers: { ...((options as RequestInit).headers ?? {}), Origin: DEFAULT_ORIGIN }
}).catch(() => {
// non-fatal — server will expire it anyway
})
}
return result
} }
/** /**
-23
View File
@@ -57,16 +57,6 @@ export type IdentityChangeContext = {
debounceCache: NodeCache<boolean> debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */ /** Logger instance for debugging and monitoring */
logger: ILogger logger: ILogger
/**
* Invoked right before `assertSessions` is called for an existing-session identity
* change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance)
* in the same order WA Web does i.e. before the E2E session is re-established.
* Must not throw; implementations are responsible for their own error handling.
*
* Skipped when the refresh itself is skipped (no_identity_node, invalid_notification,
* skipped_companion_device, skipped_self_primary, debounced, skipped_offline).
*/
onBeforeSessionRefresh?: (jid: string) => void
} }
// ============================================================================ // ============================================================================
@@ -180,19 +170,6 @@ export async function handleIdentityChange(
// This ensures we don't incorrectly debounce when we exit early (offline, etc.) // This ensures we don't incorrectly debounce when we exit early (offline, etc.)
ctx.debounceCache.set(from, true) ctx.debounceCache.set(from, true)
// Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is
// re-established. WA Web runs these in parallel with the session refresh —
// running afterwards would race with the next outbound send and risk error 463.
//
// Wrapped in try/catch so a misbehaving consumer callback cannot abort identity
// change recovery. We log and continue — assertSessions still runs so the E2E
// session always gets refreshed.
try {
ctx.onBeforeSessionRefresh?.(from)
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh')
}
// Attempt session refresh/creation // Attempt session refresh/creation
try { try {
await ctx.assertSessions([from], true) await ctx.assertSessions([from], true)
+1 -1
View File
@@ -218,7 +218,7 @@ export class MessageRetryManager {
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) { if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now() const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid) const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster const MAC_ERROR_COOLDOWN_MS = 10_000 // 10 seconds
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) { if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}` const reasonName = RetryReason[errorCode] || `code_${errorCode}`
+8 -34
View File
@@ -10,13 +10,7 @@ import { join } from 'path'
import { Readable, Transform } from 'stream' import { Readable, Transform } from 'stream'
import { URL } from 'url' import { URL } from 'url'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import { import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults'
DEFAULT_ORIGIN,
MEDIA_HKDF_KEY_MAPPING,
MEDIA_PATH_MAP,
type MediaType,
NEWSLETTER_MEDIA_PATH_MAP
} from '../Defaults'
import type { import type {
BaileysEventMap, BaileysEventMap,
DownloadableMessage, DownloadableMessage,
@@ -166,7 +160,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
height: dimensions.height height: dimensions.height
} }
} }
} else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'function') { } else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath) const jimp = await (lib.jimp.Jimp as any).read(bufferOrFilePath)
const dimensions = { const dimensions = {
width: jimp.width, width: jimp.width,
@@ -618,7 +612,7 @@ export const downloadEncryptedContent = async (
const output = new Transform({ const output = new Transform({
transform(chunk, _, callback) { transform(chunk, _, callback) {
let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk let data = Buffer.concat([remainingBytes, chunk])
const decryptLength = toSmallestChunkSize(data.length) const decryptLength = toSmallestChunkSize(data.length)
remainingBytes = data.slice(decryptLength) remainingBytes = data.slice(decryptLength)
@@ -687,10 +681,6 @@ type MediaUploadResult = {
meta_hmac?: string meta_hmac?: string
ts?: number ts?: number
fbid?: number fbid?: number
thumbnail_info?: {
thumbnail_sha256?: string
thumbnail_direct_path?: string
}
} }
export type UploadParams = { export type UploadParams = {
@@ -835,21 +825,11 @@ export const getWAUploadToServer = (
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig, { customUploadHosts, fetchAgent, logger, options }: SocketConfig,
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo> refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
): WAMediaUploadFunction => { ): WAMediaUploadFunction => {
return async (filePath, { mediaType, fileEncSha256B64, timeoutMs, newsletter }) => { return async (filePath, { mediaType, fileEncSha256B64, timeoutMs }) => {
// send a query JSON to obtain the url & auth token to upload our media // send a query JSON to obtain the url & auth token to upload our media
let uploadInfo = await refreshMediaConn(false) let uploadInfo = await refreshMediaConn(false)
let urls: let urls: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined
| {
mediaUrl: string
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}
| undefined
const hosts = [...customUploadHosts, ...uploadInfo.hosts] const hosts = [...customUploadHosts, ...uploadInfo.hosts]
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64) fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
@@ -871,11 +851,7 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`) logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth) const auth = encodeURIComponent(uploadInfo.auth)
const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType] const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
let url = `https://${hostname}${mediaPath}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
if (newsletter) {
url += '&server_thumb_gen=1'
}
let result: MediaUploadResult | undefined let result: MediaUploadResult | undefined
try { try {
@@ -892,13 +868,11 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) { if (result?.url || result?.direct_path) {
urls = { urls = {
mediaUrl: result.url || result.direct_path!, mediaUrl: result.url!,
directPath: result.direct_path!, directPath: result.direct_path!,
meta_hmac: result.meta_hmac, meta_hmac: result.meta_hmac,
fbid: result.fbid, fbid: result.fbid,
ts: result.ts, ts: result.ts
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
} }
break break
} else { } else {
+15 -108
View File
@@ -41,11 +41,9 @@ import type { ILogger } from './logger'
import { import {
downloadContentFromMessage, downloadContentFromMessage,
encryptedStream, encryptedStream,
extractImageThumb,
generateThumbnail, generateThumbnail,
getAudioDuration, getAudioDuration,
getAudioWaveform, getAudioWaveform,
getStream,
getRawMediaUploadData, getRawMediaUploadData,
type MediaDownloadOptions type MediaDownloadOptions
} from './messages-media' } from './messages-media'
@@ -196,11 +194,10 @@ export const prepareWAMessageMedia = async (
) )
const fileSha256B64 = fileSha256.toString('base64') const fileSha256B64 = fileSha256.toString('base64')
const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, { const { mediaUrl, directPath } = await options.upload(filePath, {
fileEncSha256B64: fileSha256B64, fileEncSha256B64: fileSha256B64,
mediaType: mediaType, mediaType: mediaType,
timeoutMs: options.mediaUploadTimeoutMs, timeoutMs: options.mediaUploadTimeoutMs
newsletter: true
}) })
await fs.unlink(filePath) await fs.unlink(filePath)
@@ -208,12 +205,10 @@ export const prepareWAMessageMedia = async (
const obj = WAProto.Message.fromObject({ const obj = WAProto.Message.fromObject({
// todo: add more support here // todo: add more support here
[`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({ [`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({
// url intentionally omitted — newsletters use directPath only url: mediaUrl,
directPath, directPath,
fileSha256, fileSha256,
fileLength, fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData, ...uploadData,
media: undefined media: undefined
}) })
@@ -239,7 +234,7 @@ export const prepareWAMessageMedia = async (
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined' const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation = const requiresThumbnailComputation =
(mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined' (mediaType === 'image' || mediaType === 'video') && typeof uploadData['jpegThumbnail'] === 'undefined'
const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true && typeof uploadData.waveform === 'undefined' const requiresWaveformProcessing = mediaType === 'audio' && uploadData.ptt === true
const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true const requiresAudioBackground = options.backgroundColor && mediaType === 'audio' && uploadData.ptt === true
const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream( const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(
@@ -257,9 +252,7 @@ export const prepareWAMessageMedia = async (
(async () => { (async () => {
const result = await options.upload(encFilePath, { const result = await options.upload(encFilePath, {
fileEncSha256B64, fileEncSha256B64,
// Use mediaTypeOverride for upload path (e.g. 'viewonce-image' → /o1/mms/image) mediaType,
// Encryption already used the override via encryptedStream above
mediaType: options.mediaTypeOverride || mediaType,
timeoutMs: options.mediaUploadTimeoutMs timeoutMs: options.mediaUploadTimeoutMs
}) })
logger?.debug({ mediaType, cacheableKey }, 'uploaded media') logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
@@ -297,7 +290,7 @@ export const prepareWAMessageMedia = async (
logger?.debug('computed audio duration') logger?.debug('computed audio duration')
} }
if (requiresWaveformProcessing && !uploadData.waveform) { if (requiresWaveformProcessing) {
uploadData.waveform = await getAudioWaveform(filePath, logger) uploadData.waveform = await getAudioWaveform(filePath, logger)
logger?.debug('processed waveform') logger?.debug('processed waveform')
} }
@@ -640,59 +633,6 @@ 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) // Map cards to the carousel format (processing media)
const carouselCards = await Promise.all( const carouselCards = await Promise.all(
cards.map(async card => { cards.map(async card => {
@@ -708,11 +648,6 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) { if (hasMedia && mediaOptions) {
if (card.image) { if (card.image) {
const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, mediaOptions) const { imageMessage } = await prepareWAMessageMedia({ image: card.image }, 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 // Validate image fields needed for WhatsApp rendering
if (imageMessage && !imageMessage.jpegThumbnail) { if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn( mediaOptions.logger?.warn(
@@ -1304,8 +1239,7 @@ export const generateWAMessageContent = async (
} }
// Pass options for media processing if cards have images/videos // Pass options for media processing if cards have images/videos
const generated = await generateCarouselMessage(carouselOptions, options) const generated = await generateCarouselMessage(carouselOptions, options)
// Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper // Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
// Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza
m.interactiveMessage = generated.interactiveMessage m.interactiveMessage = generated.interactiveMessage
return m return m
} }
@@ -1752,49 +1686,22 @@ export const generateWAMessageContent = async (
} }
} }
} else { } else {
// For view-once media, upload to the one-time CDN (/o1/mms/*) instead of regular CDN m = await prepareWAMessageMedia(message, options)
// WA Web view-once messages always have directPath: /o1/v/t24/... (not /v/t62.7118-24/...)
// Using the wrong CDN causes WA server to flag the account and breaks view-once capability
const isViewOnce = hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce
const viewOnceTypeMap: Record<string, string> = {
image: 'viewonce-image',
video: 'viewonce-video',
audio: 'viewonce-audio'
}
const viewOnceOverride = isViewOnce
? viewOnceTypeMap[Object.keys(message).find(k => k in viewOnceTypeMap) || '']
: undefined
m = await prepareWAMessageMedia(message, {
...options,
mediaTypeOverride: (viewOnceOverride as any) || options.mediaTypeOverride
})
} }
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) { if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Also set viewOnce=true on the inner media message (required by WA app to render as view-once) m = { viewOnceMessage: { message: m } }
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Use viewOnceMessageV2 (field 55) — modern WA clients use this wrapper
// viewOnceMessage (field 37) is the old format which WA server no longer accepts with view_once_write
m = { viewOnceMessageV2: { message: m } }
} }
if ( if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
(hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
(hasOptionalProperty(message, 'mentionAll') && message.mentionAll)
) {
const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo> const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo>
if (messageType) { if (messageType) {
const key = m[messageType] const key = m[messageType]
if (key && 'contextInfo' in key) { if (key && 'contextInfo' in key && !!key.contextInfo) {
key.contextInfo = key.contextInfo || {} key.contextInfo.mentionedJid = message.mentions
if (message.mentions?.length) { } else if (key) {
key.contextInfo.mentionedJid = message.mentions key.contextInfo = {
} mentionedJid: message.mentions
if (message.mentionAll) {
key.contextInfo.nonJidMentions = 1
} }
} }
} }
+1 -196
View File
@@ -40,7 +40,6 @@ import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history' import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger' import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js' import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
import { buildMergedTcTokenIndexWrite } from './tc-token-utils'
type ProcessMessageContext = { type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean shouldProcessHistoryMsg: boolean
@@ -63,163 +62,6 @@ const REAL_MSG_STUB_TYPES = new Set([
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD]) const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
/**
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
* over the chat table during history sync.
*
* Why this matters: when a user logs in on a new device, the multi-device history sync
* is the only way that device learns about tctokens issued/received on the original
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
* the contact triggers a fresh notification which surfaces as error 463 in production.
*
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
* reissue that fired between history-sync chunks).
*
* Index hygiene: every JID we write here is added to the persistent prune index
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
* messages-recv picks them up across sessions.
*/
/**
* Single-concurrency queue for `storeTcTokensFromHistorySync` calls.
*
* Why: the function does read-then-write merges (`keyStore.get('tctoken', ...)`
* compute `keyStore.set(...)`) which are NOT atomic at the store level. If two
* history-sync chunks invoke this concurrently (common during reconnect / QR
* scan), an older chunk that started first can `keyStore.set` AFTER a newer
* chunk, overwriting the newer entry and worse, the merged `__index` write
* can drop JIDs the other chunk just added. Result: stale tcTokens / repeat 463
* sends until the next opportunistic refetch.
*
* Serialising via a chained Promise keeps the runs ordered while still freeing
* the calling `processMessage` to emit `messaging-history.set` immediately
* (the chain is fire-and-forget at the call site). Errors don't break the chain
* each `catch` resets it to `Promise.resolve()` so a single failure can't
* stall future runs.
*
* The chain is module-scoped (one per Node process). Multiple Baileys instances
* sharing this module will serialise across instances too, but their writes
* target different keyStores so there's no correctness gain only a tiny loss
* of inter-instance parallelism for tcToken syncs, which is acceptable given
* how rarely this runs vs. how rare cross-instance contention is.
*/
let historyTcTokenChain: Promise<void> = Promise.resolve()
function scheduleHistoryTcTokenSync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
): void {
historyTcTokenChain = historyTcTokenChain
.catch(() => {
/* swallow prior error so chain stays alive */
})
.then(() => storeTcTokensFromHistorySync(chats, signalRepository, keyStore, logger))
.catch(err => {
logger?.warn({ err }, 'background tctoken history-sync persistence failed')
})
}
async function storeTcTokensFromHistorySync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
// Cheap filter first — most chats in a sync chunk don't carry tcToken at all,
// and we want to avoid spinning up promises for them.
const tokenChats = chats.filter(chat => {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
return !!chat.tcToken?.length && ts > 0
})
if (!tokenChats.length) {
return
}
// Pre-normalize so the rest of the pipeline is a synchronous join.
const normalized = tokenChats.map(chat => ({
chat,
ts: toNumber(chat.tcTokenTimestamp!),
jid: jidNormalizedUser(chat.id!)
}))
// BATCHED LID resolution. The previous shape called getLIDForPN once per
// chat (sequential await inside a for-of), which became the bottleneck
// during heavy history sync — every cold-cache hit was a DB round-trip,
// stalling messaging-history.set and spilling into the event-buffer.
// `getLIDsForPNs` resolves a deduped list in ONE batched query (and shares
// USync retry across PNs that miss cache), turning O(N) round-trips into 1.
//
// LID inputs (and `@hosted.lid`) skip the lookup entirely — they're already
// the storage form. Failures degrade gracefully: a missing mapping just
// stores under the original jid, matching `resolveTcTokenJid`'s null branch.
const pnsToResolve = [...new Set(normalized.filter(({ jid }) => !isLidUser(jid)).map(({ jid }) => jid))]
const pnToLid = new Map<string, string>()
if (pnsToResolve.length) {
try {
const mappings = await signalRepository.lidMapping.getLIDsForPNs(pnsToResolve)
// Flat loop (continue-on-skip) keeps max nesting depth at 4 for lint.
for (const { pn, lid } of mappings ?? []) {
if (!pn || !lid) continue
pnToLid.set(jidNormalizedUser(pn), lid)
}
} catch (err) {
// Per-chat fallback below (storageJid := jid). Don't abort the chunk —
// CodeRabbit noted that all-or-nothing rejection here would drop every
// tctoken in the batch AND prevent messaging-history.set from firing.
logger?.warn({ err }, 'storeTcTokensFromHistorySync: getLIDsForPNs batch failed; falling back to per-chat jid')
}
}
const candidates = normalized.map(({ chat, ts, jid }) => ({
storageJid: pnToLid.get(jid) ?? jid,
token: Buffer.from(chat.tcToken!),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
}))
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
for (const c of candidates) {
// Same-batch dedup: when two chats resolve to the same storageJid (e.g. PN+LID
// aliases collapsing through resolveTcTokenJid, or duplicate chunks across
// retries), prefer the value already written by an earlier iteration so a
// lower-ts entry can't overwrite a higher-ts one captured from `existing`.
const existingEntry = entries[c.storageJid] ?? existing[c.storageJid]
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
// Strict > guard: equal timestamps are skipped so we never clobber
// senderTimestamp written by other layers (issuance after send, etc).
if (existingTs > 0 && existingTs >= c.ts) {
continue
}
entries[c.storageJid] = {
...existingEntry,
token: c.token,
timestamp: String(c.ts),
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
}
}
if (Object.keys(entries).length) {
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
try {
// Include updated __index so cross-session pruning picks these JIDs up.
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
} catch (err) {
logger?.warn({ err }, 'failed to store tctokens from history sync')
}
}
}
/** Cleans a received message to further processing */ /** Cleans a received message to further processing */
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => { export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
// ensure remoteJid and participant doesn't have device or agent in it // ensure remoteJid and participant doesn't have device or agent in it
@@ -576,12 +418,6 @@ const processMessage = async (
// Emit LID-PN mappings from history sync // Emit LID-PN mappings from history sync
// This is how WhatsApp Web learns mappings for chats with non-contacts // This is how WhatsApp Web learns mappings for chats with non-contacts
//
// MUST run BEFORE storeTcTokensFromHistorySync — otherwise resolveTcTokenJid()
// can't resolve PN→LID for fresh-device chats (mapping cache is empty), tokens
// get persisted under PN keys, and the send path (which resolves to LID first)
// misses them — exactly the error 463 scenario this whole change is meant to
// prevent. Catches Codex P1 review on PR #386.
if (data.lidPnMappings?.length) { if (data.lidPnMappings?.length) {
logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync') logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync')
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
@@ -606,29 +442,6 @@ const processMessage = async (
} }
} }
// Persist tctokens carried by history-sync chats in BACKGROUND, serialised.
//
// Originally awaited (PR #386) to avoid 463 on first multi-device send, but in
// production this drained the event buffer per-chunk and added visible delivery
// latency (especially after restart / QR scan when many chunks arrived at once).
//
// `scheduleHistoryTcTokenSync` enqueues onto a single-concurrency promise chain
// (see definition above) — chunks persist sequentially in the order they were
// emitted, preserving timestamp monotonicity AND keeping the `__index` write
// safe from concurrent merge clobbers. The call returns immediately so the
// `messaging-history.set` emit is not blocked.
//
// TRADE-OFF: a listener that fires an outbound send IMMEDIATELY after the emit
// may race the still-pending persistence and get a 463 on that specific send.
// The existing 463 handler in messages-recv.ts triggers a getPrivacyTokens()
// refetch that auto-recovers within seconds. Net result is much better UX than
// per-chunk stalls.
//
// DO NOT add `await` back here without re-evaluating production latency, AND
// DO NOT call storeTcTokensFromHistorySync directly — it must go through the
// chain to preserve write ordering across overlapping chunks.
scheduleHistoryTcTokenSync(data.chats, signalRepository, keyStore, logger)
ev.emit('messaging-history.set', { ev.emit('messaging-history.set', {
...data, ...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined, isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
@@ -950,19 +763,12 @@ const processMessage = async (
id: jid, id: jid,
author: message.key.participant!, author: message.key.participant!,
authorPn: message.key.participantAlt!, authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participants, participants,
action action
}) })
const emitGroupUpdate = (update: Partial<GroupMetadata>) => { const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [ ev.emit('groups.update', [
{ { id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt }
id: jid,
...update,
author: message.key.participant ?? undefined,
authorPn: message.key.participantAlt,
authorUsername: message.key.participantUsername
}
]) ])
} }
@@ -971,7 +777,6 @@ const processMessage = async (
id: jid, id: jid,
author: message.key.participant!, author: message.key.participant!,
authorPn: message.key.participantAlt!, authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participant: participant.lid, participant: participant.lid,
participantPn: participant.pn, participantPn: participant.pn,
action, action,
+19 -120
View File
@@ -2,7 +2,6 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto' import { createHash } from 'crypto'
import { zipSync } from 'fflate' import { zipSync } from 'fflate'
import { promises as fs } from 'fs' import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js' import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js' import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -102,49 +101,6 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false 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 * Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações * Preserva o buffer original se for WebP para manter EXIF e animações
@@ -158,25 +114,12 @@ export const isLottieBuffer = (buffer: Buffer): boolean => {
const convertToWebP = async ( const convertToWebP = async (
buffer: Buffer, buffer: Buffer,
logger?: ILogger logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => { ): Promise<{ webpBuffer: Buffer; isAnimated: 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) // Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) { if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer) const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer') logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated, isLottie: false } return { webpBuffer: buffer, isAnimated }
} }
// Tenta usar Sharp para converter // Tenta usar Sharp para converter
@@ -192,7 +135,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp') logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer() const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false, isLottie: false } return { webpBuffer, isAnimated: false }
} }
/** /**
@@ -395,11 +338,9 @@ export type PrepareStickerPackMessageOptions = {
* *
* **Especificações WhatsApp:** * **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial) * - 3-30 stickers por pack (oficial)
* - WebP ou Lottie/WAS (application/was) * - WebP obrigatório
* - Stickers: 512x512 pixels (auto-resize)
* - Recomendado: 100KB por sticker estático, 500KB animado * - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 96x96 pixels (PNG no ZIP) * - Tray icon: 252x252 pixels
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
* *
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher * @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger * @param options - Upload function and optional logger
@@ -523,36 +464,9 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker // Obtém buffer do sticker
const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`) const buffer = await mediaToBuffer(sticker.data, `sticker ${i + 1}`)
// Detecta formato e converte se necessário // Converte para WebP
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger) let { webpBuffer, isAnimated } = 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) // ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -569,7 +483,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) { if (lib?.sharp) {
// Try quality 70 // Try quality 70
try { try {
const compressed70 = await lib.sharp.default(webpBuffer).webp({ quality: 70 }).toBuffer() const compressed70 = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
if (compressed70.length <= MAX_STICKER_SIZE) { if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -584,7 +498,7 @@ export const prepareStickerPackMessage = async (
) )
} else { } else {
// Try quality 50 // Try quality 50
const compressed50 = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer() const compressed50 = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
// eslint-disable-next-line max-depth // eslint-disable-next-line max-depth
if (compressed50.length <= MAX_STICKER_SIZE) { if (compressed50.length <= MAX_STICKER_SIZE) {
@@ -633,13 +547,12 @@ export const prepareStickerPackMessage = async (
) )
} }
// Gera nome do arquivo: hash.webp ou hash.was (WABA: plain_file_hash.ext) // Gera nome do arquivo: hash.webp (deduplicação automática)
const sha256Hash = generateSha256Hash(webpBuffer) const sha256Hash = generateSha256Hash(webpBuffer)
const extension = isLottie ? 'was' : 'webp' const fileName = `${sha256Hash}.webp`
const fileName = `${sha256Hash}.${extension}`
logger?.trace( logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie }, { index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
'Sticker processed successfully' 'Sticker processed successfully'
) )
@@ -647,7 +560,6 @@ export const prepareStickerPackMessage = async (
fileName, fileName,
webpBuffer, webpBuffer,
isAnimated, isAnimated,
isLottie,
emojis: sticker.emojis || [], emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel accessibilityLabel: sticker.accessibilityLabel
} }
@@ -666,7 +578,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) { for (const result of processedStickers) {
if (!result) continue if (!result) continue
const { fileName, webpBuffer, isAnimated, isLottie, emojis, accessibilityLabel } = result const { fileName, webpBuffer, isAnimated, emojis, accessibilityLabel } = result
// SECURITY FIX #7: Check if this hash already exists (duplicate sticker) // SECURITY FIX #7: Check if this hash already exists (duplicate sticker)
const existingMetadata = metadataByHash.get(fileName) const existingMetadata = metadataByHash.get(fileName)
@@ -693,14 +605,13 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata // New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }] 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 = { const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName, fileName,
isAnimated, isAnimated,
emojis, emojis,
accessibilityLabel, accessibilityLabel,
isLottie, isLottie: false,
mimetype: isLottie ? 'application/was' : 'image/webp' mimetype: 'image/webp'
} }
metadataByHash.set(fileName, metadata) metadataByHash.set(fileName, metadata)
@@ -725,22 +636,10 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image') logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image') coverBuffer = await mediaToBuffer(cover, 'cover image')
// Tray icon uses PNG format, 96x96 pixels (official client standard) // Converte cover para WebP e adiciona ao ZIP
const lib = await getImageProcessingLibrary() const result = await convertToWebP(coverBuffer, logger)
if (!lib?.sharp) { coverWebP = result.webpBuffer
throw new Boom( coverFileName = `${stickerPackId}.webp`
'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 }] stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) { } catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, { throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
-1
View File
@@ -46,7 +46,6 @@ export const processContactAction = (
{ {
id, id,
name: action.fullName || action.firstName || action.username || undefined, name: action.fullName || action.firstName || action.username || undefined,
username: action.username || undefined,
lid: lidJid || undefined, lid: lidJid || undefined,
phoneNumber phoneNumber
} }
+11 -139
View File
@@ -1,86 +1,12 @@
import type { SignalKeyStoreWithTransaction } from '../Types' import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary' import type { BinaryNode } from '../WABinary'
import { import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
jidNormalizedUser
} from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */ /** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800 const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */ /** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4 const TC_TOKEN_NUM_BUCKETS = 4
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ¬PSA ¬Bot). Used to gate tctoken
* storage against malformed notifications WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/** /**
* Check if a received token is expired using WA Web's rolling bucket algorithm. * Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired * Reference: WAWebTrustedContactsUtils.isTokenExpired
@@ -137,43 +63,6 @@ export async function resolveTcTokenJid(
return lid ?? normalized return lid ?? normalized
} }
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Normalizes the JID upfront and uses the `isAny*` helpers so callers can pass
* `@c.us`, `@s.whatsapp.net`, `@hosted`, `@hosted.lid`, `@lid` or device-specific
* forms `LIDMappingStore.getLIDForPN` early-returns unless `isAnyPnUser`,
* so unnormalized inputs would silently bypass routing.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (issueToLid) {
if (isAnyLidUser(normalized)) return normalized
if (!isAnyPnUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
if (!isAnyLidUser(normalized)) return normalized
if (getPNForLID) {
const pn = await getPNForLID(normalized)
return pn ?? normalized
}
return normalized
}
type TcTokenParams = { type TcTokenParams = {
jid: string jid: string
baseContent?: BinaryNode[] baseContent?: BinaryNode[]
@@ -246,13 +135,7 @@ export async function storeTcTokensFromIqResult({
continue continue
} }
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's. const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN) const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid]) const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid] const existingEntry = existingTcData[storageJid]
@@ -271,26 +154,15 @@ export async function storeTcTokensFromIqResult({
continue continue
} }
const tokenEntry = { await keys.set({
...existingEntry, tctoken: {
token: Buffer.from(tokenNode.content), [storageJid]: {
timestamp: tokenNode.attrs.t, ...existingEntry,
// WABA Android: resets real_issue_timestamp to null when storing a new token token: Buffer.from(tokenNode.content),
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null) timestamp: tokenNode.attrs.t
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
}
if (normalizedFallback !== storageJid) {
keysToStore[normalizedFallback] = tokenEntry
}
await keys.set({ tctoken: keysToStore })
onNewJidStored?.(storageJid) onNewJidStored?.(storageJid)
} }
} }
+31 -18
View File
@@ -3,7 +3,6 @@ import { createHash } from 'crypto'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
import { import {
KEY_BUNDLE_TYPE, KEY_BUNDLE_TYPE,
PROTOCOL_MODE,
WA_ADV_ACCOUNT_SIG_PREFIX, WA_ADV_ACCOUNT_SIG_PREFIX,
WA_ADV_DEVICE_SIG_PREFIX, WA_ADV_DEVICE_SIG_PREFIX,
WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
@@ -21,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
secondary: config.version[1], secondary: config.version[1],
tertiary: config.version[2] tertiary: config.version[2]
}, },
platform: proto.ClientPayload.UserAgent.Platform.WEB, platform: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE, releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1', osVersion: '0.1',
device: 'Desktop', device: 'Desktop',
@@ -59,16 +58,18 @@ const getClientPayload = (config: SocketConfig) => {
userAgent: getUserAgent(config) userAgent: getUserAgent(config)
} }
// Native protocol (WAM\x05) does not use WebInfo — only web protocol does payload.webInfo = getWebInfo(config)
if (PROTOCOL_MODE !== 'native') {
payload.webInfo = getWebInfo(config)
}
return payload return payload
} }
export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => { export const generateLoginNode = (userJid: string, config: SocketConfig): proto.IClientPayload => {
const { user, device } = jidDecode(userJid)! const decoded = jidDecode(userJid)
if (!decoded) {
throw new Boom('Invalid user JID', { statusCode: 400 })
}
const { user, device } = decoded
const payload: proto.IClientPayload = { const payload: proto.IClientPayload = {
...getClientPayload(config), ...getClientPayload(config),
passive: true, passive: true,
@@ -157,6 +158,9 @@ export const configureSuccessfulPairing = (
}: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'> }: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => { ) => {
const msgId = stanza.attrs.id const msgId = stanza.attrs.id
if (!msgId) {
throw new Boom('Missing message ID', { statusCode: 400 })
}
const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success') const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success')
@@ -172,42 +176,51 @@ export const configureSuccessfulPairing = (
const bizName = businessNode?.attrs.name const bizName = businessNode?.attrs.name
const jid = deviceNode.attrs.jid const jid = deviceNode.attrs.jid
const lid = deviceNode.attrs.lid const lid = deviceNode.attrs.lid
if (!jid || !lid) {
throw new Boom('Missing JID or LID in device node', { statusCode: 400 })
}
const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer) const { details, hmac, accountType } = proto.ADVSignedDeviceIdentityHMAC.decode(deviceIdentityNode.content as Buffer)
if (!details || !hmac) {
throw new Boom('Missing ADV signature fields', { statusCode: 400 })
}
let hmacPrefix = Buffer.from([]) let hmacPrefix = Buffer.from([])
if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) { if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) {
hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
} }
const advSign = hmacSign(Buffer.concat([hmacPrefix, details!]), Buffer.from(advSecretKey, 'base64')) const advSign = hmacSign(Buffer.concat([hmacPrefix, details]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac!, advSign) !== 0) { if (Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid account signature') throw new Boom('Invalid account signature')
} }
const account = proto.ADVSignedDeviceIdentity.decode(details!) const account = proto.ADVSignedDeviceIdentity.decode(details)
const { accountSignatureKey, accountSignature, details: deviceDetails } = account const { accountSignatureKey, accountSignature, details: deviceDetails } = account
if (!accountSignatureKey || !accountSignature || !deviceDetails) {
throw new Boom('Missing ADV account fields', { statusCode: 400 })
}
const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails!) const deviceIdentity = proto.ADVDeviceIdentity.decode(deviceDetails)
const accountSignaturePrefix = const accountSignaturePrefix =
deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED
? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX ? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
: WA_ADV_ACCOUNT_SIG_PREFIX : WA_ADV_ACCOUNT_SIG_PREFIX
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails!, signedIdentityKey.public]) const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey!, accountMsg, accountSignature!)) { if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature') throw new Boom('Failed to verify account signature')
} }
const deviceMsg = Buffer.concat([ const deviceMsg = Buffer.concat([
WA_ADV_DEVICE_SIG_PREFIX, WA_ADV_DEVICE_SIG_PREFIX,
deviceDetails!, deviceDetails,
signedIdentityKey.public, signedIdentityKey.public,
accountSignatureKey! accountSignatureKey
]) ])
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg) account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(lid!, accountSignatureKey!) const identity = createSignalIdentity(lid, accountSignatureKey)
const accountEnc = encodeSignedDeviceIdentity(account, false) const accountEnc = encodeSignedDeviceIdentity(account, false)
const reply: BinaryNode = { const reply: BinaryNode = {
@@ -215,7 +228,7 @@ export const configureSuccessfulPairing = (
attrs: { attrs: {
to: S_WHATSAPP_NET, to: S_WHATSAPP_NET,
type: 'result', type: 'result',
id: msgId! id: msgId
}, },
content: [ content: [
{ {
@@ -234,7 +247,7 @@ export const configureSuccessfulPairing = (
const authUpdate: Partial<AuthenticationCreds> = { const authUpdate: Partial<AuthenticationCreds> = {
account, account,
me: { id: jid!, name: bizName, lid }, me: { id: jid, name: bizName, lid },
signalIdentities: [...(signalIdentities || []), identity], signalIdentities: [...(signalIdentities || []), identity],
platform: platformNode?.attrs.name platform: platformNode?.attrs.name
} }
+3 -29
View File
@@ -13,37 +13,11 @@ export class USyncContactProtocol implements USyncQueryProtocol {
} }
getUserElement(user: USyncUser): BinaryNode { getUserElement(user: USyncUser): BinaryNode {
if (user.phone) { //TODO: Implement type / username fields (not yet supported)
return {
tag: 'contact',
attrs: {},
content: user.phone
}
}
if (user.username) {
return {
tag: 'contact',
attrs: {
username: user.username,
...(user.usernameKey ? { pin: user.usernameKey } : {}),
...(user.lid ? { lid: user.lid } : {})
}
}
}
if (user.type) {
return {
tag: 'contact',
attrs: {
type: user.type
}
}
}
return { return {
tag: 'contact', tag: 'contact',
attrs: {} attrs: {},
content: user.phone
} }
} }
@@ -1,28 +0,0 @@
import type { USyncQueryProtocol } from '../../Types/USync'
import { assertNodeErrorFree, type BinaryNode } from '../../WABinary'
import { USyncUser } from '../USyncUser'
export class USyncUsernameProtocol implements USyncQueryProtocol {
name = 'username'
getQueryElement(): BinaryNode {
return {
tag: 'username',
attrs: {}
}
}
getUserElement(user: USyncUser): BinaryNode | null {
void user
return null
}
parser(node: BinaryNode): string | null {
if (node.tag === 'username') {
assertNodeErrorFree(node)
return typeof node.content === 'string' ? node.content : null
}
return null
}
}
-1
View File
@@ -2,4 +2,3 @@ export * from './USyncDeviceProtocol'
export * from './USyncContactProtocol' export * from './USyncContactProtocol'
export * from './USyncStatusProtocol' export * from './USyncStatusProtocol'
export * from './USyncDisappearingModeProtocol' export * from './USyncDisappearingModeProtocol'
export * from './USyncUsernameProtocol'
+9 -22
View File
@@ -6,12 +6,11 @@ import {
USyncContactProtocol, USyncContactProtocol,
USyncDeviceProtocol, USyncDeviceProtocol,
USyncDisappearingModeProtocol, USyncDisappearingModeProtocol,
USyncStatusProtocol, USyncStatusProtocol
USyncUsernameProtocol
} from './Protocols' } from './Protocols'
import { USyncUser } from './USyncUser' import { USyncUser } from './USyncUser'
export type USyncQueryResultList = { [protocol: string]: unknown; id: string; rawId?: number } export type USyncQueryResultList = { [protocol: string]: unknown; id: string }
export type USyncQueryResult = { export type USyncQueryResult = {
list: USyncQueryResultList[] list: USyncQueryResultList[]
@@ -69,8 +68,10 @@ export class USyncQuery {
//TODO: see if there are any errors in the result node //TODO: see if there are any errors in the result node
//const resultNode = getBinaryNodeChild(usyncNode, 'result') //const resultNode = getBinaryNodeChild(usyncNode, 'result')
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] => const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
content.reduce((acc: USyncQueryResultList[], node) => {
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
const id = node?.attrs.jid const id = node?.attrs.jid
if (id) { if (id) {
const data = Array.isArray(node?.content) const data = Array.isArray(node?.content)
@@ -88,24 +89,15 @@ export class USyncQuery {
.filter(([, b]) => b !== null) as [string, unknown][] .filter(([, b]) => b !== null) as [string, unknown][]
) )
: {} : {}
const rawIdAttr = node?.attrs?.['raw_id'] acc.push({ ...data, id })
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
} }
return acc return acc
}, []) }, [])
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = parseNodeList(listNode.content)
}
const sideListNode = usyncNode ? getBinaryNodeChild(usyncNode, 'side_list') : undefined
if (sideListNode?.content && Array.isArray(sideListNode.content)) {
queryResult.sideList = parseNodeList(sideListNode.content)
} }
//TODO: implement side list
//const sideListNode = getBinaryNodeChild(usyncNode, 'side_list')
return queryResult return queryResult
} }
@@ -138,9 +130,4 @@ export class USyncQuery {
this.protocols.push(new USyncLIDProtocol()) this.protocols.push(new USyncLIDProtocol())
return this return this
} }
withUsernameProtocol() {
this.protocols.push(new USyncUsernameProtocol())
return this
}
} }
-12
View File
@@ -2,8 +2,6 @@ export class USyncUser {
id?: string id?: string
lid?: string lid?: string
phone?: string phone?: string
username?: string
usernameKey?: string
type?: string type?: string
personaId?: string personaId?: string
@@ -22,16 +20,6 @@ export class USyncUser {
return this return this
} }
withUsername(username: string) {
this.username = username
return this
}
withUsernameKey(usernameKey: string) {
this.usernameKey = usernameKey
return this
}
withType(type: string) { withType(type: string) {
this.type = type this.type = type
return this return this
@@ -297,52 +297,4 @@ describe('Identity Change Handling', () => {
expect(result.device).toBe(5) expect(result.device).toBe(5)
}) })
}) })
describe('onBeforeSessionRefresh callback', () => {
it('fires before assertSessions when a session refresh is about to run', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const callOrder: string[] = []
mockAssertSessions.mockImplementation(async () => {
callOrder.push('assertSessions')
return true
})
const onBeforeSessionRefresh = jest.fn((jid: string) => {
callOrder.push(`before:${jid}`)
})
const node = createIdentityChangeNode('user@s.whatsapp.net')
const ctx = { ...createContext(), onBeforeSessionRefresh }
const result = await handleIdentityChange(node, ctx)
expect(result.action).toBe('session_refreshed')
expect(callOrder).toEqual(['before:user@s.whatsapp.net', 'assertSessions'])
})
it('does not fire when the refresh is skipped (no_identity / offline / self)', async () => {
const onBeforeSessionRefresh = jest.fn()
// no identity node
const noIdentityNode: BinaryNode = {
tag: 'notification',
attrs: { from: 'a@s.whatsapp.net', type: 'encrypt' },
content: []
}
await handleIdentityChange(noIdentityNode, { ...createContext(), onBeforeSessionRefresh })
// offline notification
mockValidateSession.mockResolvedValue({ exists: true })
await handleIdentityChange(createIdentityChangeNode('b@s.whatsapp.net', '0'), {
...createContext(),
onBeforeSessionRefresh
})
// self primary
await handleIdentityChange(createIdentityChangeNode(mockMeId!), {
...createContext(),
onBeforeSessionRefresh
})
expect(onBeforeSessionRefresh).not.toHaveBeenCalled()
})
})
}) })
+1 -348
View File
@@ -1,17 +1,7 @@
import { jest } from '@jest/globals' import { jest } from '@jest/globals'
import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types' import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types'
import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils' import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils'
import { import { buildTcTokenFromJid, isTcTokenExpired, shouldSendNewTcToken } from '../../Utils/tc-token-utils'
buildMergedTcTokenIndexWrite,
buildTcTokenFromJid,
isRegularUser,
isTcTokenExpired,
readTcTokenIndex,
resolveIssuanceJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../../Utils/tc-token-utils'
import type { BinaryNode } from '../../WABinary' import type { BinaryNode } from '../../WABinary'
/** 7 days in seconds — matches WA Web tctoken_duration */ /** 7 days in seconds — matches WA Web tctoken_duration */
@@ -858,340 +848,3 @@ describe('tctoken integration scenarios', () => {
}) })
}) })
}) })
// ─── isRegularUser (PSA / bot / MetaAI gating) ─────────────────────────
describe('isRegularUser', () => {
it('rejects undefined / empty', () => {
expect(isRegularUser(undefined)).toBe(false)
expect(isRegularUser('')).toBe(false)
})
it('rejects PSA WID (user "0")', () => {
expect(isRegularUser('0@s.whatsapp.net')).toBe(false)
expect(isRegularUser('0@c.us')).toBe(false)
})
it('rejects bot phone numbers (1313555XXXX, 131655500XX)', () => {
expect(isRegularUser('13135550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13135559999@s.whatsapp.net')).toBe(false)
// /^131655500\d{2}$/ — 11 digits: 131655500 + 2 trailing
expect(isRegularUser('13165550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13165550099@s.whatsapp.net')).toBe(false)
})
it('rejects MetaAI (@bot server)', () => {
expect(isRegularUser('foo@bot')).toBe(false)
})
it('accepts regular PN users (@s.whatsapp.net, @c.us)', () => {
expect(isRegularUser('5511999999999@s.whatsapp.net')).toBe(true)
expect(isRegularUser('5511999999999@c.us')).toBe(true)
})
it('accepts LID users', () => {
expect(isRegularUser('123456@lid')).toBe(true)
})
it('accepts hosted PN/LID users', () => {
expect(isRegularUser('5511999999999@hosted')).toBe(true)
expect(isRegularUser('123456@hosted.lid')).toBe(true)
})
})
// ─── resolveIssuanceJid (AB prop 14303 routing) ────────────────────────
describe('resolveIssuanceJid', () => {
const PN = '5511999999999@s.whatsapp.net'
const LID = '123456@lid'
const PN_NO_LID = '5511888888888@s.whatsapp.net'
const LID_NO_PN = '999999@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN) return LID
return null
})
const getPNForLID = jest.fn<(lid: string) => Promise<string | null>>(async (lid: string) => {
if (lid === LID) return PN
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
getPNForLID.mockClear()
})
describe('AB prop 14303 ON (issueToLid=true)', () => {
it('returns LID unchanged when input is LID', async () => {
expect(await resolveIssuanceJid(LID, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('resolves PN to LID via lidMapping', async () => {
expect(await resolveIssuanceJid(PN, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).toHaveBeenCalledWith(PN)
})
it('falls back to original PN when no LID mapping exists', async () => {
expect(await resolveIssuanceJid(PN_NO_LID, true, getLIDForPN, getPNForLID)).toBe(PN_NO_LID)
})
})
describe('AB prop 14303 OFF (issueToLid=false)', () => {
it('returns PN unchanged when input is PN', async () => {
expect(await resolveIssuanceJid(PN, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).not.toHaveBeenCalled()
})
it('resolves LID to PN via lidMapping', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).toHaveBeenCalledWith(LID)
})
it('falls back to original LID when no PN mapping exists', async () => {
expect(await resolveIssuanceJid(LID_NO_PN, false, getLIDForPN, getPNForLID)).toBe(LID_NO_PN)
})
it('returns LID unchanged when getPNForLID is omitted', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN)).toBe(LID)
})
})
describe('JID normalization (handles @c.us and hosted forms)', () => {
const PN_CUS = '5511999999999@c.us'
const PN_NORMALIZED = '5511999999999@s.whatsapp.net'
const HOSTED_LID = '999999@hosted.lid'
it('normalizes @c.us before resolving (issueToLid=true)', async () => {
const fn = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN_NORMALIZED) return LID
return null
})
const result = await resolveIssuanceJid(PN_CUS, true, fn, getPNForLID)
expect(result).toBe(LID)
// Mapping store called with NORMALIZED form
expect(fn).toHaveBeenCalledWith(PN_NORMALIZED)
})
it('treats hosted LID as LID input (issueToLid=true returns it unchanged)', async () => {
expect(await resolveIssuanceJid(HOSTED_LID, true, getLIDForPN, getPNForLID)).toBe(HOSTED_LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('treats hosted LID as LID input (issueToLid=false converts via getPNForLID)', async () => {
const fn = jest.fn<(lid: string) => Promise<string | null>>(async () => null)
await resolveIssuanceJid(HOSTED_LID, false, getLIDForPN, fn)
expect(fn).toHaveBeenCalledWith(HOSTED_LID)
})
})
})
// ─── readTcTokenIndex / buildMergedTcTokenIndexWrite ───────────────────
describe('tctoken cross-session prune index', () => {
it('exports the sentinel key as "__index"', () => {
// Persisted state compatibility — tests freeze the value to catch unintended renames.
expect(TC_TOKEN_INDEX_KEY).toBe('__index')
})
describe('readTcTokenIndex', () => {
it('returns [] when the index entry is absent', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the index token buffer is empty', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.alloc(0) } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is corrupted', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('not-json') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is not an array', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('{"a":1}') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns the persisted JIDs', async () => {
const keys = createMockKeys()
const stored = ['a@lid', 'b@lid', 'c@s.whatsapp.net']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(stored)
})
it('filters out the sentinel key itself, empty strings, and non-strings', async () => {
const keys = createMockKeys()
const stored = ['a@lid', '', TC_TOKEN_INDEX_KEY, 42, null, 'b@lid']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(['a@lid', 'b@lid'])
})
})
describe('buildMergedTcTokenIndexWrite', () => {
it('merges added JIDs with the persisted set (de-duplicated)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(['a@lid', 'b@lid'])) }
})
const write = await buildMergedTcTokenIndexWrite(keys, ['b@lid', 'c@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded.sort()).toEqual(['a@lid', 'b@lid', 'c@lid'])
})
it('drops the sentinel key from the added set', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [TC_TOKEN_INDEX_KEY, 'a@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual(['a@lid'])
})
it('handles empty inputs', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual([])
})
})
})
// ─── storeTcTokensFromIqResult — isRegularUser gating + monotonicity ───
describe('storeTcTokensFromIqResult — gating and monotonicity', () => {
const PEER_PN = '5511999999999@s.whatsapp.net'
const PEER_LID = '123456@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PEER_PN) return PEER_LID
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
})
const buildIqResult = (jid: string, tokenBytes: Uint8Array, t: string): BinaryNode => ({
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid, type: 'trusted_contact', t },
content: tokenBytes
}
]
}
]
})
it('skips PSA WID (jid="0") even if returned in tokens block', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '0@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips bot phone numbers', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '13135550000@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips MetaAI (@bot server)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: 'meta-ai@bot',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('uses fallbackJid (NOT the token node jid which is own device) as storage source', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const onNewJidStored = jest.fn()
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid:1@s.whatsapp.net', Buffer.from([0xaa, 0xbb]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN,
onNewJidStored
})
expect(keys.set).toHaveBeenCalled()
const setArgs = (keys.set.mock.calls[0]?.[0] as { tctoken: Record<string, unknown> }).tctoken
expect(Object.keys(setArgs)).toContain(PEER_LID)
expect(onNewJidStored).toHaveBeenCalledWith(PEER_LID)
})
it('skips when incoming timestamp is older than existing (monotonicity)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[PEER_LID]: { token: Buffer.from([0x99]), timestamp: '1800000000' }
})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0xaa]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips timestamp-less tokens (would be immediately expired)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: {
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid: 'd@s.whatsapp.net', type: 'trusted_contact' },
content: Buffer.from([0x01])
}
]
}
]
},
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
})
-209
View File
@@ -1,209 +0,0 @@
import Long from 'long'
import $protobuf from 'protobufjs/minimal.js'
const $util = $protobuf.util
// proto implementation
function longToStringOld(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (!$util.Long) return String(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function' ? normalized.toUnsigned() : normalized
return prepared.toString()
}
// bigint implementation
function longToStringNew(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return (combined - (1n << 64n)).toString()
}
return combined.toString()
}
return String(value)
}
function longToNumberOld(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (!$util.Long) return Number(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function'
? normalized.toUnsigned()
: typeof normalized.toSigned === 'function'
? normalized.toSigned()
: normalized
return prepared.toNumber()
}
function longToNumberNew(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return Number(combined - (1n << 64n))
}
return Number(combined)
}
return Number(value)
}
describe('BigInt vs Long equivalence validation', () => {
// Test cases: [description, Long value, unsigned flag]
const testCases: [string, Long, boolean][] = [
// Basic values
['zero unsigned', Long.fromNumber(0, true), true],
['zero signed', Long.fromNumber(0, false), false],
['one unsigned', Long.fromNumber(1, true), true],
['one signed', Long.fromNumber(1, false), false],
// Typical WhatsApp timestamps (seconds since epoch)
['timestamp 2023', Long.fromNumber(1700000000, true), true],
['timestamp 2025', Long.fromNumber(1750000000, true), true],
['timestamp as signed', Long.fromNumber(1700000000, false), false],
// File sizes
['small file', Long.fromNumber(1024, true), true],
['medium file 10MB', Long.fromNumber(10485760, true), true],
['large file 2GB', Long.fromNumber(2147483648, true), true],
// Boundary values - 32-bit
['max int32', Long.fromNumber(2147483647, false), false],
['min int32', Long.fromNumber(-2147483648, false), false],
['max uint32', Long.fromNumber(4294967295, true), true],
// Around MAX_SAFE_INTEGER
['MAX_SAFE_INTEGER', Long.fromString('9007199254740991', false), false],
['MAX_SAFE_INTEGER + 1', Long.fromString('9007199254740992', false), false],
['MAX_SAFE_INTEGER unsigned', Long.fromString('9007199254740991', true), true],
// Large unsigned values
['large unsigned', Long.fromString('9999999999999999999', true), true],
['max uint64', Long.fromString('18446744073709551615', true), true],
['max uint64 - 1', Long.fromString('18446744073709551614', true), true],
// Signed negative values
['negative one', Long.fromNumber(-1, false), false],
['negative small', Long.fromNumber(-100, false), false],
['negative large', Long.fromNumber(-2147483648, false), false],
['min int64', Long.fromString('-9223372036854775808', false), false],
['max int64', Long.fromString('9223372036854775807', false), false],
['-1 as signed Long', Long.fromBits(-1, -1, false), false],
// Tricky bit patterns
['high=1, low=0', Long.fromBits(0, 1, true), true],
['high=0, low=-1 (0xFFFFFFFF)', Long.fromBits(-1, 0, true), true],
['high=-1, low=-1 unsigned (max uint64)', Long.fromBits(-1, -1, true), true],
['high=0x7FFFFFFF, low=0xFFFFFFFF (max int64)', Long.fromBits(-1, 0x7fffffff, false), false],
['high=0x80000000, low=0 (min int64)', Long.fromBits(0, -2147483648, false), false],
// Powers of 2
['2^32', Long.fromString('4294967296', true), true],
['2^32 signed', Long.fromString('4294967296', false), false],
['2^48', Long.fromString('281474976710656', true), true],
['2^63', Long.fromString('9223372036854775808', true), true]
]
describe('longToString equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToStringOld(longVal, unsigned)
const newResult = longToStringNew(longVal, unsigned)
expect(newResult).toBe(oldResult)
})
}
})
describe('longToNumber equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToNumberOld(longVal, unsigned)
const newResult = longToNumberNew(longVal, unsigned)
// For large values, both may lose precision equally (Number limitation)
// So we compare string representations
expect(newResult.toString()).toBe(oldResult.toString())
})
}
})
describe('non-Long inputs (fallback paths)', () => {
it('string passthrough', () => {
expect(longToStringNew('12345')).toBe('12345')
expect(longToStringNew('0')).toBe('0')
})
it('number passthrough', () => {
expect(longToStringNew(42)).toBe('42')
expect(longToStringNew(0)).toBe('0')
expect(longToStringNew(-1)).toBe('-1')
})
it('null/undefined fallback', () => {
expect(longToStringNew(null)).toBe('null')
expect(longToStringNew(undefined)).toBe('undefined')
})
it('object without low/high', () => {
expect(longToStringNew({ foo: 'bar' })).toBe('[object Object]')
})
// Defensive: a raw {low, high} JSON object can carry `high` as an
// unsigned 32-bit value (Long.fromBits would normalize via `| 0`,
// but plain JSON deserialization does not). Without `value.high | 0`
// the sign-bit check fails and the result is interpreted as unsigned.
// Upstream Baileys PR #2333 has this latent bug; InfiniteAPI fixes it.
it('signed sign-bit detection on raw unsigned high (-1 as {low: -1, high: 4294967295})', () => {
const raw = { low: -1, high: 0xffffffff }
// signed: -1
expect(longToStringNew(raw, false)).toBe('-1')
// unsigned: max uint64
expect(longToStringNew(raw, true)).toBe('18446744073709551615')
})
it('signed sign-bit detection on raw unsigned high (-4294967296 as {low: 0, high: 4294967295})', () => {
const raw = { low: 0, high: 0xffffffff }
// signed: -4294967296 (high word = -1 after normalization)
expect(longToStringNew(raw, false)).toBe('-4294967296')
// unsigned: 0xFFFFFFFF00000000 = 18446744069414584320
expect(longToStringNew(raw, true)).toBe('18446744069414584320')
})
})
describe('fuzz: random Long values', () => {
it('100 random unsigned values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, true)
const oldResult = longToStringOld(longVal, true)
const newResult = longToStringNew(longVal, true)
expect(newResult).toBe(oldResult)
}
})
it('100 random signed values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, false)
const oldResult = longToStringOld(longVal, false)
const newResult = longToStringNew(longVal, false)
expect(newResult).toBe(oldResult)
}
})
})
})
@@ -1,146 +0,0 @@
import { jest } from '@jest/globals'
import { proto, type WAMessage } from '../..'
import { DEFAULT_CONNECTION_CONFIG } from '../../Defaults'
import makeWASocket from '../../Socket'
import { makeSession, mockWebSocket } from '../TestUtils/session'
mockWebSocket()
// chats.ts treats a connection as a reconnection when EITHER signal is true:
// 1. authState.creds.accountSyncCounter > 0
// (at least one full history sync completed in a previous session)
// 2. socketSkippedOfflineBuffer (forwarded from socket.ts as `skipOfflineBuffer`)
// (socket.ts already decided to skip the offline buffer, e.g. because
// routingInfo was stale and was discarded on startup)
// On reconnection, AwaitingInitialSync is skipped and the buffer is flushed
// immediately so live messages are not held in the initial-sync window.
describe('Reconnection Sync Skip', () => {
it('should skip the history sync wait on reconnection (accountSyncCounter > 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Simulate a session that has already synced before
state.creds.accountSyncCounter = 1
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
// Simulate receiving pending notifications (triggers AwaitingInitialSync)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
// Emit a message immediately after — if the wait is skipped,
// the buffer should already be flushed and this message should be delivered.
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_RECONNECT' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be delivered immediately, NOT buffered
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should skip the history sync wait when socketSkippedOfflineBuffer is true (stale routingInfo, accountSyncCounter === 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh-looking session from the counter perspective
state.creds.accountSyncCounter = 0
// But routingInfo is present and we ask the socket to discard it on start.
// socket.ts will set hadStaleRoutingInfo=true → skipOfflineBuffer=true,
// which chats.ts forwards as socketSkippedOfflineBuffer. Without this
// branch, the buffers would be misaligned (offline buffer skipped while
// AwaitingInitialSync still waits) and live messages would stall.
state.creds.routingInfo = Buffer.from([1, 2, 3, 4])
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state,
clearRoutingInfoOnStart: true
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_STALE_ROUTING' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after stale routing reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should still wait for history sync on fresh pairing (both signals false)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh pairing — both reconnect signals are false:
// accountSyncCounter is 0 (default) and no stale routingInfo to clear.
state.creds.accountSyncCounter = 0
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_DURING_INITIAL_SYNC' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello during initial sync' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be BUFFERED (not delivered) because we're waiting for history sync
expect(messageListener).toHaveBeenCalledTimes(0)
// Drain the 2s AwaitingInitialSync timeout (chats.ts:1522) so its callback
// doesn't fire after Jest considers the test done — otherwise we get a
// "Cannot log after tests are done" warning from the post-teardown flush
// and the suite is flagged with --detectOpenHandles. After the timer
// fires, syncState becomes Online and the buffer flushes; that delivery
// is post-assertion and irrelevant to the test goal.
await new Promise(resolve => setTimeout(resolve, 2_100))
await sock.end(new Error('Test completed'))
await clear()
}, 10_000)
})
-88
View File
@@ -1,4 +1,3 @@
import Long from 'long'
import '../index.js' import '../index.js'
import { proto } from '../../WAProto/index.js' import { proto } from '../../WAProto/index.js'
@@ -29,91 +28,4 @@ describe('proto serialization', () => {
const json = message.toJSON() const json = message.toJSON()
expect(json.message?.imageMessage?.fileLength).toBe('1234567890123456789') expect(json.message?.imageMessage?.fileLength).toBe('1234567890123456789')
}) })
it('converts Long objects to strings correctly via BigInt fast path', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(1700000000, true),
message: {
imageMessage: {
fileLength: Long.fromString('9876543210', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('1700000000')
expect(json.message?.imageMessage?.fileLength).toBe('9876543210')
})
it('handles large unsigned Long values (> MAX_SAFE_INTEGER)', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true), // max uint64
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
it('handles zero and small Long values', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(0, true),
message: {
imageMessage: {
fileLength: Long.fromNumber(1, true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('0')
expect(json.message?.imageMessage?.fileLength).toBe('1')
})
it('roundtrips encode/decode with Long fields preserving values (> MAX_SAFE_INTEGER)', () => {
// Use uint64 values above Number.MAX_SAFE_INTEGER (2^53 - 1) so the
// roundtrip actually exercises the BigInt fast path on decode->toJSON.
// Safe integers would silently work even if longToString fell back to
// Number(value).
const original = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true),
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const encoded = proto.WebMessageInfo.encode(original).finish()
const decoded = proto.WebMessageInfo.decode(encoded)
const json = decoded.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
}) })
+2 -11
View File
@@ -28,16 +28,7 @@ console.info = function (...args: unknown[]) {
// Track errors by type + JID to avoid duplicates (using Map for better performance) // Track errors by type + JID to avoid duplicates (using Map for better performance)
const _errorTimestamps = new Map<string, number>() const _errorTimestamps = new Map<string, number>()
// Dedup window for repeated decrypt-error console lines (Bad MAC / Counter / etc). const DEDUP_WINDOW_MS = 150
// Was 150ms, but retry attempts of the SAME message are typically ~300-1000ms apart,
// so the second attempt fell outside the window and double-printed.
//
// TRADE-OFF: dedup key is `errorType + JID` (no message-id). With 5s, a burst of
// errors for the SAME JID — even of slightly different categories or different
// messages — collapses to one log line every 5s. This is intentional for a noisy
// production stream; if you need per-message visibility, set BAILEYS_LOG_LEVEL=debug
// to bypass this console-side dedup and see the structured pino logs in full.
const DEDUP_WINDOW_MS = 5000
console.error = function (...args: unknown[]) { console.error = function (...args: unknown[]) {
if (args.length > 0 && typeof args[0] === 'string') { if (args.length > 0 && typeof args[0] === 'string') {
@@ -79,7 +70,7 @@ console.error = function (...args: unknown[]) {
const lastTime = _errorTimestamps.get(dedupeKey) const lastTime = _errorTimestamps.get(dedupeKey)
if (lastTime && now - lastTime < DEDUP_WINDOW_MS) { if (lastTime && now - lastTime < DEDUP_WINDOW_MS) {
return // Skip duplicate within DEDUP_WINDOW_MS window return // Skip duplicate within 150ms window
} }
_errorTimestamps.set(dedupeKey, now) _errorTimestamps.set(dedupeKey, now)
+11 -18
View File
@@ -388,13 +388,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@borewit/text-codec@npm:^0.2.2":
version: 0.2.2
resolution: "@borewit/text-codec@npm:0.2.2"
checksum: 10c0/2d3fb132bc6a132914a8fbf8e9ff2fa1ead210ecc395b28bb7355bd7719548a5e351ffe39f21c3bee8048f6cabd99eabd404bb5cc809cad9cba25abed19d271f
languageName: node
linkType: hard
"@cacheable/node-cache@npm:^2.0.1": "@cacheable/node-cache@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "@cacheable/node-cache@npm:2.0.1" resolution: "@cacheable/node-cache@npm:2.0.1"
@@ -3112,7 +3105,7 @@ __metadata:
libsignal: "github:whiskeysockets/libsignal-node" libsignal: "github:whiskeysockets/libsignal-node"
link-preview-js: "npm:^4.0.0" link-preview-js: "npm:^4.0.0"
lru-cache: "npm:^11.2.6" lru-cache: "npm:^11.2.6"
music-metadata: "npm:^11.12.3" music-metadata: "npm:^11.12.0"
open: "npm:^11.0.0" open: "npm:^11.0.0"
p-queue: "npm:^9.0.0" p-queue: "npm:^9.0.0"
pino: "npm:^10.3.1" pino: "npm:^10.3.1"
@@ -4563,15 +4556,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"file-type@npm:^21.3.1": "file-type@npm:^21.3.0":
version: 21.3.1 version: 21.3.0
resolution: "file-type@npm:21.3.1" resolution: "file-type@npm:21.3.0"
dependencies: dependencies:
"@tokenizer/inflate": "npm:^0.4.1" "@tokenizer/inflate": "npm:^0.4.1"
strtok3: "npm:^10.3.4" strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.1" token-types: "npm:^6.1.1"
uint8array-extras: "npm:^1.4.0" uint8array-extras: "npm:^1.4.0"
checksum: 10c0/66a8eda781c803c6fc372464abba88cee564cfe30f029716f06909da1564bc51d0a4e8d34654b881dc751cdb0513338b3c0747e06a608cee0dd5c5499b018d47 checksum: 10c0/1b1fa909e6063044a6da1d2ea348ee4d747ed9286382d3f0d4d6532c11fb2ea9f2e7e67b2bc7d745d1bc937e05dee1aa8cb912c64250933bcb393a3744f4e284
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6511,21 +6504,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"music-metadata@npm:^11.12.3": "music-metadata@npm:^11.12.0":
version: 11.12.3 version: 11.12.0
resolution: "music-metadata@npm:11.12.3" resolution: "music-metadata@npm:11.12.0"
dependencies: dependencies:
"@borewit/text-codec": "npm:^0.2.2" "@borewit/text-codec": "npm:^0.2.1"
"@tokenizer/token": "npm:^0.3.0" "@tokenizer/token": "npm:^0.3.0"
content-type: "npm:^1.0.5" content-type: "npm:^1.0.5"
debug: "npm:^4.4.3" debug: "npm:^4.4.3"
file-type: "npm:^21.3.1" file-type: "npm:^21.3.0"
media-typer: "npm:^1.1.0" media-typer: "npm:^1.1.0"
strtok3: "npm:^10.3.4" strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.2" token-types: "npm:^6.1.2"
uint8array-extras: "npm:^1.5.0" uint8array-extras: "npm:^1.5.0"
win-guid: "npm:^0.2.1" win-guid: "npm:^0.2.1"
checksum: 10c0/57df905f51ec792e5b4c994645eab64d47d5608b9de11151f54b26fbd5f563598ec04d8aed7f244ef1479030d78ddab8af1ee84dfcee40a93b3241941b50999d checksum: 10c0/014a120b8941ab80452171def0ec3cba013041ba4b94f8061ad493a037d3423e6d316131a13fb0433404685ebd3c8e3ae2255c1bafaeb20673fcd73af2df0148
languageName: node languageName: node
linkType: hard linkType: hard