package whatsapp // WhatsAppProvider implements channel.ChannelProvider for WhatsApp Business API. // Reference: Chatwoot app/models/channel/whatsapp.rb + providers (360dialog, whatsapp_cloud) // // WhatsApp supports two provider backends: // - "whatsapp_cloud": Meta's official WhatsApp Business Cloud API // (https://developers.facebook.com/docs/whatsapp/cloud-api) // - "360dialog": 360dialog's hosted WhatsApp Business API // (https://docs.360dialog.com/docs/whatsapp-api) // // Both providers use the same underlying WhatsApp Business API protocol, but differ in: // - Authentication: Cloud API uses Meta access tokens; 360dialog uses API keys // - Webhook verification: Cloud API uses hub.verify_token; 360dialog uses D360 API key header // - Base URL: Cloud API uses https://graph.facebook.com/v18.0; 360dialog uses https://waba.360dialog.io // - Signature verification: Cloud API uses X-Hub-Signature-256 (HMAC-SHA256); 360dialog doesn't sign import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/channel" "github.com/gochat/gochat/internal/model" applogger "github.com/gochat/gochat/pkg/logger" ) // WhatsAppProvider implements ChannelProvider for both 360dialog and WhatsApp Cloud API. type WhatsAppProvider struct { service *WhatsAppService repository *Repository pipeline *IncomingPipeline client *resty.Client } // NewWhatsAppProvider creates a WhatsApp channel provider with all dependencies. func NewWhatsAppProvider(service *WhatsAppService, repository *Repository, pipeline *IncomingPipeline) *WhatsAppProvider { client := resty.New() client.SetTimeout(20 * time.Second) return &WhatsAppProvider{ service: service, repository: repository, pipeline: pipeline, client: client, } } // === Identity & Metadata === // Type returns the channel type identifier. func (p *WhatsAppProvider) Type() channel.ChannelType { return channel.ChannelWhatsApp } // Name returns the human-readable channel name. func (p *WhatsAppProvider) Name() string { return "WhatsApp" } // Description returns a short description of the channel. func (p *WhatsAppProvider) Description() string { return "WhatsApp Business API channel supporting Cloud API and 360dialog providers" } // === Configuration & Validation === // ConfigSchema returns the JSON Schema for WhatsApp channel configuration. // Reference: Chatwoot's EDITABLE_ATTRS for Channel::Whatsapp func (p *WhatsAppProvider) ConfigSchema() *channel.ConfigSchemaDefinition { return &channel.ConfigSchemaDefinition{ Type: "object", Properties: map[string]channel.ConfigProperty{ "provider": { Type: "string", Description: "WhatsApp provider backend", Enum: []string{"whatsapp_cloud", "360dialog"}, Default: "whatsapp_cloud", }, "phone_number_id": { Type: "string", Description: "WhatsApp Phone Number ID (from Meta Business Manager or 360dialog dashboard)", }, "business_account_id": { Type: "string", Description: "WhatsApp Business Account ID (WABA ID)", }, "access_token": { Type: "string", Description: "Access token for WhatsApp Business API", Secret: true, }, "api_key": { Type: "string", Description: "360dialog API key (only for 360dialog provider)", Secret: true, }, "webhook_verify_token": { Type: "string", Description: "Token for webhook verification (GET hub.verify_token)", }, "webhook_url": { Type: "string", Description: "Webhook URL registered with WhatsApp provider", }, }, Required: []string{"provider", "phone_number_id", "access_token"}, } } // ValidateConfig validates WhatsApp channel configuration. func (p *WhatsAppProvider) ValidateConfig(ctx context.Context, config channel.ChannelConfig) error { provider, ok := config["provider"].(string) if !ok || provider == "" { return fmt.Errorf("provider is required and must be 'whatsapp_cloud' or '360dialog'") } if provider != "whatsapp_cloud" && provider != "360dialog" { return fmt.Errorf("invalid provider: must be 'whatsapp_cloud' or '360dialog'") } phoneNumberID, ok := config["phone_number_id"].(string) if !ok || phoneNumberID == "" { return fmt.Errorf("phone_number_id is required") } accessToken, ok := config["access_token"].(string) if !ok || accessToken == "" { return fmt.Errorf("access_token is required") } // 360dialog-specific: API key required if provider == "360dialog" { apiKey, _ := config["api_key"].(string) if apiKey == "" { return fmt.Errorf("api_key is required for 360dialog provider") } } // WhatsApp Cloud API: webhook verify token required if provider == "whatsapp_cloud" { verifyToken, _ := config["webhook_verify_token"].(string) if verifyToken == "" { return fmt.Errorf("webhook_verify_token is required for WhatsApp Cloud API") } } return nil } // DefaultConfig returns default configuration for WhatsApp channel. func (p *WhatsAppProvider) DefaultConfig() channel.ChannelConfig { return channel.ChannelConfig{ "provider": "whatsapp_cloud", "phone_number_id": "", "business_account_id": "", "access_token": "", "webhook_verify_token": "", "webhook_url": "", } } // === Lifecycle: Create & Destroy === // OnCreate callback after WhatsApp channel creation. // Reference: Chatwoot's after_create :setup_webhook func (p *WhatsAppProvider) OnCreate(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (channel.ChannelConfig, error) { provider, _ := config["provider"].(string) webhookURL := fmt.Sprintf("/webhooks/whatsapp/%d", inbox.ID) config["webhook_url"] = webhookURL // Register webhook with the external provider switch provider { case "whatsapp_cloud": err := p.registerCloudWebhook(ctx, config) if err != nil { applogger.L().Warn("Failed to register WhatsApp Cloud webhook", "error", err, "inbox_id", inbox.ID, ) } case "360dialog": err := p.register360DialogWebhook(ctx, config) if err != nil { applogger.L().Warn("Failed to register 360dialog webhook", "error", err, "inbox_id", inbox.ID, ) } } return config, nil } // OnDestroy callback before WhatsApp channel destruction. // Reference: Chatwoot's after_destroy :delete_webhook func (p *WhatsAppProvider) OnDestroy(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) error { provider, _ := config["provider"].(string) switch provider { case "whatsapp_cloud": return p.unregisterCloudWebhook(ctx, config) case "360dialog": return p.unregister360DialogWebhook(ctx, config) } return nil } // === Messaging: Inbound === // ProcessIncoming transforms raw WhatsApp webhook payload into IncomingMessage. // Reference: Chatwoot's WebhooksController + IncomingMessageService func (p *WhatsAppProvider) ProcessIncoming(ctx context.Context, inbox *model.Inbox, rawPayload []byte) (*channel.IncomingMessage, error) { messages, err := p.ProcessIncomingMessages(ctx, inbox, rawPayload) if err != nil { return nil, err } if len(messages) == 0 { return nil, nil } return messages[0], nil } // ProcessIncomingMessages transforms a raw WhatsApp webhook payload into all normalized messages. func (p *WhatsAppProvider) ProcessIncomingMessages(ctx context.Context, inbox *model.Inbox, rawPayload []byte) ([]*channel.IncomingMessage, error) { event := &WAWebhookEvent{} if err := json.Unmarshal(rawPayload, event); err != nil { return nil, fmt.Errorf("failed to parse WhatsApp webhook payload: %w", err) } messages, err := p.pipeline.Process(ctx, event, inbox) if err != nil { return nil, fmt.Errorf("failed to process WhatsApp incoming pipeline: %w", err) } return messages, nil } // ValidateWebhookRequest verifies WhatsApp webhook callback authenticity. func (p *WhatsAppProvider) ValidateWebhookRequest(ctx context.Context, inbox *model.Inbox, request *channel.WebhookRequest) error { // Parse channel config from JSON string on Inbox var config channel.ChannelConfig if inbox.ChannelConfig != "" { if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { return fmt.Errorf("failed to parse inbox channel_config: %w", err) } } provider, _ := config["provider"].(string) switch provider { case "whatsapp_cloud": signature, ok := request.Headers["X-Hub-Signature-256"] if !ok || signature == "" { return fmt.Errorf("missing X-Hub-Signature-256 header") } accessToken, _ := config["access_token"].(string) if accessToken == "" { return fmt.Errorf("missing access_token for signature verification") } return p.verifyCloudSignature(request.Body, signature, accessToken) case "360dialog": apiKeyHeader, ok := request.Headers["D360-API-KEY"] if !ok || apiKeyHeader == "" { return fmt.Errorf("missing D360-API-KEY header for 360dialog") } expectedAPIKey, _ := config["api_key"].(string) if expectedAPIKey == "" { return fmt.Errorf("missing api_key in channel config for 360dialog verification") } if apiKeyHeader != expectedAPIKey { return fmt.Errorf("invalid D360-API-KEY header") } return nil default: return fmt.Errorf("unknown WhatsApp provider: %s", provider) } } // === Messaging: Outbound === // SendMessage sends a message to the WhatsApp channel. // Reference: Chatwoot's SendOnWhatsappService func (p *WhatsAppProvider) SendMessage(ctx context.Context, inbox *model.Inbox, message *model.Message, contact *model.Contact) (*channel.SendResult, error) { waChannel, err := p.repository.GetByInboxID(ctx, inbox.ID) if err != nil { return nil, fmt.Errorf("failed to get WhatsApp channel config: %w", err) } outgoingPipeline := NewOutgoingPipeline(p.service) return outgoingPipeline.Process(ctx, inbox, message, contact, waChannel) } // === Contact Info === // GetContactProfile fetches a WhatsApp contact's profile info. func (p *WhatsAppProvider) GetContactProfile(ctx context.Context, inbox *model.Inbox, contactSource string) (*channel.ContactProfile, error) { var config channel.ChannelConfig if inbox.ChannelConfig != "" { if err := json.Unmarshal([]byte(inbox.ChannelConfig), &config); err != nil { return nil, fmt.Errorf("failed to parse inbox channel_config: %w", err) } } provider, _ := config["provider"].(string) switch provider { case "whatsapp_cloud": accessToken, _ := config["access_token"].(string) return p.getCloudContactProfile(ctx, contactSource, accessToken) case "360dialog": apiKey, _ := config["api_key"].(string) return p.get360DialogContactProfile(ctx, contactSource, apiKey) default: return nil, fmt.Errorf("unknown provider: %s", provider) } } // === Capability Declaration === // Capabilities returns WhatsApp channel capabilities. func (p *WhatsAppProvider) Capabilities() channel.ChannelCapabilities { return channel.ChannelCapabilities{ SupportsAttachments: true, SupportsLocation: true, SupportsTypingIndicator: false, SupportsDeliveryStatus: true, SupportsReplies: true, SupportsEmojiReactions: true, SupportsVoiceMessages: true, SupportsVideoCalls: false, SupportsCustomCards: false, SupportsTemplates: true, SupportsEmailHeaders: false, MaxAttachmentSize: 16 * 1024 * 1024, MaxTextLength: 4096, } } // === OAuthProvider interface methods === // OAuthConfig returns OAuth configuration requirements. func (p *WhatsAppProvider) OAuthConfig() *channel.OAuthConfigDefinition { return &channel.OAuthConfigDefinition{ Provider: "whatsapp", Scopes: []string{"whatsapp_business_management", "whatsapp_business_messaging"}, AuthorizeURL: "https://www.facebook.com/v18.0/dialog/oauth", TokenURL: "https://graph.facebook.com/v18.0/oauth/access_token", RefreshURL: "https://graph.facebook.com/v18.0/oauth/access_token", RequiresRefresh: true, TokenExpiry: 5184000, // ~60 days for Meta long-lived tokens } } // BuildAuthURL constructs the OAuth authorization redirect URL. func (p *WhatsAppProvider) BuildAuthURL(ctx context.Context, accountID uint, redirectURL string) (string, error) { return fmt.Sprintf( "https://www.facebook.com/v18.0/dialog/oauth?client_id=APP_ID&redirect_uri=%s&state=%d&scope=whatsapp_business_management,whatsapp_business_messaging", redirectURL, accountID, ), nil } // ExchangeToken exchanges OAuth code for access token. func (p *WhatsAppProvider) ExchangeToken(ctx context.Context, code string, redirectURL string) (*channel.OAuthTokenResult, error) { resp, err := p.client.R(). SetFormData(map[string]string{ "client_id": "APP_ID", "client_secret": "APP_SECRET", "grant_type": "authorization_code", "code": code, "redirect_uri": redirectURL, }). Post("https://graph.facebook.com/v18.0/oauth/access_token") if err != nil { return nil, fmt.Errorf("Cloud API token exchange failed: %w", err) } if resp.StatusCode() != http.StatusOK { return nil, fmt.Errorf("Cloud API token exchange returned status %d: %s", resp.StatusCode(), resp.String()) } var tokenResp struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } return &channel.OAuthTokenResult{ AccessToken: tokenResp.AccessToken, ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), }, nil } // RefreshToken refreshes an expired WhatsApp access token. func (p *WhatsAppProvider) RefreshToken(ctx context.Context, inbox *model.Inbox, config channel.ChannelConfig) (*channel.OAuthTokenResult, error) { provider, _ := config["provider"].(string) switch provider { case "whatsapp_cloud": accessToken, _ := config["access_token"].(string) resp, err := p.client.R(). SetQueryParams(map[string]string{ "grant_type": "fb_exchange_token", "client_id": "APP_ID", "client_secret": "APP_SECRET", "fb_exchange_token": accessToken, }). Get("https://graph.facebook.com/v18.0/oauth/access_token") if err != nil { return nil, fmt.Errorf("Cloud API token refresh failed: %w", err) } var tokenResp struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` } if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil { return nil, fmt.Errorf("failed to parse token refresh response: %w", err) } return &channel.OAuthTokenResult{ AccessToken: tokenResp.AccessToken, ExpiresAt: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second), }, nil case "360dialog": // 360dialog API keys are permanent; no refresh needed accessToken, _ := config["access_token"].(string) return &channel.OAuthTokenResult{ AccessToken: accessToken, }, nil default: return nil, fmt.Errorf("unknown provider: %s", provider) } } // CheckAuthorizationError checks if an API call returned an authorization error. func (p *WhatsAppProvider) CheckAuthorizationError(ctx context.Context, apiError error) bool { if apiError == nil { return false } errMsg := apiError.Error() return strings.Contains(errMsg, "190") || strings.Contains(errMsg, "access_token") || strings.Contains(errMsg, "authorization") || strings.Contains(errMsg, "permission") } // OnReauthorization handles reauthorization needs. func (p *WhatsAppProvider) OnReauthorization(ctx context.Context, inbox *model.Inbox) error { applogger.L().Warn("WhatsApp channel requires reauthorization", "inbox_id", inbox.ID, "account_id", inbox.AccountID, ) return nil } // === Private: Cloud API helpers === // verifyCloudSignature verifies HMAC-SHA256 signature for WhatsApp Cloud API webhooks. func (p *WhatsAppProvider) verifyCloudSignature(body []byte, signature string, appSecret string) error { sigHex := strings.TrimPrefix(signature, "sha256=") mac := hmac.New(sha256.New, []byte(appSecret)) mac.Write(body) expectedMAC := hex.EncodeToString(mac.Sum(nil)) if !hmac.Equal([]byte(sigHex), []byte(expectedMAC)) { return fmt.Errorf("webhook signature verification failed") } return nil } // registerCloudWebhook registers the webhook URL with Meta's WhatsApp Business API. func (p *WhatsAppProvider) registerCloudWebhook(ctx context.Context, config channel.ChannelConfig) error { wabaID, _ := config["business_account_id"].(string) accessToken, _ := config["access_token"].(string) webhookURL, _ := config["webhook_url"].(string) verifyToken, _ := config["webhook_verify_token"].(string) if wabaID == "" || accessToken == "" { return fmt.Errorf("business_account_id and access_token required for webhook registration") } resp, err := p.client.R(). SetHeader("Authorization", "Bearer "+accessToken). SetBody(map[string]string{ "callback_url": webhookURL, "verify_token": verifyToken, }). Post(fmt.Sprintf("https://graph.facebook.com/v18.0/%s/subscriptions", wabaID)) if err != nil { return fmt.Errorf("Cloud API webhook registration failed: %w", err) } if resp.StatusCode() != http.StatusOK { return fmt.Errorf("Cloud API webhook registration returned status %d: %s", resp.StatusCode(), resp.String()) } return nil } // unregisterCloudWebhook removes the webhook subscription from Meta's API. func (p *WhatsAppProvider) unregisterCloudWebhook(ctx context.Context, config channel.ChannelConfig) error { wabaID, _ := config["business_account_id"].(string) accessToken, _ := config["access_token"].(string) resp, err := p.client.R(). SetHeader("Authorization", "Bearer "+accessToken). Delete(fmt.Sprintf("https://graph.facebook.com/v18.0/%s/subscriptions", wabaID)) if err != nil { return fmt.Errorf("Cloud API webhook unregistration failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return fmt.Errorf("Cloud API webhook unregistration returned status %d", resp.StatusCode()) } return nil } // getCloudContactProfile fetches a contact profile from WhatsApp Cloud API. func (p *WhatsAppProvider) getCloudContactProfile(ctx context.Context, phone string, accessToken string) (*channel.ContactProfile, error) { resp, err := p.client.R(). SetHeader("Authorization", "Bearer "+accessToken). Get(fmt.Sprintf("https://graph.facebook.com/v18.0/%s", phone)) if err != nil { return nil, fmt.Errorf("failed to fetch WhatsApp Cloud contact profile: %w", err) } var profileResp struct { Name string `json:"name"` } if err := json.Unmarshal(resp.Body(), &profileResp); err != nil { return nil, fmt.Errorf("failed to parse contact profile: %w", err) } return &channel.ContactProfile{ Name: profileResp.Name, Extra: channel.ChannelConfig{ "wa_id": phone, }, }, nil } // === Private: 360dialog helpers === // register360DialogWebhook registers the webhook URL with 360dialog. func (p *WhatsAppProvider) register360DialogWebhook(ctx context.Context, config channel.ChannelConfig) error { apiKey, _ := config["api_key"].(string) webhookURL, _ := config["webhook_url"].(string) if apiKey == "" { return fmt.Errorf("api_key required for 360dialog webhook registration") } resp, err := p.client.R(). SetHeader("D360-API-KEY", apiKey). SetHeader("Content-Type", "application/json"). SetBody(map[string]string{ "webhook_url": webhookURL, }). Post("https://waba.360dialog.io/webhook") if err != nil { return fmt.Errorf("360dialog webhook registration failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusCreated { return fmt.Errorf("360dialog webhook registration returned status %d: %s", resp.StatusCode(), resp.String()) } return nil } // unregister360DialogWebhook removes the webhook registration from 360dialog. func (p *WhatsAppProvider) unregister360DialogWebhook(ctx context.Context, config channel.ChannelConfig) error { apiKey, _ := config["api_key"].(string) resp, err := p.client.R(). SetHeader("D360-API-KEY", apiKey). Delete("https://waba.360dialog.io/webhook") if err != nil { return fmt.Errorf("360dialog webhook unregistration failed: %w", err) } if resp.StatusCode() != http.StatusOK && resp.StatusCode() != http.StatusNoContent { return fmt.Errorf("360dialog webhook unregistration returned status %d", resp.StatusCode()) } return nil } // get360DialogContactProfile fetches a contact profile from 360dialog API. func (p *WhatsAppProvider) get360DialogContactProfile(ctx context.Context, phone string, apiKey string) (*channel.ContactProfile, error) { resp, err := p.client.R(). SetHeader("D360-API-KEY", apiKey). Get(fmt.Sprintf("https://waba.360dialog.io/v1/contacts/%s", phone)) if err != nil { return nil, fmt.Errorf("failed to fetch 360dialog contact profile: %w", err) } var profileResp struct { Name string `json:"name"` } if err := json.Unmarshal(resp.Body(), &profileResp); err != nil { return nil, fmt.Errorf("failed to parse 360dialog contact profile: %w", err) } return &channel.ContactProfile{ Name: profileResp.Name, Extra: channel.ChannelConfig{ "wa_id": phone, }, }, nil }