feat(contacts): initiate voice calls

This commit is contained in:
2026-06-06 05:44:27 +08:00
parent 2551353e31
commit 689c79bb9c
9 changed files with 467 additions and 29 deletions
@@ -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
View File
@@ -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" }
+1
View File
@@ -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)
+219
View File
@@ -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)