feat(api): 🎉 Add business profile and catalog management
* 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:
172
pkg/whatsapp/businessapi/catalog.go
Normal file
172
pkg/whatsapp/businessapi/catalog.go
Normal 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
|
||||
}
|
||||
@@ -65,6 +65,13 @@ func (c *Client) PublishFlow(ctx context.Context, flowID string) error {
|
||||
return c.graphAPIPost(ctx, flowID+"?action=PUBLISH", nil, &resp)
|
||||
}
|
||||
|
||||
// DeprecateFlow transitions a PUBLISHED flow to DEPRECATED.
|
||||
// Deprecated flows block new sessions but remain usable by sessions already in progress.
|
||||
func (c *Client) DeprecateFlow(ctx context.Context, flowID string) error {
|
||||
var resp FlowActionResponse
|
||||
return c.graphAPIPost(ctx, flowID+"?action=DEPRECATE", nil, &resp)
|
||||
}
|
||||
|
||||
// DeleteFlow permanently removes a flow.
|
||||
func (c *Client) DeleteFlow(ctx context.Context, flowID string) error {
|
||||
return c.graphAPIDelete(ctx, flowID, nil)
|
||||
|
||||
@@ -10,6 +10,12 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// UploadMedia uploads a media file to Meta and returns the media ID.
|
||||
// Useful for pre-uploading media before referencing it in a later send call.
|
||||
func (c *Client) UploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) {
|
||||
return c.uploadMedia(ctx, data, mimeType)
|
||||
}
|
||||
|
||||
// uploadMedia uploads media to the Business API and returns the media ID
|
||||
func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) {
|
||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/media",
|
||||
|
||||
28
pkg/whatsapp/businessapi/profile.go
Normal file
28
pkg/whatsapp/businessapi/profile.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package businessapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// GetBusinessProfile retrieves the business profile for this phone number.
|
||||
func (c *Client) GetBusinessProfile(ctx context.Context) (*BusinessProfile, error) {
|
||||
params := url.Values{
|
||||
"fields": {"about,address,description,email,websites,vertical,profile_picture_url"},
|
||||
}
|
||||
|
||||
var resp BusinessProfile
|
||||
if err := c.graphAPIGet(ctx, c.config.PhoneNumberID+"/whatsapp_business_profile", params, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// UpdateBusinessProfile updates one or more business profile fields.
|
||||
// Only include fields you want to change — omitted fields are left untouched.
|
||||
func (c *Client) UpdateBusinessProfile(ctx context.Context, profile BusinessProfileUpdate) error {
|
||||
profile.MessagingProduct = "whatsapp"
|
||||
|
||||
var resp map[string]any
|
||||
return c.graphAPIPost(ctx, c.config.PhoneNumberID+"/whatsapp_business_profile", profile, &resp)
|
||||
}
|
||||
@@ -774,6 +774,79 @@ type VerifyCodeData struct {
|
||||
MessageStatus string `json:"message_status"` // "CODE_VERIFIED"
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// BusinessProfile represents a WhatsApp Business profile returned by the API.
|
||||
type BusinessProfile struct {
|
||||
About string `json:"about,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Websites []string `json:"websites,omitempty"`
|
||||
Vertical string `json:"vertical,omitempty"`
|
||||
ProfilePicURL string `json:"profile_picture_url,omitempty"`
|
||||
}
|
||||
|
||||
// BusinessProfileUpdate is the payload sent to update business profile fields.
|
||||
// Only populated fields are sent; omit a field to leave it unchanged.
|
||||
type BusinessProfileUpdate struct {
|
||||
MessagingProduct string `json:"messaging_product"` // always "whatsapp"
|
||||
About string `json:"about,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Websites []string `json:"websites,omitempty"`
|
||||
Vertical string `json:"vertical,omitempty"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Catalog / commerce
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// CatalogListResponse is the response from listing catalogs on a WABA.
|
||||
type CatalogListResponse struct {
|
||||
Data []CatalogInfo `json:"data"`
|
||||
Paging *PagingInfo `json:"paging,omitempty"`
|
||||
}
|
||||
|
||||
// CatalogInfo represents a single product catalog.
|
||||
type CatalogInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProductCount int `json:"product_count,omitempty"`
|
||||
}
|
||||
|
||||
// ProductListResponse is the response from listing products in a catalog.
|
||||
type ProductListResponse struct {
|
||||
Data []ProductInfo `json:"data"`
|
||||
Paging *PagingInfo `json:"paging,omitempty"`
|
||||
}
|
||||
|
||||
// ProductInfo represents a product in a catalog.
|
||||
type ProductInfo struct {
|
||||
ProductRetailerID string `json:"product_retailer_id"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
BasePrice float64 `json:"base_price,omitempty"`
|
||||
Currency string `json:"currency,omitempty"`
|
||||
Availability string `json:"availability,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
// ProductItem is a product reference used inside a product-list message section.
|
||||
type ProductItem struct {
|
||||
ProductRetailerID string `json:"product_retailer_id"`
|
||||
}
|
||||
|
||||
// ProductListSection is one section in a product-list interactive message.
|
||||
type ProductListSection struct {
|
||||
Title string `json:"title"`
|
||||
ProductItems []ProductItem `json:"product_items"`
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared / pagination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user