diff --git a/business.go b/business.go index da3f059b8..ebf2a53c1 100644 --- a/business.go +++ b/business.go @@ -1643,6 +1643,22 @@ type BusinessNativeFlowButtonsMessageParams struct { ContextInfo *waE2E.ContextInfo } +// BusinessCarouselCard carries already uploaded image or video media and native controls. +// Exactly one media field must be set; reply IDs must be unique across the carousel. +type BusinessCarouselCard struct { + Body string + Image *waE2E.ImageMessage + Video *waE2E.VideoMessage + Buttons []BusinessNativeFlowButton +} + +type BusinessCarouselMessageParams struct { + Body string + Footer string + Cards []BusinessCarouselCard + ContextInfo *waE2E.ContextInfo +} + type BusinessAddressMessageParams struct { Body string ButtonText string @@ -1814,7 +1830,7 @@ func BuildBusinessOrderMessage(params BusinessOrderMessageParams) (*waE2E.Messag } func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, error) { - if !bounded(params.Title, 60) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 1024) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 20) || !bounded(params.Footer, 60) { + if !bounded(params.Title, 256) || strings.TrimSpace(params.Description) == "" || !bounded(params.Description, 4096) || strings.TrimSpace(params.ButtonText) == "" || !bounded(params.ButtonText, 64) || !bounded(params.Footer, 256) { return nil, errors.New("invalid business list text") } if len(params.Sections) == 0 || len(params.Sections) > 10 { @@ -1824,16 +1840,16 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, seen := make(map[string]struct{}) rowCount := 0 for sectionIndex, section := range params.Sections { - if !bounded(section.Title, 24) || len(section.Rows) == 0 || (len(params.Sections) > 1 && strings.TrimSpace(section.Title) == "") { + if !bounded(section.Title, 256) || len(section.Rows) == 0 { return nil, fmt.Errorf("invalid business list section %d", sectionIndex) } - if len(section.Rows) > 10-rowCount { - return nil, errors.New("business list exceeds 10 rows") + if len(section.Rows) > 30-rowCount { + return nil, errors.New("business list exceeds 30 rows") } rowCount += len(section.Rows) rows := make([]*waE2E.ListMessage_Row, len(section.Rows)) for rowIndex, row := range section.Rows { - if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 200) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 24) || !bounded(row.Description, 72) { + if strings.TrimSpace(row.ID) == "" || !bounded(row.ID, 256) || strings.TrimSpace(row.Title) == "" || !bounded(row.Title, 256) || !bounded(row.Description, 1024) { return nil, fmt.Errorf("invalid business list row %d in section %d", rowIndex, sectionIndex) } if _, exists := seen[row.ID]; exists { @@ -1850,39 +1866,105 @@ func BuildBusinessListMessage(params BusinessListMessageParams) (*waE2E.Message, }}, nil } +// BuildBusinessNativeFlowButtonsMessage creates the native InteractiveMessage form. +// Web supports up to ten quick replies or three CTA buttons, without mixing them. func BuildBusinessNativeFlowButtonsMessage(params BusinessNativeFlowButtonsMessageParams) (*waE2E.Message, error) { - if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 1024) || !bounded(params.Title, 60) || !bounded(params.Footer, 60) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || !bounded(params.Title, 256) || !bounded(params.Footer, 256) { return nil, errors.New("invalid business native-flow text") } - if len(params.Buttons) == 0 || len(params.Buttons) > 3 { - return nil, errors.New("business native-flow message must contain 1 to 3 buttons") + if len(params.Buttons) == 0 || len(params.Buttons) > 10 { + return nil, errors.New("business native-flow message must contain 1 to 10 buttons") } - buttons := make([]*waE2E.ButtonsMessage_Button, len(params.Buttons)) + buttons := make([]*waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton, len(params.Buttons)) + replies := 0 for index, button := range params.Buttons { - if strings.TrimSpace(button.Name) == "" || !bounded(button.Name, 64) || strings.TrimSpace(button.ParamsJSON) == "" || !bounded(button.ParamsJSON, 8192) { + switch button.Name { + case "quick_reply": + replies++ + case "cta_url", "cta_call", "cta_copy", "cta_catalog": + default: + return nil, fmt.Errorf("unsupported business native-flow button %d", index) + } + if strings.TrimSpace(button.ParamsJSON) == "" || !bounded(button.ParamsJSON, 8192) { return nil, fmt.Errorf("invalid business native-flow button %d", index) } var object map[string]any if err := json.Unmarshal([]byte(button.ParamsJSON), &object); err != nil || object == nil { return nil, fmt.Errorf("invalid business native-flow params for button %d", index) } - buttons[index] = &waE2E.ButtonsMessage_Button{ - Type: waE2E.ButtonsMessage_Button_NATIVE_FLOW.Enum(), - NativeFlowInfo: &waE2E.ButtonsMessage_Button_NativeFlowInfo{ - Name: proto.String(button.Name), ParamsJSON: proto.String(button.ParamsJSON), - }, + buttons[index] = &waE2E.InteractiveMessage_NativeFlowMessage_NativeFlowButton{ + Name: proto.String(button.Name), ButtonParamsJSON: proto.String(button.ParamsJSON), } } - headerType := waE2E.ButtonsMessage_EMPTY - message := &waE2E.ButtonsMessage{ - ContentText: proto.String(params.Body), FooterText: optionalString(params.Footer), Buttons: buttons, HeaderType: headerType.Enum(), ContextInfo: params.ContextInfo, + if replies != 0 && replies != len(buttons) { + return nil, errors.New("business native-flow quick replies cannot be mixed with CTA buttons") + } + if replies == 0 && len(buttons) > 3 { + return nil, errors.New("business native-flow message exceeds 3 CTA buttons") + } + message := &waE2E.InteractiveMessage{ + Body: &waE2E.InteractiveMessage_Body{Text: proto.String(params.Body)}, + ContextInfo: params.ContextInfo, + InteractiveMessage: &waE2E.InteractiveMessage_NativeFlowMessage_{NativeFlowMessage: &waE2E.InteractiveMessage_NativeFlowMessage{ + Buttons: buttons, MessageVersion: proto.Int32(1), + }}, } if params.Title != "" { - headerType = waE2E.ButtonsMessage_TEXT - message.HeaderType = headerType.Enum() - message.Header = &waE2E.ButtonsMessage_Text{Text: params.Title} + message.Header = &waE2E.InteractiveMessage_Header{Title: proto.String(params.Title)} + } + if params.Footer != "" { + message.Footer = &waE2E.InteractiveMessage_Footer{Text: proto.String(params.Footer)} + } + return &waE2E.Message{InteractiveMessage: message}, nil +} + +// BuildBusinessCarouselMessage builds one bounded Linked Devices carousel. +// The shipped Web parser accepts more cards; this sender deliberately allows 2–10. +func BuildBusinessCarouselMessage(params BusinessCarouselMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(params.Body) == "" || !bounded(params.Body, 4096) || !bounded(params.Footer, 256) { + return nil, errors.New("invalid business carousel text") + } + if len(params.Cards) < 2 || len(params.Cards) > 10 { + return nil, errors.New("business carousel must contain 2 to 10 cards") + } + cards := make([]*waE2E.InteractiveMessage, len(params.Cards)) + replyIDs := make(map[string]struct{}) + for i, card := range params.Cards { + if (card.Image == nil) == (card.Video == nil) { + return nil, fmt.Errorf("carousel card %d requires exactly one image or video", i) + } + msg, err := BuildBusinessNativeFlowButtonsMessage(BusinessNativeFlowButtonsMessageParams{Body: card.Body, Buttons: card.Buttons}) + if err != nil { + return nil, fmt.Errorf("carousel card %d: %w", i, err) + } + for _, button := range card.Buttons { + if button.Name == "quick_reply" { + var fields struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(button.ParamsJSON), &fields); err != nil || strings.TrimSpace(fields.ID) == "" || !bounded(fields.ID, 256) { + return nil, fmt.Errorf("carousel card %d has an invalid reply ID", i) + } + if _, exists := replyIDs[fields.ID]; exists { + return nil, errors.New("carousel reply IDs must be unique") + } + replyIDs[fields.ID] = struct{}{} + } + } + interactive := msg.InteractiveMessage + interactive.Header = &waE2E.InteractiveMessage_Header{HasMediaAttachment: proto.Bool(true)} + if card.Image != nil { + interactive.Header.Media = &waE2E.InteractiveMessage_Header_ImageMessage{ImageMessage: card.Image} + } else { + interactive.Header.Media = &waE2E.InteractiveMessage_Header_VideoMessage{VideoMessage: card.Video} + } + cards[i] = interactive + } + interactive := &waE2E.InteractiveMessage{Body: &waE2E.InteractiveMessage_Body{Text: proto.String(params.Body)}, ContextInfo: params.ContextInfo, InteractiveMessage: &waE2E.InteractiveMessage_CarouselMessage_{CarouselMessage: &waE2E.InteractiveMessage_CarouselMessage{Cards: cards, MessageVersion: proto.Int32(1)}}} + if params.Footer != "" { + interactive.Footer = &waE2E.InteractiveMessage_Footer{Text: proto.String(params.Footer)} } - return &waE2E.Message{ButtonsMessage: message}, nil + return &waE2E.Message{InteractiveMessage: interactive}, nil } func BuildBusinessAddressMessage(params BusinessAddressMessageParams) (*waE2E.Message, error) { diff --git a/business_carousel_test.go b/business_carousel_test.go new file mode 100644 index 000000000..cff687d1a --- /dev/null +++ b/business_carousel_test.go @@ -0,0 +1,97 @@ +package whatsmeow + +import ( + "bytes" + "fmt" + "testing" + + "google.golang.org/protobuf/proto" + + "github.com/polymorfa/hypermeow/proto/waE2E" +) + +func carouselCard(index int, video bool) BusinessCarouselCard { + card := BusinessCarouselCard{Body: fmt.Sprintf("Card %d", index), Buttons: []BusinessNativeFlowButton{{Name: "quick_reply", ParamsJSON: fmt.Sprintf(`{"display_text":"Choose","id":"card-%d"}`, index)}}} + if video { + card.Video = &waE2E.VideoMessage{DirectPath: proto.String("/mms/video/synthetic"), Mimetype: proto.String("video/mp4"), MediaKey: bytes.Repeat([]byte{1}, 32), FileSHA256: bytes.Repeat([]byte{2}, 32), FileEncSHA256: bytes.Repeat([]byte{3}, 32)} + } else { + card.Image = &waE2E.ImageMessage{DirectPath: proto.String("/mms/image/synthetic"), Mimetype: proto.String("image/jpeg"), MediaKey: bytes.Repeat([]byte{1}, 32), FileSHA256: bytes.Repeat([]byte{2}, 32), FileEncSHA256: bytes.Repeat([]byte{3}, 32)} + } + return card +} + +func TestBusinessCarouselRoundTripAndRelayMetadata(t *testing.T) { + for _, count := range []int{2, 10} { + t.Run(fmt.Sprint(count), func(t *testing.T) { + cards := make([]BusinessCarouselCard, count) + for i := range cards { + cards[i] = carouselCard(i, i%2 == 1) + } + context := &waE2E.ContextInfo{StanzaID: proto.String("quoted-synthetic")} + msg, err := BuildBusinessCarouselMessage(BusinessCarouselMessageParams{Body: "Offers", Cards: cards, ContextInfo: context}) + if err != nil { + t.Fatal(err) + } + encoded, err := proto.Marshal(msg) + if err != nil { + t.Fatal(err) + } + var decoded waE2E.Message + if err = proto.Unmarshal(encoded, &decoded); err != nil { + t.Fatal(err) + } + interactive := decoded.GetInteractiveMessage() + carousel := interactive.GetCarouselMessage() + if len(carousel.GetCards()) != count || carousel.GetMessageVersion() != 1 || interactive.GetContextInfo().GetStanzaID() != "quoted-synthetic" { + t.Fatal("carousel/context did not survive protobuf round trip") + } + for i, card := range carousel.GetCards() { + if card.GetNativeFlowMessage().GetMessageVersion() != 1 || !card.GetHeader().GetHasMediaAttachment() { + t.Fatalf("card%d lacks canonical native media header", i) + } + if i%2 == 0 && !proto.Equal(card.GetHeader().GetImageMessage(), cards[i].Image) { + t.Fatal("image media mutated") + } + if i%2 == 1 && !proto.Equal(card.GetHeader().GetVideoMessage(), cards[i].Video) { + t.Fatal("video media mutated") + } + } + if getButtonTypeFromMessage(&decoded) != "native_flow" { + t.Fatal("carousel must request native business metadata") + } + biz := buildNativeFlowBizNode(&decoded, 1) + if biz.GetChildren()[0].GetChildren()[0].Attrs["name"] != "mixed" { + t.Fatal("carousel requires mixed metadata for card controls") + } + }) + } +} + +func TestBusinessCarouselRejectsInvalidCards(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*BusinessCarouselMessageParams) + }{ + {"one", func(p *BusinessCarouselMessageParams) { p.Cards = p.Cards[:1] }}, + {"eleven", func(p *BusinessCarouselMessageParams) { + for len(p.Cards) < 11 { + p.Cards = append(p.Cards, carouselCard(len(p.Cards), false)) + } + }}, + {"missing media", func(p *BusinessCarouselMessageParams) { p.Cards[0].Image = nil }}, + {"two media", func(p *BusinessCarouselMessageParams) { p.Cards[0].Video = carouselCard(3, true).Video }}, + {"empty controls", func(p *BusinessCarouselMessageParams) { p.Cards[0].Buttons = nil }}, + {"mixed controls", func(p *BusinessCarouselMessageParams) { + p.Cards[0].Buttons = append(p.Cards[0].Buttons, BusinessNativeFlowButton{Name: "cta_url", ParamsJSON: `{"display_text":"Open","url":"https://example.com"}`}) + }}, + {"duplicate reply ID", func(p *BusinessCarouselMessageParams) { p.Cards[1].Buttons = p.Cards[0].Buttons }}, + } { + t.Run(tc.name, func(t *testing.T) { + p := BusinessCarouselMessageParams{Body: "Offers", Cards: []BusinessCarouselCard{carouselCard(0, false), carouselCard(1, true)}} + tc.mutate(&p) + if _, err := BuildBusinessCarouselMessage(p); err == nil { + t.Fatal("expected invalid carousel error") + } + }) + } +} diff --git a/business_hydrated.go b/business_hydrated.go new file mode 100644 index 000000000..2540e68dd --- /dev/null +++ b/business_hydrated.go @@ -0,0 +1,99 @@ +package whatsmeow + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + + "google.golang.org/protobuf/proto" + + "github.com/polymorfa/hypermeow/proto/waE2E" +) + +// BusinessHydratedButtonsMessageParams is an uploaded-media hydrated template. +// Encoding this format does not imply Meta template approval or relay admission. +type BusinessHydratedButtonsMessageParams struct { + Title, Body, Footer string + Buttons []BusinessNativeFlowButton + Image *waE2E.ImageMessage + Video *waE2E.VideoMessage + Document *waE2E.DocumentMessage + ContextInfo *waE2E.ContextInfo +} + +// BuildBusinessHydratedButtonsMessage permits replies mixed with URL/call CTAs. +// Received Web relays additionally require a server HSM envelope. +func BuildBusinessHydratedButtonsMessage(p BusinessHydratedButtonsMessageParams) (*waE2E.Message, error) { + if strings.TrimSpace(p.Body) == "" || !bounded(p.Body, 4096) || !bounded(p.Title, 256) || !bounded(p.Footer, 256) || len(p.Buttons) < 1 || len(p.Buttons) > 10 { + return nil, errors.New("invalid hydrated template text or button count") + } + mediaCount := 0 + if p.Image != nil { + mediaCount++ + } + if p.Video != nil { + mediaCount++ + } + if p.Document != nil { + mediaCount++ + } + if mediaCount > 1 || (mediaCount > 0 && p.Title != "") { + return nil, errors.New("hydrated template accepts one title or media header") + } + t := &waE2E.TemplateMessage_HydratedFourRowTemplate{HydratedContentText: proto.String(p.Body), HydratedFooterText: optionalString(p.Footer)} + ids := map[string]bool{} + ctas := 0 + for i, b := range p.Buttons { + var params struct { + Text string `json:"display_text"` + ID string `json:"id"` + URL string `json:"url"` + Phone string `json:"phone_number"` + } + if !bounded(b.ParamsJSON, 8192) || json.Unmarshal([]byte(b.ParamsJSON), ¶ms) != nil || strings.TrimSpace(params.Text) == "" || !bounded(params.Text, 256) { + return nil, fmt.Errorf("invalid hydrated button %d", i) + } + button := &waE2E.HydratedTemplateButton{Index: proto.Uint32(uint32(i + 1))} + switch b.Name { + case "quick_reply": + if strings.TrimSpace(params.ID) == "" || !bounded(params.ID, 256) || ids[params.ID] { + return nil, errors.New("hydrated reply IDs must be nonempty and unique") + } + ids[params.ID] = true + button.HydratedButton = &waE2E.HydratedTemplateButton_QuickReplyButton{QuickReplyButton: &waE2E.HydratedTemplateButton_HydratedQuickReplyButton{DisplayText: proto.String(params.Text), ID: proto.String(params.ID)}} + case "cta_url": + parsed, err := url.ParseRequestURI(params.URL) + if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || !bounded(params.URL, 2048) { + return nil, errors.New("hydrated URL must be absolute HTTPS") + } + ctas++ + button.HydratedButton = &waE2E.HydratedTemplateButton_UrlButton{UrlButton: &waE2E.HydratedTemplateButton_HydratedURLButton{DisplayText: proto.String(params.Text), URL: proto.String(params.URL)}} + case "cta_call": + phone := strings.TrimPrefix(params.Phone, "+") + if len(phone) < 6 || len(phone) > 20 || strings.IndexFunc(phone, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return nil, errors.New("invalid hydrated call phone") + } + ctas++ + button.HydratedButton = &waE2E.HydratedTemplateButton_CallButton{CallButton: &waE2E.HydratedTemplateButton_HydratedCallButton{DisplayText: proto.String(params.Text), PhoneNumber: proto.String(params.Phone)}} + default: + return nil, errors.New("hydrated templates support replies and URL/call buttons") + } + t.HydratedButtons = append(t.HydratedButtons, button) + } + if ctas > 3 { + return nil, errors.New("hydrated template exceeds three CTA buttons") + } + switch { + case p.Image != nil: + t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_ImageMessage{ImageMessage: p.Image} + case p.Video != nil: + t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_VideoMessage{VideoMessage: p.Video} + case p.Document != nil: + t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_DocumentMessage{DocumentMessage: p.Document} + case p.Title != "": + t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_HydratedTitleText{HydratedTitleText: p.Title} + } + return &waE2E.Message{TemplateMessage: &waE2E.TemplateMessage{HydratedTemplate: t, ContextInfo: p.ContextInfo}}, nil +} diff --git a/business_hydrated_test.go b/business_hydrated_test.go new file mode 100644 index 000000000..3f783f0c0 --- /dev/null +++ b/business_hydrated_test.go @@ -0,0 +1,73 @@ +package whatsmeow + +import ( + "bytes" + "testing" + + waBinary "github.com/polymorfa/hypermeow/binary" + + "google.golang.org/protobuf/proto" + + "github.com/polymorfa/hypermeow/proto/waE2E" +) + +func TestHydratedMixedButtonsRoundTrip(t *testing.T) { + params := BusinessHydratedButtonsMessageParams{Body: "Mixed fixture", Footer: "Footer", ContextInfo: &waE2E.ContextInfo{StanzaID: proto.String("quoted-fixture")}, Buttons: []BusinessNativeFlowButton{ + {Name: "quick_reply", ParamsJSON: `{"display_text":"Reply","id":"stable-reply"}`}, + {Name: "cta_url", ParamsJSON: `{"display_text":"Open","url":"https://example.com"}`}, + {Name: "cta_call", ParamsJSON: `{"display_text":"Call","phone_number":"+12025550123"}`}, + }} + message, err := BuildBusinessHydratedButtonsMessage(params) + if err != nil { + t.Fatal(err) + } + wire, err := proto.Marshal(message) + if err != nil { + t.Fatal(err) + } + decoded := &waE2E.Message{} + if err = proto.Unmarshal(wire, decoded); err != nil { + t.Fatal(err) + } + if getButtonTypeFromMessage(decoded) != "template" { + t.Fatal("missing template format classification") + } + template := decoded.GetTemplateMessage().GetHydratedTemplate() + if len(template.GetHydratedButtons()) != 3 || template.GetHydratedButtons()[0].GetQuickReplyButton().GetID() != "stable-reply" || decoded.GetTemplateMessage().GetContextInfo().GetStanzaID() != "quoted-fixture" { + t.Fatal("mixed buttons or quote context lost") + } + for i, button := range template.GetHydratedButtons() { + if button.GetIndex() != uint32(i+1) { + t.Fatal("button index lost") + } + } + for _, bad := range []BusinessHydratedButtonsMessageParams{ + {Body: "Body"}, + {Body: "Body", Buttons: append(params.Buttons, params.Buttons[0])}, + {Body: "Body", Buttons: []BusinessNativeFlowButton{{Name: "cta_copy", ParamsJSON: `{"display_text":"Copy","copy_code":"CODE"}`}}}, + } { + if _, err := BuildBusinessHydratedButtonsMessage(bad); err == nil { + t.Fatal("invalid hydrated button message accepted") + } + } +} + +func TestHydratedRelayEnvelopePreservesCiphertextWithoutApprovalClaims(t *testing.T) { + message, err := BuildBusinessHydratedButtonsMessage(BusinessHydratedButtonsMessageParams{Body: "Offer", Buttons: []BusinessNativeFlowButton{{Name: "quick_reply", ParamsJSON: `{"display_text":"Reply","id":"stable"}`}, {Name: "cta_url", ParamsJSON: `{"display_text":"Open","url":"https://example.com"}`}}}) + if err != nil { + t.Fatal(err) + } + ciphertext := []byte("opaque encrypted payload") + content := (&Client{}).getMessageContent(waBinary.Node{Tag: "enc", Content: ciphertext}, message, nil, false, nodeExtraParams{}) + if len(content) != 3 || content[0].Tag != "enc" || content[1].Tag != "biz" || content[2].Tag != "hsm" { + t.Fatal("missing root format envelope") + } + if !bytes.Equal(content[0].Content.([]byte), ciphertext) { + t.Fatal("ciphertext changed") + } + for _, node := range content[1:] { + if len(node.Attrs) != 0 || node.Content != nil { + t.Fatal("format metadata asserted provider approval or identity") + } + } +} diff --git a/business_test.go b/business_test.go index 39a2662d2..20f9790f5 100644 --- a/business_test.go +++ b/business_test.go @@ -811,8 +811,8 @@ func TestBuildBusinessListAndNativeFlowButtonsMatchWebGenerators(t *testing.T) { if err != nil { t.Fatal(err) } - button := buttons.GetButtonsMessage().GetButtons()[0] - if button.GetType() != waE2E.ButtonsMessage_Button_NATIVE_FLOW || button.GetNativeFlowInfo().GetName() != "cta_url" { + button := buttons.GetInteractiveMessage().GetNativeFlowMessage().GetButtons()[0] + if buttons.GetButtonsMessage() != nil || buttons.GetInteractiveMessage().GetNativeFlowMessage().GetMessageVersion() != 1 || button.GetName() != "cta_url" { t.Fatalf("unexpected native-flow button: %#v", button) } } @@ -855,13 +855,25 @@ func TestBuildBusinessListRequiresBodyAndCapsRows(t *testing.T) { if _, err := BuildBusinessListMessage(missingBody); err == nil { t.Fatal("list without a body unexpectedly passed") } + thirtyRows := valid + thirtyRows.Title = strings.Repeat("t", 256) + thirtyRows.Description = strings.Repeat("b", 4096) + thirtyRows.ButtonText = strings.Repeat("v", 64) + thirtyRows.Footer = strings.Repeat("f", 256) + thirtyRows.Sections = []BusinessListSection{{Title: strings.Repeat("s", 256), Rows: make([]BusinessListRow, 30)}} + for index := range thirtyRows.Sections[0].Rows { + thirtyRows.Sections[0].Rows[index] = BusinessListRow{ID: fmt.Sprintf("row-%d", index), Title: strings.Repeat("r", 256), Description: strings.Repeat("d", 1024)} + } + if message, err := BuildBusinessListMessage(thirtyRows); err != nil || len(message.GetListMessage().GetSections()[0].GetRows()) != 30 { + t.Fatalf("thirty-row native list at API bounds failed: %v", err) + } tooManyRows := valid - tooManyRows.Sections[0].Rows = make([]BusinessListRow, 11) + tooManyRows.Sections[0].Rows = make([]BusinessListRow, 31) for index := range tooManyRows.Sections[0].Rows { tooManyRows.Sections[0].Rows[index] = BusinessListRow{ID: fmt.Sprintf("row-%d", index), Title: "Row"} } if _, err := BuildBusinessListMessage(tooManyRows); err == nil { - t.Fatal("list with more than ten rows unexpectedly passed") + t.Fatal("list with more than thirty rows unexpectedly passed") } } @@ -1162,9 +1174,9 @@ func TestBusinessProductListAndNativeFlowTextLimits(t *testing.T) { Buttons: []BusinessNativeFlowButton{{Name: "cta_url", ParamsJSON: `{}`}}, } nativeMutations := map[string]func(*BusinessNativeFlowButtonsMessageParams){ - "header": func(params *BusinessNativeFlowButtonsMessageParams) { params.Title = strings.Repeat("h", 61) }, - "body": func(params *BusinessNativeFlowButtonsMessageParams) { params.Body = strings.Repeat("b", 1025) }, - "footer": func(params *BusinessNativeFlowButtonsMessageParams) { params.Footer = strings.Repeat("f", 61) }, + "header": func(params *BusinessNativeFlowButtonsMessageParams) { params.Title = strings.Repeat("h", 257) }, + "body": func(params *BusinessNativeFlowButtonsMessageParams) { params.Body = strings.Repeat("b", 4097) }, + "footer": func(params *BusinessNativeFlowButtonsMessageParams) { params.Footer = strings.Repeat("f", 257) }, } for name, mutate := range nativeMutations { t.Run("native flow "+name, func(t *testing.T) { @@ -1183,15 +1195,15 @@ func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { Sections: []BusinessListSection{{Title: "Section", Rows: []BusinessListRow{{ID: "one", Title: "One", Description: "Description"}}}}, } mutations := map[string]func(*BusinessListMessageParams){ - "header": func(params *BusinessListMessageParams) { params.Title = strings.Repeat("h", 61) }, - "body": func(params *BusinessListMessageParams) { params.Description = strings.Repeat("b", 1025) }, - "button": func(params *BusinessListMessageParams) { params.ButtonText = strings.Repeat("c", 21) }, - "footer": func(params *BusinessListMessageParams) { params.Footer = strings.Repeat("f", 61) }, - "section title": func(params *BusinessListMessageParams) { params.Sections[0].Title = strings.Repeat("s", 25) }, - "row ID": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].ID = strings.Repeat("i", 201) }, - "row title": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].Title = strings.Repeat("r", 25) }, + "header": func(params *BusinessListMessageParams) { params.Title = strings.Repeat("h", 257) }, + "body": func(params *BusinessListMessageParams) { params.Description = strings.Repeat("b", 4097) }, + "button": func(params *BusinessListMessageParams) { params.ButtonText = strings.Repeat("c", 65) }, + "footer": func(params *BusinessListMessageParams) { params.Footer = strings.Repeat("f", 257) }, + "section title": func(params *BusinessListMessageParams) { params.Sections[0].Title = strings.Repeat("s", 257) }, + "row ID": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].ID = strings.Repeat("i", 257) }, + "row title": func(params *BusinessListMessageParams) { params.Sections[0].Rows[0].Title = strings.Repeat("r", 257) }, "row description": func(params *BusinessListMessageParams) { - params.Sections[0].Rows[0].Description = strings.Repeat("d", 73) + params.Sections[0].Rows[0].Description = strings.Repeat("d", 1025) }, } for name, mutate := range mutations { @@ -1209,8 +1221,8 @@ func TestBusinessListMessageEnforcesProtocolTextLimits(t *testing.T) { {Rows: []BusinessListRow{{ID: "one", Title: "One"}}}, {Title: "Second", Rows: []BusinessListRow{{ID: "two", Title: "Two"}}}, } - if _, err := BuildBusinessListMessage(multipleSections); err == nil { - t.Fatal("multiple sections with an empty title unexpectedly passed") + if _, err := BuildBusinessListMessage(multipleSections); err != nil { + t.Fatalf("native list optional section title failed: %v", err) } } @@ -1857,3 +1869,40 @@ func TestBusinessCoverPhotoValidationAndNodes(t *testing.T) { t.Fatal("expected empty cover ID error") } } + +func TestNativeButtonsWebConstraintsAndBusinessMetadata(t *testing.T) { + reply := BusinessNativeFlowButton{Name: "quick_reply", ParamsJSON: `{"display_text":"Reply","id":"synthetic"}`} + url := BusinessNativeFlowButton{Name: "cta_url", ParamsJSON: `{"display_text":"Open","url":"https://example.com"}`} + copyButton := BusinessNativeFlowButton{Name: "cta_copy", ParamsJSON: `{"display_text":"Copy","copy_code":"synthetic"}`} + for _, tc := range []struct { + name string + buttons []BusinessNativeFlowButton + valid bool + flowName string + }{ + {"ten replies", []BusinessNativeFlowButton{reply, reply, reply, reply, reply, reply, reply, reply, reply, reply}, true, "quick_reply"}, + {"eleven replies", []BusinessNativeFlowButton{reply, reply, reply, reply, reply, reply, reply, reply, reply, reply, reply}, false, ""}, + {"three CTA", []BusinessNativeFlowButton{url, copyButton, url}, true, "mixed"}, + {"four CTA", []BusinessNativeFlowButton{url, url, url, url}, false, ""}, + {"reply with CTA", []BusinessNativeFlowButton{reply, url}, false, ""}, + {"unsupported name", []BusinessNativeFlowButton{{Name: "unsupported", ParamsJSON: `{}`}}, false, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + msg, err := BuildBusinessNativeFlowButtonsMessage(BusinessNativeFlowButtonsMessageParams{Body: "Choose", Buttons: tc.buttons}) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v error=%v", tc.valid, err) + } + if err != nil { + return + } + if got := getButtonTypeFromMessage(msg); got != "native_flow" { + t.Fatalf("transport kind=%s", got) + } + biz := buildNativeFlowBizNode(msg, 1) + interactive := biz.GetChildren()[0] + if interactive.Attrs["type"] != "native_flow" || interactive.GetChildren()[0].Attrs["name"] != tc.flowName { + t.Fatalf("wrong business metadata: %#v", biz) + } + }) + } +} diff --git a/send.go b/send.go index 48eafd8f9..e73ece90f 100644 --- a/send.go +++ b/send.go @@ -1007,11 +1007,13 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string { return getButtonTypeFromMessage(msg.ViewOnceMessageV2Extension.Message) case msg.EphemeralMessage != nil: return getButtonTypeFromMessage(msg.EphemeralMessage.Message) + case msg.TemplateMessage != nil && msg.TemplateMessage.GetHydratedTemplate() != nil: + return "template" case msg.ButtonsMessage != nil: return "buttons" case msg.ListMessage != nil: return "list" - case msg.InteractiveMessage != nil && msg.InteractiveMessage.GetNativeFlowMessage() != nil: + case msg.InteractiveMessage != nil && (msg.InteractiveMessage.GetNativeFlowMessage() != nil || msg.InteractiveMessage.GetCarouselMessage() != nil): return "native_flow" case msg.InteractiveResponseMessage != nil: return "interactive_response" @@ -1204,7 +1206,10 @@ func (cli *Client) getMessageContent( } if buttonType := getButtonTypeFromMessage(message); buttonType != "" { - if buttonType == "native_flow" { + if buttonType == "template" { + // Format envelope only: no category, template ID, verified name or bot identity. + content = append(content, waBinary.Node{Tag: "biz"}, waBinary.Node{Tag: "hsm"}) + } else if buttonType == "native_flow" { content = append(content, buildNativeFlowBizNode(message, time.Now().Unix())) } else { content = append(content, waBinary.Node{