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
30 changed files with 862 additions and 7770 deletions
+4 -235
View File
@@ -1,7 +1,7 @@
syntax = "proto3"; syntax = "proto3";
package proto; package proto;
/// WhatsApp Version: 2.3000.1038024963 /// 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;
} }
} }
+17 -597
View File
File diff suppressed because it is too large Load Diff
+49 -4463
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,1038147544]} {"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' })
+54 -188
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,7 +69,7 @@ import {
recordMessageReceived, recordMessageReceived,
recordMessageRetry recordMessageRetry
} from '../Utils/prometheus-metrics.js' } from '../Utils/prometheus-metrics.js'
import { isTcTokenExpired, resolveTcTokenJid, storeTcTokensFromIqResult } from '../Utils/tc-token-utils' import { isTcTokenExpired, resolveTcTokenJid } from '../Utils/tc-token-utils'
import { import {
areJidsSameUser, areJidsSameUser,
type BinaryNode, type BinaryNode,
@@ -1216,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!
@@ -1314,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
@@ -1387,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'
} }
}, },
{ {
@@ -1406,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)
@@ -1443,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, {
@@ -1457,7 +1453,6 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
}) })
// When a session is refreshed (identity change), re-issue tctoken fire-and-forget // When a session is refreshed (identity change), re-issue tctoken fire-and-forget
// WABA Android: reissue stores senderTimestamp + realIssueTimestamp after IQ success
if (result.action === 'session_refreshed') { if (result.action === 'session_refreshed') {
const normalizedJid = jidNormalizedUser(from) const normalizedJid = jidNormalizedUser(from)
resolveTcTokenJid(normalizedJid, getLIDForPN) resolveTcTokenJid(normalizedJid, getLIDForPN)
@@ -1467,36 +1462,9 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) { if (entry?.token?.length && !isTcTokenExpired(entry.timestamp)) {
const senderTs = unixTimestampSeconds() const senderTs = unixTimestampSeconds()
logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' }) logTcToken('reissue', { jid: normalizedJid, reason: 'session_refreshed' })
getPrivacyTokens([normalizedJid], senderTs) getPrivacyTokens([normalizedJid], senderTs).catch(err => {
.then(async (iqResult) => { logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
await storeTcTokensFromIqResult({ })
result: iqResult,
fallbackJid: normalizedJid,
keys: authState.keys,
getLIDForPN,
onNewJidStored: (storedJid) => {
tcTokenKnownJids.add(storedJid)
scheduleTcTokenIndexSave()
}
})
// Persist senderTimestamp + realIssueTimestamp after IQ success
const currentData = await authState.keys.get('tctoken', [tcJid])
const currentEntry = currentData[tcJid]
await authState.keys.set({
tctoken: {
[tcJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: senderTs,
realIssueTimestamp: 0
}
}
})
logTcToken('reissue_ok', { jid: normalizedJid, reason: 'session_refreshed' })
})
.catch(err => {
logTcToken('reissue_fail', { jid: normalizedJid, error: err?.message })
})
} }
}) })
.catch(() => { .catch(() => {
@@ -1944,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
} }
} }
}) })
@@ -2037,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)
@@ -2243,60 +2208,18 @@ export const makeMessagesRecvSocket = (config: SocketConfig) => {
const handleMessage = async (node: BinaryNode) => { const handleMessage = async (node: BinaryNode) => {
if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) { if (shouldIgnoreJid(node.attrs.from!) && node.attrs.from !== S_WHATSAPP_NET) {
logger.trace({ from: node.attrs.from }, 'ignored message') logger.trace({ from: node.attrs.from }, 'ignored message')
// Send a clean ACK (no error code) so the server considers the
// message delivered. Using error 500 (UnhandledError) previously
// caused the server to retry delivery, generating duplicate traffic.
await sendMessageAck(node) await sendMessageAck(node)
return return
} }
const encNode = getBinaryNodeChild(node, 'enc') const encNode = getBinaryNodeChild(node, 'enc')
const unavailableNode = getBinaryNodeChild(node, 'unavailable') // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
if (encNode?.attrs.type === 'msmsg') {
// msmsg block removed — allow msmsg decryption (required for view-once on linked devices) logger.debug({ key: node.attrs.key }, 'ignored msmsg')
if (unavailableNode?.attrs?.type === 'view_once') { await sendMessageAck(node, NACK_REASONS.MissingMessageSecret)
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)
return return
} }
@@ -2413,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)
} }
@@ -2568,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)
} }
@@ -2577,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')
} }
@@ -2611,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) {
@@ -2922,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.
@@ -2975,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')
} }
+105 -337
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,
@@ -339,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 || ''
@@ -536,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> => {
@@ -602,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,
@@ -697,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[]
@@ -1041,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
} }
@@ -1052,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 = {
@@ -1074,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 () => {
@@ -1271,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
@@ -1394,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 =
@@ -1440,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
@@ -1483,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
}
}
]
}
]
}) })
} }
@@ -1551,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(
@@ -1582,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: {},
@@ -1640,79 +1474,27 @@ 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)
})
}
} }
if (tcTokenBuffer?.length) { if (tcTokenBuffer?.length) {
@@ -1743,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)
@@ -1752,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'
) )
@@ -1774,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)
@@ -1831,8 +1611,6 @@ 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
const currentData = await authState.keys.get('tctoken', [tcTokenJid]) const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid] const currentEntry = currentData[tcTokenJid]
await authState.keys.set({ await authState.keys.set({
@@ -1840,8 +1618,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
[tcTokenJid]: { [tcTokenJid]: {
...currentEntry, ...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0), token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp, senderTimestamp: issueTimestamp
realIssueTimestamp: 0
} }
} }
}) })
@@ -1927,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
} }
-4
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
@@ -187,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
+3 -16
View File
@@ -54,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
} }
/** /**
@@ -155,7 +153,6 @@ export type MessageReceiptType =
| 'sender' | 'sender'
| 'inactive' | 'inactive'
| 'played' | 'played'
| 'view_once_read'
| undefined | undefined
export type MediaConnInfo = { export type MediaConnInfo = {
@@ -179,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 */
@@ -993,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 {
+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)
} }
+44 -23
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'
@@ -392,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
@@ -504,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).
@@ -517,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,
+2 -34
View File
@@ -15,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)
@@ -375,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
@@ -424,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
} }
/** /**
+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}`
+7 -33
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,
@@ -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
} }
} }
} }
+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}`, {
+9 -20
View File
@@ -154,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
} }
+8 -15
View File
@@ -10,7 +10,7 @@ import {
} 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[]
@@ -68,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)
@@ -87,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
} }
+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