From 940c657a84d2703390a897b17f3b8999723429b7 Mon Sep 17 00:00:00 2001 From: byte-rose Date: Mon, 13 Oct 2025 12:08:53 +0300 Subject: [PATCH 1/6] feat(catalog,templates): add feed upload and MPM validation - Add catalog feed upload support with multipart/form-data - Introduce FeedUpload, FeedUploadResponse, FeedUploadSession, FeedUploadError, FeedUploadErrorReport types for status/diagnostics - Add utilities/endpoints to list sessions and fetch error reports - Pre-validate catalog and multi-product message buttons in template Create/Update to fail fast with clearer errors - Import bytes, io, mime/multipart, net/textproto, filepath to support CSV uploads Why: Improves catalog ingestion visibility and error handling, and catches invalid template button configurations client-side to reduce API round-trips and cryptic server errors. --- AGENTS.md | 36 +++ manager/catalog_manager.go | 362 ++++++++++++++++++++++++- manager/template_manager.go | 73 +++-- pkg/business/client.go | 79 ++++-- pkg/components/catalog_message.go | 42 +-- pkg/components/product_list_message.go | 58 ++-- pkg/components/template_message.go | 8 +- 7 files changed, 559 insertions(+), 99 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d470606 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,36 @@ +# Repository Guidelines + +## Project Structure & Module Organization +Wapi.go targets Go 1.21. Core SDK packages live under `pkg/` (messaging, events, business), shared HTTP plumbing sits in `internal/`, and orchestration helpers live in `manager/`. Examples are in `examples/` for quick sandbox runs. Generated references belong in `docs/` with templates in `docs/templates`. Tests sit beside implementations as `*_test.go`. + +## Build, Test, and Development Commands +- `go build ./...`: compiles all packages to catch cross‑package breakage early. +- `go test ./...`: runs the standard test suite; narrow with `-run`. +- `make format`: invokes `go fmt ./...` to match canonical Go style. +- `make docs`: installs `gomarkdoc` if absent and regenerates `docs/api-reference/`. +- `go run ./examples/chat-bot` or `go run ./examples/http-backend-integration`: validate changes against sample flows. + +## Coding Style & Naming Conventions +- Idiomatic Go: tabs and `gofmt` output. +- Exported identifiers use PascalCase with package‑level doc comments. +- Unexported helpers use camelCase and remain package‑local. +- Files/dirs are lowercase with hyphens. Prefer narrow, WhatsApp‑specific structs and reuse constructors from `manager/` and `pkg/`. + +## Testing Guidelines +- Use the standard `testing` package with table‑driven `TestXxx` functions. +- Stub outbound HTTP by wrapping `internal/request_client` (no live endpoints). +- Cover serialization, validation, and webhook dispatch; ensure `go test ./...` is clean. + +## Commit & Pull Request Guidelines +- Conventional Commits per `COMMIT_CONVENTION.md`, e.g., `feat(messaging): add template sender`. +- Branch names: `type/topic` (e.g., `fix/webhook-validation`). +- PRs summarize behavior, link issues, attach payloads/screenshots for API changes, and mention any doc updates (`make docs`). + +## Documentation Workflow +- API references are generated artifacts. Update doc comments and templates, not generated files. +- After changes, run `make docs` and commit both source and generated markdown. + +## Security & Configuration Tips (Optional) +- Provide API tokens via environment or secret management; never commit credentials. +- Store webhook secrets securely and rotate regularly. + diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index 913091b..3480d23 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -1,13 +1,18 @@ package manager import ( - "encoding/json" - "fmt" - "net/http" - "strings" - - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strings" + + "github.com/wapikit/wapi.go/internal" + "github.com/wapikit/wapi.go/internal/request_client" ) type CatalogManager struct { @@ -58,9 +63,61 @@ type ProductGroup struct { } type ProductFeed struct { - Id string `json:"id"` - FileName string `json:"file_name"` - Name string `json:"name"` + Id string `json:"id"` + FileName string `json:"file_name"` + Name string `json:"name"` +} + +// FeedUpload represents a single upload attempt and its status/diagnostics. +type FeedUpload struct { + Id string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Errors []string `json:"errors,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedTime string `json:"created_time,omitempty"` + LastUpdated string `json:"last_updated_time,omitempty"` +} + +// FeedUploadResponse wraps basic responses for CSV upload operations. +type FeedUploadResponse struct { + Id string `json:"id,omitempty"` + Status string `json:"status,omitempty"` +} + +// FeedUploadSession represents an upload session listing entry (start/end time). +type FeedUploadSession struct { + Id string `json:"id"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` +} + +// FeedUploadErrorSample represents a row sample in an error entry. +type FeedUploadErrorSample struct { + RowNumber int `json:"row_number"` + RetailerId string `json:"retailer_id"` + Id string `json:"id"` +} + +// FeedUploadError represents an individual ingestion error/warning. +type FeedUploadError struct { + Id string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Severity string `json:"severity"` // fatal | warning + Samples struct { + Data []FeedUploadErrorSample `json:"data"` + } `json:"samples"` +} + +// FeedUploadErrorReport represents the generated error report metadata. +type FeedUploadErrorReport struct { + ReportStatus string `json:"report_status,omitempty"` + FileHandle string `json:"file_handle,omitempty"` +} + +type FeedUploadErrorReportResponse struct { + ErrorReport FeedUploadErrorReport `json:"error_report,omitempty"` + Id string `json:"id,omitempty"` } type ProductError struct { @@ -342,9 +399,284 @@ type CreateProductCatalogOptions struct { } func (cm *CatalogManager) CreateNewProductCatalog() (CreateProductCatalogOptions, error) { - apiRequest := cm.requester.NewApiRequest(strings.Join([]string{cm.businessAccountId, "product_catalogs"}, "/"), http.MethodPost) - response, err := apiRequest.Execute() - var responseToReturn CreateProductCatalogOptions - json.Unmarshal([]byte(response), &responseToReturn) - return responseToReturn, err + apiRequest := cm.requester.NewApiRequest(strings.Join([]string{cm.businessAccountId, "product_catalogs"}, "/"), http.MethodPost) + response, err := apiRequest.Execute() + var responseToReturn CreateProductCatalogOptions + json.Unmarshal([]byte(response), &responseToReturn) + return responseToReturn, err +} + +// ListProductFeeds lists product feeds for a given catalog. +func (cm *CatalogManager) ListProductFeeds(catalogId string) ([]ProductFeed, error) { + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []ProductFeed `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil +} + +// CreateProductFeed creates a product feed for CSV ingestion. Meta format is assumed. +// name: Human-readable feed name +// fileFormat: e.g., "CSV" +// fileName: default file name reference (optional) +func (cm *CatalogManager) CreateProductFeed(catalogId, name, fileFormat, fileName string) (*ProductFeed, error) { + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]string{ + "name": name, + "file_format": fileFormat, + } + if fileName != "" { + body["file_name"] = fileName + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductFeed + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// UploadFeedCSV uploads a CSV file to a product feed using multipart/form-data. +func (cm *CatalogManager) UploadFeedCSV(feedId string, file io.Reader, filename, mimeType string, updateOnly bool) (*FeedUploadResponse, error) { + // Prepare multipart body with update_only and a single file part + bodyBuf := new(bytes.Buffer) + writer := multipart.NewWriter(bodyBuf) + // update_only as string field + if err := writer.WriteField("update_only", func() string { if updateOnly { return "true" } ; return "false" }()); err != nil { + return nil, fmt.Errorf("failed to write update_only: %w", err) + } + // file part + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filepath.Base(filename))) + partHeader.Set("Content-Type", mimeType) + filePart, err := writer.CreatePart(partHeader) + if err != nil { + return nil, fmt.Errorf("failed to create multipart part: %w", err) + } + if _, err := io.Copy(filePart, file); err != nil { + return nil, fmt.Errorf("failed to copy csv into part: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close writer: %w", err) + } + + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + contentType := writer.FormDataContentType() + responseBody, err := cm.requester.RequestMultipart(http.MethodPost, apiPath, bodyBuf, contentType) + if err != nil { + return nil, fmt.Errorf("error uploading CSV: %w", err) + } + + var res FeedUploadResponse + if err := json.Unmarshal([]byte(responseBody), &res); err != nil { + return nil, fmt.Errorf("failed to parse upload response: %w", err) + } + return &res, nil +} + +// UploadFeedCSVFromURL triggers a feed ingestion from a hosted CSV URL. +func (cm *CatalogManager) UploadFeedCSVFromURL(feedId, csvURL string, updateOnly bool) (*FeedUploadResponse, error) { + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]interface{}{ + // Meta docs show using 'url' for hosted feed uploads + "url": csvURL, + "update_only": updateOnly, + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListFeedUploads lists uploads for a product feed. +func (cm *CatalogManager) ListFeedUploads(feedId string) ([]FeedUploadSession, error) { + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []FeedUploadSession `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil +} + +// GetFeedUploadStatus fetches a single upload’s status/diagnostics. +func (cm *CatalogManager) GetFeedUploadStatus(uploadId string) (*FeedUploadErrorReportResponse, error) { + apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) + // include error_report field for convenience + apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadErrorReportResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetFeedUploadErrors fetches a sampling of errors/warnings for an upload session. +func (cm *CatalogManager) GetFeedUploadErrors(uploadId string) ([]FeedUploadError, error) { + apiPath := strings.Join([]string{uploadId, "errors"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []FeedUploadError `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil +} + +// RequestFeedUploadErrorReport triggers generation of a full error report. +func (cm *CatalogManager) RequestFeedUploadErrorReport(uploadId string) (bool, error) { + apiPath := strings.Join([]string{uploadId, "error_report"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + response, err := apiRequest.Execute() + if err != nil { + return false, err + } + var res struct{ + Success bool `json:"success"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return false, err + } + return res.Success, nil +} + +// GetFeedUploadErrorReport fetches the error_report field of an upload session. +func (cm *CatalogManager) GetFeedUploadErrorReport(uploadId string) (*FeedUploadErrorReportResponse, error) { + apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) + apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadErrorReportResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// ProductFeedSchedule represents schedule config for Google Sheets or hosted feeds. +type ProductFeedSchedule struct { + Url string `json:"url"` + Interval string `json:"interval"` // e.g., HOURLY, DAILY; Meta specific values + Hour *int `json:"hour,omitempty"` +} + +// CreateScheduledProductFeed creates a scheduled feed that fetches from a URL (Google Sheets supported via shareable link). +// When updateOnly is true, the feed behaves in update-only mode. +// ingestionSourceType: "PRIMARY_FEED" or "SUPPLEMENTARY_FEED" (optional) +// primaryFeedIds required for Supplementary feeds. +func (cm *CatalogManager) CreateScheduledProductFeed( + catalogId string, + name string, + schedule ProductFeedSchedule, + updateOnly bool, + ingestionSourceType string, + primaryFeedIds []string, +) (*ProductFeed, error) { + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]interface{}{ + "name": name, + "schedule": schedule, + "update_only": updateOnly, + } + if ingestionSourceType != "" { + body["ingestion_source_type"] = ingestionSourceType + } + if len(primaryFeedIds) > 0 { + body["primary_feed_ids"] = primaryFeedIds + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductFeed + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// UpsertProductItem updates or creates a product item using Meta’s format. +// fields should include at least retailer_id, name, price, currency, image_url, availability, etc. +func (cm *CatalogManager) UpsertProductItem(catalogId string, fields map[string]interface{}) (*ProductItem, error) { + apiPath := strings.Join([]string{catalogId, "products"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + payload, _ := json.Marshal(fields) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductItem + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + +// BatchUpsertProductItems performs multiple upserts sequentially. +// Returns the successfully upserted items and a map of index->error for failures. +func (cm *CatalogManager) BatchUpsertProductItems(catalogId string, items []map[string]interface{}) ([]ProductItem, map[int]error) { + var results []ProductItem + errs := make(map[int]error) + for i, fields := range items { + item, err := cm.UpsertProductItem(catalogId, fields) + if err != nil { + errs[i] = err + continue + } + results = append(results, *item) + } + return results, errs +} + +// UpdateProductImages updates image_url and additional_image_urls for a retailer_id. +func (cm *CatalogManager) UpdateProductImages(catalogId, retailerId, imageURL string, additionalImageURLs []string) (*ProductItem, error) { + fields := map[string]interface{}{ + "retailer_id": retailerId, + "image_url": imageURL, + "additional_image_urls": additionalImageURLs, + } + return cm.UpsertProductItem(catalogId, fields) } diff --git a/manager/template_manager.go b/manager/template_manager.go index df6b535..7cc1d61 100644 --- a/manager/template_manager.go +++ b/manager/template_manager.go @@ -1,12 +1,13 @@ package manager import ( - "encoding/json" - "net/http" - "strings" + "encoding/json" + "fmt" + "net/http" + "strings" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "github.com/wapikit/wapi.go/internal" + "github.com/wapikit/wapi.go/internal/request_client" ) // MessageTemplateStatus represents the status of a WhatsApp Business message template. @@ -232,11 +233,33 @@ type WhatsappMessageTemplateButtonCreateRequestBodyAlias = WhatsappMessageTempla // WhatsappMessageTemplateComponentCreateOrUpdateRequestBody represents the request body for creating/updating a component. type WhatsappMessageTemplateComponentCreateOrUpdateRequestBody struct { - Type MessageTemplateComponentType `json:"type,omitempty"` - Format MessageTemplateComponentFormat `json:"format,omitempty"` - Text string `json:"text,omitempty"` - Buttons []WhatsappMessageTemplateButtonCreateRequestBody `json:"buttons,omitempty"` - Example *TemplateMessageComponentExample `json:"example,omitempty"` + Type MessageTemplateComponentType `json:"type,omitempty"` + Format MessageTemplateComponentFormat `json:"format,omitempty"` + Text string `json:"text,omitempty"` + Buttons []WhatsappMessageTemplateButtonCreateRequestBody `json:"buttons,omitempty"` + Example *TemplateMessageComponentExample `json:"example,omitempty"` +} + +// validateCatalogAndMPMButtons ensures CATALOG and MPM buttons have required params +// and disallow unsupported fields. +func validateCatalogAndMPMButtons(components []WhatsappMessageTemplateComponentCreateOrUpdateRequestBody) error { + for _, c := range components { + if c.Type != MessageTemplateComponentTypeButtons { + continue + } + for _, b := range c.Buttons { + switch TemplateMessageButtonType(b.Type) { + case TemplateMessageButtonTypeCatalog, TemplateMessageButtonTypeMultiProductMessage: + if b.Text == "" { + return fmt.Errorf("template button of type %s requires non-empty text", b.Type) + } + // URL and phone_number are not relevant for catalog/mpm; ignore if present + default: + // no-op + } + } + } + return nil } // WhatsappMessageTemplateCreateRequestBody represents the request body for creating a message template. @@ -264,11 +287,15 @@ type MessageTemplateCreationResponse struct { // Create sends a creation request for a message template. func (manager *TemplateManager) Create(body WhatsappMessageTemplateCreateRequestBody) (*MessageTemplateCreationResponse, error) { - apiRequest := manager.requester.NewApiRequest(strings.Join([]string{manager.businessAccountId, "/", "message_templates"}, ""), http.MethodPost) - jsonBody, err := json.Marshal(body) - if err != nil { - return nil, err - } + // Pre-validate catalog and multi-product message buttons + if err := validateCatalogAndMPMButtons(body.Components); err != nil { + return nil, err + } + apiRequest := manager.requester.NewApiRequest(strings.Join([]string{manager.businessAccountId, "/", "message_templates"}, ""), http.MethodPost) + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, err + } apiRequest.SetBody(string(jsonBody)) response, err := apiRequest.Execute() if err != nil { @@ -289,11 +316,17 @@ type WhatsAppBusinessAccountMessageTemplateUpdateRequestBody struct { // Update sends an update request for a template. func (manager *TemplateManager) Update(templateId string, updates WhatsAppBusinessAccountMessageTemplateUpdateRequestBody) (*MessageTemplateCreationResponse, error) { - apiRequest := manager.requester.NewApiRequest(strings.Join([]string{templateId}, ""), http.MethodPost) - jsonBody, err := json.Marshal(updates) - if err != nil { - return nil, err - } + // Pre-validate catalog and multi-product message buttons + if len(updates.Components) > 0 { + if err := validateCatalogAndMPMButtons(updates.Components); err != nil { + return nil, err + } + } + apiRequest := manager.requester.NewApiRequest(strings.Join([]string{templateId}, ""), http.MethodPost) + jsonBody, err := json.Marshal(updates) + if err != nil { + return nil, err + } apiRequest.SetBody(string(jsonBody)) response, err := apiRequest.Execute() if err != nil { diff --git a/pkg/business/client.go b/pkg/business/client.go index 95c8dc1..35a4f01 100644 --- a/pkg/business/client.go +++ b/pkg/business/client.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" "net/http" - "strings" + _ "strings" "time" "github.com/wapikit/wapi.go/internal" @@ -151,15 +151,24 @@ func (client *BusinessClient) FetchAnalytics(options AccountAnalyticsOptions) (W analyticsField.AddFilter("granularity", string(options.Granularity)) if len(options.PhoneNumbers) > 0 { - // get specific phone numbers - analyticsField.AddFilter("phone_numbers", strings.Join(options.PhoneNumbers, ",")) + // Pass as JSON array literal per Graph API (e.g., ["123","456"]) + if b, err := json.Marshal(options.PhoneNumbers); err == nil { + analyticsField.AddFilter("phone_numbers", string(b)) + } else { + // Fallback to empty (all) + analyticsField.AddFilter("phone_numbers", "[]") + } } else { // get all phone numbers analyticsField.AddFilter("phone_numbers", "[]") } if len(options.CountryCodes) > 0 { - analyticsField.AddFilter("country_codes", strings.Join(options.CountryCodes, ",")) + if b, err := json.Marshal(options.CountryCodes); err == nil { + analyticsField.AddFilter("country_codes", string(b)) + } else { + analyticsField.AddFilter("country_codes", "[]") + } } else { // get all country codes analyticsField.AddFilter("country_codes", "[]") @@ -222,22 +231,23 @@ type ConversationAnalyticsOptions struct { Granularity ConversationAnalyticsGranularityType `json:"granularity" validate:"required"` PhoneNumbers []string `json:"phone_numbers,omitempty"` - ConversationCategory []ConversationCategoryType `json:"conversation_category,omitempty"` - ConversationTypes []ConversationCategoryType `json:"conversation_types,omitempty"` - ConversationDirection []ConversationDirection `json:"conversation_direction,omitempty"` + // Use plural filter names to align with Graph API + ConversationCategory []ConversationCategoryType `json:"conversation_categories,omitempty"` + ConversationTypes []ConversationType `json:"conversation_types,omitempty"` + ConversationDirection []ConversationDirection `json:"conversation_directions,omitempty"` Dimensions []ConversationDimensionType `json:"dimensions,omitempty"` } type WhatsAppConversationAnalyticsNode struct { - Start int `json:"start" validate:"required"` - End int `json:"end,omitempty" validate:"required"` - Conversation int `json:"conversation,omitempty"` - PhoneNumber string `json:"phone_number,omitempty"` - Country string `json:"country,omitempty"` - ConversationType string `json:"conversation_type,omitempty"` - ConversationDirection string `json:"conversation_direction,omitempty"` - ConversationCategory string `json:"conversation_category,omitempty"` - Cost int `json:"cost,omitempty"` + Start int `json:"start" validate:"required"` + End int `json:"end,omitempty" validate:"required"` + Conversation int `json:"conversation,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + Country string `json:"country,omitempty"` + ConversationType string `json:"conversation_type,omitempty"` + ConversationDirection string `json:"conversation_direction,omitempty"` + ConversationCategory string `json:"conversation_category,omitempty"` + Cost float64 `json:"cost,omitempty"` } type WhatsAppConversationAnalyticsEdge struct { @@ -263,8 +273,12 @@ func (client *BusinessClient) ConversationAnalytics(options ConversationAnalytic analyticsField.AddFilter("granularity", string(options.Granularity)) if len(options.PhoneNumbers) > 0 { - // get specific phone numbers - analyticsField.AddFilter("phone_numbers", strings.Join(options.PhoneNumbers, ",")) + // Pass as JSON array literal per Graph API + if b, err := json.Marshal(options.PhoneNumbers); err == nil { + analyticsField.AddFilter("phone_numbers", string(b)) + } else { + analyticsField.AddFilter("phone_numbers", "[]") + } } else { // get all phone numbers analyticsField.AddFilter("phone_numbers", "[]") @@ -275,9 +289,13 @@ func (client *BusinessClient) ConversationAnalytics(options ConversationAnalytic for i, category := range options.ConversationCategory { categoryStrings[i] = string(category) } - analyticsField.AddFilter("conversation_category", strings.Join(categoryStrings, ",")) + if b, err := json.Marshal(categoryStrings); err == nil { + analyticsField.AddFilter("conversation_categories", string(b)) + } else { + analyticsField.AddFilter("conversation_categories", "[]") + } } else { - analyticsField.AddFilter("conversation_category", "[]") // Empty slice + analyticsField.AddFilter("conversation_categories", "[]") // Empty slice } if len(options.ConversationTypes) > 0 { @@ -285,7 +303,11 @@ func (client *BusinessClient) ConversationAnalytics(options ConversationAnalytic for i, ctype := range options.ConversationTypes { typeStrings[i] = string(ctype) } - analyticsField.AddFilter("conversation_types", strings.Join(typeStrings, ",")) + if b, err := json.Marshal(typeStrings); err == nil { + analyticsField.AddFilter("conversation_types", string(b)) + } else { + analyticsField.AddFilter("conversation_types", "[]") + } } else { analyticsField.AddFilter("conversation_types", "[]") // Empty slice } @@ -295,9 +317,13 @@ func (client *BusinessClient) ConversationAnalytics(options ConversationAnalytic for i, direction := range options.ConversationDirection { directionStrings[i] = string(direction) } - analyticsField.AddFilter("conversation_direction", strings.Join(directionStrings, ",")) + if b, err := json.Marshal(directionStrings); err == nil { + analyticsField.AddFilter("conversation_directions", string(b)) + } else { + analyticsField.AddFilter("conversation_directions", "[]") + } } else { - analyticsField.AddFilter("conversation_direction", "[]") // Empty slice + analyticsField.AddFilter("conversation_directions", "[]") // Empty slice } if len(options.Dimensions) > 0 { @@ -305,9 +331,12 @@ func (client *BusinessClient) ConversationAnalytics(options ConversationAnalytic for i, dim := range options.Dimensions { dimensionsStrings[i] = string(dim) } - analyticsField.AddFilter("dimensions", strings.Join(dimensionsStrings, ",")) + if b, err := json.Marshal(dimensionsStrings); err == nil { + analyticsField.AddFilter("dimensions", string(b)) + } else { + analyticsField.AddFilter("dimensions", "[]") + } } else { - // get all country codes analyticsField.AddFilter("dimensions", "[]") } diff --git a/pkg/components/catalog_message.go b/pkg/components/catalog_message.go index 53c1276..a7de553 100644 --- a/pkg/components/catalog_message.go +++ b/pkg/components/catalog_message.go @@ -1,10 +1,10 @@ package components import ( - "encoding/json" - "fmt" + "encoding/json" + "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/wapikit/wapi.go/internal" ) type CatalogMessageActionParameter struct { @@ -38,15 +38,21 @@ type CatalogMessage struct { } func NewCatalogMessage(name, thumbnailProductRetailerId string) (*CatalogMessage, error) { - return &CatalogMessage{ - Type: InteractiveMessageTypeCatalog, - Action: CatalogMessageAction{ - Name: name, - Parameters: CatalogMessageActionParameter{ - ThumbnailProductRetailerId: thumbnailProductRetailerId, - }, - }, - }, nil + if thumbnailProductRetailerId == "" { + return nil, fmt.Errorf("thumbnail_product_retailer_id is required for catalog_message") + } + if name == "" { + name = "catalog_message" + } + return &CatalogMessage{ + Type: InteractiveMessageTypeCatalog, + Action: CatalogMessageAction{ + Name: name, + Parameters: CatalogMessageActionParameter{ + ThumbnailProductRetailerId: thumbnailProductRetailerId, + }, + }, + }, nil } func (m *CatalogMessage) SetHeader(text string) { @@ -75,9 +81,13 @@ type CatalogMessageApiPayload struct { // ToJson converts the product message to JSON with the given configurations. func (m *CatalogMessage) ToJson(configs ApiCompatibleJsonConverterConfigs) ([]byte, error) { - if err := internal.GetValidator().Struct(configs); err != nil { - return nil, fmt.Errorf("error validating configs: %v", err) - } + if err := internal.GetValidator().Struct(configs); err != nil { + return nil, fmt.Errorf("error validating configs: %v", err) + } + // Validate message structure and required fields as well + if err := internal.GetValidator().Struct(m); err != nil { + return nil, fmt.Errorf("error validating catalog message: %v", err) + } jsonData := CatalogMessageApiPayload{ BaseMessagePayload: NewBaseMessagePayload(configs.SendToPhoneNumber, MessageTypeInteractive), @@ -90,7 +100,7 @@ func (m *CatalogMessage) ToJson(configs ApiCompatibleJsonConverterConfigs) ([]by } } - jsonToReturn, err := json.Marshal(jsonData) + jsonToReturn, err := json.Marshal(jsonData) if err != nil { return nil, fmt.Errorf("error marshalling json: %v", err) diff --git a/pkg/components/product_list_message.go b/pkg/components/product_list_message.go index d99be18..1e48f75 100644 --- a/pkg/components/product_list_message.go +++ b/pkg/components/product_list_message.go @@ -29,9 +29,8 @@ func (ps *ProductSection) AddProduct(product Product) { } type ProductListMessageAction struct { - Sections []ProductSection `json:"sections" validate:"required"` // minimum 1 and maximum 10 - CatalogId string `json:"catalog_id" validate:"required"` - ProductRetailerId string `json:"product_retailer_id" validate:"required"` + Sections []ProductSection `json:"sections" validate:"required"` // minimum 1 and maximum 10 + CatalogId string `json:"catalog_id" validate:"required"` } func (a *ProductListMessageAction) AddSection(section ProductSection) { @@ -82,8 +81,10 @@ func (message *ProductListMessage) SetCatalogId(catalogId string) { message.Action.CatalogId = catalogId } +// SetProductRetailerId is deprecated. Product retailer IDs belong to each item. +// This method is kept for backward compatibility and is now a no-op. func (message *ProductListMessage) SetProductRetailerId(productRetailerId string) { - message.Action.ProductRetailerId = productRetailerId + // no-op } func (message *ProductListMessage) SetFooter(text string) { @@ -101,10 +102,11 @@ func (message *ProductListMessage) SetHeader(text string) { // ProductListMessageParams represents the parameters for creating a product list message. type ProductListMessageParams struct { - CatalogId string `validate:"required"` - ProductRetailerId string `validate:"required"` - BodyText string `validate:"required"` - Sections []ProductSection + CatalogId string `validate:"required"` + // Deprecated: action-level product_retailer_id. Use item-level IDs in sections. + ProductRetailerId string + BodyText string `validate:"required"` + Sections []ProductSection } // ProductListMessageApiPayload represents the API payload for a product list message. @@ -119,24 +121,36 @@ func NewProductListMessage(params ProductListMessageParams) (*ProductListMessage return nil, fmt.Errorf("error validating configs: %v", err) } - return &ProductListMessage{ - Type: InteractiveMessageTypeProductList, - Body: ProductListMessageBody{ - Text: params.BodyText, - }, - Action: ProductListMessageAction{ - CatalogId: params.CatalogId, - ProductRetailerId: params.ProductRetailerId, - Sections: params.Sections, - }, - }, nil + return &ProductListMessage{ + Type: InteractiveMessageTypeProductList, + Body: ProductListMessageBody{ + Text: params.BodyText, + }, + Action: ProductListMessageAction{ + CatalogId: params.CatalogId, + Sections: params.Sections, + }, + }, nil } // ToJson converts the product list message to JSON. func (m *ProductListMessage) ToJson(configs ApiCompatibleJsonConverterConfigs) ([]byte, error) { - if err := internal.GetValidator().Struct(configs); err != nil { - return nil, fmt.Errorf("error validating configs: %v", err) - } + if err := internal.GetValidator().Struct(configs); err != nil { + return nil, fmt.Errorf("error validating configs: %v", err) + } + // Validate message structure and section/item limits + if err := internal.GetValidator().Struct(m); err != nil { + return nil, fmt.Errorf("error validating product list message: %v", err) + } + // Enforce Meta limits: ≤10 sections, ≤30 items per section + if len(m.Action.Sections) == 0 || len(m.Action.Sections) > 10 { + return nil, fmt.Errorf("product_list must contain between 1 and 10 sections") + } + for i, s := range m.Action.Sections { + if len(s.Products) == 0 || len(s.Products) > 30 { + return nil, fmt.Errorf("section %d must contain between 1 and 30 products", i) + } + } jsonData := ProductListMessageApiPayload{ BaseMessagePayload: NewBaseMessagePayload(configs.SendToPhoneNumber, MessageTypeInteractive), diff --git a/pkg/components/template_message.go b/pkg/components/template_message.go index 7169e22..5e9badc 100644 --- a/pkg/components/template_message.go +++ b/pkg/components/template_message.go @@ -183,8 +183,11 @@ type TemplateMessageMultiProductButtonActionParameterSection struct { ProductItems []TemplateMessageMultiProductButtonActionParameterProductItem `json:"product_items" validate:"required"` } +// TemplateMessageButtonParameterAction represents the action parameters for catalog buttons. +// Per WhatsApp API docs, thumbnail_product_retailer_id is optional - if omitted, WhatsApp +// automatically uses the first product in the catalog as the thumbnail. type TemplateMessageButtonParameterAction struct { - ThumbnailProductRetailerId string `json:"thumbnail_product_retailer_id" validate:"required"` + ThumbnailProductRetailerId string `json:"thumbnail_product_retailer_id,omitempty"` Sections *[]TemplateMessageMultiProductButtonActionParameterSection `json:"sections,omitempty"` // Required for MPM buttons. UPTO 10 sections in a buttons parameter } @@ -324,5 +327,8 @@ func (m *TemplateMessage) ToJson(configs ApiCompatibleJsonConverterConfigs) ([]b return nil, fmt.Errorf("error marshalling json: %v", err) } + // Debug logging: print the actual JSON being sent + fmt.Printf("[DEBUG] Template Message JSON: %s\n", string(jsonToReturn)) + return jsonToReturn, nil } From bd0eebe75f947f601b0f70f47de0f824023a8fa9 Mon Sep 17 00:00:00 2001 From: byte-rose Date: Mon, 13 Oct 2025 12:32:31 +0300 Subject: [PATCH 2/6] fix(manager): add omitempty to FeedUpload.LastUpdated and gofmt - Add omitempty to last_updated_time tag to omit empty values in JSON, reducing noise and aligning with API expectations. - Apply gofmt: normalize imports and indentation (tabs) across catalog_manager.go. - No behavioral changes to request/response handling. --- manager/catalog_manager.go | 516 +++++++++++++------------ pkg/business/client.go | 2 +- pkg/components/product_list_message.go | 22 +- 3 files changed, 278 insertions(+), 262 deletions(-) diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index 3480d23..b9c5001 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -1,18 +1,18 @@ package manager import ( - "bytes" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/textproto" - "path/filepath" - "strings" - - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "path/filepath" + "strings" + + "github.com/wapikit/wapi.go/internal" + "github.com/wapikit/wapi.go/internal/request_client" ) type CatalogManager struct { @@ -63,61 +63,61 @@ type ProductGroup struct { } type ProductFeed struct { - Id string `json:"id"` - FileName string `json:"file_name"` - Name string `json:"name"` + Id string `json:"id"` + FileName string `json:"file_name"` + Name string `json:"name"` } // FeedUpload represents a single upload attempt and its status/diagnostics. type FeedUpload struct { - Id string `json:"id,omitempty"` - Status string `json:"status,omitempty"` - Errors []string `json:"errors,omitempty"` - ErrorMessage string `json:"error_message,omitempty"` - CreatedTime string `json:"created_time,omitempty"` - LastUpdated string `json:"last_updated_time,omitempty"` + Id string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Errors []string `json:"errors,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + CreatedTime string `json:"created_time,omitempty"` + LastUpdated string `json:"last_updated_time,omitempty"` } // FeedUploadResponse wraps basic responses for CSV upload operations. type FeedUploadResponse struct { - Id string `json:"id,omitempty"` - Status string `json:"status,omitempty"` + Id string `json:"id,omitempty"` + Status string `json:"status,omitempty"` } // FeedUploadSession represents an upload session listing entry (start/end time). type FeedUploadSession struct { - Id string `json:"id"` - StartTime string `json:"start_time,omitempty"` - EndTime string `json:"end_time,omitempty"` + Id string `json:"id"` + StartTime string `json:"start_time,omitempty"` + EndTime string `json:"end_time,omitempty"` } // FeedUploadErrorSample represents a row sample in an error entry. type FeedUploadErrorSample struct { - RowNumber int `json:"row_number"` - RetailerId string `json:"retailer_id"` - Id string `json:"id"` + RowNumber int `json:"row_number"` + RetailerId string `json:"retailer_id"` + Id string `json:"id"` } // FeedUploadError represents an individual ingestion error/warning. type FeedUploadError struct { - Id string `json:"id"` - Summary string `json:"summary"` - Description string `json:"description"` - Severity string `json:"severity"` // fatal | warning - Samples struct { - Data []FeedUploadErrorSample `json:"data"` - } `json:"samples"` + Id string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description"` + Severity string `json:"severity"` // fatal | warning + Samples struct { + Data []FeedUploadErrorSample `json:"data"` + } `json:"samples"` } // FeedUploadErrorReport represents the generated error report metadata. type FeedUploadErrorReport struct { - ReportStatus string `json:"report_status,omitempty"` - FileHandle string `json:"file_handle,omitempty"` + ReportStatus string `json:"report_status,omitempty"` + FileHandle string `json:"file_handle,omitempty"` } type FeedUploadErrorReportResponse struct { - ErrorReport FeedUploadErrorReport `json:"error_report,omitempty"` - Id string `json:"id,omitempty"` + ErrorReport FeedUploadErrorReport `json:"error_report,omitempty"` + Id string `json:"id,omitempty"` } type ProductError struct { @@ -401,26 +401,33 @@ type CreateProductCatalogOptions struct { func (cm *CatalogManager) CreateNewProductCatalog() (CreateProductCatalogOptions, error) { apiRequest := cm.requester.NewApiRequest(strings.Join([]string{cm.businessAccountId, "product_catalogs"}, "/"), http.MethodPost) response, err := apiRequest.Execute() + if err != nil { + // Return immediately on execution error; do not attempt to decode + return CreateProductCatalogOptions{}, fmt.Errorf("create catalog request failed: %w", err) + } var responseToReturn CreateProductCatalogOptions - json.Unmarshal([]byte(response), &responseToReturn) - return responseToReturn, err + if err := json.Unmarshal([]byte(response), &responseToReturn); err != nil { + // Return zero-value options with decoding context for callers + return CreateProductCatalogOptions{}, fmt.Errorf("decode create catalog response failed: %w", err) + } + return responseToReturn, nil } // ListProductFeeds lists product feeds for a given catalog. func (cm *CatalogManager) ListProductFeeds(catalogId string) ([]ProductFeed, error) { - apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res struct { - Data []ProductFeed `json:"data"` - } - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return res.Data, nil + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []ProductFeed `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil } // CreateProductFeed creates a product feed for CSV ingestion. Meta format is assumed. @@ -428,175 +435,180 @@ func (cm *CatalogManager) ListProductFeeds(catalogId string) ([]ProductFeed, err // fileFormat: e.g., "CSV" // fileName: default file name reference (optional) func (cm *CatalogManager) CreateProductFeed(catalogId, name, fileFormat, fileName string) (*ProductFeed, error) { - apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - body := map[string]string{ - "name": name, - "file_format": fileFormat, - } - if fileName != "" { - body["file_name"] = fileName - } - payload, _ := json.Marshal(body) - apiRequest.SetBody(string(payload)) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res ProductFeed - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]string{ + "name": name, + "file_format": fileFormat, + } + if fileName != "" { + body["file_name"] = fileName + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductFeed + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // UploadFeedCSV uploads a CSV file to a product feed using multipart/form-data. func (cm *CatalogManager) UploadFeedCSV(feedId string, file io.Reader, filename, mimeType string, updateOnly bool) (*FeedUploadResponse, error) { - // Prepare multipart body with update_only and a single file part - bodyBuf := new(bytes.Buffer) - writer := multipart.NewWriter(bodyBuf) - // update_only as string field - if err := writer.WriteField("update_only", func() string { if updateOnly { return "true" } ; return "false" }()); err != nil { - return nil, fmt.Errorf("failed to write update_only: %w", err) - } - // file part - partHeader := make(textproto.MIMEHeader) - partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filepath.Base(filename))) - partHeader.Set("Content-Type", mimeType) - filePart, err := writer.CreatePart(partHeader) - if err != nil { - return nil, fmt.Errorf("failed to create multipart part: %w", err) - } - if _, err := io.Copy(filePart, file); err != nil { - return nil, fmt.Errorf("failed to copy csv into part: %w", err) - } - if err := writer.Close(); err != nil { - return nil, fmt.Errorf("failed to close writer: %w", err) - } + // Prepare multipart body with update_only and a single file part + bodyBuf := new(bytes.Buffer) + writer := multipart.NewWriter(bodyBuf) + // update_only as string field + if err := writer.WriteField("update_only", func() string { + if updateOnly { + return "true" + } + return "false" + }()); err != nil { + return nil, fmt.Errorf("failed to write update_only: %w", err) + } + // file part + partHeader := make(textproto.MIMEHeader) + partHeader.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filepath.Base(filename))) + partHeader.Set("Content-Type", mimeType) + filePart, err := writer.CreatePart(partHeader) + if err != nil { + return nil, fmt.Errorf("failed to create multipart part: %w", err) + } + if _, err := io.Copy(filePart, file); err != nil { + return nil, fmt.Errorf("failed to copy csv into part: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close writer: %w", err) + } - apiPath := strings.Join([]string{feedId, "uploads"}, "/") - contentType := writer.FormDataContentType() - responseBody, err := cm.requester.RequestMultipart(http.MethodPost, apiPath, bodyBuf, contentType) - if err != nil { - return nil, fmt.Errorf("error uploading CSV: %w", err) - } + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + contentType := writer.FormDataContentType() + responseBody, err := cm.requester.RequestMultipart(http.MethodPost, apiPath, bodyBuf, contentType) + if err != nil { + return nil, fmt.Errorf("error uploading CSV: %w", err) + } - var res FeedUploadResponse - if err := json.Unmarshal([]byte(responseBody), &res); err != nil { - return nil, fmt.Errorf("failed to parse upload response: %w", err) - } - return &res, nil + var res FeedUploadResponse + if err := json.Unmarshal([]byte(responseBody), &res); err != nil { + return nil, fmt.Errorf("failed to parse upload response: %w", err) + } + return &res, nil } // UploadFeedCSVFromURL triggers a feed ingestion from a hosted CSV URL. func (cm *CatalogManager) UploadFeedCSVFromURL(feedId, csvURL string, updateOnly bool) (*FeedUploadResponse, error) { - apiPath := strings.Join([]string{feedId, "uploads"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - body := map[string]interface{}{ - // Meta docs show using 'url' for hosted feed uploads - "url": csvURL, - "update_only": updateOnly, - } - payload, _ := json.Marshal(body) - apiRequest.SetBody(string(payload)) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res FeedUploadResponse - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]interface{}{ + // Meta docs show using 'url' for hosted feed uploads + "url": csvURL, + "update_only": updateOnly, + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // ListFeedUploads lists uploads for a product feed. func (cm *CatalogManager) ListFeedUploads(feedId string) ([]FeedUploadSession, error) { - apiPath := strings.Join([]string{feedId, "uploads"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res struct { - Data []FeedUploadSession `json:"data"` - } - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return res.Data, nil + apiPath := strings.Join([]string{feedId, "uploads"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []FeedUploadSession `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil } // GetFeedUploadStatus fetches a single upload’s status/diagnostics. func (cm *CatalogManager) GetFeedUploadStatus(uploadId string) (*FeedUploadErrorReportResponse, error) { - apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) - // include error_report field for convenience - apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res FeedUploadErrorReportResponse - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) + // include error_report field for convenience + apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadErrorReportResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // GetFeedUploadErrors fetches a sampling of errors/warnings for an upload session. func (cm *CatalogManager) GetFeedUploadErrors(uploadId string) ([]FeedUploadError, error) { - apiPath := strings.Join([]string{uploadId, "errors"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res struct { - Data []FeedUploadError `json:"data"` - } - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return res.Data, nil + apiPath := strings.Join([]string{uploadId, "errors"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodGet) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res struct { + Data []FeedUploadError `json:"data"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return res.Data, nil } // RequestFeedUploadErrorReport triggers generation of a full error report. func (cm *CatalogManager) RequestFeedUploadErrorReport(uploadId string) (bool, error) { - apiPath := strings.Join([]string{uploadId, "error_report"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - response, err := apiRequest.Execute() - if err != nil { - return false, err - } - var res struct{ - Success bool `json:"success"` - } - if err := json.Unmarshal([]byte(response), &res); err != nil { - return false, err - } - return res.Success, nil + apiPath := strings.Join([]string{uploadId, "error_report"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + response, err := apiRequest.Execute() + if err != nil { + return false, err + } + var res struct { + Success bool `json:"success"` + } + if err := json.Unmarshal([]byte(response), &res); err != nil { + return false, err + } + return res.Success, nil } // GetFeedUploadErrorReport fetches the error_report field of an upload session. func (cm *CatalogManager) GetFeedUploadErrorReport(uploadId string) (*FeedUploadErrorReportResponse, error) { - apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) - apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res FeedUploadErrorReportResponse - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiRequest := cm.requester.NewApiRequest(uploadId, http.MethodGet) + apiRequest.AddField(request_client.ApiRequestQueryParamField{Name: "error_report", Filters: map[string]string{}}) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res FeedUploadErrorReportResponse + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // ProductFeedSchedule represents schedule config for Google Sheets or hosted feeds. type ProductFeedSchedule struct { - Url string `json:"url"` - Interval string `json:"interval"` // e.g., HOURLY, DAILY; Meta specific values - Hour *int `json:"hour,omitempty"` + Url string `json:"url"` + Interval string `json:"interval"` // e.g., HOURLY, DAILY; Meta specific values + Hour *int `json:"hour,omitempty"` } // CreateScheduledProductFeed creates a scheduled feed that fetches from a URL (Google Sheets supported via shareable link). @@ -604,79 +616,83 @@ type ProductFeedSchedule struct { // ingestionSourceType: "PRIMARY_FEED" or "SUPPLEMENTARY_FEED" (optional) // primaryFeedIds required for Supplementary feeds. func (cm *CatalogManager) CreateScheduledProductFeed( - catalogId string, - name string, - schedule ProductFeedSchedule, - updateOnly bool, - ingestionSourceType string, - primaryFeedIds []string, + catalogId string, + name string, + schedule ProductFeedSchedule, + updateOnly bool, + ingestionSourceType string, + primaryFeedIds []string, ) (*ProductFeed, error) { - apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - body := map[string]interface{}{ - "name": name, - "schedule": schedule, - "update_only": updateOnly, - } - if ingestionSourceType != "" { - body["ingestion_source_type"] = ingestionSourceType - } - if len(primaryFeedIds) > 0 { - body["primary_feed_ids"] = primaryFeedIds - } - payload, _ := json.Marshal(body) - apiRequest.SetBody(string(payload)) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res ProductFeed - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]interface{}{ + "name": name, + "schedule": schedule, + "update_only": updateOnly, + } + if ingestionSourceType != "" { + body["ingestion_source_type"] = ingestionSourceType + } + if len(primaryFeedIds) > 0 { + body["primary_feed_ids"] = primaryFeedIds + } + payload, _ := json.Marshal(body) + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductFeed + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // UpsertProductItem updates or creates a product item using Meta’s format. // fields should include at least retailer_id, name, price, currency, image_url, availability, etc. func (cm *CatalogManager) UpsertProductItem(catalogId string, fields map[string]interface{}) (*ProductItem, error) { - apiPath := strings.Join([]string{catalogId, "products"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - payload, _ := json.Marshal(fields) - apiRequest.SetBody(string(payload)) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res ProductItem - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil + apiPath := strings.Join([]string{catalogId, "products"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + payload, err := json.Marshal(fields) + if err != nil { + return nil, fmt.Errorf("failed to marshall product fields: %W", err) + + } + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductItem + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil } // BatchUpsertProductItems performs multiple upserts sequentially. // Returns the successfully upserted items and a map of index->error for failures. func (cm *CatalogManager) BatchUpsertProductItems(catalogId string, items []map[string]interface{}) ([]ProductItem, map[int]error) { - var results []ProductItem - errs := make(map[int]error) - for i, fields := range items { - item, err := cm.UpsertProductItem(catalogId, fields) - if err != nil { - errs[i] = err - continue - } - results = append(results, *item) - } - return results, errs + var results []ProductItem + errs := make(map[int]error) + for i, fields := range items { + item, err := cm.UpsertProductItem(catalogId, fields) + if err != nil { + errs[i] = err + continue + } + results = append(results, *item) + } + return results, errs } // UpdateProductImages updates image_url and additional_image_urls for a retailer_id. func (cm *CatalogManager) UpdateProductImages(catalogId, retailerId, imageURL string, additionalImageURLs []string) (*ProductItem, error) { - fields := map[string]interface{}{ - "retailer_id": retailerId, - "image_url": imageURL, - "additional_image_urls": additionalImageURLs, - } - return cm.UpsertProductItem(catalogId, fields) + fields := map[string]interface{}{ + "retailer_id": retailerId, + "image_url": imageURL, + "additional_image_urls": additionalImageURLs, + } + return cm.UpsertProductItem(catalogId, fields) } diff --git a/pkg/business/client.go b/pkg/business/client.go index 35a4f01..7efe7f3 100644 --- a/pkg/business/client.go +++ b/pkg/business/client.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" "net/http" - _ "strings" + "time" "github.com/wapikit/wapi.go/internal" diff --git a/pkg/components/product_list_message.go b/pkg/components/product_list_message.go index 1e48f75..6c10b84 100644 --- a/pkg/components/product_list_message.go +++ b/pkg/components/product_list_message.go @@ -53,17 +53,17 @@ const ( // ! TODO: support more header types type ProductListMessageHeader struct { - Type ProductListMessageHeaderType `json:"type" validate:"required"` - Text string `json:"text" validate:"required"` + Type ProductListMessageHeaderType `json:"type" validate:"required"` + Text string `json:"text" validate:"required"` } // ProductListMessage represents a product list message. type ProductListMessage struct { - Action ProductListMessageAction `json:"action" validate:"required"` - Body ProductListMessageBody `json:"body" validate:"required"` - Footer *ProductListMessageFooter `json:"footer,omitempty"` - Header ProductListMessageHeader `json:"header,omitempty"` - Type InteractiveMessageType `json:"type" validate:"required"` + Action ProductListMessageAction `json:"action" validate:"required"` + Body ProductListMessageBody `json:"body" validate:"required"` + Footer *ProductListMessageFooter `json:"footer,omitempty"` + Header *ProductListMessageHeader `json:"header,omitempty"` + Type InteractiveMessageType `json:"type" validate:"required"` } func (message *ProductListMessage) AddSection(section ProductSection) { @@ -94,10 +94,10 @@ func (message *ProductListMessage) SetFooter(text string) { } func (message *ProductListMessage) SetHeader(text string) { - message.Header = ProductListMessageHeader{ - Type: ProductListMessageHeaderTypeText, - Text: text, - } + message.Header = &ProductListMessageHeader{ + Type: ProductListMessageHeaderTypeText, + Text: text, + } } // ProductListMessageParams represents the parameters for creating a product list message. From 330c0ea914d41ee2df1a27caeac3c866e933a7ad Mon Sep 17 00:00:00 2001 From: byte-rose Date: Mon, 13 Oct 2025 12:42:32 +0300 Subject: [PATCH 3/6] updated json error handling on catalog creation and prouct uploads. --- manager/catalog_manager.go | 44 +++++++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index b9c5001..9935034 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -399,18 +399,18 @@ type CreateProductCatalogOptions struct { } func (cm *CatalogManager) CreateNewProductCatalog() (CreateProductCatalogOptions, error) { - apiRequest := cm.requester.NewApiRequest(strings.Join([]string{cm.businessAccountId, "product_catalogs"}, "/"), http.MethodPost) - response, err := apiRequest.Execute() - if err != nil { - // Return immediately on execution error; do not attempt to decode - return CreateProductCatalogOptions{}, fmt.Errorf("create catalog request failed: %w", err) - } - var responseToReturn CreateProductCatalogOptions - if err := json.Unmarshal([]byte(response), &responseToReturn); err != nil { - // Return zero-value options with decoding context for callers - return CreateProductCatalogOptions{}, fmt.Errorf("decode create catalog response failed: %w", err) - } - return responseToReturn, nil + apiRequest := cm.requester.NewApiRequest(strings.Join([]string{cm.businessAccountId, "product_catalogs"}, "/"), http.MethodPost) + response, err := apiRequest.Execute() + if err != nil { + // Return immediately on execution error; do not attempt to decode + return CreateProductCatalogOptions{}, fmt.Errorf("create catalog request failed: %w", err) + } + var responseToReturn CreateProductCatalogOptions + if err := json.Unmarshal([]byte(response), &responseToReturn); err != nil { + // Return zero-value options with decoding context for callers + return CreateProductCatalogOptions{}, fmt.Errorf("decode create catalog response failed: %w", err) + } + return responseToReturn, nil } // ListProductFeeds lists product feeds for a given catalog. @@ -430,7 +430,8 @@ func (cm *CatalogManager) ListProductFeeds(catalogId string) ([]ProductFeed, err return res.Data, nil } -// CreateProductFeed creates a product feed for CSV ingestion. Meta format is assumed. +// CreateProductFeed creates a product feed for CSV ingestion. We are using metas base accepted format, for more info check the meta docs +// meta whatsapp catalogs: https://developers.facebook.com/docs/commerce-platform/catalog/fields // name: Human-readable feed name // fileFormat: e.g., "CSV" // fileName: default file name reference (optional) @@ -444,7 +445,10 @@ func (cm *CatalogManager) CreateProductFeed(catalogId, name, fileFormat, fileNam if fileName != "" { body["file_name"] = fileName } - payload, _ := json.Marshal(body) + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal create feed body: %w", err) + } apiRequest.SetBody(string(payload)) response, err := apiRequest.Execute() if err != nil { @@ -509,7 +513,10 @@ func (cm *CatalogManager) UploadFeedCSVFromURL(feedId, csvURL string, updateOnly "url": csvURL, "update_only": updateOnly, } - payload, _ := json.Marshal(body) + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal hosted feed body: %w", err) + } apiRequest.SetBody(string(payload)) response, err := apiRequest.Execute() if err != nil { @@ -636,7 +643,10 @@ func (cm *CatalogManager) CreateScheduledProductFeed( if len(primaryFeedIds) > 0 { body["primary_feed_ids"] = primaryFeedIds } - payload, _ := json.Marshal(body) + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal scheduled feed body: %w", err) + } apiRequest.SetBody(string(payload)) response, err := apiRequest.Execute() if err != nil { @@ -656,7 +666,7 @@ func (cm *CatalogManager) UpsertProductItem(catalogId string, fields map[string] apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) payload, err := json.Marshal(fields) if err != nil { - return nil, fmt.Errorf("failed to marshall product fields: %W", err) + return nil, fmt.Errorf("failed to marshall product fields: %w", err) } apiRequest.SetBody(string(payload)) From 102f61e78044760ca464f490d2979d4c7acafb03 Mon Sep 17 00:00:00 2001 From: byte-rose Date: Mon, 13 Oct 2025 15:44:17 +0300 Subject: [PATCH 4/6] refactor: standardize module path to github.com/gTahidi/wapi.go --- README.md | 14 +++++++------- docs/api-reference/internal.mdx | 2 +- docs/api-reference/manager.mdx | 2 +- docs/api-reference/pkg/business.mdx | 2 +- docs/api-reference/pkg/client.mdx | 2 +- docs/api-reference/pkg/components.mdx | 2 +- docs/api-reference/pkg/events.mdx | 2 +- docs/api-reference/pkg/messaging.mdx | 2 +- .../building-message-components.mdx | 2 +- .../creating-application.mdx | 2 +- docs/guide/quickstart.mdx | 2 +- docs/mint.json | 8 ++++---- examples/chat-bot/main.go | 8 ++++---- examples/http-backend-integration/main.go | 6 +++--- go.mod | 2 +- manager/catalog_manager.go | 4 ++-- manager/event_manager.go | 2 +- manager/media_manager.go | 2 +- manager/message_manager.go | 4 ++-- manager/phone_number_manager.go | 4 ++-- manager/template_manager.go | 4 ++-- manager/webhook_manager.go | 8 ++++---- pkg/business/client.go | 6 +++--- pkg/client/client.go | 10 +++++----- pkg/components/audio_message.go | 2 +- pkg/components/catalog_message.go | 2 +- pkg/components/contact_message.go | 2 +- pkg/components/cta_message.go | 2 +- pkg/components/document_message.go | 2 +- pkg/components/image_message.go | 2 +- pkg/components/list_message.go | 2 +- pkg/components/location_message.go | 2 +- pkg/components/location_request_message.go | 2 +- pkg/components/product_list_message.go | 2 +- pkg/components/product_message.go | 2 +- pkg/components/quick_reply_button_message.go | 2 +- pkg/components/reaction_message.go | 2 +- pkg/components/sticker_message.go | 2 +- pkg/components/template_message.go | 2 +- pkg/components/text_message.go | 2 +- pkg/components/video_message.go | 2 +- pkg/events/audio_message_event.go | 2 +- pkg/events/base_event.go | 4 ++-- pkg/events/contacts_message_event.go | 2 +- pkg/events/document_message_event.go | 2 +- pkg/events/image_message_event.go | 2 +- pkg/events/location_message_event.go | 2 +- pkg/events/order_event.go | 2 +- pkg/events/reaction_event.go | 2 +- pkg/events/sticker_message_event.go | 2 +- pkg/events/video_message_event.go | 2 +- pkg/messaging/client.go | 4 ++-- 52 files changed, 81 insertions(+), 81 deletions(-) diff --git a/README.md b/README.md index e2b6c94..ad4bfb2 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Visit the documentation of the SDK [here](https://golang.wapikit.com) ## Status -Beta Version - This SDK is not stable right now. It is currently in beta version. Report issues [here](https://github.com/wapikit/wapi.go/issues). +Beta Version - This SDK is not stable right now. It is currently in beta version. Report issues [here](https://github.com/gTahidi/wapi.go/issues). This SDK is part of a technical suite built to support the WhatsApp Business Application Development ecosystem. This SDK also has a Node.js version, you can check it out [here](https://wapikit/wapi.js/js). @@ -40,7 +40,7 @@ This assumes you already have a working Go environment, if not please see `go get` _will always pull the latest tagged release from the master branch._ ```sh -go get github.com/wapikit/wapi.go +go get github.com/gTahidi/wapi.go ``` > Note: This SDK is not affiliated with the official WhatsApp Cloud API or does not act as any official solution provided the the Meta Inclusive Private Limited, this is just a open source SDK built for developers to support them in building whatsapp cloud api based chat bots easily. @@ -54,13 +54,13 @@ You can check out the example WhatsApp bot here. [Example Chatbot](./example-cha Import the package into your project. This repository has three packages exported: -- github.com/wapikit/wapi.go/components -- github.com/wapikit/wapi.go/wapi/wapi -- github.com/wapikit/wapi.go/wapi/business -- github.com/wapikit/wapi.go/wapi/events +- github.com/gTahidi/wapi.go/components +- github.com/gTahidi/wapi.go/wapi/wapi +- github.com/gTahidi/wapi.go/wapi/business +- github.com/gTahidi/wapi.go/wapi/events ```go -import "github.com/wapikit/wapi.go/wapi/wapi" +import "github.com/gTahidi/wapi.go/wapi/wapi" ``` Construct a new Wapi Client to access the managers in order to send messages and listen to incoming notifications. diff --git a/docs/api-reference/internal.mdx b/docs/api-reference/internal.mdx index 9e95b75..2dc375a 100644 --- a/docs/api-reference/internal.mdx +++ b/docs/api-reference/internal.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/internal" +import "github.com/gTahidi/wapi.go/internal" ``` diff --git a/docs/api-reference/manager.mdx b/docs/api-reference/manager.mdx index f3a6ded..92a78f9 100644 --- a/docs/api-reference/manager.mdx +++ b/docs/api-reference/manager.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/manager" +import "github.com/gTahidi/wapi.go/manager" ``` diff --git a/docs/api-reference/pkg/business.mdx b/docs/api-reference/pkg/business.mdx index 3892d5f..d034f15 100644 --- a/docs/api-reference/pkg/business.mdx +++ b/docs/api-reference/pkg/business.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/pkg/business" +import "github.com/gTahidi/wapi.go/pkg/business" ``` diff --git a/docs/api-reference/pkg/client.mdx b/docs/api-reference/pkg/client.mdx index a88cc17..4d2b111 100644 --- a/docs/api-reference/pkg/client.mdx +++ b/docs/api-reference/pkg/client.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/pkg/client" +import "github.com/gTahidi/wapi.go/pkg/client" ``` diff --git a/docs/api-reference/pkg/components.mdx b/docs/api-reference/pkg/components.mdx index f4640a7..13d17ad 100644 --- a/docs/api-reference/pkg/components.mdx +++ b/docs/api-reference/pkg/components.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" ``` diff --git a/docs/api-reference/pkg/events.mdx b/docs/api-reference/pkg/events.mdx index b26e0fa..0a742ef 100644 --- a/docs/api-reference/pkg/events.mdx +++ b/docs/api-reference/pkg/events.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/pkg/events" +import "github.com/gTahidi/wapi.go/pkg/events" ``` diff --git a/docs/api-reference/pkg/messaging.mdx b/docs/api-reference/pkg/messaging.mdx index f47a887..6f0b595 100644 --- a/docs/api-reference/pkg/messaging.mdx +++ b/docs/api-reference/pkg/messaging.mdx @@ -1,5 +1,5 @@ ```go -import "github.com/wapikit/wapi.go/pkg/messaging" +import "github.com/gTahidi/wapi.go/pkg/messaging" ``` diff --git a/docs/guide/building-your-application/building-message-components.mdx b/docs/guide/building-your-application/building-message-components.mdx index f174fa6..d4e6c62 100644 --- a/docs/guide/building-your-application/building-message-components.mdx +++ b/docs/guide/building-your-application/building-message-components.mdx @@ -22,7 +22,7 @@ Wapi.go SDK provides a simaple and easy to use classes architecture to build mes In all the media messages which includes image, document, audio, video and sticker, either we will have to use the Id of media, or a publicly accessible hosted media Url. - With the rapidly changing whatsapp business platform features and offerings, we try our best to be in sync with the new features provided by the API. If you think we are missing upon any of the available type of message support. You can open a github issue [here](https://github.com/wapikit/wapi.go/issues) or direclty [contact](/guide/contact) the maintainer of the project. + With the rapidly changing whatsapp business platform features and offerings, we try our best to be in sync with the new features provided by the API. If you think we are missing upon any of the available type of message support. You can open a github issue [here](https://github.com/gTahidi/wapi.go/issues) or direclty [contact](/guide/contact) the maintainer of the project. ### Text Message diff --git a/docs/guide/installation-and-preparations/creating-application.mdx b/docs/guide/installation-and-preparations/creating-application.mdx index 1aabffb..da9f3f5 100644 --- a/docs/guide/installation-and-preparations/creating-application.mdx +++ b/docs/guide/installation-and-preparations/creating-application.mdx @@ -11,7 +11,7 @@ The first step to build a Wapi.go based chat bot is to create a new project. You ```go go mod init -go get github.com/wapikit/wapi.go +go get github.com/gTahidi/wapi.go ``` ### Other use cases diff --git a/docs/guide/quickstart.mdx b/docs/guide/quickstart.mdx index c144d77..360bbd4 100644 --- a/docs/guide/quickstart.mdx +++ b/docs/guide/quickstart.mdx @@ -59,7 +59,7 @@ description: 'Welcome to the home of your new documentation' Check out the example chat bot to get inspiration diff --git a/docs/mint.json b/docs/mint.json index 692ee35..ad493b6 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -34,12 +34,12 @@ "topbarLinks": [ { "name": "Report Bugs", - "url": "https://github.com/wapikit/wapi.go/issues/new" + "url": "https://github.com/gTahidi/wapi.go/issues/new" } ], "topbarCtaButton": { "name": "Github", - "url": "https://github.com/wapikit/wapi.go" + "url": "https://github.com/gTahidi/wapi.go" }, "tabs": [ { @@ -51,7 +51,7 @@ { "name": "Star us on GitHub", "icon": "github", - "url": "https://github.com/wapikit/wapi.go" + "url": "https://github.com/gTahidi/wapi.go" }, { "name": "Sign up for WapiKit", @@ -122,7 +122,7 @@ ], "footerSocials": { "x": "https://x.com/wapikit", - "github": "https://github.com/wapikit/wapi.go", + "github": "https://github.com/gTahidi/wapi.go", "linkedin": "https://www.linkedin.com/company/wapikit" } } diff --git a/examples/chat-bot/main.go b/examples/chat-bot/main.go index 0cc55a7..b0f341a 100644 --- a/examples/chat-bot/main.go +++ b/examples/chat-bot/main.go @@ -5,10 +5,10 @@ import ( "strings" "time" - "github.com/wapikit/wapi.go/pkg/business" - wapi "github.com/wapikit/wapi.go/pkg/client" - wapiComponents "github.com/wapikit/wapi.go/pkg/components" - "github.com/wapikit/wapi.go/pkg/events" + "github.com/gTahidi/wapi.go/pkg/business" + wapi "github.com/gTahidi/wapi.go/pkg/client" + wapiComponents "github.com/gTahidi/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/pkg/events" ) func main() { diff --git a/examples/http-backend-integration/main.go b/examples/http-backend-integration/main.go index 16b6584..78e0de9 100644 --- a/examples/http-backend-integration/main.go +++ b/examples/http-backend-integration/main.go @@ -4,9 +4,9 @@ import ( "fmt" "github.com/labstack/echo/v4" - wapi "github.com/wapikit/wapi.go/pkg/client" - "github.com/wapikit/wapi.go/pkg/components" - "github.com/wapikit/wapi.go/pkg/events" + wapi "github.com/gTahidi/wapi.go/pkg/client" + "github.com/gTahidi/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/pkg/events" ) func main() { diff --git a/go.mod b/go.mod index 6764d23..acb817c 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/wapikit/wapi.go +module github.com/gTahidi/wapi.go go 1.21.3 diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index 9935034..c2156dc 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -11,8 +11,8 @@ import ( "path/filepath" "strings" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal/request_client" ) type CatalogManager struct { diff --git a/manager/event_manager.go b/manager/event_manager.go index 9db4dca..28e3de8 100644 --- a/manager/event_manager.go +++ b/manager/event_manager.go @@ -4,7 +4,7 @@ import ( "fmt" "sync" - "github.com/wapikit/wapi.go/pkg/events" + "github.com/gTahidi/wapi.go/pkg/events" ) // ChannelEvent represents an event that can be published and subscribed to. diff --git a/manager/media_manager.go b/manager/media_manager.go index 0c32655..abd1925 100644 --- a/manager/media_manager.go +++ b/manager/media_manager.go @@ -11,7 +11,7 @@ import ( "path/filepath" "strings" - "github.com/wapikit/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/internal/request_client" ) // MediaManager is responsible for managing media related operations. diff --git a/manager/message_manager.go b/manager/message_manager.go index ea41220..c4345e3 100644 --- a/manager/message_manager.go +++ b/manager/message_manager.go @@ -6,8 +6,8 @@ import ( "net/http" "strings" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/pkg/components" ) // MessageManager is responsible for managing messages. diff --git a/manager/phone_number_manager.go b/manager/phone_number_manager.go index c8d5c01..9758845 100644 --- a/manager/phone_number_manager.go +++ b/manager/phone_number_manager.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal/request_client" ) // PhoneNumberManager is responsible for managing phone numbers for WhatsApp Business API and phone number specific operations. diff --git a/manager/template_manager.go b/manager/template_manager.go index 7cc1d61..f331fa1 100644 --- a/manager/template_manager.go +++ b/manager/template_manager.go @@ -6,8 +6,8 @@ import ( "net/http" "strings" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal/request_client" ) // MessageTemplateStatus represents the status of a WhatsApp Business message template. diff --git a/manager/webhook_manager.go b/manager/webhook_manager.go index e7c9c1c..6fe929f 100644 --- a/manager/webhook_manager.go +++ b/manager/webhook_manager.go @@ -12,10 +12,10 @@ import ( "time" "github.com/labstack/echo/v4" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/pkg/components" - "github.com/wapikit/wapi.go/pkg/events" + "github.com/gTahidi/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/pkg/events" ) // WebhookManager represents a manager for handling webhooks. diff --git a/pkg/business/client.go b/pkg/business/client.go index 7efe7f3..bf0f485 100644 --- a/pkg/business/client.go +++ b/pkg/business/client.go @@ -7,9 +7,9 @@ import ( "time" - "github.com/wapikit/wapi.go/internal" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/manager" + "github.com/gTahidi/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/manager" ) // BusinessClient is responsible for managing business account related operations. diff --git a/pkg/client/client.go b/pkg/client/client.go index 4847fed..a48b73a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -2,11 +2,11 @@ package wapi import ( "github.com/labstack/echo/v4" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/manager" - "github.com/wapikit/wapi.go/pkg/business" - "github.com/wapikit/wapi.go/pkg/events" - "github.com/wapikit/wapi.go/pkg/messaging" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/manager" + "github.com/gTahidi/wapi.go/pkg/business" + "github.com/gTahidi/wapi.go/pkg/events" + "github.com/gTahidi/wapi.go/pkg/messaging" ) type ClientConfig struct { diff --git a/pkg/components/audio_message.go b/pkg/components/audio_message.go index 8f658f3..ae4db51 100644 --- a/pkg/components/audio_message.go +++ b/pkg/components/audio_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // AudioMessage represents an audio message. diff --git a/pkg/components/catalog_message.go b/pkg/components/catalog_message.go index a7de553..77cb24d 100644 --- a/pkg/components/catalog_message.go +++ b/pkg/components/catalog_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type CatalogMessageActionParameter struct { diff --git a/pkg/components/contact_message.go b/pkg/components/contact_message.go index d0f6414..1fec502 100644 --- a/pkg/components/contact_message.go +++ b/pkg/components/contact_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type AddressType string diff --git a/pkg/components/cta_message.go b/pkg/components/cta_message.go index d3397b2..a217b5d 100644 --- a/pkg/components/cta_message.go +++ b/pkg/components/cta_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type CallToAction struct { diff --git a/pkg/components/document_message.go b/pkg/components/document_message.go index 29b8fc2..7407857 100644 --- a/pkg/components/document_message.go +++ b/pkg/components/document_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // DocumentMessage represents a document message. diff --git a/pkg/components/image_message.go b/pkg/components/image_message.go index d162dfe..afa65b6 100644 --- a/pkg/components/image_message.go +++ b/pkg/components/image_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // ImageMessage represents a message with an image. diff --git a/pkg/components/list_message.go b/pkg/components/list_message.go index d42a6f8..c410bb6 100644 --- a/pkg/components/list_message.go +++ b/pkg/components/list_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // ListSection represents a section in the list message. diff --git a/pkg/components/location_message.go b/pkg/components/location_message.go index 857d5d7..702dc4b 100644 --- a/pkg/components/location_message.go +++ b/pkg/components/location_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // LocationMessage represents a location message with latitude, longitude, address, and name. diff --git a/pkg/components/location_request_message.go b/pkg/components/location_request_message.go index 30f8040..95410f1 100644 --- a/pkg/components/location_request_message.go +++ b/pkg/components/location_request_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type locationMessageAction struct { diff --git a/pkg/components/product_list_message.go b/pkg/components/product_list_message.go index 6c10b84..8500414 100644 --- a/pkg/components/product_list_message.go +++ b/pkg/components/product_list_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type Product struct { diff --git a/pkg/components/product_message.go b/pkg/components/product_message.go index 886bb2f..8388a8d 100644 --- a/pkg/components/product_message.go +++ b/pkg/components/product_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) type ProductMessageBody struct { diff --git a/pkg/components/quick_reply_button_message.go b/pkg/components/quick_reply_button_message.go index b5ebb5d..af8644c 100644 --- a/pkg/components/quick_reply_button_message.go +++ b/pkg/components/quick_reply_button_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // quickReplyButtonMessageButtonReply represents the reply structure of a quick reply button. diff --git a/pkg/components/reaction_message.go b/pkg/components/reaction_message.go index 6bf353c..ac37543 100644 --- a/pkg/components/reaction_message.go +++ b/pkg/components/reaction_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // ReactionMessage represents a reaction to a message. diff --git a/pkg/components/sticker_message.go b/pkg/components/sticker_message.go index 111cb0d..8f003db 100644 --- a/pkg/components/sticker_message.go +++ b/pkg/components/sticker_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // StickerMessage represents a sticker message. diff --git a/pkg/components/template_message.go b/pkg/components/template_message.go index 5e9badc..d2d618c 100644 --- a/pkg/components/template_message.go +++ b/pkg/components/template_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // TemplateMessageComponentType represents the type of a template message component. diff --git a/pkg/components/text_message.go b/pkg/components/text_message.go index 497f949..3d5eba0 100644 --- a/pkg/components/text_message.go +++ b/pkg/components/text_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // textMessage represents a text message. diff --git a/pkg/components/video_message.go b/pkg/components/video_message.go index 94f82dc..4897760 100644 --- a/pkg/components/video_message.go +++ b/pkg/components/video_message.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - "github.com/wapikit/wapi.go/internal" + "github.com/gTahidi/wapi.go/internal" ) // VideoMessage represents a video message. diff --git a/pkg/events/audio_message_event.go b/pkg/events/audio_message_event.go index dac9f14..95d1c94 100644 --- a/pkg/events/audio_message_event.go +++ b/pkg/events/audio_message_event.go @@ -1,7 +1,7 @@ package events import ( - "github.com/wapikit/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/pkg/components" ) // AudioMessageEvent represents an event for an audio message. diff --git a/pkg/events/base_event.go b/pkg/events/base_event.go index dcfc016..2eb4fca 100644 --- a/pkg/events/base_event.go +++ b/pkg/events/base_event.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/pkg/components" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/pkg/components" ) type MessageContext struct { diff --git a/pkg/events/contacts_message_event.go b/pkg/events/contacts_message_event.go index 82c1ddd..5d685fa 100644 --- a/pkg/events/contacts_message_event.go +++ b/pkg/events/contacts_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // ContactsMessageEvent represents an event that occurs when a message with contacts is received. type ContactsMessageEvent struct { diff --git a/pkg/events/document_message_event.go b/pkg/events/document_message_event.go index d1838a7..0afc5f0 100644 --- a/pkg/events/document_message_event.go +++ b/pkg/events/document_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // DocumentMessageEvent represents an event that occurs when a document message is received. type DocumentMessageEvent struct { diff --git a/pkg/events/image_message_event.go b/pkg/events/image_message_event.go index 5f7e4df..9e8db02 100644 --- a/pkg/events/image_message_event.go +++ b/pkg/events/image_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // ImageMessageEvent represents an event for an image message. type ImageMessageEvent struct { diff --git a/pkg/events/location_message_event.go b/pkg/events/location_message_event.go index 9b7e335..0d7eaca 100644 --- a/pkg/events/location_message_event.go +++ b/pkg/events/location_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // LocationMessageEvent represents an event that contains a location message. type LocationMessageEvent struct { diff --git a/pkg/events/order_event.go b/pkg/events/order_event.go index 58b4f22..84da307 100644 --- a/pkg/events/order_event.go +++ b/pkg/events/order_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // OrderEvent represents an event related to an order. type OrderEvent struct { diff --git a/pkg/events/reaction_event.go b/pkg/events/reaction_event.go index 5a0de47..89decf4 100644 --- a/pkg/events/reaction_event.go +++ b/pkg/events/reaction_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // ReactionMessageEvent represents an event that occurs when a reaction is added to a message. type ReactionMessageEvent struct { diff --git a/pkg/events/sticker_message_event.go b/pkg/events/sticker_message_event.go index 7a671fc..be9f8f4 100644 --- a/pkg/events/sticker_message_event.go +++ b/pkg/events/sticker_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // StickerMessageEvent represents an event for a sticker message. type StickerMessageEvent struct { diff --git a/pkg/events/video_message_event.go b/pkg/events/video_message_event.go index 9f06740..5712c86 100644 --- a/pkg/events/video_message_event.go +++ b/pkg/events/video_message_event.go @@ -1,6 +1,6 @@ package events -import "github.com/wapikit/wapi.go/pkg/components" +import "github.com/gTahidi/wapi.go/pkg/components" // VideoMessageEvent represents a WhatsApp video message event. type VideoMessageEvent struct { diff --git a/pkg/messaging/client.go b/pkg/messaging/client.go index eb68d94..3491f44 100644 --- a/pkg/messaging/client.go +++ b/pkg/messaging/client.go @@ -5,8 +5,8 @@ import ( "net/http" "strings" - "github.com/wapikit/wapi.go/internal/request_client" - "github.com/wapikit/wapi.go/manager" + "github.com/gTahidi/wapi.go/internal/request_client" + "github.com/gTahidi/wapi.go/manager" ) // MessagingClient represents a WhatsApp client. From 52bbcdac342dfaf812566d97660d8358d7aa29ee Mon Sep 17 00:00:00 2001 From: byte-rose Date: Mon, 13 Oct 2025 18:34:43 +0300 Subject: [PATCH 5/6] feat(catalog): add CreateProductFeed for unscheduled feed creation --- manager/catalog_manager.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index c2156dc..ba94a49 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -659,6 +659,30 @@ func (cm *CatalogManager) CreateScheduledProductFeed( return &res, nil } +// CreateProductFeed creates a product feed without a schedule (for immediate CSV uploads). +// Use UploadFeedCSV or UploadFeedCSVFromURL afterwards to ingest data. +func (cm *CatalogManager) CreateProductFeed(catalogId string, name string) (*ProductFeed, error) { + apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") + apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) + body := map[string]interface{}{ + "name": name, + } + payload, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal feed body: %w", err) + } + apiRequest.SetBody(string(payload)) + response, err := apiRequest.Execute() + if err != nil { + return nil, err + } + var res ProductFeed + if err := json.Unmarshal([]byte(response), &res); err != nil { + return nil, err + } + return &res, nil +} + // UpsertProductItem updates or creates a product item using Meta’s format. // fields should include at least retailer_id, name, price, currency, image_url, availability, etc. func (cm *CatalogManager) UpsertProductItem(catalogId string, fields map[string]interface{}) (*ProductItem, error) { From 6f28c4f490619530c4dae795bbdc168f2477381f Mon Sep 17 00:00:00 2001 From: byte-rose Date: Tue, 14 Oct 2025 08:42:05 +0300 Subject: [PATCH 6/6] fix: remove deprecated CreateProductFeed overload --- manager/catalog_manager.go | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/manager/catalog_manager.go b/manager/catalog_manager.go index ba94a49..c64e384 100644 --- a/manager/catalog_manager.go +++ b/manager/catalog_manager.go @@ -430,37 +430,6 @@ func (cm *CatalogManager) ListProductFeeds(catalogId string) ([]ProductFeed, err return res.Data, nil } -// CreateProductFeed creates a product feed for CSV ingestion. We are using metas base accepted format, for more info check the meta docs -// meta whatsapp catalogs: https://developers.facebook.com/docs/commerce-platform/catalog/fields -// name: Human-readable feed name -// fileFormat: e.g., "CSV" -// fileName: default file name reference (optional) -func (cm *CatalogManager) CreateProductFeed(catalogId, name, fileFormat, fileName string) (*ProductFeed, error) { - apiPath := strings.Join([]string{catalogId, "product_feeds"}, "/") - apiRequest := cm.requester.NewApiRequest(apiPath, http.MethodPost) - body := map[string]string{ - "name": name, - "file_format": fileFormat, - } - if fileName != "" { - body["file_name"] = fileName - } - payload, err := json.Marshal(body) - if err != nil { - return nil, fmt.Errorf("failed to marshal create feed body: %w", err) - } - apiRequest.SetBody(string(payload)) - response, err := apiRequest.Execute() - if err != nil { - return nil, err - } - var res ProductFeed - if err := json.Unmarshal([]byte(response), &res); err != nil { - return nil, err - } - return &res, nil -} - // UploadFeedCSV uploads a CSV file to a product feed using multipart/form-data. func (cm *CatalogManager) UploadFeedCSV(feedId string, file io.Reader, filename, mimeType string, updateOnly bool) (*FeedUploadResponse, error) { // Prepare multipart body with update_only and a single file part