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

642 lines
21 KiB
Go

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
}