-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtwilio.go
287 lines (249 loc) · 9.88 KB
/
twilio.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
package signup
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"github.com/twilio/twilio-go"
"github.com/twilio/twilio-go/client"
conversations "github.com/twilio/twilio-go/rest/conversations/v1"
)
type (
smsService struct {
// Twilio API base URL. This is used as an override for testing API calls.
apiBase string
// Client for making requests to Twilio's API.
client *twilio.RestClient
// Phone number SMS messages are sent from.
fromPhoneNum string
// API base for Operation Spark's SMS Messaging interface.
// This URL is used for sending webhooks on SMS events from this service.
// Default: https://messenger.operationspark.org
opSparkMessagingSvcBaseURL string
// Twilio Conversation (Chat) Service ID.
// Ex: "IS00000000000000000000000000000000"
conversationsSid string
// Twilio Conversations Service User identity name.
// Ex: "[email protected]"
conversationsIdentity string
}
// error type for invalid phone numbers
ErrInvalidNumber struct {
err error
}
twilioServiceOptions struct {
accountSID string
authToken string
// Client for making requests to Twilio's API.
client client.BaseClient
// Phone number SMS messages are sent from.
fromPhoneNum string
// API base for Operation Spark's SMS Messaging interface.
// This URL is used for sending webhooks on SMS events from this service.
// Default: https://messenger.operationspark.org
opSparkMessagingSvcBaseURL string
// Twilio API base.
apiBase string
conversationsSid string
// Twilio Conversations Service User identity name.
// Ex: "[email protected]"
conversationsIdentity string
}
)
func (e ErrInvalidNumber) Error() string {
return e.err.Error()
}
func NewTwilioService(o twilioServiceOptions) *smsService {
messengerBaseURL := "https://messenger.operationspark.org"
if len(o.opSparkMessagingSvcBaseURL) > 0 {
messengerBaseURL = o.opSparkMessagingSvcBaseURL
}
// Override for testing
apiBase := "https://api.twilio.com"
if len(o.apiBase) > 0 {
apiBase = o.apiBase
}
conversationsIdentity := "[email protected]"
if len(o.conversationsIdentity) > 0 {
conversationsIdentity = o.conversationsIdentity
}
return &smsService{
apiBase: apiBase,
client: twilio.NewRestClientWithParams(twilio.ClientParams{
Username: o.accountSID,
Password: o.authToken,
Client: o.client,
}),
fromPhoneNum: o.fromPhoneNum,
opSparkMessagingSvcBaseURL: messengerBaseURL,
conversationsSid: o.conversationsSid,
conversationsIdentity: conversationsIdentity,
}
}
// IsRequired returns false because we're now going to send the information URL back to the client in the response body. So if the SMS message fails to send, the user will still have the information URL.
func (s smsService) isRequired() bool {
return false
}
// Run sends an Info Session signup confirmation SMS to the registered participant. We use Twilio's Conversations API instead of the Messaging API to allow multiple staff members communicate with the participant through the same outgoing SMS number.
// The confirmation SMS contains a link that when clicked generates a custom page containing information on the upcoming Info Session. This signup-specific link is shortened before sent.
//
// Note: Twilio has a free Link Shortening service, but it is only available with the Messaging API, not Conversations.
func (t *smsService) run(ctx context.Context, su Signup) error {
if !su.SMSOptIn {
fmt.Printf("User opted-out from SMS messages: %s\n", su.String())
return nil
}
toNum := t.FormatCell(su.Cell)
convoName := fmt.Sprintf("%s %s", su.NameFirst, su.NameLast[0:1])
convoId := ""
existing, err := t.findConversationsByNumber(toNum)
if err != nil {
return fmt.Errorf("findConversationsByNumber: %w", err)
}
// Create a new conversation if none exists
if len(existing) == 0 {
convoId, err = t.addNumberToConversation(toNum, convoName)
if err != nil {
twilioInvalidPhoneCode := "50407"
// check if error is due to number being invalid, if so use the errInvalidNumber type
if strings.Contains(err.Error(), twilioInvalidPhoneCode) {
return ErrInvalidNumber{err: fmt.Errorf("invalid number: %s", toNum)}
}
return fmt.Errorf("addNumberToConversation: %w", err)
}
} else {
// TODO: Fix this potentially faulty logic if picking the first existing conversation
convoId = *existing[0].ConversationSid
}
// Send Opt-in confirmation
if err := t.optInConfirmation(ctx, toNum); err != nil {
return fmt.Errorf("optInConfirmation: %w", err)
}
if su.ShortLink == "" {
// This should never happen
return fmt.Errorf("shortLink is empty")
}
// Create the SMS message body
msg, err := su.shortMessage(su.ShortLink)
if err != nil {
return fmt.Errorf("shortMessage: %w", err)
}
err = t.sendSMSInConversation(msg, convoId)
if err != nil {
return fmt.Errorf("sendSMS: %w", err)
}
err = t.sendConvoWebhook(ctx, convoId)
if err != nil {
fmt.Fprintf(os.Stderr, "sendConvoWebhook (messenger API): %v", err)
}
// Carry on even if the Messenger API webhook fails
return nil
}
func (t *smsService) name() string {
return "twilio service"
}
// SendSMSInConversation uses the Twilio Conversations API to send a message to a specific Conversation. Twilio will then broadcast the message to the Conversation participants. In our case, this is two SMS-capable phone numbers.
func (t *smsService) sendSMSInConversation(body string, convoId string) error {
params := &conversations.CreateServiceConversationMessageParams{
Body: &body,
Author: &t.conversationsIdentity,
}
_, err := t.client.ConversationsV1.CreateServiceConversationMessage(t.conversationsSid, convoId, params)
if err != nil {
return fmt.Errorf("createServiceConversationMessage: %w", err)
}
return nil
}
// FindConversationsByNumber finds all Twilio Conversations that have the given phone number as a participant.
func (t *smsService) findConversationsByNumber(phNum string) ([]conversations.ConversationsV1ServiceParticipantConversation, error) {
params := &conversations.ListServiceParticipantConversationParams{}
params.SetAddress(phNum)
params.SetLimit(20)
resp, err := t.client.ConversationsV1.ListServiceParticipantConversation(t.conversationsSid, params)
if err != nil {
return resp, fmt.Errorf("listServiceParticipantConversation: %w", err)
}
return resp, nil
}
// AddNumberToConversation creates a new Conversation and adds two participants - the Operation Spark Service Identity ("[email protected]"), and the SMS recipient's phone number.
func (t *smsService) addNumberToConversation(phNum, friendlyName string) (string, error) {
cp := &conversations.CreateServiceConversationParams{}
cp.SetFriendlyName(friendlyName)
// Create new Conversation
cResp, err := t.client.ConversationsV1.CreateServiceConversation(t.conversationsSid, cp)
if err != nil {
return "", fmt.Errorf("createServiceConversation: %w", err)
}
// Add Operation Spark Conversation Identity
ppp := &conversations.CreateServiceConversationParticipantParams{}
ppp.SetIdentity(t.conversationsIdentity)
_, err = t.client.ConversationsV1.CreateServiceConversationParticipant(t.conversationsSid, *cResp.Sid, ppp)
if err != nil {
return "", fmt.Errorf("createServiceConversationParticipant with Service Identity: %w: ", err)
}
// Add SMS Recipient to conversation
pp := &conversations.CreateServiceConversationParticipantParams{}
pp.SetMessagingBindingAddress(phNum)
pp.SetMessagingBindingProxyAddress(t.fromPhoneNum)
friendlyNameWithNum := fmt.Sprintf("%s (%s)", friendlyName, phNum)
pp.SetAttributes(fmt.Sprintf(`{"friendlyName": %q}`, friendlyNameWithNum))
_, err = t.client.ConversationsV1.CreateServiceConversationParticipant(t.conversationsSid, *cResp.Sid, pp)
if err != nil {
return "", fmt.Errorf("createServiceConversationParticipant: %w\nidentity: %q", err, phNum)
}
return *cResp.Sid, nil
}
// FormatCell prepends the US country code, "+1", and removes any dashes from a phone number string.
func (t *smsService) FormatCell(cell string) string {
return "+1" + strings.ReplaceAll(cell, "-", "")
}
// SendConvoWebhook sends a webhook to OS Messaging Service to indicate a new Conversation was created.
func (t *smsService) sendConvoWebhook(ctx context.Context, convoID string) error {
url := fmt.Sprintf("%s/api/webhooks/conversation/%s", t.opSparkMessagingSvcBaseURL, convoID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return fmt.Errorf("newRequest: %w", err)
}
req.Header.Add("key", os.Getenv("URL_SHORTENER_API_KEY"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("do: %w", err)
}
if resp.StatusCode >= 300 {
return handleHTTPError(resp)
}
return nil
}
func (t *smsService) optInConfirmation(ctx context.Context, toNum string) error {
msg := "You've opted in for texts from Operation Spark for upcoming sessions. You can text us here if you have further questions. Message and data rates may apply. Reply STOP to unsubscribe."
return t.Send(ctx, toNum, msg)
}
// Send sends an SMS message to the given toNum and returns an error.
func (t *smsService) Send(ctx context.Context, toNum string, msg string) error {
// TODO: Maybe consolidate this code with some of the run() code
convoId := ""
existing, err := t.findConversationsByNumber(toNum)
if err != nil {
return fmt.Errorf("findConversationsByNumber: %w", err)
}
// Create a new conversation if none exists
// I think we should always already have an existing conversation
if len(existing) == 0 {
convoId, err = t.addNumberToConversation(toNum, toNum)
if err != nil {
return fmt.Errorf("addNumberToConversation: %w", err)
}
} else {
if len(existing) > 1 {
return fmt.Errorf("found more than one existing conversation for cell: %q. %+v", toNum, existing)
}
// There can only be one..
convoId = *existing[0].ConversationSid
}
err = t.sendSMSInConversation(msg, convoId)
if err != nil {
return fmt.Errorf("sendSMS: %w", err)
}
return nil
}