Files
gochat/internal/channel/whatsapp/pipeline.go
T
2026-06-04 15:44:48 +08:00

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
}