feat(channels): align whatsapp calls api

This commit is contained in:
2026-06-06 21:49:59 +08:00
parent 4736d67839
commit e730b28bbd
13 changed files with 1111 additions and 17 deletions
@@ -1,9 +1,14 @@
package v1
import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/service"
"github.com/gochat/gochat/pkg/response"
@@ -19,6 +24,136 @@ func NewWhatsAppCallHandler(svc *service.WhatsAppCallService) *WhatsAppCallHandl
return &WhatsAppCallHandler{svc: svc}
}
// Show returns a Chatwoot WhatsApp call payload.
// GET /api/v1/accounts/:account_id/whatsapp_calls/:call_id
func (h *WhatsAppCallHandler) Show(c *gin.Context) {
accountID, callID, ok := h.parseAccountCallParams(c)
if !ok {
return
}
call, err := h.svc.GetAccountCall(c.Request.Context(), accountID, callID)
if err != nil {
handleServiceError(c, err)
return
}
c.JSON(http.StatusOK, serializeWhatsAppAccountCall(call))
}
// Initiate starts an outbound WhatsApp call for a display-ID conversation.
// POST /api/v1/accounts/:account_id/whatsapp_calls/initiate
func (h *WhatsAppCallHandler) Initiate(c *gin.Context) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return
}
var req struct {
ConversationID uint `json:"conversation_id" binding:"required"`
SDPOffer string `json:"sdp_offer"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error())
return
}
result, err := h.svc.Initiate(c.Request.Context(), accountID, service.WhatsAppCallInitiateRequest{
ConversationID: req.ConversationID,
SDPOffer: req.SDPOffer,
AgentID: getUserID(c),
})
if err != nil {
handleWhatsAppCallError(c, err)
return
}
if result.PermissionStatus != "" {
c.JSON(http.StatusUnprocessableEntity, gin.H{"status": result.PermissionStatus})
return
}
c.JSON(http.StatusOK, gin.H{"status": "calling", "call_id": result.Call.ProviderCallID})
}
// Accept forwards an SDP answer to Meta and returns the updated call payload.
// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept
func (h *WhatsAppCallHandler) Accept(c *gin.Context) {
accountID, callID, ok := h.parseAccountCallParams(c)
if !ok {
return
}
var req struct {
SDPAnswer string `json:"sdp_answer"`
}
_ = c.ShouldBindJSON(&req)
call, err := h.svc.Accept(c.Request.Context(), accountID, callID, getUserID(c), req.SDPAnswer)
if err != nil {
handleWhatsAppCallError(c, err)
return
}
c.JSON(http.StatusOK, serializeWhatsAppAccountCall(call))
}
// Reject rejects a ringing WhatsApp call.
// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject
func (h *WhatsAppCallHandler) Reject(c *gin.Context) {
accountID, callID, ok := h.parseAccountCallParams(c)
if !ok {
return
}
call, err := h.svc.Reject(c.Request.Context(), accountID, callID, getUserID(c))
if err != nil {
handleWhatsAppCallError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"id": call.ID, "status": displayWhatsAppStatus(call.Status)})
}
// Terminate terminates an active or ringing WhatsApp call.
// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate
func (h *WhatsAppCallHandler) Terminate(c *gin.Context) {
accountID, callID, ok := h.parseAccountCallParams(c)
if !ok {
return
}
call, err := h.svc.Terminate(c.Request.Context(), accountID, callID, getUserID(c))
if err != nil {
handleWhatsAppCallError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"id": call.ID, "status": displayWhatsAppStatus(call.Status)})
}
// UploadRecording attaches an audio recording to the linked voice_call message.
// POST /api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording
func (h *WhatsAppCallHandler) UploadRecording(c *gin.Context) {
accountID, callID, ok := h.parseAccountCallParams(c)
if !ok {
return
}
file, err := c.FormFile("recording")
if err != nil {
handleWhatsAppCallError(c, service.ErrWhatsAppCallNoRecording)
return
}
status, svcErr := h.svc.UploadRecording(c.Request.Context(), accountID, callID, file.Filename, file.Size)
if svcErr != nil {
handleWhatsAppCallError(c, svcErr)
return
}
c.JSON(http.StatusOK, gin.H{"status": status})
}
func (h *WhatsAppCallHandler) parseAccountCallParams(c *gin.Context) (uint, uint, bool) {
accountID := parseAccountIDParam(c)
if accountID == 0 {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid account id")
return 0, 0, false
}
callID, err := parseUintParam(c, "call_id")
if err != nil {
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid call id")
return 0, 0, false
}
return accountID, callID, true
}
// Get retrieves a WhatsApp call by call_id.
// GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id
func (h *WhatsAppCallHandler) Get(c *gin.Context) {
@@ -137,4 +272,75 @@ func (h *WhatsAppCallHandler) Delete(c *gin.Context) {
}
response.OK(c, gin.H{"message": "deleted"})
}
}
func handleWhatsAppCallError(c *gin.Context, err error) {
status := http.StatusUnprocessableEntity
if errors.Is(err, service.ErrWhatsAppCallSDPOfferRequired) ||
errors.Is(err, service.ErrWhatsAppCallSDPAnswerRequired) ||
errors.Is(err, service.ErrWhatsAppCallContactPhoneRequired) ||
errors.Is(err, service.ErrWhatsAppCallNotEnabled) ||
errors.Is(err, service.ErrWhatsAppCallNoRecording) ||
errors.Is(err, service.ErrWhatsAppCallNoMessage) ||
errors.Is(err, service.ErrWhatsAppCallPermissionRequestFailed) ||
errors.Is(err, service.ErrWhatsAppCallAlreadyAccepted) ||
errors.Is(err, service.ErrWhatsAppCallNotRinging) {
c.JSON(status, gin.H{"error": err.Error()})
return
}
handleServiceError(c, err)
}
func serializeWhatsAppAccountCall(call *model.Call) gin.H {
if call == nil {
return gin.H{}
}
attrs := map[string]any{}
if len(call.AdditionalAttributes) > 0 {
_ = json.Unmarshal(call.AdditionalAttributes, &attrs)
}
elapsed := 0
if call.StartedAt != nil {
elapsed = int(time.Since(*call.StartedAt).Seconds())
}
caller := gin.H{}
if call.Contact.ID != 0 {
caller = gin.H{"name": call.Contact.Name, "phone": call.Contact.PhoneNumber, "avatar": call.Contact.AvatarURL}
}
return gin.H{
"id": call.ID,
"call_id": call.ProviderCallID,
"provider": call.Provider,
"status": displayWhatsAppStatus(call.Status),
"direction": displayWhatsAppDirection(call.Direction),
"conversation_id": call.ConversationID,
"inbox_id": call.InboxID,
"message_id": call.MessageID,
"accepted_by_agent_id": call.AcceptedByAgentID,
"elapsed_seconds": elapsed,
"sdp_offer": attrs["sdp_offer"],
"ice_servers": firstNonNilWhatsAppValue(attrs["ice_servers"], []map[string][]string{{"urls": []string{"stun:stun.l.google.com:19302"}}}),
"caller": caller,
}
}
func displayWhatsAppStatus(status string) string {
return strings.ReplaceAll(status, "_", "-")
}
func displayWhatsAppDirection(direction string) string {
if direction == "incoming" {
return "inbound"
}
if direction == "outgoing" {
return "outbound"
}
return direction
}
func firstNonNilWhatsAppValue(value any, fallback any) any {
if value == nil {
return fallback
}
return value
}
@@ -0,0 +1,152 @@
package v1
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
"github.com/gochat/gochat/internal/service"
)
type fakeHandlerWhatsAppCallProvider struct{}
func (fakeHandlerWhatsAppCallProvider) InitiateCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
return "wacid_handler", nil
}
func (fakeHandlerWhatsAppCallProvider) PreAcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
return nil
}
func (fakeHandlerWhatsAppCallProvider) AcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
return nil
}
func (fakeHandlerWhatsAppCallProvider) RejectCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
return nil
}
func (fakeHandlerWhatsAppCallProvider) TerminateCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
return nil
}
func (fakeHandlerWhatsAppCallProvider) SendCallPermissionRequest(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
return "wamid.req", nil
}
func setupWhatsAppCallHandlerTest(t *testing.T) (*gin.Engine, *gorm.DB, *model.Account, *model.Conversation) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{}))
account := &model.Account{Name: "Voice Account", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
require.NoError(t, db.Create(inbox).Error)
channel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+15550000000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"calling_enabled":true}`}
require.NoError(t, db.Create(channel).Error)
inbox.ChannelID = channel.ID
require.NoError(t, db.Save(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Ada", PhoneNumber: "+15551234567"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(42)
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "whatsapp", Channel: "whatsapp"}
require.NoError(t, db.Create(conversation).Error)
handler := NewWhatsAppCallHandler(service.NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), fakeHandlerWhatsAppCallProvider{}))
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("user_id", uint(7))
c.Next()
})
router.GET("/api/v1/accounts/:account_id/whatsapp_calls/:call_id", handler.Show)
router.POST("/api/v1/accounts/:account_id/whatsapp_calls/initiate", handler.Initiate)
router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/accept", handler.Accept)
router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/reject", handler.Reject)
router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/terminate", handler.Terminate)
router.POST("/api/v1/accounts/:account_id/whatsapp_calls/:call_id/upload_recording", handler.UploadRecording)
return router, db, account, conversation
}
func TestWhatsAppCallHandler_AccountRoutesMatchFrontendAPI(t *testing.T) {
router, db, account, conversation := setupWhatsAppCallHandlerTest(t)
w := httptest.NewRecorder()
body := []byte(`{"conversation_id":42,"sdp_offer":"sdp_offer"}`)
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/initiate", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
resp := whatsappDecodeMap(t, w.Body.Bytes())
require.Equal(t, "calling", resp["status"])
require.Equal(t, "wacid_handler", resp["call_id"])
var call model.Call
require.NoError(t, db.Where("account_id = ? AND conversation_id = ?", account.ID, conversation.ID).First(&call).Error)
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID), nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
show := whatsappDecodeMap(t, w.Body.Bytes())
require.Equal(t, "wacid_handler", show["call_id"])
require.Equal(t, "whatsapp", show["provider"])
}
func TestWhatsAppCallHandler_ActionsAndRecordingPayloads(t *testing.T) {
router, db, account, conversation := setupWhatsAppCallHandlerTest(t)
call := &model.Call{AccountID: account.ID, InboxID: 1, ConversationID: conversation.ID, ContactID: conversation.ContactID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_in", Status: "ringing", CallerType: "Contact", CallerID: conversation.ContactID, CallDirection: "inbound"}
require.NoError(t, db.Create(call).Error)
message := &model.Message{AccountID: account.ID, InboxID: 1, ConversationID: conversation.ID, ContentType: "voice_call", MessageType: "incoming"}
require.NoError(t, db.Create(message).Error)
require.NoError(t, db.Model(call).Update("message_id", message.ID).Error)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/accept", bytes.NewReader([]byte(`{"sdp_answer":"sdp_answer"}`)))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "in-progress", whatsappDecodeMap(t, w.Body.Bytes())["status"])
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/terminate", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "completed", whatsappDecodeMap(t, w.Body.Bytes())["status"])
var upload bytes.Buffer
writer := multipart.NewWriter(&upload)
part, err := writer.CreateFormFile("recording", "call.webm")
require.NoError(t, err)
_, err = part.Write([]byte("audio"))
require.NoError(t, err)
require.NoError(t, writer.Close())
w = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/accounts/1/whatsapp_calls/"+whatsappItoaUint(call.ID)+"/upload_recording", &upload)
req.Header.Set("Content-Type", writer.FormDataContentType())
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "uploaded", whatsappDecodeMap(t, w.Body.Bytes())["status"])
}
func whatsappDecodeMap(t *testing.T, body []byte) map[string]any {
t.Helper()
var out map[string]any
require.NoError(t, json.Unmarshal(body, &out))
return out
}
func whatsappItoaUint(id uint) string {
return strconv.FormatUint(uint64(id), 10)
}
+3 -1
View File
@@ -21,11 +21,13 @@ type Call struct {
Direction string `gorm:"column:direction;size:50" json:"direction,omitempty"`
ProviderCallID string `gorm:"column:provider_call_id;size:255;index:idx_calls_provider_call_id" json:"provider_call_id,omitempty"`
ConferenceSID string `gorm:"column:conference_sid;size:255" json:"conference_sid,omitempty"`
StartedAt *time.Time `gorm:"column:started_at" json:"started_at,omitempty"`
CallerType string `gorm:"size:100;not null" json:"caller_type"` // Contact/User
CallerID uint `json:"caller_id"`
Status string `gorm:"size:50;not null;default:'ringing'" json:"status"` // ringing/ongoing/completed/failed/missed
Duration int `json:"duration"` // seconds
CallDirection string `gorm:"size:50;not null" json:"call_direction"` // inbound/outbound
EndReason string `gorm:"column:end_reason;size:255" json:"end_reason,omitempty"`
CallDirection string `gorm:"size:50;not null" json:"call_direction"` // inbound/outbound
RecordingURL string `gorm:"size:512" json:"recording_url"`
AdditionalAttributes json.RawMessage `gorm:"type:jsonb" json:"additional_attributes"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
+4 -1
View File
@@ -16,6 +16,9 @@ func NewWhatsAppCallRepo(db *gorm.DB) *WhatsAppCallRepo {
return &WhatsAppCallRepo{db: db}
}
// DB returns the underlying GORM handle for Chatwoot call parity queries.
func (r *WhatsAppCallRepo) DB() *gorm.DB { return r.db }
func (r *WhatsAppCallRepo) Create(ctx context.Context, call *model.WhatsAppCall) error {
return r.db.WithContext(ctx).Create(call).Error
}
@@ -41,4 +44,4 @@ func (r *WhatsAppCallRepo) Update(ctx context.Context, call *model.WhatsAppCall)
func (r *WhatsAppCallRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.WhatsAppCall{}, id).Error
}
}
+8
View File
@@ -682,6 +682,14 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
// Bulk actions for conversations/contacts (ref: Chatwoot bulk_actions_controller.rb)
accounts.POST("/:account_id/bulk_actions", h.BulkAction.Create)
// Account WhatsApp calls (ref: Chatwoot enterprise whatsapp_calls_controller.rb)
accounts.GET("/:account_id/whatsapp_calls/:call_id", h.WhatsAppCall.Show)
accounts.POST("/:account_id/whatsapp_calls/initiate", h.WhatsAppCall.Initiate)
accounts.POST("/:account_id/whatsapp_calls/:call_id/accept", h.WhatsAppCall.Accept)
accounts.POST("/:account_id/whatsapp_calls/:call_id/reject", h.WhatsAppCall.Reject)
accounts.POST("/:account_id/whatsapp_calls/:call_id/terminate", h.WhatsAppCall.Terminate)
accounts.POST("/:account_id/whatsapp_calls/:call_id/upload_recording", h.WhatsAppCall.UploadRecording)
// Account members (ref: Chatwoot namespace :account_users)
accounts.GET("/:account_id/users", h.Account.ListUsers)
accounts.POST("/:account_id/users", h.Account.AddUser)
+518 -4
View File
@@ -2,23 +2,59 @@ 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
repo *repository.WhatsAppCallRepo
provider WhatsAppCallProvider
}
// NewWhatsAppCallService creates a new WhatsAppCall service.
func NewWhatsAppCallService(repo *repository.WhatsAppCallRepo) *WhatsAppCallService {
return &WhatsAppCallService{repo: repo}
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"`
@@ -101,4 +137,482 @@ func (s *WhatsAppCallService) DeleteByCallID(ctx context.Context, callID string)
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())
}
@@ -0,0 +1,183 @@
package service
import (
"context"
"errors"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/gochat/gochat/internal/model"
channelmodel "github.com/gochat/gochat/internal/model/channel"
"github.com/gochat/gochat/internal/repository"
)
type fakeWhatsAppCallProvider struct {
initiateID string
permissionRequired bool
permissionMessageID string
preAccepts int
accepts int
rejects int
terminates int
}
func (f *fakeWhatsAppCallProvider) InitiateCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
if f.permissionRequired {
return "", ErrWhatsAppCallPermissionRequired
}
if f.initiateID == "" {
return "wacid_outbound", nil
}
return f.initiateID, nil
}
func (f *fakeWhatsAppCallProvider) PreAcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
f.preAccepts++
return nil
}
func (f *fakeWhatsAppCallProvider) AcceptCall(context.Context, *channelmodel.ChannelWhatsApp, string, string) error {
f.accepts++
return nil
}
func (f *fakeWhatsAppCallProvider) RejectCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
f.rejects++
return nil
}
func (f *fakeWhatsAppCallProvider) TerminateCall(context.Context, *channelmodel.ChannelWhatsApp, string) error {
f.terminates++
return nil
}
func (f *fakeWhatsAppCallProvider) SendCallPermissionRequest(context.Context, *channelmodel.ChannelWhatsApp, string, string) (string, error) {
if f.permissionMessageID == "" {
return "wamid.req_xyz", nil
}
return f.permissionMessageID, nil
}
func setupWhatsAppCallServiceTest(t *testing.T, provider *fakeWhatsAppCallProvider) (*gorm.DB, *WhatsAppCallService, *model.Account, *model.Inbox, *model.Contact, *model.Conversation) {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.Account{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Call{}, &model.Message{}, &model.Attachment{}, &channelmodel.ChannelWhatsApp{}))
account := &model.Account{Name: "Voice Account", Status: "active"}
require.NoError(t, db.Create(account).Error)
inbox := &model.Inbox{AccountID: account.ID, Name: "WhatsApp", ChannelType: "whatsapp", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
require.NoError(t, db.Create(inbox).Error)
waChannel := &channelmodel.ChannelWhatsApp{AccountID: account.ID, InboxID: inbox.ID, PhoneNumber: "+15550000000", PhoneNumberID: "phone-1", BusinessAccountID: "waba-1", AccessToken: "token", Provider: "whatsapp_cloud", ProviderConfig: `{"calling_enabled":true}`}
require.NoError(t, db.Create(waChannel).Error)
inbox.ChannelID = waChannel.ID
require.NoError(t, db.Save(inbox).Error)
contact := &model.Contact{AccountID: account.ID, Name: "Ada", PhoneNumber: "+15551234567"}
require.NoError(t, db.Create(contact).Error)
displayID := uint(42)
conversation := &model.Conversation{AccountID: account.ID, InboxID: inbox.ID, ContactID: contact.ID, DisplayID: &displayID, Status: "open", ChannelType: "whatsapp", Channel: "whatsapp"}
require.NoError(t, db.Create(conversation).Error)
return db, NewWhatsAppCallService(repository.NewWhatsAppCallRepo(db), provider), account, inbox, contact, conversation
}
func TestWhatsAppCallService_InitiateCreatesOutboundCallAndMessage(t *testing.T) {
provider := &fakeWhatsAppCallProvider{initiateID: "wacid_outbound"}
db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider)
result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
require.NoError(t, err)
require.Equal(t, "wacid_outbound", result.Call.ProviderCallID)
var call model.Call
require.NoError(t, db.First(&call, result.Call.ID).Error)
require.Equal(t, "whatsapp", call.Provider)
require.Equal(t, "outgoing", call.Direction)
require.Equal(t, "ringing", call.Status)
require.NotNil(t, call.MessageID)
var message model.Message
require.NoError(t, db.First(&message, *call.MessageID).Error)
require.Equal(t, "voice_call", message.ContentType)
require.Equal(t, "outgoing", message.MessageType)
}
func TestWhatsAppCallService_InitiatePermissionRequestRecordsConversation(t *testing.T) {
provider := &fakeWhatsAppCallProvider{permissionRequired: true, permissionMessageID: "wamid.req_xyz"}
db, svc, account, _, _, conversation := setupWhatsAppCallServiceTest(t, provider)
result, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
require.NoError(t, err)
require.Equal(t, "permission_requested", result.PermissionStatus)
var stored model.Conversation
require.NoError(t, db.First(&stored, conversation.ID).Error)
attrs := conversationAttributes(&stored)
require.Equal(t, "wamid.req_xyz", attrs["call_permission_request_message_id"])
require.NotEmpty(t, attrs["call_permission_requested_at"])
}
func TestWhatsAppCallService_AcceptRejectTerminateAndRecording(t *testing.T) {
provider := &fakeWhatsAppCallProvider{}
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "incoming", ProviderCallID: "wacid_in", Status: "ringing", CallerType: "Contact", CallerID: contact.ID, CallDirection: "inbound"}
require.NoError(t, db.Create(call).Error)
message := &model.Message{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContentType: "voice_call", MessageType: "incoming", Content: "WhatsApp voice call"}
require.NoError(t, db.Create(message).Error)
require.NoError(t, db.Model(call).Update("message_id", message.ID).Error)
accepted, err := svc.Accept(context.Background(), account.ID, call.ID, 8, "sdp_answer")
require.NoError(t, err)
require.Equal(t, "in_progress", accepted.Status)
require.Equal(t, 1, provider.preAccepts)
require.Equal(t, 1, provider.accepts)
require.NotNil(t, accepted.StartedAt)
terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 8)
require.NoError(t, err)
require.Equal(t, "completed", terminated.Status)
require.Equal(t, 1, provider.terminates)
status, err := svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100)
require.NoError(t, err)
require.Equal(t, "uploaded", status)
status, err = svc.UploadRecording(context.Background(), account.ID, call.ID, "call.webm", 100)
require.NoError(t, err)
require.Equal(t, "already_uploaded", status)
}
func TestWhatsAppCallService_InitiateValidatesReferenceGuards(t *testing.T) {
provider := &fakeWhatsAppCallProvider{}
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
_, err := svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, AgentID: 7})
require.True(t, errors.Is(err, ErrWhatsAppCallSDPOfferRequired))
require.NoError(t, db.Model(contact).Update("phone_number", "").Error)
_, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
require.True(t, errors.Is(err, ErrWhatsAppCallContactPhoneRequired))
require.NoError(t, db.Model(contact).Update("phone_number", "+15551234567").Error)
require.NoError(t, db.Model(inbox).Update("channel_config", `{"voice_enabled":false}`).Error)
require.NoError(t, db.Model(&channelmodel.ChannelWhatsApp{}).Where("inbox_id = ?", inbox.ID).Update("provider_config", `{"calling_enabled":false}`).Error)
_, err = svc.Initiate(context.Background(), account.ID, WhatsAppCallInitiateRequest{ConversationID: *conversation.DisplayID, SDPOffer: "sdp_offer", AgentID: 7})
require.True(t, errors.Is(err, ErrWhatsAppCallNotEnabled))
}
func TestWhatsAppCallService_TerminateUsesNoAnswerBeforeProgress(t *testing.T) {
provider := &fakeWhatsAppCallProvider{}
db, svc, account, inbox, contact, conversation := setupWhatsAppCallServiceTest(t, provider)
call := &model.Call{AccountID: account.ID, InboxID: inbox.ID, ConversationID: conversation.ID, ContactID: contact.ID, Provider: "whatsapp", Direction: "outgoing", ProviderCallID: "wacid_out", Status: "ringing", CallerType: "User", CallerID: 7, CallDirection: "outbound", StartedAt: ptrTime(time.Now())}
require.NoError(t, db.Create(call).Error)
terminated, err := svc.Terminate(context.Background(), account.ID, call.ID, 7)
require.NoError(t, err)
require.Equal(t, "no_answer", terminated.Status)
}
func ptrTime(t time.Time) *time.Time { return &t }