feat(twilio): expose voice callbacks
This commit is contained in:
@@ -3,10 +3,12 @@ package router
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +26,7 @@ import (
|
||||
ws "github.com/gochat/gochat/internal/handler/ws"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
wspkg "github.com/gochat/gochat/internal/ws"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -448,6 +451,12 @@ func RegisterRoutes(
|
||||
}
|
||||
handlers.TwilioWebhook.HandleTwilioDeliveryStatus(c)
|
||||
})
|
||||
// Enterprise Twilio voice callback routes.
|
||||
// Reference: Chatwoot enterprise Twilio::VoiceController routes.rb:643-646.
|
||||
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
||||
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
|
||||
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
|
||||
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
|
||||
|
||||
// Twitter webhook — Account Activity API CRC validation + event processing
|
||||
// GET: CRC challenge response (crc_token query param)
|
||||
@@ -2129,3 +2138,272 @@ func requestHost(req *http.Request) string {
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func twilioVoiceCallTwiML(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
call, err := resolveTwilioVoiceCall(c, db)
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "Not found")
|
||||
return
|
||||
}
|
||||
conferenceSID := strings.TrimSpace(call.ConferenceSID)
|
||||
if conferenceSID == "" {
|
||||
conferenceSID = fmt.Sprintf("conf_account_%d_call_%d", call.AccountID, call.ID)
|
||||
_ = db.WithContext(c.Request.Context()).Model(call).Update("conference_sid", conferenceSID).Error
|
||||
}
|
||||
|
||||
participantLabel := twilioParticipantLabel(c.PostForm("From"))
|
||||
phoneDigits := twilioPhoneDigits(c.Param("phone"))
|
||||
xml := fmt.Sprintf(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Response><Dial><Conference startConferenceOnEnter="%t" endConferenceOnExit="false" record="record-from-start" recordingStatusCallback="/twilio/voice/recording_status/%s" recordingStatusCallbackEvent="completed" recordingStatusCallbackMethod="POST" statusCallback="/twilio/voice/conference_status/%s" statusCallbackEvent="start end join leave" statusCallbackMethod="POST" participantLabel="%s">%s</Conference></Dial></Response>`,
|
||||
twilioAgentLeg(c.PostForm("From")),
|
||||
html.EscapeString(phoneDigits),
|
||||
html.EscapeString(phoneDigits),
|
||||
html.EscapeString(participantLabel),
|
||||
html.EscapeString(conferenceSID),
|
||||
)
|
||||
c.Data(http.StatusOK, "application/xml; charset=utf-8", []byte(xml))
|
||||
}
|
||||
}
|
||||
|
||||
func twilioVoiceStatus(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
call, err := resolveTwilioVoiceCallbackCall(c, db)
|
||||
if err == nil {
|
||||
updates := map[string]any{"status": twilioCallStatus(c.PostForm("CallStatus"))}
|
||||
if duration, parseErr := strconv.Atoi(c.PostForm("CallDuration")); parseErr == nil {
|
||||
updates["duration"] = duration
|
||||
}
|
||||
mergeCallAttributes(call, formPayload(c), "twilio_status_payload")
|
||||
updates["additional_attributes"] = call.AdditionalAttributes
|
||||
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func twilioVoiceConferenceStatus(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
event := twilioConferenceEvent(c.PostForm("StatusCallbackEvent"))
|
||||
if event == "" {
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
call, err := resolveTwilioVoiceCallbackCall(c, db)
|
||||
if err == nil {
|
||||
updates := map[string]any{"status": twilioConferenceCallStatus(event)}
|
||||
attrs := formPayload(c)
|
||||
if sid := strings.TrimSpace(c.PostForm("ConferenceSid")); sid != "" {
|
||||
attrs["twilio_conference_sid"] = sid
|
||||
}
|
||||
attrs["event"] = event
|
||||
mergeCallAttributes(call, attrs, "twilio_conference_payload")
|
||||
updates["additional_attributes"] = call.AdditionalAttributes
|
||||
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func twilioVoiceRecordingStatus(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
call, err := resolveTwilioVoiceCallbackCall(c, db)
|
||||
if err == nil {
|
||||
updates := map[string]any{"recording_url": strings.TrimSpace(c.PostForm("RecordingUrl"))}
|
||||
if duration, parseErr := strconv.Atoi(c.PostForm("RecordingDuration")); parseErr == nil {
|
||||
updates["duration"] = duration
|
||||
}
|
||||
mergeCallAttributes(call, formPayload(c), "twilio_recording_payload")
|
||||
updates["additional_attributes"] = call.AdditionalAttributes
|
||||
_ = db.WithContext(c.Request.Context()).Model(call).Updates(updates).Error
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveTwilioVoiceCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
|
||||
if db == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
inbox, err := findTwilioVoiceInbox(c, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(c.PostForm("From"))
|
||||
if twilioAgentLeg(from) {
|
||||
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("call_sid"))
|
||||
}
|
||||
|
||||
direction := strings.TrimSpace(firstNonEmpty(c.PostForm("Direction"), c.PostForm("CallDirection")))
|
||||
callSID := strings.TrimSpace(c.PostForm("CallSid"))
|
||||
if direction == "outbound-dial" {
|
||||
if parent := strings.TrimSpace(c.PostForm("ParentCallSid")); parent != "" {
|
||||
callSID = parent
|
||||
}
|
||||
}
|
||||
return findTwilioVoiceCall(c, db, inbox.ID, callSID)
|
||||
}
|
||||
|
||||
func resolveTwilioVoiceCallbackCall(c *gin.Context, db *gorm.DB) (*model.Call, error) {
|
||||
if db == nil {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
inbox, err := findTwilioVoiceInbox(c, db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if friendlyName := strings.TrimSpace(c.PostForm("FriendlyName")); friendlyName != "" {
|
||||
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, friendlyName); err == nil {
|
||||
return call, nil
|
||||
}
|
||||
}
|
||||
if conferenceSID := strings.TrimSpace(c.PostForm("ConferenceSid")); conferenceSID != "" {
|
||||
if call, err := findTwilioVoiceCallByConference(c, db, inbox.ID, conferenceSID); err == nil {
|
||||
return call, nil
|
||||
}
|
||||
}
|
||||
return findTwilioVoiceCall(c, db, inbox.ID, c.PostForm("CallSid"))
|
||||
}
|
||||
|
||||
func findTwilioVoiceInbox(c *gin.Context, db *gorm.DB) (*model.Inbox, error) {
|
||||
phone := "+" + twilioPhoneDigits(c.Param("phone"))
|
||||
var channel channelmodel.ChannelTwilioSMS
|
||||
if err := db.WithContext(c.Request.Context()).Where("phone_number = ?", phone).First(&channel).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inbox model.Inbox
|
||||
if err := db.WithContext(c.Request.Context()).Where("id = ? AND channel_type IN ?", channel.InboxID, []string{"twilio_sms", "sms"}).First(&inbox).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !twilioVoiceEnabled(inbox.ChannelConfig) {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return &inbox, nil
|
||||
}
|
||||
|
||||
func findTwilioVoiceCall(c *gin.Context, db *gorm.DB, inboxID uint, callSID string) (*model.Call, error) {
|
||||
var call model.Call
|
||||
err := db.WithContext(c.Request.Context()).
|
||||
Where("inbox_id = ? AND provider = ? AND provider_call_id = ?", inboxID, "twilio", strings.TrimSpace(callSID)).
|
||||
First(&call).Error
|
||||
return &call, err
|
||||
}
|
||||
|
||||
func findTwilioVoiceCallByConference(c *gin.Context, db *gorm.DB, inboxID uint, conferenceSID string) (*model.Call, error) {
|
||||
var call model.Call
|
||||
err := db.WithContext(c.Request.Context()).
|
||||
Where("inbox_id = ? AND provider = ? AND conference_sid = ?", inboxID, "twilio", strings.TrimSpace(conferenceSID)).
|
||||
First(&call).Error
|
||||
return &call, err
|
||||
}
|
||||
|
||||
func twilioVoiceEnabled(rawConfig string) bool {
|
||||
config := map[string]any{}
|
||||
if rawConfig != "" {
|
||||
_ = json.Unmarshal([]byte(rawConfig), &config)
|
||||
}
|
||||
value, ok := config["voice_enabled"]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
return strings.EqualFold(v, "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func twilioPhoneDigits(phone string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range phone {
|
||||
if r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func twilioAgentLeg(from string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(from), "client:")
|
||||
}
|
||||
|
||||
func twilioParticipantLabel(from string) string {
|
||||
from = strings.TrimSpace(from)
|
||||
if twilioAgentLeg(from) {
|
||||
return strings.TrimPrefix(from, "client:")
|
||||
}
|
||||
return "contact"
|
||||
}
|
||||
|
||||
func twilioCallStatus(status string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(status)) {
|
||||
case "in-progress", "answered":
|
||||
return string(model.CallStatusOngoing)
|
||||
case "completed":
|
||||
return string(model.CallStatusCompleted)
|
||||
case "failed", "busy", "no-answer", "canceled":
|
||||
return string(model.CallStatusFailed)
|
||||
default:
|
||||
return string(model.CallStatusRinging)
|
||||
}
|
||||
}
|
||||
|
||||
func twilioConferenceEvent(event string) string {
|
||||
event = strings.ToLower(strings.TrimSpace(event))
|
||||
switch {
|
||||
case strings.Contains(event, "conference-start"):
|
||||
return "start"
|
||||
case strings.Contains(event, "participant-join"):
|
||||
return "join"
|
||||
case strings.Contains(event, "participant-leave"):
|
||||
return "leave"
|
||||
case strings.Contains(event, "conference-end"):
|
||||
return "end"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func twilioConferenceCallStatus(event string) string {
|
||||
if event == "end" {
|
||||
return string(model.CallStatusCompleted)
|
||||
}
|
||||
return string(model.CallStatusOngoing)
|
||||
}
|
||||
|
||||
func formPayload(c *gin.Context) map[string]any {
|
||||
_ = c.Request.ParseForm()
|
||||
payload := map[string]any{}
|
||||
for key, values := range c.Request.PostForm {
|
||||
if len(values) > 0 {
|
||||
payload[key] = values[0]
|
||||
}
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func mergeCallAttributes(call *model.Call, value map[string]any, key string) {
|
||||
attrs := map[string]any{}
|
||||
if len(call.AdditionalAttributes) > 0 {
|
||||
_ = json.Unmarshal(call.AdditionalAttributes, &attrs)
|
||||
}
|
||||
attrs[key] = value
|
||||
data, err := json.Marshal(attrs)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
call.AdditionalAttributes = data
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/gochat/gochat/internal/config"
|
||||
"github.com/gochat/gochat/internal/middleware"
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
channelmodel "github.com/gochat/gochat/internal/model/channel"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
@@ -69,6 +71,10 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
"POST /webhooks/whatsapp/:phone_number",
|
||||
"POST /webhooks/tiktok",
|
||||
"POST /webhooks/shopify",
|
||||
"POST /twilio/voice/call/:phone",
|
||||
"POST /twilio/voice/status/:phone",
|
||||
"POST /twilio/voice/conference_status/:phone",
|
||||
"POST /twilio/voice/recording_status/:phone",
|
||||
}
|
||||
|
||||
for _, key := range expected {
|
||||
@@ -78,6 +84,93 @@ func TestRegisterRoutesBootsWithChatwootParityConflictGroups(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwilioVoiceRoutesServeConferenceAndPersistCallbacks(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, call := setupRouterTwilioVoiceDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
||||
engine.POST("/twilio/voice/status/:phone", twilioVoiceStatus(db))
|
||||
engine.POST("/twilio/voice/conference_status/:phone", twilioVoiceConferenceStatus(db))
|
||||
engine.POST("/twilio/voice/recording_status/:phone", twilioVoiceRecordingStatus(db))
|
||||
|
||||
twiml := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{
|
||||
"CallSid": {call.ProviderCallID},
|
||||
"Direction": {"outbound-api"},
|
||||
"From": {"+15550990000"},
|
||||
"ParentCallSid": {""},
|
||||
})
|
||||
if twiml.Code != http.StatusOK || !strings.Contains(twiml.Body.String(), "<Conference") || !strings.Contains(twiml.Body.String(), call.ConferenceSID) {
|
||||
t.Fatalf("expected conference TwiML, got %d %q", twiml.Code, twiml.Body.String())
|
||||
}
|
||||
if !strings.Contains(twiml.Body.String(), `participantLabel="contact"`) {
|
||||
t.Fatalf("expected contact participant label, got %s", twiml.Body.String())
|
||||
}
|
||||
|
||||
status := performFormPost(engine, "/twilio/voice/status/15551234567", url.Values{
|
||||
"CallSid": {call.ProviderCallID},
|
||||
"CallStatus": {"completed"},
|
||||
"CallDuration": {"42"},
|
||||
})
|
||||
if status.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected status callback 204, got %d", status.Code)
|
||||
}
|
||||
|
||||
conference := performFormPost(engine, "/twilio/voice/conference_status/15551234567", url.Values{
|
||||
"CallSid": {call.ProviderCallID},
|
||||
"FriendlyName": {call.ConferenceSID},
|
||||
"ConferenceSid": {"CF123"},
|
||||
"StatusCallbackEvent": {"participant-join"},
|
||||
})
|
||||
if conference.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected conference callback 204, got %d", conference.Code)
|
||||
}
|
||||
|
||||
recording := performFormPost(engine, "/twilio/voice/recording_status/15551234567", url.Values{
|
||||
"CallSid": {call.ProviderCallID},
|
||||
"RecordingUrl": {"https://api.twilio.com/recording.mp3"},
|
||||
"RecordingDuration": {"43"},
|
||||
"RecordingStatus": {"completed"},
|
||||
"RecordingSource": {"Conference"},
|
||||
"RecordingChannels": {"1"},
|
||||
"RecordingStartTime": {"Sat, 06 Jun 2026 09:00:00 +0000"},
|
||||
})
|
||||
if recording.Code != http.StatusNoContent {
|
||||
t.Fatalf("expected recording callback 204, got %d", recording.Code)
|
||||
}
|
||||
|
||||
var updated model.Call
|
||||
if err := db.First(&updated, call.ID).Error; err != nil {
|
||||
t.Fatalf("failed to reload call: %v", err)
|
||||
}
|
||||
if updated.Status != string(model.CallStatusOngoing) || updated.Duration != 43 || updated.RecordingURL != "https://api.twilio.com/recording.mp3" {
|
||||
t.Fatalf("expected persisted Twilio callback state, got status=%s duration=%d recording=%s", updated.Status, updated.Duration, updated.RecordingURL)
|
||||
}
|
||||
if !strings.Contains(string(updated.AdditionalAttributes), "twilio_conference_sid") || !strings.Contains(string(updated.AdditionalAttributes), "twilio_recording_payload") {
|
||||
t.Fatalf("expected callback payload attrs, got %s", string(updated.AdditionalAttributes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwilioVoiceCallRejectsUnknownOrDisabledInbox(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, call := setupRouterTwilioVoiceDB(t)
|
||||
if err := db.Model(&model.Inbox{}).Where("id = ?", call.InboxID).Update("channel_config", `{"voice_enabled":false}`).Error; err != nil {
|
||||
t.Fatalf("failed to disable voice inbox: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.POST("/twilio/voice/call/:phone", twilioVoiceCallTwiML(db))
|
||||
|
||||
disabled := performFormPost(engine, "/twilio/voice/call/15551234567", url.Values{"CallSid": {call.ProviderCallID}})
|
||||
if disabled.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected disabled voice inbox 404, got %d", disabled.Code)
|
||||
}
|
||||
missing := performFormPost(engine, "/twilio/voice/call/15550000000", url.Values{"CallSid": {call.ProviderCallID}})
|
||||
if missing.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected missing voice inbox 404, got %d", missing.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomDomainChallengeMatchesChatwootVerification(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupRouterPortalDB(t)
|
||||
@@ -214,6 +307,14 @@ func performHostGet(engine *gin.Engine, path string, host string) *httptest.Resp
|
||||
return recorder
|
||||
}
|
||||
|
||||
func performFormPost(engine *gin.Engine, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
engine.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
||||
@@ -227,3 +328,51 @@ func setupRouterPortalDB(t *testing.T) *gorm.DB {
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func setupRouterTwilioVoiceDB(t *testing.T) (*gorm.DB, *model.Call) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=private"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Account{}, &model.Inbox{}, &channelmodel.ChannelTwilioSMS{}, &model.Call{}); err != nil {
|
||||
t.Fatalf("failed to migrate twilio voice models: %v", err)
|
||||
}
|
||||
account := &model.Account{Name: "Voice Account", Locale: "en"}
|
||||
if err := db.Create(account).Error; err != nil {
|
||||
t.Fatalf("failed to create account: %v", err)
|
||||
}
|
||||
inbox := &model.Inbox{AccountID: account.ID, Name: "Voice", ChannelType: "twilio_sms", ChannelID: 1, ChannelConfig: `{"voice_enabled":true}`}
|
||||
if err := db.Create(inbox).Error; err != nil {
|
||||
t.Fatalf("failed to create inbox: %v", err)
|
||||
}
|
||||
channel := &channelmodel.ChannelTwilioSMS{AccountID: account.ID, InboxID: inbox.ID, AccountSID: "AC123", PhoneNumber: "+15551234567"}
|
||||
if err := db.Create(channel).Error; err != nil {
|
||||
t.Fatalf("failed to create twilio channel: %v", err)
|
||||
}
|
||||
call := &model.Call{
|
||||
AccountID: account.ID,
|
||||
InboxID: inbox.ID,
|
||||
ConversationID: 1,
|
||||
Provider: "twilio",
|
||||
ProviderCallID: "CA123",
|
||||
ConferenceSID: "conf_account_1_call_1",
|
||||
CallerType: "User",
|
||||
CallerID: 1,
|
||||
Status: string(model.CallStatusRinging),
|
||||
CallDirection: "outbound",
|
||||
Direction: "outgoing",
|
||||
AdditionalAttributes: json.RawMessage(`{}`),
|
||||
AcceptedByAgentID: nil,
|
||||
ContactID: 1,
|
||||
MessageID: nil,
|
||||
RecordingURL: "",
|
||||
Duration: 0,
|
||||
}
|
||||
if err := db.Create(call).Error; err != nil {
|
||||
t.Fatalf("failed to create call: %v", err)
|
||||
}
|
||||
return db, call
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user