Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

dev: mocktwilio refactor #2602

Draft
wants to merge 54 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
1784ebb
rewrite mocktwilio with assertions
mastercactapus Aug 17, 2022
11e5bd3
add numbers after startup
mastercactapus Aug 17, 2022
6fc65e1
update harness to use mocktwilio package for assertions
mastercactapus Aug 17, 2022
243efa5
fix server
mastercactapus Aug 17, 2022
4c02a8b
add simple functionality test
mastercactapus Aug 17, 2022
5317ab6
add post message to db
mastercactapus Aug 17, 2022
5b35c9c
test additional endpoints
mastercactapus Aug 17, 2022
ca42364
fix startup race
mastercactapus Aug 17, 2022
4c1b658
put date in url
mastercactapus Aug 17, 2022
251f46a
add better logs
mastercactapus Aug 17, 2022
88e6e20
fix fast-forward time
mastercactapus Aug 17, 2022
53c4a88
clairify message output
mastercactapus Aug 17, 2022
6b9059c
fix waitandassert
mastercactapus Aug 17, 2022
cae20fc
code organization
mastercactapus Aug 17, 2022
4aca25b
init routes
mastercactapus Aug 19, 2022
cd94238
handle call routing
mastercactapus Aug 23, 2022
a854a18
post call status updates
mastercactapus Aug 23, 2022
86a7433
twiml package
mastercactapus Aug 24, 2022
7d4e1d4
add verbs
mastercactapus Aug 25, 2022
e876fcb
add type safe interfaces
mastercactapus Aug 25, 2022
f7a9f3f
call state handling
mastercactapus Aug 25, 2022
4996cde
fix name conflict
mastercactapus Aug 25, 2022
be60927
relative url handling
mastercactapus Aug 25, 2022
79747d1
track invalid urls
mastercactapus Aug 25, 2022
12fe6ee
voice server test
mastercactapus Aug 25, 2022
4fd8663
Merge remote-tracking branch 'origin/master' into mocktwilio-refactor
mastercactapus Aug 26, 2022
76dba6a
fix message service handling
mastercactapus Aug 26, 2022
52ab665
include url and data in error
mastercactapus Aug 26, 2022
76eb335
use correct method for fetching voice status
mastercactapus Aug 26, 2022
31b708b
call status
mastercactapus Aug 26, 2022
923ed6f
fix redirect parse
mastercactapus Aug 26, 2022
22e52cc
fix intepreter order
mastercactapus Aug 26, 2022
228a909
consistent call handling
mastercactapus Aug 29, 2022
dfb2436
api updates
mastercactapus Aug 29, 2022
766a431
additional refresh
mastercactapus Aug 29, 2022
68a1d3b
refresh while waiting for calls
mastercactapus Aug 29, 2022
bf9cbe8
fix voice failure test
mastercactapus Aug 29, 2022
45a0f03
fix carrier info lookup
mastercactapus Aug 29, 2022
49f8662
remove unused code
mastercactapus Aug 29, 2022
5b0c3f8
Merge branch 'master' into mocktwilio-refactor
mastercactapus Aug 29, 2022
bc91ba2
cleanup and comment mocktwilio lib
mastercactapus Aug 31, 2022
9d2e796
fix dest
mastercactapus Aug 31, 2022
3eec0d9
Merge remote-tracking branch 'origin/master' into mocktwilio-refactor
mastercactapus Sep 12, 2022
b74d474
Merge remote-tracking branch 'origin/master' into mocktwilio-refactor
mastercactapus Nov 14, 2022
879436e
Merge branch 'master' into mocktwilio-refactor
mastercactapus Nov 21, 2022
b8b6643
Merge branch 'master' into mocktwilio-refactor
mastercactapus Dec 6, 2022
ac8185d
Merge branch 'master' into mocktwilio-refactor
mastercactapus Dec 8, 2022
d14a2ac
Merge remote-tracking branch 'origin/master' into mocktwilio-refactor
mastercactapus Dec 14, 2022
3dc3fd7
update to Text() method
mastercactapus Dec 14, 2022
6857e7c
add assertion example
mastercactapus Dec 14, 2022
999e995
add examples
mastercactapus Dec 14, 2022
9431833
make handler methods private
mastercactapus Dec 14, 2022
56a9562
move assertions to separate package
mastercactapus Dec 14, 2022
979427f
update docs
mastercactapus Dec 14, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions devtools/mocktwilio/call.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package mocktwilio

import (
"context"
)

type Call interface {
ID() string

From() string
To() string

// Text will return the last message returned by the application. It is empty until Answer is called.
Text() string

// User interactions below

Answer(context.Context) error
Hangup(context.Context, FinalCallStatus) error

// Press will simulate a press of the specified key(s).
//
// It does nothing if the call isn't waiting for input.
Press(context.Context, string) error

// PressTimeout will simulate a user waiting for the menu to timeout.
//
// It does nothing if the call isn't waiting for input.
PressTimeout(context.Context) error
}

type FinalCallStatus string

const (
CallCompleted FinalCallStatus = "completed"
CallFailed FinalCallStatus = "failed"
CallBusy FinalCallStatus = "busy"
CallNoAnswer FinalCallStatus = "no-answer"
CallCanceled FinalCallStatus = "canceled"
)

type call struct {
*callState
}

func (c *call) ID() string { return c.callState.ID }
func (c *call) From() string { return c.callState.From }
func (c *call) To() string { return c.callState.To }

func (srv *Server) Calls() <-chan Call { return srv.callCh }

// StartCall will start a new voice call.
func (srv *Server) StartCall(ctx context.Context, from, to string) error {
return nil
}
315 changes: 315 additions & 0 deletions devtools/mocktwilio/callstate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,315 @@
package mocktwilio

import (
"context"
"encoding/json"
"fmt"
"math"
"net/url"
"strconv"
"sync"
"time"

"github.com/target/goalert/devtools/mocktwilio/twiml"
)

type callState struct {
CreatedAt time.Time `json:"date_created"`
UpdatedAt time.Time `json:"date_updated"`
StartedAt *time.Time `json:"start_time,omitempty"`
EndedAt *time.Time `json:"end_time,omitempty"`
Duration string `json:"duration,omitempty"`
Direction string `json:"direction"`
MsgSID string `json:"messaging_service_sid,omitempty"`
ID string `json:"sid"`
Status string `json:"status"`
To string `json:"to"`
From string `json:"from"`

StatusURL string `json:"-"`
CallURL string `json:"-"`

srv *Server
mx sync.Mutex
seq int

action chan struct{}

run *twiml.Interpreter

text string
}

func (srv *Server) newCallState() *callState {
n := time.Now()
return &callState{
srv: srv,
ID: srv.nextID("CA"),
CreatedAt: n,
Status: "queued",
UpdatedAt: n,
action: make(chan struct{}, 1),
run: twiml.NewInterpreter(),
}
}

func (s *callState) lifecycle(ctx context.Context) {
s.setStatus(ctx, "initiated")
s.setStatus(ctx, "ringing")

select {
case <-ctx.Done():
case s.srv.callCh <- &call{s}:
}
}

// Text returns the last spoken text of the call.
func (s *callState) Text() string {
s.mx.Lock()
defer s.mx.Unlock()
return s.text
}

func (s *callState) IsActive() bool {
switch s.status() {
case "completed", "failed", "busy", "no-answer", "canceled":
return false
}

return true
}

func (s *callState) Answer(ctx context.Context) error {
s.action <- struct{}{}
if s.status() != "ringing" {
<-s.action
return nil
}

s.setStatus(ctx, "in-progress")

err := s.update(ctx, "")
if err != nil {
s.setStatus(ctx, "failed")
<-s.action
return err
}

<-s.action
return nil
}

func (s *callState) update(ctx context.Context, digits string) error {
v := make(url.Values)
v.Set("AccountSid", s.srv.cfg.AccountSID)
v.Set("ApiVersion", "2010-04-01")
v.Set("CallSid", s.ID)
v.Set("CallStatus", "in-progress")
v.Set("Direction", s.Direction)
v.Set("From", s.From)
v.Set("To", s.To)
if digits != "" {
v.Set("Digits", digits)
}

data, err := s.srv.post(ctx, s.CallURL, v)
if err != nil {
return err
}

err = s.run.SetResponse(data)
if err != nil {
return err
}

return s.process(ctx)
}

// process will interpret the returned TwiML and update the call state and spoken text.
func (s *callState) process(ctx context.Context) error {
s.text = ""

for s.run.Next() {
switch t := s.run.Verb().(type) {
case *twiml.Say:
s.text += t.Content + "\n"
case *twiml.Redirect:
err := s.setCallURL(t.URL)
if err != nil {
return err
}
return s.update(ctx, "")
case *twiml.Gather:
return nil
case *twiml.Reject:
s.setStatus(ctx, t.Reason)
return nil
case *twiml.Hangup:
s.setStatus(ctx, "completed")
return nil
case *twiml.Pause:
// ignored
}
}

return nil
}

func (s *callState) setCallURL(url string) error {
newURL := relURL(s.CallURL, url)
if newURL == "" {
return fmt.Errorf("invalid redirect url: %s", url)
}

s.CallURL = newURL
return nil
}

func (s *callState) status() string {
s.mx.Lock()
defer s.mx.Unlock()
return s.Status
}

// setStatus will update the call status, posting to the status callback URL, if provided.
func (s *callState) setStatus(ctx context.Context, status string) {
s.mx.Lock()
defer s.mx.Unlock()
if s.Status == status {
return
}

s.Status = status
s.UpdatedAt = time.Now()
switch status {
case "in-progress":
s.StartedAt = &s.UpdatedAt
case "completed", "failed", "busy", "no-answer", "canceled":
s.EndedAt = &s.UpdatedAt
if s.StartedAt == nil {
s.StartedAt = &s.UpdatedAt
}
}

if s.StatusURL == "" {
return
}

v := make(url.Values)
v.Set("AccountSid", s.srv.cfg.AccountSID)
v.Set("ApiVersion", "2010-04-01")
if s.StartedAt != nil {
dur := time.Since(*s.StartedAt)
if s.EndedAt != nil {
dur = s.EndedAt.Sub(*s.StartedAt)
}
v.Set("Duration", strconv.Itoa(int(math.Ceil(dur.Seconds()))))
}
v.Set("CallSid", s.ID)
v.Set("CallStatus", status)
v.Set("Direction", s.Direction)
v.Set("From", s.From)
v.Set("To", s.To)
s.seq++
v.Set("SequenceNumber", strconv.Itoa(s.seq))

_, err := s.srv.post(ctx, s.StatusURL, v)
if err != nil {
s.srv.logErr(ctx, err)
}
}

func (s *callState) Press(ctx context.Context, digits string) error {
s.action <- struct{}{}
if s.status() != "in-progress" {
<-s.action
return fmt.Errorf("call not in progress")
}

g, ok := s.run.Verb().(*twiml.Gather)
if !ok {
<-s.action
return fmt.Errorf("gather not in progress")
}
err := s.setCallURL(g.Action)
if err != nil {
s.setStatus(ctx, "failed")
<-s.action
return err
}

err = s.update(ctx, digits)
if err != nil {
s.setStatus(ctx, "failed")
<-s.action
return err
}

<-s.action
return nil
}

func (s *callState) PressTimeout(ctx context.Context) error {
s.action <- struct{}{}
if s.status() != "in-progress" {
<-s.action
return fmt.Errorf("call not in progress")
}

g, ok := s.run.Verb().(*twiml.Gather)
if !ok {
<-s.action
return fmt.Errorf("gather not in progress")
}
if !g.ActionOnEmptyResult {
err := s.process(ctx)
<-s.action
return err
}

err := s.setCallURL(g.Action)
if err != nil {
s.setStatus(ctx, "failed")
<-s.action
return err
}

err = s.update(ctx, "")
if err != nil {
s.setStatus(ctx, "failed")
<-s.action
return err
}

<-s.action
return nil
}

func (s *callState) Hangup(ctx context.Context, status FinalCallStatus) error {
s.action <- struct{}{}
if !s.IsActive() {
<-s.action
return nil
}

s.setStatus(ctx, string(status))
return nil
}

func (v *callState) MarshalJSON() ([]byte, error) {
if v == nil {
return []byte("null"), nil
}

v.mx.Lock()
defer v.mx.Unlock()

if v.StartedAt != nil && v.EndedAt != nil {
v.Duration = strconv.Itoa(int(v.EndedAt.Sub(*v.StartedAt).Seconds()))
} else {
v.Duration = ""
}

type data callState
return json.Marshal((*data)(v))
}
Loading