package whatsapp // WhatsAppService provides high-level operations for the WhatsApp channel. // Reference: Chatwoot app/services/whatsapp_service.rb — wraps channel model methods // // This service coordinates between: // - ChannelWhatsApp model (CRUD) // - WhatsAppProvider (Cloud API / 360dialog API calls) // - IncomingMessageProcessor (pipeline integration) // - OutgoingMessageProcessor (pipeline integration) // // Design: Service pattern follows Chatwoot's service layer where each channel // has a service module that wraps channel-specific business logic. // // 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" "time" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/go-resty/resty/v2" applogger "github.com/gochat/gochat/pkg/logger" ) // WhatsAppService handles WhatsApp channel business logic. type WhatsAppService struct { client *resty.Client repository *Repository graphAPIBase string // Cloud API base URL (e.g., "https://graph.facebook.com/v18.0") dialogAPIBase string // 360dialog API base URL (e.g., "https://waba.360dialog.io") } // NewWhatsAppService creates a new WhatsApp service. func NewWhatsAppService(repository *Repository) *WhatsAppService { client := resty.New() client.SetTimeout(30 * time.Second) client.SetRetryCount(3) client.SetRetryWaitTime(1 * time.Second) client.SetRetryMaxWaitTime(5 * time.Second) return &WhatsAppService{ client: client, repository: repository, graphAPIBase: "https://graph.facebook.com/v18.0", dialogAPIBase: "https://waba.360dialog.io", } } // === CRUD Operations === // CreateChannel creates a new WhatsApp channel. // Reference: Chatwoot WhatsApp channel creation flow: // validate phone_number_id → create ChannelWhatsApp → setup webhook → create Inbox // // Flow: // 1. Validate provider (whatsapp_cloud or 360dialog) // 2. Validate access token via provider API // 3. Create ChannelWhatsApp record in DB // 4. Setup webhook subscription (provider-specific) func (s *WhatsAppService) CreateChannel(ctx context.Context, accountID uint, params map[string]interface{}) (*channelmodel.ChannelWhatsApp, error) { phoneNumber, _ := params["phone_number"].(string) phoneNumberID, _ := params["phone_number_id"].(string) businessAccountID, _ := params["business_account_id"].(string) accessToken, _ := params["access_token"].(string) provider, _ := params["provider"].(string) webhookVerifyToken, _ := params["webhook_verify_token"].(string) // Validate required fields if phoneNumber == "" { return nil, fmt.Errorf("phone_number is required") } if phoneNumberID == "" { return nil, fmt.Errorf("phone_number_id is required") } if accessToken == "" { return nil, fmt.Errorf("access_token is required") } // Validate provider choice if provider == "" { provider = "whatsapp_cloud" // default to Cloud API } if provider != "whatsapp_cloud" && provider != "360dialog" { return nil, fmt.Errorf("provider must be 'whatsapp_cloud' or '360dialog'") } // Validate access token via provider API if err := s.validateAccessToken(ctx, provider, accessToken, phoneNumberID); err != nil { return nil, fmt.Errorf("access token validation failed: %w", err) } // Get account name from provider accountName := s.fetchAccountName(ctx, provider, accessToken, phoneNumberID) channel := &channelmodel.ChannelWhatsApp{ AccountID: accountID, PhoneNumber: phoneNumber, PhoneNumberID: phoneNumberID, BusinessAccountID: businessAccountID, WhatsAppAccountName: accountName, AccessToken: accessToken, Provider: provider, WebhookVerifyToken: webhookVerifyToken, AutoCreateContact: true, } if err := s.repository.Create(ctx, channel); err != nil { return nil, fmt.Errorf("failed to create WhatsApp channel: %w", err) } applogger.L().Info("WhatsApp channel created", "account_id", accountID, "phone_number", phoneNumber, "provider", provider, ) return channel, nil } // UpdateChannel updates an existing WhatsApp channel configuration. // Reference: Chatwoot WhatsApp channel update (facebook_pages_controller#update analog) func (s *WhatsAppService) UpdateChannel(ctx context.Context, channelID uint, params map[string]interface{}) (*channelmodel.ChannelWhatsApp, error) { channel, err := s.repository.GetByID(ctx, channelID) if err != nil { return nil, fmt.Errorf("failed to find WhatsApp channel: %w", err) } // Update fields from params if phoneNumber, ok := params["phone_number"].(string); ok { channel.PhoneNumber = phoneNumber } if phoneNumberID, ok := params["phone_number_id"].(string); ok { channel.PhoneNumberID = phoneNumberID } if businessAccountID, ok := params["business_account_id"].(string); ok { channel.BusinessAccountID = businessAccountID } if accessToken, ok := params["access_token"].(string); ok && accessToken != "" { channel.AccessToken = accessToken } if accountName, ok := params["whatsapp_account_name"].(string); ok { channel.WhatsAppAccountName = accountName } if provider, ok := params["provider"].(string); ok { if provider != "whatsapp_cloud" && provider != "360dialog" { return nil, fmt.Errorf("provider must be 'whatsapp_cloud' or '360dialog'") } channel.Provider = provider } if webhookVerifyToken, ok := params["webhook_verify_token"].(string); ok { channel.WebhookVerifyToken = webhookVerifyToken } if autoCreate, ok := params["auto_create_contact"].(bool); ok { channel.AutoCreateContact = autoCreate } if err := s.repository.Update(ctx, channel); err != nil { return nil, fmt.Errorf("failed to update WhatsApp channel: %w", err) } applogger.L().Info("WhatsApp channel updated", "channel_id", channelID, ) return channel, nil } // DeleteChannel removes a WhatsApp channel. // Reference: Chatwoot WhatsApp channel destroy (after_destroy :delete_webhook) func (s *WhatsAppService) DeleteChannel(ctx context.Context, channelID uint) error { channel, err := s.repository.GetByID(ctx, channelID) if err != nil { return fmt.Errorf("failed to find WhatsApp channel: %w", err) } // Cleanup webhook subscription before deleting if err := s.deleteWebhook(ctx, channel); err != nil { applogger.L().Warn("Failed to delete WhatsApp webhook during channel destroy", "error", err, "channel_id", channelID, ) // Don't fail deletion if webhook cleanup fails } if err := s.repository.Delete(ctx, channelID); err != nil { return fmt.Errorf("failed to delete WhatsApp channel: %w", err) } applogger.L().Info("WhatsApp channel deleted", "channel_id", channelID, ) return nil } // === WhatsApp Business API Operations === // SendMessage sends a text message via WhatsApp Business API. // Reference: Chatwoot SendOnWhatsappService // // Cloud API: POST /v18.0/{phone_number_id}/messages // 360dialog: POST /v1/messages func (s *WhatsAppService) SendMessage(ctx context.Context, channel *channelmodel.ChannelWhatsApp, recipientPhone string, content string) (*WASendMessageResponse, error) { outbound := &WASendMessageRequest{ MessagingProduct: "whatsapp", RecipientType: "individual", To: recipientPhone, Type: "text", Text: &WASendText{ Body: content, }, } return s.sendOutboundMessage(ctx, channel, outbound) } // SendTemplateMessage sends a template message via WhatsApp Business API. // Reference: Chatwoot template message support func (s *WhatsAppService) SendTemplateMessage(ctx context.Context, channel *channelmodel.ChannelWhatsApp, recipientPhone string, templateName string, languageCode string, components []WATemplateComponent) (*WASendMessageResponse, error) { outbound := &WASendMessageRequest{ MessagingProduct: "whatsapp", RecipientType: "individual", To: recipientPhone, Type: "template", Template: &WASendTemplate{ Name: templateName, Language: &WATemplateLanguage{Code: languageCode}, Components: components, }, } return s.sendOutboundMessage(ctx, channel, outbound) } // SendMediaMessage sends a media message (image, document, audio, video, sticker) via WhatsApp Business API. func (s *WhatsAppService) SendMediaMessage(ctx context.Context, channel *channelmodel.ChannelWhatsApp, recipientPhone string, mediaType string, mediaURL string, caption string) (*WASendMessageResponse, error) { outbound := &WASendMessageRequest{ MessagingProduct: "whatsapp", RecipientType: "individual", To: recipientPhone, Type: mediaType, } // Set media content based on type switch mediaType { case "image": outbound.Image = &WASendMedia{Link: mediaURL, Caption: caption} case "document": outbound.Document = &WASendDocument{Link: mediaURL, Caption: caption} case "audio": outbound.Audio = &WASendMedia{Link: mediaURL} case "video": outbound.Video = &WASendMedia{Link: mediaURL, Caption: caption} case "sticker": outbound.Sticker = &WASendMedia{Link: mediaURL} default: return nil, fmt.Errorf("unsupported media type: %s", mediaType) } return s.sendOutboundMessage(ctx, channel, outbound) } // GetPhoneNumberDetails fetches phone number quality rating and verification status. // Cloud API: GET /v18.0/{phone_number_id} func (s *WhatsAppService) GetPhoneNumberDetails(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (*WAPhoneNumber, error) { if !channel.IsCloudAPI() { // 360dialog doesn't have equivalent API return nil, fmt.Errorf("phone number details API only available for Cloud API") } url := fmt.Sprintf("%s/%s", s.graphAPIBase, channel.PhoneNumberID) resp, err := s.client.R(). SetContext(ctx). SetAuthToken(channel.AccessToken). SetResult(&WAPhoneNumber{}). Get(url) if err != nil { return nil, fmt.Errorf("WhatsApp phone number API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("WhatsApp phone number API returned status %d", resp.StatusCode()) } return resp.Result().(*WAPhoneNumber), nil } // FetchMessageTemplates retrieves available message templates for the WhatsApp Business Account. // Cloud API: GET /v18.0/{business_account_id}/message_templates func (s *WhatsAppService) FetchMessageTemplates(ctx context.Context, channel *channelmodel.ChannelWhatsApp) ([]interface{}, error) { if !channel.IsCloudAPI() { return nil, fmt.Errorf("message templates API only available for Cloud API") } url := fmt.Sprintf("%s/%s/message_templates", s.graphAPIBase, channel.BusinessAccountID) resp, err := s.client.R(). SetContext(ctx). SetAuthToken(channel.AccessToken). Get(url) if err != nil { return nil, fmt.Errorf("WhatsApp templates API call failed: %w", err) } if resp.StatusCode() != 200 { return nil, fmt.Errorf("WhatsApp templates API returned status %d", resp.StatusCode()) } var result struct { Data []interface{} `json:"data"` } if err := json.Unmarshal(resp.Body(), &result); err != nil { return nil, fmt.Errorf("failed to parse templates response: %w", err) } // Cache templates in channel model templateJSON, _ := json.Marshal(result.Data) channel.MessageTemplates = string(templateJSON) s.repository.Update(ctx, channel) return result.Data, nil } // === Webhook Management === // SetupWebhook registers webhook subscription with WhatsApp provider. // Cloud API: POST /v18.0/{business_account_id}/subscribed_apps // 360dialog: Webhook is configured via 360dialog Hub dashboard func (s *WhatsAppService) SetupWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp, webhookURL string) error { if channel.IsCloudAPI() { url := fmt.Sprintf("%s/%s/subscribed_apps", s.graphAPIBase, channel.BusinessAccountID) resp, err := s.client.R(). SetContext(ctx). SetAuthToken(channel.AccessToken). Post(url) if err != nil { return fmt.Errorf("WhatsApp Cloud API webhook subscription failed: %w", err) } if resp.StatusCode() != 200 { return fmt.Errorf("WhatsApp Cloud API webhook subscription returned status %d", resp.StatusCode()) } applogger.L().Info("WhatsApp Cloud API webhook subscription created", "business_account_id", channel.BusinessAccountID, ) return nil } // 360dialog: webhook is configured via their dashboard, no API call needed applogger.L().Info("WhatsApp 360dialog webhook configured via dashboard", "phone_number", channel.PhoneNumber, ) return nil } // === Internal Helper Methods === // validateAccessToken validates the WhatsApp access token by making a test API call. func (s *WhatsAppService) validateAccessToken(ctx context.Context, provider string, accessToken string, phoneNumberID string) error { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if provider == "whatsapp_cloud" { // Cloud API: GET /v18.0/{phone_number_id} to validate token url := fmt.Sprintf("%s/%s", s.graphAPIBase, phoneNumberID) resp, err := s.client.R(). SetContext(ctx). SetAuthToken(accessToken). Get(url) if err != nil { return fmt.Errorf("WhatsApp Cloud API token validation failed: %w", err) } if resp.StatusCode() != 200 { return fmt.Errorf("WhatsApp Cloud API returned status %d — token may be invalid", resp.StatusCode()) } } else { // 360dialog: GET /v1/settings/application to validate API key url := fmt.Sprintf("%s/v1/settings/application", s.dialogAPIBase) resp, err := s.client.R(). SetContext(ctx). SetHeader("D360-API-KEY", accessToken). Get(url) if err != nil { return fmt.Errorf("WhatsApp 360dialog API key validation failed: %w", err) } if resp.StatusCode() != 200 { return fmt.Errorf("WhatsApp 360dialog returned status %d — API key may be invalid", resp.StatusCode()) } } return nil } // fetchAccountName retrieves the WhatsApp business account display name. func (s *WhatsAppService) fetchAccountName(ctx context.Context, provider string, accessToken string, phoneNumberID string) string { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if provider == "whatsapp_cloud" { url := fmt.Sprintf("%s/%s", s.graphAPIBase, phoneNumberID) resp, err := s.client.R(). SetContext(ctx). SetAuthToken(accessToken). SetResult(&WAPhoneNumber{}). Get(url) if err == nil && resp.StatusCode() == 200 { info := resp.Result().(*WAPhoneNumber) if info.VerifiedName != nil && info.VerifiedName.Name != "" { return info.VerifiedName.Name } } } // Fallback: use phone number as display name return "" } // sendOutboundMessage sends a message via the appropriate WhatsApp provider API. func (s *WhatsAppService) sendOutboundMessage(ctx context.Context, channel *channelmodel.ChannelWhatsApp, outbound *WASendMessageRequest) (*WASendMessageResponse, error) { var url string var req *resty.Request if channel.IsCloudAPI() { // Cloud API: POST /v18.0/{phone_number_id}/messages url = fmt.Sprintf("%s/%s/messages", s.graphAPIBase, channel.PhoneNumberID) req = s.client.R(). SetContext(ctx). SetAuthToken(channel.AccessToken). SetBody(outbound). SetResult(&WASendMessageResponse{}) } else { // 360dialog: POST /v1/messages url = fmt.Sprintf("%s/v1/messages", s.dialogAPIBase) req = s.client.R(). SetContext(ctx). SetHeader("D360-API-KEY", channel.AccessToken). SetBody(outbound). SetResult(&WASendMessageResponse{}) } resp, err := req.Post(url) if err != nil { return nil, fmt.Errorf("WhatsApp outbound message API call failed: %w", err) } if resp.StatusCode() != 200 && resp.StatusCode() != 201 { // Parse error response var apiError WACloudAPIError if jsonErr := json.Unmarshal(resp.Body(), &apiError); jsonErr == nil { return nil, fmt.Errorf("WhatsApp API error: %s (code %d)", apiError.Message, apiError.Code) } return nil, fmt.Errorf("WhatsApp outbound API returned status %d", resp.StatusCode()) } result := resp.Result().(*WASendMessageResponse) applogger.L().Info("WhatsApp message sent", "phone_number_id", channel.PhoneNumberID, "recipient", outbound.To, "message_type", outbound.Type, ) return result, nil } // deleteWebhook removes webhook subscription from WhatsApp provider. func (s *WhatsAppService) deleteWebhook(ctx context.Context, channel *channelmodel.ChannelWhatsApp) error { if channel.IsCloudAPI() { url := fmt.Sprintf("%s/%s/subscribed_apps", s.graphAPIBase, channel.BusinessAccountID) _, err := s.client.R(). SetContext(ctx). SetAuthToken(channel.AccessToken). Delete(url) if err != nil { return fmt.Errorf("WhatsApp Cloud API webhook deletion failed: %w", err) } } // 360dialog: webhook managed via dashboard, no API deletion needed return nil } // GetChannelByInboxID retrieves the WhatsApp channel associated with an inbox. func (s *WhatsAppService) GetChannelByInboxID(ctx context.Context, inboxID uint) (*channelmodel.ChannelWhatsApp, error) { return s.repository.GetByInboxID(ctx, inboxID) }