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.
327 lines
12 KiB
Go
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
|
|
}
|