diff --git a/devtools/mocktwilio/server.go b/devtools/mocktwilio/server.go index 9e6f628cc2..31b56a5915 100644 --- a/devtools/mocktwilio/server.go +++ b/devtools/mocktwilio/server.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "math/rand" "net/http" "net/url" "strings" @@ -47,6 +48,7 @@ type Server struct { messages map[string]*SMS calls map[string]*VoiceCall + msgSvc map[string][]string mux *http.ServeMux @@ -71,6 +73,7 @@ func NewServer(cfg Config) *Server { mux: http.NewServeMux(), messages: make(map[string]*SMS), calls: make(map[string]*VoiceCall), + msgSvc: make(map[string][]string), smsCh: make(chan *SMS), smsInCh: make(chan *SMS), callCh: make(chan *VoiceCall), @@ -196,6 +199,45 @@ func (s *Server) SetCarrierInfo(number string, info twilio.CarrierInfo) { s.carrierInfo[number] = info } +// getFromNumber will return a random number from the messaging service if ID is a +// messaging SID, or the value itself otherwise. +func (s *Server) getFromNumber(id string) string { + if !strings.HasPrefix(id, "MG") { + return id + } + + s.mx.Lock() + defer s.mx.Unlock() + + // select a random number from the message service + if len(s.msgSvc[id]) == 0 { + return "" + } + + return s.msgSvc[id][rand.Intn(len(s.msgSvc[id]))] +} + +// NewMessagingService registers a new Messaging SID for the given numbers. +func (s *Server) NewMessagingService(smsURL, voiceURL string, numbers ...string) (string, error) { + err := validate.Many( + validate.URL("SMS URL", smsURL), + validate.URL("Voice URL", voiceURL), + ) + if err != nil { + return "", err + } + svcID := s.id("MG") + + s.mx.Lock() + defer s.mx.Unlock() + for _, num := range numbers { + s.callbacks["SMS:"+num] = smsURL + s.callbacks["VOICE:"+num] = voiceURL + } + + return svcID, nil +} + // RegisterSMSCallback will set/update a callback URL for SMS calls made to the given number. func (s *Server) RegisterSMSCallback(number, url string) error { err := validate.URL("URL", url) diff --git a/devtools/mocktwilio/sms.go b/devtools/mocktwilio/sms.go index 786e565a19..5b1474c658 100644 --- a/devtools/mocktwilio/sms.go +++ b/devtools/mocktwilio/sms.go @@ -29,7 +29,8 @@ type SMS struct { doneCh chan struct{} } -func (s *Server) sendSMS(from, to, body, statusURL, destURL string) (*SMS, error) { +func (s *Server) sendSMS(fromValue, to, body, statusURL, destURL string) (*SMS, error) { + fromNumber := s.getFromNumber(fromValue) if statusURL != "" { err := validate.URL("StatusCallback", statusURL) if err != nil { @@ -39,7 +40,7 @@ func (s *Server) sendSMS(from, to, body, statusURL, destURL string) (*SMS, error } } s.mx.RLock() - _, hasCallback := s.callbacks["SMS:"+from] + _, hasCallback := s.callbacks["SMS:"+fromNumber] s.mx.RUnlock() if !hasCallback { @@ -63,7 +64,6 @@ func (s *Server) sendSMS(from, to, body, statusURL, destURL string) (*SMS, error s: s, msg: twilio.Message{ To: to, - From: from, Status: twilio.MessageStatusAccepted, SID: s.id("SM"), }, @@ -75,6 +75,12 @@ func (s *Server) sendSMS(from, to, body, statusURL, destURL string) (*SMS, error doneCh: make(chan struct{}), } + if strings.HasPrefix(fromValue, "MG") { + sms.msg.MessagingServiceSID = fromValue + } else { + sms.msg.From = fromValue + } + s.mx.Lock() s.messages[sms.msg.SID] = sms s.mx.Unlock() @@ -130,6 +136,15 @@ func (s *Server) serveMessageStatus(w http.ResponseWriter, req *http.Request) { func (sms *SMS) updateStatus(stat twilio.MessageStatus) { sms.mx.Lock() sms.msg.Status = stat + switch stat { + case twilio.MessageStatusAccepted, twilio.MessageStatusQueued: + default: + if sms.msg.MessagingServiceSID == "" { + break + } + + sms.msg.From = sms.s.getFromNumber(sms.msg.MessagingServiceSID) + } sms.mx.Unlock() if sms.statusURL == "" { diff --git a/devtools/mocktwilio/voicecall.go b/devtools/mocktwilio/voicecall.go index 361c5e7c8b..01aac420fc 100644 --- a/devtools/mocktwilio/voicecall.go +++ b/devtools/mocktwilio/voicecall.go @@ -150,9 +150,10 @@ func (s *Server) serveNewCall(w http.ResponseWriter, req *http.Request) { hangupCh: make(chan struct{}), } - vc.call.From = req.FormValue("From") + fromValue := req.FormValue("From") + fromNumber := s.getFromNumber(fromValue) s.mx.RLock() - _, hasCallback := s.callbacks["VOICE:"+vc.call.From] + _, hasCallback := s.callbacks["VOICE:"+fromNumber] s.mx.RUnlock() if !hasCallback { apiError(400, w, &twilio.Exception{ @@ -160,6 +161,13 @@ func (s *Server) serveNewCall(w http.ResponseWriter, req *http.Request) { }) return } + + if strings.HasPrefix(fromValue, "MG") { + vc.call.MessagingServiceSID = fromValue + } else { + vc.call.From = fromValue + } + vc.s = s vc.call.To = req.FormValue("To") vc.call.SID = s.id("CA") @@ -211,10 +219,20 @@ func (vc *VoiceCall) updateStatus(stat twilio.CallStatus) { // move to queued vc.mx.Lock() vc.call.Status = stat - if stat == twilio.CallStatusInProgress { - vc.callStart = time.Now() + switch stat { + case twilio.CallStatusQueued, twilio.CallStatusInitiated: + default: + if vc.call.MessagingServiceSID == "" { + break + } + + vc.call.From = vc.s.getFromNumber(vc.call.MessagingServiceSID) } - if stat == twilio.CallStatusCompleted { + + switch stat { + case twilio.CallStatusInProgress: + vc.callStart = time.Now() + case twilio.CallStatusCompleted: vc.call.CallDuration = time.Since(vc.callStart) } *vc.call.SequenceNumber++ diff --git a/graphql2/graphqlapp/toolbox.go b/graphql2/graphqlapp/toolbox.go index 9ce0e04ed0..b68687ac0a 100644 --- a/graphql2/graphqlapp/toolbox.go +++ b/graphql2/graphqlapp/toolbox.go @@ -1,7 +1,7 @@ package graphqlapp import ( - context "context" + "context" "fmt" "net/url" @@ -24,7 +24,7 @@ func (a *Mutation) DebugSendSms(ctx context.Context, input graphql2.DebugSendSMS err = validate.Many( validate.Phone("To", input.To), - validate.Phone("From", input.From), + validate.TwilioFromValue("From", input.From), validate.Text("Body", input.Body, 1, 1000), ) if err != nil { diff --git a/notification/twilio/call.go b/notification/twilio/call.go index 51e4615aaa..819c38f79c 100644 --- a/notification/twilio/call.go +++ b/notification/twilio/call.go @@ -55,6 +55,8 @@ type Call struct { CallDuration time.Duration ErrorMessage *string ErrorCode *CallErrorCode + + MessagingServiceSID string `json:"messaging_service_sid"` } func (call *Call) sentMessage() *notification.SentMessage { diff --git a/notification/twilio/message.go b/notification/twilio/message.go index 5247a0ee35..13658f02e4 100644 --- a/notification/twilio/message.go +++ b/notification/twilio/message.go @@ -67,6 +67,8 @@ type Message struct { Status MessageStatus ErrorCode *MessageErrorCode ErrorMessage *string + + MessagingServiceSID string `json:"messaging_service_sid"` } func (msg *Message) sentMessage() *notification.SentMessage { diff --git a/smoketest/harness/harness.go b/smoketest/harness/harness.go index 7398e89b5a..74dc73423d 100644 --- a/smoketest/harness/harness.go +++ b/smoketest/harness/harness.go @@ -73,6 +73,8 @@ type Harness struct { t *testing.T closing bool + msgSvcID string + tw *twilioAssertionAPI twS *httptest.Server @@ -641,6 +643,25 @@ func (h *Harness) TwilioNumber(id string) string { return num } +// TwilioMessagingService will return the id and phone numbers for the mock messaging service. +func (h *Harness) TwilioMessagingService() string { + h.mx.Lock() + if h.msgSvcID != "" { + h.mx.Unlock() + return h.msgSvcID + } + defer h.mx.Unlock() + + nums := []string{h.phoneCCG.Get(""), h.phoneCCG.Get(""), h.phoneCCG.Get("")} + newID, err := h.tw.NewMessagingService(h.URL()+"/v1/twilio/sms/messages", h.URL()+"/v1/twilio/voice/call", nums...) + if err != nil { + panic(err) + } + + h.msgSvcID = newID + return newID +} + // CreateUser generates a random user. func (h *Harness) CreateUser() (u *user.User) { h.t.Helper() diff --git a/validation/validate/twiliofromvalue.go b/validation/validate/twiliofromvalue.go new file mode 100644 index 0000000000..0cb1eeab07 --- /dev/null +++ b/validation/validate/twiliofromvalue.go @@ -0,0 +1,19 @@ +package validate + +import ( + "strings" + + "github.com/target/goalert/validation" +) + +// TwlioFromValue will validate a from value as either a phone number, or messaging service SID starting with 'MG'. +func TwilioFromValue(fname, value string) error { + switch { + case strings.HasPrefix(value, "+"): + return Phone(fname, value) + case strings.HasPrefix(value, "MG"): + return ASCII(fname, value, 2, 64) + } + + return validation.NewFieldError(fname, "must be a valid phone number or alphanumeric sender ID") +} diff --git a/web/src/app/admin/AdminFieldComponents.tsx b/web/src/app/admin/AdminFieldComponents.tsx index 2b07ef4a79..1aedd7fed5 100644 --- a/web/src/app/admin/AdminFieldComponents.tsx +++ b/web/src/app/admin/AdminFieldComponents.tsx @@ -9,7 +9,7 @@ import VisibilityOff from '@material-ui/icons/VisibilityOff' import TelTextField from '../util/TelTextField' interface InputProps { - type: string + type?: string name: string value: string password?: boolean @@ -36,7 +36,7 @@ export function StringInput(props: InputProps): JSX.Element { ) } - if (type === 'tel') { + if (props.name === 'Twilio.FromNumber') { return onChange(e.target.value)} {...rest} /> } return ( diff --git a/web/src/app/admin/AdminNumberLookup.tsx b/web/src/app/admin/AdminNumberLookup.tsx index 6a5ca095c1..e305ce7e89 100644 --- a/web/src/app/admin/AdminNumberLookup.tsx +++ b/web/src/app/admin/AdminNumberLookup.tsx @@ -96,8 +96,6 @@ export default function AdminNumberLookup(): JSX.Element { }} value={number} label='Phone Number' - helperText='Please provide your country code e.g. +1 (USA)' - type='tel' /> diff --git a/web/src/app/admin/AdminSMSSend.tsx b/web/src/app/admin/AdminSMSSend.tsx index 34befd45a5..728da7bc53 100644 --- a/web/src/app/admin/AdminSMSSend.tsx +++ b/web/src/app/admin/AdminSMSSend.tsx @@ -21,6 +21,7 @@ import AppLink from '../util/AppLink' import TelTextField from '../util/TelTextField' import LoadingButton from '../loading/components/LoadingButton' import DialogContentError from '../dialogs/components/DialogContentError' +import FromValueField from '../util/FromValueField' const sendSMSMutation = gql` mutation DebugSendSMS($input: DebugSendSMSInput!) { @@ -65,13 +66,10 @@ export default function AdminSMSSend(): JSX.Element { - setFromNumber(e.target.value)} value={fromNumber} fullWidth - label='From Number' - helperText='Please provide your country code e.g. +1 (USA)' - type='tel' /> @@ -80,8 +78,6 @@ export default function AdminSMSSend(): JSX.Element { value={toNumber} fullWidth label='To Number' - helperText='Please provide your country code e.g. +1 (USA)' - type='tel' /> @@ -90,9 +86,6 @@ export default function AdminSMSSend(): JSX.Element { value={body} fullWidth label='Body' - InputLabelProps={{ - shrink: true, - }} multiline /> @@ -112,8 +105,11 @@ export default function AdminSMSSend(): JSX.Element {
- Sent from {sendStatus.data.debugSendSMS.fromNumber}. Open in - Twilio  + {/* TODO: query for message status if from number / SID not immediately available */} + {sendStatus.data.debugSendSMS.fromNumber + ? `Sent from ${sendStatus.data.debugSendSMS.fromNumber}. ` + : ''} + Open in Twilio 
diff --git a/web/src/app/admin/AdminSection.tsx b/web/src/app/admin/AdminSection.tsx index b6e082bf73..b203f3ca47 100644 --- a/web/src/app/admin/AdminSection.tsx +++ b/web/src/app/admin/AdminSection.tsx @@ -97,7 +97,6 @@ export default function AdminSection(props: AdminSectionProps): JSX.Element { />
({ justifyContent: 'center', }, }, - gridItem: { - [theme.breakpoints.up('md')]: { - maxWidth: '65%', - }, - }, groupTitle: { fontSize: '1.1rem', }, @@ -29,7 +24,7 @@ export default function AdminToolbox(): JSX.Element { return ( - + - + - {!edit && ( - - Please provide your country code e.g. +1 (USA), +91 (India), +44 (UK) - - )} ) } diff --git a/web/src/app/users/UserPhoneNumberFilterContainer.tsx b/web/src/app/users/UserPhoneNumberFilterContainer.tsx index 680a425d6d..8421a4931a 100644 --- a/web/src/app/users/UserPhoneNumberFilterContainer.tsx +++ b/web/src/app/users/UserPhoneNumberFilterContainer.tsx @@ -52,8 +52,6 @@ export default function UserPhoneNumberFilterContainer( fullWidth name='user-phone-search' label='Search by Phone Number' - helperText='Please provide your country code e.g. +1 (USA)' - type='tel' /> diff --git a/web/src/app/util/FromValueField.tsx b/web/src/app/util/FromValueField.tsx new file mode 100644 index 0000000000..7aa4b462bd --- /dev/null +++ b/web/src/app/util/FromValueField.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react' +import TextField, { TextFieldProps } from '@material-ui/core/TextField' +import TelTextField from './TelTextField' +import ClickableText from './ClickableText' +import ToggleIcon from '@material-ui/icons/CompareArrows' + +export default function FromValueField( + props: TextFieldProps & { value: string }, +): JSX.Element { + const [phoneMode, setPhoneMode] = useState( + props.value === '' || props.value.startsWith('+'), + ) + useEffect(() => { + if (props.value === '') return // don't change phone mode if empty + setPhoneMode(props.value.startsWith('+')) + }, [props.value]) + + if (!phoneMode) { + return ( + { + if (!props.onChange) return + + e.target.value = e.target.value.trim().toLowerCase() + + if (e.target.value === 'm') { + e.target.value = 'M' + } else if (e.target.value === 'mg') { + e.target.value = 'MG' + } else if (e.target.value.startsWith('mg')) { + e.target.value = 'MG' + e.target.value.replace(/[^0-9a-f]/g, '') + } else { + e.target.value = '' + } + + props.onChange(e) + }} + helperText={ + } + onClick={(_e: unknown) => { + setPhoneMode(true) + if (!props.onChange) return + + const e = _e as React.ChangeEvent + e.target.value = '' + props.onChange(e) + }} + > + Use a phone number + + } + /> + ) + } + + return ( + } + onClick={(_e: unknown) => { + setPhoneMode(false) + if (!props.onChange) return + + const e = _e as React.ChangeEvent + e.target.value = '' + props.onChange(e) + }} + > + Use a Messaging Service SID + + } + /> + ) +} diff --git a/web/src/app/util/TelTextField.tsx b/web/src/app/util/TelTextField.tsx index bbfe813f40..3e4dad765a 100644 --- a/web/src/app/util/TelTextField.tsx +++ b/web/src/app/util/TelTextField.tsx @@ -89,6 +89,10 @@ export default function TelTextField( function handleChange(e: React.ChangeEvent): void { if (!props.onChange) return if (!e.target.value) return props.onChange(e) + + // ignore SID being pasted in + if (e.target.value.toLowerCase().startsWith('mg')) return + e.target.value = '+' + e.target.value.replace(/[^0-9]/g, '') return props.onChange(e) } @@ -99,6 +103,10 @@ export default function TelTextField( {...props} InputProps={iprops} type={props.type || 'tel'} + helperText={ + props.helperText || + 'Include country code e.g. +1 (USA), +91 (India), +44 (UK)' + } onChange={handleChange} value={(props.value || '').replace(/[^0-9]/g, '')} />