Skip to content

Commit

Permalink
Merge pull request #559 from nyaruka/msg_locale
Browse files Browse the repository at this point in the history
Support reading `locale` instead of `language`+`country` on templating
  • Loading branch information
rowanseymour authored Jan 30, 2023
2 parents a73d953 + 1391b7d commit 8fc3f4f
Show file tree
Hide file tree
Showing 6 changed files with 108 additions and 52 deletions.
4 changes: 4 additions & 0 deletions backends/rapidpro/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ type DBMsg struct {
URNAuth_ string `json:"urn_auth"`
Text_ string `json:"text" db:"text"`
Attachments_ pq.StringArray `json:"attachments" db:"attachments"`
Locale_ null.String `json:"locale" db:"locale"`
ExternalID_ null.String `json:"external_id" db:"external_id"`
ResponseToExternalID_ string `json:"response_to_external_id"`
IsResend_ bool `json:"is_resend,omitempty"`
Expand Down Expand Up @@ -378,6 +379,7 @@ func (m *DBMsg) EventID() int64 { return int64(m.ID_) }
func (m *DBMsg) UUID() courier.MsgUUID { return m.UUID_ }
func (m *DBMsg) Text() string { return m.Text_ }
func (m *DBMsg) Attachments() []string { return []string(m.Attachments_) }
func (m *DBMsg) Locale() courier.Locale { return courier.Locale(string(m.Locale_)) }
func (m *DBMsg) ExternalID() string { return string(m.ExternalID_) }
func (m *DBMsg) URN() urns.URN { return m.URN_ }
func (m *DBMsg) URNAuth() string { return m.URNAuth_ }
Expand Down Expand Up @@ -474,6 +476,8 @@ func (m *DBMsg) WithAttachment(url string) courier.Msg {
return m
}

func (m *DBMsg) WithLocale(lc courier.Locale) courier.Msg { m.Locale_ = null.String(lc); return m }

// WithURNAuth can be used to add a URN auth setting to a message
func (m *DBMsg) WithURNAuth(auth string) courier.Msg {
m.URNAuth_ = auth
Expand Down
86 changes: 43 additions & 43 deletions handlers/facebookapp/facebookapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,7 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg,
msgParts = handlers.SplitMsgByChannel(msg.Channel(), msg.Text(), maxMsgLength)
}
qrs := msg.QuickReplies()
langCode := getSupportedLanguage(msg.Locale())

var payloadAudio wacMTPayload

Expand All @@ -1132,15 +1133,15 @@ func (h *handler) sendCloudAPIWhatsappMsg(ctx context.Context, msg courier.Msg,
if len(msg.Attachments()) == 0 {
// do we have a template?
var templating *MsgTemplating
templating, err := h.getTemplate(msg)
templating, err := h.getTemplating(msg)
if err != nil {
return nil, errors.Wrapf(err, "unable to decode template: %s for channel: %s", string(msg.Metadata()), msg.Channel().UUID())
}
if templating != nil {

payload.Type = "template"

template := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: templating.Language}}
template := wacTemplate{Name: templating.Template.Name, Language: &wacLanguage{Policy: "deterministic", Code: langCode}}
payload.Template = &template

component := &wacComponent{Type: "body"}
Expand Down Expand Up @@ -1515,39 +1516,27 @@ func fbCalculateSignature(appSecret string, body []byte) (string, error) {
return hex.EncodeToString(mac.Sum(nil)), nil
}

func (h *handler) getTemplate(msg courier.Msg) (*MsgTemplating, error) {
mdJSON := msg.Metadata()
if len(mdJSON) == 0 {
func (h *handler) getTemplating(msg courier.Msg) (*MsgTemplating, error) {
if len(msg.Metadata()) == 0 {
return nil, nil
}
metadata := &TemplateMetadata{}
err := json.Unmarshal(mdJSON, metadata)
if err != nil {

metadata := &struct {
Templating *MsgTemplating `json:"templating"`
}{}
if err := json.Unmarshal(msg.Metadata(), metadata); err != nil {
return nil, err
}
templating := metadata.Templating
if templating == nil {

if metadata.Templating == nil {
return nil, nil
}

// check our template is valid
err = utils.Validate(templating)
if err != nil {
if err := utils.Validate(metadata.Templating); err != nil {
return nil, errors.Wrapf(err, "invalid templating definition")
}
// check country
if templating.Country != "" {
templating.Language = fmt.Sprintf("%s_%s", templating.Language, templating.Country)
}

// map our language from iso639-3_iso3166-2 to the WA country / iso638-2 pair
language, found := languageMap[templating.Language]
if !found {
return nil, fmt.Errorf("unable to find mapping for language: %s", templating.Language)
}
templating.Language = language

return templating, err
return metadata.Templating, nil
}

// BuildAttachmentRequest to download media for message attachment with Bearer token set
Expand All @@ -1568,23 +1557,34 @@ func (h *handler) BuildAttachmentRequest(ctx context.Context, b courier.Backend,

var _ courier.AttachmentRequestBuilder = (*handler)(nil)

type TemplateMetadata struct {
Templating *MsgTemplating `json:"templating"`
}

type MsgTemplating struct {
Template struct {
Name string `json:"name" validate:"required"`
UUID string `json:"uuid" validate:"required"`
} `json:"template" validate:"required,dive"`
Language string `json:"language" validate:"required"`
Country string `json:"country"`
Namespace string `json:"namespace"`
Variables []string `json:"variables"`
}

// mapping from iso639-3_iso3166-2 to WA language code
var languageMap = map[string]string{
func getSupportedLanguage(lc courier.Locale) string {
// look for exact match
if lang := supportedLanguages[lc]; lang != "" {
return lang
}

// if we have a country, strip that off and look again for a match
l, c := lc.ToParts()
if c != "" {
if lang := supportedLanguages[courier.Locale(l)]; lang != "" {
return lang
}
}
return "en" // fallback to English
}

// Mapping from engine locales to supported languages. Note that these are not all valid BCP47 codes, e.g. fil
// see https://developers.facebook.com/docs/whatsapp/api/messages/message-templates/
var supportedLanguages = map[courier.Locale]string{
"afr": "af", // Afrikaans
"sqi": "sq", // Albanian
"ara": "ar", // Arabic
Expand All @@ -1593,16 +1593,16 @@ var languageMap = map[string]string{
"bul": "bg", // Bulgarian
"cat": "ca", // Catalan
"zho": "zh_CN", // Chinese
"zho_CN": "zh_CN", // Chinese (CHN)
"zho_HK": "zh_HK", // Chinese (HKG)
"zho_TW": "zh_TW", // Chinese (TAI)
"zho-CN": "zh_CN", // Chinese (CHN)
"zho-HK": "zh_HK", // Chinese (HKG)
"zho-TW": "zh_TW", // Chinese (TAI)
"hrv": "hr", // Croatian
"ces": "cs", // Czech
"dah": "da", // Danish
"nld": "nl", // Dutch
"eng": "en", // English
"eng_GB": "en_GB", // English (UK)
"eng_US": "en_US", // English (US)
"eng-GB": "en_GB", // English (UK)
"eng-US": "en_US", // English (US)
"est": "et", // Estonian
"fil": "fil", // Filipino
"fin": "fi", // Finnish
Expand Down Expand Up @@ -1635,18 +1635,18 @@ var languageMap = map[string]string{
"fas": "fa", // Persian
"pol": "pl", // Polish
"por": "pt_PT", // Portuguese
"por_BR": "pt_BR", // Portuguese (BR)
"por_PT": "pt_PT", // Portuguese (POR)
"por-BR": "pt_BR", // Portuguese (BR)
"por-PT": "pt_PT", // Portuguese (POR)
"pan": "pa", // Punjabi
"ron": "ro", // Romanian
"rus": "ru", // Russian
"srp": "sr", // Serbian
"slk": "sk", // Slovak
"slv": "sl", // Slovenian
"spa": "es", // Spanish
"spa_AR": "es_AR", // Spanish (ARG)
"spa_ES": "es_ES", // Spanish (SPA)
"spa_MX": "es_MX", // Spanish (MEX)
"spa-AR": "es_AR", // Spanish (ARG)
"spa-ES": "es_ES", // Spanish (SPA)
"spa-MX": "es_MX", // Spanish (MEX)
"swa": "sw", // Swahili
"swe": "sv", // Swedish
"tam": "ta", // Tamil
Expand Down
34 changes: 27 additions & 7 deletions handlers/facebookapp/facebookapp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1291,9 +1291,10 @@ var SendTestCasesWAC = []ChannelSendTestCase{
Label: "Template Send",
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "eng",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`),
ExpectedMsgStatus: "W",
ExpectedExternalID: "157b5e14568e8",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "language": "eng", "variables": ["Chef", "tomorrow"]}}`),
MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`,
MockResponseStatus: 200,
ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"template","template":{"name":"revive_issue","language":{"policy":"deterministic","code":"en"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`,
Expand All @@ -1303,7 +1304,8 @@ var SendTestCasesWAC = []ChannelSendTestCase{
Label: "Template Country Language",
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "language": "eng", "country": "US", "variables": ["Chef", "tomorrow"]}}`),
MsgLocale: "eng-US",
MsgMetadata: json.RawMessage(`{ "templating": { "template": { "name": "revive_issue", "uuid": "171f8a4d-f725-46d7-85a6-11aceff0bfe3" }, "variables": ["Chef", "tomorrow"]}}`),
MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`,
MockResponseStatus: 200,
ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"template","template":{"name":"revive_issue","language":{"policy":"deterministic","code":"en_US"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`,
Expand All @@ -1312,11 +1314,17 @@ var SendTestCasesWAC = []ChannelSendTestCase{
SendPrep: setSendURL,
},
{
Label: "Template Invalid Language",
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "language": "bnt", "variables": ["Chef", "tomorrow"]}}`),
ExpectedErrors: []*courier.ChannelError{courier.NewChannelError("", "", `unable to decode template: {"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "language": "bnt", "variables": ["Chef", "tomorrow"]}} for channel: 8eb23e93-5ecb-45ba-b726-3b064e0c56ab: unable to find mapping for language: bnt`)},
Label: "Template Invalid Language",
MsgText: "templated message",
MsgURN: "whatsapp:250788123123",
MsgLocale: "bnt",
MsgMetadata: json.RawMessage(`{"templating": { "template": { "name": "revive_issue", "uuid": "8ca114b4-bee2-4d3b-aaf1-9aa6b48d41e8" }, "variables": ["Chef", "tomorrow"]}}`),
MockResponseBody: `{ "messages": [{"id": "157b5e14568e8"}] }`,
MockResponseStatus: 200,
ExpectedRequestBody: `{"messaging_product":"whatsapp","recipient_type":"individual","to":"250788123123","type":"template","template":{"name":"revive_issue","language":{"policy":"deterministic","code":"en"},"components":[{"type":"body","sub_type":"","index":"","parameters":[{"type":"text","text":"Chef"},{"type":"text","text":"tomorrow"}]}]}}`,
ExpectedMsgStatus: "W",
ExpectedExternalID: "157b5e14568e8",
SendPrep: setSendURL,
},
{
Label: "Interactive Button Message Send",
Expand Down Expand Up @@ -1526,3 +1534,15 @@ func TestBuildMediaRequest(t *testing.T) {
assert.Equal(t, "https://example.org/v1/media/41", req.URL.String())
assert.Equal(t, http.Header{}, req.Header)
}

func TestGetSupportedLanguage(t *testing.T) {
assert.Equal(t, "en", getSupportedLanguage(courier.NilLocale))
assert.Equal(t, "en", getSupportedLanguage(courier.Locale("eng")))
assert.Equal(t, "en_US", getSupportedLanguage(courier.Locale("eng-US")))
assert.Equal(t, "pt_PT", getSupportedLanguage(courier.Locale("por")))
assert.Equal(t, "pt_PT", getSupportedLanguage(courier.Locale("por-PT")))
assert.Equal(t, "pt_BR", getSupportedLanguage(courier.Locale("por-BR")))
assert.Equal(t, "fil", getSupportedLanguage(courier.Locale("fil")))
assert.Equal(t, "fr", getSupportedLanguage(courier.Locale("fra-CA")))
assert.Equal(t, "en", getSupportedLanguage(courier.Locale("run")))
}
2 changes: 2 additions & 0 deletions handlers/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ type ChannelSendTestCase struct {
MsgURNAuth string
MsgAttachments []string
MsgQuickReplies []string
MsgLocale courier.Locale
MsgTopic string
MsgHighPriority bool
MsgResponseToExternalID string
Expand Down Expand Up @@ -305,6 +306,7 @@ func RunChannelSendTestCases(t *testing.T, channel courier.Channel, handler cour
require := require.New(t)

msg := mb.NewOutgoingMsg(channel, courier.NewMsgID(10), urns.URN(tc.MsgURN), tc.MsgText, tc.MsgHighPriority, tc.MsgQuickReplies, tc.MsgTopic, tc.MsgResponseToExternalID)
msg.WithLocale(tc.MsgLocale)

for _, a := range tc.MsgAttachments {
msg.WithAttachment(a)
Expand Down
31 changes: 29 additions & 2 deletions msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ import (
"encoding/json"
"errors"
"strconv"
"strings"
"time"

"github.com/nyaruka/null"

"github.com/gofrs/uuid"
"github.com/nyaruka/gocommon/urns"
"github.com/nyaruka/null"
)

// ErrMsgNotFound is returned when trying to queue the status for a Msg that doesn't exit
Expand Down Expand Up @@ -83,6 +83,31 @@ type FlowReference struct {
Name string `json:"name"`
}

//-----------------------------------------------------------------------------
// Locale
//-----------------------------------------------------------------------------

// Locale is the combination of a language and optional country, e.g. US English, Brazilian Portuguese, encoded as the
// language code followed by the country code, e.g. eng-US, por-BR
type Locale string

func (l Locale) ToParts() (string, string) {
if l == NilLocale || len(l) < 3 {
return "", ""
}

parts := strings.SplitN(string(l), "-", 2)
lang := parts[0]
country := ""
if len(parts) > 1 {
country = parts[1]
}

return lang, country
}

var NilLocale = Locale("")

//-----------------------------------------------------------------------------
// Msg interface
//-----------------------------------------------------------------------------
Expand All @@ -93,6 +118,7 @@ type Msg interface {
UUID() MsgUUID
Text() string
Attachments() []string
Locale() Locale
ExternalID() string
URN() urns.URN
URNAuth() string
Expand Down Expand Up @@ -120,6 +146,7 @@ type Msg interface {
WithID(id MsgID) Msg
WithUUID(uuid MsgUUID) Msg
WithAttachment(url string) Msg
WithLocale(Locale) Msg
WithURNAuth(auth string) Msg
WithMetadata(metadata json.RawMessage) Msg
WithFlow(flow *FlowReference) Msg
Expand Down
3 changes: 3 additions & 0 deletions test/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type mockMsg struct {
urnAuth string
text string
attachments []string
locale courier.Locale
externalID string
contactName string
highPriority bool
Expand Down Expand Up @@ -66,6 +67,7 @@ func (m *mockMsg) EventID() int64 { return int64(m.id) }
func (m *mockMsg) UUID() courier.MsgUUID { return m.uuid }
func (m *mockMsg) Text() string { return m.text }
func (m *mockMsg) Attachments() []string { return m.attachments }
func (m *mockMsg) Locale() courier.Locale { return m.locale }
func (m *mockMsg) ExternalID() string { return m.externalID }
func (m *mockMsg) URN() urns.URN { return m.urn }
func (m *mockMsg) URNAuth() string { return m.urnAuth }
Expand All @@ -91,6 +93,7 @@ func (m *mockMsg) WithAttachment(url string) courier.Msg {
m.attachments = append(m.attachments, url)
return m
}
func (m *mockMsg) WithLocale(lc courier.Locale) courier.Msg { m.locale = lc; return m }
func (m *mockMsg) WithMetadata(metadata json.RawMessage) courier.Msg { m.metadata = metadata; return m }

func (m *mockMsg) WithFlow(flow *courier.FlowReference) courier.Msg { m.flow = flow; return m }

0 comments on commit 8fc3f4f

Please sign in to comment.