From 2885ceb506f7f9d55f44f80024a14fc7010bb083 Mon Sep 17 00:00:00 2001 From: cesar carlos Date: Fri, 17 Jul 2026 18:24:54 +0000 Subject: [PATCH 1/4] fix: decrypt incoming message edits for webhook payload Newer WhatsApp clients send edits as SecretEncryptedMessage; without decrypting, Message webhooks arrived without the new text. Decrypt MESSAGE_EDIT before marshal, set IsEdit, and document the contract. Closes #92 Co-authored-by: Cursor --- docs/wiki/recursos-avancados/events-system.md | 12 ++++++++++ pkg/whatsmeow/service/whatsmeow.go | 22 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/wiki/recursos-avancados/events-system.md b/docs/wiki/recursos-avancados/events-system.md index ef653007..69115172 100644 --- a/docs/wiki/recursos-avancados/events-system.md +++ b/docs/wiki/recursos-avancados/events-system.md @@ -637,6 +637,18 @@ O Evolution GO usa dois níveis de classificação de eventos: - `Receipt` - Confirmação de leitura (`READ_RECEIPT`) - Reações, edições, deleções de mensagens +#### Edição de mensagem recebida + +Quando um contato edita uma mensagem, o evento continua sendo `Message` (subscribe `MESSAGE`). Não existe categoria separada `MESSAGE_EDIT`. + +Após descriptografia, o payload inclui: + +- `IsEdit: true` +- Texto novo em `Message.protocolMessage.editedMessage` (ex.: `conversation` ou `extendedTextMessage`) +- ID da mensagem original em `Message.protocolMessage.key.id` (ou, antes do decrypt, em `secretEncryptedMessage.targetMessageKey.id`) + +**Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão. Se a mensagem original não estiver no store (ex.: enviada antes da sessão atual), o webhook pode ainda chegar com `secretEncryptedMessage` cifrado e sem texto em claro. + ### Eventos de Grupos **Categoria**: `GROUP` diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..ef2d1cc9 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1236,12 +1236,34 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { }() } + // Newer WhatsApp clients send edits as SecretEncryptedMessage(MESSAGE_EDIT). + // Decrypt before classification/marshal so the webhook includes the new text. + if enc := evt.Message.GetSecretEncryptedMessage(); enc != nil && + enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT { + decrypted, err := mycli.WAClient.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( + "[%s] Failed to decrypt secret encrypted message edit (original secret may be missing): %v", + mycli.userID, err) + } else { + evt.Message = decrypted + evt.IsEdit = true + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo( + "[%s] Decrypted secret encrypted message edit for %s", mycli.userID, evt.Info.ID) + } + } + parsedMessageType := utils.GetMessageType(evt.Message) if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Message ignored because it's a unknown protocol message", mycli.userID) return } + // Legacy plaintext edits arrive as ProtocolMessage MESSAGE_EDIT (IsEdit stays false otherwise). + if parsedMessageType == "edit" { + evt.IsEdit = true + } + if postMap["data"] != nil { jsonBytes, err := json.Marshal(postMap["data"]) if err != nil { From 00e91caee7c19fe86ef191001be3f3131809b1f9 Mon Sep 17 00:00:00 2001 From: cesar carlos Date: Fri, 17 Jul 2026 18:31:12 +0000 Subject: [PATCH 2/4] fix: clarify revoke/delete flags on Message webhook Expose IsRevoke, messageType, and protocolMessage.typeName so consumers can detect deletes without relying on opaque Info.Edit or numeric type 0. Part of #92 Co-authored-by: Cursor --- docs/wiki/recursos-avancados/events-system.md | 13 +++++++++ pkg/whatsmeow/service/whatsmeow.go | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/docs/wiki/recursos-avancados/events-system.md b/docs/wiki/recursos-avancados/events-system.md index 69115172..c665f995 100644 --- a/docs/wiki/recursos-avancados/events-system.md +++ b/docs/wiki/recursos-avancados/events-system.md @@ -644,11 +644,24 @@ Quando um contato edita uma mensagem, o evento continua sendo `Message` (subscri Após descriptografia, o payload inclui: - `IsEdit: true` +- `messageType: "edit"` +- `Message.protocolMessage.typeName: "MESSAGE_EDIT"` (o campo numérico `type` continua presente) - Texto novo em `Message.protocolMessage.editedMessage` (ex.: `conversation` ou `extendedTextMessage`) - ID da mensagem original em `Message.protocolMessage.key.id` (ou, antes do decrypt, em `secretEncryptedMessage.targetMessageKey.id`) **Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão. Se a mensagem original não estiver no store (ex.: enviada antes da sessão atual), o webhook pode ainda chegar com `secretEncryptedMessage` cifrado e sem texto em claro. +#### Exclusão (revoke) de mensagem + +Quando um contato apaga uma mensagem “para todos”, o evento também é `Message` (subscribe `MESSAGE`). + +O payload inclui: + +- `IsRevoke: true` +- `messageType: "revoke"` +- `Message.protocolMessage.typeName: "REVOKE"` (equivalente ao `type: 0` / `Info.Edit: "7"`) +- ID da mensagem apagada em `Message.protocolMessage.key.id` + ### Eventos de Grupos **Categoria**: `GROUP` diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index ef2d1cc9..ed12f38a 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1288,6 +1288,19 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { dataMap = make(map[string]interface{}) } + // Explicit action flags for edit/revoke — protocolMessage.type alone is a + // numeric enum (0 = REVOKE, 14 = MESSAGE_EDIT) and Info.Edit is opaque. + switch parsedMessageType { + case "edit": + dataMap["IsEdit"] = true + dataMap["messageType"] = "edit" + setProtocolMessageTypeName(dataMap, "MESSAGE_EDIT") + case "revoke": + dataMap["IsRevoke"] = true + dataMap["messageType"] = "revoke" + setProtocolMessageTypeName(dataMap, "REVOKE") + } + referral := extractReferralFromMessage(evt.Message) if evt.Message.GetPollUpdateMessage() != nil { @@ -2903,6 +2916,22 @@ func (w *whatsmeowService) ConfirmPasskey(instanceId string) error { return nil } +// setProtocolMessageTypeName adds a human-readable protocolMessage.typeName +// (e.g. REVOKE, MESSAGE_EDIT) without replacing the numeric type enum. +func setProtocolMessageTypeName(dataMap map[string]interface{}, typeName string) { + message, ok := dataMap["Message"].(map[string]interface{}) + if !ok { + return + } + pm, ok := message["protocolMessage"].(map[string]interface{}) + if !ok { + return + } + pm["typeName"] = typeName + message["protocolMessage"] = pm + dataMap["Message"] = message +} + // cleanSenderID remove a parte ":numero" do sender ID para exibir apenas o remoteJid correto // Exemplo: "557499879409:3@s.whatsapp.net" -> "557499879409@s.whatsapp.net" func cleanSenderID(senderID string) string { From ffe3945c9ee1f47f0b14ab96366a167af028112d Mon Sep 17 00:00:00 2001 From: cesar carlos Date: Fri, 17 Jul 2026 18:35:16 +0000 Subject: [PATCH 3/4] fix: harden incoming edit decrypt and flag fallbacks Keep IsEdit/messageType when decrypt fails, unwrap EditedMessage after decrypt without resetting RawMessage, prefer clientPointer like polls, and document key.ID plus the failure-path contract. Co-authored-by: Cursor --- docs/wiki/recursos-avancados/events-system.md | 6 +-- pkg/whatsmeow/service/whatsmeow.go | 45 ++++++++++++++----- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/wiki/recursos-avancados/events-system.md b/docs/wiki/recursos-avancados/events-system.md index c665f995..e2989ff5 100644 --- a/docs/wiki/recursos-avancados/events-system.md +++ b/docs/wiki/recursos-avancados/events-system.md @@ -647,9 +647,9 @@ Após descriptografia, o payload inclui: - `messageType: "edit"` - `Message.protocolMessage.typeName: "MESSAGE_EDIT"` (o campo numérico `type` continua presente) - Texto novo em `Message.protocolMessage.editedMessage` (ex.: `conversation` ou `extendedTextMessage`) -- ID da mensagem original em `Message.protocolMessage.key.id` (ou, antes do decrypt, em `secretEncryptedMessage.targetMessageKey.id`) +- ID da mensagem original em `Message.protocolMessage.key.ID` (ou, antes do decrypt, em `secretEncryptedMessage.targetMessageKey.ID`) -**Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão. Se a mensagem original não estiver no store (ex.: enviada antes da sessão atual), o webhook pode ainda chegar com `secretEncryptedMessage` cifrado e sem texto em claro. +**Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão. Se a mensagem original não estiver no store (ex.: enviada antes da sessão atual), o webhook ainda chega com `secretEncryptedMessage` cifrado (sem texto em claro), mas mantém `IsEdit: true` e `messageType: "edit"` para identificar a ação. #### Exclusão (revoke) de mensagem @@ -660,7 +660,7 @@ O payload inclui: - `IsRevoke: true` - `messageType: "revoke"` - `Message.protocolMessage.typeName: "REVOKE"` (equivalente ao `type: 0` / `Info.Edit: "7"`) -- ID da mensagem apagada em `Message.protocolMessage.key.id` +- ID da mensagem apagada em `Message.protocolMessage.key.ID` ### Eventos de Grupos diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index ed12f38a..dd99978f 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1238,29 +1238,47 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { // Newer WhatsApp clients send edits as SecretEncryptedMessage(MESSAGE_EDIT). // Decrypt before classification/marshal so the webhook includes the new text. + secretEditEnvelope := false if enc := evt.Message.GetSecretEncryptedMessage(); enc != nil && enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT { - decrypted, err := mycli.WAClient.DecryptSecretEncryptedMessage(context.Background(), evt) - if err != nil { + secretEditEnvelope = true + evt.IsEdit = true + + client := mycli.clientPointer[mycli.userID] + if client == nil { + client = mycli.WAClient + } + if client == nil { mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( - "[%s] Failed to decrypt secret encrypted message edit (original secret may be missing): %v", - mycli.userID, err) + "[%s] No client available to decrypt secret encrypted message edit", mycli.userID) } else { - evt.Message = decrypted - evt.IsEdit = true - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo( - "[%s] Decrypted secret encrypted message edit for %s", mycli.userID, evt.Info.ID) + decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( + "[%s] Failed to decrypt secret encrypted message edit (original secret may be missing): %v", + mycli.userID, err) + } else { + evt.Message = decrypted + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo( + "[%s] Decrypted secret encrypted message edit for %s", mycli.userID, evt.Info.ID) + } } } + // EditedMessage wrapper (legacy wire format or post-decrypt). Do not call + // UnwrapRaw here — it resets Message from RawMessage and would undo decrypt. + if evt.Message != nil && evt.Message.GetEditedMessage().GetMessage() != nil { + evt.Message = evt.Message.GetEditedMessage().GetMessage() + evt.IsEdit = true + } + parsedMessageType := utils.GetMessageType(evt.Message) if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Message ignored because it's a unknown protocol message", mycli.userID) return } - // Legacy plaintext edits arrive as ProtocolMessage MESSAGE_EDIT (IsEdit stays false otherwise). - if parsedMessageType == "edit" { + if parsedMessageType == "edit" || secretEditEnvelope { evt.IsEdit = true } @@ -1299,6 +1317,13 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { dataMap["IsRevoke"] = true dataMap["messageType"] = "revoke" setProtocolMessageTypeName(dataMap, "REVOKE") + default: + // Decrypt failed or plaintext not yet classified as "edit", but envelope + // already identified the event as an incoming message edit. + if secretEditEnvelope { + dataMap["IsEdit"] = true + dataMap["messageType"] = "edit" + } } referral := extractReferralFromMessage(evt.Message) From ed0511056345d521a7c710ba7efcef019259e8bc Mon Sep 17 00:00:00 2001 From: cesar carlos Date: Fri, 17 Jul 2026 19:33:06 +0000 Subject: [PATCH 4/4] fix: decrypt inbound edits before LID/PN JID swap DecryptSecretEncryptedMessage derives keys from wire Sender/Chat; swapping LID to PN first caused MAC failures despite a stored message secret. Also expose decryptFailed when plaintext cannot be recovered. Co-authored-by: Cursor --- docs/wiki/recursos-avancados/events-system.md | 2 +- pkg/whatsmeow/service/whatsmeow.go | 79 ++++++++++--------- 2 files changed, 44 insertions(+), 37 deletions(-) diff --git a/docs/wiki/recursos-avancados/events-system.md b/docs/wiki/recursos-avancados/events-system.md index e2989ff5..33623914 100644 --- a/docs/wiki/recursos-avancados/events-system.md +++ b/docs/wiki/recursos-avancados/events-system.md @@ -649,7 +649,7 @@ Após descriptografia, o payload inclui: - Texto novo em `Message.protocolMessage.editedMessage` (ex.: `conversation` ou `extendedTextMessage`) - ID da mensagem original em `Message.protocolMessage.key.ID` (ou, antes do decrypt, em `secretEncryptedMessage.targetMessageKey.ID`) -**Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão. Se a mensagem original não estiver no store (ex.: enviada antes da sessão atual), o webhook ainda chega com `secretEncryptedMessage` cifrado (sem texto em claro), mas mantém `IsEdit: true` e `messageType: "edit"` para identificar a ação. +**Limitação**: o decrypt usa o `messageSecret` da mensagem original armazenado na sessão e deve rodar **antes** de qualquer normalização LID→PN do `Info.Sender`/`Chat`. Se o decrypt falhar, o webhook ainda chega com `IsEdit: true`, `messageType: "edit"` e `decryptFailed: true` (pode manter `secretEncryptedMessage` sem texto em claro). #### Exclusão (revoke) de mensagem diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index dd99978f..b0a29f9e 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1182,6 +1182,46 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { return } + // Decrypt MESSAGE_EDIT BEFORE LID/PN JID swap. whatsmeow derives the edit + // key from Info.Sender/Chat as received on the wire; swapping first causes + // cipher: message authentication failed even when the secret exists. + secretEditEnvelope := false + decryptFailed := false + if enc := evt.Message.GetSecretEncryptedMessage(); enc != nil && + enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT { + secretEditEnvelope = true + evt.IsEdit = true + + client := mycli.clientPointer[mycli.userID] + if client == nil { + client = mycli.WAClient + } + if client == nil { + decryptFailed = true + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( + "[%s] No client available to decrypt secret encrypted message edit", mycli.userID) + } else { + decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + decryptFailed = true + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( + "[%s] Failed to decrypt secret encrypted message edit: %v", + mycli.userID, err) + } else { + evt.Message = decrypted + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo( + "[%s] Decrypted secret encrypted message edit for %s", mycli.userID, evt.Info.ID) + } + } + } + + // EditedMessage wrapper (legacy wire format or post-decrypt). Do not call + // UnwrapRaw here — it resets Message from RawMessage and would undo decrypt. + if evt.Message != nil && evt.Message.GetEditedMessage().GetMessage() != nil { + evt.Message = evt.Message.GetEditedMessage().GetMessage() + evt.IsEdit = true + } + // Trata o caso especial onde Sender é @lid e SenderAlt é @s.whatsapp.net // Neste caso, devemos inverter: Sender e Chat devem ser @s.whatsapp.net, SenderAlt deve ser @lid senderStr := evt.Info.Sender.String() @@ -1236,42 +1276,6 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { }() } - // Newer WhatsApp clients send edits as SecretEncryptedMessage(MESSAGE_EDIT). - // Decrypt before classification/marshal so the webhook includes the new text. - secretEditEnvelope := false - if enc := evt.Message.GetSecretEncryptedMessage(); enc != nil && - enc.GetSecretEncType() == waE2E.SecretEncryptedMessage_MESSAGE_EDIT { - secretEditEnvelope = true - evt.IsEdit = true - - client := mycli.clientPointer[mycli.userID] - if client == nil { - client = mycli.WAClient - } - if client == nil { - mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( - "[%s] No client available to decrypt secret encrypted message edit", mycli.userID) - } else { - decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) - if err != nil { - mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn( - "[%s] Failed to decrypt secret encrypted message edit (original secret may be missing): %v", - mycli.userID, err) - } else { - evt.Message = decrypted - mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo( - "[%s] Decrypted secret encrypted message edit for %s", mycli.userID, evt.Info.ID) - } - } - } - - // EditedMessage wrapper (legacy wire format or post-decrypt). Do not call - // UnwrapRaw here — it resets Message from RawMessage and would undo decrypt. - if evt.Message != nil && evt.Message.GetEditedMessage().GetMessage() != nil { - evt.Message = evt.Message.GetEditedMessage().GetMessage() - evt.IsEdit = true - } - parsedMessageType := utils.GetMessageType(evt.Message) if parsedMessageType == "ignore" || strings.HasPrefix(parsedMessageType, "unknown_protocol_") { mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Message ignored because it's a unknown protocol message", mycli.userID) @@ -1325,6 +1329,9 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { dataMap["messageType"] = "edit" } } + if decryptFailed { + dataMap["decryptFailed"] = true + } referral := extractReferralFromMessage(evt.Message)