Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 104 additions & 22 deletions business.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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) {
Expand Down
97 changes: 97 additions & 0 deletions business_carousel_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
}
99 changes: 99 additions & 0 deletions business_hydrated.go
Original file line number Diff line number Diff line change
@@ -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), &params) != 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
}
Loading
Loading