Skip to content

Commit

Permalink
Merge pull request #1234 from nyaruka/templating_preview
Browse files Browse the repository at this point in the history
Fix and improve generation of template msg previews
  • Loading branch information
rowanseymour authored Mar 20, 2024
2 parents 64a9b37 + 7f5604f commit 5357857
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 136 deletions.
59 changes: 28 additions & 31 deletions flows/actions/send_msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"github.com/nyaruka/goflow/assets"
"github.com/nyaruka/goflow/flows"
"github.com/nyaruka/goflow/flows/events"
"github.com/nyaruka/goflow/utils"
)

func init() {
Expand Down Expand Up @@ -143,7 +142,7 @@ func (a *SendMsgAction) Execute(run flows.Run, step flows.Step, logModifier flow
func (a *SendMsgAction) getTemplateMsg(run flows.Run, urn urns.URN, channelRef *assets.ChannelReference, translation *flows.TemplateTranslation, unsendableReason flows.UnsendableReason, logEvent flows.EventCallback) *flows.MsgOut {
evaluatedParams := make(map[string][]string)

// start by localizing and evaluating either the per-component params
// start by localizing and evaluating the param values
for _, comp := range a.Templating.Components {
localizedCompParams, _ := run.GetTextArray(comp.UUID, "params", comp.Params, nil)
evaluatedCompParams := make([]string, len(localizedCompParams))
Expand All @@ -156,48 +155,46 @@ func (a *SendMsgAction) getTemplateMsg(run flows.Run, urn urns.URN, channelRef *
}

// next we cross reference with params defined in the template translation to get types
components := make([]flows.TemplatingComponent, 0)
oldParams := make(map[string][]flows.TemplatingParam, len(translation.Components()))
components := make([]*flows.TemplatingComponent, 0, len(translation.Components()))
legacy := make(map[string][]flows.TemplatingParam, len(translation.Components())) // TODO deprecated

translationComponents := translation.Components()
// the message we return is an approximate preview of what the channel will send using the template
var previewParts []string
var previewQRs []string

for _, comp := range translationComponents {
compParams := comp.Params()
key := comp.Name()
for _, comp := range translation.Components() {
paramValues := evaluatedParams[comp.Name()]
params := make([]flows.TemplatingParam, len(comp.Params()))

if len(compParams) > 0 {
oldParams[key] = make([]flows.TemplatingParam, len(compParams))
for i, p := range comp.Params() {
if i < len(paramValues) {
params[i] = flows.TemplatingParam{Type: p.Type(), Value: paramValues[i]}
} else {
params[i] = flows.TemplatingParam{Type: p.Type(), Value: ""}
}
}

params := make([]flows.TemplatingParam, len(compParams))
compTemplating := &flows.TemplatingComponent{Type: comp.Type(), Params: params}
previewContent, _ := compTemplating.Preview(comp)

for i, tp := range compParams {
if i < len(evaluatedParams[key]) {
params[i] = flows.TemplatingParam{Type: tp.Type(), Value: evaluatedParams[key][i]}
oldParams[key][i] = flows.TemplatingParam{Type: tp.Type(), Value: evaluatedParams[key][i]}
} else {
params[i] = flows.TemplatingParam{Type: tp.Type(), Value: ""}
oldParams[key][i] = flows.TemplatingParam{Type: tp.Type(), Value: ""}
if previewContent != "" {
if comp.Type() == "header" || comp.Type() == "body" || comp.Type() == "footer" {
previewParts = append(previewParts, previewContent)
} else if strings.HasPrefix(comp.Type(), "button/") {
previewQRs = append(previewQRs, stringsx.TruncateEllipsis(previewContent, maxQuickReplyLength))
}
}

if len(params) > 0 {
components = append(components, flows.TemplatingComponent{Type: comp.Type(), Params: params})
components = append(components, compTemplating)
legacy[comp.Name()] = params
}

}

locale := translation.Locale()
templating := flows.NewMsgTemplating(a.Templating.Template, oldParams, components, translation.Namespace())
previewText := strings.Join(previewParts, "\n\n")

// extract content for preview message
preview := translation.Preview(templating)
previewText := preview["body"]
var previewQRs []string
for _, key := range utils.SortedKeys(preview) {
if strings.HasPrefix(key, "button.") {
previewQRs = append(previewQRs, stringsx.TruncateEllipsis(preview[key], maxQuickReplyLength))
}
}
locale := translation.Locale()
templating := flows.NewMsgTemplating(a.Templating.Template, legacy, components, translation.Namespace())

return flows.NewMsgOut(urn, channelRef, previewText, nil, previewQRs, templating, flows.NilMsgTopic, locale, unsendableReason)
}
49 changes: 46 additions & 3 deletions flows/msg.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"

"github.com/go-playground/validator/v10"
"github.com/nyaruka/gocommon/i18n"
Expand Down Expand Up @@ -178,16 +181,56 @@ type TemplatingComponent struct {
Params []TemplatingParam `json:"params"`
}

var templateVariableRegex = regexp.MustCompile(`{{(\d+)}}`)

func getTemplateVariableCount(s string) int {
count := 0
for _, m := range templateVariableRegex.FindAllStringSubmatch(s, -1) {
if v, _ := strconv.Atoi(m[1]); v > count {
count = v
}
}
return count
}

// Preview returns the content and display for given template component using these templating params
func (tc *TemplatingComponent) Preview(c assets.TemplateComponent) (string, string) {
content := c.Content()
display := c.Display()
numContentParams := getTemplateVariableCount(content)
numDisplayParams := getTemplateVariableCount(display)

// replace {{?}} placeholders in component content
for i := 0; i < numContentParams; i++ {
value := ""
if i < len(tc.Params) {
value = tc.Params[i].Value
}
content = strings.ReplaceAll(content, fmt.Sprintf("{{%d}}", i+1), value)
}

// replace {{?}} placeholders in component display using any remaining param values
for i := 0; i < numDisplayParams; i++ {
value := ""
if (numContentParams + i) < len(tc.Params) {
value = tc.Params[numContentParams+i].Value
}
display = strings.ReplaceAll(display, fmt.Sprintf("{{%d}}", i+1), value)
}

return content, display
}

// MsgTemplating represents any substituted message template that should be applied when sending this message
type MsgTemplating struct {
Template_ *assets.TemplateReference `json:"template"`
Params_ map[string][]TemplatingParam `json:"params,omitempty"`
Components_ []TemplatingComponent `json:"components,omitempty"`
Components_ []*TemplatingComponent `json:"components,omitempty"`
Namespace_ string `json:"namespace"`
}

// NewMsgTemplating creates and returns a new msg template
func NewMsgTemplating(template *assets.TemplateReference, params map[string][]TemplatingParam, components []TemplatingComponent, namespace string) *MsgTemplating {
func NewMsgTemplating(template *assets.TemplateReference, params map[string][]TemplatingParam, components []*TemplatingComponent, namespace string) *MsgTemplating {
return &MsgTemplating{Template_: template, Namespace_: namespace, Components_: components, Params_: params}
}

Expand All @@ -198,7 +241,7 @@ func (t *MsgTemplating) Template() *assets.TemplateReference { return t.Template
func (t *MsgTemplating) Namespace() string { return t.Namespace_ }

// Components returns the components that should be used for the templates
func (t *MsgTemplating) Components() []TemplatingComponent { return t.Components_ }
func (t *MsgTemplating) Components() []*TemplatingComponent { return t.Components_ }

// Params returns the params that should be used for the template
func (t *MsgTemplating) Params() map[string][]TemplatingParam { return t.Params_ }
Expand Down
50 changes: 48 additions & 2 deletions flows/msg_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,12 +163,12 @@ func TestMsgTemplating(t *testing.T) {

templateRef := assets.NewTemplateReference("61602f3e-f603-4c70-8a8f-c477505bf4bf", "Affirmation")

msgTemplating := flows.NewMsgTemplating(templateRef, map[string][]flows.TemplatingParam{"body": {{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}, []flows.TemplatingComponent{{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}}, "0162a7f4_dfe4_4c96_be07_854d5dba3b2b")
msgTemplating := flows.NewMsgTemplating(templateRef, map[string][]flows.TemplatingParam{"body": {{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}, []*flows.TemplatingComponent{{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}}, "0162a7f4_dfe4_4c96_be07_854d5dba3b2b")

assert.Equal(t, templateRef, msgTemplating.Template())
assert.Equal(t, "0162a7f4_dfe4_4c96_be07_854d5dba3b2b", msgTemplating.Namespace())
assert.Equal(t, map[string][]flows.TemplatingParam{"body": {{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}, msgTemplating.Params())
assert.Equal(t, []flows.TemplatingComponent{{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}}, msgTemplating.Components())
assert.Equal(t, []*flows.TemplatingComponent{{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Ryan Lewis"}, {Type: "text", Value: "boy"}}}}, msgTemplating.Components())

// test marshaling our msg
marshaled, err := jsonx.Marshal(msgTemplating)
Expand Down Expand Up @@ -207,3 +207,49 @@ func TestMsgTemplating(t *testing.T) {
}
}`), marshaled, "JSON mismatch")
}

func TestTemplatingComponentPreview(t *testing.T) {
tcs := []struct {
templating *flows.TemplatingComponent
component assets.TemplateComponent
expectedContent string
expectedDisplay string
}{
{ // 0: no params
component: static.NewTemplateComponent("body", "body", "Hello", "", []*static.TemplateParam{}),
templating: &flows.TemplatingComponent{Type: "body", Params: []flows.TemplatingParam{}},
expectedContent: "Hello",
expectedDisplay: "",
},
{ // 1: two params on component and two params in templating
component: static.NewTemplateComponent("body", "body", "Hello {{1}} {{2}}", "", []*static.TemplateParam{{Type_: "text"}, {Type_: "text"}}),
templating: &flows.TemplatingComponent{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Dr"}, {Type: "text", Value: "Bob"}}},
expectedContent: "Hello Dr Bob",
expectedDisplay: "",
},
{ // 2: one less param in templating than on component
component: static.NewTemplateComponent("body", "body", "Hello {{1}} {{2}}", "", []*static.TemplateParam{{Type_: "text"}, {Type_: "text"}}),
templating: &flows.TemplatingComponent{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Dr"}}},
expectedContent: "Hello Dr ",
expectedDisplay: "",
},
{ // 3
component: static.NewTemplateComponent("button/quick_reply", "button.0", "{{1}}", "", []*static.TemplateParam{{Type_: "text"}}),
templating: &flows.TemplatingComponent{Type: "button/quick_reply", Params: []flows.TemplatingParam{{Type: "text", Value: "Yes"}}},
expectedContent: "Yes",
expectedDisplay: "",
},
{ // 4: one param for content, one for display
component: static.NewTemplateComponent("button/url", "button.0", "example.com?p={{1}}", "{{1}}", []*static.TemplateParam{{Type_: "text"}}),
templating: &flows.TemplatingComponent{Type: "button/url", Params: []flows.TemplatingParam{{Type: "text", Value: "123"}, {Type: "text", Value: "Go"}}},
expectedContent: "example.com?p=123",
expectedDisplay: "Go",
},
}

for i, tc := range tcs {
actualContent, actualDisplay := tc.templating.Preview(tc.component)
assert.Equal(t, tc.expectedContent, actualContent, "content mismatch in test %d", i)
assert.Equal(t, tc.expectedDisplay, actualDisplay, "display mismatch in test %d", i)
}
}
28 changes: 0 additions & 28 deletions flows/template.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
package flows

import (
"fmt"
"regexp"
"strings"

"github.com/nyaruka/gocommon/i18n"
"github.com/nyaruka/goflow/assets"
)
Expand Down Expand Up @@ -62,30 +58,6 @@ func NewTemplateTranslation(t assets.TemplateTranslation) *TemplateTranslation {
// Asset returns the underlying asset
func (t *TemplateTranslation) Asset() assets.TemplateTranslation { return t.TemplateTranslation }

var templateRegex = regexp.MustCompile(`({{\d+}})`)

func (t *TemplateTranslation) Preview(templating *MsgTemplating) map[string]string {
preview := make(map[string]string, len(t.Components()))

for ic, comp := range t.Components() {
content := comp.Content()
key := comp.Name()

if ic < len(templating.Components()) {
for i, p := range templating.Components()[ic].Params {
content = strings.ReplaceAll(content, fmt.Sprintf("{{%d}}", i+1), p.Value)
}
}

// replace any remaining unmatched items with empty string
content = templateRegex.ReplaceAllString(content, "")

preview[key] = content
}

return preview
}

// TemplateAssets is our type for all the templates in an environment
type TemplateAssets struct {
templates []*Template
Expand Down
72 changes: 0 additions & 72 deletions flows/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,75 +56,3 @@ func TestFindTranslation(t *testing.T) {
assert.Equal(t, assets.NewTemplateReference("c520cbda-e118-440f-aaf6-c0485088384f", "greeting"), template.Reference())
assert.Equal(t, (*assets.TemplateReference)(nil), (*flows.Template)(nil).Reference())
}

func TestTemplatePreview(t *testing.T) {
channel := test.NewChannel("WhatsApp", "+12345", []string{"whatsapp"}, []assets.ChannelRole{}, nil)
channelRef := assets.NewChannelReference(channel.UUID(), channel.Name())

tt := static.NewTemplateTranslation(channelRef, i18n.Locale("eng"), "", []*static.TemplateComponent{
{
Content_: "Hello {{1}}, {{2}}",
Type_: "body",
Name_: "body",
Params_: []*static.TemplateParam{static.NewTemplateParam("text")},
},
{
Content_: "Yes",
Type_: "button/quick_reply",
Name_: "button.0",
Params_: []*static.TemplateParam{},
},
{
Content_: "No {{1}}",
Type_: "button/quick_reply",
Name_: "button.1",
Params_: []*static.TemplateParam{static.NewTemplateParam("text")},
},
})

template := flows.NewTemplate(static.NewTemplate("c520cbda-e118-440f-aaf6-c0485088384f", "greeting", []*static.TemplateTranslation{tt}))
translation := template.FindTranslation(channel, []i18n.Locale{"eng"})

tcs := []struct {
components []flows.TemplatingComponent
expected map[string]string
}{
{
[]flows.TemplatingComponent{}, // no params
map[string]string{"body": "Hello , ", "button.0": "Yes", "button.1": "No "},
},
{
[]flows.TemplatingComponent{{
Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Bob"}}, // missing 1 param for body
}},
map[string]string{"body": "Hello Bob, ", "button.0": "Yes", "button.1": "No "},
},
{
[]flows.TemplatingComponent{{
Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Bob"}, {Type: "text", Value: "how are you?"}, {Type: "text", Value: "xxx"}}, // 1 extra param
}},
map[string]string{"body": "Hello Bob, how are you?", "button.0": "Yes", "button.1": "No "},
},
{
[]flows.TemplatingComponent{
{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Bob"}}},
{Type: "header", Params: []flows.TemplatingParam{{Type: "text", Value: "Hi"}}},
}, // extra component ignored
map[string]string{"body": "Hello Bob, ", "button.0": "Yes", "button.1": "No "},
},
{
[]flows.TemplatingComponent{
{Type: "body", Params: []flows.TemplatingParam{{Type: "text", Value: "Bob"}}},
{Type: "button/quick_reply", Params: []flows.TemplatingParam{}},
{Type: "button/quick_reply", Params: []flows.TemplatingParam{{Type: "text", Value: "code002"}}},
},
map[string]string{"body": "Hello Bob, ", "button.0": "Yes", "button.1": "No code002"},
},
}

for _, tc := range tcs {
templating := flows.NewMsgTemplating(template.Reference(), map[string][]flows.TemplatingParam{}, tc.components, "")

assert.Equal(t, tc.expected, translation.Preview(templating))
}
}

0 comments on commit 5357857

Please sign in to comment.