diff --git a/handlers/twiml/handlers.go b/handlers/twiml/handlers.go index 663152ad3..d36c7f367 100644 --- a/handlers/twiml/handlers.go +++ b/handlers/twiml/handlers.go @@ -11,6 +11,7 @@ import ( "crypto/sha1" _ "embed" "encoding/base64" + "encoding/json" "fmt" "log/slog" "net/http" @@ -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 ( @@ -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 == "" { @@ -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 + } + + sendURL, err := utils.AddURLPath(baseURL, "2010-04-01", "Accounts", accountSID, "Messages.json") + if err != nil { + return err + } + + req, err := http.NewRequest(http.MethodPost, sendURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + 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 + } + + // 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 diff --git a/handlers/twiml/handlers_test.go b/handlers/twiml/handlers_test.go index 742726607..cdf19d66f 100644 --- a/handlers/twiml/handlers_test.go +++ b/handlers/twiml/handlers_test.go @@ -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{ @@ -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) {