package service import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "os" "strings" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" ) var whatsappAuthorizationHTTPClient = http.DefaultClient type WhatsAppAuthorizationRequest struct { Code string `json:"code" form:"code"` BusinessID string `json:"business_id" form:"business_id"` WabaID string `json:"waba_id" form:"waba_id"` PhoneNumberID string `json:"phone_number_id" form:"phone_number_id"` InboxID *uint `json:"inbox_id" form:"inbox_id"` } type WhatsAppAuthorizationResult struct { Inbox *model.Inbox Message string } type whatsappPhoneInfo struct { PhoneNumberID string PhoneNumber string BusinessName string } // AuthorizeWhatsAppEmbeddedSignup implements Chatwoot's WhatsApp embedded signup // callback endpoint for both new channel creation and reauthorization. func (s *InboxService) AuthorizeWhatsAppEmbeddedSignup(ctx context.Context, accountID uint, req WhatsAppAuthorizationRequest) (*WhatsAppAuthorizationResult, error) { if err := validateWhatsAppAuthorizationParams(req); err != nil { return nil, err } if s == nil || s.repo == nil || s.whatsappRepo == nil { return nil, fmt.Errorf("WhatsApp authorization service is not configured") } if req.InboxID != nil && *req.InboxID > 0 { if _, err := s.repo.FindByAccountAndID(ctx, accountID, *req.InboxID); err != nil { return nil, err } } accessToken, err := exchangeWhatsAppAuthorizationCode(ctx, req.Code) if err != nil { return nil, err } phoneInfo, err := fetchWhatsAppPhoneInfo(ctx, req.WabaID, req.PhoneNumberID, accessToken) if err != nil { return nil, err } if err := validateWhatsAppTokenAccess(ctx, accessToken, req.WabaID); err != nil { return nil, err } if req.InboxID != nil && *req.InboxID > 0 { return s.reauthorizeWhatsAppInbox(ctx, accountID, *req.InboxID, req, accessToken, phoneInfo) } return s.createWhatsAppEmbeddedSignupInbox(ctx, accountID, req, accessToken, phoneInfo) } func validateWhatsAppAuthorizationParams(req WhatsAppAuthorizationRequest) error { missing := make([]string, 0, 3) if strings.TrimSpace(req.Code) == "" { missing = append(missing, "code") } if strings.TrimSpace(req.BusinessID) == "" { missing = append(missing, "business_id") } if strings.TrimSpace(req.WabaID) == "" { missing = append(missing, "waba_id") } if len(missing) > 0 { return fmt.Errorf("Required parameters are missing: %s", strings.Join(missing, ", ")) } return nil } func (s *InboxService) createWhatsAppEmbeddedSignupInbox(ctx context.Context, accountID uint, req WhatsAppAuthorizationRequest, accessToken string, phoneInfo whatsappPhoneInfo) (*WhatsAppAuthorizationResult, error) { if err := s.EnsureCanCreateInbox(ctx, accountID); err != nil { return nil, err } var count int64 if err := s.repo.DB().WithContext(ctx).Model(&channelmodel.ChannelWhatsApp{}).Where("phone_number = ?", phoneInfo.PhoneNumber).Count(&count).Error; err != nil { return nil, err } if count > 0 { return nil, fmt.Errorf("Channel already exists") } inboxName := strings.TrimSpace(phoneInfo.BusinessName) if inboxName == "" { inboxName = "WhatsApp" } else { inboxName += " WhatsApp" } inbox := &model.Inbox{AccountID: accountID, Name: inboxName, ChannelType: "whatsapp", Enabled: true, EnableEmailCollect: true, AllowMessagesAfterResolved: true, SenderNameType: "friendly_name", Timezone: "UTC"} if err := s.repo.Create(ctx, inbox); err != nil { return nil, err } channel := &channelmodel.ChannelWhatsApp{ AccountID: accountID, InboxID: inbox.ID, PhoneNumber: phoneInfo.PhoneNumber, PhoneNumberID: firstNonEmpty(phoneInfo.PhoneNumberID, req.PhoneNumberID), BusinessAccountID: req.WabaID, WhatsAppAccountName: phoneInfo.BusinessName, AccessToken: accessToken, Provider: "whatsapp_cloud", ProviderConfig: marshalInboxJSON(whatsappEmbeddedProviderConfig(accessToken, firstNonEmpty(phoneInfo.PhoneNumberID, req.PhoneNumberID), req.WabaID)), WebhookVerifyToken: generateInboxSecret(), AutoCreateContact: true, } if err := s.whatsappRepo.Create(ctx, channel); err != nil { return nil, err } inbox.ChannelID = channel.ID inbox.ChannelConfig = marshalInboxJSON(whatsappInboxChannelConfig(channel)) if err := s.repo.Update(ctx, inbox); err != nil { return nil, err } _ = s.setupWhatsAppWebhook(ctx, channel, whatsappWebhookCallbackURL(channel.PhoneNumber)) return &WhatsAppAuthorizationResult{Inbox: inbox}, nil } func (s *InboxService) reauthorizeWhatsAppInbox(ctx context.Context, accountID, inboxID uint, req WhatsAppAuthorizationRequest, accessToken string, phoneInfo whatsappPhoneInfo) (*WhatsAppAuthorizationResult, error) { inbox, err := s.repo.FindByAccountAndID(ctx, accountID, inboxID) if err != nil { return nil, err } channel, err := s.whatsappRepo.GetByInboxID(ctx, inbox.ID) if err != nil || channel.Provider != "whatsapp_cloud" { return nil, fmt.Errorf("WhatsApp channel not found") } if phoneInfo.PhoneNumber != "" && channel.PhoneNumber != "" && phoneInfo.PhoneNumber != channel.PhoneNumber { return nil, fmt.Errorf("Phone number mismatch. Expected %s, got %s", channel.PhoneNumber, phoneInfo.PhoneNumber) } channel.AccessToken = accessToken channel.PhoneNumberID = firstNonEmpty(phoneInfo.PhoneNumberID, req.PhoneNumberID) channel.BusinessAccountID = req.BusinessID channel.ProviderConfig = marshalInboxJSON(whatsappEmbeddedProviderConfig(accessToken, channel.PhoneNumberID, req.BusinessID)) channel.ReauthorizationRequired = false if phoneInfo.BusinessName != "" { channel.WhatsAppAccountName = phoneInfo.BusinessName inbox.Name = phoneInfo.BusinessName } if err := s.whatsappRepo.Update(ctx, channel); err != nil { return nil, err } inbox.ChannelConfig = marshalInboxJSON(whatsappInboxChannelConfig(channel)) if err := s.repo.Update(ctx, inbox); err != nil { return nil, err } _ = s.setupWhatsAppWebhook(ctx, channel, whatsappWebhookCallbackURL(channel.PhoneNumber)) return &WhatsAppAuthorizationResult{Inbox: inbox, Message: "Inbox reauthorized successfully"}, nil } func whatsappEmbeddedProviderConfig(accessToken, phoneNumberID, businessAccountID string) map[string]any { return map[string]any{"api_key": accessToken, "phone_number_id": phoneNumberID, "business_account_id": businessAccountID, "source": "embedded_signup"} } func whatsappInboxChannelConfig(channel *channelmodel.ChannelWhatsApp) map[string]any { providerConfig := parseJSONMap(channel.ProviderConfig) return map[string]any{ "phone_number": channel.PhoneNumber, "provider": channel.Provider, "provider_config": providerConfig, "message_templates": []any{}, "reauthorization_required": channel.ReauthorizationRequired, "webhook_verify_token": channel.WebhookVerifyToken, } } func whatsappWebhookCallbackURL(phoneNumber string) string { return strings.TrimRight(envOrDefaultService("FRONTEND_URL", "http://localhost:3000"), "/") + "/webhooks/whatsapp/" + url.PathEscape(phoneNumber) } func exchangeWhatsAppAuthorizationCode(ctx context.Context, code string) (string, error) { endpoint := whatsappGraphAPIBase() + "/" + whatsappAPIVersion() + "/oauth/access_token" params := url.Values{} params.Set("client_id", os.Getenv("WHATSAPP_APP_ID")) params.Set("client_secret", os.Getenv("WHATSAPP_APP_SECRET")) params.Set("code", code) body, err := whatsappGET(ctx, endpoint, params, "Token exchange failed") if err != nil { return "", err } accessToken, _ := body["access_token"].(string) if accessToken == "" { return "", fmt.Errorf("No access token in response") } return accessToken, nil } func fetchWhatsAppPhoneInfo(ctx context.Context, wabaID, requestedPhoneNumberID, accessToken string) (whatsappPhoneInfo, error) { endpoint := whatsappGraphAPIBase() + "/" + whatsappAPIVersion() + "/" + url.PathEscape(wabaID) + "/phone_numbers" params := url.Values{"access_token": []string{accessToken}} body, err := whatsappGET(ctx, endpoint, params, "WABA phone numbers fetch failed") if err != nil { return whatsappPhoneInfo{}, err } items, _ := body["data"].([]any) for _, item := range items { phone, _ := item.(map[string]any) if len(phone) == 0 { continue } if requestedPhoneNumberID == "" || whatsappStringMapValue(phone, "id") == requestedPhoneNumberID { return buildWhatsAppPhoneInfo(phone), nil } } if len(items) > 0 { if phone, _ := items[0].(map[string]any); len(phone) > 0 { return buildWhatsAppPhoneInfo(phone), nil } } return whatsappPhoneInfo{}, fmt.Errorf("No phone numbers found for WABA %s", wabaID) } func validateWhatsAppTokenAccess(ctx context.Context, accessToken, wabaID string) error { endpoint := whatsappGraphAPIBase() + "/" + whatsappAPIVersion() + "/debug_token" params := url.Values{} params.Set("input_token", accessToken) params.Set("access_token", os.Getenv("WHATSAPP_APP_ID")+"|"+os.Getenv("WHATSAPP_APP_SECRET")) body, err := whatsappGET(ctx, endpoint, params, "Token validation failed") if err != nil { return err } data, _ := body["data"].(map[string]any) scopes, _ := data["granular_scopes"].([]any) for _, item := range scopes { scope, _ := item.(map[string]any) if whatsappStringMapValue(scope, "scope") != "whatsapp_business_management" { continue } for _, id := range anySlice(scope["target_ids"]) { if fmt.Sprint(id) == wabaID { return nil } } return fmt.Errorf("Token does not have access to WABA %s", wabaID) } return fmt.Errorf("No WABA scope found in token") } func whatsappGET(ctx context.Context, endpoint string, params url.Values, errorPrefix string) (map[string]any, error) { if len(params) > 0 { endpoint += "?" + params.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err } resp, err := whatsappAuthorizationHTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("%s: %s", errorPrefix, string(body)) } var parsed map[string]any if err := json.Unmarshal(body, &parsed); err != nil { return nil, err } return parsed, nil } func buildWhatsAppPhoneInfo(phone map[string]any) whatsappPhoneInfo { display := sanitizeWhatsAppPhoneNumber(whatsappStringMapValue(phone, "display_phone_number")) name := firstNonEmpty(whatsappStringMapValue(phone, "verified_name"), whatsappStringMapValue(phone, "display_phone_number")) return whatsappPhoneInfo{PhoneNumberID: whatsappStringMapValue(phone, "id"), PhoneNumber: "+" + display, BusinessName: name} } func sanitizeWhatsAppPhoneNumber(phone string) string { replacer := strings.NewReplacer(" ", "", "-", "", "(", "", ")", "", ".", "", "+", "") return strings.TrimSpace(replacer.Replace(phone)) } func whatsappGraphAPIBase() string { return strings.TrimRight(envOrDefaultService("WHATSAPP_GRAPH_API_BASE", "https://graph.facebook.com"), "/") } func whatsappAPIVersion() string { return strings.Trim(strings.TrimSpace(envOrDefaultService("WHATSAPP_API_VERSION", "v22.0")), "/") } func envOrDefaultService(key string, fallback string) string { if value := strings.TrimSpace(os.Getenv(key)); value != "" { return value } return fallback } func parseJSONMap(raw string) map[string]any { var out map[string]any if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil { return map[string]any{} } return out } func whatsappStringMapValue(values map[string]any, key string) string { if values == nil || values[key] == nil { return "" } return fmt.Sprint(values[key]) } func anySlice(value any) []any { if items, ok := value.([]any); ok { return items } return nil }