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:
73
README.md
73
README.md
@@ -769,7 +769,16 @@ Send a test message:
|
|||||||
- ✅ Send/receive images with captions
|
- ✅ Send/receive images with captions
|
||||||
- ✅ Send/receive videos with captions
|
- ✅ Send/receive videos with captions
|
||||||
- ✅ Send/receive documents with filenames
|
- ✅ 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
|
- ✅ Delivery and read receipts
|
||||||
- ✅ Event publishing to webhooks (same format as whatsmeow)
|
- ✅ 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)
|
- `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):**
|
**Protected Endpoints (require authentication if enabled):**
|
||||||
|
|
||||||
|
*Hooks & Accounts:*
|
||||||
- `GET /api/hooks` - List all hooks
|
- `GET /api/hooks` - List all hooks
|
||||||
- `POST /api/hooks/add` - Add a new hook
|
- `POST /api/hooks/add` - Add a new hook
|
||||||
- `POST /api/hooks/remove` - Remove a hook
|
- `POST /api/hooks/remove` - Remove a hook
|
||||||
- `GET /api/accounts` - List all WhatsApp accounts
|
- `GET /api/accounts` - List all WhatsApp accounts
|
||||||
- `POST /api/accounts/add` - Add a new WhatsApp account
|
- `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/image` - Send an image
|
||||||
- `POST /api/send/video` - Send a video
|
- `POST /api/send/video` - Send a video
|
||||||
- `POST /api/send/document` - Send a document
|
- `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
|
- `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
|
## WhatsApp JID Format
|
||||||
|
|
||||||
|
|||||||
84
pkg/handlers/business_profile.go
Normal file
84
pkg/handlers/business_profile.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
215
pkg/handlers/catalog.go
Normal file
215
pkg/handlers/catalog.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
@@ -193,6 +193,43 @@ func (h *Handlers) PublishFlow(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, map[string]string{"status": "ok"})
|
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.
|
// DeleteFlow permanently removes a flow.
|
||||||
// POST /api/flows/delete {"account_id","flow_id"}
|
// POST /api/flows/delete {"account_id","flow_id"}
|
||||||
func (h *Handlers) DeleteFlow(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) DeleteFlow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
52
pkg/handlers/media_upload.go
Normal file
52
pkg/handlers/media_upload.go
Normal file
@@ -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})
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
@@ -384,6 +384,154 @@
|
|||||||
<span class="endpoint-method post">POST</span>
|
<span class="endpoint-method post">POST</span>
|
||||||
<span class="endpoint-path">/api/send/document</span>
|
<span class="endpoint-path">/api/send/document</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/audio</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/sticker</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/location</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/contacts</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/interactive</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/template</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/flow</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/reaction</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/messages/read</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>📄 Templates</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/templates</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/templates/upload</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/templates/delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🔄 Flows</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/create</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/get</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/upload</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/publish</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/deprecate</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/flows/delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>📞 Phone Numbers</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/phone-numbers</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/phone-numbers/request-code</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/phone-numbers/verify-code</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🏪 Catalog / Commerce</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/catalogs</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/catalogs/products</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/catalog</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/product</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/send/product-list</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🏢 Business Profile</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/business-profile</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/business-profile/update</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint-group">
|
||||||
|
<h3>🗑️ Media Management</h3>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/media/upload</span>
|
||||||
|
</div>
|
||||||
|
<div class="endpoint">
|
||||||
|
<span class="endpoint-method post">POST</span>
|
||||||
|
<span class="endpoint-path">/api/media-delete</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="endpoint-group">
|
<div class="endpoint-group">
|
||||||
|
|||||||
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)
|
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.
|
// DeleteFlow permanently removes a flow.
|
||||||
func (c *Client) DeleteFlow(ctx context.Context, flowID string) error {
|
func (c *Client) DeleteFlow(ctx context.Context, flowID string) error {
|
||||||
return c.graphAPIDelete(ctx, flowID, nil)
|
return c.graphAPIDelete(ctx, flowID, nil)
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import (
|
|||||||
"net/http"
|
"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
|
// 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) {
|
func (c *Client) uploadMedia(ctx context.Context, data []byte, mimeType string) (string, error) {
|
||||||
url := fmt.Sprintf("https://graph.facebook.com/%s/%s/media",
|
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"
|
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
|
// Shared / pagination
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/flows/get", h.Auth(h.GetFlow))
|
mux.HandleFunc("/api/flows/get", h.Auth(h.GetFlow))
|
||||||
mux.HandleFunc("/api/flows/upload", h.Auth(h.UploadFlowAsset))
|
mux.HandleFunc("/api/flows/upload", h.Auth(h.UploadFlowAsset))
|
||||||
mux.HandleFunc("/api/flows/publish", h.Auth(h.PublishFlow))
|
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))
|
mux.HandleFunc("/api/flows/delete", h.Auth(h.DeleteFlow))
|
||||||
|
|
||||||
// Phone number management (with auth)
|
// 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))
|
mux.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode))
|
||||||
|
|
||||||
// Media management (with auth)
|
// Media management (with auth)
|
||||||
|
mux.HandleFunc("/api/media/upload", h.Auth(h.UploadMedia))
|
||||||
mux.HandleFunc("/api/media-delete", h.Auth(h.DeleteMediaFile))
|
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)
|
// Message cache management (with auth)
|
||||||
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
|
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
|
||||||
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
|
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
|
||||||
|
|||||||
Reference in New Issue
Block a user