From 1b0612025b5b2d3db3f89170810207d7d3a6d0a8 Mon Sep 17 00:00:00 2001 From: joaoporth <124004601+joaoporth@users.noreply.github.com> Date: Thu, 4 Jun 2026 15:18:25 -0300 Subject: [PATCH 1/4] fix(message): fix React silent drop and add SubscribePresence before SendChatPresence - React: remove `ID: msgId` from SendRequestExtra so whatsmeow generates a fresh unique message ID for the reaction envelope. Passing the original message ID as the send ID caused WhatsApp to silently deduplicate and drop the reaction. The msgId still lives correctly inside MessageKey (the reference to the message being reacted to). - ChatPresence: call client.SubscribePresence before SendChatPresence. WhatsApp requires an active presence subscription on the recipient JID before it forwards chatstate (typing/recording) events. Without it the call succeeds locally but the indicator never appears on the recipient side. SubscribePresence errors are logged as warnings and are non-fatal. Fixes #63 (typing indicator) and the silent reaction drop issue. --- pkg/message/service/message_service.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index b2b21c72..9711a7a3 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -150,7 +150,8 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta reaction = "" } - // Create MessageKey + // Create MessageKey — msgId here is the ID of the message being reacted to, + // NOT the ID of the reaction message itself. messageKey := &waCommon.MessageKey{ RemoteJID: proto.String(recipient.String()), FromMe: proto.Bool(fromMe), @@ -167,16 +168,16 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta msg := &waE2E.Message{ ReactionMessage: &waE2E.ReactionMessage{ - Key: messageKey, - Text: proto.String(reaction), - // GroupingKey: proto.String(reaction), + Key: messageKey, + Text: proto.String(reaction), SenderTimestampMS: proto.Int64(time.Now().UnixMilli()), }, } - response, err := client.SendMessage(context.Background(), recipient, msg, whatsmeow.SendRequestExtra{ - ID: msgId, - }) + // Do NOT pass ID: msgId in SendRequestExtra — that would reuse the original + // message ID as the reaction envelope ID, causing WhatsApp to silently + // deduplicate and drop the reaction. Let whatsmeow generate a fresh ID. + response, err := client.SendMessage(context.Background(), recipient, msg) if err != nil { return nil, err } @@ -191,7 +192,7 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta IsFromMe: true, IsGroup: isGroup, }, - ID: msgId, + ID: response.ID, Timestamp: time.Now(), ServerID: response.ServerID, Type: messageType, @@ -225,12 +226,19 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan media = "audio" } + // Subscribe to the recipient's presence first. + // WhatsApp requires an active presence subscription before chat-state events + // (typing / recording indicators) are forwarded to the recipient by the servers. + if subErr := client.SubscribePresence(context.Background(), recipient); subErr != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SubscribePresence for %s failed (non-fatal): %v", instance.Id, data.Number, subErr) + } + err = client.SendChatPresence(context.Background(), recipient, types.ChatPresence(data.State), types.ChatPresenceMedia(media)) if err != nil { return "", err } - m.loggerWrapper.GetLogger(instance.Id).LogInfo("Message sent to %s", data.Number) + m.loggerWrapper.GetLogger(instance.Id).LogInfo("Presence sent to %s", data.Number) return ts.String(), nil } From a60cdca800c5c9e4d1d40c531ceb46416581c567 Mon Sep 17 00:00:00 2001 From: joaoporth <124004601+joaoporth@users.noreply.github.com> Date: Thu, 4 Jun 2026 16:12:11 -0300 Subject: [PATCH 2/4] fix(message): fix React silent drop by removing wrong ID from SendRequestExtra Passing ID: msgId in SendRequestExtra caused WhatsApp to use the original message ID as the reaction envelope ID. Since that ID is already known to the server, it silently deduplicates and drops the reaction. The msgId belongs only inside MessageKey (reference to the message being reacted to). Let whatsmeow generate a fresh ID for the reaction envelope. Also update messageInfo.ID to use response.ID (the real generated ID) instead of the original msgId. ChatPresence is reverted to its original correct form. The SubscribePresence call added previously was wrong: it is for receiving presence updates from a contact, not for sending chatstate (typing) events. SendChatPresence sends a chatstate node directly and does not require SubscribePresence. --- pkg/message/service/message_service.go | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index 9711a7a3..9632d5ff 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -150,8 +150,8 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta reaction = "" } - // Create MessageKey — msgId here is the ID of the message being reacted to, - // NOT the ID of the reaction message itself. + // Create MessageKey — msgId is the ID of the message being reacted to, + // NOT the ID of the reaction envelope itself. messageKey := &waCommon.MessageKey{ RemoteJID: proto.String(recipient.String()), FromMe: proto.Bool(fromMe), @@ -174,9 +174,10 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta }, } - // Do NOT pass ID: msgId in SendRequestExtra — that would reuse the original - // message ID as the reaction envelope ID, causing WhatsApp to silently - // deduplicate and drop the reaction. Let whatsmeow generate a fresh ID. + // Do NOT pass ID: msgId in SendRequestExtra. Doing so would reuse the + // original message ID as the reaction envelope ID; WhatsApp silently + // deduplicates it and drops the reaction. Let whatsmeow generate a + // fresh, unique ID for the envelope. response, err := client.SendMessage(context.Background(), recipient, msg) if err != nil { return nil, err @@ -226,19 +227,12 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan media = "audio" } - // Subscribe to the recipient's presence first. - // WhatsApp requires an active presence subscription before chat-state events - // (typing / recording indicators) are forwarded to the recipient by the servers. - if subErr := client.SubscribePresence(context.Background(), recipient); subErr != nil { - m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SubscribePresence for %s failed (non-fatal): %v", instance.Id, data.Number, subErr) - } - err = client.SendChatPresence(context.Background(), recipient, types.ChatPresence(data.State), types.ChatPresenceMedia(media)) if err != nil { return "", err } - m.loggerWrapper.GetLogger(instance.Id).LogInfo("Presence sent to %s", data.Number) + m.loggerWrapper.GetLogger(instance.Id).LogInfo("Message sent to %s", data.Number) return ts.String(), nil } From 73e4c41d160d2d6af0708c98398831c576ddbe7a Mon Sep 17 00:00:00 2001 From: joao porth Date: Thu, 4 Jun 2026 22:04:44 -0300 Subject: [PATCH 3/4] fix(presence): deliver typing/recording indicators via canonical JID The /message/presence chatstate (typing & recording-audio) was silently dropped: ParseJID/CreateJID prefixes phone numbers with '+' (e.g. +5541...@s.whatsapp.net). Message sending tolerates this because whatsmeow normalizes the JID during usync/device resolution, but RAW nodes (chatstate, read receipts) are sent without usync, so the malformed '+JID' reaches the server and the node is never routed to the recipient. - Add utils.CanonicalJID to strip the leading '+' for RAW-node targets, with unit test (TestCanonicalJID). - Apply it in ChatPresence (typing/recording) and MarkRead (read receipts). - Send SendPresence(available) before the chatstate so the server forwards the indicator (it only relays chatstate while the sender is online). - Add optional 'delay' (ms) to /message/presence: keep the indicator alive for the duration (re-sending every 5s, capped at 60s) then send 'paused', instead of a single ephemeral fire. Tested end-to-end: 'digitando...' and 'gravando audio...' both confirmed appearing on the recipient device. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/message/service/message_service.go | 64 +++++++++++++++++++++++++- pkg/utils/utils.go | 15 ++++++ pkg/utils/utils_test.go | 59 ++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index 9632d5ff..6df4945b 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 "composing"/"recording" indicator alive + // for the given duration (re-sending it periodically) and then sends "paused". + // Only applies when State is "composing". 0 = single fire (legacy behaviour). + Delay int `json:"delay"` } type MarkReadStruct struct { @@ -221,18 +225,70 @@ func (m *messageService) ChatPresence(data *ChatPresenceStruct, instance *instan return "", errors.New("invalid phone number") } + // chatstate (typing) is a RAW node sent without usync normalization, so it + // needs a canonical digits-only JID or WhatsApp silently drops it. See + // utils.CanonicalJID for the full rationale. + recipient = utils.CanonicalJID(recipient) + media := "" if data.IsAudio { media = "audio" } - err = client.SendChatPresence(context.Background(), recipient, types.ChatPresence(data.State), types.ChatPresenceMedia(media)) + // WhatsApp only forwards chatstate (typing / recording) events to the + // recipient while the sender is marked online. SendChatPresence merely + // sends the chatstate node — it does NOT mark us available. Background + // presence handling (events.AppStateSyncComplete) may have set us to + // Unavailable, in which case the server silently drops the typing + // indicator. Mark ourselves available first to guarantee delivery. + if presErr := client.SendPresence(context.Background(), types.PresenceAvailable); presErr != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendPresence(available) before chatstate failed (non-fatal): %v", instance.Id, presErr) + } + + state := types.ChatPresence(data.State) + mediaType := types.ChatPresenceMedia(media) + + err = client.SendChatPresence(context.Background(), recipient, state, mediaType) if err != nil { return "", err } - m.loggerWrapper.GetLogger(instance.Id).LogInfo("Message sent to %s", data.Number) + // A single "composing" indicator is ephemeral: WhatsApp expires it after a + // few seconds unless refreshed. When a Delay is provided (and we're typing), + // keep the indicator alive for the requested duration by re-sending it, then + // send "paused" so the indicator clears cleanly instead of timing out. + if data.Delay > 0 && state == types.ChatPresenceComposing { + const keepAliveInterval = 5 * time.Second + const maxDelay = 60 * time.Second + + remaining := time.Duration(data.Delay) * time.Millisecond + if remaining > maxDelay { + remaining = maxDelay + } + + for remaining > 0 { + sleep := keepAliveInterval + if remaining < sleep { + sleep = remaining + } + time.Sleep(sleep) + remaining -= sleep + + if remaining > 0 { + // Refresh the indicator so it doesn't expire mid-delay. + if refreshErr := client.SendChatPresence(context.Background(), recipient, state, mediaType); refreshErr != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] Refresh chatstate failed (non-fatal): %v", instance.Id, refreshErr) + } + } + } + + if pausedErr := client.SendChatPresence(context.Background(), recipient, types.ChatPresencePaused, mediaType); pausedErr != nil { + m.loggerWrapper.GetLogger(instance.Id).LogWarn("[%s] SendChatPresence(paused) failed (non-fatal): %v", instance.Id, pausedErr) + } + } + + m.loggerWrapper.GetLogger(instance.Id).LogInfo("Presence (%s) sent to %s", data.State, data.Number) return ts.String(), nil } @@ -251,6 +307,10 @@ func (m *messageService) MarkRead(data *MarkReadStruct, instance *instance_model return "", errors.New("invalid phone number") } + // Read receipts are RAW nodes (no usync) — strip the "+" so the receipt + // reaches the recipient. Same root cause as the typing fix above. + jid = utils.CanonicalJID(jid) + err = client.MarkRead(context.Background(), data.Id, time.Now(), jid, jid) if err != nil { m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] error marking message as read: %v", instance.Id, err) diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index e7593477..7ba65bdc 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 JID safe for RAW protocol nodes (chatstate / typing, +// read receipts, presence subscribe, etc.). +// +// CreateJID intentionally prefixes phone numbers with "+" (e.g. +// "+554187083284@s.whatsapp.net") to match the IsOnWhatsApp/display convention. +// Message sending tolerates this because whatsmeow normalizes the JID during +// usync/device resolution. RAW nodes are sent WITHOUT usync, so a malformed +// "+JID" reaches the server and the node is silently dropped (e.g. the "typing" +// indicator never reaches the recipient). WhatsApp user JIDs are digits-only, so +// strip the leading "+" to get the canonical form. +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..5ff8795f 100644 --- a/pkg/utils/utils_test.go +++ b/pkg/utils/utils_test.go @@ -1,6 +1,7 @@ package utils import ( + "strings" "testing" ) @@ -325,3 +326,61 @@ func TestParseJID(t *testing.T) { }) } } + +// TestCanonicalJID locks in the fix for the silent typing-indicator / receipt +// drop: ParseJID (via CreateJID) prefixes phone numbers with "+", which is +// tolerated by message sending (usync normalizes it) but breaks RAW nodes +// (chatstate, read receipts). CanonicalJID must strip that "+" while leaving +// already-canonical JIDs (LID, group, digits-only) untouched. +func TestCanonicalJID(t *testing.T) { + tests := []struct { + name string + input string + expectedJID string // canonical JID expected after ParseJID -> CanonicalJID + }{ + { + name: "BR phone strips the leading +", + input: "554187083284", + expectedJID: "554187083284@s.whatsapp.net", + }, + { + name: "US phone strips the leading +", + input: "15551234567", + expectedJID: "15551234567@s.whatsapp.net", + }, + { + name: "Already-canonical JID is unchanged", + input: "554187083284@s.whatsapp.net", + expectedJID: "554187083284@s.whatsapp.net", + }, + { + name: "LID JID is left untouched", + input: "15883309207561@lid", + expectedJID: "15883309207561@lid", + }, + { + name: "Group JID is left untouched", + input: "120363123456789012@g.us", + expectedJID: "120363123456789012@g.us", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jid, ok := ParseJID(tt.input) + if !ok { + t.Fatalf("ParseJID(%q) failed", tt.input) + } + + canonical := CanonicalJID(jid) + + if got := canonical.String(); got != tt.expectedJID { + t.Errorf("CanonicalJID for input %q = %q, want %q", tt.input, got, tt.expectedJID) + } + + if strings.HasPrefix(canonical.User, "+") { + t.Errorf("CanonicalJID for input %q still has '+' in User: %q", tt.input, canonical.User) + } + }) + } +} From 97d1907643f734b9bf8d38611ac1f91fcbd0dc16 Mon Sep 17 00:00:00 2001 From: joao porth Date: Thu, 4 Jun 2026 22:30:09 -0300 Subject: [PATCH 4/4] fix(react): deliver reactions via canonical JID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reaction (/message/react) was failing for the same root cause as the presence indicators: ParseJID/CreateJID prefix the number with '+'. In React that malformed JID is used in two places: - as the SendMessage target (usync/device resolution), which could stall the send (usync timeout); and - as the MessageKey RemoteJID that references the reacted message's chat — a '+'-prefixed chat JID does not match the real chat, so the reaction never attaches to the original message. Apply utils.CanonicalJID to the recipient (covers both the SendMessage target and the MessageKey RemoteJID) and to the optional group participant. Tested end-to-end: reactions (including multi-codepoint emoji like ❤️) confirmed attaching to the correct message on the recipient device, with no usync timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/message/service/message_service.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/message/service/message_service.go b/pkg/message/service/message_service.go index 6df4945b..4488714d 100644 --- a/pkg/message/service/message_service.go +++ b/pkg/message/service/message_service.go @@ -141,6 +141,13 @@ func (m *messageService) React(data *ReactStruct, instance *instance_model.Insta return nil, errors.New("invalid phone number") } + // Strip the "+" that ParseJID/CreateJID adds. The recipient is used both as + // the SendMessage target (usync/device resolution) AND as the MessageKey + // RemoteJID that references the reacted message's chat. A malformed "+JID" + // breaks device resolution (usync) and prevents the reaction from matching + // the original message's chat. See utils.CanonicalJID. + recipient = utils.CanonicalJID(recipient) + if data.Id == "" { m.loggerWrapper.GetLogger(instance.Id).LogError("[%s] Missing Id in Payload", instance.Id) return nil, errors.New("missing id in payload") @@ -166,7 +173,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()) } }