619 lines
23 KiB
Go
619 lines
23 KiB
Go
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())
|
|
}
|