Files
gochat/internal/channel/whatsapp/service.go
T

557 lines
19 KiB
Go

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"
"os"
"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)
apiVersion := os.Getenv("WHATSAPP_API_VERSION")
if apiVersion == "" {
apiVersion = "v22.0"
}
return &WhatsAppService{
client: client,
repository: repository,
graphAPIBase: "https://graph.facebook.com/" + apiVersion,
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
}
// FetchHealthStatus retrieves WhatsApp Cloud phone-number health data and formats
// it to the payload consumed by Chatwoot's inbox settings screen.
// Reference: reference/chatwoot/app/services/whatsapp/health_service.rb
func (s *WhatsAppService) FetchHealthStatus(ctx context.Context, channel *channelmodel.ChannelWhatsApp) (map[string]interface{}, error) {
if channel == nil {
return nil, fmt.Errorf("Channel is required")
}
if channel.AccessToken == "" {
return nil, fmt.Errorf("API key is missing")
}
if channel.PhoneNumberID == "" {
return nil, fmt.Errorf("Phone number ID is missing")
}
var result map[string]interface{}
url := fmt.Sprintf("%s/%s", s.graphAPIBase, channel.PhoneNumberID)
resp, err := s.client.R().
SetContext(ctx).
SetQueryParams(map[string]string{
"fields": whatsappHealthFields(),
"access_token": channel.AccessToken,
}).
SetResult(&result).
Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch WhatsApp health status: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("WhatsApp API request failed: %d - %s", resp.StatusCode(), resp.String())
}
return formatWhatsAppHealthResponse(result, channel), nil
}
func whatsappHealthFields() string {
return "id,quality_rating,messaging_limit_tier,code_verification_status,account_mode,display_phone_number,name_status,verified_name,webhook_configuration,throughput,last_onboarded_time,platform_type,certificate"
}
func formatWhatsAppHealthResponse(response map[string]interface{}, channel *channelmodel.ChannelWhatsApp) map[string]interface{} {
return map[string]interface{}{
"id": response["id"],
"display_phone_number": response["display_phone_number"],
"verified_name": response["verified_name"],
"name_status": response["name_status"],
"quality_rating": response["quality_rating"],
"messaging_limit_tier": response["messaging_limit_tier"],
"account_mode": response["account_mode"],
"code_verification_status": response["code_verification_status"],
"webhook_configuration": response["webhook_configuration"],
"expected_webhook_url": buildExpectedWhatsAppWebhookURL(channel.PhoneNumber),
"throughput": response["throughput"],
"last_onboarded_time": response["last_onboarded_time"],
"platform_type": response["platform_type"],
"certificate": response["certificate"],
"business_id": channel.BusinessAccountID,
}
}
func buildExpectedWhatsAppWebhookURL(phoneNumber string) string {
return fmt.Sprintf("%s/webhooks/whatsapp/%s", os.Getenv("FRONTEND_URL"), phoneNumber)
}
// === 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)
}