642 lines
21 KiB
Go
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
|
|
}
|