feat(api): 🎉 Add business profile and catalog management
Some checks failed
CI / Lint (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Test (1.23) (push) Has been cancelled
CI / Test (1.22) (push) Has been cancelled

* Implement endpoints for managing business profiles:
  - Get business profile
  - Update business profile

* Add catalog management features:
  - List catalogs
  - List products in a catalog
  - Send catalog messages
  - Send single product messages
  - Send product list messages

* Introduce media upload functionality for sending media files.

* Add flow management capabilities:
  - Deprecate flows

* Update API documentation to reflect new endpoints and features.
This commit is contained in:
Hein
2026-02-04 11:17:40 +02:00
parent a7a5831911
commit ecd5525430
13 changed files with 906 additions and 84 deletions

View File

@@ -0,0 +1,172 @@
package businessapi
import (
"context"
"net/url"
"git.warky.dev/wdevs/whatshooked/pkg/events"
"git.warky.dev/wdevs/whatshooked/pkg/logging"
"go.mau.fi/whatsmeow/types"
)
// ListCatalogs returns all product catalogs linked to the business account.
func (c *Client) ListCatalogs(ctx context.Context) (*CatalogListResponse, error) {
if c.config.BusinessAccountID == "" {
return nil, errNoBusinessAccount
}
params := url.Values{
"fields": {"id,name,product_count"},
}
var resp CatalogListResponse
if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/catalogs", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// ListProducts returns products in a specific catalog.
func (c *Client) ListProducts(ctx context.Context, catalogID string) (*ProductListResponse, error) {
params := url.Values{
"fields": {"product_retailer_id,name,description,image_url,base_price,currency,availability,category"},
}
var resp ProductListResponse
if err := c.graphAPIGet(ctx, catalogID+"/products", params, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// SendCatalogMessage sends a catalog message that shares the full product catalog.
// thumbnailProductRetailerID is optional — when non-empty it sets which product image
// appears as the catalog preview thumbnail.
func (c *Client) SendCatalogMessage(ctx context.Context, jid types.JID, bodyText string, thumbnailProductRetailerID string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
action := map[string]any{
"name": "catalog_message",
}
if thumbnailProductRetailerID != "" {
action["parameters"] = map[string]any{
"thumbnail_product_retailer_id": thumbnailProductRetailerID,
}
}
msg := map[string]any{
"messaging_product": "whatsapp",
"to": phoneNumber,
"type": "interactive",
"interactive": map[string]any{
"type": "catalog_message",
"body": map[string]any{"text": bodyText},
"action": action,
},
}
messageID, err := c.postToMessagesEndpoint(ctx, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, bodyText, err))
return "", err
}
logging.Debug("Catalog message sent via Business API", "account_id", c.id, "to", phoneNumber)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, bodyText))
return messageID, nil
}
// SendSingleProduct sends a single-product interactive message.
func (c *Client) SendSingleProduct(ctx context.Context, jid types.JID, catalogID, productRetailerID, bodyText, footerText string) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
interactive := map[string]any{
"type": "product",
"header": map[string]any{
"type": "product",
"product_retailer_id": productRetailerID,
},
"body": map[string]any{"text": bodyText},
"action": map[string]any{
"catalog_id": catalogID,
"product_retailer_id": productRetailerID,
},
}
if footerText != "" {
interactive["footer"] = map[string]any{"text": footerText}
}
msg := map[string]any{
"messaging_product": "whatsapp",
"to": phoneNumber,
"type": "interactive",
"interactive": interactive,
}
messageID, err := c.postToMessagesEndpoint(ctx, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, bodyText, err))
return "", err
}
logging.Debug("Single product sent via Business API", "account_id", c.id, "to", phoneNumber, "product", productRetailerID)
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, bodyText))
return messageID, nil
}
// SendProductList sends a multi-product list message. Up to 30 products across up to 10 sections.
func (c *Client) SendProductList(ctx context.Context, jid types.JID, headerText, bodyText, footerText, catalogID string, sections []ProductListSection) (string, error) {
if ctx == nil {
ctx = context.Background()
}
phoneNumber := jidToPhoneNumber(jid)
actionSections := make([]map[string]any, len(sections))
for i, s := range sections {
items := make([]map[string]any, len(s.ProductItems))
for j, item := range s.ProductItems {
items[j] = map[string]any{"product_retailer_id": item.ProductRetailerID}
}
actionSections[i] = map[string]any{
"title": s.Title,
"product_items": items,
}
}
interactive := map[string]any{
"type": "product_list",
"header": map[string]any{"type": "text", "text": headerText},
"body": map[string]any{"text": bodyText},
"action": map[string]any{
"catalog_id": catalogID,
"sections": actionSections,
},
}
if footerText != "" {
interactive["footer"] = map[string]any{"text": footerText}
}
msg := map[string]any{
"messaging_product": "whatsapp",
"to": phoneNumber,
"type": "interactive",
"interactive": interactive,
}
messageID, err := c.postToMessagesEndpoint(ctx, msg)
if err != nil {
c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, bodyText, err))
return "", err
}
logging.Debug("Product list sent via Business API", "account_id", c.id, "to", phoneNumber, "sections", len(sections))
c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, bodyText))
return messageID, nil
}