9.9 KiB
WuzAPI Reply/Quote System - Technical Documentation
Last Updated: 2026-01-24 Status: ✅ Working Author: AI Assistant
Overview
This document explains how the Reply/Quote feature works for WhatsApp messages through WuzAPI integration. When a user replies to a message in WhatsApp, the quoted message should appear in Chatwoot's interface.
Architecture Diagram
┌─────────────────────────────────────────────────────────────────────────────┐
│ REPLY FLOW │
└─────────────────────────────────────────────────────────────────────────────┘
WhatsApp User replies to a message
│
▼
┌────────────────────┐
│ WuzAPI Server │ ──► Sends webhook with contextInfo.stanzaID
└────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Chatwoot Backend │
│ │
│ 1. WhatsappController.sanitize_payload_for_sidekiq (preserves stanzaID) │
│ 2. WhatsappEventsJob → IncomingMessageWuzapiService │
│ 3. PayloadParser.in_reply_to_external_id (extracts stanzaID) │
│ 4. build_message → finds original by source_id, sets in_reply_to_id │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ Chatwoot Frontend │
│ │
│ MessageList.vue → getInReplyToMessage() → looks up inReplyToId │
│ Base.vue → displays replyToPreview │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Files
| File | Purpose |
|---|---|
app/controllers/webhooks/whatsapp_controller.rb |
Sanitizes payload, preserves stanzaID |
app/services/whatsapp/incoming_message_wuzapi_service.rb |
Creates message with in_reply_to_id |
app/services/whatsapp/providers/wuzapi/payload_parser.rb |
Extracts stanzaID from contextInfo |
app/services/whatsapp/providers/wuzapi_service.rb |
Sends messages, returns clean WAID:xxx |
app/javascript/dashboard/components-next/message/MessageList.vue |
Resolves reply reference |
app/javascript/dashboard/components-next/message/bubbles/Base.vue |
Displays quoted message |
How It Works
1. Incoming Reply (WhatsApp → Chatwoot)
Step 1: WuzAPI sends webhook with reply context
{
"event": {
"Message": {
"extendedTextMessage": {
"text": "My reply message",
"contextInfo": {
"stanzaID": "3EB0B12FB7571691E025DD",
"participant": "556191544165@s.whatsapp.net",
"quotedMessage": { "conversation": "Original message" }
}
}
}
}
}
Step 2: Sanitizer preserves stanzaID (whatsapp_controller.rb lines 84-90)
if msg['extendedTextMessage']['contextInfo'].is_a?(Hash)
ctx = msg['extendedTextMessage']['contextInfo']
clean_msg['extendedTextMessage']['contextInfo'] = {
'stanzaID' => ctx['stanzaID'] || ctx['stanzaId'],
'participant' => ctx['participant']
}.compact
end
Step 3: PayloadParser extracts stanzaID (payload_parser.rb)
def in_reply_to_external_id
msg = unwrap_ephemeral_message(params.dig(:event, :Message))
ctx = msg.dig(:extendedTextMessage, :contextInfo)
return ctx[:stanzaID] || ctx[:stanzaId] if ctx.present?
# ... also checks imageMessage, videoMessage, etc.
end
Step 4: IncomingMessageWuzapiService links messages (lines 112-124)
if (reply_id = parser.in_reply_to_external_id).present?
clean_reply_id = "WAID:#{reply_id}"
original_message = conversation.messages.find_by(source_id: clean_reply_id)
if original_message
msg_params[:in_reply_to_id] = original_message.id
else
# Fallback: store for UI display
msg_params[:content_attributes] = { in_reply_to_external_id: clean_reply_id }
end
end
2. Outgoing Messages (Chatwoot → WhatsApp)
CRITICAL: The source_id must be saved in format WAID:xxx for replies to work!
WuzapiService.send_message extracts ID from response:
def send_message(phone_number, message)
# ... send message to WuzAPI ...
response = client.send_text(...)
extract_message_id(response) # Returns "WAID:xxx"
end
def extract_message_id(response)
# WuzAPI returns: {"code" => 200, "data" => {"Id" => "xxx"}}
message_id = response.dig('data', 'Id')
return nil if message_id.blank?
"WAID:#{message_id}"
end
3. Frontend Display
MessageList.vue resolves the reply:
const getInReplyToMessage = parentMessage => {
const inReplyToMessageId =
parentMessage.inReplyToId ?? parentMessage.contentAttributes?.inReplyTo;
if (!inReplyToMessageId) return null;
return props.messages?.find(msg => msg.id === inReplyToMessageId);
};
Base.vue displays the preview:
<div v-if="inReplyTo" class="p-2 -mx-1 mb-2 rounded-lg cursor-pointer">
<span class="break-all line-clamp-2">{{ replyToPreview }}</span>
</div>
Troubleshooting Guide
❌ Reply not showing in Chatwoot
Check 1: Is stanzaID in the webhook?
grep "WuzAPI Reply Debug" log/development.log | tail -10
- ✅ Good:
Found extendedTextMessage contextInfo = {"stanzaID" => "xxx"} - ❌ Bad:
Message keys = []orNo reply context found
If stanzaID is missing, the message was NOT sent as a reply in WhatsApp.
Check 2: Is source_id in correct format?
bundle exec rails runner "puts Message.last(5).pluck(:id, :source_id, :in_reply_to_id)"
- ✅ Good:
source_id: "WAID:3EB0B12FB7571691E025DD" - ❌ Bad:
source_id: "{\"code\" => 200, ...}"(JSON instead of ID)
If format is wrong, check WuzapiService.extract_message_id.
Check 3: Can original message be found?
The reply searches for: WAID:#{stanzaID}
The original must have source_id = WAID:#{same_id}
If IDs don't match, the original wasn't saved correctly.
Common Issues & Fixes
| Problem | Cause | Solution |
|---|---|---|
source_id is JSON |
extract_message_id not returning clean ID |
Check wuzapi_service.rb line ~155 |
| stanzaID not in payload | Sanitizer removing it | Check whatsapp_controller.rb lines 84-90 |
| Reply detected but not linked | Original message not found | Check if original has matching source_id |
| Frontend not showing | inReplyToId null in API response |
Check jbuilder includes in_reply_to_id |
Testing Checklist
-
Send message from Chatwoot
- Check:
source_idsaved asWAID:xxxformat
- Check:
-
Reply to that message in WhatsApp
- Check: Webhook has
contextInfo.stanzaID - Check:
in_reply_to_idis set in new message
- Check: Webhook has
-
View in Chatwoot UI
- Check: Quote box appears above message
Debug Commands
# See recent messages with reply info
bundle exec rails runner "
Message.last(10).each do |m|
puts \"#{m.id}: #{m.content&.truncate(30)} | source_id: #{m.source_id} | in_reply_to_id: #{m.in_reply_to_id}\"
end
"
# Check webhook payload for stanzaID
grep "stanzaID" log/development.log | tail -5
# See reply debug logs
grep "WuzAPI Reply Debug" log/development.log | tail -20
Files Changed in This Implementation
-
app/services/whatsapp/providers/wuzapi_service.rb- Added
extract_message_id(response)method - Returns
WAID:xxxformat instead of raw JSON
- Added
-
app/services/whatsapp/providers/wuzapi/payload_parser.rb- Enhanced
in_reply_to_external_idwith debug logging - Handles multiple message types (text, image, video, etc.)
- Enhanced
-
app/services/whatsapp/incoming_message_wuzapi_service.rbbuild_messagemethod links replies viain_reply_to_id- Fallback stores
in_reply_to_external_idin content_attributes
-
app/controllers/webhooks/whatsapp_controller.rb- Sanitizer preserves
stanzaIDandparticipantin contextInfo
- Sanitizer preserves