From abb7591eb928a88cb7767c1ab6fc07736245dc14 Mon Sep 17 00:00:00 2001 From: Rowan Seymour Date: Thu, 13 Feb 2025 15:23:49 +0000 Subject: [PATCH] Convert quick replies into structs --- flows/actions/base.go | 4 ++-- flows/events/base_test.go | 2 +- flows/msg.go | 29 ++++++++++++++++++++++++++--- flows/msg_test.go | 27 ++++++++++++++++++++++----- flows/runs/run_test.go | 16 ++++++++-------- flows/template.go | 4 ++-- flows/template_test.go | 2 +- 7 files changed, 62 insertions(+), 22 deletions(-) diff --git a/flows/actions/base.go b/flows/actions/base.go index f3acd07c6..aab5a8c8f 100644 --- a/flows/actions/base.go +++ b/flows/actions/base.go @@ -99,14 +99,14 @@ func (a *baseAction) evaluateMessage(run flows.Run, languages []i18n.Language, a // localize and evaluate the quick replies translatedQuickReplies, qrsLang := run.GetTextArray(uuids.UUID(a.UUID()), "quick_replies", actionQuickReplies, languages) - evaluatedQuickReplies := make([]string, 0, len(translatedQuickReplies)) + evaluatedQuickReplies := make([]flows.QuickReply, 0, len(translatedQuickReplies)) for _, qr := range translatedQuickReplies { evaluatedQuickReply, _ := run.EvaluateTemplate(qr, logEvent) if evaluatedQuickReply == "" { logEvent(events.NewErrorf("quick reply evaluated to empty string, skipping")) continue } - evaluatedQuickReplies = append(evaluatedQuickReplies, stringsx.TruncateEllipsis(evaluatedQuickReply, flows.MaxQuickReplyLength)) + evaluatedQuickReplies = append(evaluatedQuickReplies, flows.QuickReply{Text: stringsx.TruncateEllipsis(evaluatedQuickReply, flows.MaxQuickReplyLength)}) } // although it's possible for the different parts of the message to have different languages, we want to resolve diff --git a/flows/events/base_test.go b/flows/events/base_test.go index 16899b2a6..b2ae868cd 100644 --- a/flows/events/base_test.go +++ b/flows/events/base_test.go @@ -475,7 +475,7 @@ func TestEventMarshaling(t *testing.T) { &flows.MsgContent{ Text: "Hi there", Attachments: []utils.Attachment{"image/jpeg:http://s3.amazon.com/bucket/test.jpg"}, - QuickReplies: []string{"yes", "no"}, + QuickReplies: []flows.QuickReply{{Text: "yes"}, {Text: "no"}}, }, nil, flows.MsgTopicAgent, diff --git a/flows/msg.go b/flows/msg.go index f3510aa10..4bedab557 100644 --- a/flows/msg.go +++ b/flows/msg.go @@ -1,11 +1,13 @@ package flows import ( + "encoding/json" "fmt" "slices" "github.com/go-playground/validator/v10" "github.com/nyaruka/gocommon/i18n" + "github.com/nyaruka/gocommon/jsonx" "github.com/nyaruka/gocommon/urns" "github.com/nyaruka/gocommon/uuids" "github.com/nyaruka/goflow/assets" @@ -66,7 +68,7 @@ type MsgIn struct { type MsgOut struct { BaseMsg - QuickReplies_ []string `json:"quick_replies,omitempty"` + QuickReplies_ []QuickReply `json:"quick_replies,omitempty"` Templating_ *MsgTemplating `json:"templating,omitempty"` Topic_ MsgTopic `json:"topic,omitempty"` Locale_ i18n.Locale `json:"locale,omitempty"` @@ -157,7 +159,7 @@ func (m *MsgIn) ExternalID() string { return m.ExternalID_ } func (m *MsgIn) SetExternalID(id string) { m.ExternalID_ = id } // QuickReplies returns the quick replies of this outgoing message -func (m *MsgOut) QuickReplies() []string { return m.QuickReplies_ } +func (m *MsgOut) QuickReplies() []QuickReply { return m.QuickReplies_ } // Templating returns the templating to use to send this message (if any) func (m *MsgOut) Templating() *MsgTemplating { return m.Templating_ } @@ -194,11 +196,32 @@ func NewMsgTemplating(template *assets.TemplateReference, components []*Templati return &MsgTemplating{Template: template, Components: components, Variables: variables} } +type QuickReply struct { + Text string `json:"text"` +} + +func (q QuickReply) MarshalJSON() ([]byte, error) { + // TODO for now we always marshal as a string but once everything can unmarshal as a struct we can change this + return json.Marshal(q.Text) +} + +func (q *QuickReply) UnmarshalJSON(d []byte) error { + // if we have a string we unmarshal it into the text field + if len(d) > 2 && d[0] == '"' && d[len(d)-1] == '"' { + return jsonx.Unmarshal(d, &q.Text) + } + + // alias our type so we don't end up here again + type alias QuickReply + + return jsonx.Unmarshal(d, (*alias)(q)) +} + // MsgContent is message content in a particular language type MsgContent struct { Text string `json:"text"` Attachments []utils.Attachment `json:"attachments,omitempty"` - QuickReplies []string `json:"quick_replies,omitempty"` + QuickReplies []QuickReply `json:"quick_replies,omitempty"` } func (c *MsgContent) Empty() bool { diff --git a/flows/msg_test.go b/flows/msg_test.go index da910b06c..5ac09d0a8 100644 --- a/flows/msg_test.go +++ b/flows/msg_test.go @@ -124,7 +124,7 @@ func TestMsgContent(t *testing.T) { assert.True(t, (&flows.MsgContent{}).Empty()) assert.False(t, (&flows.MsgContent{Text: "hi"}).Empty()) assert.False(t, (&flows.MsgContent{Attachments: []utils.Attachment{"image:https://test.jpg"}}).Empty()) - assert.False(t, (&flows.MsgContent{QuickReplies: []string{"Ok"}}).Empty()) + assert.False(t, (&flows.MsgContent{QuickReplies: []flows.QuickReply{{Text: "Ok"}}}).Empty()) } func TestBroadcastTranslations(t *testing.T) { @@ -182,24 +182,24 @@ func TestBroadcastTranslations(t *testing.T) { { // 4: merges content from different translations env: envs.NewBuilder().WithAllowedLanguages("eng", "spa").WithDefaultCountry("US").Build(), translations: flows.BroadcastTranslations{ - "eng": &flows.MsgContent{Text: "Hello", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hello.jpg"}, QuickReplies: []string{"Yes", "No"}}, + "eng": &flows.MsgContent{Text: "Hello", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hello.jpg"}, QuickReplies: []flows.QuickReply{{Text: "Yes"}, {Text: "No"}}}, "spa": &flows.MsgContent{Text: "Hola"}, }, baseLanguage: "eng", contactLanguage: "spa", - expectedContent: &flows.MsgContent{Text: "Hola", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hello.jpg"}, QuickReplies: []string{"Yes", "No"}}, + expectedContent: &flows.MsgContent{Text: "Hola", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hello.jpg"}, QuickReplies: []flows.QuickReply{{Text: "Yes"}, {Text: "No"}}}, expectedLocale: "spa-US", }, { // 5: merges content from different translations env: envs.NewBuilder().WithAllowedLanguages("eng", "spa").WithDefaultCountry("US").Build(), translations: flows.BroadcastTranslations{ - "eng": &flows.MsgContent{QuickReplies: []string{"Yes", "No"}}, + "eng": &flows.MsgContent{QuickReplies: []flows.QuickReply{{Text: "Yes"}, {Text: "No"}}}, "spa": &flows.MsgContent{Attachments: []utils.Attachment{"image/jpeg:https://example.com/hola.jpg"}}, "kin": &flows.MsgContent{Text: "Muraho"}, }, baseLanguage: "kin", contactLanguage: "spa", - expectedContent: &flows.MsgContent{Text: "Muraho", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hola.jpg"}, QuickReplies: []string{"Yes", "No"}}, + expectedContent: &flows.MsgContent{Text: "Muraho", Attachments: []utils.Attachment{"image/jpeg:https://example.com/hola.jpg"}, QuickReplies: []flows.QuickReply{{Text: "Yes"}, {Text: "No"}}}, expectedLocale: "kin-US", }, } @@ -260,3 +260,20 @@ func TestMsgTemplating(t *testing.T) { ] }`), marshaled, "JSON mismatch") } + +func TestQuickReplies(t *testing.T) { + // can unmarshal from a string + qr1 := flows.QuickReply{} + jsonx.MustUnmarshal([]byte(`"Yes"`), &qr1) + assert.Equal(t, flows.QuickReply{Text: "Yes"}, qr1) + + // can unmarshal from a struct + qr2 := flows.QuickReply{} + jsonx.MustUnmarshal([]byte(`{"text": "No"}`), &qr2) + assert.Equal(t, flows.QuickReply{Text: "No"}, qr2) + + // always marshals as a string for now + assert.Equal(t, []byte(`"Yes"`), jsonx.MustMarshal(qr1)) + assert.Equal(t, []byte(`"No"`), jsonx.MustMarshal(qr2)) + assert.Equal(t, []byte(`["Yes","No"]`), jsonx.MustMarshal([]flows.QuickReply{qr1, qr2})) +} diff --git a/flows/runs/run_test.go b/flows/runs/run_test.go index f64493fa4..312467a29 100644 --- a/flows/runs/run_test.go +++ b/flows/runs/run_test.go @@ -352,7 +352,7 @@ func TestTranslation(t *testing.T) { msgAction []byte expectedText string expectedAttachments []utils.Attachment - expectedQuickReplies []string + expectedQuickReplies []flows.QuickReply }{ { description: "contact language is valid and is flow base language, msg action has all fields", @@ -364,7 +364,7 @@ func TestTranslation(t *testing.T) { "image/jpeg:http://media.com/hello.jpg", "audio/mp4:http://media.com/hello.m4a", }, - expectedQuickReplies: []string{"yes", "no"}, + expectedQuickReplies: []flows.QuickReply{{Text: "yes"}, {Text: "no"}}, }, { description: "contact language is valid and translations exist, msg action has all fields", @@ -375,7 +375,7 @@ func TestTranslation(t *testing.T) { expectedAttachments: []utils.Attachment{ "audio/mp4:http://media.com/hola.m4a", }, - expectedQuickReplies: []string{"si"}, + expectedQuickReplies: []flows.QuickReply{{Text: "si"}}, }, { description: "contact language is allowed but no translations exist, msg action has all fields", @@ -387,7 +387,7 @@ func TestTranslation(t *testing.T) { "image/jpeg:http://media.com/hello.jpg", "audio/mp4:http://media.com/hello.m4a", }, - expectedQuickReplies: []string{"yes", "no"}, + expectedQuickReplies: []flows.QuickReply{{Text: "yes"}, {Text: "no"}}, }, { description: "contact language is not allowed and translations exist, msg action has all fields", @@ -399,7 +399,7 @@ func TestTranslation(t *testing.T) { "image/jpeg:http://media.com/hello.jpg", "audio/mp4:http://media.com/hello.m4a", }, - expectedQuickReplies: []string{"yes", "no"}, + expectedQuickReplies: []flows.QuickReply{{Text: "yes"}, {Text: "no"}}, }, { description: "contact language is valid and is flow base language, msg action only has text", @@ -408,7 +408,7 @@ func TestTranslation(t *testing.T) { msgAction: msgAction2, expectedText: "Hello", expectedAttachments: []utils.Attachment{}, - expectedQuickReplies: []string{}, + expectedQuickReplies: []flows.QuickReply{}, }, { description: "contact language is valid and translations exist, msg action only has text", @@ -419,7 +419,7 @@ func TestTranslation(t *testing.T) { expectedAttachments: []utils.Attachment{ "audio/mp4:http://media.com/hola.m4a", }, - expectedQuickReplies: []string{"si"}, + expectedQuickReplies: []flows.QuickReply{{Text: "si"}}, }, { description: "attachments and quick replies translations are single empty strings and should be ignored", @@ -431,7 +431,7 @@ func TestTranslation(t *testing.T) { "image/jpeg:http://media.com/hello.jpg", "audio/mp4:http://media.com/hello.m4a", }, - expectedQuickReplies: []string{"yes", "no"}, + expectedQuickReplies: []flows.QuickReply{{Text: "yes"}, {Text: "no"}}, }, } diff --git a/flows/template.go b/flows/template.go index 2be5d0fe8..7f2c9bb7e 100644 --- a/flows/template.go +++ b/flows/template.go @@ -94,7 +94,7 @@ func (t *TemplateTranslation) Asset() assets.TemplateTranslation { return t.Temp func (t *TemplateTranslation) Preview(vars []*TemplatingVariable) *MsgContent { var text []string var attachments []utils.Attachment - var quickReplies []string + var quickReplies []QuickReply for _, comp := range t.Components() { content := comp.Content() @@ -112,7 +112,7 @@ func (t *TemplateTranslation) Preview(vars []*TemplatingVariable) *MsgContent { if comp.Type() == "header/text" || comp.Type() == "body/text" || comp.Type() == "footer/text" { text = append(text, content) } else if strings.HasPrefix(comp.Type(), "button/") { - quickReplies = append(quickReplies, stringsx.TruncateEllipsis(content, MaxQuickReplyLength)) + quickReplies = append(quickReplies, QuickReply{Text: stringsx.TruncateEllipsis(content, MaxQuickReplyLength)}) } } } diff --git a/flows/template_test.go b/flows/template_test.go index f57fc1397..50f6c13d6 100644 --- a/flows/template_test.go +++ b/flows/template_test.go @@ -242,7 +242,7 @@ func TestTemplating(t *testing.T) { {Type: "text", Value: "No"}, }, }, - expectedPreview: &flows.MsgContent{QuickReplies: []string{"Yes", "No"}}, + expectedPreview: &flows.MsgContent{QuickReplies: []flows.QuickReply{{Text: "Yes"}, {Text: "No"}}}, }, { // 4: header image becomes an attachment template: []byte(`{