feat(channels): align whatsapp calls api
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
Reference in New Issue
Block a user