From 6e88d259b8ad62f0432316282db608120e013867 Mon Sep 17 00:00:00 2001 From: Joel Wetzell Date: Sun, 28 Dec 2025 20:40:09 -0600 Subject: [PATCH] support for responding with dtmf or an audio file to both sip call types --- internal/module/sip-call-server.go | 112 ++++++++---------- internal/module/sip-dtmf-server.go | 77 +++++++++++- ...create.go => sip-response-audio-create.go} | 22 ++-- .../processor/sip-response-dtmf-create.go | 97 +++++++++++++++ 4 files changed, 234 insertions(+), 74 deletions(-) rename internal/processor/{sip-response-create.go => sip-response-audio-create.go} (59%) create mode 100644 internal/processor/sip-response-dtmf-create.go diff --git a/internal/module/sip-call-server.go b/internal/module/sip-call-server.go index 95e6030..4cf326b 100644 --- a/internal/module/sip-call-server.go +++ b/internal/module/sip-call-server.go @@ -3,9 +3,11 @@ package module import ( "context" "errors" + "fmt" "io" "log/slog" "os" + "sync" "time" "github.com/emiago/diago" @@ -33,6 +35,11 @@ type SIPCallMessage struct { To string } +type SIPCall struct { + inDialog *diago.DialogServerSession + lock sync.Mutex +} + type sipCallContextKey string func init() { @@ -147,92 +154,75 @@ func (scs *SIPCallServer) HandleCall(inDialog *diago.DialogServerSession) { inDialog.Ringing() inDialog.Answer() - dialogContext := context.WithValue(scs.ctx, sipCallContextKey("inDialog"), inDialog) + dialogContext := context.WithValue(scs.ctx, sipCallContextKey("call"), &SIPCall{ + inDialog: inDialog, + }) scs.router.HandleInput(dialogContext, scs.Id(), SIPCallMessage{ To: inDialog.ToUser(), }) - inDialog.Hangup(dialogContext) + fmt.Println(inDialog.LoadState()) } func (scs *SIPCallServer) Output(ctx context.Context, payload any) error { - inDialog, ok := ctx.Value(sipCallContextKey("inDialog")).(*diago.DialogServerSession) + call, ok := ctx.Value(sipCallContextKey("call")).(*SIPCall) if !ok { return errors.New("sip.call.server output must originate from sip.call.server input") } - // _, ok := payload.([]byte) + gotLock := call.lock.TryLock() - // if !ok { - // return errors.New("sip.call.server is only able to output bytes") - // } - - payloadResponse, ok := payload.(processor.SipAudioFileResponse) - - if !ok { - return errors.New("sip.call.server is only able to handle SipCallResponse") + if !gotLock { + return errors.New("sip.call.server call is already locked") } - audioFile, err := os.Open(payloadResponse.AudioFile) - if err != nil { - return err - } - defer audioFile.Close() - - playback, err := inDialog.PlaybackCreate() - - if err != nil { - return err + if call.inDialog.LoadState() == sip.DialogStateEnded { + return errors.New("sip.call.server inDialog already ended") } - time.Sleep(time.Millisecond * time.Duration(payloadResponse.PreWait)) + payloadDTMFResponse, ok := payload.(processor.SipDTMFResponse) - _, err = playback.Play(audioFile, "audio/wav") + if ok { + dtmfWriter := call.inDialog.AudioWriterDTMF() - time.Sleep(time.Millisecond * time.Duration(payloadResponse.PostWait)) + time.Sleep(time.Millisecond * time.Duration(payloadDTMFResponse.PreWait)) + for i, dtmfRune := range payloadDTMFResponse.Digits { + err := dtmfWriter.WriteDTMF(dtmfRune) - if err != nil { - return err + 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 } - // payloadMsg, ok := payload.(string) - // if !ok { - // return errors.New("sip.call.server output payload must be of type string") - // } + payloadAudioFileResponse, ok := payload.(processor.SipAudioFileResponse) - // if scs.dg == nil { - // return errors.New("sip.call.server diago is not initialized") - // } + if ok { + audioFile, err := os.Open(payloadAudioFileResponse.AudioFile) + if err != nil { + return err + } + defer audioFile.Close() - // var uri sip.Uri - // err := sip.ParseUri(payloadMsg, &uri) - // 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, - // }) + playback, err := call.inDialog.PlaybackCreate() - // if err != nil { - // return fmt.Errorf("sip.call.server failed to create new dialog: %s", err) - // } + if err != nil { + return err + } - // err = outDialog.Invite(scs.ctx, diago.InviteClientOptions{}) - // if err != nil { - // return fmt.Errorf("sip.call.server failed to send invite: %s", err) - // } + time.Sleep(time.Millisecond * time.Duration(payloadAudioFileResponse.PreWait)) - // err = outDialog.Ack(scs.ctx) - // if err != nil { - // return fmt.Errorf("sip.call.server failed to send ack: %s", err) - // } - // // TODO(jwetzell): make this configurable - // // NOTE(jwetzell): wait 5 seconds before hanging up the call - // time.Sleep(5 * time.Second) - // err = outDialog.Hangup(scs.ctx) - // if err != nil { - // return fmt.Errorf("sip.call.server failed to hangup call: %s", err) - // } - return nil + _, 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") } diff --git a/internal/module/sip-dtmf-server.go b/internal/module/sip-dtmf-server.go index ebc2efb..3758332 100644 --- a/internal/module/sip-dtmf-server.go +++ b/internal/module/sip-dtmf-server.go @@ -3,9 +3,12 @@ package module import ( "context" "errors" + "fmt" "io" "log/slog" + "os" "strings" + "sync" "time" "github.com/emiago/diago" @@ -13,6 +16,7 @@ import ( "github.com/emiago/sipgo" "github.com/emiago/sipgo/sip" "github.com/jwetzell/showbridge-go/internal/config" + "github.com/jwetzell/showbridge-go/internal/processor" "github.com/jwetzell/showbridge-go/internal/route" ) @@ -32,6 +36,11 @@ type SIPDTMFMessage struct { Digits string } +type SIPDTMFCall struct { + inDialog *diago.DialogServerSession + lock sync.Mutex +} + func init() { RegisterModule(ModuleRegistration{ Type: "sip.dtmf.server", @@ -148,10 +157,14 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) error reader := inDialog.AudioReaderDTMF() userString := "" + return reader.Listen(func(dtmf rune) error { if dtmf == rune(sds.Separator[0]) { 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(), Digits: userString, }) @@ -165,5 +178,65 @@ func (sds *SIPDTMFServer) HandleCall(inDialog *diago.DialogServerSession) 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") } diff --git a/internal/processor/sip-response-create.go b/internal/processor/sip-response-audio-create.go similarity index 59% rename from internal/processor/sip-response-create.go rename to internal/processor/sip-response-audio-create.go index 152b821..4f964a0 100644 --- a/internal/processor/sip-response-create.go +++ b/internal/processor/sip-response-audio-create.go @@ -9,7 +9,7 @@ import ( "github.com/jwetzell/showbridge-go/internal/config" ) -type SipResponseCreate struct { +type SipResponseAudioCreate struct { config config.ProcessorConfig PreWait int PostWait int @@ -22,7 +22,7 @@ type SipAudioFileResponse struct { AudioFile string } -func (scc *SipResponseCreate) Process(ctx context.Context, payload any) (any, error) { +func (scc *SipResponseAudioCreate) Process(ctx context.Context, payload any) (any, error) { var audioFileBuffer bytes.Buffer err := scc.AudioFile.Execute(&audioFileBuffer, payload) @@ -40,50 +40,50 @@ func (scc *SipResponseCreate) Process(ctx context.Context, payload any) (any, er }, nil } -func (scc *SipResponseCreate) Type() string { +func (scc *SipResponseAudioCreate) Type() string { return scc.config.Type } func init() { RegisterProcessor(ProcessorRegistration{ - Type: "sip.response.create", + 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.create requires a preWait parameter") + return nil, errors.New("sip.response.audio.create requires a preWait parameter") } preWaitNum, ok := preWait.(float64) if !ok { - return nil, errors.New("sip.response.create preWait must be a number") + 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.create requires a postWait parameter") + return nil, errors.New("sip.response.audio.create requires a postWait parameter") } postWaitNum, ok := postWait.(float64) if !ok { - return nil, errors.New("sip.response.create postWait must be a number") + 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.create requires a audioFile parameter") + return nil, errors.New("sip.response.audio.create requires a audioFile parameter") } audioFileString, ok := audioFile.(string) if !ok { - return nil, errors.New("sip.response.create audioFile must be a string") + return nil, errors.New("sip.response.audio.create audioFile must be a string") } audioFileTemplate, err := template.New("audioFile").Parse(audioFileString) @@ -91,7 +91,7 @@ func init() { if err != nil { return nil, err } - return &SipResponseCreate{config: config, AudioFile: audioFileTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum)}, nil + return &SipResponseAudioCreate{config: config, AudioFile: audioFileTemplate, PreWait: int(preWaitNum), PostWait: int(postWaitNum)}, nil }, }) } diff --git a/internal/processor/sip-response-dtmf-create.go b/internal/processor/sip-response-dtmf-create.go new file mode 100644 index 0000000..55f3967 --- /dev/null +++ b/internal/processor/sip-response-dtmf-create.go @@ -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 + }, + }) +}