feat(whatsapp): 🎉 Add extended sending and template management
* 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.
This commit is contained in:
@@ -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
|
// HookTriggeredEvent creates a hook triggered event
|
||||||
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
|
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
|
||||||
return NewEvent(ctx, EventHookTriggered, map[string]any{
|
return NewEvent(ctx, EventHookTriggered, map[string]any{
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const (
|
|||||||
EventMessageDelivered EventType = "message.delivered"
|
EventMessageDelivered EventType = "message.delivered"
|
||||||
EventMessageRead EventType = "message.read"
|
EventMessageRead EventType = "message.read"
|
||||||
|
|
||||||
|
// Template events
|
||||||
|
EventTemplateStatusUpdate EventType = "template.status_update"
|
||||||
|
|
||||||
// Hook events
|
// Hook events
|
||||||
EventHookTriggered EventType = "hook.triggered"
|
EventHookTriggered EventType = "hook.triggered"
|
||||||
EventHookSuccess EventType = "hook.success"
|
EventHookSuccess EventType = "hook.success"
|
||||||
@@ -88,6 +91,7 @@ func (eb *EventBus) SubscribeAll(subscriber Subscriber) {
|
|||||||
EventMessageFailed,
|
EventMessageFailed,
|
||||||
EventMessageDelivered,
|
EventMessageDelivered,
|
||||||
EventMessageRead,
|
EventMessageRead,
|
||||||
|
EventTemplateStatusUpdate,
|
||||||
EventHookTriggered,
|
EventHookTriggered,
|
||||||
EventHookSuccess,
|
EventHookSuccess,
|
||||||
EventHookFailed,
|
EventHookFailed,
|
||||||
|
|||||||
305
pkg/handlers/flows.go
Normal file
305
pkg/handlers/flows.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
"git.warky.dev/wdevs/whatshooked/pkg/hooks"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
"git.warky.dev/wdevs/whatshooked/pkg/logging"
|
||||||
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp"
|
||||||
|
"git.warky.dev/wdevs/whatshooked/pkg/whatsapp/businessapi"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handlers holds all HTTP handlers with their dependencies
|
// Handlers holds all HTTP handlers with their dependencies
|
||||||
@@ -57,6 +59,23 @@ func (h *Handlers) WithAuthConfig(cfg *AuthConfig) *Handlers {
|
|||||||
return h
|
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
|
// writeJSON is a helper that writes JSON response and logs errors
|
||||||
func writeJSON(w http.ResponseWriter, v interface{}) {
|
func writeJSON(w http.ResponseWriter, v interface{}) {
|
||||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||||
|
|||||||
42
pkg/handlers/media_management.go
Normal file
42
pkg/handlers/media_management.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
115
pkg/handlers/phone_numbers.go
Normal file
115
pkg/handlers/phone_numbers.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
316
pkg/handlers/send_extended.go
Normal file
316
pkg/handlers/send_extended.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
158
pkg/handlers/templates.go
Normal file
158
pkg/handlers/templates.go
Normal file
@@ -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"})
|
||||||
|
}
|
||||||
@@ -87,6 +87,7 @@ func (m *Manager) Start() {
|
|||||||
events.EventMessageFailed,
|
events.EventMessageFailed,
|
||||||
events.EventMessageDelivered,
|
events.EventMessageDelivered,
|
||||||
events.EventMessageRead,
|
events.EventMessageRead,
|
||||||
|
events.EventTemplateStatusUpdate,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to all event types with a generic handler
|
// Subscribe to all event types with a generic handler
|
||||||
|
|||||||
@@ -52,58 +52,78 @@ func (c *Client) HandleWebhook(r *http.Request) error {
|
|||||||
return nil
|
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) {
|
func (c *Client) processChange(change WebhookChange) {
|
||||||
ctx := context.Background()
|
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 {
|
switch change.Field {
|
||||||
case "messages":
|
case "messages":
|
||||||
// Process messages
|
var value WebhookValue
|
||||||
for i := range change.Value.Messages {
|
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||||
msg := change.Value.Messages[i]
|
logging.Error("Failed to parse messages webhook value",
|
||||||
c.processMessage(ctx, msg, change.Value.Contacts)
|
"account_id", c.id, "error", err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process statuses
|
logging.Info("Processing webhook change",
|
||||||
for _, status := range change.Value.Statuses {
|
"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)
|
c.processStatus(ctx, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "message_template_status_update":
|
case "message_template_status_update":
|
||||||
// Log template status updates for visibility
|
var value TemplateStatusValue
|
||||||
logging.Info("Message template status update received",
|
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||||
"account_id", c.id,
|
logging.Error("Failed to parse template status update",
|
||||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
"account_id", c.id, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case "account_update":
|
logging.Info("Template status update received",
|
||||||
// Log account updates
|
|
||||||
logging.Info("Account update received",
|
|
||||||
"account_id", c.id,
|
"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":
|
c.eventBus.Publish(events.TemplateStatusUpdateEvent(
|
||||||
// Log quality updates
|
ctx,
|
||||||
logging.Info("Phone number quality update received",
|
c.id,
|
||||||
"account_id", c.id,
|
value.TemplateName,
|
||||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
value.TemplateID,
|
||||||
|
value.Language,
|
||||||
|
value.Status,
|
||||||
|
value.RejectionReasons,
|
||||||
|
))
|
||||||
|
|
||||||
case "phone_number_name_update":
|
case "account_update", "phone_number_quality_update", "phone_number_name_update", "account_alerts":
|
||||||
// Log name updates
|
// These all carry the standard WebhookValue with metadata
|
||||||
logging.Info("Phone number name update received",
|
var value WebhookValue
|
||||||
"account_id", c.id,
|
if err := json.Unmarshal(change.Value, &value); err != nil {
|
||||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
logging.Error("Failed to parse webhook value",
|
||||||
|
"account_id", c.id, "field", change.Field, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
case "account_alerts":
|
if change.Field == "account_alerts" {
|
||||||
// Log account alerts
|
logging.Warn("Account alert received",
|
||||||
logging.Warn("Account alert received",
|
"account_id", c.id,
|
||||||
"account_id", c.id,
|
"phone_number_id", value.Metadata.PhoneNumberID)
|
||||||
"phone_number_id", change.Value.Metadata.PhoneNumberID)
|
} else {
|
||||||
|
logging.Info("Webhook notification received",
|
||||||
|
"account_id", c.id,
|
||||||
|
"field", change.Field,
|
||||||
|
"phone_number_id", value.Metadata.PhoneNumberID)
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logging.Debug("Unknown webhook field type",
|
logging.Debug("Unknown webhook field type",
|
||||||
|
|||||||
71
pkg/whatsapp/businessapi/flows.go
Normal file
71
pkg/whatsapp/businessapi/flows.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
171
pkg/whatsapp/businessapi/helpers.go
Normal file
171
pkg/whatsapp/businessapi/helpers.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
267
pkg/whatsapp/businessapi/sending.go
Normal file
267
pkg/whatsapp/businessapi/sending.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
50
pkg/whatsapp/businessapi/templates.go
Normal file
50
pkg/whatsapp/businessapi/templates.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,15 +1,23 @@
|
|||||||
package businessapi
|
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 {
|
type SendMessageRequest struct {
|
||||||
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
|
MessagingProduct string `json:"messaging_product"` // Always "whatsapp"
|
||||||
RecipientType string `json:"recipient_type,omitempty"` // "individual"
|
RecipientType string `json:"recipient_type,omitempty"` // "individual"
|
||||||
To string `json:"to"` // Phone number in E.164 format
|
To string `json:"to"` // Phone number in E.164 format
|
||||||
Type string `json:"type"` // "text", "image", "video", "document"
|
Type string `json:"type"` // "text", "image", "video", "document", "audio", "sticker", "location", "contacts", "interactive", "template"
|
||||||
Text *TextObject `json:"text,omitempty"`
|
Text *TextObject `json:"text,omitempty"`
|
||||||
Image *MediaObject `json:"image,omitempty"`
|
Image *MediaObject `json:"image,omitempty"`
|
||||||
Video *MediaObject `json:"video,omitempty"`
|
Video *MediaObject `json:"video,omitempty"`
|
||||||
Document *DocumentObject `json:"document,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
|
// TextObject represents a text message
|
||||||
@@ -82,10 +90,12 @@ type WebhookEntry struct {
|
|||||||
Changes []WebhookChange `json:"changes"`
|
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 {
|
type WebhookChange struct {
|
||||||
Value WebhookValue `json:"value"`
|
Value json.RawMessage `json:"value"`
|
||||||
Field string `json:"field"` // "messages"
|
Field string `json:"field"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookValue contains the actual webhook data
|
// WebhookValue contains the actual webhook data
|
||||||
@@ -394,3 +404,386 @@ type BusinessAccountDetails struct {
|
|||||||
TimezoneID string `json:"timezone_id"`
|
TimezoneID string `json:"timezone_id"`
|
||||||
MessageTemplateNamespace string `json:"message_template_namespace,omitempty"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,38 @@ func (s *Server) setupRoutes() *http.ServeMux {
|
|||||||
// Business API webhooks (no auth - Meta validates via verify_token)
|
// Business API webhooks (no auth - Meta validates via verify_token)
|
||||||
mux.HandleFunc("/webhooks/whatsapp/", h.BusinessAPIWebhook)
|
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)
|
// Message cache management (with auth)
|
||||||
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
|
mux.HandleFunc("/api/cache", h.Auth(h.GetCachedEvents)) // GET - list cached events
|
||||||
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
|
mux.HandleFunc("/api/cache/stats", h.Auth(h.GetCacheStats)) // GET - cache statistics
|
||||||
|
|||||||
Reference in New Issue
Block a user