438 lines
15 KiB
Go
438 lines
15 KiB
Go
package whatsapp
|
|
|
|
// WhatsApp pipeline integration for incoming and outgoing message processing.
|
|
// Reference: Chatwoot's pipeline architecture:
|
|
// - Incoming: WebhookController → IncomingMessageService → MessageBuilder → Conversation → Message
|
|
// - Outgoing: SendOnWhatsappService → WhatsApp Business API → Message status callback
|
|
//
|
|
// Design: Pipeline pattern follows gochat's existing channel pipeline architecture
|
|
// where each channel has an incoming pipeline (webhook → normalized message) and
|
|
// an outgoing pipeline (message → external API → result).
|
|
//
|
|
// WhatsApp pipeline specifics:
|
|
// - Incoming: WAWebhookEvent → parse messages/statuses → IncomingMessage (channel.Channelable)
|
|
// - Outgoing: model.Message → WAOutboundMessage → WhatsApp Business API → SendResult
|
|
//
|
|
// WhatsApp Cloud API: https://developers.facebook.com/docs/whatsapp/cloud-api
|
|
// 360dialog API: https://docs.360dialog.com/docs/whatsapp-api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gochat/gochat/internal/channel"
|
|
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
|
"github.com/gochat/gochat/internal/model"
|
|
applogger "github.com/gochat/gochat/pkg/logger"
|
|
)
|
|
|
|
// IncomingPipeline processes WhatsApp webhook events into normalized IncomingMessages.
|
|
// Reference: Chatwoot's Whatsapp::IncomingMessageService
|
|
//
|
|
// Flow:
|
|
// 1. Parse WAWebhookEvent from raw webhook payload
|
|
// 2. For each entry/changes, extract WAWebhookValue
|
|
// 3. For each WAMessage in value.Messages:
|
|
// a. Map WAMessage type to channel.ContentType
|
|
// b. Build channel.IncomingMessage with source IDs and metadata
|
|
// c. Route to conversation/message creation via channel broker
|
|
// 4. For each WAStatus in value.Statuses:
|
|
// a. Map status type to delivery status update
|
|
// b. Route to message status update processing
|
|
type IncomingPipeline struct {
|
|
service *WhatsAppService
|
|
}
|
|
|
|
// NewIncomingPipeline creates a WhatsApp incoming message pipeline.
|
|
func NewIncomingPipeline(service *WhatsAppService) *IncomingPipeline {
|
|
return &IncomingPipeline{service: service}
|
|
}
|
|
|
|
// Process transforms a WAWebhookEvent into one or more IncomingMessages.
|
|
// Reference: Chatwoot Whatsapp::IncomingMessageService.process
|
|
//
|
|
// This method is called by the WebhookHandler after parsing the raw webhook payload.
|
|
// It normalizes WhatsApp-specific message formats into the generic channel.IncomingMessage
|
|
// format that the channel broker can route to conversation creation.
|
|
func (p *IncomingPipeline) Process(ctx context.Context, event *WAWebhookEvent, inbox *model.Inbox) ([]*channel.IncomingMessage, error) {
|
|
if event == nil || len(event.Entry) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var messages []*channel.IncomingMessage
|
|
|
|
for _, entry := range event.Entry {
|
|
for _, change := range entry.Changes {
|
|
if change.Field != "messages" {
|
|
continue // Skip non-message changes (e.g., account_update)
|
|
}
|
|
|
|
value := &change.Value
|
|
if len(value.Messages) == 0 {
|
|
// Process status updates if no new messages
|
|
if len(value.Statuses) > 0 {
|
|
p.processStatuses(ctx, value.Statuses, inbox)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Process each incoming message
|
|
for i := range value.Messages {
|
|
waMsg := &value.Messages[i]
|
|
incoming, err := p.processMessage(ctx, waMsg, value, inbox)
|
|
if err != nil {
|
|
applogger.L().Warn("Failed to process WhatsApp incoming message",
|
|
"error", err,
|
|
"message_id", waMsg.ID,
|
|
)
|
|
continue // Skip malformed messages, don't fail the whole batch
|
|
}
|
|
if incoming != nil {
|
|
messages = append(messages, incoming)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return messages, nil
|
|
}
|
|
|
|
// processMessage maps a WAMessage to a channel.IncomingMessage.
|
|
// Reference: Chatwoot Whatsapp::IncomingMessageService.build_message
|
|
func (p *IncomingPipeline) processMessage(ctx context.Context, waMsg *WAMessage, value *WAWebhookValue, inbox *model.Inbox) (*channel.IncomingMessage, error) {
|
|
// Determine sender info from contacts array
|
|
senderID := waMsg.From // Phone number of sender
|
|
senderName := waMsg.From
|
|
|
|
// Look up sender name from contacts array
|
|
for _, contact := range value.Contacts {
|
|
if contact.WAID == waMsg.From {
|
|
if contact.Name != nil && contact.Name.FormattedName != "" {
|
|
senderName = contact.Name.FormattedName
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
// Map WhatsApp message type to content type and extract content
|
|
contentType, content, attachments, err := p.mapMessageType(waMsg)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Parse timestamp
|
|
tsInt, _ := strconv.ParseInt(waMsg.Timestamp, 10, 64)
|
|
receivedAt := time.Unix(tsInt, 0)
|
|
if tsInt == 0 {
|
|
receivedAt = time.Now()
|
|
}
|
|
|
|
// Get phone_number_id from metadata
|
|
phoneNumberID := ""
|
|
if value.Metadata != nil {
|
|
phoneNumberID = value.Metadata.PhoneNumberID
|
|
}
|
|
|
|
// Build the normalized incoming message
|
|
incoming := &channel.IncomingMessage{
|
|
ChannelType: channel.ChannelType("whatsapp"),
|
|
SourceID: waMsg.ID,
|
|
ConversationID: waMsg.ID, // WhatsApp doesn't have separate thread IDs
|
|
SenderID: senderID,
|
|
SenderName: senderName,
|
|
SenderType: channel.SenderContact,
|
|
Content: content,
|
|
ContentType: contentType,
|
|
Attachments: attachments,
|
|
InboxID: inbox.ID,
|
|
AccountID: inbox.AccountID,
|
|
ReceivedAt: receivedAt,
|
|
Extra: channel.ChannelConfig{
|
|
"whatsapp_message_type": waMsg.Type,
|
|
"phone_number_id": phoneNumberID,
|
|
"wa_id": waMsg.From,
|
|
},
|
|
SenderExtra: channel.ChannelConfig{
|
|
"wa_id": senderID,
|
|
"name": senderName,
|
|
},
|
|
}
|
|
|
|
// Add reply-to context if present
|
|
if waMsg.Context != nil && waMsg.Context.ID != "" {
|
|
incoming.ReplyToID = waMsg.Context.ID
|
|
incoming.Extra["replied_to_message_id"] = waMsg.Context.ID
|
|
}
|
|
|
|
return incoming, nil
|
|
}
|
|
|
|
// mapMessageType converts WhatsApp message type to channel content type.
|
|
// Reference: Chatwoot's message type mapping (text, image, document, audio, video, sticker, location, contacts, reaction, template, interactive)
|
|
func (p *IncomingPipeline) mapMessageType(waMsg *WAMessage) (channel.ContentType, string, []channel.Attachment, error) {
|
|
switch waMsg.Type {
|
|
case "text":
|
|
if waMsg.Text != nil {
|
|
return channel.ContentText, waMsg.Text.Body, nil, nil
|
|
}
|
|
return channel.ContentText, "", nil, fmt.Errorf("text message missing Text field")
|
|
|
|
case "image":
|
|
if waMsg.Image != nil {
|
|
return channel.ContentImage, waMsg.Image.Caption, []channel.Attachment{
|
|
{
|
|
URL: waMsg.Image.ID, // Media ID; must be downloaded separately via Cloud API
|
|
ContentType: waMsg.Image.MimeType,
|
|
FileSize: waMsg.Image.FileSize,
|
|
},
|
|
}, nil
|
|
}
|
|
return channel.ContentImage, "", nil, fmt.Errorf("image message missing Image field")
|
|
|
|
case "document":
|
|
if waMsg.Document != nil {
|
|
return channel.ContentFile, waMsg.Document.Caption, []channel.Attachment{
|
|
{
|
|
URL: waMsg.Document.ID, // Media ID; must be downloaded separately via Cloud API
|
|
ContentType: waMsg.Document.MimeType,
|
|
FileSize: waMsg.Document.FileSize,
|
|
Filename: waMsg.Document.Filename,
|
|
},
|
|
}, nil
|
|
}
|
|
return channel.ContentFile, "", nil, fmt.Errorf("document message missing Document field")
|
|
|
|
case "audio":
|
|
if waMsg.Audio != nil {
|
|
return channel.ContentAudio, "", []channel.Attachment{
|
|
{
|
|
URL: waMsg.Audio.ID, // Media ID; must be downloaded separately via Cloud API
|
|
ContentType: waMsg.Audio.MimeType,
|
|
FileSize: waMsg.Audio.FileSize,
|
|
},
|
|
}, nil
|
|
}
|
|
return channel.ContentAudio, "", nil, fmt.Errorf("audio message missing Audio field")
|
|
|
|
case "video":
|
|
if waMsg.Video != nil {
|
|
return channel.ContentVideo, waMsg.Video.Caption, []channel.Attachment{
|
|
{
|
|
URL: waMsg.Video.ID, // Media ID; must be downloaded separately via Cloud API
|
|
ContentType: waMsg.Video.MimeType,
|
|
FileSize: waMsg.Video.FileSize,
|
|
},
|
|
}, nil
|
|
}
|
|
return channel.ContentVideo, "", nil, fmt.Errorf("video message missing Video field")
|
|
|
|
case "sticker":
|
|
if waMsg.Sticker != nil {
|
|
return channel.ContentImage, "", []channel.Attachment{
|
|
{
|
|
URL: waMsg.Sticker.ID, // Media ID; must be downloaded separately via Cloud API
|
|
ContentType: waMsg.Sticker.MimeType,
|
|
FileSize: waMsg.Sticker.FileSize,
|
|
},
|
|
}, nil
|
|
}
|
|
return channel.ContentImage, "", nil, fmt.Errorf("sticker message missing Sticker field")
|
|
|
|
case "location":
|
|
if waMsg.Location != nil {
|
|
content := fmt.Sprintf("Location: %s (%.6f, %.6f)", waMsg.Location.Name, waMsg.Location.Latitude, waMsg.Location.Longitude)
|
|
if waMsg.Location.Address != "" {
|
|
content = fmt.Sprintf("Location: %s - %s (%.6f, %.6f)", waMsg.Location.Name, waMsg.Location.Address, waMsg.Location.Latitude, waMsg.Location.Longitude)
|
|
}
|
|
return channel.ContentLocation, content, nil, nil
|
|
}
|
|
return channel.ContentLocation, "", nil, fmt.Errorf("location message missing Location field")
|
|
|
|
case "contacts":
|
|
if waMsg.Contacts != nil {
|
|
// Serialize contacts as text content
|
|
content := "Shared contacts"
|
|
return channel.ContentText, content, nil, nil
|
|
}
|
|
return channel.ContentText, "", nil, fmt.Errorf("contacts message missing Contacts field")
|
|
|
|
case "reaction":
|
|
if waMsg.Reaction != nil {
|
|
content := waMsg.Reaction.Emoji
|
|
return channel.ContentText, content, nil, nil
|
|
}
|
|
return channel.ContentText, "", nil, fmt.Errorf("reaction message missing Reaction field")
|
|
|
|
case "template":
|
|
// Template messages contain structured content
|
|
content := "Template message"
|
|
if waMsg.Template != nil {
|
|
content = fmt.Sprintf("Template: %s", waMsg.Template.Name)
|
|
}
|
|
return channel.ContentText, content, nil, nil
|
|
|
|
case "interactive":
|
|
// Interactive messages (buttons, lists)
|
|
content := "Interactive message"
|
|
if waMsg.Interactive != nil {
|
|
switch waMsg.Interactive.Type {
|
|
case "button_reply":
|
|
if waMsg.Interactive.ButtonReply != nil {
|
|
content = waMsg.Interactive.ButtonReply.Title
|
|
}
|
|
case "list_reply":
|
|
if waMsg.Interactive.ListReply != nil {
|
|
content = waMsg.Interactive.ListReply.Title
|
|
}
|
|
default:
|
|
content = fmt.Sprintf("Interactive message (%s)", waMsg.Interactive.Type)
|
|
}
|
|
}
|
|
return channel.ContentText, content, nil, nil
|
|
|
|
case "order":
|
|
content := "Order message"
|
|
return channel.ContentText, content, nil, nil
|
|
|
|
case "system":
|
|
// WhatsApp system messages don't have a dedicated struct in our types
|
|
// They are typically change notifications (number changed, encryption, etc.)
|
|
return channel.ContentText, "System message", nil, nil
|
|
|
|
default:
|
|
applogger.L().Warn("Unknown WhatsApp message type",
|
|
"type", waMsg.Type,
|
|
"message_id", waMsg.ID,
|
|
)
|
|
return channel.ContentText, fmt.Sprintf("[Unsupported message type: %s]", waMsg.Type), nil, nil
|
|
}
|
|
}
|
|
|
|
// processStatuses handles WhatsApp message delivery status updates.
|
|
// Reference: Chatwoot's message status processing (sent, delivered, read, failed)
|
|
func (p *IncomingPipeline) processStatuses(ctx context.Context, statuses []WAStatus, inbox *model.Inbox) {
|
|
for _, status := range statuses {
|
|
applogger.L().Info("WhatsApp message status update",
|
|
"message_id", status.ID,
|
|
"status", status.Status,
|
|
"recipient_id", status.RecipientID,
|
|
)
|
|
|
|
// Map WhatsApp status to channel event
|
|
// In a full implementation, this would update the Message model's status
|
|
// and trigger any notifications (e.g., delivery confirmation to agent)
|
|
switch status.Status {
|
|
case "sent":
|
|
// Message sent to WhatsApp servers
|
|
case "delivered":
|
|
// Message delivered to recipient's device
|
|
case "read":
|
|
// Recipient read the message
|
|
case "failed":
|
|
// Message delivery failed
|
|
if len(status.Errors) > 0 {
|
|
applogger.L().Warn("WhatsApp message delivery failed",
|
|
"message_id", status.ID,
|
|
"error_code", status.Errors[0].Code,
|
|
"error_title", status.Errors[0].Title,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// OutgoingPipeline processes outgoing messages from the platform to WhatsApp.
|
|
// Reference: Chatwoot's SendOnWhatsappService
|
|
//
|
|
// Flow:
|
|
// 1. Receive model.Message from conversation system
|
|
// 2. Map content type to WhatsApp outbound message type
|
|
// 3. Call WhatsApp Business API to send the message
|
|
// 4. Return SendResult with external message ID for correlation
|
|
type OutgoingPipeline struct {
|
|
service *WhatsAppService
|
|
}
|
|
|
|
// NewOutgoingPipeline creates a WhatsApp outgoing message pipeline.
|
|
func NewOutgoingPipeline(service *WhatsAppService) *OutgoingPipeline {
|
|
return &OutgoingPipeline{service: service}
|
|
}
|
|
|
|
// Process transforms a model.Message into a WhatsApp outbound API call.
|
|
// Reference: Chatwoot's SendOnWhatsappService.perform
|
|
func (p *OutgoingPipeline) Process(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact, waChannel *channelmodel.ChannelWhatsApp) (*channel.SendResult, error) {
|
|
// Determine recipient phone number
|
|
recipientPhone := ""
|
|
if contact != nil && contact.PhoneNumber != "" {
|
|
recipientPhone = contact.PhoneNumber
|
|
} else {
|
|
// Fallback: try to get from message ExternalSourceIDs metadata
|
|
var extIDs map[string]interface{}
|
|
if message.ExternalSourceIDs != nil {
|
|
if jsonErr := json.Unmarshal(message.ExternalSourceIDs, &extIDs); jsonErr == nil {
|
|
if waID, ok := extIDs["wa_id"].(string); ok {
|
|
recipientPhone = waID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if recipientPhone == "" {
|
|
return nil, fmt.Errorf("no recipient phone number available for WhatsApp outbound message")
|
|
}
|
|
|
|
// Map message content type to WhatsApp outbound type
|
|
var response *WASendMessageResponse
|
|
var err error
|
|
|
|
switch message.ContentType {
|
|
case "template":
|
|
// Template messages need template name + language
|
|
// Extract from ContentAttributes (datatypes.JSON)
|
|
var contentAttrs map[string]interface{}
|
|
templateName := ""
|
|
languageCode := ""
|
|
if message.ContentAttributes != nil {
|
|
if jsonErr := json.Unmarshal(message.ContentAttributes, &contentAttrs); jsonErr == nil {
|
|
if tn, ok := contentAttrs["template_name"].(string); ok {
|
|
templateName = tn
|
|
}
|
|
if lc, ok := contentAttrs["language_code"].(string); ok {
|
|
languageCode = lc
|
|
}
|
|
}
|
|
}
|
|
if templateName == "" {
|
|
return nil, fmt.Errorf("template_name is required for WhatsApp template messages")
|
|
}
|
|
if languageCode == "" {
|
|
languageCode = "en" // default to English
|
|
}
|
|
response, err = p.service.SendTemplateMessage(ctx, waChannel, recipientPhone, templateName, languageCode, nil)
|
|
default:
|
|
// Default: send as text message
|
|
response, err = p.service.SendMessage(ctx, waChannel, recipientPhone, message.Content)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("WhatsApp outbound message failed: %w", err)
|
|
}
|
|
|
|
// Extract external message ID from response
|
|
externalID := ""
|
|
if len(response.Messages) > 0 {
|
|
externalID = response.Messages[0].ID
|
|
}
|
|
|
|
return &channel.SendResult{
|
|
ExternalID: externalID,
|
|
DeliveredAt: time.Now(),
|
|
Extra: channel.ChannelConfig{
|
|
"whatsapp_provider": waChannel.Provider,
|
|
"phone_number_id": waChannel.PhoneNumberID,
|
|
},
|
|
}, nil
|
|
} |