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 May 30, 2024
1 parent 43f290b commit eaf4544
Show file tree
Hide file tree
Showing 2 changed files with 291 additions and 22 deletions.
126 changes: 104 additions & 22 deletions handlers/twiml/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"crypto/sha1"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
Expand All @@ -26,6 +27,7 @@ import (
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/i18n"
"github.com/nyaruka/gocommon/urns"
"golang.org/x/exp/maps"
)

const (
Expand Down Expand Up @@ -235,36 +237,33 @@ 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.ErrFailedWithReason("", "template missing contentSID")
}

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)

// 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, _ := json.Marshal(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 +314,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 352 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L352

Added line #L352 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 357 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L357

Added line #L357 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 362 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L362

Added line #L362 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 370 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L370

Added line #L370 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.ErrFailedWithReason("", "template missing contentSID"),
},
{
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

0 comments on commit eaf4544

Please sign in to comment.