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" ) // WhatsAppCallHandler handles WhatsApp voice call API endpoints. type WhatsAppCallHandler struct { svc *service.WhatsAppCallService } // NewWhatsAppCallHandler creates a new WhatsAppCallHandler. func NewWhatsAppCallHandler(svc *service.WhatsAppCallService) *WhatsAppCallHandler { 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) { callID := c.Param("call_id") if callID == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid call_id") return } call, svcErr := h.svc.GetByCallID(c.Request.Context(), callID) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, call) } // List retrieves all WhatsApp calls for a conversation. // GET /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls func (h *WhatsAppCallHandler) List(c *gin.Context) { conversationID, err := parseUintParam(c, "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id") return } calls, svcErr := h.svc.ListByConversation(c.Request.Context(), conversationID) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, gin.H{"whatsapp_calls": calls}) } // Create creates a new WhatsApp call record. // POST /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls func (h *WhatsAppCallHandler) Create(c *gin.Context) { var req struct { CallID string `json:"call_id" binding:"required"` CallStatus string `json:"call_status" binding:"required"` Duration int `json:"duration"` CallerNumber string `json:"caller_number"` InboxID uint `json:"inbox_id" binding:"required"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } conversationID, err := parseUintParam(c, "conversation_id") if err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid conversation_id") return } call := &service.WhatsAppCallCreateRequest{ CallID: req.CallID, InboxID: req.InboxID, ConversationID: conversationID, CallStatus: req.CallStatus, Duration: req.Duration, CallerNumber: req.CallerNumber, } result, svcErr := h.svc.CreateFromRequest(c.Request.Context(), call) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, result) } // Update updates a WhatsApp call (e.g. status transition from ringing -> active -> ended). // PUT /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id func (h *WhatsAppCallHandler) Update(c *gin.Context) { callID := c.Param("call_id") if callID == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid call_id") return } var req struct { CallStatus string `json:"call_status" binding:"required"` Duration int `json:"duration"` } if err := c.ShouldBindJSON(&req); err != nil { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, err.Error()) return } result, svcErr := h.svc.UpdateByCallID(c.Request.Context(), callID, req.CallStatus, req.Duration) if svcErr != nil { handleServiceError(c, svcErr) return } response.OK(c, result) } // Delete removes a WhatsApp call record. // DELETE /api/v1/accounts/:account_id/conversations/:conversation_id/whatsapp_calls/:call_id func (h *WhatsAppCallHandler) Delete(c *gin.Context) { callID := c.Param("call_id") if callID == "" { response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrBadRequest, "invalid call_id") return } svcErr := h.svc.DeleteByCallID(c.Request.Context(), callID) if svcErr != nil { handleServiceError(c, svcErr) return } 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 }