Skip to content

Commit

Permalink
Add support for sending Twilio Template
Browse files Browse the repository at this point in the history
  • Loading branch information
norkans7 committed Jun 3, 2024
1 parent 43f290b commit 0b31b51
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 20 deletions.
128 changes: 108 additions & 20 deletions handlers/twiml/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ import (
"github.com/nyaruka/courier/utils"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/i18n"
"github.com/nyaruka/gocommon/jsonx"
"github.com/nyaruka/gocommon/urns"
"golang.org/x/exp/maps"
)

const (
Expand Down Expand Up @@ -235,36 +237,39 @@ func (h *handler) Send(ctx context.Context, msg courier.MsgOut, res *courier.Sen
return err
}

parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength)
for i, part := range parts {
// build our request
// do we have a template and support whatsapp scheme?
if msg.Templating() != nil && channel.IsScheme(urns.WhatsApp) {
if msg.Templating().ExternalID == "" {
return courier.ErrMessageInvalid
}

form := url.Values{
"To": []string{msg.URN().Path()},
"Body": []string{part},
"To": []string{fmt.Sprintf("%s:+%s", urns.WhatsApp.Prefix, msg.URN().Path())},
"StatusCallback": []string{callbackURL},
"ContentSid": []string{msg.Templating().ExternalID},
"From": []string{fmt.Sprintf("%s:%s", urns.WhatsApp.Prefix, channel.Address())},
}

// add any attachments to the first part
if i == 0 {
for _, a := range attachments {
form.Add("MediaUrl", a.URL)
contentVariables := make(map[string]string, len(msg.Templating().Variables))

for _, comp := range msg.Templating().Components {
for varKey, varIndex := range comp.Variables {
contentVariables[varKey] = msg.Templating().Variables[varIndex].Value
}
}

// set our from, either as a messaging service or from our address
serviceSID := channel.StringConfigForKey(configMessagingServiceSID, "")
if serviceSID != "" {
form["MessagingServiceSid"] = []string{serviceSID}
} else {
form["From"] = []string{channel.Address()}
for _, comp := range msg.Templating().Components {
varNames := maps.Keys(comp.Variables)
sort.Strings(varNames)
for _, varName := range varNames {
contentVariables[varName] = msg.Templating().Variables[comp.Variables[varName]].Value
}
}

// for whatsapp channels, we have to prepend whatsapp to the To and From
if channel.IsScheme(urns.WhatsApp) {
form["To"][0] = fmt.Sprintf("%s:+%s", urns.WhatsApp.Prefix, form["To"][0])
form["From"][0] = fmt.Sprintf("%s:%s", urns.WhatsApp.Prefix, form["From"][0])
contentVariablesJson := jsonx.MustMarshal(contentVariables)
if len(contentVariables) > 0 {
form["ContentVariables"] = []string{string(contentVariablesJson)}
}

// build our URL
baseURL := h.baseURL(channel)
if baseURL == "" {
Expand Down Expand Up @@ -315,6 +320,89 @@ func (h *handler) Send(ctx context.Context, msg courier.MsgOut, res *courier.Sen
res.AddExternalID(externalID)
}

} else {

parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength)
for i, part := range parts {
// build our request
form := url.Values{
"To": []string{msg.URN().Path()},
"Body": []string{part},
"StatusCallback": []string{callbackURL},
}

// add any attachments to the first part
if i == 0 {
for _, a := range attachments {
form.Add("MediaUrl", a.URL)
}
}

// set our from, either as a messaging service or from our address
serviceSID := channel.StringConfigForKey(configMessagingServiceSID, "")
if serviceSID != "" {
form["MessagingServiceSid"] = []string{serviceSID}
} else {
form["From"] = []string{channel.Address()}
}

// for whatsapp channels, we have to prepend whatsapp to the To and From
if channel.IsScheme(urns.WhatsApp) {
form["To"][0] = fmt.Sprintf("%s:+%s", urns.WhatsApp.Prefix, form["To"][0])
form["From"][0] = fmt.Sprintf("%s:%s", urns.WhatsApp.Prefix, form["From"][0])
}

// build our URL
baseURL := h.baseURL(channel)
if baseURL == "" {
return courier.ErrChannelConfig

Check warning on line 358 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L358

Added line #L358 was not covered by tests
}

sendURL, err := utils.AddURLPath(baseURL, "2010-04-01", "Accounts", accountSID, "Messages.json")
if err != nil {
return err

Check warning on line 363 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L363

Added line #L363 was not covered by tests
}

req, err := http.NewRequest(http.MethodPost, sendURL, strings.NewReader(form.Encode()))
if err != nil {
return err

Check warning on line 368 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L368

Added line #L368 was not covered by tests
}
req.SetBasicAuth(accountSID, accountToken)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")

resp, respBody, err := h.RequestHTTP(req, clog)
if err != nil || resp.StatusCode/100 == 5 {
return courier.ErrConnectionFailed

Check warning on line 376 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L376

Added line #L376 was not covered by tests
}

// see if we can parse the error if we have one
if resp.StatusCode/100 != 2 && len(respBody) > 0 {
errorCode, _ := jsonparser.GetInt(respBody, "code")
if errorCode != 0 {
if errorCode == errorStopped {
return courier.ErrContactStopped
}
codeAsStr := strconv.Itoa(int(errorCode))
errMsg, err := jsonparser.GetString(errorCodes, codeAsStr)
if err != nil {
errMsg = fmt.Sprintf("Service specific error: %s.", codeAsStr)
}
return courier.ErrFailedWithReason(codeAsStr, errMsg)
}

return courier.ErrResponseStatus
}

// grab the external id
externalID, err := jsonparser.GetString(respBody, "sid")
if err != nil {
clog.Error(courier.ErrorResponseValueMissing("sid"))
} else {
res.AddExternalID(externalID)
}
}

}

return nil
Expand Down
187 changes: 187 additions & 0 deletions handlers/twiml/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,35 @@ var waSendTestCases = []OutgoingTestCase{
}},
ExpectedExtIDs: []string{"1002"},
},
{
Label: "Template Send",
MsgText: "templated message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"http://example.com/sigware_api/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(200, nil, []byte(`{ "sid": "1002" }`)),
},
},

ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedExtIDs: []string{"1002"},
},
}

var twaSendTestCases = []OutgoingTestCase{
Expand All @@ -1087,6 +1116,164 @@ var twaSendTestCases = []OutgoingTestCase{
}},
ExpectedExtIDs: []string{"1002"},
},
{
Label: "Template Send",
MsgText: "templated message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"https://api.twilio.com/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(200, nil, []byte(`{ "sid": "1002" }`)),
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/twa/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedExtIDs: []string{"1002"},
},
{
Label: "Template Send missing external ID",
MsgText: "templated message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"language": "en_US"
}`,
ExpectedError: courier.ErrMessageInvalid,
},
{
Label: "Error Code",
MsgText: "Error Code",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"https://api.twilio.com/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(400, nil, []byte(`{ "code": 1001 }`)),
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/twa/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedError: courier.ErrFailedWithReason("1001", "Service specific error: 1001."),
},
{
Label: "Stopped Contact Code",
MsgText: "Stopped Contact",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"https://api.twilio.com/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(400, nil, []byte(`{ "code": 21610 }`)),
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/twa/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedError: courier.ErrContactStopped,
},
{
Label: "No SID",
MsgText: "No SID",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"https://api.twilio.com/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(200, nil, []byte(`{ }`)),
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/twa/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedLogErrors: []*courier.ChannelError{courier.ErrorResponseValueMissing("sid")},
},
{
Label: "Error Sending",
MsgText: "Error Message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgTemplating: `{
"template": {"uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3", "name": "revive_issue"},
"components": [
{"type": "body", "name": "body", "variables": {"1": 0, "2": 1}}
],
"variables": [
{"type": "text", "value": "Chef"},
{"type": "text" , "value": "tomorrow"}
],
"external_id": "ext_id_revive_issue",
"language": "en_US"
}`,
MockResponses: map[string][]*httpx.MockResponse{
"https://api.twilio.com/2010-04-01/Accounts/accountSID/Messages.json": {
httpx.NewMockResponse(401, nil, []byte(`{ "error": "out of credits" }`)),
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/twa/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}, "ContentSid": {"ext_id_revive_issue"}, "ContentVariables": {"{\"1\":\"Chef\",\"2\":\"tomorrow\"}"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedError: courier.ErrResponseStatus,
},
}

func TestOutgoing(t *testing.T) {
Expand Down
9 changes: 9 additions & 0 deletions sender.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ var ErrConnectionThrottled error = &SendError{
clogMsg: "Connection to server has been rate limited.",
}

// ErrMessageInvalid should be returned by a handler send method when the message it has received is invalid
var ErrMessageInvalid error = &SendError{
msg: "message invalid",
retryable: false,
loggable: true,
clogCode: "message_invalid",
clogMsg: "Message is missing required values.",
}

// ErrResponseStatus should be returned when channel the response has a non-success status code
var ErrResponseStatus error = &SendError{
msg: "response status code",
Expand Down

0 comments on commit 0b31b51

Please sign in to comment.