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 +