Skip to content

Commit 4a7484b

Browse files
committed
Build mixed hydrated template buttons with format metadata
1 parent 25a70f2 commit 4a7484b

4 files changed

Lines changed: 157 additions & 2 deletions

File tree

‎business_carousel_test.go‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@ import (
55
"fmt"
66
"testing"
77

8-
"github.com/polymorfa/hypermeow/proto/waE2E"
98
"google.golang.org/protobuf/proto"
9+
10+
"github.com/polymorfa/hypermeow/proto/waE2E"
1011
)
1112

1213
func carouselCard(index int, video bool) BusinessCarouselCard {

‎business_hydrated.go‎

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package whatsmeow
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"net/url"
8+
"strings"
9+
10+
"google.golang.org/protobuf/proto"
11+
12+
"github.com/polymorfa/hypermeow/proto/waE2E"
13+
)
14+
15+
// BusinessHydratedButtonsMessageParams is an uploaded-media hydrated template.
16+
// Encoding this format does not imply Meta template approval or relay admission.
17+
type BusinessHydratedButtonsMessageParams struct {
18+
Title, Body, Footer string
19+
Buttons []BusinessNativeFlowButton
20+
Image *waE2E.ImageMessage
21+
Video *waE2E.VideoMessage
22+
Document *waE2E.DocumentMessage
23+
ContextInfo *waE2E.ContextInfo
24+
}
25+
26+
// BuildBusinessHydratedButtonsMessage permits replies mixed with URL/call CTAs.
27+
// Received Web relays additionally require a server HSM envelope.
28+
func BuildBusinessHydratedButtonsMessage(p BusinessHydratedButtonsMessageParams) (*waE2E.Message, error) {
29+
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 {
30+
return nil, errors.New("invalid hydrated template text or button count")
31+
}
32+
mediaCount := 0
33+
if p.Image != nil {
34+
mediaCount++
35+
}
36+
if p.Video != nil {
37+
mediaCount++
38+
}
39+
if p.Document != nil {
40+
mediaCount++
41+
}
42+
if mediaCount > 1 || (mediaCount > 0 && p.Title != "") {
43+
return nil, errors.New("hydrated template accepts one title or media header")
44+
}
45+
t := &waE2E.TemplateMessage_HydratedFourRowTemplate{HydratedContentText: proto.String(p.Body), HydratedFooterText: optionalString(p.Footer)}
46+
ids := map[string]bool{}
47+
ctas := 0
48+
for i, b := range p.Buttons {
49+
var params struct {
50+
Text string `json:"display_text"`
51+
ID string `json:"id"`
52+
URL string `json:"url"`
53+
Phone string `json:"phone_number"`
54+
}
55+
if !bounded(b.ParamsJSON, 8192) || json.Unmarshal([]byte(b.ParamsJSON), &params) != nil || strings.TrimSpace(params.Text) == "" || !bounded(params.Text, 256) {
56+
return nil, fmt.Errorf("invalid hydrated button %d", i)
57+
}
58+
button := &waE2E.HydratedTemplateButton{Index: proto.Uint32(uint32(i + 1))}
59+
switch b.Name {
60+
case "quick_reply":
61+
if strings.TrimSpace(params.ID) == "" || !bounded(params.ID, 256) || ids[params.ID] {
62+
return nil, errors.New("hydrated reply IDs must be nonempty and unique")
63+
}
64+
ids[params.ID] = true
65+
button.HydratedButton = &waE2E.HydratedTemplateButton_QuickReplyButton{QuickReplyButton: &waE2E.HydratedTemplateButton_HydratedQuickReplyButton{DisplayText: proto.String(params.Text), ID: proto.String(params.ID)}}
66+
case "cta_url":
67+
parsed, err := url.ParseRequestURI(params.URL)
68+
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" || !bounded(params.URL, 2048) {
69+
return nil, errors.New("hydrated URL must be absolute HTTPS")
70+
}
71+
ctas++
72+
button.HydratedButton = &waE2E.HydratedTemplateButton_UrlButton{UrlButton: &waE2E.HydratedTemplateButton_HydratedURLButton{DisplayText: proto.String(params.Text), URL: proto.String(params.URL)}}
73+
case "cta_call":
74+
phone := strings.TrimPrefix(params.Phone, "+")
75+
if len(phone) < 6 || len(phone) > 20 || strings.IndexFunc(phone, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
76+
return nil, errors.New("invalid hydrated call phone")
77+
}
78+
ctas++
79+
button.HydratedButton = &waE2E.HydratedTemplateButton_CallButton{CallButton: &waE2E.HydratedTemplateButton_HydratedCallButton{DisplayText: proto.String(params.Text), PhoneNumber: proto.String(params.Phone)}}
80+
default:
81+
return nil, errors.New("hydrated templates support replies and URL/call buttons")
82+
}
83+
t.HydratedButtons = append(t.HydratedButtons, button)
84+
}
85+
if ctas > 3 {
86+
return nil, errors.New("hydrated template exceeds three CTA buttons")
87+
}
88+
switch {
89+
case p.Image != nil:
90+
t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_ImageMessage{ImageMessage: p.Image}
91+
case p.Video != nil:
92+
t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_VideoMessage{VideoMessage: p.Video}
93+
case p.Document != nil:
94+
t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_DocumentMessage{DocumentMessage: p.Document}
95+
case p.Title != "":
96+
t.Title = &waE2E.TemplateMessage_HydratedFourRowTemplate_HydratedTitleText{HydratedTitleText: p.Title}
97+
}
98+
return &waE2E.Message{TemplateMessage: &waE2E.TemplateMessage{HydratedTemplate: t, ContextInfo: p.ContextInfo}}, nil
99+
}

‎business_hydrated_test.go‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package whatsmeow
2+
3+
import (
4+
"testing"
5+
6+
"google.golang.org/protobuf/proto"
7+
8+
"github.com/polymorfa/hypermeow/proto/waE2E"
9+
)
10+
11+
func TestHydratedMixedButtonsRoundTrip(t *testing.T) {
12+
params := BusinessHydratedButtonsMessageParams{Body: "Mixed fixture", Footer: "Footer", ContextInfo: &waE2E.ContextInfo{StanzaID: proto.String("quoted-fixture")}, Buttons: []BusinessNativeFlowButton{
13+
{Name: "quick_reply", ParamsJSON: `{"display_text":"Reply","id":"stable-reply"}`},
14+
{Name: "cta_url", ParamsJSON: `{"display_text":"Open","url":"https://example.com"}`},
15+
{Name: "cta_call", ParamsJSON: `{"display_text":"Call","phone_number":"+12025550123"}`},
16+
}}
17+
message, err := BuildBusinessHydratedButtonsMessage(params)
18+
if err != nil {
19+
t.Fatal(err)
20+
}
21+
wire, err := proto.Marshal(message)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
decoded := &waE2E.Message{}
26+
if err = proto.Unmarshal(wire, decoded); err != nil {
27+
t.Fatal(err)
28+
}
29+
if getButtonTypeFromMessage(decoded) != "template" {
30+
t.Fatal("missing template format classification")
31+
}
32+
template := decoded.GetTemplateMessage().GetHydratedTemplate()
33+
if len(template.GetHydratedButtons()) != 3 || template.GetHydratedButtons()[0].GetQuickReplyButton().GetID() != "stable-reply" || decoded.GetTemplateMessage().GetContextInfo().GetStanzaID() != "quoted-fixture" {
34+
t.Fatal("mixed buttons or quote context lost")
35+
}
36+
for i, button := range template.GetHydratedButtons() {
37+
if button.GetIndex() != uint32(i+1) {
38+
t.Fatal("button index lost")
39+
}
40+
}
41+
for _, bad := range []BusinessHydratedButtonsMessageParams{
42+
{Body: "Body"},
43+
{Body: "Body", Buttons: append(params.Buttons, params.Buttons[0])},
44+
{Body: "Body", Buttons: []BusinessNativeFlowButton{{Name: "cta_copy", ParamsJSON: `{"display_text":"Copy","copy_code":"CODE"}`}}},
45+
} {
46+
if _, err := BuildBusinessHydratedButtonsMessage(bad); err == nil {
47+
t.Fatal("invalid hydrated button message accepted")
48+
}
49+
}
50+
}

‎send.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1007,6 +1007,8 @@ func getButtonTypeFromMessage(msg *waE2E.Message) string {
10071007
return getButtonTypeFromMessage(msg.ViewOnceMessageV2Extension.Message)
10081008
case msg.EphemeralMessage != nil:
10091009
return getButtonTypeFromMessage(msg.EphemeralMessage.Message)
1010+
case msg.TemplateMessage != nil && msg.TemplateMessage.GetHydratedTemplate() != nil:
1011+
return "template"
10101012
case msg.ButtonsMessage != nil:
10111013
return "buttons"
10121014
case msg.ListMessage != nil:
@@ -1204,7 +1206,10 @@ func (cli *Client) getMessageContent(
12041206
}
12051207

12061208
if buttonType := getButtonTypeFromMessage(message); buttonType != "" {
1207-
if buttonType == "native_flow" {
1209+
if buttonType == "template" {
1210+
// Format envelope only: no category, template ID, verified name or bot identity.
1211+
content = append(content, waBinary.Node{Tag: "biz"}, waBinary.Node{Tag: "hsm"})
1212+
} else if buttonType == "native_flow" {
12081213
content = append(content, buildNativeFlowBizNode(message, time.Now().Unix()))
12091214
} else {
12101215
content = append(content, waBinary.Node{

0 commit comments

Comments
 (0)