diff --git a/pkg/whatsmeow/service/secret_edit.go b/pkg/whatsmeow/service/secret_edit.go new file mode 100644 index 00000000..a5259fc4 --- /dev/null +++ b/pkg/whatsmeow/service/secret_edit.go @@ -0,0 +1,89 @@ +package whatsmeow_service + +import ( + "context" + + "go.mau.fi/whatsmeow/proto/waCommon" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +// secretEncryptedEdit returns the envelope carrying a sealed message edit, or nil when the +// message is not one. +// +// WhatsApp no longer sends message edits as a plaintext protocolMessage: the new content is +// encrypted with a key derived from the target message secret. Without decrypting it, the +// envelope reaches consumers as an opaque blob with no text at all. +func secretEncryptedEdit(message *waE2E.Message) *waE2E.SecretEncryptedMessage { + if message == nil { + return nil + } + + envelope := message.GetSecretEncryptedMessage() + if envelope == nil || envelope.GetSecretEncType() != waE2E.SecretEncryptedMessage_MESSAGE_EDIT { + return nil + } + + return envelope +} + +// buildEditProtocolMessage rebuilds the plaintext shape consumers already handle — +// protocolMessage{type: MESSAGE_EDIT, key: , editedMessage: } — so the +// decrypted edit needs no new contract downstream. Returns nil when there is nothing to +// rebuild, letting callers keep the original envelope. +func buildEditProtocolMessage(target *waCommon.MessageKey, decrypted *waE2E.Message, timestampMS int64) *waE2E.Message { + if target == nil || decrypted == nil { + return nil + } + + protocolMessage := &waE2E.ProtocolMessage{ + Key: target, + Type: waE2E.ProtocolMessage_MESSAGE_EDIT.Enum(), + EditedMessage: decrypted, + } + + if timestampMS > 0 { + protocolMessage.TimestampMS = proto.Int64(timestampMS) + } + + return &waE2E.Message{ProtocolMessage: protocolMessage} +} + +// unwrapSecretEncryptedEdit decrypts a MESSAGE_EDIT envelope in place, so message typing, the +// webhook payload and persistence all see the edited text instead of the sealed envelope. +// +// Every failure path leaves the event untouched on purpose: forwarding the original envelope +// is what happens today, while dropping the event would lose the edit signal entirely. +func (mycli *MyClient) unwrapSecretEncryptedEdit(evt *events.Message) { + if evt == nil { + return + } + + envelope := secretEncryptedEdit(evt.Message) + if envelope == nil { + return + } + + client := mycli.clientPointer[mycli.userID] + if client == nil { + return + } + + targetKey := envelope.GetTargetMessageKey() + + decrypted, err := client.DecryptSecretEncryptedMessage(context.Background(), evt) + if err != nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogError("[%s] Failed to decrypt edited message %s: %v", mycli.userID, evt.Info.ID, err) + return + } + + rebuilt := buildEditProtocolMessage(targetKey, decrypted, evt.Info.Timestamp.UnixMilli()) + if rebuilt == nil { + mycli.loggerWrapper.GetLogger(mycli.userID).LogWarn("[%s] Decrypted edit %s has no target message key, forwarding envelope as-is", mycli.userID, evt.Info.ID) + return + } + + evt.Message = rebuilt + mycli.loggerWrapper.GetLogger(mycli.userID).LogInfo("[%s] Decrypted edited message %s targeting %s", mycli.userID, evt.Info.ID, targetKey.GetID()) +} diff --git a/pkg/whatsmeow/service/secret_edit_test.go b/pkg/whatsmeow/service/secret_edit_test.go new file mode 100644 index 00000000..faa314b8 --- /dev/null +++ b/pkg/whatsmeow/service/secret_edit_test.go @@ -0,0 +1,113 @@ +package whatsmeow_service + +import ( + "testing" + + "github.com/evolution-foundation/evolution-go/pkg/utils" + "go.mau.fi/whatsmeow/proto/waCommon" + "go.mau.fi/whatsmeow/proto/waE2E" +) + +func secretEditEnvelope(encType waE2E.SecretEncryptedMessage_SecretEncType) *waE2E.Message { + return &waE2E.Message{ + SecretEncryptedMessage: &waE2E.SecretEncryptedMessage{ + TargetMessageKey: &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, + EncPayload: []byte("ciphertext"), + EncIV: []byte("iv"), + SecretEncType: encType.Enum(), + }, + } +} + +func TestSecretEncryptedEdit(t *testing.T) { + envelope := secretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT)) + if envelope == nil { + t.Fatal("expected MESSAGE_EDIT envelope to be detected as an edit") + } + + if envelope.GetTargetMessageKey().GetID() != "ORIGINAL_ID" { + t.Fatalf("expected the target message key to be reachable, got %q", envelope.GetTargetMessageKey().GetID()) + } + + if secretEncryptedEdit(secretEditEnvelope(waE2E.SecretEncryptedMessage_POLL_EDIT)) != nil { + t.Fatal("expected POLL_EDIT envelope not to be treated as a message edit") + } + + if secretEncryptedEdit(&waE2E.Message{Conversation: stringPtr("plain text")}) != nil { + t.Fatal("expected a plain message not to be treated as an edit") + } + + if secretEncryptedEdit(nil) != nil { + t.Fatal("expected a nil message not to be treated as an edit") + } +} + +func TestBuildEditProtocolMessageRestoresPlaintextShape(t *testing.T) { + target := &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")} + decrypted := &waE2E.Message{Conversation: stringPtr("corrected text")} + + rebuilt := buildEditProtocolMessage(target, decrypted, 1754216820000) + if rebuilt == nil { + t.Fatal("expected a rebuilt message, got nil") + } + + protocolMessage := rebuilt.GetProtocolMessage() + if protocolMessage.GetType() != waE2E.ProtocolMessage_MESSAGE_EDIT { + t.Fatalf("expected type MESSAGE_EDIT, got %v", protocolMessage.GetType()) + } + + if protocolMessage.GetKey().GetID() != "ORIGINAL_ID" { + t.Fatalf("expected the target message key to be preserved, got %q", protocolMessage.GetKey().GetID()) + } + + if protocolMessage.GetEditedMessage().GetConversation() != "corrected text" { + t.Fatalf("expected the decrypted text, got %q", protocolMessage.GetEditedMessage().GetConversation()) + } + + if protocolMessage.GetTimestampMS() != 1754216820000 { + t.Fatalf("expected the edit timestamp to be carried over, got %d", protocolMessage.GetTimestampMS()) + } +} + +func TestBuildEditProtocolMessageOmitsUnknownTimestamp(t *testing.T) { + rebuilt := buildEditProtocolMessage( + &waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, + &waE2E.Message{Conversation: stringPtr("corrected text")}, + 0, + ) + + if rebuilt.GetProtocolMessage().TimestampMS != nil { + t.Fatal("expected no timestamp to be set when the event carries none") + } +} + +func TestBuildEditProtocolMessageRequiresTargetAndContent(t *testing.T) { + decrypted := &waE2E.Message{Conversation: stringPtr("corrected text")} + + if buildEditProtocolMessage(nil, decrypted, 0) != nil { + t.Fatal("expected nil without a target message key") + } + + if buildEditProtocolMessage(&waCommon.MessageKey{ID: stringPtr("ORIGINAL_ID")}, nil, 0) != nil { + t.Fatal("expected nil without decrypted content") + } +} + +// The envelope is typed as "secret encrypted", which carries no text. Rebuilding it as a +// protocolMessage is what makes the pipeline treat it as an edit. +func TestRebuiltEditIsTypedAsEdit(t *testing.T) { + sealed := secretEditEnvelope(waE2E.SecretEncryptedMessage_MESSAGE_EDIT) + if got := utils.GetMessageType(sealed); got == "edit" { + t.Fatalf("expected the sealed envelope not to be typed as an edit, got %q", got) + } + + rebuilt := buildEditProtocolMessage( + secretEncryptedEdit(sealed).GetTargetMessageKey(), + &waE2E.Message{Conversation: stringPtr("corrected text")}, + 0, + ) + + if got := utils.GetMessageType(rebuilt); got != "edit" { + t.Fatalf("expected the rebuilt message to be typed as %q, got %q", "edit", got) + } +} diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go index 366f0edb..89a3568f 100644 --- a/pkg/whatsmeow/service/whatsmeow.go +++ b/pkg/whatsmeow/service/whatsmeow.go @@ -1236,6 +1236,10 @@ func (mycli *MyClient) myEventHandler(rawEvt interface{}) { }() } + // Edits arrive sealed in a secretEncryptedMessage envelope. Unwrap before typing the + // message, so it is classified as "edit" and the webhook carries the new text. + mycli.unwrapSecretEncryptedEdit(evt) + 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)