Compare commits

..

6 Commits

Author SHA1 Message Date
Renato Alcara cdab2e3c63 fix: address PR review — stale comments, createCallLink compat, relay guard
- Fix call link URL: remove media prefix (Frida shows https://call.whatsapp.com/<token>)
- Make createCallLink() backward compatible with chats.ts signature (event, timeoutMs)
- Guard CB:relay handler — require both callId AND callCreator before emitting
- Extract extractParticipants() helper to eliminate 3x code duplication

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:17:17 -03:00
Renato Alcara 3535a16b32 fix: address audit findings in call signaling implementation
- Fix handleCall to process ALL children of <call> node (not just first)
- Add CB:relay handler for top-level relay stanzas (TURN servers, tokens)
- Add 'video' and 'relay' to WACallUpdateType and getCallStatusFromNode
- Add <device-identity /> to voice call offers (verified via Frida capture)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:33:25 -03:00
Renato Alcara 919bc52c77 feat: complete call signaling — types, status handling, call link parsing
Complete the call signaling implementation with proper type definitions,
status handling, and call link URL construction:

- WACallEvent: add linkToken, linkCreator, participants, media, duration,
  terminateReason, connectedLimit fields
- WACallParticipant: new type for group/link call participants
- WACallUpdateType: add preaccept, transport, relaylatency, group_update,
  reminder, heartbeat, mute_v2, enc_rekey status types
- getCallStatusFromNode: handle all new call node tags
- handleCall: extract group_info, link_info, call_summary, participants
  from incoming call link/group stanzas
- createCallLink: parse server response to return token and full URL
  (https://call.whatsapp.com/video/<token>)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:20:32 -03:00
Renato Alcara 46622bd8c1 feat: implement complete call signaling from Frida captures
Add all remaining call signaling functions verified via Frida capture
on WhatsApp Android v2.26:

- acceptCall(): answer an incoming call with audio/video capabilities
- preacceptCall(): signal device capabilities before accepting
- sendRelayLatency(): report relay server latency measurements
- sendTransport(): send p2p/ICE transport candidates
- sendCallDuration(): log call duration to server after hangup
- muteCall(): mute/unmute during active call (MUTE_V2)
- sendHeartbeat(): keep group call alive
- sendEncRekey(): encryption re-key during call
- sendVideoState(): toggle video on/off during call

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:14:39 -03:00
Renato Alcara 664964216c feat: add call link support (create, query, join)
Add createCallLink(), queryCallLink(), and joinCallLink() functions
for WhatsApp call link feature. Stanza structures verified via Frida
capture on WhatsApp Android v2.26.

- createCallLink(media) creates a shareable call link
- queryCallLink(token, media) queries call info before joining
- joinCallLink(token, media) joins a call via its link token
  with proper audio/video/net/capability child nodes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:10:12 -03:00
Renato Alcara 1bbcebc59c feat: add offerCall and terminateCall for call signaling
Add outgoing call support with offerCall() and terminateCall() functions.
Stanza structures verified via Frida captures on WhatsApp Android v2.26,
matching real voice and video call flows.

- offerCall(jid, isVideo?) sends call offer with correct child nodes
  (privacy, audio×2, video if applicable, net, capability, enc, encopt)
- terminateCall(callId, callTo, ...) sends call termination with optional
  reason and duration attributes
- Both functions exported from the socket chain

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:06:21 -03:00
48 changed files with 1093 additions and 9663 deletions
+4 -235
View File
@@ -1,7 +1,7 @@
syntax = "proto3";
package proto;
/// WhatsApp Version: 2.3000.1038024963
/// WhatsApp Version: 2.3000.1034274421
message ADVDeviceIdentity {
optional uint32 rawId = 1;
@@ -74,7 +74,6 @@ message AIMediaCollectionMessage {
message AIMediaCollectionMetadata {
optional string collectionId = 1;
optional uint32 uploadOrderIndex = 2;
}
message AIQueryFanout {
@@ -252,12 +251,10 @@ message AIThreadInfo {
optional AIThreadClientInfo clientInfo = 2;
message AIThreadClientInfo {
optional AIThreadType type = 1;
optional string sourceChatJid = 2;
enum AIThreadType {
UNKNOWN = 0;
DEFAULT = 1;
INCOGNITO = 2;
SIDE_CHAT = 3;
}
}
@@ -345,14 +342,6 @@ message BotAgeCollectionMetadata {
}
}
message BotAgentDeepLinkMetadata {
optional string token = 1;
}
message BotAgentMetadata {
optional BotAgentDeepLinkMetadata deepLinkMetadata = 1;
}
message BotCapabilityMetadata {
repeated BotCapabilityType capabilities = 1;
enum BotCapabilityType {
@@ -414,19 +403,9 @@ message BotCapabilityMetadata {
RICH_RESPONSE_UR_BLOKS_ENABLED = 55;
RICH_RESPONSE_INLINE_LINKS_ENABLED = 56;
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 {
optional DocumentPluginType pluginType = 1;
enum DocumentPluginType {
@@ -658,7 +637,6 @@ message BotMetadata {
optional BotRenderingConfigMetadata botRenderingConfigMetadata = 36;
optional BotInfrastructureDiagnostics botInfrastructureDiagnostics = 37;
optional AIMediaCollectionMetadata aiMediaCollectionMetadata = 38;
optional BotCommandMetadata commandMetadata = 39;
optional bytes internalMetadata = 999;
}
@@ -709,8 +687,6 @@ enum BotMetricsEntryPoint {
WEB_INTRO_PANEL = 46;
WEB_NAVIGATION_BAR = 47;
GROUP_MEMBER = 54;
CHATLIST_SEARCH = 55;
NEW_CHAT_LIST = 56;
}
message BotMetricsMetadata {
optional string destinationId = 1;
@@ -1093,7 +1069,6 @@ message ClientPairingProps {
optional bool isSyncdPureLidSession = 2;
optional bool isSyncdSnapshotRecoveryEnabled = 3;
optional bool isHsThumbnailSyncEnabled = 4;
optional bytes subscriptionSyncPayload = 5;
}
message ClientPayload {
@@ -1131,7 +1106,6 @@ message ClientPayload {
optional bool paaLink = 44;
optional int32 preacksCount = 45;
optional int32 processingQueueSize = 46;
repeated string pairedPeripherals = 47;
enum AccountType {
DEFAULT = 0;
GUEST = 1;
@@ -1474,11 +1448,6 @@ message ContextInfo {
optional AdType adType = 25;
optional string wtwaWebsiteUrl = 26;
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 {
CTWA = 0;
CAWC = 1;
@@ -1633,8 +1602,6 @@ message Conversation {
optional bool limitSharingInitiatedByMe = 53;
optional bool maibaAiThreadEnabled = 54;
optional bool isMarketingMessageThread = 55;
optional bool isSenderNewAccount = 56;
optional uint32 afterReadDuration = 57;
enum EndOfHistoryTransferType {
COMPLETE_BUT_MORE_MESSAGES_REMAIN_ON_PRIMARY = 0;
COMPLETE_AND_NO_MORE_MESSAGE_REMAIN_ON_PRIMARY = 1;
@@ -1661,10 +1628,6 @@ message DeviceCapabilities {
message BusinessBroadcast {
optional bool importListEnabled = 1;
optional bool companionSupportEnabled = 2;
optional bool campaignSyncEnabled = 3;
optional bool insightsSyncEnabled = 4;
optional int32 recipientLimit = 5;
}
enum ChatLockSupportLevel {
@@ -1739,9 +1702,6 @@ message DeviceProps {
optional uint32 thumbnailSyncDaysLimit = 19;
optional uint32 initialSyncMaxMessagesPerChat = 20;
optional bool supportManusHistory = 21;
optional bool supportHatchHistory = 22;
repeated string supportedBotChannelFbids = 23;
optional bool supportInlineContacts = 24;
}
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 {
optional ClientHello clientHello = 2;
optional ServerHello serverHello = 3;
@@ -1946,8 +1896,6 @@ message HandshakeMessage {
optional bytes static = 1;
optional bytes payload = 2;
optional bytes extendedCiphertext = 3;
optional bytes paddedBytes = 4;
optional bool simulateXxkemFs = 5;
}
message ClientHello {
@@ -1956,31 +1904,13 @@ message HandshakeMessage {
optional bytes payload = 3;
optional bool useExtended = 4;
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 {
optional bytes ephemeral = 1;
optional bytes static = 2;
optional bytes payload = 3;
optional bytes extendedStatic = 4;
optional bytes paddingBytes = 5;
optional bytes extendedCiphertext = 6;
}
}
@@ -2004,8 +1934,6 @@ message HistorySync {
optional bytes shareableChatIdentifierEncryptionKey = 17;
repeated Account accounts = 18;
optional bytes nctSalt = 19;
repeated InlineContact inlineContacts = 20;
optional bool inlineContactsProvided = 21;
enum BotAIWaitListState {
IN_WAITLIST = 0;
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 {
repeated Point polygonVertices = 1;
optional bool shouldSkipConfirmation = 4;
@@ -2186,6 +2106,7 @@ message LimitSharing {
CHAT_SETTING = 1;
BIZ_SUPPORTS_FB_HOSTING = 2;
UNKNOWN_GROUP = 3;
DEPRECATION = 4;
}
}
@@ -2346,8 +2267,6 @@ message Message {
optional PollCreationMessage pollCreationMessageV6 = 119;
optional ConditionalRevealMessage conditionalRevealMessage = 120;
optional PollAddOptionMessage pollAddOptionMessage = 121;
optional EventInviteMessage eventInviteMessage = 122;
optional GroupRootKeyShare groupRootKeyShare = 123;
message AlbumMessage {
optional uint32 expectedImageCount = 2;
optional uint32 expectedVideoCount = 3;
@@ -2539,7 +2458,6 @@ message Message {
UNKNOWN = 0;
CONTROL_PASSED = 1;
CONTROL_TAKEN = 2;
INFO = 3;
}
message CloudAPIThreadControlNotificationContent {
optional string handoffNotificationText = 1;
@@ -2629,16 +2547,6 @@ message Message {
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 {
optional ContextInfo contextInfo = 1;
optional bool isCanceled = 2;
@@ -2890,13 +2798,6 @@ message Message {
optional bool securityNotificationEnabled = 1;
}
enum InsightDeliveryState {
SENT = 0;
DELIVERED = 1;
READ = 2;
REPLIED = 3;
QUICK_REPLIED = 4;
}
message InteractiveMessage {
optional Header header = 1;
optional Body body = 2;
@@ -3165,10 +3066,9 @@ message Message {
message MessageHistoryMetadata {
repeated string historyReceivers = 1;
optional int64 oldestMessageTimestampInWindow = 2;
optional int64 oldestMessageTimestamp = 2;
optional int64 messageCount = 3;
repeated string nonHistoryReceivers = 4;
optional int64 oldestMessageTimestampInBundle = 5;
}
message MessageHistoryNotice {
@@ -3229,11 +3129,6 @@ message Message {
optional int64 expiryTimestamp = 2;
optional bool incentiveEligible = 3;
optional string referralId = 4;
optional InviteType inviteType = 5;
enum InviteType {
DEFAULT = 0;
MAPPER = 1;
}
enum ServiceType {
UNKNOWN = 0;
FBPAY = 1;
@@ -3275,16 +3170,6 @@ message Message {
optional HistorySyncChunkRetryRequest historySyncChunkRetryRequest = 8;
optional GalaxyFlowAction galaxyFlowAction = 9;
optional CompanionCanonicalUserNonceFetchRequest companionCanonicalUserNonceFetchRequest = 10;
optional BizBroadcastInsightsContactListRequest bizBroadcastInsightsContactListRequest = 11;
optional BizBroadcastInsightsRefreshRequest bizBroadcastInsightsRefreshRequest = 12;
message BizBroadcastInsightsContactListRequest {
optional string campaignId = 1;
}
message BizBroadcastInsightsRefreshRequest {
optional string campaignId = 1;
}
message CompanionCanonicalUserNonceFetchRequest {
optional string registrationTraceId = 1;
}
@@ -3321,7 +3206,6 @@ message Message {
optional int32 onDemandMsgCount = 4;
optional int64 oldestMsgTimestampMs = 5;
optional string accountLid = 6;
optional bool supportInlineResponse = 7;
}
message PlaceholderMessageResendRequest {
@@ -3360,18 +3244,6 @@ message Message {
optional CompanionCanonicalUserNonceFetchResponse companionCanonicalUserNonceFetchRequestResponse = 9;
optional HistorySyncChunkRetryResponse historySyncChunkRetryResponse = 10;
optional FlowResponsesCsvBundle flowResponsesCsvBundle = 11;
optional BizBroadcastInsightsContactListResponse bizBroadcastInsightsContactListResponse = 12;
message BizBroadcastInsightsContactListResponse {
optional string campaignId = 1;
optional int64 timestampMs = 2;
repeated Message.PeerDataOperationRequestResponseMessage.PeerDataOperationResult.BizBroadcastInsightsContactState contacts = 3;
}
message BizBroadcastInsightsContactState {
optional string contactJid = 1;
optional Message.InsightDeliveryState state = 2;
}
message CompanionCanonicalUserNonceFetchResponse {
optional string nonce = 1;
optional string waFbid = 2;
@@ -3408,7 +3280,6 @@ message Message {
ERROR_REQUEST_ON_NON_SMB_PRIMARY = 4;
ERROR_HOSTED_DEVICE_NOT_CONNECTED = 5;
ERROR_HOSTED_DEVICE_LOGIN_TIME_NOT_SET = 6;
ERROR_MULTI_PROVIDER_NOT_CONFIGURED = 7;
}
message HistorySyncChunkRetryResponse {
optional Message.HistorySyncType syncType = 1;
@@ -3486,8 +3357,6 @@ message Message {
COMPANION_CANONICAL_USER_NONCE_FETCH = 9;
HISTORY_SYNC_CHUNK_RETRY = 10;
GALAXY_FLOW_ACTION = 11;
BUSINESS_BROADCAST_INSIGHTS_DELIVERED_TO = 12;
BUSINESS_BROADCAST_INSIGHTS_REFRESH = 13;
}
message PinInChatMessage {
optional MessageKey key = 1;
@@ -3689,7 +3558,6 @@ message Message {
message RequestWelcomeMessageMetadata {
optional LocalChatState localChatState = 1;
optional WelcomeTrigger welcomeTrigger = 2;
optional BotAgentMetadata botAgentMetadata = 3;
enum LocalChatState {
EMPTY = 0;
NON_EMPTY = 1;
@@ -3807,7 +3675,6 @@ message Message {
optional bool isLottie = 21;
optional string accessibilityLabel = 22;
optional int32 premium = 24;
optional string emojis = 25;
}
message StickerPackMessage {
@@ -4028,7 +3895,6 @@ message MessageContextInfo {
optional LimitSharing limitSharingV2 = 14;
repeated ThreadID threadId = 15;
optional WebLinkRenderConfig weblinkRenderConfig = 16;
optional bytes teeBotMetadata = 17;
enum MessageAddonExpiryType {
STATIC = 1;
DEPENDENT_ON_PARENT = 2;
@@ -4101,7 +3967,6 @@ message MsgOpaqueData {
optional string quarantineExtractedText = 48;
optional int64 pollEndTime = 49;
optional bool pollHideVoterNames = 50;
optional bool pollAllowAddOption = 52;
message EventLocation {
optional double degreesLatitude = 1;
optional double degreesLongitude = 2;
@@ -4218,11 +4083,6 @@ enum MutationProps {
NCT_SALT_SYNC_ACTION = 80;
BUSINESS_BROADCAST_CAMPAIGN_ACTION = 81;
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;
BUSINESS_BROADCAST_ACTION = 10002;
AI_THREAD_DELETE_ACTION = 10003;
@@ -4536,12 +4396,6 @@ message ReportingTokenInfo {
optional bytes reportingTag = 1;
}
message ScheduledMessageMetadata {
optional string revealKeyId = 1;
optional bytes revealKey = 2;
optional uint64 scheduledTime = 3;
}
message SenderKeyDistributionMessage {
optional uint32 id = 1;
optional uint32 iteration = 2;
@@ -4697,7 +4551,6 @@ message StatusAttribution {
APPLE_MUSIC = 8;
SHARECHAT = 9;
GOOGLE_PHOTOS = 10;
SOUNDCLOUD = 11;
}
}
@@ -4865,11 +4718,6 @@ message SyncActionValue {
optional NctSaltSyncAction nctSaltSyncAction = 80;
optional BusinessBroadcastCampaignAction businessBroadcastCampaignAction = 81;
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 {
optional string name = 1;
optional int32 deviceID = 2;
@@ -4889,10 +4737,6 @@ message SyncActionValue {
optional SyncActionValue.SyncActionMessageRange messageRange = 2;
}
message AutoOrganizeBusinessChatSetting {
optional bool autoOrganize = 1;
}
message AvatarUpdatedAction {
optional AvatarEventType eventType = 1;
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 {
optional bool isSent = 1;
}
@@ -4962,7 +4792,6 @@ message SyncActionValue {
repeated SyncActionValue.BroadcastListParticipant participants = 2;
optional string listName = 3;
repeated string labelIds = 4;
optional string audienceExpression = 5;
}
message CallLogAction {
@@ -5010,20 +4839,6 @@ message SyncActionValue {
repeated SyncActionValue.CustomPaymentMethod customPaymentMethods = 1;
}
message CustomerDataAction {
optional string chatJid = 1;
optional int32 contactType = 2;
optional string email = 3;
optional string altPhoneNumbers = 4;
optional int64 birthday = 5;
optional string address = 6;
optional int32 acquisitionSource = 7;
optional int32 leadStage = 8;
optional int64 lastOrder = 9;
optional int64 createdAt = 10;
optional int64 modifiedAt = 11;
}
message DeleteChatAction {
optional SyncActionValue.SyncActionMessageRange messageRange = 1;
}
@@ -5092,7 +4907,6 @@ message SyncActionValue {
DRAFTED = 8;
AI_HANDOFF = 9;
CHANNELS = 10;
AI_RESPONDING = 11;
}
}
@@ -5308,8 +5122,6 @@ message SyncActionValue {
optional bool isStatusNotificationEnabled = 29;
optional int32 statusNotificationToneId = 30;
optional bool shouldPlaySoundForCallNotification = 31;
optional string chatThemeId = 32;
optional string colorSchemeId = 33;
enum DisplayMode {
DISPLAY_MODE_UNKNOWN = 0;
ALWAYS = 1;
@@ -5354,8 +5166,6 @@ message SyncActionValue {
IS_STATUS_NOTIFICATION_ENABLED = 29;
STATUS_NOTIFICATION_TONE_ID = 30;
SHOULD_PLAY_SOUND_FOR_CALL_NOTIFICATION = 31;
CHAT_THEME_ID = 32;
COLOR_SCHEME_ID = 33;
}
enum SettingPlatform {
PLATFORM_UNKNOWN = 0;
@@ -5379,22 +5189,11 @@ message SyncActionValue {
repeated string userJid = 2;
optional bool shareToFB = 3;
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 {
ALLOW_LIST = 0;
DENY_LIST = 1;
CONTACTS = 2;
CLOSE_FRIENDS = 3;
CUSTOM_LIST = 4;
}
}
@@ -5420,29 +5219,6 @@ message SyncActionValue {
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 {
optional MessageKey key = 1;
optional int64 timestamp = 2;
@@ -5454,10 +5230,6 @@ message SyncActionValue {
repeated SyncActionValue.SyncActionMessage messages = 3;
}
message ThreadPinAction {
optional bool pinned = 1;
}
message TimeFormatAction {
optional bool isTwentyFourHourFormatEnabled = 1;
}
@@ -5579,6 +5351,7 @@ message TemplateButton {
message ThreadID {
optional ThreadType threadType = 1;
optional MessageKey threadKey = 2;
optional string sourceChatJid = 3;
enum ThreadType {
UNKNOWN = 0;
VIEW_REPLIES = 1;
@@ -5781,8 +5554,6 @@ message WebMessageInfo {
optional QuarantinedMessage quarantinedMessage = 77;
optional uint32 nonJidMentions = 78;
optional string hsmTag = 79;
optional uint64 ephemeralExpirationTimestamp = 80;
optional ScheduledMessageMetadata scheduledMessageMetadata = 81;
enum BizPrivacyStatus {
E2EE = 0;
FB = 2;
@@ -6022,8 +5793,6 @@ message WebMessageInfo {
GROUP_MEMBER_SHARE_GROUP_HISTORY_MODE = 221;
GROUP_OPEN_BOT_ADDED = 222;
GROUP_TEE_BOT_ADDED = 223;
CONTACT_INFO = 224;
SCHEDULED_MESSAGE_CREATED = 225;
}
}
+18 -26
View File
@@ -18,21 +18,14 @@ try {
'\tif (typeof value === "number") {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\t// BigInt.toString() is a native C++ operation, much faster than Long\'s pure JS division loops\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\t// Normalize high to signed int32 so the sign-bit check works for inputs\n' +
'\t\t// where high is stored unsigned (e.g. raw {low, high} JSON).\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn (combined - (1n << 64n)).toString();\n' +
'\t\t}\n' +
'\t\treturn combined.toString();\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn String(value);\n' +
'\t}\n' +
'\treturn String(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: normalized;\n' +
'\treturn prepared.toString();\n' +
'}\n\n'
const longToNumberHelper =
'function longToNumber(value, unsigned) {\n' +
@@ -40,20 +33,19 @@ try {
'\t\treturn value;\n' +
'\t}\n' +
'\tif (typeof value === "string") {\n' +
'\t\tconst numeric = Number(value);\n' +
'\t\treturn numeric;\n' +
'\t}\n' +
'\tif (!$util.Long) {\n' +
'\t\treturn Number(value);\n' +
'\t}\n' +
'\t// Fast path: convert Long {low, high} directly via native BigInt\n' +
'\tif (value && typeof value.low === "number" && typeof value.high === "number") {\n' +
'\t\tconst high = value.high | 0;\n' +
'\t\tconst lo = BigInt(value.low >>> 0);\n' +
'\t\tconst hi = BigInt(value.high >>> 0);\n' +
'\t\tconst combined = (hi << 32n) | lo;\n' +
'\t\tif (!unsigned && high < 0) {\n' +
'\t\t\treturn Number(combined - (1n << 64n));\n' +
'\t\t}\n' +
'\t\treturn Number(combined);\n' +
'\t}\n' +
'\treturn Number(value);\n' +
'\tconst normalized = $util.Long.fromValue(value);\n' +
'\tconst prepared = unsigned && normalized && typeof normalized.toUnsigned === "function"\n' +
'\t\t? normalized.toUnsigned()\n' +
'\t\t: typeof normalized.toSigned === "function"\n' +
'\t\t\t? normalized.toSigned()\n' +
'\t\t\t: normalized;\n' +
'\treturn prepared.toNumber();\n' +
'}\n\n'
if (!content.includes('function longToString(')) {
+17 -597
View File
File diff suppressed because it is too large Load Diff
+67 -4489
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-531
View File
@@ -1,531 +0,0 @@
# Mensagens View-Once (Visualização Única) — Guia de Implementação
**Data:** 2026-03-23
**Versão:** 1.0
---
## 1. O que é View-Once
Mensagem de visualização única é um tipo especial de mídia (imagem, vídeo ou áudio) que o destinatário pode abrir apenas uma vez. Após abrir, o conteúdo some permanentemente.
---
> ## ⚠️ RESTRIÇÃO CRÍTICA — Contas Hosted (Meta Business Cloud API)
>
> **Contas registradas como "hosted" na Meta Business Cloud API têm view-once bloqueado pelo servidor do WhatsApp.**
>
> Essa restrição se aplica a **todos os clientes** (Android, iOS, WA Web) e **não há como contornar por código** — é uma política imposta diretamente pelo servidor WA para contas hosted.
>
> **Como identificar se sua conta é hosted:**
> - A conta foi criada via Meta Business Cloud API (WABA)
> - Aparece como "Meta" ou "Business Cloud" nas configurações do WhatsApp Business Manager
>
> **Contas não-hosted** (registradas diretamente via QR code ou pair code) **funcionam normalmente** com view-once.
---
**Comportamento em cada dispositivo após envio via API (contas não-hosted):**
| Dispositivo | O que aparece |
|---|---|
| Destinatário (Android/iOS) | Recebe a mídia com ícone de olhinho — pode abrir uma vez |
| Android principal do remetente | Mostra na conversa que a mensagem foi enviada (ícone de olhinho) |
| WA Web do remetente | "Aguardando mensagem. Abra o WhatsApp no seu celular." |
> **Nota:** A mensagem "Aguardando mensagem..." no WA Web é o comportamento correto e esperado quando o envio vem de um companion (API). É uma limitação do protocolo WhatsApp — o servidor só permite que o celular principal notifique companions com o conteúdo da view-once.
---
## 2. Como Enviar via API
### 2.1 Imagem
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Olha só isso!",
"imageUrl": "https://exemplo.com/foto.jpg",
"viewOnce": true
}'
```
**Resposta de sucesso:**
```json
{ "ok": true }
```
### 2.2 Vídeo
```bash
curl -X POST https://sua-api.com/v1/messages/send_video \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"caption": "Assista uma vez",
"videoUrl": "https://exemplo.com/video.mp4",
"viewOnce": true
}'
```
### 2.3 Áudio
```bash
curl -X POST https://sua-api.com/v1/messages/send_audio \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"audioUrl": "https://exemplo.com/audio.ogg",
"ptt": false,
"viewOnce": true
}'
```
### 2.4 Usando Base64 (sem URL pública)
```bash
curl -X POST https://sua-api.com/v1/messages/send_image \
-H "Content-Type: application/json" \
-H "x-api-key: SEU_API_KEY" \
-d '{
"instance": "nome-da-instancia",
"to": "5511999999999",
"imageBase64": "data:image/jpeg;base64,/9j/4AAQSkZJRgAB...",
"viewOnce": true
}'
```
---
## 3. Como Receber via Webhook
Quando um contato envia uma mensagem de visualização única para o número conectado na API, o webhook recebe o evento com `key.isViewOnce = true`.
### 3.1 Estrutura do evento recebido
```json
{
"event": "messages.upsert",
"instance": "nome-da-instancia",
"data": {
"messages": [
{
"key": {
"remoteJid": "5511999999999@s.whatsapp.net",
"fromMe": false,
"id": "3EB0ABCDEF123456",
"isViewOnce": true
},
"message": {
"viewOnceMessageV2": {
"message": {
"imageMessage": {
"url": "https://mmg.whatsapp.net/...",
"mimetype": "image/jpeg",
"mediaKey": "base64...",
"fileEncSha256": "base64...",
"directPath": "/v/...",
"viewOnce": true
}
}
}
},
"messageTimestamp": 1742700000,
"pushName": "João Silva"
}
],
"type": "notify"
}
}
```
### 3.2 Como identificar que é view-once
O campo principal para identificar é `key.isViewOnce = true`. Existem dois caminhos para confirmar:
```javascript
function isViewOnce(msg) {
// Caminho 1: flag direta (mais simples)
if (msg.key?.isViewOnce) return true
// Caminho 2: verificar o wrapper da mensagem
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
return !!(
inner?.imageMessage?.viewOnce ||
inner?.videoMessage?.viewOnce ||
inner?.audioMessage?.viewOnce
)
}
```
### 3.3 Como acessar a mídia
```javascript
function getViewOnceMedia(msg) {
const inner =
msg.message?.viewOnceMessageV2?.message ||
msg.message?.viewOnceMessage?.message ||
msg.message?.viewOnceMessageV2Extension?.message
if (!inner) return null
if (inner.imageMessage) return { type: 'image', media: inner.imageMessage }
if (inner.videoMessage) return { type: 'video', media: inner.videoMessage }
if (inner.audioMessage) return { type: 'audio', media: inner.audioMessage }
return null
}
// Uso:
const media = getViewOnceMedia(msg)
if (media) {
console.log(`View-once ${media.type}`)
console.log('URL:', media.media.url)
console.log('DirectPath:', media.media.directPath)
console.log('MediaKey:', media.media.mediaKey) // necessário para download
}
```
---
## 4. Como Exibir numa Ferramenta (Frontend/CRM)
### 4.1 Lógica de detecção e exibição
```javascript
function renderMessage(msg) {
if (!isViewOnce(msg)) {
// Renderizar mensagem normal
return renderNormalMessage(msg)
}
const media = getViewOnceMedia(msg)
if (!media) return
// Verificar se já foi aberta (controle local da ferramenta)
const alreadyOpened = localStorage.getItem(`vo_${msg.key.id}`)
if (alreadyOpened) {
// Mostrar placeholder de "já visualizado"
return renderViewOncePlaceholder(media.type, msg.key.fromMe)
}
if (msg.key.fromMe) {
// Mensagem enviada por mim — nunca mostrar conteúdo
return renderSentViewOnce(media.type)
}
// Mensagem recebida — mostrar botão para "abrir uma vez"
return renderViewOnceButton(media.type, msg)
}
```
### 4.2 Componente de exibição — mensagem recebida
```javascript
function renderViewOnceButton(type, msg) {
const icons = { image: '📷', video: '🎥', audio: '🎵' }
const labels = { image: 'Foto', video: 'Vídeo', áudio: 'Áudio' }
return `
<div class="view-once-bubble received">
<span class="view-once-icon">${icons[type]}</span>
<span class="view-once-label">${labels[type]} de visualização única</span>
<button onclick="openViewOnce('${msg.key.id}', '${type}')">
Abrir
</button>
</div>
`
}
async function openViewOnce(msgId, type) {
// 1. Marcar como aberta ANTES de mostrar (só uma chance)
localStorage.setItem(`vo_${msgId}`, '1')
// 2. Baixar e mostrar a mídia
const mediaUrl = await downloadViewOnceMedia(msgId)
showMedia(type, mediaUrl)
// 3. Após fechar/visualizar, substituir pelo placeholder
onMediaClosed(() => {
replaceWithPlaceholder(msgId, type)
})
}
```
### 4.3 Componente de exibição — mensagem enviada
```javascript
function renderSentViewOnce(type) {
const labels = { image: 'Foto', video: 'Vídeo', audio: 'Áudio' }
return `
<div class="view-once-bubble sent">
<span class="view-once-icon">👁️</span>
<span>${labels[type]} de visualização única</span>
<span class="view-once-status">Enviada</span>
</div>
`
}
```
### 4.4 Placeholder após visualização
```javascript
function renderViewOncePlaceholder(type, fromMe) {
const labels = { image: 'foto', video: 'vídeo', audio: 'áudio' }
const text = fromMe
? `Você enviou uma ${labels[type]} de visualização única`
: `${labels[type]} de visualização única aberta`
return `
<div class="view-once-bubble placeholder">
<span class="view-once-icon">🚫</span>
<span>${text}</span>
</div>
`
}
```
### 4.5 CSS sugerido
```css
.view-once-bubble {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 12px;
max-width: 280px;
}
.view-once-bubble.received {
background: #f0f0f0;
color: #333;
}
.view-once-bubble.sent {
background: #dcf8c6;
color: #333;
align-self: flex-end;
}
.view-once-bubble.placeholder {
background: #e8e8e8;
color: #999;
font-style: italic;
}
.view-once-bubble button {
background: #25d366;
color: white;
border: none;
border-radius: 8px;
padding: 6px 12px;
cursor: pointer;
font-size: 13px;
}
```
---
## 5. Código Implementado na API
### 5.1 Geração da mensagem — `src/Utils/messages.ts`
Quando `viewOnce: true` é passado, a mídia é encapsulada no wrapper moderno `viewOnceMessageV2` (campo 55 do proto) e o flag `viewOnce=true` é setado na mídia interna:
```typescript
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Seta viewOnce=true na mensagem de mídia interna
if (m.imageMessage) m.imageMessage.viewOnce = true
else if (m.videoMessage) m.videoMessage.viewOnce = true
else if (m.audioMessage) m.audioMessage.viewOnce = true
// Usa viewOnceMessageV2 (campo 55) — formato atual do WA
m = { viewOnceMessageV2: { message: m } }
}
```
### 5.2 Envio — `src/Socket/messages-send.ts`
**Detecção de view-once real** (não confunde com botões/listas que também usam o wrapper `viewOnceMessage`):
```typescript
// Detecta view-once real pela flag viewOnce na mídia interna.
// viewOnceMessage* wrappers são também usados por botões/listas/carrosséis
// (que carregam interactiveMessage dentro) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
message.viewOnceMessageV2?.message ||
message.viewOnceMessage?.message ||
message.viewOnceMessageV2Extension?.message
const isViewOnceMsg = !!(
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
)
// Para view-once: DSM enviado apenas para o celular principal (device=0).
// Companions (device>0) são omitidos — o servidor WA gera
// <unavailable type="view_once"/> automaticamente para eles.
const viewOnceMeRecipients = isViewOnceMsg
? meRecipients.filter(jid => !jidDecode(jid)?.device)
: meRecipients
// assertSessions apenas para quem vai realmente receber
await assertSessions([...viewOnceMeRecipients, ...otherRecipients])
const [meNodes, otherNodes] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
// phash recalculado com os participantes reais
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...otherRecipients]
: [...meRecipients, ...otherRecipients]
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
}
```
**`getMediaType()` — garante que vídeo e áudio view-once sejam entregues:**
```typescript
const getMediaType = (message: proto.IMessage) => {
// Desencapsula wrapper view-once antes de verificar o tipo de mídia.
// Sem isso, message.videoMessage e message.audioMessage são undefined
// (estão dentro do wrapper) e o atributo mediatype ficaria ausente no
// enc node — o servidor WA descarta silenciosamente vídeo/áudio view-once.
const inner =
message.viewOnceMessage?.message ||
message.viewOnceMessageV2?.message ||
message.viewOnceMessageV2Extension?.message
if (inner) {
return getMediaType(inner)
}
if (message.imageMessage) return 'image'
else if (message.videoMessage) return 'video'
// ...
}
```
### 5.3 Recepção — `src/Utils/decode-wa-message.ts`
Quando a API recebe uma view-once como companion (linked device), o servidor envia dois stanzas:
- **Stanza 1** (`enc`): conteúdo completo com metadados da mídia
- **Stanza 2** (`unavailable type="view_once"`): sinal de sync
O stanza 1 é detectado e marcado corretamente:
```typescript
// Detecta view-once na stanza 1 recebida por linked device.
// O wrapper viewOnceMessage também é usado por mensagens interativas
// (interactiveMessage, listMessage) — esses NÃO têm viewOnce=true na mídia.
const viewOnceInner =
msg.viewOnceMessage?.message ||
msg.viewOnceMessageV2?.message ||
msg.viewOnceMessageV2Extension?.message
if (
viewOnceInner?.imageMessage?.viewOnce ||
viewOnceInner?.videoMessage?.viewOnce ||
viewOnceInner?.audioMessage?.viewOnce
) {
fullMessage.key.isViewOnce = true
}
```
---
## 6. Fluxo Completo
```
API envia view-once para destinatário
├── Protobuf gerado:
│ viewOnceMessageV2 {
│ message {
│ imageMessage { viewOnce: true, url, mediaKey, ... }
│ }
│ }
├── Stanza enviado ao servidor WA:
│ <message to="destinatario@s.whatsapp.net" type="media">
│ <enc type="pkmsg">...viewOnceMessageV2 cifrado...</enc> ← para o destinatário
│ <participants>
│ <to jid="meu-numero:0@lid"> ← celular principal
│ <enc type="msg">...DSM{viewOnceMessageV2}...</enc>
│ </to>
│ <!-- device>0 omitidos — servidor gera unavailable -->
│ </participants>
│ </message>
└── Distribuição pelo servidor:
├── Destinatário → recebe enc → abre uma vez ✅
├── Celular principal → recebe DSM → mostra "você enviou" ✅
└── WA Web → recebe <unavailable> → "Aguardando..." ✅
Destinatário envia view-once de volta para a API
├── Stanza recebido pela API (stanza 1 — enc com conteúdo):
│ <message from="destinatario@s.whatsapp.net" type="media">
│ <enc type="msg">...viewOnceMessageV2 cifrado...</enc>
│ </message>
├── decode-wa-message.ts:
│ └── Descriptografa → detecta viewOnce=true → seta key.isViewOnce=true
├── Stanza recebido (stanza 2 — unavailable, ignorado):
│ <message from="...">
│ <unavailable type="view_once"/>
│ </message>
└── Webhook emitido:
messages.upsert → msg.key.isViewOnce = true ✅
```
---
## 7. Limitações
| Situação | Comportamento | Motivo |
|---|---|---|
| **Conta hosted (Meta Business Cloud API)** | **View-once bloqueado para todos os clientes** | **Política do servidor WA para contas hosted — sem workaround** |
| WA Web do remetente | "Aguardando mensagem. Abra no celular." | Servidor WA só aceita `<unavailable>` vindo do celular principal, não de companions |
| View-once de documento/sticker | Não suportado | Limitação do protocolo WA — só imagem, vídeo e áudio |
| `error_479` nos logs | Normal, não é erro | TcToken é atualizado após o envio de view-once |
| Companion não consegue abrir | Por design | O conteúdo não é entregue para linked devices |
---
## 8. Checklist de Validação
**Envio:**
- [ ] Destinatário recebe com ícone de olhinho
- [ ] Destinatário consegue abrir a mídia
- [ ] Após abrir, a mídia some
- [ ] Celular principal do remetente mostra "você enviou" com ícone de olhinho
- [ ] WA Web do remetente mostra "Aguardando mensagem..."
- [ ] Funciona para imagem, vídeo e áudio
- [ ] Logs mostram `📤 Message sent` (error_479 é normal)
**Recepção:**
- [ ] Webhook recebe `key.isViewOnce = true`
- [ ] `viewOnceMessageV2.message.imageMessage` (ou video/audio) está presente
- [ ] API não crasha ao receber view-once de outro dispositivo
- [ ] Mensagem interativa (botão/lista) enviada após view-once não é afetada
+1 -1
View File
@@ -47,7 +47,7 @@
"fflate": "^0.8.2",
"libsignal": "github:whiskeysockets/libsignal-node",
"lru-cache": "^11.2.6",
"music-metadata": "^11.12.3",
"music-metadata": "^11.12.0",
"p-queue": "^9.0.0",
"pino": "^10.3.1",
"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 NOISE_MODE = 'Noise_XX_25519_AESGCM_SHA256\0\0\0\0'
/**
* 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 DICT_VERSION = 3
export const KEY_BUNDLE_TYPE = Buffer.from([5])
// 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 }
export const NOISE_WA_HEADER = Buffer.from([87, 65, 6, DICT_VERSION]) // last is "DICT_VERSION"
/** 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
@@ -73,33 +56,10 @@ export const PROCESSABLE_HISTORY_TYPES = [
// 6 hours in milliseconds
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 = {
version: version as WAVersion,
versionCheckIntervalMs: SIX_HOURS_MS,
browser: resolveDefaultBrowser(),
browser: Browsers.macOS('Chrome'),
waWebSocketUrl: 'wss://web.whatsapp.com/ws/chat',
connectTimeoutMs: 20_000,
keepAliveIntervalMs: 15_000,
@@ -171,20 +131,7 @@ export const MEDIA_PATH_MAP: { [T in MediaType]?: string } = {
'md-msg-hist': '/mms/md-app-state',
'biz-cover-photo': '/pps/biz-cover-photo',
'sticker-pack': '/mms/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'
'thumbnail-sticker-pack': '/mms/thumbnail-sticker-pack'
}
export const MEDIA_HKDF_KEY_MAPPING = {
@@ -208,11 +155,7 @@ export const MEDIA_HKDF_KEY_MAPPING = {
ptv: 'Video',
'biz-cover-photo': 'Image',
'sticker-pack': 'Sticker Pack',
'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'
'thumbnail-sticker-pack': 'Sticker Pack Thumbnail'
}
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) */
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 = 200
export const MIN_PREKEY_COUNT = 5
// Initial pool size matching WA Business (CDP IDB capture: prekey-store = 812 on registration)
// Rounded to 800 for cleanliness; replenishment always tops up to this value
export const INITIAL_PREKEY_COUNT = 800
// Moderate prekey count (upstream uses 812, reduced to balance rate limiting and availability)
export const INITIAL_PREKEY_COUNT = 200
export const UPLOAD_TIMEOUT = 30000 // 30 seconds
// 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.
const migrationInFlight = new Map<string, Promise<{ migrated: number; skipped: number; total: number }>>()
// Resolve PN JID to its canonical LID JID for transaction locking.
// This prevents PN/LID race conditions where concurrent operations for the
// same logical contact acquire different mutex locks because one uses PN
// and the other uses LID. (Aligned with WABA behavior — all operations use LID internally.)
const resolveCanonicalJid = async(jid: string): Promise<string> => {
if (isAnyLidUser(jid)) {
return jid
}
if (isAnyPnUser(jid)) {
const lid = await lidMapping.getLIDForPN(jid)
if (lid) {
return lid
}
}
return jid
}
const repository: SignalRepositoryWithLIDStore = {
decryptGroupMessage({ group, authorJid, msg }) {
const senderName = jidToSignalSenderKeyName(group, authorJid)
@@ -415,24 +396,23 @@ export function makeLibSignalRepository(
return result
}
// Use canonical JID (PN→LID resolved) as transaction key to prevent
// PN/LID race conditions on the same logical session.
const canonicalJid = await resolveCanonicalJid(jid)
// If it's not a sync message, we need to ensure atomicity
// For regular messages, we use a transaction to ensure atomicity
return parsedKeys.transaction(async () => {
return await doDecrypt()
}, canonicalJid)
}, jid)
},
async encryptMessage({ jid, data }) {
const addr = jidToSignalProtocolAddress(jid)
const cipher = new libsignal.SessionCipher(storage, addr)
const canonicalJid = await resolveCanonicalJid(jid)
// Use transaction to ensure atomicity
return parsedKeys.transaction(async () => {
const { type: sigType, body } = await cipher.encrypt(data)
const type = sigType === 3 ? 'pkmsg' : 'msg'
return { type, ciphertext: Buffer.from(body, 'binary') }
}, canonicalJid)
}, jid)
},
async encryptGroupMessage({ group, meId, data }) {
@@ -674,10 +654,9 @@ export function makeLibSignalRepository(
// Session exists (guaranteed from device discovery)
const fromSession = libsignal.SessionRecord.deserialize(pnSession)
if (fromSession.haveOpenSession()) {
// Queue for bulk update: copy to LID, retain PN session.
// WABA retains both PN and LID sessions during migration to avoid
// No Session errors if messages arrive via PN before migration completes.
// Queue for bulk update: copy to LID, delete from PN
sessionUpdates[lidAddrStr] = fromSession.serialize()
sessionUpdates[pnAddrStr] = null
migratedCount++
}
@@ -797,13 +776,6 @@ function signalStorage(
return id
}
// Delayed PreKey deletion: grace period to handle race conditions
// where two pkmsg with the same preKeyId arrive nearly simultaneously.
// WABA deletes immediately (33ms), but we add a 5-min grace period
// because we can't handle "Invalid PreKey ID" errors at the native level.
const PREKEY_GRACE_PERIOD_MS = 5 * 60 * 1000 // 5 minutes
const pendingPreKeyDeletions = new Map<string, ReturnType<typeof setTimeout>>()
return {
loadSession: async (id: string) => {
try {
@@ -836,26 +808,7 @@ function signalStorage(
}
}
},
removePreKey: (id: number) => {
const keyId = id.toString()
// Clear any existing timer for this key
const existing = pendingPreKeyDeletions.get(keyId)
if (existing) {
clearTimeout(existing)
}
// Schedule deletion after grace period
const timer = setTimeout(async () => {
pendingPreKeyDeletions.delete(keyId)
try {
await keys.set({ 'pre-key': { [id]: null } })
} catch {
// Keystore may be destroyed if connection closed — safe to ignore
}
}, PREKEY_GRACE_PERIOD_MS)
pendingPreKeyDeletions.set(keyId, timer)
},
removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
loadSignedPreKey: () => {
const key = creds.signedPreKey
return {
@@ -945,24 +898,14 @@ function signalStorage(
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
const previousFingerprint = generateKeyFingerprint(existingKey)
// 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
}
// Delete old session and save new identity key atomically
await keys.set({
session: { [wireJid]: null },
'identity-key': identityUpdates
'identity-key': { [wireJid]: identityKey }
})
// Update cache for both addresses
// Update cache
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'changed' })
@@ -998,19 +941,10 @@ function signalStorage(
if (!existingKey) {
// NEW CONTACT - Trust On First Use (TOFU)
// Store in both LID and PN addresses (aligned with WABA dual identity storage)
const identityUpdates: Record<string, Uint8Array> = { [wireJid]: identityKey }
if (wireJid !== id) {
identityUpdates[id] = identityKey
}
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
await keys.set({ 'identity-key': identityUpdates })
// Update cache for both addresses
// Update cache
identityKeyCache.set(wireJid, identityKey)
if (wireJid !== id) {
identityKeyCache.set(id, identityKey)
}
// Record metrics
metrics.signalIdentityChanges?.inc({ type: 'new' })
+20 -86
View File
@@ -50,8 +50,7 @@ import {
isMissingKeyError,
MAX_SYNC_ATTEMPTS,
newLTHashState,
processSyncAction,
resolveLidToPn
processSyncAction
} from '../Utils'
import { makeKeyedMutex, makeMutex } from '../Utils/make-mutex'
import processMessage from '../Utils/process-message'
@@ -98,24 +97,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
let privacySettings: { [_: string]: string } | undefined
/**
* Server-assigned AB props that gate tctoken-related protocol behavior.
* Defaults match WA Web (safe — avoids spurious error 463 if the prop never
* arrives). Populated from `fetchProps()` on connection.
*
* - `privacyTokenOn1to1` (AB prop 10518 / `privacy_token_sending_on_all_1_on_1_messages`):
* include tctoken in 1:1 messages.
* - `profilePicPrivacyToken` (AB prop 9666 / `profile_scraping_privacy_token_in_photo_iq`):
* include tctoken in profile picture IQs.
* - `lidTrustedTokenIssueToLid` (AB prop 14303 / `lid_trusted_token_issue_to_lid`):
* issue privacy tokens to the contact's LID instead of the PN.
*/
const serverProps = {
privacyTokenOn1to1: true,
profilePicPrivacyToken: true,
lidTrustedTokenIssueToLid: false
}
let syncState: SyncState = SyncState.Connecting
/** this mutex ensures that messages from the same chat are processed in order, while allowing parallel processing of messages from different chats */
@@ -776,16 +757,9 @@ export const makeChatsSocket = (config: SocketConfig) => {
}, authState?.creds?.me?.id || 'resync-app-state')
const { onMutation } = newAppStateChunkHandler(isInitialSync)
const lidMapping = signalRepository.lidMapping
for (const key in globalMutationMap) {
const mutation = globalMutationMap[key]
if (!mutation) continue
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation)
}
}
@@ -809,10 +783,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
me && (normalizedJid === jidNormalizedUser(me.id) || (me.lid && normalizedJid === jidNormalizedUser(me.lid)))
let content: BinaryNode[] | undefined = baseContent
// Gate inclusion on AB prop 9666 (profile_scraping_privacy_token_in_photo_iq).
// WA Web defaults to true; if the server flips it off, we mirror that to
// avoid divergence with the spec-compliant client.
if (serverProps.profilePicPrivacyToken && isUserJid && !isSelf) {
if (isUserJid && !isSelf) {
content = await buildTcTokenFromJid({
authState,
jid: normalizedJid,
@@ -941,16 +912,16 @@ export const makeChatsSocket = (config: SocketConfig) => {
})
}
const handlePresenceUpdate = async ({ tag, attrs, content }: BinaryNode) => {
const handlePresenceUpdate = ({ tag, attrs, content }: BinaryNode) => {
let presence: PresenceData | undefined
const rawJid = attrs.from
const rawParticipant = attrs.participant || attrs.from
if (!rawJid) {
const jid = attrs.from
const participant = attrs.participant || attrs.from
if (!jid) {
logger.warn({ attrs }, 'handlePresenceUpdate: jid (attrs.from) is missing, skipping')
return
}
if (shouldIgnoreJid(rawJid) && rawJid !== S_WHATSAPP_NET) {
if (shouldIgnoreJid(jid) && jid !== S_WHATSAPP_NET) {
return
}
@@ -962,7 +933,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
} else if (Array.isArray(content)) {
const [firstChild] = content
if (!firstChild) {
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
logger.warn({ jid }, 'handlePresenceUpdate: firstChild content is empty, skipping')
return
}
@@ -981,19 +952,12 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
if (presence) {
if (!rawParticipant) {
logger.warn({ jid: rawJid }, 'handlePresenceUpdate: participant is missing, skipping')
if (!participant) {
logger.warn({ jid }, 'handlePresenceUpdate: participant is missing, skipping')
return
}
// Resolve LID→PN so consumers always see phone-number JIDs
const lidMapping = signalRepository.lidMapping
const [jid, participant] = await Promise.all([
resolveLidToPn(rawJid, lidMapping, logger),
resolveLidToPn(rawParticipant, lidMapping, logger)
])
ev.emit('presence.update', { id: jid!, presences: { [participant!]: presence } })
ev.emit('presence.update', { id: jid, presences: { [participant]: presence } })
}
}
@@ -1067,35 +1031,28 @@ export const makeChatsSocket = (config: SocketConfig) => {
undefined,
logger
)
const lidMapping = signalRepository.lidMapping
for (const key in mutationMap) {
const mutation = mutationMap[key]!
// Normalize LID→PN in sync action index[1] (chat/contact ID)
if (mutation.index[1] && isAnyLidUser(mutation.index[1])) {
const resolved = await resolveLidToPn(mutation.index[1], lidMapping, logger)
if (resolved) mutation.index[1] = resolved
}
onMutation(mutation)
onMutation(mutationMap[key]!)
}
}
}
/** fetch AB props */
/** sending non-abt props may fix QR scan fail if server expects */
const fetchProps = async () => {
//TODO: implement both protocol 1 and protocol 2 prop fetching, specially for abKey for WM
const resultNode = await query({
tag: 'iq',
attrs: {
to: S_WHATSAPP_NET,
xmlns: 'abt',
xmlns: 'w',
type: 'get'
},
content: [
{
tag: 'props',
attrs: {
protocol: '1',
...(authState?.creds?.lastPropHash ? { hash: authState.creds.lastPropHash } : {})
protocol: '2',
hash: authState?.creds?.lastPropHash || ''
}
}
]
@@ -1114,25 +1071,7 @@ export const makeChatsSocket = (config: SocketConfig) => {
props = reduceBinaryNodeToDictionary(propsNode, 'prop')
}
// Extract protocol-relevant AB props (defaults match WA Web; see serverProps doc).
// We accept both numeric IDs and human-readable names so the parser is resilient
// to upstream renaming and to future-versioned WA Web servers.
const privacyTokenProp = props['10518'] ?? props['privacy_token_sending_on_all_1_on_1_messages']
if (privacyTokenProp !== undefined) {
serverProps.privacyTokenOn1to1 = privacyTokenProp === 'true' || privacyTokenProp === '1'
}
const profilePicProp = props['9666'] ?? props['profile_scraping_privacy_token_in_photo_iq']
if (profilePicProp !== undefined) {
serverProps.profilePicPrivacyToken = profilePicProp === 'true' || profilePicProp === '1'
}
const lidIssueProp = props['14303'] ?? props['lid_trusted_token_issue_to_lid']
if (lidIssueProp !== undefined) {
serverProps.lidTrustedTokenIssueToLid = lidIssueProp === 'true' || lidIssueProp === '1'
}
logger.debug({ serverProps }, 'fetched props')
logger.debug('fetched props')
return props
}
@@ -1437,12 +1376,8 @@ export const makeChatsSocket = (config: SocketConfig) => {
}
})
ws.on('CB:presence', (node: BinaryNode) => {
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling presence update'))
})
ws.on('CB:chatstate', (node: BinaryNode) => {
handlePresenceUpdate(node).catch(err => onUnexpectedError(err, 'handling chatstate update'))
})
ws.on('CB:presence', handlePresenceUpdate)
ws.on('CB:chatstate', handlePresenceUpdate)
ws.on('CB:ib,,dirty', async (node: BinaryNode) => {
const { attrs } = getBinaryNodeChild(node, 'dirty')!
@@ -1650,7 +1585,6 @@ export const makeChatsSocket = (config: SocketConfig) => {
return {
...sock,
serverProps,
createCallLink,
getBotListV2,
messageMutex,
+7 -49
View File
@@ -1,7 +1,7 @@
import { proto } from '../../WAProto/index.js'
import type { GroupMetadata, GroupParticipant, ParticipantAction, SocketConfig, WAMessageKey } from '../Types'
import { WAMessageAddressingMode, WAMessageStubType } from '../Types'
import { generateMessageIDV2, resolveLidToPn, unixTimestampSeconds } from '../Utils'
import { generateMessageIDV2, unixTimestampSeconds } from '../Utils'
import {
type BinaryNode,
getBinaryNodeChild,
@@ -17,43 +17,6 @@ import { makeChatsSocket } from './chats'
export const makeGroupsSocket = (config: SocketConfig) => {
const sock = makeChatsSocket(config)
const { authState, ev, query, upsertMessage } = sock
const { signalRepository } = sock
const { logger } = config
/** Normalize group metadata participant IDs from LID to PN */
const normalizeGroupMetadata = async (metadata: GroupMetadata): Promise<GroupMetadata> => {
const lidMapping = signalRepository.lidMapping
// Resolve all participant LIDs in parallel for better performance on large groups
await Promise.all(metadata.participants.map(async (p) => {
if (isLidUser(p.id)) {
if (p.phoneNumber) {
p.lid = p.id
p.id = p.phoneNumber
} else {
const resolved = await resolveLidToPn(p.id, lidMapping, logger)
if (resolved && resolved !== p.id) {
p.lid = p.id
p.id = resolved
}
}
}
}))
// Normalize owner/subjectOwner if LID (parallel)
const [resolvedOwner, resolvedSubjectOwner] = await Promise.all([
metadata.owner && isLidUser(metadata.owner)
? (metadata.ownerPn || resolveLidToPn(metadata.owner, lidMapping, logger))
: null,
metadata.subjectOwner && isLidUser(metadata.subjectOwner)
? (metadata.subjectOwnerPn || resolveLidToPn(metadata.subjectOwner, lidMapping, logger))
: null
])
if (resolvedOwner) metadata.owner = resolvedOwner
if (resolvedSubjectOwner) metadata.subjectOwner = resolvedSubjectOwner
return metadata
}
const groupQuery = async (jid: string, type: 'get' | 'set', content: BinaryNode[]) =>
query({
@@ -68,7 +31,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
const groupMetadata = async (jid: string) => {
const result = await groupQuery(jid, 'get', [{ tag: 'query', attrs: { request: 'interactive' } }])
return normalizeGroupMetadata(extractGroupMetadata(result))
return extractGroupMetadata(result)
}
const groupFetchAllParticipating = async () => {
@@ -95,15 +58,16 @@ export const makeGroupsSocket = (config: SocketConfig) => {
if (groupsChild) {
const groups = getBinaryNodeChildren(groupsChild, 'group')
for (const groupNode of groups) {
const meta = await normalizeGroupMetadata(extractGroupMetadata({
const meta = extractGroupMetadata({
tag: 'result',
attrs: {},
content: [groupNode]
}))
})
data[meta.id] = meta
}
}
// TODO: properly parse LID / PN DATA
sock.ev.emit('groups.update', Object.values(data))
return data
@@ -137,7 +101,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
}))
}
])
return normalizeGroupMetadata(extractGroupMetadata(result))
return extractGroupMetadata(result)
},
groupLeave: async (id: string) => {
await groupQuery('@g.us', 'set', [
@@ -313,7 +277,7 @@ export const makeGroupsSocket = (config: SocketConfig) => {
),
groupGetInviteInfo: async (code: string) => {
const results = await groupQuery('@g.us', 'get', [{ tag: 'invite', attrs: { code } }])
return normalizeGroupMetadata(extractGroupMetadata(results))
return extractGroupMetadata(results)
},
groupToggleEphemeral: async (jid: string, ephemeralExpiration: number) => {
const content: BinaryNode = ephemeralExpiration
@@ -343,13 +307,11 @@ export const extractGroupMetadata = (result: BinaryNode) => {
let descId: string | undefined
let descOwner: string | undefined
let descOwnerPn: string | undefined
let descOwnerUsername: string | undefined
let descTime: number | undefined
if (descChild) {
desc = getBinaryNodeChildString(descChild, 'body')
descOwner = descChild.attrs.participant ? jidNormalizedUser(descChild.attrs.participant) : undefined
descOwnerPn = descChild.attrs.participant_pn ? jidNormalizedUser(descChild.attrs.participant_pn) : undefined
descOwnerUsername = descChild.attrs.participant_username || undefined
descTime = +descChild.attrs.t!
descId = descChild.attrs.id
}
@@ -364,19 +326,16 @@ export const extractGroupMetadata = (result: BinaryNode) => {
subject: group.attrs.subject!,
subjectOwner: group.attrs.s_o,
subjectOwnerPn: group.attrs.s_o_pn,
subjectOwnerUsername: group.attrs.s_o_username,
subjectTime: +(group.attrs.s_t ?? '0'),
size: group.attrs.size ? +group.attrs.size : getBinaryNodeChildren(group, 'participant').length,
creation: +(group.attrs.creation ?? '0'),
owner: group.attrs.creator ? jidNormalizedUser(group.attrs.creator) : undefined,
ownerPn: group.attrs.creator_pn ? jidNormalizedUser(group.attrs.creator_pn) : undefined,
ownerUsername: group.attrs.creator_username || undefined,
owner_country_code: group.attrs.creator_country_code,
desc,
descId,
descOwner,
descOwnerPn,
descOwnerUsername,
descTime,
linkedParent: getBinaryNodeChild(group, 'linked_parent')?.attrs.jid || undefined,
restrict: !!getBinaryNodeChild(group, 'locked'),
@@ -391,7 +350,6 @@ export const extractGroupMetadata = (result: BinaryNode) => {
id: attrs.jid!,
phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
username: attrs.participant_username || attrs.username || undefined,
admin: (attrs.type || null) as GroupParticipant['admin']
}
}),
File diff suppressed because it is too large Load Diff
+110 -395
View File
@@ -1,4 +1,3 @@
import { randomBytes } from 'crypto'
import NodeCache from '@cacheable/node-cache'
import { Boom } from '@hapi/boom'
import { proto } from '../../WAProto/index.js'
@@ -9,7 +8,6 @@ import type {
AlbumMessageOptions,
AlbumSendResult,
AnyMessageContent,
LIDMapping,
MediaConnInfo,
MessageReceiptType,
MessageRelayOptions,
@@ -46,10 +44,7 @@ import { makeKeyedMutex } from '../Utils/make-mutex'
import { metrics, recordMessageFailure, recordMessageSent } from '../Utils/prometheus-metrics'
import { getMessageReportingToken, shouldIncludeReportingToken } from '../Utils/reporting-utils'
import {
buildMergedTcTokenIndexWrite,
isRegularUser,
isTcTokenExpired,
resolveIssuanceJid,
resolveTcTokenJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult
@@ -128,15 +123,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Tracks JIDs with an in-flight getPrivacyTokens IQ to avoid duplicate concurrent fetches
const tcTokenFetchingJids = new Set<string>()
/**
* Set of tctoken storage JIDs with a fire-and-forget `issuePrivacyTokens` IQ in flight.
* Distinct from `tcTokenFetchingJids` (which dedupes inbound *fetches* of the peer's
* token). Prevents duplicate IQs when a caller fires several rapid back-to-back sends
* to the same contact before `senderTimestamp` persists. Entries are always removed
* in `.finally()`, so the set is bounded by current concurrency.
*/
const inFlightTcTokenIssuance = new Set<string>()
let mediaConn: Promise<MediaConnInfo>
const refreshMediaConn = async (forceGet = false) => {
const media = await mediaConn
@@ -351,33 +337,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// 4th LID→PN source: device-list entries sharing the same raw_id
// WA Business uses this for accounts where HistorySync sends zero phoneNumberToLidMappings
const allEntries = [...result.list, ...result.sideList]
const rawIdMap = new Map<number, { pn?: string; lid?: string }>()
for (const item of allEntries) {
if (typeof item.rawId !== 'number' || isNaN(item.rawId)) continue
const decoded = jidDecode(item.id)
if (!decoded) continue
const entry = rawIdMap.get(item.rawId) || {}
if (decoded.server === 'lid' || decoded.server === 'hosted.lid') {
entry.lid = item.id
} else if (decoded.server === 's.whatsapp.net' || decoded.server === 'c.us') {
entry.pn = item.id
}
rawIdMap.set(item.rawId, entry)
}
const rawIdMappings: LIDMapping[] = []
for (const { pn, lid } of rawIdMap.values()) {
if (pn && lid) {
rawIdMappings.push({ lid: jidNormalizedUser(lid), pn: jidNormalizedUser(pn) })
}
}
if (rawIdMappings.length > 0) {
await signalRepository.lidMapping.storeLIDPNMappings(rawIdMappings)
logger.debug({ count: rawIdMappings.length }, 'stored LID-PN mappings from raw_id pairing')
}
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid || ''
@@ -548,38 +507,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
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 (
pdoMessage: proto.Message.IPeerDataOperationRequestMessage
): Promise<string> => {
@@ -614,82 +541,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
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 (
recipientJids: string[],
message: proto.IMessage,
@@ -709,23 +560,58 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const meId = authState.creds.me?.id
if (!meId) throw new Boom('Not authenticated', { statusCode: 401 })
const meLid = authState.creds.me?.lid
const meLidUser = meLid ? (jidDecode(meLid)?.user ?? null) : null
const onPkmsg = () => {
shouldIncludeDeviceIdentity = true
}
const meLidUser = meLid ? jidDecode(meLid)?.user : null
const encryptionPromises = (patchedMessages as { recipientJid: string; message: proto.IMessage }[]).map(
({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) =>
encryptPatchedMessageForRecipient({
jid,
patchedMessage,
dsmMessage,
meId,
meLid,
meLidUser,
extraAttrs,
onPkmsg
})
async ({ recipientJid: jid, message: patchedMessage }: { recipientJid: string; message: proto.IMessage }) => {
try {
if (!jid) return null
let msgToEncrypt = patchedMessage
if (dsmMessage) {
const { user: targetUser } = jidDecode(jid)!
const { user: ownPnUser } = jidDecode(meId)!
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[]
@@ -1053,8 +939,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
await authState.keys.transaction(async () => {
// normalizeMessageContent unwraps viewOnceMessage/viewOnceMessageV2 so getMediaType works correctly
const mediaType = getMediaType(normalizeMessageContent(message) || message)
const mediaType = getMediaType(message)
if (mediaType) {
extraAttrs['mediatype'] = mediaType
}
@@ -1064,7 +949,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const bytes = encodeNewsletterMessage(patched as proto.IMessage)
binaryNodeContent.push({
tag: 'plaintext',
attrs: mediaType ? { mediatype: mediaType } : {},
attrs: {},
content: bytes
})
const stanza: BinaryNode = {
@@ -1086,8 +971,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
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) {
const [groupData, senderKeyMap] = await Promise.all([
(async () => {
@@ -1283,57 +1166,21 @@ export const makeMessagesSocket = (config: SocketConfig) => {
allRecipients.push(jid)
}
const isCarouselFanout = isCarouselMessage(message)
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])
await assertSessions(allRecipients)
const [
{ nodes: meNodes, shouldIncludeDeviceIdentity: s1 },
{ nodes: otherNodes, shouldIncludeDeviceIdentity: s2 }
] = await Promise.all([
createParticipantNodes(viewOnceMeRecipients, meMsg || message, extraAttrs),
createParticipantNodes(effectiveOtherRecipients, message, extraAttrs)
// For own devices: use DSM (deviceSentMessage) wrapper
createParticipantNodes(meRecipients, meMsg || message, extraAttrs),
createParticipantNodes(otherRecipients, message, extraAttrs)
])
participants.push(...meNodes)
participants.push(...otherNodes)
// phash: para view-once inclui só celular principal + destinatários
const phashRecipients = isViewOnceMsg
? [...viewOnceMeRecipients, ...effectiveOtherRecipients]
: effectiveAllRecipients
if (phashRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2(phashRecipients)
if (meRecipients.length > 0 || otherRecipients.length > 0) {
extraAttrs['phash'] = generateParticipantHashV2([...meRecipients, ...otherRecipients])
}
shouldIncludeDeviceIdentity = shouldIncludeDeviceIdentity || s1 || s2
@@ -1406,15 +1253,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const isCarousel = isCarouselMessage(message)
// Collect biz/bot nodes to append AFTER device-identity and tctoken
// Stanza order: participants → device-identity → tctoken → biz (when applicable)
// CDP capture confirms Pastorini INCLUDES biz node for carousel (native_flow v=9, name=mixed)
// Stanza order: participants → device-identity → tctoken → biz
// The biz node MUST be last for WhatsApp Web carousel rendering
const deferredNodes: BinaryNode[] = []
// Inject biz node for interactive messages (including carousel — CDP evidence from Pastorini)
if ((buttonType || isCarousel) && enableInteractiveMessages) {
// Inject biz node for interactive messages (including carousel)
if (buttonType && enableInteractiveMessages) {
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
const interactiveMsg =
@@ -1452,7 +1297,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
)
// Track that we're sending an interactive message
metrics.interactiveMessagesSent.inc({ type: effectiveButtonType })
metrics.interactiveMessagesSent.inc({ type: buttonType })
try {
// Classify button types for native_flow name and bot node decisions
@@ -1495,49 +1340,27 @@ export const makeMessagesSocket = (config: SocketConfig) => {
)
const interactiveType = 'native_flow'
const 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({
tag: 'biz',
attrs: {},
content: bizContent
content: [
{
tag: 'interactive',
attrs: {
type: interactiveType,
v: '1'
},
content: [
{
tag: interactiveType,
attrs: {
v: '9',
name: nativeFlowName
}
}
]
}
]
})
}
@@ -1563,11 +1386,11 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
// Track success and latency after message is sent
metrics.interactiveMessagesSuccess.inc({ type: effectiveButtonType })
metrics.interactiveMessagesLatency.observe({ type: effectiveButtonType }, Date.now() - startTime)
metrics.interactiveMessagesSuccess.inc({ type: buttonType })
metrics.interactiveMessagesLatency.observe({ type: buttonType }, Date.now() - startTime)
} catch (error) {
logger.error({ error, msgId, buttonType: effectiveButtonType }, '[BIZ NODE] Failed to inject biz node')
metrics.interactiveMessagesFailures.inc({ type: effectiveButtonType, reason: 'injection_failed' })
logger.error({ error, msgId, buttonType }, '[BIZ NODE] Failed to inject biz node')
metrics.interactiveMessagesFailures.inc({ type: buttonType, reason: 'injection_failed' })
}
} else if (buttonType && !enableInteractiveMessages) {
logger.warn(
@@ -1594,8 +1417,7 @@ export const makeMessagesSocket = (config: SocketConfig) => {
stanza.attrs.to = destinationJid
}
// Always include device-identity for carousel (Pastorini stanza always has it)
if (shouldIncludeDeviceIdentity || isCarousel) {
if (shouldIncludeDeviceIdentity) {
;(stanza.content as BinaryNode[]).push({
tag: 'device-identity',
attrs: {},
@@ -1652,91 +1474,30 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
}
// If tctoken is missing for a 1:1 send, fetch it
// CDP capture confirms Pastorini stanza includes tctoken for carousel
// If tctoken is missing for a 1:1 send, fire-and-forget fetch so the
// retry path (error 463 → handleBadAck) can pick it up on resend
if (!tcTokenBuffer?.length && is1on1Send && !tcTokenFetchingJids.has(tcTokenJid)) {
tcTokenFetchingJids.add(tcTokenJid)
logTcToken('fetch', { jid: destinationJid })
if (isCarousel) {
// 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
getPrivacyTokens([destinationJid])
.then(async fetchResult => {
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN
}).catch(() => {})
} catch (err: any) {
logger.warn({ jid: destinationJid, err: err?.message }, '[CAROUSEL] Blocking tctoken fetch failed')
} finally {
// onNewJidStored not passed — the pruning index lives in messages-recv (higher layer)
})
})
.catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
tcTokenFetchingJids.delete(tcTokenJid)
}
} else {
// Fire-and-forget for non-carousel
getPrivacyTokens([destinationJid])
.then(async fetchResult => {
await storeTcTokensFromIqResult({
result: fetchResult,
fallbackJid: destinationJid,
keys: authState.keys,
getLIDForPN
})
})
.catch(err => {
logger.debug({ jid: destinationJid, err: err?.message }, 'fire-and-forget tctoken fetch failed')
})
.finally(() => {
tcTokenFetchingJids.delete(tcTokenJid)
})
}
})
}
// Gate inclusion on AB prop 10518 (privacy_token_sending_on_all_1_on_1_messages).
// WA Web defaults to true; if the server flips it off we mirror that to avoid
// divergence with the spec-compliant client.
//
// CARROUSEL EXCEPTION: Pastorini-validated carousel stanzas REQUIRE tctoken
// (CDP capture confirms it). If the AB prop ever flips off, dropping the
// tctoken from carousel would break rendering on Android. Carousel always
// includes the tctoken when one is available, regardless of the prop —
// matching the fork's existing behaviour pre-PR #2339.
if (tcTokenBuffer?.length && (sock.serverProps.privacyTokenOn1to1 || isCarousel)) {
if (tcTokenBuffer?.length) {
;(stanza.content as BinaryNode[]).push({
tag: 'tctoken',
attrs: {},
@@ -1764,7 +1525,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
logger.debug({ msgId }, `sending message to ${participants.length} devices`)
// ======= 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 {
const encoded = proto.Message.encode(message).finish()
const decoded = proto.Message.decode(encoded)
@@ -1773,19 +1535,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
const card0 = decodedInteractive?.carouselMessage?.cards?.[0]
const card0Header = card0?.header
logger.info(
logger.debug(
{
msgId,
encodedSize: encoded.length,
messageKeys: Object.keys(message),
hasInteractive: !!message.interactiveMessage,
hasViewOnce: !!message.viewOnceMessage,
hasCarouselAfterDecode: !!decodedInteractive?.carouselMessage,
cardsCount,
card0Title: card0Header?.title,
card0HasImage: !!card0Header?.imageMessage,
card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0,
messageVersion: decodedInteractive?.carouselMessage?.messageVersion
card0Buttons: card0?.nativeFlowMessage?.buttons?.length || 0
},
'[ROUNDTRIP] Protobuf encode→decode verification'
)
@@ -1795,7 +1553,8 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
// ======= 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 => {
if (!node) return ''
const pad = ' '.repeat(indent)
@@ -1836,39 +1595,13 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Gated only by shouldSendNewTcToken — removed tcTokenBuffer?.length guard so
// issuance fires even when we don't yet hold a token (bucket boundary crossed).
// IMPORTANT: must run AFTER sendNode — issuing before the message causes error 463.
//
// WA Web also skips issuance for:
// - protocol messages (revoke, ephemeral settings, etc — see TcTokenChatAction)
// - PSA, bots, MetaAI (isRegularUser filter)
// and dedupes back-to-back issuances with `inFlightTcTokenIssuance` so a burst of
// rapid sends to the same contact only triggers a single IQ before senderTimestamp
// is persisted.
const isProtocolMsg = !!normalizeMessageContent(message)?.protocolMessage
// Use isRegularUser (the same Wid.isRegularUser() port that gates the store
// path) so we filter PSA/bot/MetaAI consistently regardless of JID server
// (@c.us vs @s.whatsapp.net) and device suffix. The previous PSA_WID/isJidBot
// checks only matched @c.us forms — destinationJid arrives normalized.
const isBotOrPSA = !isRegularUser(destinationJid)
if (
is1on1Send &&
!isProtocolMsg &&
!isBotOrPSA &&
shouldSendNewTcToken(existingTokenEntry?.senderTimestamp) &&
!inFlightTcTokenIssuance.has(tcTokenJid)
) {
inFlightTcTokenIssuance.add(tcTokenJid)
if (is1on1Send && shouldSendNewTcToken(existingTokenEntry?.senderTimestamp)) {
const issueTimestamp = unixTimestampSeconds()
logTcToken('reissue', { jid: destinationJid })
const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
resolveIssuanceJid(
destinationJid,
sock.serverProps.lidTrustedTokenIssueToLid,
getLIDForPN,
getPNForLID
)
.then(issueJid => getPrivacyTokens([issueJid], issueTimestamp))
getPrivacyTokens([destinationJid], issueTimestamp)
.then(async result => {
// Store any tokens received in the IQ response.
// onNewJidStored not passed — pruning index lives in messages-recv (higher layer).
await storeTcTokensFromIqResult({
result,
fallbackJid: tcTokenJid,
@@ -1878,21 +1611,15 @@ export const makeMessagesSocket = (config: SocketConfig) => {
// Persist senderTimestamp unconditionally — WA Web stores it in the chat table
// regardless of whether a token exists. Spread preserves token+timestamp if present.
// WABA Android: INSERT INTO wa_trusted_contacts_send (jid, sent_tc_token_timestamp, real_issue_timestamp)
// VALUES (?, ?, 0) — realIssueTimestamp=0 means issued but not yet confirmed by server.
// Also bump the cross-session prune index so this JID is tracked persistently.
const currentData = await authState.keys.get('tctoken', [tcTokenJid])
const currentEntry = currentData[tcTokenJid]
const indexWrite = await buildMergedTcTokenIndexWrite(authState.keys, [tcTokenJid])
await authState.keys.set({
tctoken: {
[tcTokenJid]: {
...currentEntry,
token: currentEntry?.token ?? Buffer.alloc(0),
senderTimestamp: issueTimestamp,
realIssueTimestamp: 0
},
...indexWrite
senderTimestamp: issueTimestamp
}
}
})
@@ -1901,9 +1628,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
.catch(err => {
logTcToken('reissue_fail', { jid: destinationJid, error: err?.message })
})
.finally(() => {
inFlightTcTokenIssuance.delete(tcTokenJid)
})
}
// Log with [BAILEYS] prefix
@@ -1980,15 +1704,6 @@ export const makeMessagesSocket = (config: SocketConfig) => {
}
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) {
return 'image'
} else if (message.videoMessage) {
+2 -2
View File
@@ -123,11 +123,11 @@ export const makeNewsletterSocket = (config: SocketConfig) => {
},
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) => {
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) => {
+14 -53
View File
@@ -38,7 +38,7 @@ import {
signedKeyPair,
xmppSignedPreKey
} from '../Utils'
import { getPlatformId, isAndroidBrowser } from '../Utils/browser-utils'
import { getPlatformId } from '../Utils/browser-utils'
import {
CircuitBreaker,
CircuitOpenError,
@@ -634,6 +634,8 @@ export const makeSocket = (config: SocketConfig) => {
}
helloMsg = proto.HandshakeMessage.fromObject(helloMsg)
logger.info({ browser, helloMsg }, 'connected to WA')
const init = proto.HandshakeMessage.encode(helloMsg).finish()
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:
// the concurrent upload already replenished the pool, so there is nothing left to do.
// Prevent multiple concurrent uploads
if (uploadPreKeysPromise) {
logger.debug('Pre-key upload already in progress, waiting for completion')
await uploadPreKeysPromise
return
}
const uploadLogic = async () => {
@@ -786,15 +786,14 @@ export const makeSocket = (config: SocketConfig) => {
try {
let count = 0
const preKeyCount = await getAvailablePreKeysOnServer()
// How many to upload: top-up to INITIAL_PREKEY_COUNT from whatever remains on server
count = Math.max(0, INITIAL_PREKEY_COUNT - preKeyCount)
if (preKeyCount === 0) count = INITIAL_PREKEY_COUNT
else count = MIN_PREKEY_COUNT
const { exists: currentPreKeyExists, currentPreKeyId } = await verifyCurrentPreKeyExists()
logger.info(`${preKeyCount} pre-keys found on server`)
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 < MIN_PREKEY_COUNT
const lowServerCount = preKeyCount <= count
const missingCurrentPreKey = !currentPreKeyExists && currentPreKeyId > 0
const shouldUpload = lowServerCount || missingCurrentPreKey
@@ -1154,7 +1153,7 @@ export const makeSocket = (config: SocketConfig) => {
// Decrement active connections
decrementActiveConnections()
clearTimeout(keepAliveReq)
clearInterval(keepAliveReq)
clearTimeout(qrTimer)
// Clear offline-buffer safety timer so its callback cannot call ev.flush()
@@ -1265,16 +1264,8 @@ export const makeSocket = (config: SocketConfig) => {
})
}
const startKeepAliveRequest = () => {
// Use recursive setTimeout with ±15% jitter to match WA Desktop behaviour
// (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 = () => {
const startKeepAliveRequest = () =>
(keepAliveReq = setInterval(() => {
if (!lastDateRecv) {
lastDateRecv = new Date()
}
@@ -1286,7 +1277,6 @@ export const makeSocket = (config: SocketConfig) => {
*/
if (diff > keepAliveIntervalMs + 5000) {
void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }))
return // connection closing — do not reschedule
} else if (ws.isOpen) {
// 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
@@ -1309,15 +1299,7 @@ export const makeSocket = (config: SocketConfig) => {
} else {
logger.warn('keep alive called when WS not open')
}
// Do not reschedule once shutdown has started (closed set by end() on any concurrent path)
if (!closed) {
scheduleNextKeepAlive()
}
}
scheduleNextKeepAlive()
}
}, keepAliveIntervalMs))
/** i have no idea why this exists. pls enlighten me */
const sendPassiveIq = (tag: 'passive' | 'active') =>
query({
@@ -1370,25 +1352,6 @@ export const makeSocket = (config: SocketConfig) => {
id: jidEncode(phoneNumber, 's.whatsapp.net'),
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)
await sendNode({
tag: 'iq',
@@ -1421,12 +1384,12 @@ export const makeSocket = (config: SocketConfig) => {
{
tag: 'companion_platform_id',
attrs: {},
content: pairPlatformId
content: getPlatformId(browser[1])
},
{
tag: 'companion_platform_display',
attrs: {},
content: pairPlatformDisplay
content: `${browser[1]} (${browser[0]})`
},
{
tag: 'link_code_pairing_nonce',
@@ -1556,9 +1519,7 @@ export const makeSocket = (config: SocketConfig) => {
})
// login complete
ws.on('CB:success', async (node: BinaryNode) => {
const isAndroid = isAndroidBrowser(browser)
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'}`)
logger.info('opened connection to WA')
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 } })
+1 -1
View File
@@ -80,7 +80,7 @@ export type SignalDataTypeMap = {
'app-state-sync-version': LTHashState
'lid-mapping': string
'device-list': string[]
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number; realIssueTimestamp?: number | null }
tctoken: { token: Buffer; timestamp?: string; senderTimestamp?: number }
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
'identity-key': Uint8Array
}
-2
View File
@@ -9,8 +9,6 @@ export interface Contact {
name?: string
/** name of the contact, the contact has set on their own on WA */
notify?: string
/** username associated with this contact, when provided by WA */
username?: string
/** I have no idea */
verifiedName?: string
// Baileys Added
-6
View File
@@ -27,8 +27,6 @@ export type BaileysEventMap = {
chats: Chat[]
contacts: Contact[]
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
progress?: number | null
syncType?: proto.HistorySync.HistorySyncType | null
@@ -81,7 +79,6 @@ export type BaileysEventMap = {
id: string
author: string
authorPn?: string
authorUsername?: string
participants: GroupParticipant[]
action: ParticipantAction
}
@@ -89,7 +86,6 @@ export type BaileysEventMap = {
id: string
author: string
authorPn?: string
authorUsername?: string
participant: string
participantPn?: string
action: RequestJoinAction
@@ -189,8 +185,6 @@ export type BufferedEventData = {
chats: { [jid: string]: Chat }
contacts: { [jid: string]: Contact }
messages: { [uqId: string]: WAMessage }
/** Keyed by groupJid for O(1) deduplication across chunks */
pastParticipants: { [groupJid: string]: proto.IPastParticipant[] }
empty: boolean
isLatest: boolean
progress?: number | null
-4
View File
@@ -20,20 +20,17 @@ export interface GroupMetadata {
addressingMode?: WAMessageAddressingMode
owner: string | undefined
ownerPn?: string | undefined
ownerUsername?: string | undefined
owner_country_code?: string | undefined
subject: string
/** group subject owner */
subjectOwner?: string
subjectOwnerPn?: string
subjectOwnerUsername?: string
/** group subject modification date */
subjectTime?: number
creation?: number
desc?: string
descOwner?: string
descOwnerPn?: string
descOwnerUsername?: string
descId?: string
descTime?: number
/** if this group is part of a community, it returns the jid of the community to which it belongs */
@@ -59,7 +56,6 @@ export interface GroupMetadata {
/** the person who added you to group or changed some setting in group */
author?: string
authorPn?: string
authorUsername?: string
}
export interface WAGroupCreateResponse {
+3 -18
View File
@@ -19,9 +19,7 @@ export type WAContactMessage = proto.Message.IContactMessage
export type WAContactsArrayMessage = proto.Message.IContactsArrayMessage
export type WAMessageKey = proto.IMessageKey & {
remoteJidAlt?: string
remoteJidUsername?: string
participantAlt?: string
participantUsername?: string
server_id?: string
addressingMode?: string
isViewOnce?: boolean // TODO: remove out of the message key, place in WebMessageInfo
@@ -56,14 +54,12 @@ export type WAMediaUpload = Buffer | WAMediaPayloadStream | WAMediaPayloadURL
* Individual sticker in a sticker pack
*/
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
/** Array of emojis associated with this sticker (max 3 recommended per WhatsApp standards) */
emojis?: string[]
/** Accessibility label for screen readers (max 125 chars for static, 255 for animated) */
accessibilityLabel?: string
/** Force Lottie format detection (auto-detected if omitted) */
isLottie?: boolean
}
/**
@@ -157,7 +153,6 @@ export type MessageReceiptType =
| 'sender'
| 'inactive'
| 'played'
| 'view_once_read'
| undefined
export type MediaConnInfo = {
@@ -181,8 +176,6 @@ export interface WAUrlInfo {
type Mentionable = {
/** list of jids that are mentioned in the accompanying text */
mentions?: string[]
/** mention all participants in the group */
mentionAll?: boolean
}
type Contextable = {
/** add contextInfo to the message */
@@ -995,16 +988,8 @@ export type MessageGenerationOptionsFromContent = MiscMessageGenerationOptions &
export type WAMediaUploadFunction = (
encFilePath: string,
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number; newsletter?: boolean }
) => Promise<{
mediaUrl: string | undefined
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}>
opts: { fileEncSha256B64: string; mediaType: MediaType; timeoutMs?: number }
) => Promise<{ mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number }>
export type MediaGenerationOptions = {
logger?: ILogger
+8 -8
View File
@@ -4,10 +4,10 @@ export enum XWAPaths {
xwa2_newsletter_view = 'xwa2_newsletter_view',
xwa2_newsletter_metadata = 'xwa2_newsletter',
xwa2_newsletter_admin_count = 'xwa2_newsletter_admin',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_update_user_setting',
xwa2_newsletter_follow = 'xwa2_newsletter_join_v2',
xwa2_newsletter_unfollow = 'xwa2_newsletter_leave_v2',
xwa2_newsletter_mute_v2 = 'xwa2_newsletter_mute_v2',
xwa2_newsletter_unmute_v2 = 'xwa2_newsletter_unmute_v2',
xwa2_newsletter_follow = 'xwa2_newsletter_follow',
xwa2_newsletter_unfollow = 'xwa2_newsletter_unfollow',
xwa2_newsletter_change_owner = 'xwa2_newsletter_change_owner',
xwa2_newsletter_demote = 'xwa2_newsletter_demote',
xwa2_newsletter_delete_v2 = 'xwa2_newsletter_delete_v2'
@@ -17,10 +17,10 @@ export enum QueryIds {
UPDATE_METADATA = '24250201037901610',
METADATA = '6563316087068696',
SUBSCRIBERS = '9783111038412085',
FOLLOW = '24404358912487870',
UNFOLLOW = '9767147403369991',
MUTE = '31938993655691868',
UNMUTE = '31938993655691868',
FOLLOW = '7871414976211147',
UNFOLLOW = '7238632346214362',
MUTE = '29766401636284406',
UNMUTE = '9864994326891137',
ADMIN_COUNT = '7130823597031706',
CHANGE_OWNER = '7341777602580933',
DEMOTE = '6551828931592903',
-1
View File
@@ -23,7 +23,6 @@ export type BrowsersMap = {
baileys(browser: string): [string, string, string]
windows(browser: string): [string, string, string]
appropriate(browser: string): [string, string, string]
android(apiLevel: string): [string, string, string]
}
export enum DisconnectReason {
+3 -20
View File
@@ -94,13 +94,7 @@ const BROWSER_TO_PLATFORM_ID: ReadonlyMap<string, string> = (() => {
}
}
const map = 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
return new Map(entries)
})()
// ============================================================
@@ -337,8 +331,7 @@ export const Browsers: BrowsersMap = {
macOS: (browser: string): [string, string, string] => ['Mac OS', browser, OS_VERSIONS.macOS],
windows: (browser: string): [string, string, string] => ['Windows', browser, OS_VERSIONS.windows],
baileys: (browser: string): [string, string, string] => ['Baileys', browser, OS_VERSIONS.baileys],
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()],
android: (apiLevel: string): [string, string, string] => [apiLevel, 'Android', '']
appropriate: (browser: string): [string, string, string] => [getPlatformName(), browser, getAppropriateVersion()]
} as const
/**
@@ -385,7 +378,7 @@ export const getPlatformId = (browser: unknown): string => {
* properties like 'toString' or 'constructor'.
*
* @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
* isValidBrowserPreset('ubuntu') // true
@@ -398,16 +391,6 @@ export const getPlatformId = (browser: unknown): string => {
* 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 => {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(Browsers, value)
}
-1
View File
@@ -1083,7 +1083,6 @@ export const processSyncAction = (
action.lidContactAction.firstName ||
action.lidContactAction.username ||
undefined,
username: action.lidContactAction.username || undefined,
lid: id!,
phoneNumber: undefined
}
+68 -39
View File
@@ -14,6 +14,7 @@ import {
isJidStatusBroadcast,
isLidUser,
isPnUser,
jidDecode
// transferDevice
} from '../WABinary'
import { unpadRandomMax16 } from './generics'
@@ -242,14 +243,10 @@ export function decodeMessageNode(stanza: BinaryNode, meId: string, meLid: strin
const key: WAMessageKey = {
remoteJid: chatId,
remoteJidAlt: !isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
remoteJidUsername: !isJidGroup(chatId)
? stanza.attrs.peer_recipient_username || stanza.attrs.recipient_username
: undefined,
fromMe,
id: msgId,
participant,
participantAlt: isJidGroup(chatId) ? addressingContext.senderAlt : undefined,
participantUsername: stanza.attrs.participant ? stanza.attrs.participant_username : undefined,
addressingMode: addressingContext.addressingMode,
...(msgType === 'newsletter' && stanza.attrs.server_id ? { server_id: stanza.attrs.server_id } : {})
}
@@ -279,6 +276,7 @@ export const decryptMessageNode = (
meLid: string,
repository: SignalRepositoryWithLIDStore,
logger: ILogger,
autoCleanCorrupted = true
) => {
const { fullMessage, author, sender } = decodeMessageNode(stanza, meId, meLid)
return {
@@ -396,23 +394,6 @@ export const decryptMessageNode = (
} else {
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) {
// Check if this is a final failure after all retries exhausted
const isRetryExhausted = err instanceof RetryExhaustedError
@@ -441,19 +422,34 @@ export const decryptMessageNode = (
if (isRetryExhausted) {
logger.error(
errorContext,
`⚠️ Session corrupted after ${err.attempts} attempts. Retry+pkmsg flow will recover.`
`⚠️ Session corrupted and recovery failed after ${err.attempts} attempts. Auto-cleanup will attempt to recover.`
)
} else {
// First occurrence - log as warning since auto-recovery will attempt
logger.warn(errorContext, '⚠️ Corrupted session detected - attempting auto-recovery')
}
// Session cleanup is deferred to retry exhaustion (safety net).
// The Signal Protocol handles recovery naturally via retry+pkmsg:
// Bad MAC -> retry receipt -> sender re-sends as pkmsg -> new session.
// Deleting sessions here (hot path) causes cascading failures when
// multiple messages from the same contact arrive simultaneously.
// See: messages-recv.ts sendRetryRequest() for deferred cleanup.
// Automatic cleanup of corrupted session (if enabled)
// eslint-disable-next-line max-depth
if (autoCleanCorrupted) {
// eslint-disable-next-line max-depth
try {
const deletedCount = await cleanupCorruptedSession(decryptionJid, repository, logger)
// Mask only user portion of JID for privacy (preserve domain info)
const { user, server } = jidDecode(decryptionJid) || {}
const maskedUser =
user && user.length > 8 ? `${user.substring(0, 4)}****${user.substring(user.length - 4)}` : user
const maskedJid = maskedUser && server ? `${maskedUser}@${server}` : decryptionJid
logger.info(
{ jid: decryptionJid, maskedJid, targetedDevices: deletedCount },
`🔄 Session Reset | JID: ${maskedJid} | Targeted: ${deletedCount} devices | Will recreate on next message`
)
} catch (cleanupErr) {
logger.error({ decryptionJid, err: cleanupErr }, '❌ Failed to cleanup corrupted session')
}
}
} else if (isSessionRecord) {
// Session record errors are transient - retry should handle them
// eslint-disable-next-line max-depth
@@ -508,20 +504,53 @@ export function isCorruptedSessionError(error: any): boolean {
}
/**
* Clean up corrupted session for a specific device JID.
* WABA behavior: DELETE sessions WHERE recipient_id=? AND device_id=?
* Only deletes the exact device that was corrupted, not all devices.
*
* NOTE: This should NOT be called on every Bad MAC error (hot path).
* Instead, let the retry+pkmsg flow handle recovery naturally (like WhatsApp does).
* Only call this as a safety net when retries are exhausted.
* Clean up corrupted session by deleting all device sessions for a JID
* Signal Protocol will automatically recreate the session on next message
*/
export async function cleanupCorruptedSession(
async function cleanupCorruptedSession(
jid: string,
repository: SignalRepositoryWithLIDStore,
logger: ILogger
): Promise<number> {
await repository.deleteSession([jid])
logger.info({ jid }, 'Cleaned up corrupted session for specific device')
return 1
const { user, device } = jidDecode(jid) || {}
if (!user) {
logger.warn({ jid }, 'Cannot cleanup session - invalid JID')
return 0
}
// Build list of JIDs to delete (primary + secondary devices)
const jidsToDelete: string[] = []
// Determine domain type correctly (handle hosted JIDs)
// JID formats:
// - PN: user@s.whatsapp.net
// - LID: user@lid
// - Hosted PN: user@hosted
// - Hosted LID: user@hosted.lid
const isLID = jid.endsWith('@lid') || jid.endsWith('@hosted.lid')
const isHosted = jid.includes('@hosted')
let domain: string
if (isLID) {
domain = isHosted ? 'hosted.lid' : 'lid'
} else {
domain = isHosted ? 'hosted' : 's.whatsapp.net'
}
// Primary device (0)
jidsToDelete.push(`${user}@${domain}`)
// Secondary devices (1-5 common range for Web/Desktop/etc)
for (let i = 1; i <= 5; i++) {
jidsToDelete.push(`${user}:${i}@${domain}`)
}
// If specific device was identified and > 5, ensure it's included
if (device !== undefined && device > 5) {
jidsToDelete.push(`${user}:${device}@${domain}`)
}
await repository.deleteSession(jidsToDelete)
return jidsToDelete.length
}
-28
View File
@@ -1,5 +1,4 @@
import EventEmitter from 'events'
import { proto } from '../../WAProto/index.js'
import type {
BaileysEvent,
BaileysEventEmitter,
@@ -891,7 +890,6 @@ const makeBufferData = (): BufferedEventData => {
chats: {},
messages: {},
contacts: {},
pastParticipants: {},
isLatest: false,
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.syncType = eventData.syncType
data.historySets.progress = eventData.progress
@@ -1316,10 +1292,6 @@ function consolidateEvents(data: BufferedEventData) {
chats: Object.values(data.historySets.chats),
messages: Object.values(data.historySets.messages),
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,
progress: data.historySets.progress,
isLatest: data.historySets.isLatest,
+13 -46
View File
@@ -1,6 +1,5 @@
import { pipeline } from 'stream/promises'
import { promisify } from 'util'
import { createInflate, inflate } from 'zlib'
import { inflate } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { Chat, Contact, LIDMapping, WAMessage } from '../Types'
import { WAMessageStubType } from '../Types'
@@ -16,8 +15,7 @@ import {
import { toNumber } from './generics'
import type { ILogger } from './logger.js'
import { normalizeMessageContent } from './messages'
import { DEFAULT_ORIGIN } from '../Defaults'
import { downloadContentFromMessage, getUrlFromDirectPath } from './messages-media'
import { downloadContentFromMessage } from './messages-media'
const inflatePromise = promisify(inflate)
@@ -30,15 +28,16 @@ const inflatePromise = promisify(inflate)
*/
export const downloadHistory = async (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => {
const stream = await downloadContentFromMessage(msg, 'md-msg-hist', { options })
// Pipe decrypted stream directly through zlib inflate.
// Avoids allocating an intermediate buffer for the compressed payload —
// memory peaks during 50MB history syncs drop ~50% (PR upstream #2333).
const inflater = createInflate()
const chunks: Buffer[] = []
inflater.on('data', (chunk: Buffer) => chunks.push(chunk))
await pipeline(stream, inflater)
const bufferArray: Buffer[] = []
for await (const chunk of stream) {
bufferArray.push(chunk)
}
let buffer: Buffer = Buffer.concat(bufferArray)
// decompress buffer
buffer = await inflatePromise(buffer)
const buffer = Buffer.concat(chunks)
const syncData = proto.HistorySync.decode(buffer)
return syncData
}
@@ -315,7 +314,6 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
contacts.push({
id: chatId,
name: chat.displayName || chat.name || chat.username || undefined,
username: chat.username || undefined,
lid: chat.lidJid || chat.accountLid || undefined,
phoneNumber: chat.pnJid || undefined
})
@@ -361,7 +359,7 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
}
}
chats.push(chat)
chats.push({ ...chat })
}
break
@@ -376,25 +374,10 @@ export const processHistoryMessage = (item: proto.IHistorySync, logger?: ILogger
// Convert Map back to array for return
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 {
chats,
contacts,
messages,
pastParticipants,
lidPnMappings,
syncType: item.syncType,
progress: item.progress
@@ -425,23 +408,7 @@ export const downloadAndProcessHistorySyncNotification = async (
historyMsg = await downloadHistory(msg, options)
}
const result = 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
return processHistoryMessage(historyMsg, logger)
}
/**
-23
View File
@@ -57,16 +57,6 @@ export type IdentityChangeContext = {
debounceCache: NodeCache<boolean>
/** Logger instance for debugging and monitoring */
logger: ILogger
/**
* Invoked right before `assertSessions` is called for an existing-session identity
* change. Used to kick off fire-and-forget side effects (e.g. tctoken re-issuance)
* in the same order WA Web does i.e. before the E2E session is re-established.
* Must not throw; implementations are responsible for their own error handling.
*
* Skipped when the refresh itself is skipped (no_identity_node, invalid_notification,
* skipped_companion_device, skipped_self_primary, debounced, skipped_offline).
*/
onBeforeSessionRefresh?: (jid: string) => void
}
// ============================================================================
@@ -180,19 +170,6 @@ export async function handleIdentityChange(
// This ensures we don't incorrectly debounce when we exit early (offline, etc.)
ctx.debounceCache.set(from, true)
// Fire-and-forget side effects (e.g. tctoken re-issuance) BEFORE the session is
// re-established. WA Web runs these in parallel with the session refresh —
// running afterwards would race with the next outbound send and risk error 463.
//
// Wrapped in try/catch so a misbehaving consumer callback cannot abort identity
// change recovery. We log and continue — assertSessions still runs so the E2E
// session always gets refreshed.
try {
ctx.onBeforeSessionRefresh?.(from)
} catch (error) {
ctx.logger.warn({ error, jid: from }, 'onBeforeSessionRefresh callback threw — continuing with session refresh')
}
// Attempt session refresh/creation
try {
await ctx.assertSessions([from], true)
+4 -22
View File
@@ -211,33 +211,15 @@ export class MessageRetryManager {
}
}
// MAC errors require session recreation, but rate-limited to prevent loops.
// When multiple messages fail with Bad MAC simultaneously, only the first
// should trigger recreation; subsequent ones within the cooldown window
// piggyback on the same new session established by the retry+pkmsg flow.
// MAC errors require IMMEDIATE session recreation regardless of history
// This handles the case where contact reinstalled WhatsApp (identity key changed)
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
const now = Date.now()
const prevTime = this.sessionRecreateHistory.get(jid)
const MAC_ERROR_COOLDOWN_MS = 1_000 // 1 second — WABA recovers faster
if (prevTime && now - prevTime < MAC_ERROR_COOLDOWN_MS) {
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
this.logger.debug(
{ jid, errorCode: reasonName, msSinceLast: now - prevTime },
'MAC error session recreation skipped — cooldown active'
)
return {
reason: '',
recreate: false
}
}
this.sessionRecreateHistory.set(jid, now)
this.sessionRecreateHistory.set(jid, Date.now())
this.statistics.sessionRecreations++
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
metrics.signalMacErrors?.inc({ action: 'session_recreation' })
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, recreating session')
this.logger.warn({ jid, errorCode: reasonName }, 'MAC error detected, forcing immediate session recreation')
return {
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
recreate: true
+8 -34
View File
@@ -10,13 +10,7 @@ import { join } from 'path'
import { Readable, Transform } from 'stream'
import { URL } from 'url'
import { proto } from '../../WAProto/index.js'
import {
DEFAULT_ORIGIN,
MEDIA_HKDF_KEY_MAPPING,
MEDIA_PATH_MAP,
type MediaType,
NEWSLETTER_MEDIA_PATH_MAP
} from '../Defaults'
import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP, type MediaType } from '../Defaults'
import type {
BaileysEventMap,
DownloadableMessage,
@@ -166,7 +160,7 @@ export const extractImageThumb = async (bufferOrFilePath: Readable | Buffer | st
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 dimensions = {
width: jimp.width,
@@ -618,7 +612,7 @@ export const downloadEncryptedContent = async (
const output = new Transform({
transform(chunk, _, callback) {
let data = remainingBytes.length ? Buffer.concat([remainingBytes, chunk]) : chunk
let data = Buffer.concat([remainingBytes, chunk])
const decryptLength = toSmallestChunkSize(data.length)
remainingBytes = data.slice(decryptLength)
@@ -687,10 +681,6 @@ type MediaUploadResult = {
meta_hmac?: string
ts?: number
fbid?: number
thumbnail_info?: {
thumbnail_sha256?: string
thumbnail_direct_path?: string
}
}
export type UploadParams = {
@@ -835,21 +825,11 @@ export const getWAUploadToServer = (
{ customUploadHosts, fetchAgent, logger, options }: SocketConfig,
refreshMediaConn: (force: boolean) => Promise<MediaConnInfo>
): 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
let uploadInfo = await refreshMediaConn(false)
let urls:
| {
mediaUrl: string
directPath: string
meta_hmac?: string
ts?: number
fbid?: number
thumbnailDirectPath?: string
thumbnailSha256?: string
}
| undefined
let urls: { mediaUrl: string; directPath: string; meta_hmac?: string; ts?: number; fbid?: number } | undefined
const hosts = [...customUploadHosts, ...uploadInfo.hosts]
fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
@@ -871,11 +851,7 @@ export const getWAUploadToServer = (
logger.debug(`uploading to "${hostname}"`)
const auth = encodeURIComponent(uploadInfo.auth)
const mediaPath = (newsletter ? NEWSLETTER_MEDIA_PATH_MAP[mediaType] : undefined) || MEDIA_PATH_MAP[mediaType]
let url = `https://${hostname}${mediaPath}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
if (newsletter) {
url += '&server_thumb_gen=1'
}
const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
let result: MediaUploadResult | undefined
try {
@@ -892,13 +868,11 @@ export const getWAUploadToServer = (
if (result?.url || result?.direct_path) {
urls = {
mediaUrl: result.url || result.direct_path!,
mediaUrl: result.url!,
directPath: result.direct_path!,
meta_hmac: result.meta_hmac,
fbid: result.fbid,
ts: result.ts,
thumbnailDirectPath: result.thumbnail_info?.thumbnail_direct_path,
thumbnailSha256: result.thumbnail_info?.thumbnail_sha256
ts: result.ts
}
break
} else {
+15 -108
View File
@@ -41,11 +41,9 @@ import type { ILogger } from './logger'
import {
downloadContentFromMessage,
encryptedStream,
extractImageThumb,
generateThumbnail,
getAudioDuration,
getAudioWaveform,
getStream,
getRawMediaUploadData,
type MediaDownloadOptions
} from './messages-media'
@@ -196,11 +194,10 @@ export const prepareWAMessageMedia = async (
)
const fileSha256B64 = fileSha256.toString('base64')
const { directPath, thumbnailDirectPath, thumbnailSha256 } = await options.upload(filePath, {
const { mediaUrl, directPath } = await options.upload(filePath, {
fileEncSha256B64: fileSha256B64,
mediaType: mediaType,
timeoutMs: options.mediaUploadTimeoutMs,
newsletter: true
timeoutMs: options.mediaUploadTimeoutMs
})
await fs.unlink(filePath)
@@ -208,12 +205,10 @@ export const prepareWAMessageMedia = async (
const obj = WAProto.Message.fromObject({
// todo: add more support here
[`${mediaType}Message`]: (MessageTypeProto as any)[mediaType].fromObject({
// url intentionally omitted — newsletters use directPath only
url: mediaUrl,
directPath,
fileSha256,
fileLength,
thumbnailDirectPath,
thumbnailSha256: thumbnailSha256 ? Buffer.from(thumbnailSha256, 'base64') : undefined,
...uploadData,
media: undefined
})
@@ -239,7 +234,7 @@ export const prepareWAMessageMedia = async (
const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
const requiresThumbnailComputation =
(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 requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation
const { mediaKey, encFilePath, originalFilePath, fileEncSha256, fileSha256, fileLength } = await encryptedStream(
@@ -257,9 +252,7 @@ export const prepareWAMessageMedia = async (
(async () => {
const result = await options.upload(encFilePath, {
fileEncSha256B64,
// Use mediaTypeOverride for upload path (e.g. 'viewonce-image' → /o1/mms/image)
// Encryption already used the override via encryptedStream above
mediaType: options.mediaTypeOverride || mediaType,
mediaType,
timeoutMs: options.mediaUploadTimeoutMs
})
logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
@@ -297,7 +290,7 @@ export const prepareWAMessageMedia = async (
logger?.debug('computed audio duration')
}
if (requiresWaveformProcessing && !uploadData.waveform) {
if (requiresWaveformProcessing) {
uploadData.waveform = await getAudioWaveform(filePath, logger)
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)
const carouselCards = await Promise.all(
cards.map(async card => {
@@ -708,11 +648,6 @@ export const generateCarouselMessage = async (
if (hasMedia && mediaOptions) {
if (card.image) {
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
if (imageMessage && !imageMessage.jpegThumbnail) {
mediaOptions.logger?.warn(
@@ -1304,8 +1239,7 @@ export const generateWAMessageContent = async (
}
// Pass options for media processing if cards have images/videos
const generated = await generateCarouselMessage(carouselOptions, options)
// Frida capture shows interactiveMessage DIRECT (field 45) in DSM — no viewOnceMessage wrapper
// Testing without wrapper: biz node + quality_control already match Pastorini CDP stanza
// Direct interactiveMessage — no viewOnceMessage wrapper, no root header/body/footer
m.interactiveMessage = generated.interactiveMessage
return m
}
@@ -1752,49 +1686,22 @@ export const generateWAMessageContent = async (
}
}
} else {
// For view-once media, upload to the one-time CDN (/o1/mms/*) instead of regular CDN
// 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
})
m = await prepareWAMessageMedia(message, options)
}
if (hasOptionalProperty(message, 'viewOnce') && !!message.viewOnce) {
// Also set viewOnce=true on the inner media message (required by WA app to render as view-once)
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 } }
m = { viewOnceMessage: { message: m } }
}
if (
(hasOptionalProperty(message, 'mentions') && message.mentions?.length) ||
(hasOptionalProperty(message, 'mentionAll') && message.mentionAll)
) {
if (hasOptionalProperty(message, 'mentions') && message.mentions?.length) {
const messageType = Object.keys(m)[0] as Extract<keyof proto.IMessage, MessageWithContextInfo>
if (messageType) {
const key = m[messageType]
if (key && 'contextInfo' in key) {
key.contextInfo = key.contextInfo || {}
if (message.mentions?.length) {
key.contextInfo.mentionedJid = message.mentions
}
if (message.mentionAll) {
key.contextInfo.nonJidMentions = 1
if (key && 'contextInfo' in key && !!key.contextInfo) {
key.contextInfo.mentionedJid = message.mentions
} else if (key) {
key.contextInfo = {
mentionedJid: message.mentions
}
}
}
+29 -198
View File
@@ -19,13 +19,10 @@ import type {
WAMessage,
WAMessageKey
} from '../Types'
import type { LIDMappingStore } from '../Signal/lid-mapping'
import { WAMessageStubType } from '../Types'
import { getContentType, normalizeMessageContent } from '../Utils/messages'
import {
areJidsSameUser,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidBroadcast,
@@ -40,7 +37,6 @@ import { getKeyAuthor, toNumber } from './generics'
import { downloadAndProcessHistorySyncNotification } from './history'
import type { ILogger } from './logger'
import { metrics, recordHistorySyncMessages } from './prometheus-metrics.js'
import { buildMergedTcTokenIndexWrite, resolveTcTokenJid } from './tc-token-utils'
type ProcessMessageContext = {
shouldProcessHistoryMsg: boolean
@@ -63,89 +59,6 @@ const REAL_MSG_STUB_TYPES = new Set([
const REAL_MSG_REQ_ME_STUB_TYPES = new Set([WAMessageStubType.GROUP_PARTICIPANT_ADD])
/**
* Extract tctoken / tcTokenTimestamp / tcTokenSenderTimestamp from history-sync chats
* and persist them to the `tctoken` store. Mirrors WA Web's `bulkCreateOrMerge` pass
* over the chat table during history sync.
*
* Why this matters: when a user logs in on a new device, the multi-device history sync
* is the only way that device learns about tctokens issued/received on the original
* device. Without this pass, the new device sends 1:1 messages with no tctoken until
* the contact triggers a fresh notification which surfaces as error 463 in production.
*
* Monotonicity: we only overwrite an existing entry if the incoming timestamp is
* STRICTLY newer (`incoming > existing`). Equal timestamps are skipped to avoid
* reverting senderTimestamp / realIssueTimestamp set by other layers (e.g. a
* reissue that fired between history-sync chunks).
*
* Index hygiene: every JID we write here is added to the persistent prune index
* (TC_TOKEN_INDEX_KEY) via buildMergedTcTokenIndexWrite, so the 24h prune sweep in
* messages-recv picks them up across sessions.
*/
async function storeTcTokensFromHistorySync(
chats: Chat[],
signalRepository: SignalRepositoryWithLIDStore,
keyStore: SignalKeyStoreWithTransaction,
logger?: ILogger
) {
const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
const candidates: { storageJid: string; token: Buffer; ts: number; senderTs?: number }[] = []
for (const chat of chats) {
const ts = chat.tcTokenTimestamp ? toNumber(chat.tcTokenTimestamp) : 0
if (chat.tcToken?.length && ts > 0) {
const jid = jidNormalizedUser(chat.id!)
const storageJid = await resolveTcTokenJid(jid, getLIDForPN)
candidates.push({
storageJid,
token: Buffer.from(chat.tcToken),
ts,
senderTs: chat.tcTokenSenderTimestamp ? toNumber(chat.tcTokenSenderTimestamp) : undefined
})
}
}
if (!candidates.length) {
return
}
const jids = candidates.map(c => c.storageJid)
const existing = await keyStore.get('tctoken', jids)
const entries: Record<string, { token: Buffer; timestamp?: string; senderTimestamp?: number }> = {}
for (const c of candidates) {
// Same-batch dedup: when two chats resolve to the same storageJid (e.g. PN+LID
// aliases collapsing through resolveTcTokenJid, or duplicate chunks across
// retries), prefer the value already written by an earlier iteration so a
// lower-ts entry can't overwrite a higher-ts one captured from `existing`.
const existingEntry = entries[c.storageJid] ?? existing[c.storageJid]
const existingTs = existingEntry?.timestamp ? Number(existingEntry.timestamp) : 0
// Strict > guard: equal timestamps are skipped so we never clobber
// senderTimestamp written by other layers (issuance after send, etc).
if (existingTs > 0 && existingTs >= c.ts) {
continue
}
entries[c.storageJid] = {
...existingEntry,
token: c.token,
timestamp: String(c.ts),
...(c.senderTs !== undefined ? { senderTimestamp: c.senderTs } : {})
}
}
if (Object.keys(entries).length) {
logger?.debug({ count: Object.keys(entries).length }, 'storing tctokens from history sync')
try {
// Include updated __index so cross-session pruning picks these JIDs up.
const indexWrite = await buildMergedTcTokenIndexWrite(keyStore, Object.keys(entries))
await keyStore.set({ tctoken: { ...entries, ...indexWrite } })
} catch (err) {
logger?.warn({ err }, 'failed to store tctokens from history sync')
}
}
}
/** Cleans a received message to further processing */
export const cleanMessage = (message: WAMessage, meId: string, meLid: string) => {
// ensure remoteJid and participant doesn't have device or agent in it
@@ -212,94 +125,42 @@ export const cleanMessage = (message: WAMessage, meId: string, meLid: string) =>
}
}
/**
* Resolves a LID JID to its PN equivalent using the LID mapping store.
* Returns the original JID if it's not a LID or if no mapping is found.
* Safe to call with any JID type (group, newsletter, PN, etc.).
*/
export const resolveLidToPn = async (
jid: string | undefined | null,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isAnyLidUser(jid)) {
const pn = await lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN')
}
return pn || jid
}
return jid
}
/**
* Normalizes a WAMessageKey by resolving LIDPN for remoteJid and participant.
*/
export const normalizeKeyLidToPn = async (
key: WAMessageKey,
lidMapping: LIDMappingStore,
logger?: ILogger
): Promise<void> => {
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(key.remoteJid, lidMapping, logger),
resolveLidToPn(key.participant, lidMapping, logger)
])
if (resolvedRemoteJid) {
key.remoteJid = resolvedRemoteJid
}
if (resolvedParticipant) {
key.participant = resolvedParticipant
}
}
export const normalizeMessageJids = async (
message: WAMessage,
signalRepository: SignalRepositoryWithLIDStore,
logger?: ILogger
): Promise<void> => {
const lidMapping = signalRepository.lidMapping
const key = message.key
// FAST PATH: Use alt JIDs directly when available (avoids LIDMappingStore race condition).
// The stanza always carries both formats (LID + PN) in the attributes.
// When addressing_mode=lid, remoteJid is LID and remoteJidAlt is PN.
// When addressing_mode=pn, remoteJid is already PN (no conversion needed).
if (key.remoteJid && isAnyLidUser(key.remoteJid) && key.remoteJidAlt && isAnyPnUser(key.remoteJidAlt)) {
logger?.debug({ lid: key.remoteJid, pn: key.remoteJidAlt }, 'Resolved remoteJid LID→PN via alt (fast path)')
key.remoteJid = key.remoteJidAlt
}
if (key.participant && isAnyLidUser(key.participant) && key.participantAlt && isAnyPnUser(key.participantAlt)) {
logger?.debug({ lid: key.participant, pn: key.participantAlt }, 'Resolved participant LID→PN via alt (fast path)')
key.participant = key.participantAlt
}
// SLOW PATH: Resolve any remaining LIDs via LIDMappingStore lookup
await normalizeKeyLidToPn(key, lidMapping, logger)
// Also normalize participantAlt (the alternative JID format — can be LID when addressing_mode=pn)
if (key.participantAlt && isAnyLidUser(key.participantAlt)) {
const resolved = await resolveLidToPn(key.participantAlt, lidMapping, logger)
if (resolved) {
key.participantAlt = resolved
const resolveLidToPn = async (jid: string | undefined | null): Promise<string | undefined> => {
if (!jid) {
return undefined
}
if (isLidUser(jid) || isHostedLidUser(jid)) {
const pn = await signalRepository.lidMapping.getPNForLID(jid)
if (pn) {
logger?.debug({ lid: jid, pn }, 'Resolved LID to PN for inbound message')
} else {
logger?.debug({ lid: jid }, 'PN not found for inbound LID, keeping LID')
}
return pn || jid
}
return jid
}
// Normalize nested message keys (reaction, poll) that may contain LID JIDs
const content = normalizeMessageContent(message.message)
if (content?.reactionMessage?.key) {
await normalizeKeyLidToPn(content.reactionMessage.key, lidMapping, logger)
// Execute both lookups in parallel instead of sequentially to reduce latency
const [resolvedRemoteJid, resolvedParticipant] = await Promise.all([
resolveLidToPn(message.key.remoteJid),
resolveLidToPn(message.key.participant)
])
if (resolvedRemoteJid) {
message.key.remoteJid = resolvedRemoteJid
}
if (content?.pollUpdateMessage?.pollCreationMessageKey) {
await normalizeKeyLidToPn(content.pollUpdateMessage.pollCreationMessageKey, lidMapping, logger)
if (resolvedParticipant) {
message.key.participant = resolvedParticipant
}
}
@@ -502,12 +363,6 @@ const processMessage = async (
// Emit LID-PN mappings from history sync
// This is how WhatsApp Web learns mappings for chats with non-contacts
//
// MUST run BEFORE storeTcTokensFromHistorySync — otherwise resolveTcTokenJid()
// can't resolve PN→LID for fresh-device chats (mapping cache is empty), tokens
// get persisted under PN keys, and the send path (which resolves to LID first)
// misses them — exactly the error 463 scenario this whole change is meant to
// prevent. Catches Codex P1 review on PR #386.
if (data.lidPnMappings?.length) {
logger?.debug({ count: data.lidPnMappings.length }, 'processing LID-PN mappings from history sync')
// eslint-disable-next-line max-depth
@@ -532,12 +387,6 @@ const processMessage = async (
}
}
// Persist tctokens carried by history-sync chats BEFORE emitting messaging-history.set
// — listeners may immediately fire outbound sends that need the tctoken, and the store
// has to be populated first to avoid an error 463 on the first multi-device send.
// Runs AFTER storeLIDPNMappings (see comment above) so LID resolution works.
await storeTcTokensFromHistorySync(data.chats, signalRepository, keyStore, logger)
ev.emit('messaging-history.set', {
...data,
isLatest: histNotification.syncType !== proto.HistorySync.HistorySyncType.ON_DEMAND ? isLatest : undefined,
@@ -685,12 +534,6 @@ const processMessage = async (
'CTWA: Successfully recovered message via placeholder resend'
)
// Normalize LID→PN in PDO-recovered message key before emitting
// eslint-disable-next-line max-depth
if (webMessageInfo.key && signalRepository) {
await normalizeKeyLidToPn(webMessageInfo.key as WAMessageKey, signalRepository.lidMapping, logger)
}
// wait till another upsert event is available, don't want it to be part of the PDO response message
// TODO: parse through proper message handling utilities (to add relevant key fields)
ev.emit('messages.upsert', {
@@ -831,13 +674,9 @@ const processMessage = async (
response: responseMsg
}
// Normalize creationMsgKey JIDs for the emitted event
const normalizedCreationKey = { ...creationMsgKey }
await normalizeKeyLidToPn(normalizedCreationKey, signalRepository.lidMapping, logger)
ev.emit('messages.update', [
{
key: normalizedCreationKey,
key: creationMsgKey,
update: {
eventResponses: [eventResponse]
}
@@ -859,19 +698,12 @@ const processMessage = async (
id: jid,
author: message.key.participant!,
authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participants,
action
})
const emitGroupUpdate = (update: Partial<GroupMetadata>) => {
ev.emit('groups.update', [
{
id: jid,
...update,
author: message.key.participant ?? undefined,
authorPn: message.key.participantAlt,
authorUsername: message.key.participantUsername
}
{ id: jid, ...update, author: message.key.participant ?? undefined, authorPn: message.key.participantAlt }
])
}
@@ -880,7 +712,6 @@ const processMessage = async (
id: jid,
author: message.key.participant!,
authorPn: message.key.participantAlt!,
authorUsername: message.key.participantUsername!,
participant: participant.lid,
participantPn: participant.pn,
action,
@@ -888,7 +719,7 @@ const processMessage = async (
})
}
const participantsIncludesMe = () => participants.find(p => areJidsSameUser(meId, p.id) || areJidsSameUser(meId, p.phoneNumber))
const participantsIncludesMe = () => participants.find(jid => areJidsSameUser(meId, jid.phoneNumber)) // ADD SUPPORT FOR LID
switch (message.messageStubType) {
case WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER:
+19 -120
View File
@@ -2,7 +2,6 @@ import { Boom } from '@hapi/boom'
import { createHash } from 'crypto'
import { zipSync } from 'fflate'
import { promises as fs } from 'fs'
import { gzipSync, gunzipSync } from 'zlib'
import { proto } from '../../WAProto/index.js'
import type { MediaType } from '../Defaults/index.js'
import type { StickerPack, WAMediaUpload, WAMediaUploadFunction } from '../Types/Message.js'
@@ -102,49 +101,6 @@ export const isAnimatedWebP = (buffer: Buffer): boolean => {
return false
}
/**
* Detecta se um buffer é Lottie JSON (raw ou gzip-compressed/WAS)
*
* WABA usa mimetype `application/was` para stickers Lottie.
* WAS (WhatsApp Animated Sticker) = gzip-compressed Lottie JSON.
*
* Detecção:
* - Gzip (0x1f 0x8b): descomprime e verifica se é Lottie JSON
* - JSON bruto: verifica campos Lottie obrigatórios (v, ip, op, layers)
*
* @param buffer - Buffer to check
* @returns true if buffer is Lottie/WAS format
*/
export const isLottieBuffer = (buffer: Buffer): boolean => {
if (buffer.length < 2) return false
let jsonBuffer: Buffer
// Check if gzip-compressed (WAS format)
if (buffer[0] === 0x1f && buffer[1] === 0x8b) {
try {
// SECURITY: Limit decompressed output to 50MB to prevent decompression bombs
jsonBuffer = gunzipSync(buffer, { maxOutputLength: 50 * 1024 * 1024 }) as Buffer
} catch {
return false
}
} else if (buffer[0] === 0x7b) {
// Starts with '{' - could be raw Lottie JSON
jsonBuffer = buffer
} else {
return false
}
// Validate Lottie JSON structure: must have v, ip, op, AND layers
try {
const str = jsonBuffer.toString('utf8', 0, Math.min(jsonBuffer.length, 4096))
// Quick check for ALL required Lottie fields
return str.includes('"v"') && str.includes('"layers"') && str.includes('"ip"') && str.includes('"op"')
} catch {
return false
}
}
/**
* Converte uma imagem para WebP usando Sharp
* Preserva o buffer original se for WebP para manter EXIF e animações
@@ -158,25 +114,12 @@ export const isLottieBuffer = (buffer: Buffer): boolean => {
const convertToWebP = async (
buffer: Buffer,
logger?: ILogger
): Promise<{ webpBuffer: Buffer; isAnimated: boolean; isLottie: boolean }> => {
// Lottie/WAS: ensure gzip-compressed format (WAS = gzip Lottie JSON)
if (isLottieBuffer(buffer)) {
let wasBuffer = buffer
// Raw Lottie JSON (starts with '{') must be gzipped to produce valid WAS
if (buffer[0] === 0x7b) {
logger?.trace('Raw Lottie JSON detected, gzip-compressing to WAS format')
wasBuffer = gzipSync(buffer) as Buffer
}
logger?.trace('Input is Lottie/WAS format')
return { webpBuffer: wasBuffer, isAnimated: true, isLottie: true }
}
): Promise<{ webpBuffer: Buffer; isAnimated: boolean }> => {
// Se já é WebP, preserva o buffer original (mantém EXIF e animações)
if (isWebPBuffer(buffer)) {
const isAnimated = isAnimatedWebP(buffer)
logger?.trace({ isAnimated }, 'Input is already WebP, preserving original buffer')
return { webpBuffer: buffer, isAnimated, isLottie: false }
return { webpBuffer: buffer, isAnimated }
}
// Tenta usar Sharp para converter
@@ -192,7 +135,7 @@ const convertToWebP = async (
logger?.trace('Converting image to WebP using Sharp')
const webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
return { webpBuffer, isAnimated: false, isLottie: false }
return { webpBuffer, isAnimated: false }
}
/**
@@ -395,11 +338,9 @@ export type PrepareStickerPackMessageOptions = {
*
* **Especificações WhatsApp:**
* - 3-30 stickers por pack (oficial)
* - WebP ou Lottie/WAS (application/was)
* - Stickers: 512x512 pixels (auto-resize)
* - WebP obrigatório
* - Recomendado: 100KB por sticker estático, 500KB animado
* - Tray icon: 96x96 pixels (PNG no ZIP)
* - Thumbnail: 252x252 pixels (JPEG, upload separado)
* - Tray icon: 252x252 pixels
*
* @param stickerPack - Sticker pack data with stickers, cover, name, publisher
* @param options - Upload function and optional logger
@@ -523,36 +464,9 @@ export const prepareStickerPackMessage = async (
// Obtém buffer do sticker
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
let { webpBuffer, isAnimated, isLottie } = await convertToWebP(buffer, logger)
// Validate explicit isLottie flag — must match detected format
if (sticker.isLottie !== undefined && sticker.isLottie !== isLottie) {
throw new Boom(
`Sticker ${i + 1}: explicit isLottie=${sticker.isLottie} does not match detected format (detected=${isLottie})`,
{ statusCode: 400 }
)
}
// WABA: Enforce 512x512 dimensions for WebP stickers (Lottie is vector, skip)
if (!isLottie && isWebPBuffer(webpBuffer)) {
const lib = await getImageProcessingLibrary()
if (lib?.sharp) {
const metadata = await lib.sharp.default(webpBuffer).metadata()
if (metadata.width !== 512 || metadata.height !== 512) {
logger?.trace(
{ index: i, width: metadata.width, height: metadata.height },
'Resizing sticker to 512x512 (WABA standard)'
)
webpBuffer = await lib.sharp
.default(webpBuffer)
.resize(512, 512, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.webp()
.toBuffer()
}
}
}
let { webpBuffer, isAnimated } = await convertToWebP(buffer, logger)
// ENHANCEMENT: Auto-compression if exceeds 1MB (try quality 70, then 50)
const MAX_STICKER_SIZE = 1024 * 1024 // 1MB
@@ -569,7 +483,7 @@ export const prepareStickerPackMessage = async (
if (lib?.sharp) {
// Try quality 70
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
if (compressed70.length <= MAX_STICKER_SIZE) {
@@ -584,7 +498,7 @@ export const prepareStickerPackMessage = async (
)
} else {
// 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
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 extension = isLottie ? 'was' : 'webp'
const fileName = `${sha256Hash}.${extension}`
const fileName = `${sha256Hash}.webp`
logger?.trace(
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated, isLottie },
{ index: i, fileName, sizeKB: finalSizeKB.toFixed(2), isAnimated },
'Sticker processed successfully'
)
@@ -647,7 +560,6 @@ export const prepareStickerPackMessage = async (
fileName,
webpBuffer,
isAnimated,
isLottie,
emojis: sticker.emojis || [],
accessibilityLabel: sticker.accessibilityLabel
}
@@ -666,7 +578,7 @@ export const prepareStickerPackMessage = async (
for (const result of processedStickers) {
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)
const existingMetadata = metadataByHash.get(fileName)
@@ -693,14 +605,13 @@ export const prepareStickerPackMessage = async (
// New sticker - add to ZIP and create metadata
stickerData[fileName] = [new Uint8Array(webpBuffer), { level: 0 as 0 }]
// WABA: mimetype é 'application/was' para Lottie, 'image/webp' para WebP
const metadata: proto.Message.StickerPackMessage.ISticker = {
fileName,
isAnimated,
emojis,
accessibilityLabel,
isLottie,
mimetype: isLottie ? 'application/was' : 'image/webp'
isLottie: false,
mimetype: 'image/webp'
}
metadataByHash.set(fileName, metadata)
@@ -725,22 +636,10 @@ export const prepareStickerPackMessage = async (
logger?.trace('Processing cover image')
coverBuffer = await mediaToBuffer(cover, 'cover image')
// Tray icon uses PNG format, 96x96 pixels (official client standard)
const lib = await getImageProcessingLibrary()
if (!lib?.sharp) {
throw new Boom(
'Sharp library is required for cover/tray icon processing. Install with: yarn add sharp',
{ statusCode: 400 }
)
}
coverWebP = await lib.sharp
.default(coverBuffer)
.resize(96, 96, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png()
.toBuffer()
coverFileName = `${stickerPackId}.png`
// Converte cover para WebP e adiciona ao ZIP
const result = await convertToWebP(coverBuffer, logger)
coverWebP = result.webpBuffer
coverFileName = `${stickerPackId}.webp`
stickerData[coverFileName] = [new Uint8Array(coverWebP), { level: 0 as 0 }]
} catch (error) {
throw new Boom(`Failed to process cover image: ${(error as Error).message}`, {
-1
View File
@@ -46,7 +46,6 @@ export const processContactAction = (
{
id,
name: action.fullName || action.firstName || action.username || undefined,
username: action.username || undefined,
lid: lidJid || undefined,
phoneNumber
}
+11 -139
View File
@@ -1,86 +1,12 @@
import type { SignalKeyStoreWithTransaction } from '../Types'
import type { BinaryNode } from '../WABinary'
import {
getBinaryNodeChild,
getBinaryNodeChildren,
isAnyLidUser,
isAnyPnUser,
isHostedLidUser,
isHostedPnUser,
isJidMetaAI,
isLidUser,
isPnUser,
jidNormalizedUser
} from '../WABinary'
import { getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidNormalizedUser } from '../WABinary'
/** 7 days in seconds — matches WA Web AB prop tctoken_duration */
const TC_TOKEN_BUCKET_DURATION = 604800
/** 4 buckets → ~28-day rolling window — matches WA Web AB prop tctoken_num_buckets */
const TC_TOKEN_NUM_BUCKETS = 4
/**
* Sentinel key under the `tctoken` store holding a JSON array of tracked storage JIDs
* for cross-session pruning. Mirrors WA Web's CLEAN_TC_TOKENS index lookup.
*
* Exported so other modules (messages-recv, messages-send, process-message) reference
* the same constant. The fork previously inlined this string in messages-recv.ts;
* keep the value identical (`'__index'`) for backward compatibility with persisted state.
*/
export const TC_TOKEN_INDEX_KEY = '__index'
// Phone-number pattern matching WABinary's isJidBot, applied against the user part so
// the check is invariant to @c.us ↔ @s.whatsapp.net normalization.
const BOT_PHONE_REGEX = /^1313555\d{4}$|^131655500\d{2}$/
/**
* Mirrors WA Web's `Wid.isRegularUser()` (user ¬PSA ¬Bot). Used to gate tctoken
* storage against malformed notifications WA Web filters server-side but we
* defend here for parity with `WAWebSetTcTokenChatAction.handleIncomingTcToken`.
* Works for both pre- and post-normalized JIDs (`@c.us` vs `@s.whatsapp.net`).
*/
export function isRegularUser(jid: string | undefined): boolean {
if (!jid) return false
const user = jid.split('@')[0] ?? ''
if (user === '0') return false // PSA
if (BOT_PHONE_REGEX.test(user)) return false // Bot by phone pattern
if (isJidMetaAI(jid)) return false // MetaAI (@bot server)
return !!(isPnUser(jid) || isLidUser(jid) || isHostedPnUser(jid) || isHostedLidUser(jid) || jid.endsWith('@c.us'))
}
/** Read the persisted tctoken JID index and return its entries (never contains the sentinel key itself). */
export async function readTcTokenIndex(keys: SignalKeyStoreWithTransaction): Promise<string[]> {
const data = await keys.get('tctoken', [TC_TOKEN_INDEX_KEY])
const entry = data[TC_TOKEN_INDEX_KEY]
if (!entry?.token?.length) return []
try {
const parsed = JSON.parse(Buffer.from(entry.token).toString())
if (!Array.isArray(parsed)) return []
return parsed.filter((j): j is string => typeof j === 'string' && j.length > 0 && j !== TC_TOKEN_INDEX_KEY)
} catch {
return []
}
}
/**
* Build a SignalDataSet fragment that writes the merged index (persisted added)
* under the sentinel key. Lets callers update the index without clobbering writes
* made by other layers (history sync, concurrent sessions on the same store).
*/
export async function buildMergedTcTokenIndexWrite(
keys: SignalKeyStoreWithTransaction,
addedJids: Iterable<string>
): Promise<{ [TC_TOKEN_INDEX_KEY]: { token: Buffer } }> {
const persisted = await readTcTokenIndex(keys)
const merged = new Set(persisted)
for (const jid of addedJids) {
if (jid && jid !== TC_TOKEN_INDEX_KEY) merged.add(jid)
}
return {
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...merged])) }
}
}
/**
* Check if a received token is expired using WA Web's rolling bucket algorithm.
* Reference: WAWebTrustedContactsUtils.isTokenExpired
@@ -137,43 +63,6 @@ export async function resolveTcTokenJid(
return lid ?? normalized
}
/**
* Resolve target JID for issuing privacy token based on AB prop 14303
* (`lid_trusted_token_issue_to_lid`). When the prop is on, issuance goes to
* the LID; when off, it goes to the PN. Returns the original JID if no
* mapping is found in either direction.
*
* Normalizes the JID upfront and uses the `isAny*` helpers so callers can pass
* `@c.us`, `@s.whatsapp.net`, `@hosted`, `@hosted.lid`, `@lid` or device-specific
* forms `LIDMappingStore.getLIDForPN` early-returns unless `isAnyPnUser`,
* so unnormalized inputs would silently bypass routing.
*
* Reference: WAWebTrustedContactsManager.issuePrivacyTokens
*/
export async function resolveIssuanceJid(
jid: string,
issueToLid: boolean,
getLIDForPN: (pn: string) => Promise<string | null>,
getPNForLID?: (lid: string) => Promise<string | null>
): Promise<string> {
const normalized = jidNormalizedUser(jid)
if (issueToLid) {
if (isAnyLidUser(normalized)) return normalized
if (!isAnyPnUser(normalized)) return normalized
const lid = await getLIDForPN(normalized)
return lid ?? normalized
}
if (!isAnyLidUser(normalized)) return normalized
if (getPNForLID) {
const pn = await getPNForLID(normalized)
return pn ?? normalized
}
return normalized
}
type TcTokenParams = {
jid: string
baseContent?: BinaryNode[]
@@ -246,13 +135,7 @@ export async function storeTcTokensFromIqResult({
continue
}
// In notifications, tokenNode.attrs.jid is OUR own device JID, not the sender's.
// Prefer fallbackJid (resolved from notification's `from` / `sender_lid`) so the
// token is stored under the peer's JID, never under self.
const rawJid = jidNormalizedUser(fallbackJid || tokenNode.attrs.jid)
// Defense against malformed notifications (PSA WID '0', bots, MetaAI). WA Web
// filters these server-side; we mirror Wid.isRegularUser() locally.
if (!isRegularUser(rawJid)) continue
const rawJid = jidNormalizedUser(tokenNode.attrs.jid || fallbackJid)
const storageJid = await resolveTcTokenJid(rawJid, getLIDForPN)
const existingTcData = await keys.get('tctoken', [storageJid])
const existingEntry = existingTcData[storageJid]
@@ -271,26 +154,15 @@ export async function storeTcTokensFromIqResult({
continue
}
const tokenEntry = {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t,
// WABA Android: resets real_issue_timestamp to null when storing a new token
// (UPDATE wa_trusted_contacts_send SET real_issue_timestamp=null)
realIssueTimestamp: null
}
// 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 })
await keys.set({
tctoken: {
[storageJid]: {
...existingEntry,
token: Buffer.from(tokenNode.content),
timestamp: tokenNode.attrs.t
}
}
})
onNewJidStored?.(storageJid)
}
}
+31 -18
View File
@@ -3,7 +3,6 @@ import { createHash } from 'crypto'
import { proto } from '../../WAProto/index.js'
import {
KEY_BUNDLE_TYPE,
PROTOCOL_MODE,
WA_ADV_ACCOUNT_SIG_PREFIX,
WA_ADV_DEVICE_SIG_PREFIX,
WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
@@ -21,7 +20,7 @@ const getUserAgent = (config: SocketConfig): proto.ClientPayload.IUserAgent => {
secondary: config.version[1],
tertiary: config.version[2]
},
platform: proto.ClientPayload.UserAgent.Platform.WEB,
platform: proto.ClientPayload.UserAgent.Platform.MACOS,
releaseChannel: proto.ClientPayload.UserAgent.ReleaseChannel.RELEASE,
osVersion: '0.1',
device: 'Desktop',
@@ -59,16 +58,18 @@ const getClientPayload = (config: SocketConfig) => {
userAgent: getUserAgent(config)
}
// Native protocol (WAM\x05) does not use WebInfo — only web protocol does
if (PROTOCOL_MODE !== 'native') {
payload.webInfo = getWebInfo(config)
}
payload.webInfo = getWebInfo(config)
return payload
}
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 = {
...getClientPayload(config),
passive: true,
@@ -157,6 +158,9 @@ export const configureSuccessfulPairing = (
}: Pick<AuthenticationCreds, 'advSecretKey' | 'signedIdentityKey' | 'signalIdentities'>
) => {
const msgId = stanza.attrs.id
if (!msgId) {
throw new Boom('Missing message ID', { statusCode: 400 })
}
const pairSuccessNode = getBinaryNodeChild(stanza, 'pair-success')
@@ -172,42 +176,51 @@ export const configureSuccessfulPairing = (
const bizName = businessNode?.attrs.name
const jid = deviceNode.attrs.jid
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)
if (!details || !hmac) {
throw new Boom('Missing ADV signature fields', { statusCode: 400 })
}
let hmacPrefix = Buffer.from([])
if (accountType !== undefined && accountType === proto.ADVEncryptionType.HOSTED) {
hmacPrefix = WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
}
const advSign = hmacSign(Buffer.concat([hmacPrefix, details!]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac!, advSign) !== 0) {
const advSign = hmacSign(Buffer.concat([hmacPrefix, details]), Buffer.from(advSecretKey, 'base64'))
if (Buffer.compare(hmac, advSign) !== 0) {
throw new Boom('Invalid account signature')
}
const account = proto.ADVSignedDeviceIdentity.decode(details!)
const account = proto.ADVSignedDeviceIdentity.decode(details)
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 =
deviceIdentity.deviceType === proto.ADVEncryptionType.HOSTED
? WA_ADV_HOSTED_ACCOUNT_SIG_PREFIX
: WA_ADV_ACCOUNT_SIG_PREFIX
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails!, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey!, accountMsg, accountSignature!)) {
const accountMsg = Buffer.concat([accountSignaturePrefix, deviceDetails, signedIdentityKey.public])
if (!Curve.verify(accountSignatureKey, accountMsg, accountSignature)) {
throw new Boom('Failed to verify account signature')
}
const deviceMsg = Buffer.concat([
WA_ADV_DEVICE_SIG_PREFIX,
deviceDetails!,
deviceDetails,
signedIdentityKey.public,
accountSignatureKey!
accountSignatureKey
])
account.deviceSignature = Curve.sign(signedIdentityKey.private, deviceMsg)
const identity = createSignalIdentity(lid!, accountSignatureKey!)
const identity = createSignalIdentity(lid, accountSignatureKey)
const accountEnc = encodeSignedDeviceIdentity(account, false)
const reply: BinaryNode = {
@@ -215,7 +228,7 @@ export const configureSuccessfulPairing = (
attrs: {
to: S_WHATSAPP_NET,
type: 'result',
id: msgId!
id: msgId
},
content: [
{
@@ -234,7 +247,7 @@ export const configureSuccessfulPairing = (
const authUpdate: Partial<AuthenticationCreds> = {
account,
me: { id: jid!, name: bizName, lid },
me: { id: jid, name: bizName, lid },
signalIdentities: [...(signalIdentities || []), identity],
platform: platformNode?.attrs.name
}
+3 -29
View File
@@ -13,37 +13,11 @@ export class USyncContactProtocol implements USyncQueryProtocol {
}
getUserElement(user: USyncUser): BinaryNode {
if (user.phone) {
return {
tag: 'contact',
attrs: {},
content: user.phone
}
}
if (user.username) {
return {
tag: 'contact',
attrs: {
username: user.username,
...(user.usernameKey ? { pin: user.usernameKey } : {}),
...(user.lid ? { lid: user.lid } : {})
}
}
}
if (user.type) {
return {
tag: 'contact',
attrs: {
type: user.type
}
}
}
//TODO: Implement type / username fields (not yet supported)
return {
tag: 'contact',
attrs: {}
attrs: {},
content: user.phone
}
}
@@ -1,28 +0,0 @@
import type { USyncQueryProtocol } from '../../Types/USync'
import { assertNodeErrorFree, type BinaryNode } from '../../WABinary'
import { USyncUser } from '../USyncUser'
export class USyncUsernameProtocol implements USyncQueryProtocol {
name = 'username'
getQueryElement(): BinaryNode {
return {
tag: 'username',
attrs: {}
}
}
getUserElement(user: USyncUser): BinaryNode | null {
void user
return null
}
parser(node: BinaryNode): string | null {
if (node.tag === 'username') {
assertNodeErrorFree(node)
return typeof node.content === 'string' ? node.content : null
}
return null
}
}
-1
View File
@@ -2,4 +2,3 @@ export * from './USyncDeviceProtocol'
export * from './USyncContactProtocol'
export * from './USyncStatusProtocol'
export * from './USyncDisappearingModeProtocol'
export * from './USyncUsernameProtocol'
+9 -22
View File
@@ -6,12 +6,11 @@ import {
USyncContactProtocol,
USyncDeviceProtocol,
USyncDisappearingModeProtocol,
USyncStatusProtocol,
USyncUsernameProtocol
USyncStatusProtocol
} from './Protocols'
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 = {
list: USyncQueryResultList[]
@@ -69,8 +68,10 @@ export class USyncQuery {
//TODO: see if there are any errors in the result node
//const resultNode = getBinaryNodeChild(usyncNode, 'result')
const parseNodeList = (content: BinaryNode[]): USyncQueryResultList[] =>
content.reduce((acc: USyncQueryResultList[], node) => {
const listNode = usyncNode ? getBinaryNodeChild(usyncNode, 'list') : undefined
if (listNode?.content && Array.isArray(listNode.content)) {
queryResult.list = listNode.content.reduce((acc: USyncQueryResultList[], node) => {
const id = node?.attrs.jid
if (id) {
const data = Array.isArray(node?.content)
@@ -88,24 +89,15 @@ export class USyncQuery {
.filter(([, b]) => b !== null) as [string, unknown][]
)
: {}
const rawIdAttr = node?.attrs?.['raw_id']
const rawId = rawIdAttr !== undefined ? Number(rawIdAttr) : undefined
acc.push({ ...data, id, ...(rawId !== undefined && !isNaN(rawId) ? { rawId } : {}) })
acc.push({ ...data, id })
}
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
}
@@ -138,9 +130,4 @@ export class USyncQuery {
this.protocols.push(new USyncLIDProtocol())
return this
}
withUsernameProtocol() {
this.protocols.push(new USyncUsernameProtocol())
return this
}
}
-12
View File
@@ -2,8 +2,6 @@ export class USyncUser {
id?: string
lid?: string
phone?: string
username?: string
usernameKey?: string
type?: string
personaId?: string
@@ -22,16 +20,6 @@ export class USyncUser {
return this
}
withUsername(username: string) {
this.username = username
return this
}
withUsernameKey(usernameKey: string) {
this.usernameKey = usernameKey
return this
}
withType(type: string) {
this.type = type
return this
@@ -297,52 +297,4 @@ describe('Identity Change Handling', () => {
expect(result.device).toBe(5)
})
})
describe('onBeforeSessionRefresh callback', () => {
it('fires before assertSessions when a session refresh is about to run', async () => {
mockValidateSession.mockResolvedValue({ exists: true })
const callOrder: string[] = []
mockAssertSessions.mockImplementation(async () => {
callOrder.push('assertSessions')
return true
})
const onBeforeSessionRefresh = jest.fn((jid: string) => {
callOrder.push(`before:${jid}`)
})
const node = createIdentityChangeNode('user@s.whatsapp.net')
const ctx = { ...createContext(), onBeforeSessionRefresh }
const result = await handleIdentityChange(node, ctx)
expect(result.action).toBe('session_refreshed')
expect(callOrder).toEqual(['before:user@s.whatsapp.net', 'assertSessions'])
})
it('does not fire when the refresh is skipped (no_identity / offline / self)', async () => {
const onBeforeSessionRefresh = jest.fn()
// no identity node
const noIdentityNode: BinaryNode = {
tag: 'notification',
attrs: { from: 'a@s.whatsapp.net', type: 'encrypt' },
content: []
}
await handleIdentityChange(noIdentityNode, { ...createContext(), onBeforeSessionRefresh })
// offline notification
mockValidateSession.mockResolvedValue({ exists: true })
await handleIdentityChange(createIdentityChangeNode('b@s.whatsapp.net', '0'), {
...createContext(),
onBeforeSessionRefresh
})
// self primary
await handleIdentityChange(createIdentityChangeNode(mockMeId!), {
...createContext(),
onBeforeSessionRefresh
})
expect(onBeforeSessionRefresh).not.toHaveBeenCalled()
})
})
})
+1 -348
View File
@@ -1,17 +1,7 @@
import { jest } from '@jest/globals'
import { DisconnectReason, type SignalKeyStoreWithTransaction } from '../../Types'
import { getErrorCodeFromStreamError, SERVER_ERROR_CODES } from '../../Utils'
import {
buildMergedTcTokenIndexWrite,
buildTcTokenFromJid,
isRegularUser,
isTcTokenExpired,
readTcTokenIndex,
resolveIssuanceJid,
shouldSendNewTcToken,
storeTcTokensFromIqResult,
TC_TOKEN_INDEX_KEY
} from '../../Utils/tc-token-utils'
import { buildTcTokenFromJid, isTcTokenExpired, shouldSendNewTcToken } from '../../Utils/tc-token-utils'
import type { BinaryNode } from '../../WABinary'
/** 7 days in seconds — matches WA Web tctoken_duration */
@@ -858,340 +848,3 @@ describe('tctoken integration scenarios', () => {
})
})
})
// ─── isRegularUser (PSA / bot / MetaAI gating) ─────────────────────────
describe('isRegularUser', () => {
it('rejects undefined / empty', () => {
expect(isRegularUser(undefined)).toBe(false)
expect(isRegularUser('')).toBe(false)
})
it('rejects PSA WID (user "0")', () => {
expect(isRegularUser('0@s.whatsapp.net')).toBe(false)
expect(isRegularUser('0@c.us')).toBe(false)
})
it('rejects bot phone numbers (1313555XXXX, 131655500XX)', () => {
expect(isRegularUser('13135550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13135559999@s.whatsapp.net')).toBe(false)
// /^131655500\d{2}$/ — 11 digits: 131655500 + 2 trailing
expect(isRegularUser('13165550000@s.whatsapp.net')).toBe(false)
expect(isRegularUser('13165550099@s.whatsapp.net')).toBe(false)
})
it('rejects MetaAI (@bot server)', () => {
expect(isRegularUser('foo@bot')).toBe(false)
})
it('accepts regular PN users (@s.whatsapp.net, @c.us)', () => {
expect(isRegularUser('5511999999999@s.whatsapp.net')).toBe(true)
expect(isRegularUser('5511999999999@c.us')).toBe(true)
})
it('accepts LID users', () => {
expect(isRegularUser('123456@lid')).toBe(true)
})
it('accepts hosted PN/LID users', () => {
expect(isRegularUser('5511999999999@hosted')).toBe(true)
expect(isRegularUser('123456@hosted.lid')).toBe(true)
})
})
// ─── resolveIssuanceJid (AB prop 14303 routing) ────────────────────────
describe('resolveIssuanceJid', () => {
const PN = '5511999999999@s.whatsapp.net'
const LID = '123456@lid'
const PN_NO_LID = '5511888888888@s.whatsapp.net'
const LID_NO_PN = '999999@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN) return LID
return null
})
const getPNForLID = jest.fn<(lid: string) => Promise<string | null>>(async (lid: string) => {
if (lid === LID) return PN
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
getPNForLID.mockClear()
})
describe('AB prop 14303 ON (issueToLid=true)', () => {
it('returns LID unchanged when input is LID', async () => {
expect(await resolveIssuanceJid(LID, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('resolves PN to LID via lidMapping', async () => {
expect(await resolveIssuanceJid(PN, true, getLIDForPN, getPNForLID)).toBe(LID)
expect(getLIDForPN).toHaveBeenCalledWith(PN)
})
it('falls back to original PN when no LID mapping exists', async () => {
expect(await resolveIssuanceJid(PN_NO_LID, true, getLIDForPN, getPNForLID)).toBe(PN_NO_LID)
})
})
describe('AB prop 14303 OFF (issueToLid=false)', () => {
it('returns PN unchanged when input is PN', async () => {
expect(await resolveIssuanceJid(PN, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).not.toHaveBeenCalled()
})
it('resolves LID to PN via lidMapping', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN, getPNForLID)).toBe(PN)
expect(getPNForLID).toHaveBeenCalledWith(LID)
})
it('falls back to original LID when no PN mapping exists', async () => {
expect(await resolveIssuanceJid(LID_NO_PN, false, getLIDForPN, getPNForLID)).toBe(LID_NO_PN)
})
it('returns LID unchanged when getPNForLID is omitted', async () => {
expect(await resolveIssuanceJid(LID, false, getLIDForPN)).toBe(LID)
})
})
describe('JID normalization (handles @c.us and hosted forms)', () => {
const PN_CUS = '5511999999999@c.us'
const PN_NORMALIZED = '5511999999999@s.whatsapp.net'
const HOSTED_LID = '999999@hosted.lid'
it('normalizes @c.us before resolving (issueToLid=true)', async () => {
const fn = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PN_NORMALIZED) return LID
return null
})
const result = await resolveIssuanceJid(PN_CUS, true, fn, getPNForLID)
expect(result).toBe(LID)
// Mapping store called with NORMALIZED form
expect(fn).toHaveBeenCalledWith(PN_NORMALIZED)
})
it('treats hosted LID as LID input (issueToLid=true returns it unchanged)', async () => {
expect(await resolveIssuanceJid(HOSTED_LID, true, getLIDForPN, getPNForLID)).toBe(HOSTED_LID)
expect(getLIDForPN).not.toHaveBeenCalled()
})
it('treats hosted LID as LID input (issueToLid=false converts via getPNForLID)', async () => {
const fn = jest.fn<(lid: string) => Promise<string | null>>(async () => null)
await resolveIssuanceJid(HOSTED_LID, false, getLIDForPN, fn)
expect(fn).toHaveBeenCalledWith(HOSTED_LID)
})
})
})
// ─── readTcTokenIndex / buildMergedTcTokenIndexWrite ───────────────────
describe('tctoken cross-session prune index', () => {
it('exports the sentinel key as "__index"', () => {
// Persisted state compatibility — tests freeze the value to catch unintended renames.
expect(TC_TOKEN_INDEX_KEY).toBe('__index')
})
describe('readTcTokenIndex', () => {
it('returns [] when the index entry is absent', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the index token buffer is empty', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.alloc(0) } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is corrupted', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('not-json') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns [] when the JSON payload is not an array', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({ [TC_TOKEN_INDEX_KEY]: { token: Buffer.from('{"a":1}') } })
expect(await readTcTokenIndex(keys)).toEqual([])
})
it('returns the persisted JIDs', async () => {
const keys = createMockKeys()
const stored = ['a@lid', 'b@lid', 'c@s.whatsapp.net']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(stored)
})
it('filters out the sentinel key itself, empty strings, and non-strings', async () => {
const keys = createMockKeys()
const stored = ['a@lid', '', TC_TOKEN_INDEX_KEY, 42, null, 'b@lid']
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(stored)) }
})
expect(await readTcTokenIndex(keys)).toEqual(['a@lid', 'b@lid'])
})
})
describe('buildMergedTcTokenIndexWrite', () => {
it('merges added JIDs with the persisted set (de-duplicated)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify(['a@lid', 'b@lid'])) }
})
const write = await buildMergedTcTokenIndexWrite(keys, ['b@lid', 'c@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded.sort()).toEqual(['a@lid', 'b@lid', 'c@lid'])
})
it('drops the sentinel key from the added set', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [TC_TOKEN_INDEX_KEY, 'a@lid'])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual(['a@lid'])
})
it('handles empty inputs', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const write = await buildMergedTcTokenIndexWrite(keys, [])
const decoded = JSON.parse(write[TC_TOKEN_INDEX_KEY].token.toString()) as string[]
expect(decoded).toEqual([])
})
})
})
// ─── storeTcTokensFromIqResult — isRegularUser gating + monotonicity ───
describe('storeTcTokensFromIqResult — gating and monotonicity', () => {
const PEER_PN = '5511999999999@s.whatsapp.net'
const PEER_LID = '123456@lid'
const getLIDForPN = jest.fn<(pn: string) => Promise<string | null>>(async (pn: string) => {
if (pn === PEER_PN) return PEER_LID
return null
})
beforeEach(() => {
getLIDForPN.mockClear()
})
const buildIqResult = (jid: string, tokenBytes: Uint8Array, t: string): BinaryNode => ({
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid, type: 'trusted_contact', t },
content: tokenBytes
}
]
}
]
})
it('skips PSA WID (jid="0") even if returned in tokens block', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '0@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips bot phone numbers', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: '13135550000@s.whatsapp.net',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips MetaAI (@bot server)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0x01, 0x02]), '1700000000'),
fallbackJid: 'meta-ai@bot',
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('uses fallbackJid (NOT the token node jid which is own device) as storage source', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
const onNewJidStored = jest.fn()
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid:1@s.whatsapp.net', Buffer.from([0xaa, 0xbb]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN,
onNewJidStored
})
expect(keys.set).toHaveBeenCalled()
const setArgs = (keys.set.mock.calls[0]?.[0] as { tctoken: Record<string, unknown> }).tctoken
expect(Object.keys(setArgs)).toContain(PEER_LID)
expect(onNewJidStored).toHaveBeenCalledWith(PEER_LID)
})
it('skips when incoming timestamp is older than existing (monotonicity)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({
[PEER_LID]: { token: Buffer.from([0x99]), timestamp: '1800000000' }
})
await storeTcTokensFromIqResult({
result: buildIqResult('myDeviceJid@s.whatsapp.net', Buffer.from([0xaa]), '1700000000'),
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
it('skips timestamp-less tokens (would be immediately expired)', async () => {
const keys = createMockKeys()
;(keys.get as any).mockResolvedValue({})
await storeTcTokensFromIqResult({
result: {
tag: 'iq',
attrs: {},
content: [
{
tag: 'tokens',
attrs: {},
content: [
{
tag: 'token',
attrs: { jid: 'd@s.whatsapp.net', type: 'trusted_contact' },
content: Buffer.from([0x01])
}
]
}
]
},
fallbackJid: PEER_PN,
keys,
getLIDForPN
})
expect(keys.set).not.toHaveBeenCalled()
})
})
-209
View File
@@ -1,209 +0,0 @@
import Long from 'long'
import $protobuf from 'protobufjs/minimal.js'
const $util = $protobuf.util
// proto implementation
function longToStringOld(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (!$util.Long) return String(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function' ? normalized.toUnsigned() : normalized
return prepared.toString()
}
// bigint implementation
function longToStringNew(value: any, unsigned?: boolean): string {
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return (combined - (1n << 64n)).toString()
}
return combined.toString()
}
return String(value)
}
function longToNumberOld(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (!$util.Long) return Number(value)
const normalized = ($util.Long as any).fromValue(value)
const prepared =
unsigned && normalized && typeof normalized.toUnsigned === 'function'
? normalized.toUnsigned()
: typeof normalized.toSigned === 'function'
? normalized.toSigned()
: normalized
return prepared.toNumber()
}
function longToNumberNew(value: any, unsigned?: boolean): number {
if (typeof value === 'number') return value
if (typeof value === 'string') return Number(value)
if (value && typeof value.low === 'number' && typeof value.high === 'number') {
const high = value.high | 0
const lo = BigInt(value.low >>> 0)
const hi = BigInt(value.high >>> 0)
const combined = (hi << 32n) | lo
if (!unsigned && high < 0) {
return Number(combined - (1n << 64n))
}
return Number(combined)
}
return Number(value)
}
describe('BigInt vs Long equivalence validation', () => {
// Test cases: [description, Long value, unsigned flag]
const testCases: [string, Long, boolean][] = [
// Basic values
['zero unsigned', Long.fromNumber(0, true), true],
['zero signed', Long.fromNumber(0, false), false],
['one unsigned', Long.fromNumber(1, true), true],
['one signed', Long.fromNumber(1, false), false],
// Typical WhatsApp timestamps (seconds since epoch)
['timestamp 2023', Long.fromNumber(1700000000, true), true],
['timestamp 2025', Long.fromNumber(1750000000, true), true],
['timestamp as signed', Long.fromNumber(1700000000, false), false],
// File sizes
['small file', Long.fromNumber(1024, true), true],
['medium file 10MB', Long.fromNumber(10485760, true), true],
['large file 2GB', Long.fromNumber(2147483648, true), true],
// Boundary values - 32-bit
['max int32', Long.fromNumber(2147483647, false), false],
['min int32', Long.fromNumber(-2147483648, false), false],
['max uint32', Long.fromNumber(4294967295, true), true],
// Around MAX_SAFE_INTEGER
['MAX_SAFE_INTEGER', Long.fromString('9007199254740991', false), false],
['MAX_SAFE_INTEGER + 1', Long.fromString('9007199254740992', false), false],
['MAX_SAFE_INTEGER unsigned', Long.fromString('9007199254740991', true), true],
// Large unsigned values
['large unsigned', Long.fromString('9999999999999999999', true), true],
['max uint64', Long.fromString('18446744073709551615', true), true],
['max uint64 - 1', Long.fromString('18446744073709551614', true), true],
// Signed negative values
['negative one', Long.fromNumber(-1, false), false],
['negative small', Long.fromNumber(-100, false), false],
['negative large', Long.fromNumber(-2147483648, false), false],
['min int64', Long.fromString('-9223372036854775808', false), false],
['max int64', Long.fromString('9223372036854775807', false), false],
['-1 as signed Long', Long.fromBits(-1, -1, false), false],
// Tricky bit patterns
['high=1, low=0', Long.fromBits(0, 1, true), true],
['high=0, low=-1 (0xFFFFFFFF)', Long.fromBits(-1, 0, true), true],
['high=-1, low=-1 unsigned (max uint64)', Long.fromBits(-1, -1, true), true],
['high=0x7FFFFFFF, low=0xFFFFFFFF (max int64)', Long.fromBits(-1, 0x7fffffff, false), false],
['high=0x80000000, low=0 (min int64)', Long.fromBits(0, -2147483648, false), false],
// Powers of 2
['2^32', Long.fromString('4294967296', true), true],
['2^32 signed', Long.fromString('4294967296', false), false],
['2^48', Long.fromString('281474976710656', true), true],
['2^63', Long.fromString('9223372036854775808', true), true]
]
describe('longToString equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToStringOld(longVal, unsigned)
const newResult = longToStringNew(longVal, unsigned)
expect(newResult).toBe(oldResult)
})
}
})
describe('longToNumber equivalence', () => {
for (const [desc, longVal, unsigned] of testCases) {
it(`${desc}: Long(${longVal.low}, ${longVal.high}, ${unsigned})`, () => {
const oldResult = longToNumberOld(longVal, unsigned)
const newResult = longToNumberNew(longVal, unsigned)
// For large values, both may lose precision equally (Number limitation)
// So we compare string representations
expect(newResult.toString()).toBe(oldResult.toString())
})
}
})
describe('non-Long inputs (fallback paths)', () => {
it('string passthrough', () => {
expect(longToStringNew('12345')).toBe('12345')
expect(longToStringNew('0')).toBe('0')
})
it('number passthrough', () => {
expect(longToStringNew(42)).toBe('42')
expect(longToStringNew(0)).toBe('0')
expect(longToStringNew(-1)).toBe('-1')
})
it('null/undefined fallback', () => {
expect(longToStringNew(null)).toBe('null')
expect(longToStringNew(undefined)).toBe('undefined')
})
it('object without low/high', () => {
expect(longToStringNew({ foo: 'bar' })).toBe('[object Object]')
})
// Defensive: a raw {low, high} JSON object can carry `high` as an
// unsigned 32-bit value (Long.fromBits would normalize via `| 0`,
// but plain JSON deserialization does not). Without `value.high | 0`
// the sign-bit check fails and the result is interpreted as unsigned.
// Upstream Baileys PR #2333 has this latent bug; InfiniteAPI fixes it.
it('signed sign-bit detection on raw unsigned high (-1 as {low: -1, high: 4294967295})', () => {
const raw = { low: -1, high: 0xffffffff }
// signed: -1
expect(longToStringNew(raw, false)).toBe('-1')
// unsigned: max uint64
expect(longToStringNew(raw, true)).toBe('18446744073709551615')
})
it('signed sign-bit detection on raw unsigned high (-4294967296 as {low: 0, high: 4294967295})', () => {
const raw = { low: 0, high: 0xffffffff }
// signed: -4294967296 (high word = -1 after normalization)
expect(longToStringNew(raw, false)).toBe('-4294967296')
// unsigned: 0xFFFFFFFF00000000 = 18446744069414584320
expect(longToStringNew(raw, true)).toBe('18446744069414584320')
})
})
describe('fuzz: random Long values', () => {
it('100 random unsigned values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, true)
const oldResult = longToStringOld(longVal, true)
const newResult = longToStringNew(longVal, true)
expect(newResult).toBe(oldResult)
}
})
it('100 random signed values match', () => {
for (let i = 0; i < 100; i++) {
const low = (Math.random() * 0xffffffff) | 0
const high = (Math.random() * 0xffffffff) | 0
const longVal = Long.fromBits(low, high, false)
const oldResult = longToStringOld(longVal, false)
const newResult = longToStringNew(longVal, false)
expect(newResult).toBe(oldResult)
}
})
})
})
@@ -1,146 +0,0 @@
import { jest } from '@jest/globals'
import { proto, type WAMessage } from '../..'
import { DEFAULT_CONNECTION_CONFIG } from '../../Defaults'
import makeWASocket from '../../Socket'
import { makeSession, mockWebSocket } from '../TestUtils/session'
mockWebSocket()
// chats.ts treats a connection as a reconnection when EITHER signal is true:
// 1. authState.creds.accountSyncCounter > 0
// (at least one full history sync completed in a previous session)
// 2. socketSkippedOfflineBuffer (forwarded from socket.ts as `skipOfflineBuffer`)
// (socket.ts already decided to skip the offline buffer, e.g. because
// routingInfo was stale and was discarded on startup)
// On reconnection, AwaitingInitialSync is skipped and the buffer is flushed
// immediately so live messages are not held in the initial-sync window.
describe('Reconnection Sync Skip', () => {
it('should skip the history sync wait on reconnection (accountSyncCounter > 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Simulate a session that has already synced before
state.creds.accountSyncCounter = 1
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
// Simulate receiving pending notifications (triggers AwaitingInitialSync)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
// Emit a message immediately after — if the wait is skipped,
// the buffer should already be flushed and this message should be delivered.
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_RECONNECT' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be delivered immediately, NOT buffered
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should skip the history sync wait when socketSkippedOfflineBuffer is true (stale routingInfo, accountSyncCounter === 0)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh-looking session from the counter perspective
state.creds.accountSyncCounter = 0
// But routingInfo is present and we ask the socket to discard it on start.
// socket.ts will set hadStaleRoutingInfo=true → skipOfflineBuffer=true,
// which chats.ts forwards as socketSkippedOfflineBuffer. Without this
// branch, the buffers would be misaligned (offline buffer skipped while
// AwaitingInitialSync still waits) and live messages would stall.
state.creds.routingInfo = Buffer.from([1, 2, 3, 4])
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state,
clearRoutingInfoOnStart: true
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_AFTER_STALE_ROUTING' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello after stale routing reconnect' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
expect(messageListener).toHaveBeenCalledTimes(1)
expect(messageListener).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([expect.objectContaining({ key: msg.key })]),
type: 'notify'
})
)
await sock.end(new Error('Test completed'))
await clear()
})
it('should still wait for history sync on fresh pairing (both signals false)', async () => {
const { state, clear } = await makeSession()
state.creds.me = { id: '1234567890:1@s.whatsapp.net', name: 'Test User' }
// Fresh pairing — both reconnect signals are false:
// accountSyncCounter is 0 (default) and no stale routingInfo to clear.
state.creds.accountSyncCounter = 0
const sock = makeWASocket({
...DEFAULT_CONNECTION_CONFIG,
auth: state
})
const messageListener = jest.fn()
sock.ev.on('messages.upsert', messageListener)
sock.ev.emit('connection.update', { receivedPendingNotifications: true })
const msg = proto.WebMessageInfo.fromObject({
key: { remoteJid: '1234567890@s.whatsapp.net', fromMe: false, id: 'MSG_DURING_INITIAL_SYNC' },
messageTimestamp: Date.now() / 1000,
message: { conversation: 'Hello during initial sync' }
}) as WAMessage
sock.ev.emit('messages.upsert', { messages: [msg], type: 'notify' })
await new Promise(resolve => setTimeout(resolve, 50))
// Message should be BUFFERED (not delivered) because we're waiting for history sync
expect(messageListener).toHaveBeenCalledTimes(0)
// Drain the 2s AwaitingInitialSync timeout (chats.ts:1522) so its callback
// doesn't fire after Jest considers the test done — otherwise we get a
// "Cannot log after tests are done" warning from the post-teardown flush
// and the suite is flagged with --detectOpenHandles. After the timer
// fires, syncState becomes Online and the buffer flushes; that delivery
// is post-assertion and irrelevant to the test goal.
await new Promise(resolve => setTimeout(resolve, 2_100))
await sock.end(new Error('Test completed'))
await clear()
}, 10_000)
})
-88
View File
@@ -1,4 +1,3 @@
import Long from 'long'
import '../index.js'
import { proto } from '../../WAProto/index.js'
@@ -29,91 +28,4 @@ describe('proto serialization', () => {
const json = message.toJSON()
expect(json.message?.imageMessage?.fileLength).toBe('1234567890123456789')
})
it('converts Long objects to strings correctly via BigInt fast path', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(1700000000, true),
message: {
imageMessage: {
fileLength: Long.fromString('9876543210', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('1700000000')
expect(json.message?.imageMessage?.fileLength).toBe('9876543210')
})
it('handles large unsigned Long values (> MAX_SAFE_INTEGER)', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true), // max uint64
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
it('handles zero and small Long values', () => {
const message = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromNumber(0, true),
message: {
imageMessage: {
fileLength: Long.fromNumber(1, true)
}
}
})
const json = message.toJSON()
expect(json.messageTimestamp).toBe('0')
expect(json.message?.imageMessage?.fileLength).toBe('1')
})
it('roundtrips encode/decode with Long fields preserving values (> MAX_SAFE_INTEGER)', () => {
// Use uint64 values above Number.MAX_SAFE_INTEGER (2^53 - 1) so the
// roundtrip actually exercises the BigInt fast path on decode->toJSON.
// Safe integers would silently work even if longToString fell back to
// Number(value).
const original = proto.WebMessageInfo.fromObject({
key: {
remoteJid: '123@s.whatsapp.net',
id: 'ABC123',
fromMe: false
},
messageTimestamp: Long.fromString('18446744073709551615', true),
message: {
imageMessage: {
fileLength: Long.fromString('9999999999999999999', true)
}
}
})
const encoded = proto.WebMessageInfo.encode(original).finish()
const decoded = proto.WebMessageInfo.decode(encoded)
const json = decoded.toJSON()
expect(json.messageTimestamp).toBe('18446744073709551615')
expect(json.message?.imageMessage?.fileLength).toBe('9999999999999999999')
})
})
+11 -18
View File
@@ -388,13 +388,6 @@ __metadata:
languageName: node
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":
version: 2.0.1
resolution: "@cacheable/node-cache@npm:2.0.1"
@@ -3112,7 +3105,7 @@ __metadata:
libsignal: "github:whiskeysockets/libsignal-node"
link-preview-js: "npm:^4.0.0"
lru-cache: "npm:^11.2.6"
music-metadata: "npm:^11.12.3"
music-metadata: "npm:^11.12.0"
open: "npm:^11.0.0"
p-queue: "npm:^9.0.0"
pino: "npm:^10.3.1"
@@ -4563,15 +4556,15 @@ __metadata:
languageName: node
linkType: hard
"file-type@npm:^21.3.1":
version: 21.3.1
resolution: "file-type@npm:21.3.1"
"file-type@npm:^21.3.0":
version: 21.3.0
resolution: "file-type@npm:21.3.0"
dependencies:
"@tokenizer/inflate": "npm:^0.4.1"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.1"
uint8array-extras: "npm:^1.4.0"
checksum: 10c0/66a8eda781c803c6fc372464abba88cee564cfe30f029716f06909da1564bc51d0a4e8d34654b881dc751cdb0513338b3c0747e06a608cee0dd5c5499b018d47
checksum: 10c0/1b1fa909e6063044a6da1d2ea348ee4d747ed9286382d3f0d4d6532c11fb2ea9f2e7e67b2bc7d745d1bc937e05dee1aa8cb912c64250933bcb393a3744f4e284
languageName: node
linkType: hard
@@ -6511,21 +6504,21 @@ __metadata:
languageName: node
linkType: hard
"music-metadata@npm:^11.12.3":
version: 11.12.3
resolution: "music-metadata@npm:11.12.3"
"music-metadata@npm:^11.12.0":
version: 11.12.0
resolution: "music-metadata@npm:11.12.0"
dependencies:
"@borewit/text-codec": "npm:^0.2.2"
"@borewit/text-codec": "npm:^0.2.1"
"@tokenizer/token": "npm:^0.3.0"
content-type: "npm:^1.0.5"
debug: "npm:^4.4.3"
file-type: "npm:^21.3.1"
file-type: "npm:^21.3.0"
media-typer: "npm:^1.1.0"
strtok3: "npm:^10.3.4"
token-types: "npm:^6.1.2"
uint8array-extras: "npm:^1.5.0"
win-guid: "npm:^0.2.1"
checksum: 10c0/57df905f51ec792e5b4c994645eab64d47d5608b9de11151f54b26fbd5f563598ec04d8aed7f244ef1479030d78ddab8af1ee84dfcee40a93b3241941b50999d
checksum: 10c0/014a120b8941ab80452171def0ec3cba013041ba4b94f8061ad493a037d3423e6d316131a13fb0433404685ebd3c8e3ae2255c1bafaeb20673fcd73af2df0148
languageName: node
linkType: hard