From 23a5d1192172939d729bb231a83dc7f342e8b6cc Mon Sep 17 00:00:00 2001 From: Fernando Paes Date: Mon, 15 Jun 2026 00:25:48 +0000 Subject: [PATCH] fix(message): canonicalize JID for reactions, presence and read receipts Reactions (/message/react), typing/recording indicators (/message/presence) and read receipts (/message/markread) returned HTTP 200 but failed silently: the indicator never appeared and reactions did not attach on the recipient device. Root cause: CreateJID/ParseJID prefix phone numbers with "+" (e.g. "+5541999999999@s.whatsapp.net"). Normal message sending tolerates this because whatsmeow normalizes the JID during device resolution / usync, but these three features are delivered as raw protocol nodes that bypass that normalization, so the malformed "+"-prefixed JID survives at the wire level and WhatsApp cannot route the node. This adds a utils.CanonicalJID helper that strips the leading "+" from the user part, producing a digits-only WhatsApp JID, and applies it in React (recipient and group participant), ChatPresence (recipient) and MarkRead (recipient) before the raw node is sent. ChatPresence additionally sends an "available" presence first (WhatsApp only forwards chat presence while the sender is marked online) and gains an optional "delay" parameter (ms, capped at 60s) that sustains the typing/recording indicator by re-sending it every 5 seconds. A TestCanonicalJID unit test is added to utils_test.go. This fix is inspired by upstream PR #73 by @joaoporth, reimplemented cleanly on top of v0.7.0. Co-Authored-By: Claude Sonnet 4.6 --- pkg/message/service/message_service.go | 51 ++++++++++++++++++++++++-- pkg/utils/utils.go | 15 ++++++++ pkg/utils/utils_test.go | 46 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index b2b21c72..324ea61a 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -52,6 +52,10 @@ type ChatPresenceStruct struct { Number string `json:"number"` State string `json:"state"` IsAudio bool `json:"isAudio"` + // Delay, in milliseconds, keeps the typing/recording indicator alive by + // re-sending the chat presence every few seconds. Capped at 60s. 0 sends + // the indicator once. + Delay int `json:"delay,omitempty"` } type MarkReadStruct struct { @@ -136,6 +140,9 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error validating message fields", instance.Id) return nil, errors.New("invalid phone number") } + // Reactions travel as raw protocol nodes, so the target JID must be + // canonical (digits only, no leading "+") or WhatsApp drops them silently. + recipient = utils.CanonicalJID(recipient) if data.Id == "" { m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Missing Id in Payload", instance.Id) @@ -161,7 +168,7 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta if data.Participant != "" { participantJID, ok := utils.ParseJID(data.Participant) if ok { - messageKey.Participant = proto.String(participantJID.String()) + messageKey.Participant = proto.String(utils.CanonicalJID(participantJID).String()) } } @@ -218,6 +225,9 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error validating message fields", instance.Id) return "", errors.New("invalid phone number") } + // Chat presence is delivered as a raw protocol node, so the target JID must + // be canonical (digits only, no leading "+") or WhatsApp drops it silently. + recipient = utils.CanonicalJID(recipient) media := "" @@ -225,11 +235,43 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan media = "audio" } - err = client.SendChatPresence(context.Background(), recipient, types.ChatPresence(data.State), types.ChatPresenceMedia(media)) - if err != nil { + // WhatsApp only forwards chat presence (typing/recording) while the sender + // is marked online, so announce availability before sending the indicator. + if err = client.SendPresence(context.Background(), types.PresenceAvailable); err != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] failed to send available presence: %v", instance.Id, err) + } + + presence := types.ChatPresence(data.State) + presenceMedia := types.ChatPresenceMedia(media) + + if err = client.SendChatPresence(context.Background(), recipient, presence, presenceMedia); err != nil { return "", err } + // Optionally sustain the indicator by re-sending it periodically, since a + // single chat presence node expires after a few seconds on the client. + if data.Delay > 0 { + const refreshInterval = 5 * time.Second + const maxDelay = 60 * time.Second + + remaining := time.Duration(data.Delay) * time.Millisecond + if remaining > maxDelay { + remaining = maxDelay + } + + for remaining > refreshInterval { + time.Sleep(refreshInterval) + remaining -= refreshInterval + if err = client.SendChatPresence(context.Background(), recipient, presence, presenceMedia); err != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] failed to refresh chat presence: %v", instance.Id, err) + break + } + } + if remaining > 0 { + time.Sleep(remaining) + } + } + m.loggerWrapper.GetLogger(instance.Id).LogInfo("Message sent to %s", data.Number) return ts.String(), nil @@ -248,6 +290,9 @@ func (m *messageService) MarkRead(data *MarkReadStruct, instance *instance_model m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Error validating message fields", instance.Id) return "", errors.New("invalid phone number") } + // Read receipts are sent as raw protocol nodes, so the target JID must be + // canonical (digits only, no leading "+") or WhatsApp drops them silently. + jid = utils.CanonicalJID(jid) err = client.MarkRead(context.Background(), data.Id, time.Now(), jid, jid) if err != nil { diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e7593477..83fa01b3 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -215,6 +215,21 @@ func ParseJID(arg string) (whatsmeow_types.JID, bool) { return recipient, true } +// CanonicalJID returns a copy of the given JID with any leading "+" removed +// from the user part, producing a digits-only WhatsApp JID. +// +// CreateJID/ParseJID intentionally keep the leading "+" (e.g. +// "+5541999999999@s.whatsapp.net") because some flows such as IsOnWhatsApp +// rely on it. However, raw protocol nodes (chat presence, reactions and read +// receipts) are sent without the device resolution / usync normalization that +// happens during normal message sending, so the "+"-prefixed user survives at +// the wire level and WhatsApp silently fails to route the node. Stripping the +// "+" yields the canonical target those raw nodes expect. +func CanonicalJID(jid whatsmeow_types.JID) whatsmeow_types.JID { + jid.User = strings.TrimPrefix(jid.User, "+") + return jid +} + func CreateHTTPProxy(httpHost, httpPort, user, password string) (func(*http.Request) (*url.URL, error), error) { address := fmt.Sprintf("http://%s:%s@%s:%s", user, password, httpHost, httpPort) diff --git a/pkg/utils/utils_test.go b/pkg/utils/utils_test.go index 25a99792..aa5d2f8b 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -2,6 +2,8 @@ package utils import ( "testing" + + whatsmeow_types "go.mau.fi/whatsmeow/types" ) func TestCreateJID(t *testing.T) { @@ -171,6 +173,50 @@ func TestCreateJID(t *testing.T) { } } +func TestCanonicalJID(t *testing.T) { + tests := []struct { + name string + input string + server string + expected string + }{ + { + name: "Strips leading + from user part", + input: "+15551234567", + server: whatsmeow_types.DefaultUserServer, + expected: "15551234567@s.whatsapp.net", + }, + { + name: "Leaves already-canonical user untouched", + input: "15551234567", + server: whatsmeow_types.DefaultUserServer, + expected: "15551234567@s.whatsapp.net", + }, + { + name: "Only strips a single leading +", + input: "+5541999999999", + server: whatsmeow_types.DefaultUserServer, + expected: "5541999999999@s.whatsapp.net", + }, + { + name: "Group JID user is unaffected", + input: "120363123456789012", + server: whatsmeow_types.GroupServer, + expected: "120363123456789012@g.us", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jid := whatsmeow_types.NewJID(tt.input, tt.server) + result := CanonicalJID(jid) + if result.String() != tt.expected { + t.Errorf("For input %q, expected %q, but got %q", tt.input, tt.expected, result.String()) + } + }) + } +} + func TestFormatMXOrARNumber(t *testing.T) { tests := []struct { name string