feat(contacts): initiate voice calls
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
"github.com/gochat/gochat/internal/repository"
|
||||
@@ -288,6 +290,48 @@ func (h *ContactHandler) Delete(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (h *ContactHandler) InitiateCall(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid account id"})
|
||||
return
|
||||
}
|
||||
contactID, err := parseUintParam(c, "contact_id")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid contact id"})
|
||||
return
|
||||
}
|
||||
userID := getUserID(c)
|
||||
if userID == 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
InboxID uint `json:"inbox_id" binding:"required"`
|
||||
ConversationID *uint `json:"conversation_id"`
|
||||
}
|
||||
if bindErr := c.ShouldBindJSON(&body); bindErr != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": bindErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.InitiateCall(c.Request.Context(), accountID, contactID, service.InitiateContactCallRequest{
|
||||
InboxID: body.InboxID,
|
||||
ConversationID: body.ConversationID,
|
||||
UserID: userID,
|
||||
})
|
||||
if svcErr != nil {
|
||||
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "resource not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *ContactHandler) DeleteAvatar(c *gin.Context) {
|
||||
accountID := parseAccountIDParam(c)
|
||||
if accountID == 0 {
|
||||
|
||||
@@ -57,6 +57,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
&model.Notification{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.Call{},
|
||||
&model.Attachment{},
|
||||
&model.ContactInbox{},
|
||||
&model.InboxMember{},
|
||||
@@ -96,6 +97,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupSuite() {
|
||||
s.router.PUT("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Update)
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id", s.handler.Delete)
|
||||
s.router.DELETE("/api/v1/accounts/:id/contacts/:contact_id/avatar", s.handler.DeleteAvatar)
|
||||
s.router.POST("/api/v1/accounts/:id/contacts/:contact_id/call", s.handler.InitiateCall)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/conversations", s.handler.ListConversations)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/attachments", s.handler.ListAttachments)
|
||||
s.router.GET("/api/v1/accounts/:id/contacts/:contact_id/labels", s.handler.ListLabels)
|
||||
@@ -139,6 +141,7 @@ func (s *ContactHandlerCRUDTestSuite) SetupTest() {
|
||||
s.db.Exec("DELETE FROM data_imports")
|
||||
s.db.Exec("DELETE FROM notifications")
|
||||
s.db.Exec("DELETE FROM attachments")
|
||||
s.db.Exec("DELETE FROM calls")
|
||||
s.db.Exec("DELETE FROM messages")
|
||||
s.db.Exec("DELETE FROM notes")
|
||||
s.db.Exec("DELETE FROM contact_inboxes")
|
||||
@@ -976,6 +979,161 @@ func (s *ContactHandlerCRUDTestSuite) TestListContactInboxes_EmptyResult() {
|
||||
s.Equal(float64(0), meta["count"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_CreatesConversationCallAndVoiceMessage() {
|
||||
inbox := s.createVoiceInbox(true, true)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
|
||||
s.Require().Equal(http.StatusOK, w.Code, w.Body.String())
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Equal(float64(inbox.ID), resp["inbox_id"])
|
||||
s.NotEmpty(resp["call_sid"])
|
||||
s.NotEmpty(resp["conference_sid"])
|
||||
|
||||
var conversation model.Conversation
|
||||
s.Require().NoError(s.db.Where("account_id = ? AND display_id = ?", s.account.ID, uint(resp["conversation_id"].(float64))).First(&conversation).Error)
|
||||
s.Equal(inbox.ID, conversation.InboxID)
|
||||
s.Equal(s.contact.ID, conversation.ContactID)
|
||||
s.Equal("open", conversation.Status)
|
||||
s.NotNil(conversation.ContactInboxID)
|
||||
|
||||
var contactInbox model.ContactInbox
|
||||
s.Require().NoError(s.db.Where("contact_id = ? AND inbox_id = ?", s.contact.ID, inbox.ID).First(&contactInbox).Error)
|
||||
s.Equal(s.contact.PhoneNumber, contactInbox.SourceID)
|
||||
|
||||
var call model.Call
|
||||
s.Require().NoError(s.db.Where("conversation_id = ?", conversation.ID).First(&call).Error)
|
||||
s.Equal("twilio", call.Provider)
|
||||
s.Equal("outgoing", call.Direction)
|
||||
s.Equal("outbound", call.CallDirection)
|
||||
s.Equal("ringing", call.Status)
|
||||
s.NotNil(call.MessageID)
|
||||
s.Equal(resp["call_sid"], call.ProviderCallID)
|
||||
s.Equal(resp["conference_sid"], call.ConferenceSID)
|
||||
|
||||
var message model.Message
|
||||
s.Require().NoError(s.db.First(&message, *call.MessageID).Error)
|
||||
s.Equal("voice_call", message.ContentType)
|
||||
s.Equal("outgoing", message.MessageType)
|
||||
s.Equal(call.ID, uint(jsonNumberAt(s.T(), message.ContentAttributes, "data", "call_id")))
|
||||
s.Equal(call.ProviderCallID, jsonStringAt(s.T(), message.ContentAttributes, "data", "call_sid"))
|
||||
s.Equal("twilio", jsonStringAt(s.T(), message.ContentAttributes, "data", "call_source"))
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_ReusesOnlyMatchingOpenConversation() {
|
||||
inbox := s.createVoiceInbox(true, true)
|
||||
contactInbox := &model.ContactInbox{ContactID: s.contact.ID, InboxID: inbox.ID, SourceID: s.contact.PhoneNumber}
|
||||
s.Require().NoError(s.db.Create(contactInbox).Error)
|
||||
openConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "open", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
resolvedConversation := &model.Conversation{AccountID: s.account.ID, InboxID: inbox.ID, ContactID: s.contact.ID, ContactInboxID: &contactInbox.ID, Status: "resolved", Priority: "low", ChannelType: inbox.ChannelType, Channel: inbox.ChannelType}
|
||||
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), openConversation))
|
||||
s.Require().NoError(repository.NewConversationRepo(s.db).Create(s.T().Context(), resolvedConversation))
|
||||
|
||||
body := []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *openConversation.DisplayID))
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.Equal(float64(*openConversation.DisplayID), resp["conversation_id"])
|
||||
|
||||
body = []byte(fmt.Sprintf(`{"inbox_id":%d,"conversation_id":%d}`, inbox.ID, *resolvedConversation.DisplayID))
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusOK, w.Code)
|
||||
s.NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
s.NotEqual(float64(*resolvedConversation.DisplayID), resp["conversation_id"])
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RejectsInvalidVoiceInputs() {
|
||||
inbox := s.createVoiceInbox(false, true)
|
||||
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
|
||||
assignedVoiceInbox := s.createVoiceInbox(true, true)
|
||||
s.Require().NoError(s.db.Model(s.contact).Update("phone_number", "").Error)
|
||||
body = []byte(fmt.Sprintf(`{"inbox_id":%d}`, assignedVoiceInbox.ID))
|
||||
w = httptest.NewRecorder()
|
||||
req, _ = http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) TestInitiateCall_RequiresAssignedInbox() {
|
||||
inbox := s.createVoiceInbox(true, false)
|
||||
body := []byte(fmt.Sprintf(`{"inbox_id":%d}`, inbox.ID))
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/contacts/%d/call", s.account.ID, s.contact.ID), bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
s.router.ServeHTTP(w, req)
|
||||
s.Equal(http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func (s *ContactHandlerCRUDTestSuite) createVoiceInbox(voiceEnabled bool, assigned bool) *model.Inbox {
|
||||
inbox := &model.Inbox{
|
||||
AccountID: s.account.ID,
|
||||
Name: fmt.Sprintf("Voice Inbox %d", time.Now().UnixNano()),
|
||||
ChannelType: string(model.InboxChannelTypeTwilioSMS),
|
||||
ChannelID: uint(time.Now().UnixNano()),
|
||||
ChannelConfig: fmt.Sprintf(`{"voice_enabled":%t}`, voiceEnabled),
|
||||
}
|
||||
s.Require().NoError(s.db.Create(inbox).Error)
|
||||
if assigned {
|
||||
s.Require().NoError(s.db.Create(&model.InboxMember{InboxID: inbox.ID, UserID: s.user.ID, Role: "agent"}).Error)
|
||||
}
|
||||
return inbox
|
||||
}
|
||||
|
||||
func jsonStringAt(t *testing.T, raw []byte, path ...string) string {
|
||||
t.Helper()
|
||||
value := jsonValueAt(t, raw, path...)
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
t.Fatalf("expected string at %v, got %T", path, value)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func jsonNumberAt(t *testing.T, raw []byte, path ...string) float64 {
|
||||
t.Helper()
|
||||
value := jsonValueAt(t, raw, path...)
|
||||
number, ok := value.(float64)
|
||||
if !ok {
|
||||
t.Fatalf("expected number at %v, got %T", path, value)
|
||||
}
|
||||
return number
|
||||
}
|
||||
|
||||
func jsonValueAt(t *testing.T, raw []byte, path ...string) any {
|
||||
t.Helper()
|
||||
var value any
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
for _, key := range path {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected object before %s, got %T", key, value)
|
||||
}
|
||||
value = object[key]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// ListConversations
|
||||
// ===========================
|
||||
|
||||
+27
-15
@@ -10,22 +10,34 @@ import (
|
||||
// Call represents a voice call session.
|
||||
// Reference: Chatwoot Call model + P2B M11 spec
|
||||
type Call struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
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
|
||||
RecordingURL string `gorm:"size:512" json:"recording_url"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AccountID uint `gorm:"not null;index" json:"account_id"`
|
||||
InboxID uint `gorm:"index" json:"inbox_id,omitempty"`
|
||||
ConversationID uint `gorm:"not null;index" json:"conversation_id"`
|
||||
ContactID uint `gorm:"index" json:"contact_id,omitempty"`
|
||||
MessageID *uint `gorm:"column:message_id;index" json:"message_id,omitempty"`
|
||||
AcceptedByAgentID *uint `gorm:"column:accepted_by_agent_id;index" json:"accepted_by_agent_id,omitempty"`
|
||||
Provider string `gorm:"column:provider;size:50;default:'twilio';index:idx_calls_provider_call_id" json:"provider,omitempty"`
|
||||
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"`
|
||||
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
|
||||
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"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
Account Account `gorm:"foreignKey:AccountID" json:"account,omitempty"`
|
||||
Conversation Conversation `gorm:"foreignKey:ConversationID" json:"conversation,omitempty"`
|
||||
Inbox Inbox `gorm:"foreignKey:InboxID" json:"inbox,omitempty"`
|
||||
Contact Contact `gorm:"foreignKey:ContactID" json:"contact,omitempty"`
|
||||
Message Message `gorm:"foreignKey:MessageID" json:"message,omitempty"`
|
||||
AcceptedByAgent User `gorm:"foreignKey:AcceptedByAgentID" json:"accepted_by_agent,omitempty"`
|
||||
}
|
||||
|
||||
func (Call) TableName() string { return "calls" }
|
||||
func (Call) TableName() string { return "calls" }
|
||||
|
||||
@@ -1004,6 +1004,7 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
|
||||
contacts.PATCH("/:contact_id", h.Contact.Update)
|
||||
contacts.DELETE("/:contact_id", h.Contact.Delete)
|
||||
contacts.DELETE("/:contact_id/avatar", h.Contact.DeleteAvatar)
|
||||
contacts.POST("/:contact_id/call", h.Contact.InitiateCall)
|
||||
|
||||
// M4 G3: Contact extension routes (active, export, import, contactable_inboxes, attachments, custom_attributes)
|
||||
contacts.GET("/:contact_id/conversations", h.Contact.ListConversations)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -139,6 +140,224 @@ func (s *ContactService) GetByAccountAndID(ctx context.Context, accountID, id ui
|
||||
return s.repo.FindByAccountAndID(ctx, accountID, id)
|
||||
}
|
||||
|
||||
type InitiateContactCallRequest struct {
|
||||
InboxID uint `json:"inbox_id"`
|
||||
ConversationID *uint `json:"conversation_id,omitempty"`
|
||||
UserID uint `json:"-"`
|
||||
}
|
||||
|
||||
type InitiateContactCallResponse struct {
|
||||
ConversationID uint `json:"conversation_id"`
|
||||
InboxID uint `json:"inbox_id"`
|
||||
CallSID string `json:"call_sid"`
|
||||
ConferenceSID string `json:"conference_sid"`
|
||||
}
|
||||
|
||||
func (s *ContactService) InitiateCall(ctx context.Context, accountID, contactID uint, req InitiateContactCallRequest) (*InitiateContactCallResponse, error) {
|
||||
if s == nil || s.repo == nil || s.repo.DB() == nil {
|
||||
return nil, errors.New("contact service unavailable")
|
||||
}
|
||||
if req.UserID == 0 {
|
||||
return nil, errors.New("agent required")
|
||||
}
|
||||
if req.InboxID == 0 {
|
||||
return nil, errors.New("inbox_id is required")
|
||||
}
|
||||
|
||||
db := s.repo.DB().WithContext(ctx)
|
||||
var out InitiateContactCallResponse
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var contact model.Contact
|
||||
if err := tx.Where("account_id = ? AND id = ?", accountID, contactID).First(&contact).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(contact.PhoneNumber) == "" {
|
||||
return errors.New("Contact phone number required")
|
||||
}
|
||||
|
||||
var inbox model.Inbox
|
||||
if err := tx.Where("account_id = ? AND id = ? AND channel_type = ?", accountID, req.InboxID, string(model.InboxChannelTypeTwilioSMS)).First(&inbox).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !contactCallVoiceEnabled(inbox.ChannelConfig) {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
var member model.InboxMember
|
||||
if err := tx.Where("inbox_id = ? AND user_id = ?", inbox.ID, req.UserID).First(&member).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contactInbox, err := ensureVoiceContactInbox(ctx, tx, contact.ID, inbox.ID, contact.PhoneNumber)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conversation, err := reusableVoiceConversation(ctx, tx, accountID, contact.ID, inbox.ID, req.ConversationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if conversation == nil {
|
||||
conversation = &model.Conversation{
|
||||
AccountID: accountID,
|
||||
InboxID: inbox.ID,
|
||||
ContactID: contact.ID,
|
||||
ContactInboxID: &contactInbox.ID,
|
||||
Status: string(model.ConversationStatusOpen),
|
||||
Priority: string(model.ConversationPriorityLow),
|
||||
ChannelType: inbox.ChannelType,
|
||||
Channel: inbox.ChannelType,
|
||||
}
|
||||
if err := repository.NewConversationRepo(tx).Create(ctx, conversation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
callSID := newVoiceCallSID()
|
||||
call := &model.Call{
|
||||
AccountID: accountID,
|
||||
InboxID: inbox.ID,
|
||||
ConversationID: conversation.ID,
|
||||
ContactID: contact.ID,
|
||||
AcceptedByAgentID: &req.UserID,
|
||||
Provider: "twilio",
|
||||
Direction: "outgoing",
|
||||
Status: "ringing",
|
||||
ProviderCallID: callSID,
|
||||
CallerType: "User",
|
||||
CallerID: req.UserID,
|
||||
CallDirection: "outbound",
|
||||
AdditionalAttributes: mustContactCallJSON(map[string]any{
|
||||
"initiated_at": time.Now().Unix(),
|
||||
}),
|
||||
}
|
||||
if err := tx.Create(call).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
call.ConferenceSID = fmt.Sprintf("conf_account_%d_call_%d", accountID, call.ID)
|
||||
if err := tx.Model(call).Update("conference_sid", call.ConferenceSID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
messageAttrs := mustContactCallJSON(map[string]any{"data": map[string]any{
|
||||
"call_id": call.ID,
|
||||
"call_sid": call.ProviderCallID,
|
||||
"call_source": call.Provider,
|
||||
"call_direction": "outbound",
|
||||
"status": "ringing",
|
||||
}})
|
||||
message := &model.Message{
|
||||
AccountID: accountID,
|
||||
ConversationID: conversation.ID,
|
||||
InboxID: inbox.ID,
|
||||
SenderID: &req.UserID,
|
||||
SenderType: "user",
|
||||
Content: "Twilio voice call",
|
||||
ContentType: "voice_call",
|
||||
MessageType: string(model.MessageTypeOutgoing),
|
||||
Status: "sent",
|
||||
ContentAttributes: datatypes.JSON(messageAttrs),
|
||||
AdditionalAttributes: datatypes.JSON([]byte(`{}`)),
|
||||
}
|
||||
if err := tx.Create(message).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
call.MessageID = &message.ID
|
||||
if err := tx.Model(call).Update("message_id", message.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
updates := map[string]any{"last_activity_at": now, "last_message_at": now}
|
||||
if conversation.ContactInboxID == nil {
|
||||
updates["contact_inbox_id"] = contactInbox.ID
|
||||
}
|
||||
if err := tx.Model(conversation).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
conversationID := conversation.ID
|
||||
if conversation.DisplayID != nil && *conversation.DisplayID != 0 {
|
||||
conversationID = *conversation.DisplayID
|
||||
}
|
||||
out = InitiateContactCallResponse{ConversationID: conversationID, InboxID: inbox.ID, CallSID: call.ProviderCallID, ConferenceSID: call.ConferenceSID}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func contactCallVoiceEnabled(rawConfig string) bool {
|
||||
config := map[string]any{}
|
||||
if rawConfig != "" {
|
||||
_ = json.Unmarshal([]byte(rawConfig), &config)
|
||||
}
|
||||
if value, ok := config["voice_enabled"]; ok {
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return strings.EqualFold(v, "true")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ensureVoiceContactInbox(ctx context.Context, tx *gorm.DB, contactID, inboxID uint, sourceID string) (*model.ContactInbox, error) {
|
||||
var contactInbox model.ContactInbox
|
||||
if err := tx.WithContext(ctx).Where("contact_id = ? AND inbox_id = ?", contactID, inboxID).First(&contactInbox).Error; err == nil {
|
||||
return &contactInbox, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hmacToken, err := generateToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pubsubToken, err := generateToken(24)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contactInbox = model.ContactInbox{ContactID: contactID, InboxID: inboxID, SourceID: sourceID, HMACToken: hmacToken, PubsubToken: pubsubToken}
|
||||
if err := tx.WithContext(ctx).Create(&contactInbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &contactInbox, nil
|
||||
}
|
||||
|
||||
func reusableVoiceConversation(ctx context.Context, tx *gorm.DB, accountID, contactID, inboxID uint, displayID *uint) (*model.Conversation, error) {
|
||||
if displayID == nil || *displayID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var conversation model.Conversation
|
||||
err := tx.WithContext(ctx).Where("account_id = ? AND display_id = ?", accountID, *displayID).First(&conversation).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if conversation.InboxID != inboxID || conversation.ContactID != contactID || conversation.Status != string(model.ConversationStatusOpen) {
|
||||
return nil, nil
|
||||
}
|
||||
return &conversation, nil
|
||||
}
|
||||
|
||||
func newVoiceCallSID() string {
|
||||
return "CA" + strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
|
||||
func mustContactCallJSON(value any) []byte {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return []byte(`{}`)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// ListContactInboxes retrieves all contact_inboxes for a contact.
|
||||
func (s *ContactService) ListContactInboxes(ctx context.Context, contactID uint) ([]model.ContactInbox, error) {
|
||||
return s.contactInboxSvc.ListByContact(ctx, contactID)
|
||||
|
||||
Reference in New Issue
Block a user