Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions pkg/whatsmeow/service/secret_edit.go
Original file line number Diff line number Diff line change
@@ -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: <target>, editedMessage: <new content>} — 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())
}
113 changes: 113 additions & 0 deletions pkg/whatsmeow/service/secret_edit_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
4 changes: 4 additions & 0 deletions pkg/whatsmeow/service/whatsmeow.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down