Files
gochat/backend/internal/service/whatsapp_authorization_service.go
T
rogee aeddedf2a3 Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories:
- backend/: Go module root (cmd, internal, pkg, configs, migrations,
  docs/swagger, scripts, tests, go.mod, Makefile, .air.toml)
- deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd
- docs/: project documentation + reports/ (moved from repo root)
- AGENTS.md: new AI coding-agent guide at repo root

Update all references to the new layout:
- Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root)
- docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile,
  env_file ../../.env, volume mounts ../../backend:/app
- deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile
- CI: working-directory: backend for go commands, file deploy/docker/Dockerfile,
  coverage path backend/coverage.out, health_check backend/scripts/
- backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../
- README: architecture tree, quickstart, config paths updated

Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh,
gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to
preserve history. Build, vet, SQLite tests, and docker compose config verified.
2026-07-07 14:44:12 +08:00

327 lines
12 KiB
Go

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", 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
}