From a7a583191191a673f99d8a96025973f097a10837 Mon Sep 17 00:00:00 2001 From: Hein Date: Tue, 3 Feb 2026 18:07:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(whatsapp):=20=F0=9F=8E=89=20Add=20extended?= =?UTF-8?q?=20sending=20and=20template=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implemented new endpoints for sending various message types: - Audio - Sticker - Location - Contacts - Interactive messages - Template messages - Flow messages - Reactions - Marking messages as read * Added template management endpoints: - List templates - Upload templates - Delete templates * Introduced flow management endpoints: - List flows - Create flows - Get flow details - Upload flow assets - Publish flows - Delete flows * Added phone number management endpoints: - List phone numbers - Request verification code - Verify code * Enhanced media management with delete media endpoint. * Updated dependencies. --- pkg/events/builders.go | 12 + pkg/events/events.go | 4 + pkg/handlers/flows.go | 305 +++++++++++++++++++ pkg/handlers/handlers.go | 19 ++ pkg/handlers/media_management.go | 42 +++ pkg/handlers/phone_numbers.go | 115 +++++++ pkg/handlers/send_extended.go | 316 +++++++++++++++++++ pkg/handlers/templates.go | 158 ++++++++++ pkg/hooks/manager.go | 1 + pkg/whatsapp/businessapi/events.go | 92 +++--- pkg/whatsapp/businessapi/flows.go | 71 +++++ pkg/whatsapp/businessapi/helpers.go | 171 +++++++++++ pkg/whatsapp/businessapi/sending.go | 267 +++++++++++++++++ pkg/whatsapp/businessapi/templates.go | 50 +++ pkg/whatsapp/businessapi/types.go | 417 +++++++++++++++++++++++++- pkg/whatshooked/server.go | 32 ++ 16 files changed, 2024 insertions(+), 48 deletions(-) create mode 100644 pkg/handlers/flows.go create mode 100644 pkg/handlers/media_management.go create mode 100644 pkg/handlers/phone_numbers.go create mode 100644 pkg/handlers/send_extended.go create mode 100644 pkg/handlers/templates.go create mode 100644 pkg/whatsapp/businessapi/flows.go create mode 100644 pkg/whatsapp/businessapi/helpers.go create mode 100644 pkg/whatsapp/businessapi/sending.go create mode 100644 pkg/whatsapp/businessapi/templates.go diff --git a/pkg/events/builders.go b/pkg/events/builders.go index f4c2639..f3bfec9 100644 --- a/pkg/events/builders.go +++ b/pkg/events/builders.go @@ -132,6 +132,18 @@ func MessageReadEvent(ctx context.Context, accountID, messageID, from string, ti }) } +// TemplateStatusUpdateEvent creates a template status update event +func TemplateStatusUpdateEvent(ctx context.Context, accountID, templateName, templateID, language, status string, rejectionReasons []string) Event { + return NewEvent(ctx, EventTemplateStatusUpdate, map[string]any{ + "account_id": accountID, + "template_name": templateName, + "template_id": templateID, + "language": language, + "status": status, + "rejection_reasons": rejectionReasons, + }) +} + // HookTriggeredEvent creates a hook triggered event func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event { return NewEvent(ctx, EventHookTriggered, map[string]any{ diff --git a/pkg/events/events.go b/pkg/events/events.go index b236dec..38886bc 100644 --- a/pkg/events/events.go +++ b/pkg/events/events.go @@ -27,6 +27,9 @@ const ( EventMessageDelivered EventType = "message.delivered" EventMessageRead EventType = "message.read" + // Template events + EventTemplateStatusUpdate EventType = "template.status_update" + // Hook events EventHookTriggered EventType = "hook.triggered" EventHookSuccess EventType = "hook.success" @@ -88,6 +91,7 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) { EventMessageFailed, EventMessageDelivered, EventMessageRead, + EventTemplateStatusUpdate, EventHookTriggered, EventHookSuccess, EventHookFailed, diff --git a/pkg/handlers/flows.go b/pkg/handlers/flows.go new file mode 100644 index 0000000..0110a5d --- /dev/null +++ b/pkg/handlers/flows.go @@ -0,0 +1,305 @@ +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" +) + +// ListFlows returns all flows for the account. +// POST /api/flows {"account_id"} +func (h *Handlers) ListFlows(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.ListFlows(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// CreateFlow creates a new flow. +// POST /api/flows/create {"account_id","name","categories":[...],"endpoint_url"} +func (h *Handlers) CreateFlow(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"` + businessapi.FlowCreateRequest + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" || len(req.Categories) == 0 { + http.Error(w, "name and categories are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := baClient.CreateFlow(r.Context(), req.FlowCreateRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// GetFlow returns details for a single flow. +// POST /api/flows/get {"account_id","flow_id"} +func (h *Handlers) GetFlow(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 + } + + // validate account exists and is business-api (even though GetFlow doesn't strictly need it, consistency) + if _, err := h.getBusinessAPIClient(req.AccountID); 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.GetFlow(r.Context(), req.FlowID) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// UploadFlowAsset uploads a screens JSON to a DRAFT flow. +// POST /api/flows/upload {"account_id","flow_id","screens_json":"{...}"} +func (h *Handlers) UploadFlowAsset(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"` + ScreensJSON string `json:"screens_json"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.FlowID == "" || req.ScreensJSON == "" { + http.Error(w, "flow_id and screens_json are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := baClient.UpdateFlowAssets(r.Context(), req.FlowID, req.ScreensJSON); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// PublishFlow publishes a DRAFT flow. +// POST /api/flows/publish {"account_id","flow_id"} +func (h *Handlers) PublishFlow(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.PublishFlow(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) { + 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.DeleteFlow(r.Context(), req.FlowID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendFlow sends an interactive flow message. +// POST /api/send/flow {"account_id","to","flow_id","flow_token","screen_name","data":{...}} +func (h *Handlers) SendFlow(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"` + FlowID string `json:"flow_id"` + FlowToken string `json:"flow_token"` + ScreenName string `json:"screen_name"` + Data map[string]any `json:"data"` + Header string `json:"header"` + Body string `json:"body"` + Footer string `json:"footer"` + Dynamic bool `json:"dynamic"` // if true, use type "unpublished" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.FlowID == "" || req.Body == "" { + http.Error(w, "flow_id and body 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 + } + + paramType := "payload" + if req.Dynamic { + paramType = "unpublished" + } + + interactive := &businessapi.InteractiveObject{ + Type: "flow", + Body: &businessapi.InteractiveBody{Text: req.Body}, + Action: &businessapi.InteractiveAction{ + Name: "flow", + Parameters: &businessapi.FlowActionParams{ + Type: paramType, + FlowToken: req.FlowToken, + Name: req.ScreenName, + Data: req.Data, + }, + }, + } + if req.Header != "" { + interactive.Header = &businessapi.InteractiveHeader{Type: "text", Text: req.Header} + } + if req.Footer != "" { + interactive.Footer = &businessapi.InteractiveFooter{Text: req.Footer} + } + + if _, err := baClient.SendInteractive(r.Context(), jid, interactive); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/handlers.go b/pkg/handlers/handlers.go index 3c1d512..a23969f 100644 --- a/pkg/handlers/handlers.go +++ b/pkg/handlers/handlers.go @@ -2,12 +2,14 @@ package handlers import ( "encoding/json" + "fmt" "net/http" "git.warky.dev/wdevs/whatshooked/pkg/config" "git.warky.dev/wdevs/whatshooked/pkg/hooks" "git.warky.dev/wdevs/whatshooked/pkg/logging" "git.warky.dev/wdevs/whatshooked/pkg/whatsapp" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" ) // Handlers holds all HTTP handlers with their dependencies @@ -57,6 +59,23 @@ func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers { return h } +// getBusinessAPIClient looks up an account and asserts it is a Business API client. +// Returns a typed *businessapi.Client or an error with an appropriate message. +func (h *Handlers) getBusinessAPIClient(accountID string) (*businessapi.Client, error) { + if accountID == "" { + return nil, fmt.Errorf("account_id is required") + } + client, exists := h.whatsappMgr.GetClient(accountID) + if !exists { + return nil, fmt.Errorf("account %s not found", accountID) + } + baClient, ok := client.(*businessapi.Client) + if !ok { + return nil, fmt.Errorf("account %s is not a Business API account (type: %s)", accountID, client.GetType()) + } + return baClient, nil +} + // writeJSON is a helper that writes JSON response and logs errors func writeJSON(w http.ResponseWriter, v interface{}) { if err := json.NewEncoder(w).Encode(v); err != nil { diff --git a/pkg/handlers/media_management.go b/pkg/handlers/media_management.go new file mode 100644 index 0000000..3c463ff --- /dev/null +++ b/pkg/handlers/media_management.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// DeleteMediaFile deletes a previously uploaded media file from Meta's servers. +// POST /api/media-delete {"account_id","media_id"} +func (h *Handlers) DeleteMediaFile(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"` + MediaID string `json:"media_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.MediaID == "" { + http.Error(w, "media_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.DeleteMedia(r.Context(), req.MediaID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/phone_numbers.go b/pkg/handlers/phone_numbers.go new file mode 100644 index 0000000..e6b170f --- /dev/null +++ b/pkg/handlers/phone_numbers.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "encoding/json" + "net/http" +) + +// ListPhoneNumbers returns all phone numbers for the account. +// POST /api/phone-numbers {"account_id"} +func (h *Handlers) ListPhoneNumbers(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.ListPhoneNumbers(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// RequestVerificationCode sends a verification code to a phone number. +// POST /api/phone-numbers/request-code {"account_id","phone_number_id","method":"SMS"|"VOICE"} +func (h *Handlers) RequestVerificationCode(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"` + PhoneNumberID string `json:"phone_number_id"` + Method string `json:"method"` // "SMS" or "VOICE" + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.PhoneNumberID == "" || req.Method == "" { + http.Error(w, "phone_number_id and method are required", http.StatusBadRequest) + return + } + if req.Method != "SMS" && req.Method != "VOICE" { + http.Error(w, "method must be SMS or VOICE", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := baClient.RequestVerificationCode(r.Context(), req.PhoneNumberID, req.Method); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// VerifyCode verifies a phone number with the code received. +// POST /api/phone-numbers/verify-code {"account_id","phone_number_id","code"} +func (h *Handlers) VerifyCode(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"` + PhoneNumberID string `json:"phone_number_id"` + Code string `json:"code"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.PhoneNumberID == "" || req.Code == "" { + http.Error(w, "phone_number_id and code are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := baClient.VerifyCode(r.Context(), req.PhoneNumberID, req.Code); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/send_extended.go b/pkg/handlers/send_extended.go new file mode 100644 index 0000000..5c2a352 --- /dev/null +++ b/pkg/handlers/send_extended.go @@ -0,0 +1,316 @@ +package handlers + +import ( + "encoding/base64" + "encoding/json" + "net/http" + + "git.warky.dev/wdevs/whatshooked/pkg/utils" + "git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi" + "go.mau.fi/whatsmeow/types" +) + +// SendAudio sends an audio message via Business API. +// POST /api/send/audio {"account_id","to","audio_data"(base64),"mime_type"} +func (h *Handlers) SendAudio(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"` + AudioData string `json:"audio_data"` + MimeType string `json:"mime_type"` + } + 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 + } + + audioData, err := base64.StdEncoding.DecodeString(req.AudioData) + if err != nil { + http.Error(w, "Invalid base64 audio data", http.StatusBadRequest) + return + } + + if req.MimeType == "" { + req.MimeType = "audio/mpeg" + } + + 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.SendAudio(r.Context(), jid, audioData, req.MimeType); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendSticker sends a sticker message via Business API. +// POST /api/send/sticker {"account_id","to","sticker_data"(base64),"mime_type"} +func (h *Handlers) SendSticker(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"` + StickerData string `json:"sticker_data"` + MimeType string `json:"mime_type"` + } + 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 + } + + stickerData, err := base64.StdEncoding.DecodeString(req.StickerData) + if err != nil { + http.Error(w, "Invalid base64 sticker data", http.StatusBadRequest) + return + } + + if req.MimeType == "" { + req.MimeType = "image/webp" + } + + 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.SendSticker(r.Context(), jid, stickerData, req.MimeType); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendLocation sends a location message via Business API. +// POST /api/send/location {"account_id","to","latitude","longitude","name","address"} +func (h *Handlers) SendLocation(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"` + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name"` + Address string `json:"address"` + } + 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 + } + + 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.SendLocation(r.Context(), jid, req.Latitude, req.Longitude, req.Name, req.Address); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendContacts sends contact card(s) via Business API. +// POST /api/send/contacts {"account_id","to","contacts":[...]} +func (h *Handlers) SendContacts(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"` + Contacts []businessapi.SendContactObject `json:"contacts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if len(req.Contacts) == 0 { + http.Error(w, "at least one contact 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.SendContacts(r.Context(), jid, req.Contacts); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendInteractive sends an interactive message (buttons, list, or flow). +// POST /api/send/interactive {"account_id","to","interactive":{...}} +func (h *Handlers) SendInteractive(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"` + Interactive *businessapi.InteractiveObject `json:"interactive"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Interactive == nil { + http.Error(w, "interactive object 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.SendInteractive(r.Context(), jid, req.Interactive); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendReaction sends a reaction (emoji) to an existing message. +// POST /api/send/reaction {"account_id","to","message_id","emoji"} +func (h *Handlers) SendReaction(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"` + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.MessageID == "" || req.Emoji == "" { + http.Error(w, "message_id and emoji 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.SendReaction(r.Context(), jid, req.MessageID, req.Emoji); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// MarkAsRead marks a received message as read. +// POST /api/messages/read {"account_id","message_id"} +func (h *Handlers) MarkAsRead(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"` + MessageID string `json:"message_id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.MessageID == "" { + http.Error(w, "message_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.MarkAsRead(r.Context(), req.MessageID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/handlers/templates.go b/pkg/handlers/templates.go new file mode 100644 index 0000000..ab2f35a --- /dev/null +++ b/pkg/handlers/templates.go @@ -0,0 +1,158 @@ +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" +) + +// ListTemplates returns all message templates for the account. +// POST /api/templates {"account_id"} +func (h *Handlers) ListTemplates(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.ListTemplates(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// UploadTemplate creates a new message template. +// POST /api/templates/upload {"account_id","name","language","category","components",[...]} +func (h *Handlers) UploadTemplate(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"` + businessapi.TemplateUploadRequest + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" || req.Language == "" || req.Category == "" { + http.Error(w, "name, language, and category are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + resp, err := baClient.UploadTemplate(r.Context(), req.TemplateUploadRequest) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, resp) +} + +// DeleteTemplate deletes a template by name and language. +// POST /api/templates/delete {"account_id","name","language"} +func (h *Handlers) DeleteTemplate(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"` + Name string `json:"name"` + Language string `json:"language"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" || req.Language == "" { + http.Error(w, "name and language are required", http.StatusBadRequest) + return + } + + baClient, err := h.getBusinessAPIClient(req.AccountID) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := baClient.DeleteTemplate(r.Context(), req.Name, req.Language); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} + +// SendTemplate sends a template message. +// POST /api/send/template {"account_id","to","template":{...}} +func (h *Handlers) SendTemplate(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"` + Template *businessapi.TemplateMessageObject `json:"template"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Template == nil || req.Template.Name == "" { + http.Error(w, "template with name 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.SendTemplateMessage(r.Context(), jid, req.Template); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]string{"status": "ok"}) +} diff --git a/pkg/hooks/manager.go b/pkg/hooks/manager.go index c92034e..a7f8b07 100644 --- a/pkg/hooks/manager.go +++ b/pkg/hooks/manager.go @@ -87,6 +87,7 @@ func (m *Manager) Start() { events.EventMessageFailed, events.EventMessageDelivered, events.EventMessageRead, + events.EventTemplateStatusUpdate, } // Subscribe to all event types with a generic handler diff --git a/pkg/whatsapp/businessapi/events.go b/pkg/whatsapp/businessapi/events.go index 680a891..c5cbea2 100644 --- a/pkg/whatsapp/businessapi/events.go +++ b/pkg/whatsapp/businessapi/events.go @@ -52,58 +52,78 @@ func (c *Client) HandleWebhook(r *http.Request) error { return nil } -// processChange processes a webhook change +// processChange processes a webhook change. +// change.Value is json.RawMessage because Meta uses different payload shapes per field type. +// Each case unmarshals into the appropriate struct. func (c *Client) processChange(change WebhookChange) { ctx := context.Background() - logging.Info("Processing webhook change", - "account_id", c.id, - "field", change.Field, - "phone_number_id", change.Value.Metadata.PhoneNumberID) - - // Handle different field types switch change.Field { case "messages": - // Process messages - for i := range change.Value.Messages { - msg := change.Value.Messages[i] - c.processMessage(ctx, msg, change.Value.Contacts) + var value WebhookValue + if err := json.Unmarshal(change.Value, &value); err != nil { + logging.Error("Failed to parse messages webhook value", + "account_id", c.id, "error", err) + return } - // Process statuses - for _, status := range change.Value.Statuses { + logging.Info("Processing webhook change", + "account_id", c.id, + "field", change.Field, + "phone_number_id", value.Metadata.PhoneNumberID) + + for i := range value.Messages { + c.processMessage(ctx, value.Messages[i], value.Contacts) + } + for _, status := range value.Statuses { c.processStatus(ctx, status) } case "message_template_status_update": - // Log template status updates for visibility - logging.Info("Message template status update received", - "account_id", c.id, - "phone_number_id", change.Value.Metadata.PhoneNumberID) + var value TemplateStatusValue + if err := json.Unmarshal(change.Value, &value); err != nil { + logging.Error("Failed to parse template status update", + "account_id", c.id, "error", err) + return + } - case "account_update": - // Log account updates - logging.Info("Account update received", + logging.Info("Template status update received", "account_id", c.id, - "phone_number_id", change.Value.Metadata.PhoneNumberID) + "template_name", value.TemplateName, + "template_id", value.TemplateID, + "language", value.Language, + "status", value.Status, + "rejection_reasons", value.RejectionReasons) - case "phone_number_quality_update": - // Log quality updates - logging.Info("Phone number quality update received", - "account_id", c.id, - "phone_number_id", change.Value.Metadata.PhoneNumberID) + c.eventBus.Publish(events.TemplateStatusUpdateEvent( + ctx, + c.id, + value.TemplateName, + value.TemplateID, + value.Language, + value.Status, + value.RejectionReasons, + )) - case "phone_number_name_update": - // Log name updates - logging.Info("Phone number name update received", - "account_id", c.id, - "phone_number_id", change.Value.Metadata.PhoneNumberID) + case "account_update", "phone_number_quality_update", "phone_number_name_update", "account_alerts": + // These all carry the standard WebhookValue with metadata + var value WebhookValue + if err := json.Unmarshal(change.Value, &value); err != nil { + logging.Error("Failed to parse webhook value", + "account_id", c.id, "field", change.Field, "error", err) + return + } - case "account_alerts": - // Log account alerts - logging.Warn("Account alert received", - "account_id", c.id, - "phone_number_id", change.Value.Metadata.PhoneNumberID) + if change.Field == "account_alerts" { + logging.Warn("Account alert received", + "account_id", c.id, + "phone_number_id", value.Metadata.PhoneNumberID) + } else { + logging.Info("Webhook notification received", + "account_id", c.id, + "field", change.Field, + "phone_number_id", value.Metadata.PhoneNumberID) + } default: logging.Debug("Unknown webhook field type", diff --git a/pkg/whatsapp/businessapi/flows.go b/pkg/whatsapp/businessapi/flows.go new file mode 100644 index 0000000..529b8bf --- /dev/null +++ b/pkg/whatsapp/businessapi/flows.go @@ -0,0 +1,71 @@ +package businessapi + +import ( + "context" + "net/url" +) + +// ListFlows returns all flows for the business account. +func (c *Client) ListFlows(ctx context.Context) (*FlowListResponse, error) { + if c.config.BusinessAccountID == "" { + return nil, errNoBusinessAccount + } + + params := url.Values{ + "fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"}, + } + + var resp FlowListResponse + if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/flows", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// CreateFlow creates a new flow and returns its ID. +func (c *Client) CreateFlow(ctx context.Context, flow FlowCreateRequest) (*FlowCreateResponse, error) { + if c.config.BusinessAccountID == "" { + return nil, errNoBusinessAccount + } + + var resp FlowCreateResponse + if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/flows", flow, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// GetFlow returns details for a single flow. +func (c *Client) GetFlow(ctx context.Context, flowID string) (*FlowInfo, error) { + params := url.Values{ + "fields": {"id,name,status,categories,created_at,updated_at,endpoint_url,preview_url,signed_preview_url,signed_flow_url"}, + } + + var resp FlowInfo + if err := c.graphAPIGet(ctx, flowID, params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UpdateFlowAssets uploads a screens JSON definition to an existing flow (must be in DRAFT). +func (c *Client) UpdateFlowAssets(ctx context.Context, flowID string, screensJSON string) error { + fields := map[string]string{ + "asset_type": "SCREENS_JSON", + "asset_data": screensJSON, + } + + var resp FlowActionResponse + return c.graphAPIPostForm(ctx, flowID+"/assets", fields, &resp) +} + +// PublishFlow transitions a DRAFT flow to PUBLISHED. +func (c *Client) PublishFlow(ctx context.Context, flowID string) error { + var resp FlowActionResponse + return c.graphAPIPost(ctx, flowID+"?action=PUBLISH", 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/helpers.go b/pkg/whatsapp/businessapi/helpers.go new file mode 100644 index 0000000..a4c5d2b --- /dev/null +++ b/pkg/whatsapp/businessapi/helpers.go @@ -0,0 +1,171 @@ +package businessapi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" +) + +// graphAPIGet performs an authenticated GET to the Graph API and unmarshals the response. +func (c *Client) graphAPIGet(ctx context.Context, path string, params url.Values, result any) error { + u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path) + if len(params) > 0 { + u += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, "GET", u, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + + return c.executeRequest(req, result) +} + +// graphAPIPost performs an authenticated POST with a JSON body. +// body may be nil for action-only endpoints (e.g. publish a flow). +func (c *Client) graphAPIPost(ctx context.Context, path string, body any, result any) error { + u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path) + + var reqBody io.Reader + if body != nil { + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, "POST", u, reqBody) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + return c.executeRequest(req, result) +} + +// graphAPIPostForm performs an authenticated POST with multipart form fields. +func (c *Client) graphAPIPostForm(ctx context.Context, path string, fields map[string]string, result any) error { + u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + for k, v := range fields { + if err := writer.WriteField(k, v); err != nil { + return fmt.Errorf("failed to write form field %s: %w", k, err) + } + } + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close multipart writer: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", u, &buf) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + return c.executeRequest(req, result) +} + +// graphAPIDelete performs an authenticated DELETE request. +func (c *Client) graphAPIDelete(ctx context.Context, path string, params url.Values) error { + u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path) + if len(params) > 0 { + u += "?" + params.Encode() + } + + req, err := http.NewRequestWithContext(ctx, "DELETE", u, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + + return c.executeRequest(req, nil) +} + +// executeRequest runs an HTTP request, handles error responses, and optionally unmarshals the body. +func (c *Client) executeRequest(req *http.Request, result any) error { + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var errResp ErrorResponse + if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Message != "" { + return fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code) + } + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + if result != nil && len(body) > 0 { + if err := json.Unmarshal(body, result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + } + return nil +} + +// postToMessagesEndpoint POSTs an arbitrary body to the phone number's /messages endpoint. +// Used by sendMessage (typed) and by reaction/read-receipt (different top-level shape). +func (c *Client) postToMessagesEndpoint(ctx context.Context, body any) (string, error) { + path := c.config.PhoneNumberID + "/messages" + + jsonData, err := json.Marshal(body) + if err != nil { + return "", fmt.Errorf("failed to marshal message: %w", err) + } + + u := fmt.Sprintf("https://graph.facebook.com/%s/%s", c.config.APIVersion, path) + req, err := http.NewRequestWithContext(ctx, "POST", u, bytes.NewBuffer(jsonData)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.config.AccessToken) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + var errResp ErrorResponse + if err := json.Unmarshal(respBody, &errResp); err == nil { + return "", fmt.Errorf("API error: %s (code: %d)", errResp.Error.Message, errResp.Error.Code) + } + return "", fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var sendResp SendMessageResponse + if err := json.Unmarshal(respBody, &sendResp); err != nil { + return "", nil // some endpoints (read receipt) return non-message JSON + } + if len(sendResp.Messages) == 0 { + return "", nil + } + return sendResp.Messages[0].ID, nil +} diff --git a/pkg/whatsapp/businessapi/sending.go b/pkg/whatsapp/businessapi/sending.go new file mode 100644 index 0000000..25f20d5 --- /dev/null +++ b/pkg/whatsapp/businessapi/sending.go @@ -0,0 +1,267 @@ +package businessapi + +import ( + "context" + "errors" + "fmt" + "net/url" + + "git.warky.dev/wdevs/whatshooked/pkg/events" + "git.warky.dev/wdevs/whatshooked/pkg/logging" + + "go.mau.fi/whatsmeow/types" +) + +var errNoBusinessAccount = errors.New("business_account_id is required for this operation") + +// SendAudio sends an audio message via Business API. +func (c *Client) SendAudio(ctx context.Context, jid types.JID, audioData []byte, mimeType string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + mediaID, err := c.uploadMedia(ctx, audioData, mimeType) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", fmt.Errorf("failed to upload audio: %w", err) + } + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "audio", + Audio: &MediaObject{ID: mediaID}, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Audio sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, "")) + return messageID, nil +} + +// SendSticker sends a sticker message via Business API. +func (c *Client) SendSticker(ctx context.Context, jid types.JID, stickerData []byte, mimeType string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + mediaID, err := c.uploadMedia(ctx, stickerData, mimeType) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", fmt.Errorf("failed to upload sticker: %w", err) + } + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "sticker", + Sticker: &MediaObject{ID: mediaID}, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Sticker sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, "")) + return messageID, nil +} + +// SendLocation sends a location message via Business API. +func (c *Client) SendLocation(ctx context.Context, jid types.JID, latitude, longitude float64, name, address string) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "location", + Location: &LocationObject{ + Latitude: latitude, + Longitude: longitude, + Name: name, + Address: address, + }, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Location sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, fmt.Sprintf("%.6f,%.6f", latitude, longitude))) + return messageID, nil +} + +// SendContacts sends one or more contact cards. +func (c *Client) SendContacts(ctx context.Context, jid types.JID, contacts []SendContactObject) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "contacts", + Contacts: contacts, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Contacts sent via Business API", "account_id", c.id, "to", phoneNumber) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, "")) + return messageID, nil +} + +// SendInteractive sends an interactive message (buttons, list, or flow). +func (c *Client) SendInteractive(ctx context.Context, jid types.JID, interactive *InteractiveObject) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "interactive", + Interactive: interactive, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Interactive message sent via Business API", "account_id", c.id, "to", phoneNumber, "type", interactive.Type) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, "")) + return messageID, nil +} + +// SendTemplateMessage sends a template message. +func (c *Client) SendTemplateMessage(ctx context.Context, jid types.JID, tmpl *TemplateMessageObject) (string, error) { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + reqBody := SendMessageRequest{ + MessagingProduct: "whatsapp", + To: phoneNumber, + Type: "template", + Template: tmpl, + } + + messageID, err := c.sendMessage(ctx, reqBody) + if err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, "", err)) + return "", err + } + + logging.Debug("Template message sent via Business API", "account_id", c.id, "to", phoneNumber, "template", tmpl.Name) + c.eventBus.Publish(events.MessageSentEvent(ctx, c.id, messageID, phoneNumber, tmpl.Name)) + return messageID, nil +} + +// SendReaction sends a reaction (emoji) to an existing message. +func (c *Client) SendReaction(ctx context.Context, jid types.JID, messageID string, emoji string) error { + if ctx == nil { + ctx = context.Background() + } + phoneNumber := jidToPhoneNumber(jid) + + msg := ReactionMessage{ + MessagingProduct: "whatsapp", + MessageType: "reaction", + Reaction: ReactionObject{ + MessageID: messageID, + Emoji: emoji, + }, + } + + if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil { + c.eventBus.Publish(events.MessageFailedEvent(ctx, c.id, phoneNumber, emoji, err)) + return err + } + + logging.Debug("Reaction sent via Business API", "account_id", c.id, "to", phoneNumber, "emoji", emoji) + return nil +} + +// MarkAsRead marks a received message as read. +func (c *Client) MarkAsRead(ctx context.Context, messageID string) error { + if ctx == nil { + ctx = context.Background() + } + + msg := ReadReceiptMessage{ + MessagingProduct: "whatsapp", + Status: "read", + MessageID: messageID, + } + + if _, err := c.postToMessagesEndpoint(ctx, msg); err != nil { + return err + } + + logging.Debug("Message marked as read via Business API", "account_id", c.id, "message_id", messageID) + return nil +} + +// ListPhoneNumbers returns all phone numbers for the business account. +func (c *Client) ListPhoneNumbers(ctx context.Context) (*PhoneNumberListResponse, error) { + if c.config.BusinessAccountID == "" { + return nil, errNoBusinessAccount + } + + params := url.Values{ + "fields": {"id,display_phone_number,phone_number,verified_name,code_verification_status,quality_rating,throughput"}, + } + + var resp PhoneNumberListResponse + if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/phone_numbers", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// RequestVerificationCode sends a verification code (SMS or VOICE) to the given phone number. +func (c *Client) RequestVerificationCode(ctx context.Context, phoneNumberID string, method string) error { + body := map[string]string{ + "verification_method": method, + } + + var resp RequestCodeResponse + return c.graphAPIPost(ctx, phoneNumberID+"/request_code", body, &resp) +} + +// VerifyCode verifies a phone number with the code received via SMS/VOICE. +func (c *Client) VerifyCode(ctx context.Context, phoneNumberID string, code string) error { + body := VerifyCodeRequest{Code: code} + + var resp VerifyCodeResponse + return c.graphAPIPost(ctx, phoneNumberID+"/verify_code", body, &resp) +} + +// DeleteMedia deletes a previously uploaded media file. +func (c *Client) DeleteMedia(ctx context.Context, mediaID string) error { + return c.graphAPIDelete(ctx, mediaID, nil) +} diff --git a/pkg/whatsapp/businessapi/templates.go b/pkg/whatsapp/businessapi/templates.go new file mode 100644 index 0000000..d2d8bdd --- /dev/null +++ b/pkg/whatsapp/businessapi/templates.go @@ -0,0 +1,50 @@ +package businessapi + +import ( + "context" + "net/url" +) + +// ListTemplates returns all message templates for the business account. +// Requires BusinessAccountID in the client config. +func (c *Client) ListTemplates(ctx context.Context) (*TemplateListResponse, error) { + if c.config.BusinessAccountID == "" { + return nil, errNoBusinessAccount + } + + params := url.Values{ + "fields": {"id,name,status,language,category,created_at,components,rejection_reasons,quality_score"}, + } + + var resp TemplateListResponse + if err := c.graphAPIGet(ctx, c.config.BusinessAccountID+"/message_templates", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// UploadTemplate creates a new message template. +func (c *Client) UploadTemplate(ctx context.Context, tmpl TemplateUploadRequest) (*TemplateUploadResponse, error) { + if c.config.BusinessAccountID == "" { + return nil, errNoBusinessAccount + } + + var resp TemplateUploadResponse + if err := c.graphAPIPost(ctx, c.config.BusinessAccountID+"/message_templates", tmpl, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// DeleteTemplate deletes a template by name and language. +func (c *Client) DeleteTemplate(ctx context.Context, name, language string) error { + if c.config.BusinessAccountID == "" { + return errNoBusinessAccount + } + + params := url.Values{ + "name": {name}, + "language": {language}, + } + return c.graphAPIDelete(ctx, c.config.BusinessAccountID+"/message_templates", params) +} diff --git a/pkg/whatsapp/businessapi/types.go b/pkg/whatsapp/businessapi/types.go index a1be0b9..9596a2d 100644 --- a/pkg/whatsapp/businessapi/types.go +++ b/pkg/whatsapp/businessapi/types.go @@ -1,15 +1,23 @@ package businessapi -// SendMessageRequest represents a request to send a text message via Business API +import "encoding/json" + +// SendMessageRequest represents a request to send a message via Business API type SendMessageRequest struct { - MessagingProduct string `json:"messaging_product"` // Always "whatsapp" - RecipientType string `json:"recipient_type,omitempty"` // "individual" - To string `json:"to"` // Phone number in E.164 format - Type string `json:"type"` // "text", "image", "video", "document" - Text *TextObject `json:"text,omitempty"` - Image *MediaObject `json:"image,omitempty"` - Video *MediaObject `json:"video,omitempty"` - Document *DocumentObject `json:"document,omitempty"` + MessagingProduct string `json:"messaging_product"` // Always "whatsapp" + RecipientType string `json:"recipient_type,omitempty"` // "individual" + To string `json:"to"` // Phone number in E.164 format + Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "template" + Text *TextObject `json:"text,omitempty"` + Image *MediaObject `json:"image,omitempty"` + Video *MediaObject `json:"video,omitempty"` + Document *DocumentObject `json:"document,omitempty"` + Audio *MediaObject `json:"audio,omitempty"` + Sticker *MediaObject `json:"sticker,omitempty"` + Location *LocationObject `json:"location,omitempty"` + Contacts []SendContactObject `json:"contacts,omitempty"` + Interactive *InteractiveObject `json:"interactive,omitempty"` + Template *TemplateMessageObject `json:"template,omitempty"` } // TextObject represents a text message @@ -82,10 +90,12 @@ type WebhookEntry struct { Changes []WebhookChange `json:"changes"` } -// WebhookChange represents a change notification +// WebhookChange represents a change notification. +// Value is kept as raw JSON because the shape differs per Field type: +// "messages" → WebhookValue, "message_template_status_update" → TemplateStatusValue, etc. type WebhookChange struct { - Value WebhookValue `json:"value"` - Field string `json:"field"` // "messages" + Value json.RawMessage `json:"value"` + Field string `json:"field"` } // WebhookValue contains the actual webhook data @@ -394,3 +404,386 @@ type BusinessAccountDetails struct { TimezoneID string `json:"timezone_id"` MessageTemplateNamespace string `json:"message_template_namespace,omitempty"` } + +// TemplateStatusValue represents the value payload in a message_template_status_update webhook. +// This is a distinct shape from WebhookValue — Meta does not include messaging_product or +// metadata in these notifications. +type TemplateStatusValue struct { + Event string `json:"event"` // e.g. "TEMPLATE_APPROVED", "TEMPLATE_REJECTED" + TemplateName string `json:"template_name"` // name used during upload + TemplateID string `json:"template_id"` // Meta-assigned template ID + Language string `json:"language"` // e.g. "en_US" + Status string `json:"status"` // "APPROVED", "REJECTED", "PENDING" + RejectionReasons []string `json:"rejection_reasons"` // non-empty only when Status == "REJECTED" +} + +// --------------------------------------------------------------------------- +// Outgoing message types (sending) +// --------------------------------------------------------------------------- + +// LocationObject is used when sending a location message +type LocationObject struct { + Latitude float64 `json:"latitude"` + Longitude float64 `json:"longitude"` + Name string `json:"name,omitempty"` + Address string `json:"address,omitempty"` +} + +// SendContactObject is a contact card in an outgoing contacts message +type SendContactObject struct { + Name ContactNameObj `json:"name"` + Addresses []ContactAddressObj `json:"addresses,omitempty"` + Emails []ContactEmailObj `json:"emails,omitempty"` + Phones []ContactPhoneObj `json:"phones,omitempty"` + Org *ContactOrgObj `json:"org,omitempty"` + URLs []ContactURLObj `json:"urls,omitempty"` +} + +type ContactNameObj struct { + FormattedName string `json:"formatted_name"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + MiddleName string `json:"middle_name,omitempty"` + Suffix string `json:"suffix,omitempty"` + Prefix string `json:"prefix,omitempty"` +} + +type ContactAddressObj struct { + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + CountryCode string `json:"country_code,omitempty"` + State string `json:"state,omitempty"` + Street string `json:"street,omitempty"` + Type string `json:"type,omitempty"` + Zip string `json:"zip,omitempty"` +} + +type ContactEmailObj struct { + Email string `json:"email,omitempty"` + Type string `json:"type,omitempty"` +} + +type ContactPhoneObj struct { + Phone string `json:"phone,omitempty"` + Type string `json:"type,omitempty"` +} + +type ContactOrgObj struct { + Company string `json:"company,omitempty"` + Department string `json:"department,omitempty"` + Title string `json:"title,omitempty"` +} + +type ContactURLObj struct { + URL string `json:"url,omitempty"` + Type string `json:"type,omitempty"` +} + +// InteractiveObject is the payload for interactive messages (buttons, list, flow) +type InteractiveObject struct { + Type string `json:"type"` // "button", "list", "flow" + Header *InteractiveHeader `json:"header,omitempty"` + Body *InteractiveBody `json:"body"` + Footer *InteractiveFooter `json:"footer,omitempty"` + Buttons []InteractiveButton `json:"buttons,omitempty"` // type "button": 1-3 reply buttons + Action *InteractiveAction `json:"action,omitempty"` // type "list" or "flow" +} + +type InteractiveHeader struct { + Type string `json:"type"` // "text", "image", "video", "document" + Text string `json:"text,omitempty"` + Image *MediaObject `json:"image,omitempty"` + Video *MediaObject `json:"video,omitempty"` + Document *MediaObject `json:"document,omitempty"` +} + +type InteractiveBody struct { + Text string `json:"text"` +} + +type InteractiveFooter struct { + Text string `json:"text"` +} + +type InteractiveButton struct { + Type string `json:"type"` // "reply" + Reply InteractiveButtonReply `json:"reply"` +} + +type InteractiveButtonReply struct { + ID string `json:"id"` // up to 20 chars; returned in webhook reply + Title string `json:"title"` // up to 20 chars; displayed on button +} + +// InteractiveAction is shared by list and flow interactive messages +type InteractiveAction struct { + // list fields + Button string `json:"button,omitempty"` // label on the list button + Sections []InteractiveSection `json:"sections,omitempty"` // 1-10 sections + // flow fields + Name string `json:"name,omitempty"` // "flow" + Parameters *FlowActionParams `json:"parameters,omitempty"` +} + +type InteractiveSection struct { + Title string `json:"title,omitempty"` + Rows []InteractiveSectionRow `json:"rows"` // 1-10 rows +} + +type InteractiveSectionRow struct { + ID string `json:"id"` // up to 20 chars + Title string `json:"title"` // up to 60 chars + Description string `json:"description,omitempty"` // up to 72 chars +} + +// FlowActionParams defines parameters when sending an interactive flow message +type FlowActionParams struct { + Type string `json:"type"` // "payload" or "unpublished" + FlowToken string `json:"flow_token"` + Name string `json:"name"` // initial screen name + Data map[string]any `json:"data"` // dynamic key/value pairs for the screen +} + +// TemplateMessageObject defines a template message for sending +type TemplateMessageObject struct { + Name string `json:"name"` + Language TemplateLanguage `json:"language"` + Components []TemplateSendComponent `json:"components,omitempty"` +} + +type TemplateLanguage struct { + Code string `json:"code"` // e.g. "en_US", "en" + Policy string `json:"policy,omitempty"` // "deterministic" +} + +type TemplateSendComponent struct { + Type string `json:"type"` // "header", "body", "buttons" + SubType string `json:"sub_type,omitempty"` // "quick_reply" or "url" (buttons only) + Index string `json:"index,omitempty"` // "0","1","2" (buttons only) + Parameters []TemplateParameter `json:"parameters"` +} + +type TemplateParameter struct { + Type string `json:"type"` // "text","currency","date_time","image","video","document","location","payload" + Text string `json:"text,omitempty"` + Currency *CurrencyObject `json:"currency,omitempty"` + DateTime *DateTimeObject `json:"date_time,omitempty"` + Image *MediaObject `json:"image,omitempty"` + Video *MediaObject `json:"video,omitempty"` + Document *MediaObject `json:"document,omitempty"` + Location *LocationObject `json:"location,omitempty"` + Payload string `json:"payload,omitempty"` // quick_reply button payload +} + +type CurrencyObject struct { + Currency string `json:"currency"` // ISO 4217 + Amount string `json:"amount"` +} + +type DateTimeObject struct { + Raw string `json:"raw"` // RFC 3339 datetime string +} + +// ReactionMessage is the body sent to react to an existing message. +// Uses "message_type" (not "type") at the top level — distinct from SendMessageRequest. +type ReactionMessage struct { + MessagingProduct string `json:"messaging_product"` // "whatsapp" + MessageType string `json:"message_type"` // "reaction" + Reaction ReactionObject `json:"reaction"` +} + +type ReactionObject struct { + MessageID string `json:"message_id"` + Emoji string `json:"emoji"` +} + +// ReadReceiptMessage marks a received message as read +type ReadReceiptMessage struct { + MessagingProduct string `json:"messaging_product"` // "whatsapp" + Status string `json:"status"` // "read" + MessageID string `json:"message_id"` +} + +// --------------------------------------------------------------------------- +// Template management (CRUD) +// --------------------------------------------------------------------------- + +type TemplateListResponse struct { + Data []TemplateInfo `json:"data"` + Paging *PagingInfo `json:"paging,omitempty"` +} + +type TemplateInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // APPROVED, PENDING, REJECTED + Language string `json:"language"` + Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION + CreatedAt string `json:"created_at"` + Components []TemplateComponentDef `json:"components"` + RejectionReasons []string `json:"rejection_reasons,omitempty"` + QualityScore string `json:"quality_score,omitempty"` +} + +type TemplateComponentDef struct { + Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS + Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION + Text string `json:"text,omitempty"` + Buttons []TemplateButtonDef `json:"buttons,omitempty"` + Example *TemplateComponentExample `json:"example,omitempty"` +} + +type TemplateButtonDef struct { + Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE + Text string `json:"text"` + URL string `json:"url,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Dynamic bool `json:"dynamic,omitempty"` +} + +type TemplateComponentExample struct { + HeaderHandle []string `json:"header_handle,omitempty"` + BodyExample [][]string `json:"body_example,omitempty"` +} + +type TemplateUploadRequest struct { + Name string `json:"name"` + Language string `json:"language"` + Category string `json:"category"` // MARKETING, UTILITY, AUTHENTICATION + Components []TemplateUploadComponent `json:"components"` + AllowCategoryChange bool `json:"allow_category_change,omitempty"` +} + +type TemplateUploadComponent struct { + Type string `json:"type"` // HEADER, BODY, FOOTER, BUTTONS + Format string `json:"format,omitempty"` // TEXT, IMAGE, VIDEO, DOCUMENT, LOCATION (HEADER only) + Text string `json:"text,omitempty"` + Buttons []TemplateUploadButton `json:"buttons,omitempty"` + Example *TemplateUploadExample `json:"example,omitempty"` +} + +type TemplateUploadButton struct { + Type string `json:"type"` // PHONE_NUMBER, URL, QUICK_REPLY, COPY_CODE + Text string `json:"text"` + URL string `json:"url,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Example []string `json:"example,omitempty"` // example values for dynamic parts +} + +type TemplateUploadExample struct { + HeaderHandle []string `json:"header_handle,omitempty"` + BodyExample [][]string `json:"body_example,omitempty"` +} + +type TemplateUploadResponse struct { + Data TemplateUploadData `json:"data"` +} + +type TemplateUploadData struct { + ID string `json:"id"` + Status string `json:"status"` +} + +// --------------------------------------------------------------------------- +// Flow management (CRUD) +// --------------------------------------------------------------------------- + +type FlowListResponse struct { + Data []FlowInfo `json:"data"` + Paging *PagingInfo `json:"paging,omitempty"` +} + +type FlowInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` // DRAFT, PUBLISHED, DEPRECATED, BLOCKED, ERROR + Categories []string `json:"categories"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + EndpointURL string `json:"endpoint_url,omitempty"` + PreviewURL string `json:"preview_url,omitempty"` + SignedPreviewURL string `json:"signed_preview_url,omitempty"` + SignedFlowURL string `json:"signed_flow_url,omitempty"` +} + +type FlowCreateRequest struct { + Name string `json:"name"` + Categories []string `json:"categories"` // e.g. ["SIGN_IN"] + EndpointURL string `json:"endpoint_url,omitempty"` + CloneFlowID string `json:"clone_flow_id,omitempty"` +} + +type FlowCreateResponse struct { + Data FlowCreateData `json:"data"` +} + +type FlowCreateData struct { + ID string `json:"id"` + Status string `json:"status"` +} + +type FlowActionResponse struct { + Data FlowActionData `json:"data"` +} + +type FlowActionData struct { + Success bool `json:"success"` + Status string `json:"status,omitempty"` +} + +// --------------------------------------------------------------------------- +// Phone number management +// --------------------------------------------------------------------------- + +type PhoneNumberListResponse struct { + Data []PhoneNumberListItem `json:"data"` + Paging *PagingInfo `json:"paging,omitempty"` +} + +type PhoneNumberListItem struct { + ID string `json:"id"` + DisplayPhoneNumber string `json:"display_phone_number"` + PhoneNumber string `json:"phone_number"` + VerifiedName string `json:"verified_name"` + CodeVerificationStatus string `json:"code_verification_status"` + QualityRating string `json:"quality_rating"` + Throughput ThroughputInfo `json:"throughput"` +} + +type RequestCodeRequest struct { + CertificateData string `json:"certificate_data,omitempty"` +} + +type RequestCodeResponse struct { + Data RequestCodeData `json:"data"` +} + +type RequestCodeData struct { + MessageStatus string `json:"message_status"` // "CODE_SENT" +} + +type VerifyCodeRequest struct { + Code string `json:"code"` +} + +type VerifyCodeResponse struct { + Data VerifyCodeData `json:"data"` +} + +type VerifyCodeData struct { + MessageStatus string `json:"message_status"` // "CODE_VERIFIED" +} + +// --------------------------------------------------------------------------- +// Shared / pagination +// --------------------------------------------------------------------------- + +type PagingInfo struct { + Cursors PagingCursors `json:"cursors"` + Next string `json:"next,omitempty"` +} + +type PagingCursors struct { + Before string `json:"before"` + After string `json:"after"` +} diff --git a/pkg/whatshooked/server.go b/pkg/whatshooked/server.go index 705903c..4b5dcc2 100644 --- a/pkg/whatshooked/server.go +++ b/pkg/whatshooked/server.go @@ -241,6 +241,38 @@ func (s *Server) setupRoutes() *http.ServeMux { // Business API webhooks (no auth - Meta validates via verify_token) mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook) + // Extended sending (with auth) + mux.HandleFunc("/api/send/audio", h.Auth(h.SendAudio)) + mux.HandleFunc("/api/send/sticker", h.Auth(h.SendSticker)) + mux.HandleFunc("/api/send/location", h.Auth(h.SendLocation)) + mux.HandleFunc("/api/send/contacts", h.Auth(h.SendContacts)) + mux.HandleFunc("/api/send/interactive", h.Auth(h.SendInteractive)) + mux.HandleFunc("/api/send/template", h.Auth(h.SendTemplate)) + mux.HandleFunc("/api/send/flow", h.Auth(h.SendFlow)) + mux.HandleFunc("/api/send/reaction", h.Auth(h.SendReaction)) + mux.HandleFunc("/api/messages/read", h.Auth(h.MarkAsRead)) + + // Template management (with auth) + mux.HandleFunc("/api/templates", h.Auth(h.ListTemplates)) + mux.HandleFunc("/api/templates/upload", h.Auth(h.UploadTemplate)) + mux.HandleFunc("/api/templates/delete", h.Auth(h.DeleteTemplate)) + + // Flow management (with auth) + mux.HandleFunc("/api/flows", h.Auth(h.ListFlows)) + mux.HandleFunc("/api/flows/create", h.Auth(h.CreateFlow)) + 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/delete", h.Auth(h.DeleteFlow)) + + // Phone number management (with auth) + mux.HandleFunc("/api/phone-numbers", h.Auth(h.ListPhoneNumbers)) + mux.HandleFunc("/api/phone-numbers/request-code", h.Auth(h.RequestVerificationCode)) + mux.HandleFunc("/api/phone-numbers/verify-code", h.Auth(h.VerifyCode)) + + // Media management (with auth) + mux.HandleFunc("/api/media-delete", h.Auth(h.DeleteMediaFile)) + // 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