diff --git a/README.md b/README.md index 06f27a6..ee0647c 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,23 @@ Send (or remove) an emoji reaction to a message. Inbound reactions received from others are stored automatically as messages with `media_type = "reaction"`. The `reaction_to_message_id` field in each reaction message indicates which message was reacted to. +When webhook forwarding is enabled, inbound reactions are also posted to `WEBHOOK_URL` as typed events. Reaction removals use an empty `content`/`reactionEmoji` and `reactionRemoved: true`. + +```json +{ + "eventType": "reaction", + "sender": "15551234567", + "chatJID": "15551234567@s.whatsapp.net", + "isFromMe": true, + "content": "👍", + "messageId": "reaction-stanza-id", + "mediaType": "reaction", + "reactionToMessageId": "target-message-id", + "reactionEmoji": "👍", + "reactionRemoved": false +} +``` + **Natural Language Examples:** - "React to that message with a thumbs up" diff --git a/whatsapp-bridge/main.go b/whatsapp-bridge/main.go index 976afc4..d16a236 100644 --- a/whatsapp-bridge/main.go +++ b/whatsapp-bridge/main.go @@ -1490,13 +1490,17 @@ func handleMessage(client *whatsmeow.Client, messageStore *MessageStore, msg *ev reactedToID = key.GetID() } if reactedToID != "" { + emoji := reaction.GetText() if err := messageStore.StoreMessage( - msg.Info.ID, chatJID, sender, reaction.GetText(), + msg.Info.ID, chatJID, sender, emoji, msg.Info.Timestamp, msg.Info.IsFromMe, "reaction", reactedToID, "", nil, nil, nil, 0, "", ); err != nil { logger.Warnf("Failed to store reaction: %v", err) } + if forwardSelfMessages || !msg.Info.IsFromMe { + SendReactionWebhook(sender, chatJID, msg.Info.IsFromMe, msg.Info.ID, reactedToID, emoji) + } } return } diff --git a/whatsapp-bridge/main_test.go b/whatsapp-bridge/main_test.go index 2cd0dce..cc82cea 100644 --- a/whatsapp-bridge/main_test.go +++ b/whatsapp-bridge/main_test.go @@ -1147,6 +1147,26 @@ func captureWebhook(t *testing.T) (*httptest.Server, <-chan WebhookPayload) { return srv, ch } +// captureRawWebhook starts a local httptest server that records the first raw +// JSON webhook payload it receives. +func captureRawWebhook(t *testing.T) (*httptest.Server, <-chan map[string]any) { + t.Helper() + ch := make(chan map[string]any, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var p map[string]any + if err := json.Unmarshal(body, &p); err == nil { + select { + case ch <- p: + default: + } + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(srv.Close) + return srv, ch +} + // TestHandleMessage_ImageOnly_WebhookForwarded verifies that an image message // with no text caption is forwarded to the webhook endpoint (not silently // dropped), and that the webhook payload contains the expected media fields. @@ -1695,9 +1715,133 @@ func TestHandleMessage_EmptyEmojiReaction_Stored(t *testing.T) { } } +// TestHandleMessage_InboundReaction_WebhookForwarded verifies that inbound +// reactions are forwarded as typed webhook events after being stored. +func TestHandleMessage_InboundReaction_WebhookForwarded(t *testing.T) { + srv, webhookCh := captureRawWebhook(t) + t.Setenv("WEBHOOK_URL", srv.URL) + + client := newTestClient(&mockLIDStore{}) + ms := newTestMessageStore(t) + logger := testLogger() + chatJID := phonePN.String() + targetID := "3AABCDEF01234569" + emoji := "👍" + + msg := buildReactionMessage(phonePN, phonePN, false, targetID, emoji) + handleMessage(client, ms, msg, logger) + + mediaType, filename, found := queryMessageMediaTypeAndFilename(ms, chatJID, msg.Info.ID) + if !found { + t.Fatalf("expected reaction to be stored, but message row not found") + } + if mediaType != "reaction" { + t.Errorf("media_type = %q, want %q", mediaType, "reaction") + } + if filename != targetID { + t.Errorf("filename = %q, want %q", filename, targetID) + } + + select { + case payload := <-webhookCh: + if payload["eventType"] != "reaction" { + t.Errorf("eventType = %v, want reaction", payload["eventType"]) + } + if payload["mediaType"] != "reaction" { + t.Errorf("mediaType = %v, want reaction", payload["mediaType"]) + } + if payload["messageId"] != msg.Info.ID { + t.Errorf("messageId = %v, want %s", payload["messageId"], msg.Info.ID) + } + if payload["reactionToMessageId"] != targetID { + t.Errorf("reactionToMessageId = %v, want %s", payload["reactionToMessageId"], targetID) + } + if payload["reactionEmoji"] != emoji { + t.Errorf("reactionEmoji = %v, want %s", payload["reactionEmoji"], emoji) + } + if payload["reactionRemoved"] != false { + t.Errorf("reactionRemoved = %v, want false", payload["reactionRemoved"]) + } + if payload["content"] != emoji { + t.Errorf("content = %v, want %s", payload["content"], emoji) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for reaction webhook call") + } +} + +// TestHandleMessage_EmptyEmojiReaction_WebhookForwarded verifies that reaction +// removals are forwarded even though their content is the empty string. +func TestHandleMessage_EmptyEmojiReaction_WebhookForwarded(t *testing.T) { + srv, webhookCh := captureRawWebhook(t) + t.Setenv("WEBHOOK_URL", srv.URL) + + client := newTestClient(&mockLIDStore{}) + ms := newTestMessageStore(t) + logger := testLogger() + targetID := "3AABCDEF01234570" + + msg := buildReactionMessage(phonePN, phonePN, false, targetID, "") + handleMessage(client, ms, msg, logger) + + select { + case payload := <-webhookCh: + if payload["eventType"] != "reaction" { + t.Errorf("eventType = %v, want reaction", payload["eventType"]) + } + if payload["content"] != "" { + t.Errorf("content = %v, want empty string", payload["content"]) + } + if payload["reactionEmoji"] != "" { + t.Errorf("reactionEmoji = %v, want empty string", payload["reactionEmoji"]) + } + if payload["reactionRemoved"] != true { + t.Errorf("reactionRemoved = %v, want true", payload["reactionRemoved"]) + } + if payload["reactionToMessageId"] != targetID { + t.Errorf("reactionToMessageId = %v, want %s", payload["reactionToMessageId"], targetID) + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for reaction removal webhook call") + } +} + +// TestHandleMessage_SelfReactionWebhook_RespectsForwardSelf verifies that +// self-authored reactions use the same FORWARD_SELF behavior as normal messages. +func TestHandleMessage_SelfReactionWebhook_RespectsForwardSelf(t *testing.T) { + srv, webhookCh := captureRawWebhook(t) + t.Setenv("WEBHOOK_URL", srv.URL) + + previous := forwardSelfMessages + forwardSelfMessages = false + t.Cleanup(func() { + forwardSelfMessages = previous + }) + + client := newTestClient(&mockLIDStore{}) + ms := newTestMessageStore(t) + logger := testLogger() + + msg := buildReactionMessage(phonePN, phonePN, true, "3AABCDEF01234571", "👍") + handleMessage(client, ms, msg, logger) + + if count := queryMessageCount(ms, phonePN.String()); count != 1 { + t.Errorf("expected self reaction to be stored, got %d stored messages", count) + } + + select { + case payload := <-webhookCh: + t.Fatalf("unexpected webhook for self reaction when FORWARD_SELF=false: %#v", payload) + case <-time.After(200 * time.Millisecond): + } +} + // TestHandleMessage_ReactionWithoutKey_NotStored verifies that a reaction // with no key (no reacted-to message ID) is silently ignored and not stored. func TestHandleMessage_ReactionWithoutKey_NotStored(t *testing.T) { + srv, webhookCh := captureRawWebhook(t) + t.Setenv("WEBHOOK_URL", srv.URL) + client := newTestClient(&mockLIDStore{}) ms := newTestMessageStore(t) logger := testLogger() @@ -1724,6 +1868,12 @@ func TestHandleMessage_ReactionWithoutKey_NotStored(t *testing.T) { if count := queryMessageCount(ms, phonePN.String()); count != 0 { t.Errorf("expected reaction without key to be discarded, got %d stored messages", count) } + + select { + case payload := <-webhookCh: + t.Fatalf("unexpected webhook for reaction without key: %#v", payload) + case <-time.After(200 * time.Millisecond): + } } // TestReactHandler_MissingFields_Returns400 verifies that the /api/react diff --git a/whatsapp-bridge/webhook.go b/whatsapp-bridge/webhook.go index a88ab34..fc7221a 100644 --- a/whatsapp-bridge/webhook.go +++ b/whatsapp-bridge/webhook.go @@ -22,6 +22,7 @@ var webhookClient = &http.Client{Timeout: 30 * time.Second} // WebhookPayload represents the data sent to the webhook type WebhookPayload struct { + EventType string `json:"eventType,omitempty"` Sender string `json:"sender"` Content string `json:"content"` ChatJID string `json:"chatJID"` @@ -35,6 +36,10 @@ type WebhookPayload struct { MimeType string `json:"mimeType,omitempty"` MediaFilename string `json:"mediaFilename,omitempty"` MediaBase64 string `json:"mediaBase64,omitempty"` + // Reaction fields - populated when EventType is "reaction". + ReactionToMessageID string `json:"reactionToMessageId,omitempty"` + ReactionEmoji *string `json:"reactionEmoji,omitempty"` + ReactionRemoved *bool `json:"reactionRemoved,omitempty"` } // sendWebhookPayload marshals and POSTs a WebhookPayload to the configured webhook URL. @@ -116,5 +121,22 @@ func SendWebhookWithMedia( }) } +// SendReactionWebhook sends a typed reaction event to the webhook endpoint. +func SendReactionWebhook(sender, chatJID string, isFromMe bool, messageID, reactionToMessageID, emoji string) { + removed := emoji == "" + sendWebhookPayload(WebhookPayload{ + EventType: "reaction", + Sender: sender, + Content: emoji, + ChatJID: chatJID, + IsFromMe: isFromMe, + MessageID: messageID, + MediaType: "reaction", + ReactionToMessageID: reactionToMessageID, + ReactionEmoji: &emoji, + ReactionRemoved: &removed, + }) +} + // In main.go, handleMessage forwards webhooks for messages with text content. // It will forward self-sent messages when the env var FORWARD_SELF=true.