Skip to content

Support TWA templates #727

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

Closed
wants to merge 3 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion handlers/dialog360/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,7 +425,7 @@ var SendTestCasesD3C = []OutgoingTestCase{
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "components": [{"type": "body", "params": [{"type":"text", "value":"Chef"}, {"type": "text" , "value": "tomorrow"}]}], "language": "en_US"}}`),
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "components": [{"type": "body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text", "name": "2", "value": "tomorrow"}]}], "language": "en_US"}}`),
MockResponses: map[string][]*httpx.MockResponse{
"https://waba-v2.360dialog.io/messages": {
httpx.NewMockResponse(200, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)),
Expand Down
10 changes: 7 additions & 3 deletions handlers/meta/whataspp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "components": [{"type":"body", "params": [{"type":"text", "value":"Chef"}, {"type": "text" , "value": "tomorrow"}]}], "language": "en_US"}}`),
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}], "language": "en_US"}}`),
MockResponses: map[string][]*httpx.MockResponse{
"*/12345_ID/messages": {
httpx.NewMockResponse(201, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)),
Expand All @@ -410,7 +410,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "components": [], "variables": [], "language": "en_US"}}`),
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [], "variables": [], "language": "en_US"}}`),
MockResponses: map[string][]*httpx.MockResponse{
"*/12345_ID/messages": {
httpx.NewMockResponse(200, nil, []byte(`{ "messages": [{"id": "157b5e14568e8"}] }`)),
Expand All @@ -426,17 +426,19 @@ var whatsappOutgoingTests = []OutgoingTestCase{
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" },"components": [
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [
{
"type": "body",
"name": "body",
"params": [
{
"type": "text",
"name": "1",
"value": "Ryan Lewis"
},
{
"type": "text",
"name": "2",
"value": "niño"
}
]
Expand All @@ -447,6 +449,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{
"params": [
{
"type": "text",
"name": "1",
"value": "Sip"
}
]
Expand All @@ -457,6 +460,7 @@ var whatsappOutgoingTests = []OutgoingTestCase{
"params": [
{
"type": "url",
"name": "1",
"value": "id00231"
}
]
Expand Down
2 changes: 2 additions & 0 deletions handlers/meta/whatsapp/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,13 @@ type MsgTemplating struct {
UUID string `json:"uuid" validate:"required"`
} `json:"template" validate:"required,dive"`
Namespace string `json:"namespace"`
ExternalID string `json:"external_id"`
Components []struct {
Type string `json:"type"`
Name string `json:"name"`
Params []struct {
Type string `json:"type"`
Name string `json:"name"`
Value string `json:"value"`
} `json:"params"`
} `json:"components"`
Expand Down
129 changes: 110 additions & 19 deletions handlers/twiml/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"crypto/sha1"
_ "embed"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
Expand All @@ -22,9 +23,11 @@
"github.com/buger/jsonparser"
"github.com/nyaruka/courier"
"github.com/nyaruka/courier/handlers"
"github.com/nyaruka/courier/handlers/meta/whatsapp"
"github.com/nyaruka/courier/utils"
"github.com/nyaruka/gocommon/httpx"
"github.com/nyaruka/gocommon/urns"
"github.com/pkg/errors"
)

const (
Expand Down Expand Up @@ -230,34 +233,40 @@
return err
}

parts := handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength)
for i, part := range parts {
// do we have a template?
templating, err := whatsapp.GetTemplating(msg)
if err != nil {
return errors.Wrapf(err, "unable to decode template: %s for channel: %s", string(msg.Metadata()), msg.Channel().UUID())
}

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

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L239-L240

Added lines #L239 - L240 were not covered by tests

if templating != nil && channel.IsScheme(urns.WhatsAppScheme) {

if templating.ExternalID == "" {
return courier.ErrFailedWithReason("", "template missing contentSID")
}

// build our request
form := url.Values{
"To": []string{msg.URN().Path()},
"Body": []string{part},
"To": []string{fmt.Sprintf("%s:+%s", urns.WhatsAppScheme, msg.URN().Path())},
"StatusCallback": []string{callbackURL},
"ContentSid": []string{templating.ExternalID},
"From": []string{fmt.Sprintf("%s:%s", urns.WhatsAppScheme, 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)

for _, comp := range templating.Components {
if comp.Type == "body" {
for _, p := range comp.Params {
contentVariables[p.Name] = p.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()}
}
contentVariablesJson, _ := json.Marshal(contentVariables)

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

// build our URL
Expand Down Expand Up @@ -310,6 +319,88 @@
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.WhatsAppScheme) {
form["To"][0] = fmt.Sprintf("%s:+%s", urns.WhatsAppScheme, form["To"][0])
form["From"][0] = fmt.Sprintf("%s:%s", urns.WhatsAppScheme, form["From"][0])
}

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

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

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L356-L357

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

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L361-L362

Added lines #L361 - L362 were not covered by tests

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

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

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L366-L367

Added lines #L366 - L367 were 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 375 in handlers/twiml/handlers.go

View check run for this annotation

Codecov / codecov/patch

handlers/twiml/handlers.go#L374-L375

Added lines #L374 - L375 were 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
116 changes: 114 additions & 2 deletions handlers/twiml/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package twiml

import (
"context"
"encoding/json"
"net/http"
"net/url"
"testing"
Expand Down Expand Up @@ -1051,7 +1052,25 @@ var waSendTestCases = []OutgoingTestCase{
},
},
ExpectedRequests: []ExpectedRequest{{
Form: url.Values{"Body": {"Simple Message ☺"}, "To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/t/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}},
Form: url.Values{"Body": {"Simple Message ☺"}, "To": {"whatsapp:+250788383383"}, "From": {"whatsapp:+12065551212"}, "StatusCallback": {"https://localhost/c/sw/8eb23e93-5ecb-45ba-b726-3b064e0c56ab/status?id=10&action=callback"}},
Headers: map[string]string{"Authorization": "Basic YWNjb3VudFNJRDphdXRoVG9rZW4="},
}},
ExpectedExtIDs: []string{"1002"},
},
{
Label: "Template Send",
MsgText: "templated message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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/sw/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"},
Expand All @@ -1074,6 +1093,99 @@ var twaSendTestCases = []OutgoingTestCase{
}},
ExpectedExtIDs: []string{"1002"},
},
{
Label: "Template Send",
MsgText: "templated message",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
ExpectedError: courier.ErrFailedWithReason("", "template missing contentSID"),
},
{
Label: "Error Code",
MsgText: "Error Code",
MsgURN: "whatsapp:250788383383",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "external_id": "ext_id_revive_issue", "components": [{"type":"body", "params": [{"type":"text", "name": "1", "value":"Chef"}, {"type": "text" , "name": "2", "value": "tomorrow"}]}]}}`),
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 Expand Up @@ -1117,7 +1229,7 @@ func TestOutgoing(t *testing.T) {
)
waChannel.SetScheme(urns.WhatsAppScheme)

RunOutgoingTestCases(t, waChannel, newTWIMLHandler("T", "Twilio Whatsapp", true), waSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil)
RunOutgoingTestCases(t, waChannel, newTWIMLHandler("SW", "SignalWire", true), waSendTestCases, []string{httpx.BasicAuth("accountSID", "authToken")}, nil)

twaChannel := test.NewMockChannel("8eb23e93-5ecb-45ba-b726-3b064e0c56ab", "TWA", "+12065551212", "US",
map[string]any{
Expand Down
Loading
Loading