package service import ( "context" "encoding/json" "errors" "fmt" "strings" "time" "github.com/go-resty/resty/v2" "github.com/gochat/gochat/internal/model" channelmodel "github.com/gochat/gochat/internal/model/channel" "github.com/gochat/gochat/internal/repository" applogger "github.com/gochat/gochat/pkg/logger" "gorm.io/datatypes" "gorm.io/gorm" ) // WhatsAppCallService implements business logic for WhatsAppCall operations. type WhatsAppCallService struct { repo *repository.WhatsAppCallRepo provider WhatsAppCallProvider } // NewWhatsAppCallService creates a new WhatsAppCall service. func NewWhatsAppCallService(repo *repository.WhatsAppCallRepo, providers ...WhatsAppCallProvider) *WhatsAppCallService { provider := WhatsAppCallProvider(defaultWhatsAppCallProvider{client: resty.New()}) if len(providers) > 0 && providers[0] != nil { provider = providers[0] } return &WhatsAppCallService{repo: repo, provider: provider} } // WhatsAppCallProvider wraps Meta WhatsApp Calling operations behind a fakeable boundary. type WhatsAppCallProvider interface { InitiateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, sdpOffer string) (string, error) PreAcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error AcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error RejectCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error TerminateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error SendCallPermissionRequest(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, body string) (string, error) } var ( ErrWhatsAppCallNotEnabled = errors.New("WhatsApp calling is not enabled for this inbox") ErrWhatsAppCallSDPOfferRequired = errors.New("sdp_offer is required") ErrWhatsAppCallSDPAnswerRequired = errors.New("sdp_answer is required") ErrWhatsAppCallContactPhoneRequired = errors.New("Contact phone number is required") ErrWhatsAppCallNoRecording = errors.New("recording is required") ErrWhatsAppCallNoMessage = errors.New("Call message not found") ErrWhatsAppCallPermissionRequired = errors.New("WhatsApp call permission required") ErrWhatsAppCallPermissionRequestFailed = errors.New("Failed to send WhatsApp call permission request") ErrWhatsAppCallAlreadyAccepted = errors.New("Call already accepted by another agent") ErrWhatsAppCallNotRinging = errors.New("Call is not in ringing state") ) // WhatsAppCallCreateRequest is the DTO for creating a WhatsApp call. type WhatsAppCallCreateRequest struct { CallID string `json:"call_id" validate:"required"` InboxID uint `json:"inbox_id" validate:"required"` ConversationID uint `json:"conversation_id" validate:"required"` CallStatus string `json:"call_status" validate:"required"` Duration int `json:"duration"` CallerNumber string `json:"caller_number"` } // validCallStatuses defines allowed WhatsApp call status values. var validCallStatuses = map[string]bool{"ringing": true, "active": true, "ended": true, "failed": true} // GetByCallID retrieves a WhatsApp call by its call ID. func (s *WhatsAppCallService) GetByCallID(ctx context.Context, callID string) (*model.WhatsAppCall, error) { call, err := s.repo.FindByCallID(ctx, callID) if err != nil { applogger.L().Errorf("WhatsAppCallService.GetByCallID error: %v", err) return nil, err } return call, nil } // ListByConversation retrieves all WhatsApp calls for a conversation. func (s *WhatsAppCallService) ListByConversation(ctx context.Context, conversationID uint) ([]model.WhatsAppCall, error) { calls, err := s.repo.FindByConversationID(ctx, conversationID) if err != nil { applogger.L().Errorf("WhatsAppCallService.ListByConversation error: %v", err) return nil, err } return calls, nil } // CreateFromRequest creates a new WhatsApp call record from a DTO. func (s *WhatsAppCallService) CreateFromRequest(ctx context.Context, req *WhatsAppCallCreateRequest) (*model.WhatsAppCall, error) { if !validCallStatuses[req.CallStatus] { return nil, errors.New("invalid call_status: must be ringing/active/ended/failed") } call := &model.WhatsAppCall{ CallID: req.CallID, InboxID: req.InboxID, ConversationID: req.ConversationID, CallStatus: req.CallStatus, Duration: req.Duration, CallerNumber: req.CallerNumber, } if err := s.repo.Create(ctx, call); err != nil { applogger.L().Errorf("WhatsAppCallService.CreateFromRequest error: %v", err) return nil, err } return call, nil } // UpdateByCallID updates a WhatsApp call by its call_id (e.g. status transition). func (s *WhatsAppCallService) UpdateByCallID(ctx context.Context, callID string, callStatus string, duration int) (*model.WhatsAppCall, error) { if !validCallStatuses[callStatus] { return nil, errors.New("invalid call_status: must be ringing/active/ended/failed") } call, err := s.repo.FindByCallID(ctx, callID) if err != nil { return nil, err } call.CallStatus = callStatus call.Duration = duration if err := s.repo.Update(ctx, call); err != nil { applogger.L().Errorf("WhatsAppCallService.UpdateByCallID error: %v", err) return nil, err } return call, nil } // DeleteByCallID deletes a WhatsApp call record by call_id. func (s *WhatsAppCallService) DeleteByCallID(ctx context.Context, callID string) error { call, err := s.repo.FindByCallID(ctx, callID) if err != nil { return err } if err := s.repo.Delete(ctx, call.ID); err != nil { applogger.L().Errorf("WhatsAppCallService.DeleteByCallID error: %v", err) return err } return nil } // GetAccountCall returns a Chatwoot account-scoped WhatsApp Call row. func (s *WhatsAppCallService) GetAccountCall(ctx context.Context, accountID, callID uint) (*model.Call, error) { return s.findAccountCall(ctx, accountID, callID) } type WhatsAppCallInitiateRequest struct { ConversationID uint SDPOffer string AgentID uint } type WhatsAppCallInitiateResult struct { Call *model.Call PermissionStatus string PermissionMessage string } // Initiate creates an outbound WhatsApp Call and linked voice_call message. // Reference: Enterprise WhatsappCallsController#initiate. func (s *WhatsAppCallService) Initiate(ctx context.Context, accountID uint, req WhatsAppCallInitiateRequest) (*WhatsAppCallInitiateResult, error) { if strings.TrimSpace(req.SDPOffer) == "" { return nil, ErrWhatsAppCallSDPOfferRequired } var conversation model.Conversation db := s.repo.DB().WithContext(ctx) if err := db.Where("account_id = ? AND display_id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { if err := db.Where("account_id = ? AND id = ?", accountID, req.ConversationID).First(&conversation).Error; err != nil { return nil, err } } inbox, contact, channel, err := s.loadCallContext(ctx, accountID, conversation.InboxID, conversation.ContactID) if err != nil { return nil, err } if !whatsAppCallingEnabled(inbox, channel) { return nil, ErrWhatsAppCallNotEnabled } if strings.TrimSpace(contact.PhoneNumber) == "" { return nil, ErrWhatsAppCallContactPhoneRequired } toNumber := strings.TrimPrefix(strings.TrimSpace(contact.PhoneNumber), "+") providerCallID, err := s.provider.InitiateCall(ctx, channel, toNumber, req.SDPOffer) if err != nil { if errors.Is(err, ErrWhatsAppCallPermissionRequired) { return s.handlePermissionRequest(ctx, &conversation, inbox, channel, toNumber) } return nil, err } var call model.Call err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { attrs := map[string]any{"sdp_offer": req.SDPOffer, "ice_servers": defaultWhatsAppIceServers()} encodedAttrs, _ := json.Marshal(attrs) call = model.Call{ AccountID: accountID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, AcceptedByAgentID: &req.AgentID, Provider: "whatsapp", Direction: "outgoing", ProviderCallID: providerCallID, Status: "ringing", CallerType: "User", CallerID: req.AgentID, CallDirection: "outbound", AdditionalAttributes: json.RawMessage(encodedAttrs), } if err := tx.Create(&call).Error; err != nil { return err } message, err := createWhatsAppCallMessage(tx, &call, req.AgentID) if err != nil { return err } messageID := message.ID call.MessageID = &messageID return tx.Model(&call).Update("message_id", messageID).Error }) if err != nil { return nil, err } return &WhatsAppCallInitiateResult{Call: &call}, nil } func (s *WhatsAppCallService) Accept(ctx context.Context, accountID, callID, agentID uint, sdpAnswer string) (*model.Call, error) { if strings.TrimSpace(sdpAnswer) == "" { return nil, ErrWhatsAppCallSDPAnswerRequired } call, err := s.findAccountCall(ctx, accountID, callID) if err != nil { return nil, err } _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) if err != nil { return nil, err } if call.Status == "in_progress" { return nil, ErrWhatsAppCallAlreadyAccepted } if call.Status != "ringing" { return nil, ErrWhatsAppCallNotRinging } if err := s.provider.PreAcceptCall(ctx, channel, call.ProviderCallID, sdpAnswer); err != nil { return nil, err } if err := s.provider.AcceptCall(ctx, channel, call.ProviderCallID, sdpAnswer); err != nil { return nil, err } now := time.Now() err = s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { attrs := callAttributes(call) attrs["sdp_answer"] = sdpAnswer encodedAttrs, _ := json.Marshal(attrs) call.Status = "in_progress" call.AcceptedByAgentID = &agentID call.StartedAt = &now call.AdditionalAttributes = json.RawMessage(encodedAttrs) if err := tx.Save(call).Error; err != nil { return err } return updateWhatsAppCallMessageAndConversation(tx, call, "in_progress", agentID, nil) }) return call, err } func (s *WhatsAppCallService) Reject(ctx context.Context, accountID, callID, agentID uint) (*model.Call, error) { call, err := s.findAccountCall(ctx, accountID, callID) if err != nil { return nil, err } _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) if err != nil { return nil, err } if call.Status == "ringing" { if err := s.provider.RejectCall(ctx, channel, call.ProviderCallID); err != nil { return nil, err } err = s.finalize(ctx, call, "failed", "agent_rejected", agentID, nil) } return call, err } func (s *WhatsAppCallService) Terminate(ctx context.Context, accountID, callID, agentID uint) (*model.Call, error) { call, err := s.findAccountCall(ctx, accountID, callID) if err != nil { return nil, err } _, _, channel, err := s.loadCallContext(ctx, accountID, call.InboxID, call.ContactID) if err != nil { return nil, err } if isTerminalWhatsAppCall(call.Status) { return call, nil } if err := s.provider.TerminateCall(ctx, channel, call.ProviderCallID); err != nil { return nil, err } status := "no_answer" var duration *int if call.Status == "in_progress" { status = "completed" seconds := 0 if call.StartedAt != nil { seconds = int(time.Since(*call.StartedAt).Seconds()) } duration = &seconds } err = s.finalize(ctx, call, status, "agent_hangup", agentID, duration) return call, err } func (s *WhatsAppCallService) UploadRecording(ctx context.Context, accountID, callID uint, fileName string, fileSize int64) (string, error) { call, err := s.findAccountCall(ctx, accountID, callID) if err != nil { return "", err } if call.MessageID == nil || *call.MessageID == 0 { return "", ErrWhatsAppCallNoMessage } if fileName == "" { return "", ErrWhatsAppCallNoRecording } var existing int64 if err := s.repo.DB().WithContext(ctx).Model(&model.Attachment{}). Where("message_id = ? AND file_type = ?", *call.MessageID, "audio").Count(&existing).Error; err != nil { return "", err } if existing > 0 { return "already_uploaded", nil } attachment := &model.Attachment{MessageID: *call.MessageID, AccountID: accountID, FileType: "audio", FileName: fileName, FileSize: int(fileSize), FileURL: fileName} if err := s.repo.DB().WithContext(ctx).Create(attachment).Error; err != nil { return "", err } return "uploaded", nil } func (s *WhatsAppCallService) finalize(ctx context.Context, call *model.Call, status, reason string, agentID uint, duration *int) error { return s.repo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { attrs := callAttributes(call) attrs["ended_at"] = time.Now().Unix() encodedAttrs, _ := json.Marshal(attrs) call.Status = status call.AdditionalAttributes = json.RawMessage(encodedAttrs) call.AcceptedByAgentID = firstUintPtr(call.AcceptedByAgentID, agentID) call.EndReason = reason if duration != nil { call.Duration = *duration } if err := tx.Save(call).Error; err != nil { return err } return updateWhatsAppCallMessageAndConversation(tx, call, status, agentID, duration) }) } func (s *WhatsAppCallService) handlePermissionRequest(ctx context.Context, conversation *model.Conversation, inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp, toNumber string) (*WhatsAppCallInitiateResult, error) { body := whatsappPermissionRequestBody(channel) messageID, err := s.provider.SendCallPermissionRequest(ctx, channel, toNumber, body) if err != nil || messageID == "" { return nil, ErrWhatsAppCallPermissionRequestFailed } attrs := conversationAttributes(conversation) attrs["call_permission_requested_at"] = time.Now().Format(time.RFC3339) attrs["call_permission_request_message_id"] = messageID encodedAttrs, _ := json.Marshal(attrs) if err := s.repo.DB().WithContext(ctx).Model(conversation).Update("additional_attributes", datatypes.JSON(encodedAttrs)).Error; err != nil { return nil, err } return &WhatsAppCallInitiateResult{PermissionStatus: "permission_requested"}, nil } func (s *WhatsAppCallService) findAccountCall(ctx context.Context, accountID, callID uint) (*model.Call, error) { var call model.Call err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND provider = ? AND id = ?", accountID, "whatsapp", callID).First(&call).Error if err != nil { return nil, err } return &call, nil } func (s *WhatsAppCallService) loadCallContext(ctx context.Context, accountID, inboxID, contactID uint) (*model.Inbox, *model.Contact, *channelmodel.ChannelWhatsApp, error) { var inbox model.Inbox if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, inboxID).First(&inbox).Error; err != nil { return nil, nil, nil, err } var contact model.Contact if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, contactID).First(&contact).Error; err != nil { return nil, nil, nil, err } var channel channelmodel.ChannelWhatsApp if err := s.repo.DB().WithContext(ctx).Where("account_id = ? AND inbox_id = ?", accountID, inboxID).First(&channel).Error; err != nil { return nil, nil, nil, ErrWhatsAppCallNotEnabled } return &inbox, &contact, &channel, nil } func createWhatsAppCallMessage(tx *gorm.DB, call *model.Call, agentID uint) (*model.Message, error) { attrs := map[string]any{"data": map[string]any{"call_id": call.ID, "call_sid": call.ProviderCallID, "call_source": "whatsapp", "call_direction": "outbound", "status": displayWhatsAppCallStatus(call.Status)}} encodedAttrs, _ := json.Marshal(attrs) message := &model.Message{ ConversationID: call.ConversationID, AccountID: call.AccountID, InboxID: call.InboxID, SenderID: &agentID, SenderType: "user", Content: "WhatsApp voice call", ContentType: "voice_call", Status: "sent", MessageType: "outgoing", ContentAttributes: datatypes.JSON(encodedAttrs), } return message, tx.Create(message).Error } func updateWhatsAppCallMessageAndConversation(tx *gorm.DB, call *model.Call, status string, agentID uint, duration *int) error { if call.MessageID != nil && *call.MessageID != 0 { var message model.Message if err := tx.First(&message, *call.MessageID).Error; err == nil { attrs := messageAttributes(&message) data, _ := attrs["data"].(map[string]any) if data == nil { data = map[string]any{} } data["status"] = displayWhatsAppCallStatus(status) if agentID != 0 { data["accepted_by"] = map[string]any{"id": agentID} } if duration != nil { data["duration_seconds"] = *duration } attrs["data"] = data encodedAttrs, _ := json.Marshal(attrs) if err := tx.Model(&message).Update("content_attributes", datatypes.JSON(encodedAttrs)).Error; err != nil { return err } } } var conversation model.Conversation if err := tx.First(&conversation, call.ConversationID).Error; err != nil { return err } attrs := conversationAttributes(&conversation) attrs["call_status"] = displayWhatsAppCallStatus(status) encodedAttrs, _ := json.Marshal(attrs) return tx.Model(&conversation).Update("additional_attributes", datatypes.JSON(encodedAttrs)).Error } func firstUintPtr(existing *uint, fallback uint) *uint { if existing != nil { return existing } return &fallback } func callAttributes(call *model.Call) map[string]any { attrs := map[string]any{} if len(call.AdditionalAttributes) > 0 { _ = json.Unmarshal(call.AdditionalAttributes, &attrs) } return attrs } func messageAttributes(message *model.Message) map[string]any { attrs := map[string]any{} if len(message.ContentAttributes) > 0 { _ = json.Unmarshal(message.ContentAttributes, &attrs) } return attrs } func conversationAttributes(conversation *model.Conversation) map[string]any { attrs := map[string]any{} if len(conversation.AdditionalAttributes) > 0 { _ = json.Unmarshal(conversation.AdditionalAttributes, &attrs) } return attrs } func whatsAppCallingEnabled(inbox *model.Inbox, channel *channelmodel.ChannelWhatsApp) bool { if inbox.ChannelType != "whatsapp" || !channel.IsCloudAPI() { return false } inboxConfig := map[string]any{} _ = json.Unmarshal([]byte(inbox.ChannelConfig), &inboxConfig) if boolValue(inboxConfig["voice_enabled"]) { return true } providerConfig := map[string]any{} _ = json.Unmarshal([]byte(channel.ProviderConfig), &providerConfig) return boolValue(providerConfig["calling_enabled"]) } func boolValue(value any) bool { switch v := value.(type) { case bool: return v case string: return strings.EqualFold(v, "true") default: return false } } func displayWhatsAppCallStatus(status string) string { return strings.ReplaceAll(status, "_", "-") } func displayWhatsAppCallDirection(direction string) string { if direction == "incoming" { return "inbound" } if direction == "outgoing" { return "outbound" } return direction } func isTerminalWhatsAppCall(status string) bool { return status == "completed" || status == "no_answer" || status == "failed" } func defaultWhatsAppIceServers() []map[string][]string { return []map[string][]string{{"urls": []string{"stun:stun.l.google.com:19302"}}} } func whatsappPermissionRequestBody(channel *channelmodel.ChannelWhatsApp) string { providerConfig := map[string]any{} _ = json.Unmarshal([]byte(channel.ProviderConfig), &providerConfig) if body, ok := providerConfig["call_permission_request_body"].(string); ok { return body } return "Please allow WhatsApp calls from this business." } type defaultWhatsAppCallProvider struct { client *resty.Client } func (p defaultWhatsAppCallProvider) InitiateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, sdpOffer string) (string, error) { resp, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "to": toNumber, "action": "connect", "session": map[string]any{"sdp_type": "offer", "sdp": sdpOffer}}) if err != nil { return "", err } if calls, ok := resp["calls"].([]any); ok && len(calls) > 0 { if first, ok := calls[0].(map[string]any); ok { if id, ok := first["id"].(string); ok { return id, nil } } } if id, ok := resp["call_id"].(string); ok { return id, nil } return "", fmt.Errorf("Meta initiate_call failed") } func (p defaultWhatsAppCallProvider) PreAcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error { _, err := p.call(ctx, channel, p.callActionBody(providerCallID, "pre_accept", sdpAnswer)) return err } func (p defaultWhatsAppCallProvider) AcceptCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string, sdpAnswer string) error { _, err := p.call(ctx, channel, p.callActionBody(providerCallID, "accept", sdpAnswer)) return err } func (p defaultWhatsAppCallProvider) RejectCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error { _, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "call_id": providerCallID, "action": "reject"}) return err } func (p defaultWhatsAppCallProvider) TerminateCall(ctx context.Context, channel *channelmodel.ChannelWhatsApp, providerCallID string) error { _, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "call_id": providerCallID, "action": "terminate"}) return err } func (p defaultWhatsAppCallProvider) SendCallPermissionRequest(ctx context.Context, channel *channelmodel.ChannelWhatsApp, toNumber string, body string) (string, error) { resp, err := p.call(ctx, channel, map[string]any{"messaging_product": "whatsapp", "to": toNumber, "type": "text", "text": map[string]any{"body": body}}) if err != nil { return "", err } if messages, ok := resp["messages"].([]any); ok && len(messages) > 0 { if first, ok := messages[0].(map[string]any); ok { if id, ok := first["id"].(string); ok { return id, nil } } } return "", nil } func (p defaultWhatsAppCallProvider) callActionBody(callID string, action string, sdpAnswer string) map[string]any { return map[string]any{"messaging_product": "whatsapp", "call_id": callID, "action": action, "session": map[string]any{"sdp_type": "answer", "sdp": sdpAnswer}} } func (p defaultWhatsAppCallProvider) call(ctx context.Context, channel *channelmodel.ChannelWhatsApp, body map[string]any) (map[string]any, error) { url := fmt.Sprintf("%s/%s/%s/calls", whatsappGraphAPIBase(), whatsappAPIVersion(), channel.PhoneNumberID) resp, err := p.client.R().SetContext(ctx).SetAuthToken(channel.AccessToken).SetBody(body).Post(url) if err != nil { return nil, err } if resp.StatusCode() >= 200 && resp.StatusCode() < 300 { out := map[string]any{} _ = json.Unmarshal(resp.Body(), &out) return out, nil } if strings.Contains(string(resp.Body()), "138006") { return nil, ErrWhatsAppCallPermissionRequired } return nil, fmt.Errorf("Meta WhatsApp call API returned status %d", resp.StatusCode()) }