Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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@s.whatsapp.net",
Comment thread
Copilot marked this conversation as resolved.
Outdated
"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"
Expand Down
6 changes: 5 additions & 1 deletion whatsapp-bridge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
150 changes: 150 additions & 0 deletions whatsapp-bridge/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()
Expand All @@ -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
Expand Down
22 changes: 22 additions & 0 deletions whatsapp-bridge/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Loading