From ecd552543080e247491199678bb5681df44ae0f6 Mon Sep 17 00:00:00 2001 From: Hein Date: Wed, 4 Feb 2026 11:17:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(api):=20=F0=9F=8E=89=20Add=20business=20pr?= =?UTF-8?q?ofile=20and=20catalog=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- README.md | 73 +++++++++- pkg/handlers/business_profile.go | 84 +++++++++++ pkg/handlers/catalog.go | 215 ++++++++++++++++++++++++++++ pkg/handlers/flows.go | 37 +++++ pkg/handlers/media_upload.go | 52 +++++++ pkg/handlers/static/README.md | 82 ----------- pkg/handlers/static/index.html | 148 +++++++++++++++++++ pkg/whatsapp/businessapi/catalog.go | 172 ++++++++++++++++++++++ pkg/whatsapp/businessapi/flows.go | 7 + pkg/whatsapp/businessapi/media.go | 6 + pkg/whatsapp/businessapi/profile.go | 28 ++++ pkg/whatsapp/businessapi/types.go | 73 ++++++++++ pkg/whatshooked/server.go | 13 ++ 13 files changed, 906 insertions(+), 84 deletions(-) create mode 100644 pkg/handlers/business_profile.go create mode 100644 pkg/handlers/catalog.go create mode 100644 pkg/handlers/media_upload.go delete mode 100644 pkg/handlers/static/README.md create mode 100644 pkg/whatsapp/businessapi/catalog.go create mode 100644 pkg/whatsapp/businessapi/profile.go diff --git a/README.md b/README.md index 3143fa2..0aebda7 100644 --- a/README.md +++ b/README.md @@ -769,7 +769,16 @@ Send a test message: - ✅ Send/receive images with captions - ✅ Send/receive videos with captions - ✅ Send/receive documents with filenames -- ✅ Media upload via Meta CDN +- ✅ Send audio, stickers, locations, and contact cards +- ✅ Interactive messages (buttons and lists) +- ✅ Template messages and template management (list, upload, delete) +- ✅ Flows — create, upload, publish, deprecate, delete, and send flow messages +- ✅ Commerce — catalog messages, single product, multi-product list, catalog and product listing +- ✅ Business profile management (get and update) +- ✅ Reactions (emoji) on messages +- ✅ Mark messages as read +- ✅ Phone number management and verification +- ✅ Media upload and delete via Meta CDN - ✅ Delivery and read receipts - ✅ Event publishing to webhooks (same format as whatsmeow) @@ -995,16 +1004,76 @@ The server exposes the following HTTP endpoints: - `GET/POST /webhooks/whatsapp/{accountID}` - Business API webhook verification and events (no authentication, validated by Meta's verify_token) **Protected Endpoints (require authentication if enabled):** + +*Hooks & Accounts:* - `GET /api/hooks` - List all hooks - `POST /api/hooks/add` - Add a new hook - `POST /api/hooks/remove` - Remove a hook - `GET /api/accounts` - List all WhatsApp accounts - `POST /api/accounts/add` - Add a new WhatsApp account -- `POST /api/send` - Send a message +- `POST /api/accounts/update` - Update a WhatsApp account +- `POST /api/accounts/remove` - Remove a WhatsApp account +- `POST /api/accounts/disable` - Disable a WhatsApp account +- `POST /api/accounts/enable` - Enable a WhatsApp account + +*Send Messages:* +- `POST /api/send` - Send a text message - `POST /api/send/image` - Send an image - `POST /api/send/video` - Send a video - `POST /api/send/document` - Send a document +- `POST /api/send/audio` - Send an audio message (Business API, base64-encoded) +- `POST /api/send/sticker` - Send a sticker (Business API, base64-encoded) +- `POST /api/send/location` - Send a location (Business API) +- `POST /api/send/contacts` - Send contact card(s) (Business API) +- `POST /api/send/interactive` - Send an interactive message — buttons or list (Business API) +- `POST /api/send/template` - Send a template message (Business API) +- `POST /api/send/flow` - Send an interactive flow message (Business API) +- `POST /api/send/reaction` - React to a message with an emoji (Business API) +- `POST /api/messages/read` - Mark a message as read (Business API) + +*Templates (Business API):* +- `POST /api/templates` - List all message templates for an account +- `POST /api/templates/upload` - Create a new message template +- `POST /api/templates/delete` - Delete a template by name and language + +*Flows (Business API):* +- `POST /api/flows` - List all flows for an account +- `POST /api/flows/create` - Create a new flow +- `POST /api/flows/get` - Get details of a specific flow +- `POST /api/flows/upload` - Upload screens JSON to a draft flow +- `POST /api/flows/publish` - Publish a draft flow +- `POST /api/flows/deprecate` - Deprecate a flow (blocks new sessions; existing sessions continue) +- `POST /api/flows/delete` - Permanently delete a flow + +*Phone Numbers (Business API):* +- `POST /api/phone-numbers` - List phone numbers for an account +- `POST /api/phone-numbers/request-code` - Request a verification code (SMS or VOICE) +- `POST /api/phone-numbers/verify-code` - Verify a phone number with the received code + +*Business Profile (Business API):* +- `POST /api/business-profile` - Retrieve the business profile for an account +- `POST /api/business-profile/update` - Update business profile fields (about, address, description, email, websites, vertical) + +*Catalog / Commerce (Business API):* +- `POST /api/catalogs` - List product catalogs linked to an account +- `POST /api/catalogs/products` - List products in a specific catalog +- `POST /api/send/catalog` - Send a catalog message (shares full product catalog) +- `POST /api/send/product` - Send a single-product interactive message +- `POST /api/send/product-list` - Send a multi-product list message (up to 30 products across 10 sections) + +*Media:* - `GET /api/media/{accountID}/{filename}` - Serve media files +- `POST /api/media/upload` - Upload a media file to Meta and return its media_id (Business API) +- `POST /api/media-delete` - Delete a previously uploaded media file from Meta (Business API) + +*Message Cache:* +- `GET /api/cache` - List cached events +- `GET /api/cache/stats` - Cache statistics +- `POST /api/cache/replay` - Replay all cached events +- `GET /api/cache/event?id=` - Get a single cached event +- `POST /api/cache/event/replay?id=` - Replay a single cached event +- `DELETE /api/cache/event/delete?id=` - Delete a single cached event +- `DELETE /api/cache/clear?confirm=true` - Clear all cached events ## WhatsApp JID Format diff --git a/pkg/handlers/business_profile.go b/pkg/handlers/business_profile.go new file mode 100644 index 0000000..06566f8 --- /dev/null +++ b/pkg/handlers/business_profile.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" +) + +// GetBusinessProfile retrieves the business profile for a Business API account. +// POST /api/business-profile {"account_id"} +func (h *Handlers) GetBusinessProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + profile, err := baClient.GetBusinessProfile(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, profile) +} + +// UpdateBusinessProfile updates the business profile for a Business API account. +// POST /api/business-profile/update {"account_id","about","address","description","email","websites":[],"vertical"} +func (h *Handlers) UpdateBusinessProfile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + 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"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + profile := businessapi.BusinessProfileUpdate{ + About: req.About, + Address: req.Address, + Description: req.Description, + Email: req.Email, + Websites: req.Websites, + Vertical: req.Vertical, + } + + if err := baClient.UpdateBusinessProfile(r.Context(), profile); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/catalog.go b/pkg/handlers/catalog.go new file mode 100644 index 0000000..5183fe0 --- /dev/null +++ b/pkg/handlers/catalog.go @@ -0,0 +1,215 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/pkg/utils" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" + "go.mau.fi/whatsmeow/types" +) + +// ListCatalogs returns product catalogs for a Business API account. +// POST /api/catalogs {"account_id"} +func (h *Handlers) ListCatalogs(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := baClient.ListCatalogs(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// ListProducts returns products in a specific catalog. +// POST /api/catalogs/products {"account_id","catalog_id"} +func (h *Handlers) ListProducts(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + CatalogID string `json:"catalog_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.CatalogID == "" { + http.Error(w, "catalog_id is required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := baClient.ListProducts(r.Context(), req.CatalogID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// SendCatalogMessage sends a catalog message that shares the full product catalog. +// POST /api/send/catalog {"account_id","to","body_text","thumbnail_product_retailer_id"} +func (h *Handlers) SendCatalogMessage(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + To string `json:"to"` + BodyText string `json:"body_text"` + ThumbnailProductRetailerID string `json:"thumbnail_product_retailer_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.BodyText == "" { + http.Error(w, "body_text is required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + jid, err := types.ParseJID(utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)) + if err != nil { + http.Error(w, "Invalid phone number", http.StatusBadRequest) + return + } + + if _, err := baClient.SendCatalogMessage(r.Context(), jid, req.BodyText, req.ThumbnailProductRetailerID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendSingleProduct sends a single-product interactive message. +// POST /api/send/product {"account_id","to","catalog_id","product_retailer_id","body_text","footer_text"} +func (h *Handlers) SendSingleProduct(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + To string `json:"to"` + CatalogID string `json:"catalog_id"` + ProductRetailerID string `json:"product_retailer_id"` + BodyText string `json:"body_text"` + FooterText string `json:"footer_text"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.CatalogID == "" || req.ProductRetailerID == "" || req.BodyText == "" { + http.Error(w, "catalog_id, product_retailer_id, and body_text are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + jid, err := types.ParseJID(utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)) + if err != nil { + http.Error(w, "Invalid phone number", http.StatusBadRequest) + return + } + + if _, err := baClient.SendSingleProduct(r.Context(), jid, req.CatalogID, req.ProductRetailerID, req.BodyText, req.FooterText); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendProductList sends a multi-product list message (up to 30 products across up to 10 sections). +// POST /api/send/product-list {"account_id","to","header_text","body_text","footer_text","catalog_id","sections":[...]} +func (h *Handlers) SendProductList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + To string `json:"to"` + HeaderText string `json:"header_text"` + BodyText string `json:"body_text"` + FooterText string `json:"footer_text"` + CatalogID string `json:"catalog_id"` + Sections []businessapi.ProductListSection `json:"sections"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.CatalogID == "" || req.HeaderText == "" || req.BodyText == "" || len(req.Sections) == 0 { + http.Error(w, "catalog_id, header_text, body_text, and sections are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + jid, err := types.ParseJID(utils.FormatPhoneToJID(req.To, h.config.Server.DefaultCountryCode)) + if err != nil { + http.Error(w, "Invalid phone number", http.StatusBadRequest) + return + } + + if _, err := baClient.SendProductList(r.Context(), jid, req.HeaderText, req.BodyText, req.FooterText, req.CatalogID, req.Sections); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/flows.go b/pkg/handlers/flows.go index 0110a5d..700a117 100644 --- a/pkg/handlers/flows.go +++ b/pkg/handlers/flows.go @@ -193,6 +193,43 @@ func (h *Handlers) PublishFlow(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +// DeprecateFlow transitions a PUBLISHED flow to DEPRECATED. +// Deprecated flows block new sessions but remain usable by sessions already in progress. +// POST /api/flows/deprecate {"account_id","flow_id"} +func (h *Handlers) DeprecateFlow(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + FlowID string `json:"flow_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.FlowID == "" { + http.Error(w, "flow_id is required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := baClient.DeprecateFlow(r.Context(), req.FlowID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + // DeleteFlow permanently removes a flow. // POST /api/flows/delete {"account_id","flow_id"} func (h *Handlers) DeleteFlow(w http.ResponseWriter, r *http.Request) { diff --git a/pkg/handlers/media_upload.go b/pkg/handlers/media_upload.go new file mode 100644 index 0000000..bf1bc24 --- /dev/null +++ b/pkg/handlers/media_upload.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "net/http" +) + +// UploadMedia uploads a media file to Meta's servers and returns the media ID. +// Useful for pre-uploading media before referencing the ID in a subsequent send call. +// POST /api/media/upload {"account_id","data"(base64),"mime_type"} +func (h *Handlers) UploadMedia(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + AccountID string `json:"account_id"` + Data string `json:"data"` + MimeType string `json:"mime_type"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Data == "" || req.MimeType == "" { + http.Error(w, "data and mime_type are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + mediaData, err := base64.StdEncoding.DecodeString(req.Data) + if err != nil { + http.Error(w, "Invalid base64 data", http.StatusBadRequest) + return + } + + mediaID, err := baClient.UploadMedia(r.Context(), mediaData, req.MimeType) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"media_id": mediaID}) +} diff --git a/pkg/handlers/static/README.md b/pkg/handlers/static/README.md deleted file mode 100644 index 7a52da5..0000000 --- a/pkg/handlers/static/README.md +++ /dev/null @@ -1,82 +0,0 @@ -# Static Files - -This directory contains the embedded static files for the WhatsHooked landing page. - -## Files - -- `index.html` - Landing page with API documentation -- `logo.png` - WhatsHooked logo (from `assets/image/whatshooked_tp.png`) - -## How It Works - -These files are embedded into the Go binary using `go:embed` directive in `static.go`. - -When you build the server: -```bash -go build ./cmd/server/ -``` - -The files in this directory are compiled directly into the binary, so the server can run without any external files. - -## Updating the Landing Page - -1. **Edit the HTML:** - ```bash - vim pkg/handlers/static/index.html - ``` - -2. **Rebuild the server:** - ```bash - go build ./cmd/server/ - ``` - -3. **Restart the server:** - ```bash - ./server -config bin/config.json - ``` - -The changes will be embedded in the new binary. - -## Updating the Logo - -1. **Replace the logo:** - ```bash - cp path/to/new-logo.png pkg/handlers/static/logo.png - ``` - -2. **Rebuild:** - ```bash - go build ./cmd/server/ - ``` - -## Routes - -- `GET /` - Serves `index.html` -- `GET /static/logo.png` - Serves `logo.png` -- `GET /static/*` - Serves any file in this directory - -## Development Tips - -- Files are cached with `Cache-Control: public, max-age=3600` (1 hour) -- Force refresh in browser: `Ctrl+Shift+R` or `Cmd+Shift+R` -- Changes require rebuild - no hot reload -- Keep files small - they're embedded in the binary - -## File Structure - -``` -pkg/handlers/ -├── static.go # Handler with go:embed directive -├── static/ -│ ├── index.html # Landing page -│ ├── logo.png # Logo image -│ └── README.md # This file -``` - -## Benefits of Embedded Files - -✅ **Single binary deployment** - No external dependencies -✅ **Fast serving** - Files loaded from memory -✅ **No file system access** - Works in restricted environments -✅ **Portable** - Binary includes everything -✅ **Version controlled** - Static assets tracked with code diff --git a/pkg/handlers/static/index.html b/pkg/handlers/static/index.html index bb25a69..934adf4 100644 --- a/pkg/handlers/static/index.html +++ b/pkg/handlers/static/index.html @@ -384,6 +384,154 @@ POST /api/send/document +
+ POST + /api/send/audio +
+
+ POST + /api/send/sticker +
+
+ POST + /api/send/location +
+
+ POST + /api/send/contacts +
+
+ POST + /api/send/interactive +
+
+ POST + /api/send/template +
+
+ POST + /api/send/flow +
+
+ POST + /api/send/reaction +
+
+ POST + /api/messages/read +
+ + +
+

📄 Templates

+
+ POST + /api/templates +
+
+ POST + /api/templates/upload +
+
+ POST + /api/templates/delete +
+
+ +
+

🔄 Flows

+
+ POST + /api/flows +
+
+ POST + /api/flows/create +
+
+ POST + /api/flows/get +
+
+ POST + /api/flows/upload +
+
+ POST + /api/flows/publish +
+
+ POST + /api/flows/deprecate +
+
+ POST + /api/flows/delete +
+
+ +
+

📞 Phone Numbers

+
+ POST + /api/phone-numbers +
+
+ POST + /api/phone-numbers/request-code +
+
+ POST + /api/phone-numbers/verify-code +
+
+ +
+

🏪 Catalog / Commerce

+
+ POST + /api/catalogs +
+
+ POST + /api/catalogs/products +
+
+ POST + /api/send/catalog +
+
+ POST + /api/send/product +
+
+ POST + /api/send/product-list +
+
+ +
+

🏢 Business Profile

+
+ POST + /api/business-profile +
+
+ POST + /api/business-profile/update +
+
+ +
+

🗑️ Media Management

+
+ POST + /api/media/upload +
+
+ POST + /api/media-delete +
diff --git a/pkg/whatsapp/businessapi/catalog.go b/pkg/whatsapp/businessapi/catalog.go new file mode 100644 index 0000000..e744216 --- /dev/null +++ b/pkg/whatsapp/businessapi/catalog.go @@ -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 +} diff --git a/pkg/whatsapp/businessapi/flows.go b/pkg/whatsapp/businessapi/flows.go index 529b8bf..cb7aeb3 100644 --- a/pkg/whatsapp/businessapi/flows.go +++ b/pkg/whatsapp/businessapi/flows.go @@ -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) diff --git a/pkg/whatsapp/businessapi/media.go b/pkg/whatsapp/businessapi/media.go index 1ce9f87..ce998a5 100644 --- a/pkg/whatsapp/businessapi/media.go +++ b/pkg/whatsapp/businessapi/media.go @@ -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", diff --git a/pkg/whatsapp/businessapi/profile.go b/pkg/whatsapp/businessapi/profile.go new file mode 100644 index 0000000..533631d --- /dev/null +++ b/pkg/whatsapp/businessapi/profile.go @@ -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) +} diff --git a/pkg/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go index 9596a2d..4c74583 100644 --- a/pkg/whatsapp/businessapi/types.go +++ b/pkg/whatsapp/businessapi/types.go @@ -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 // --------------------------------------------------------------------------- diff --git a/pkg/whatshooked/server.go b/pkg/whatshooked/server.go index 4b5dcc2..86f6298 100644 --- a/pkg/whatshooked/server.go +++ b/pkg/whatshooked/server.go @@ -263,6 +263,7 @@ func (s *Server) setupRoutes() *http.ServeMux { mux.HandleFunc("/api/flows/get", h.Auth(h.GetFlow)) mux.HandleFunc("/api/flows/upload", h.Auth(h.UploadFlowAsset)) mux.HandleFunc("/api/flows/publish", h.Auth(h.PublishFlow)) + mux.HandleFunc("/api/flows/deprecate", h.Auth(h.DeprecateFlow)) mux.HandleFunc("/api/flows/delete", h.Auth(h.DeleteFlow)) // Phone number management (with auth) @@ -271,8 +272,20 @@ func (s *Server) setupRoutes() *http.ServeMux { mux.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode)) // Media management (with auth) + mux.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia)) mux.HandleFunc("/api/media-delete", h.Auth(h.DeleteMediaFile)) + // Business profile (with auth) + mux.HandleFunc("/api/business-profile", h.Auth(h.GetBusinessProfile)) + mux.HandleFunc("/api/business-profile/update", h.Auth(h.UpdateBusinessProfile)) + + // Catalog / commerce (with auth) + mux.HandleFunc("/api/catalogs", h.Auth(h.ListCatalogs)) + mux.HandleFunc("/api/catalogs/products", h.Auth(h.ListProducts)) + mux.HandleFunc("/api/send/catalog", h.Auth(h.SendCatalogMessage)) + mux.HandleFunc("/api/send/product", h.Auth(h.SendSingleProduct)) + mux.HandleFunc("/api/send/product-list", h.Auth(h.SendProductList)) + // Message cache management (with auth) mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics