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
|
||||
func HookTriggeredEvent(ctx context.Context, hookID, hookName, url string, payload any) Event {
|
||||
return NewEvent(ctx, EventHookTriggered, map[string]any{
|
||||
|
||||
@@ -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,
|
||||
|
||||
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 (
|
||||
"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 {
|
||||
|
||||
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.EventMessageDelivered,
|
||||
events.EventMessageRead,
|
||||
events.EventTemplateStatusUpdate,
|
||||
}
|
||||
|
||||
// Subscribe to all event types with a generic handler
|
||||
|
||||
@@ -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",
|
||||
|
||||
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
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user