Merge pull request #34 from jwetzell/feat/sip-response

support responding to sip dtmf/audio calls
This commit is contained in:
Joel Wetzell
2025-12-28 20:45:51 -06:00
committed by GitHub
4 changed files with 335 additions and 33 deletions

View File

@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"sync"
"time" "time"
"github.com/emiago/diago" "github.com/emiago/diago"
@@ -13,6 +15,7 @@ import (
"github.com/emiago/sipgo" "github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip" "github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
) )
@@ -32,6 +35,13 @@ type SIPCallMessage struct {
To string To string
} }
type SIPCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
type sipCallContextKey string
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "sip.call.server", Type: "sip.call.server",
@@ -143,51 +153,76 @@ func (scs *SIPCallServer) HandleCall(inDialog *diago.DialogServerSession) {
inDialog.Trying() inDialog.Trying()
inDialog.Ringing() inDialog.Ringing()
inDialog.Answer() inDialog.Answer()
scs.router.HandleInput(scs.ctx, scs.Id(), SIPCallMessage{
dialogContext := context.WithValue(scs.ctx, sipCallContextKey("call"), &SIPCall{
inDialog: inDialog,
})
scs.router.HandleInput(dialogContext, scs.Id(), SIPCallMessage{
To: inDialog.ToUser(), To: inDialog.ToUser(),
}) })
<-inDialog.Context().Done() fmt.Println(inDialog.LoadState())
} }
func (scs *SIPCallServer) Output(ctx context.Context, payload any) error { func (scs *SIPCallServer) Output(ctx context.Context, payload any) error {
payloadMsg, ok := payload.(string) call, ok := ctx.Value(sipCallContextKey("call")).(*SIPCall)
if !ok { if !ok {
return errors.New("sip.call.server output payload must be of type string") return errors.New("sip.call.server output must originate from sip.call.server input")
} }
if scs.dg == nil { gotLock := call.lock.TryLock()
return errors.New("sip.call.server diago is not initialized")
if !gotLock {
return errors.New("sip.call.server call is already locked")
} }
var uri sip.Uri if call.inDialog.LoadState() == sip.DialogStateEnded {
err := sip.ParseUri(payloadMsg, &uri) return errors.New("sip.call.server inDialog already ended")
if err != nil {
return fmt.Errorf("sip.call.server output payload is not a valid SIP URI: %s", err)
}
outDialog, err := scs.dg.NewDialog(uri, diago.NewDialogOptions{
Transport: scs.Transport,
})
if err != nil {
return fmt.Errorf("sip.call.server failed to create new dialog: %s", err)
} }
err = outDialog.Invite(scs.ctx, diago.InviteClientOptions{}) payloadDTMFResponse, ok := payload.(processor.SipDTMFResponse)
if ok {
dtmfWriter := call.inDialog.AudioWriterDTMF()
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
for i, dtmfRune := range payloadDTMFResponse.Digits {
err := dtmfWriter.WriteDTMF(dtmfRune)
if err != nil { if err != nil {
return fmt.Errorf("sip.call.server failed to send invite: %s", err) return fmt.Errorf("sip.dtmf.server error output dtmf digit at index %d", i)
}
}
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
return nil
} }
err = outDialog.Ack(scs.ctx) payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse)
if ok {
audioFile, err := os.Open(payloadAudioFileResponse.AudioFile)
if err != nil { if err != nil {
return fmt.Errorf("sip.call.server failed to send ack: %s", err) return err
} }
// TODO(jwetzell): make this configurable defer audioFile.Close()
// NOTE(jwetzell): wait 5 seconds before hanging up the call
time.Sleep(5 * time.Second) playback, err := call.inDialog.PlaybackCreate()
err = outDialog.Hangup(scs.ctx)
if err != nil { if err != nil {
return fmt.Errorf("sip.call.server failed to hangup call: %s", err) return err
}
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PreWait))
_, err = playback.Play(audioFile, "audio/wav")
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PostWait))
if err != nil {
return err
} }
return nil return nil
} }
return errors.New("sip.dtmf.server can only output SipDTMFResponse or SipAudioFileResponse")
}

View File

@@ -3,9 +3,12 @@ package module
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"strings" "strings"
"sync"
"time" "time"
"github.com/emiago/diago" "github.com/emiago/diago"
@@ -13,6 +16,7 @@ import (
"github.com/emiago/sipgo" "github.com/emiago/sipgo"
"github.com/emiago/sipgo/sip" "github.com/emiago/sipgo/sip"
"github.com/jwetzell/showbridge-go/internal/config" "github.com/jwetzell/showbridge-go/internal/config"
"github.com/jwetzell/showbridge-go/internal/processor"
"github.com/jwetzell/showbridge-go/internal/route" "github.com/jwetzell/showbridge-go/internal/route"
) )
@@ -32,6 +36,11 @@ type SIPDTMFMessage struct {
Digits string Digits string
} }
type SIPDTMFCall struct {
inDialog *diago.DialogServerSession
lock sync.Mutex
}
func init() { func init() {
RegisterModule(ModuleRegistration{ RegisterModule(ModuleRegistration{
Type: "sip.dtmf.server", Type: "sip.dtmf.server",
@@ -148,10 +157,14 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error
reader := inDialog.AudioReaderDTMF() reader := inDialog.AudioReaderDTMF()
userString := "" userString := ""
return reader.Listen(func(dtmf rune) error { return reader.Listen(func(dtmf rune) error {
if dtmf == rune(sds.Separator[0]) { if dtmf == rune(sds.Separator[0]) {
if sds.router != nil { if sds.router != nil {
sds.router.HandleInput(sds.ctx, sds.Id(), SIPDTMFMessage{ dialogContext := context.WithValue(sds.ctx, sipCallContextKey("call"), &SIPDTMFCall{
inDialog: inDialog,
})
sds.router.HandleInput(dialogContext, sds.Id(), SIPDTMFMessage{
To: inDialog.ToUser(), To: inDialog.ToUser(),
Digits: userString, Digits: userString,
}) })
@@ -165,5 +178,65 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error
} }
func (sds *SIPDTMFServer) Output(ctx context.Context, payload any) error { func (sds *SIPDTMFServer) Output(ctx context.Context, payload any) error {
return errors.New("sip.dtmf.server output is not implemented") call, ok := ctx.Value(sipCallContextKey("call")).(*SIPDTMFCall)
if !ok {
return errors.New("sip.dtmf.server output must originate from sip.dtmf.server input")
}
gotLock := call.lock.TryLock()
if !gotLock {
return errors.New("sip.dtmf.server call is already locked")
}
if call.inDialog.LoadState() == sip.DialogStateEnded {
return errors.New("sip.dtmf.server inDialog already ended")
}
payloadDTMFResponse, ok := payload.(processor.SipDTMFResponse)
if ok {
dtmfWriter := call.inDialog.AudioWriterDTMF()
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
for i, dtmfRune := range payloadDTMFResponse.Digits {
err := dtmfWriter.WriteDTMF(dtmfRune)
if err != nil {
return fmt.Errorf("sip.dtmf.server error output dtmf digit at index %d", i)
}
}
time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait))
return nil
}
payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse)
if ok {
audioFile, err := os.Open(payloadAudioFileResponse.AudioFile)
if err != nil {
return err
}
defer audioFile.Close()
playback, err := call.inDialog.PlaybackCreate()
if err != nil {
return err
}
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PreWait))
_, err = playback.Play(audioFile, "audio/wav")
time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PostWait))
if err != nil {
return err
}
return nil
}
return errors.New("sip.dtmf.server can only output SipDTMFResponse or SipAudioFileResponse")
} }

View File

@@ -0,0 +1,97 @@
package processor
import (
"bytes"
"context"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type SipResponseAudioCreate struct {
config config.ProcessorConfig
PreWait int
PostWait int
AudioFile *template.Template
}
type SipAudioFileResponse struct {
PreWait int
PostWait int
AudioFile string
}
func (scc *SipResponseAudioCreate) Process(ctx context.Context, payload any) (any, error) {
var audioFileBuffer bytes.Buffer
err := scc.AudioFile.Execute(&audioFileBuffer, payload)
if err != nil {
return nil, err
}
audioFileString := audioFileBuffer.String()
return SipAudioFileResponse{
PreWait: scc.PreWait,
PostWait: scc.PostWait,
AudioFile: audioFileString,
}, nil
}
func (scc *SipResponseAudioCreate) Type() string {
return scc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "sip.response.audio.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
preWait, ok := params["preWait"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a preWait parameter")
}
preWaitNum, ok := preWait.(float64)
if !ok {
return nil, errors.New("sip.response.audio.create preWait must be a number")
}
postWait, ok := params["postWait"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a postWait parameter")
}
postWaitNum, ok := postWait.(float64)
if !ok {
return nil, errors.New("sip.response.audio.create postWait must be a number")
}
audioFile, ok := params["audioFile"]
if !ok {
return nil, errors.New("sip.response.audio.create requires a audioFile parameter")
}
audioFileString, ok := audioFile.(string)
if !ok {
return nil, errors.New("sip.response.audio.create audioFile must be a string")
}
audioFileTemplate, err := template.New("audioFile").Parse(audioFileString)
if err != nil {
return nil, err
}
return &SipResponseAudioCreate{config: config, AudioFile: audioFileTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum)}, nil
},
})
}

View File

@@ -0,0 +1,97 @@
package processor
import (
"bytes"
"context"
"errors"
"text/template"
"github.com/jwetzell/showbridge-go/internal/config"
)
type SipResponseDTMFCreate struct {
config config.ProcessorConfig
PreWait int
PostWait int
Digits *template.Template
}
type SipDTMFResponse struct {
PreWait int
PostWait int
Digits string
}
func (scc *SipResponseDTMFCreate) Process(ctx context.Context, payload any) (any, error) {
var digitsBuffer bytes.Buffer
err := scc.Digits.Execute(&digitsBuffer, payload)
if err != nil {
return nil, err
}
digitsString := digitsBuffer.String()
return SipDTMFResponse{
PreWait: scc.PreWait,
PostWait: scc.PostWait,
Digits: digitsString,
}, nil
}
func (scc *SipResponseDTMFCreate) Type() string {
return scc.config.Type
}
func init() {
RegisterProcessor(ProcessorRegistration{
Type: "sip.response.dtmf.create",
New: func(config config.ProcessorConfig) (Processor, error) {
params := config.Params
preWait, ok := params["preWait"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a preWait parameter")
}
preWaitNum, ok := preWait.(float64)
if !ok {
return nil, errors.New("sip.response.dtmf.create preWait must be a number")
}
postWait, ok := params["postWait"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a postWait parameter")
}
postWaitNum, ok := postWait.(float64)
if !ok {
return nil, errors.New("sip.response.dtmf.create postWait must be a number")
}
digits, ok := params["digits"]
if !ok {
return nil, errors.New("sip.response.dtmf.create requires a digits parameter")
}
digitsString, ok := digits.(string)
if !ok {
return nil, errors.New("sip.response.dtmf.create digits must be a string")
}
digitsTemplate, err := template.New("digits").Parse(digitsString)
if err != nil {
return nil, err
}
return &SipResponseDTMFCreate{config: config, Digits: digitsTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum)}, nil
},
})
}