From 5310ac05e64c2b5660651b32b8d3fcd578112bb9 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Tue, 26 Dec 2023 18:22:51 +0100 Subject: [PATCH 01/10] refactor --- media.go | 28 +-- phone_numbers.go | 82 ++++---- config.go => pkg/config/config.go | 23 +-- pkg/http/hooks.go | 4 +- pkg/http/http.go | 161 ++++++++++------ pkg/http/http_test.go | 68 +++---- pkg/http/slog.go | 64 +++++++ pkg/models/models.go | 13 +- qr.go | 33 ++-- whatsapp.go | 306 +++++++----------------------- 10 files changed, 353 insertions(+), 429 deletions(-) rename config.go => pkg/config/config.go (72%) create mode 100644 pkg/http/slog.go diff --git a/media.go b/media.go index 77ea035..6aefc61 100644 --- a/media.go +++ b/media.go @@ -75,10 +75,10 @@ type ( // GetMediaInformation retrieve the media object by using its corresponding media ID. func (client *Client) GetMediaInformation(ctx context.Context, mediaID string) (*MediaInformation, error) { reqCtx := &whttp.RequestContext{ - Name: "get media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{mediaID}, + RequestType: "get media", + BaseURL: client.config.BaseURL, + ApiVersion: client.config.Version, + Endpoints: []string{mediaID}, } params := &whttp.Request{ @@ -101,10 +101,10 @@ func (client *Client) GetMediaInformation(ctx context.Context, mediaID string) ( // DeleteMedia delete the media by using its corresponding media ID. func (client *Client) DeleteMedia(ctx context.Context, mediaID string) (*DeleteMediaResponse, error) { reqCtx := &whttp.RequestContext{ - Name: "delete media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{mediaID}, + RequestType: "delete media", + BaseURL: client.config.BaseURL, + ApiVersion: client.config.Version, + Endpoints: []string{mediaID}, } params := &whttp.Request{ @@ -133,10 +133,10 @@ func (client *Client) UploadMedia(ctx context.Context, mediaType MediaType, file } reqCtx := &whttp.RequestContext{ - Name: "upload media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{client.config.PhoneNumberID, "media"}, + RequestType: "upload media", + BaseURL: client.config.BaseURL, + ApiVersion: client.config.Version, + Endpoints: []string{client.config.PhoneNumberID, "media"}, } params := &whttp.Request{ @@ -200,7 +200,7 @@ func (client *Client) DownloadMedia(ctx context.Context, mediaID string, retries request := whttp.MakeRequest( whttp.WithRequestContext(&whttp.RequestContext{ - Name: "download media", + RequestType: "download media", BaseURL: media.URL, ApiVersion: client.config.Version, PhoneNumberID: client.config.PhoneNumberID, @@ -208,7 +208,7 @@ func (client *Client) DownloadMedia(ctx context.Context, mediaID string, retries BusinessAccountID: "", Endpoints: nil, }), - whttp.WithRequestName("download media"), + whttp.WithRequestType("download media"), whttp.WithMethod(http.MethodGet), whttp.WithBearer(client.config.AccessToken)) decoder := &DownloadResponseDecoder{} diff --git a/phone_numbers.go b/phone_numbers.go index 5b41f4c..6352e4f 100644 --- a/phone_numbers.go +++ b/phone_numbers.go @@ -91,23 +91,14 @@ type ( func (client *Client) RequestVerificationCode(ctx context.Context, codeMethod VerificationMethod, language string, ) error { - reqCtx := &whttp.RequestContext{ - Name: "request code", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{"request_code"}, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeRequestCode, "request_code") + + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(client.config.AccessToken), + whttp.WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded"}), + whttp.WithForm(map[string]string{"code_method": string(codeMethod), "language": language}), + ) - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, - Query: nil, - Bearer: client.config.AccessToken, - Form: map[string]string{"code_method": string(codeMethod), "language": language}, - Payload: nil, - } err := client.bc.base.Do(ctx, params, nil) if err != nil { return fmt.Errorf("failed to send request: %w", err) @@ -118,21 +109,13 @@ func (client *Client) RequestVerificationCode(ctx context.Context, // VerifyCode should be run to verify the code retrieved by RequestVerificationCode. func (client *Client) VerifyCode(ctx context.Context, code string) (*StatusResponse, error) { - reqCtx := &whttp.RequestContext{ - Name: "verify code", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{"verify_code"}, - } - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, - Query: nil, - Bearer: client.config.AccessToken, - Form: map[string]string{"code": code}, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeVerifyCode, "verify_code") + + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(client.config.AccessToken), + whttp.WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded"}), + whttp.WithForm(map[string]string{"code": code}), + ) var resp StatusResponse err := client.bc.base.Do(ctx, params, &resp) @@ -209,28 +192,25 @@ func (client *Client) VerifyCode(ctx context.Context, code string) (*StatusRespo // } // } func (client *Client) ListPhoneNumbers(ctx context.Context, filters []*FilterParams) (*PhoneNumbersList, error) { - reqCtx := &whttp.RequestContext{ - Name: "list phone numbers", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.BusinessAccountID, - Endpoints: []string{"phone_numbers"}, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeListPhoneNumbers, "phone_numbers") + + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithMethod(http.MethodGet), + whttp.WithQuery(map[string]string{"access_token": client.config.AccessToken}), + ) - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodGet, - Query: map[string]string{"access_token": client.config.AccessToken}, - } if filters != nil { p := filters jsonParams, err := json.Marshal(p) if err != nil { return nil, fmt.Errorf("failed to marshal filter params: %w", err) } + params.Query["filtering"] = string(jsonParams) } + var phoneNumbersList PhoneNumbersList + err := client.bc.base.Do(ctx, params, &phoneNumbersList) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) @@ -242,19 +222,21 @@ func (client *Client) ListPhoneNumbers(ctx context.Context, filters []*FilterPar // PhoneNumberByID returns the phone number associated with the given ID. func (client *Client) PhoneNumberByID(ctx context.Context) (*PhoneNumber, error) { reqCtx := &whttp.RequestContext{ - Name: "get phone number by id", + RequestType: "get phone number by id", BaseURL: client.config.BaseURL, ApiVersion: client.config.Version, PhoneNumberID: client.config.PhoneNumberID, } - request := &whttp.Request{ - Context: reqCtx, - Method: http.MethodGet, - Headers: map[string]string{ + + request := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithMethod(http.MethodGet), + whttp.WithHeaders(map[string]string{ "Authorization": "Bearer " + client.config.AccessToken, - }, - } + }), + ) + var phoneNumber PhoneNumber + if err := client.bc.base.Do(ctx, request, &phoneNumber); err != nil { return nil, fmt.Errorf("get phone muber by id: %w", err) } diff --git a/config.go b/pkg/config/config.go similarity index 72% rename from config.go rename to pkg/config/config.go index 96c8855..e0b77db 100644 --- a/config.go +++ b/pkg/config/config.go @@ -17,16 +17,14 @@ * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package whatsapp +package config -import ( - "context" -) +import "context" type ( - // Config is a struct that holds the configuration for the whatsapp client. + // Values is a struct that holds the configuration for the whatsapp client. // It is used to create a new whatsapp client. - Config struct { + Values struct { BaseURL string Version string AccessToken string @@ -34,17 +32,16 @@ type ( BusinessAccountID string } - // ConfigReader is an interface that can be used to read the configuration + // Reader is an interface that can be used to read the configuration // from a file or any other source. - ConfigReader interface { - Read(ctx context.Context) (*Config, error) + Reader interface { + Read(ctx context.Context) (*Values, error) } - // ConfigReaderFunc is a function that implements the ConfigReader interface. - ConfigReaderFunc func(ctx context.Context) (*Config, error) + ReaderFunc func(ctx context.Context) (*Values, error) ) -// Read implements the ConfigReader interface. -func (fn ConfigReaderFunc) Read(ctx context.Context) (*Config, error) { +// Read implements the Reader interface. +func (fn ReaderFunc) Read(ctx context.Context) (*Values, error) { return fn(ctx) } diff --git a/pkg/http/hooks.go b/pkg/http/hooks.go index 51b1636..0ecf681 100644 --- a/pkg/http/hooks.go +++ b/pkg/http/hooks.go @@ -35,7 +35,7 @@ type ( func LogRequestHook(logger *slog.Logger) RequestHook { return func(ctx context.Context, request *http.Request) error { - name := RequestNameFromContext(ctx) + name := RequestTypeFromContext(ctx) reader, err := request.GetBody() if err != nil { return fmt.Errorf("log request hook: %w", err) @@ -61,7 +61,7 @@ func LogRequestHook(logger *slog.Logger) RequestHook { func LogResponseHook(logger *slog.Logger) ResponseHook { return func(ctx context.Context, response *http.Response) error { - name := RequestNameFromContext(ctx) + name := RequestTypeFromContext(ctx) reader := response.Body buf := new(bytes.Buffer) if _, err := buf.ReadFrom(reader); err != nil { diff --git a/pkg/http/http.go b/pkg/http/http.go index 763913a..0b3a85b 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -29,8 +29,10 @@ import ( "log/slog" "net/http" "net/url" + "slices" "strings" + "github.com/piusalfred/whatsapp/pkg/config" werrors "github.com/piusalfred/whatsapp/pkg/errors" ) @@ -69,15 +71,15 @@ func (client *Client) Close() error { // to create a new http request and send it to the server. // Example: // -// client := NewClient( -// WithHTTPClient(http.DefaultClient), -// WithRequestHooks( -// // Add your request hooks here -// ), -// WithResponseHooks( -// // Add your response hooks here -// ), -// ) +// client := NewClient( +// WithHTTPClient(http.DefaultClient), +// WithRequestHooks( +// // Add your request hooks here +// ), +// WithResponseHooks( +// // Add your response hooks here +// ), +// ) func NewClient(options ...ClientOption) *Client { client := &Client{ http: http.DefaultClient, @@ -194,7 +196,7 @@ func (client *Client) DoWithDecoder(ctx context.Context, r *Request, decoder Res return fmt.Errorf("prepare request: %w", err) } - response, err := client.http.Do(request) + response, err := client.http.Do(request) //nolint:bodyclose if err != nil { return fmt.Errorf("http send: %w", err) } @@ -220,12 +222,20 @@ func (client *Client) DoWithDecoder(ctx context.Context, r *Request, decoder Res response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - noctx, ok := decoder.(RawResponseDecoder) + rawResponseDecoder, ok := decoder.(RawResponseDecoder) if ok { - return noctx(response) + if err := rawResponseDecoder(response); err != nil { + return fmt.Errorf("raw response decoder: %w", err) + } + + return nil } - return decoder.DecodeResponse(response, v) + if err := decoder.DecodeResponse(response, v); err != nil { + return fmt.Errorf("response decoder: %w", err) + } + + return nil } var ErrRequestFailed = errors.New("request failed") @@ -282,7 +292,7 @@ func runResponseHooks(ctx context.Context, response *http.Response, hooks ...Res func prepareRequest(ctx context.Context, r *Request, hooks ...RequestHook) (*http.Request, error) { // create a new request, run hooks and return the request after restoring the body - ctx = withRequestName(ctx, r.Context.Name) + ctx = attachRequestType(ctx, r.Context.RequestType) request, err := NewRequestWithContext(ctx, r) if err != nil { @@ -336,8 +346,10 @@ type ( RequestOption func(*Request) + RequestType string + RequestContext struct { - Name string + RequestType RequestType BaseURL string ApiVersion string //nolint: revive,stylecheck PhoneNumberID string @@ -347,43 +359,78 @@ type ( } ) -func (request *Request) LogValue() slog.Value { - if request == nil { - return slog.StringValue("nil") - } - var reqURL string - if request.Context != nil { - reqURL, _ = url.JoinPath(request.Context.BaseURL, request.Context.Endpoints...) - } - - var metadataAttr []any - - for key, value := range request.Metadata { - metadataAttr = append(metadataAttr, slog.String(key, value)) +// MakeRequestContext creates a new request context. +func MakeRequestContext(config *config.Values, name RequestType, endpoints ...string) *RequestContext { + return &RequestContext{ + RequestType: name, + BaseURL: config.BaseURL, + ApiVersion: config.Version, + PhoneNumberID: config.PhoneNumberID, + Bearer: config.AccessToken, + BusinessAccountID: config.BusinessAccountID, + Endpoints: endpoints, } +} - var headersAttr []any +const ( + RequestTypeTextMessage RequestType = "text message" + RequestTypeLocation RequestType = "location message" + RequestTypeMedia RequestType = "media message" + RequestTypeReply RequestType = "reply message" + RequestTypeTemplate RequestType = "template message" + RequestTypeReact RequestType = "react message" + RequestTypeContacts RequestType = "contact message" + RequestTypeInteractiveTemplate RequestType = "interactive template message" + RequestTypeTextTemplate RequestType = "text template message" + RequestTypeMediaTemplate RequestType = "media template message" + RequestTypeMarkMessageRead RequestType = "mark message read" + RequestTypeInteractiveMessage RequestType = "interactive message" + RequestTypeRequestCode RequestType = "request verification code" + RequestTypeVerifyCode RequestType = "verify verification code" + RequestTypeListPhoneNumbers RequestType = "list phone numbers" + RequestTypeCreateQRCode RequestType = "create qr code" + RequestTypeDeleteQRCode RequestType = "delete qr code" + RequestTypeListQRCodes RequestType = "list qr codes" + RequestTypeUpdateQRCode RequestType = "update qr code" + RequestTypeGetQRCode RequestType = "get qr code" +) - for key, value := range request.Headers { - headersAttr = append(headersAttr, slog.String(key, value)) +// ParseRequestType parses the string representation of the request type into a RequestType. +func ParseRequestType(name string) RequestType { + all := []string{ + RequestTypeTextMessage.String(), + RequestTypeLocation.String(), + RequestTypeMedia.String(), + RequestTypeReply.String(), + RequestTypeTemplate.String(), + RequestTypeReact.String(), + RequestTypeContacts.String(), + RequestTypeInteractiveTemplate.String(), + RequestTypeTextTemplate.String(), + RequestTypeMediaTemplate.String(), + RequestTypeMarkMessageRead.String(), + RequestTypeInteractiveMessage.String(), + RequestTypeRequestCode.String(), + RequestTypeVerifyCode.String(), + RequestTypeListPhoneNumbers.String(), + RequestTypeCreateQRCode.String(), + RequestTypeDeleteQRCode.String(), + RequestTypeListQRCodes.String(), + RequestTypeUpdateQRCode.String(), + RequestTypeGetQRCode.String(), } - var queryAttr []any - - for key, value := range request.Query { - queryAttr = append(queryAttr, slog.String(key, value)) + index := slices.Index[[]string](all, name) + if index == -1 { + return "" } - value := slog.GroupValue( - slog.String("name", request.Context.Name), - slog.String("method", request.Method), - slog.String("url", reqURL), - slog.Group("metadata", metadataAttr...), - slog.Group("headers", headersAttr...), - slog.Group("query", queryAttr...), - ) + return RequestType(all[index]) +} - return value +// String returns the string representation of the request type. +func (r RequestType) String() string { + return string(r) } func MakeRequest(options ...RequestOption) *Request { @@ -417,9 +464,9 @@ func WithRequestContext(ctx *RequestContext) RequestOption { } } -func WithRequestName(name string) RequestOption { +func WithRequestType(name RequestType) RequestOption { return func(request *Request) { - request.Context.Name = name + request.Context.RequestType = name } } @@ -597,29 +644,29 @@ func extractRequestBody(payload interface{}) (io.Reader, error) { } } -// requestNameKey is a type that holds the name of a request. This is usually passed -// extracted from Request.Context.Name and passed down to the Do function. +// requestTypeKey is a type that holds the name of a request. This is usually passed +// extracted from Request.Context.RequestType and passed down to the Do function. // then passed down with to the request hooks. In request hooks, the name can be // used to identify the request and other multiple use cases like instrumentation, // logging etc. -type requestNameKey string +type requestTypeKey string -const requestNameValue = "request-name" +const requestTypeValue = "request-name" -// withRequestName takes a string and a context and returns a new context with the string +// attachRequestType takes a string and a context and returns a new context with the string // as the request name. -func withRequestName(ctx context.Context, name string) context.Context { - return context.WithValue(ctx, requestNameKey(requestNameValue), name) +func attachRequestType(ctx context.Context, name RequestType) context.Context { + return context.WithValue(ctx, requestTypeKey(requestTypeValue), name) } -// RequestNameFromContext returns the request name from the context. -func RequestNameFromContext(ctx context.Context) string { - name, ok := ctx.Value(requestNameKey(requestNameValue)).(string) +// RequestTypeFromContext returns the request name from the context. +func RequestTypeFromContext(ctx context.Context) string { + rt, ok := ctx.Value(requestTypeKey(requestTypeValue)).(RequestType) if !ok { return "unknown request name" } - return name + return rt.String() } // RequestURLFromContext returns the request url from the context. diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go index 7b5ea5e..f1d046c 100644 --- a/pkg/http/http_test.go +++ b/pkg/http/http_test.go @@ -87,7 +87,7 @@ func TestSend(t *testing.T) { //nolint:paralleltest defer server.Close() reqCtx := &RequestContext{ - Name: "test", + RequestType: "test", BaseURL: server.URL, ApiVersion: "", PhoneNumberID: "", @@ -123,44 +123,6 @@ func TestSend(t *testing.T) { //nolint:paralleltest t.Logf("user: %+v", user) } -func TestRequestNameFromContext(t *testing.T) { - t.Parallel() - type args struct { - name string - } - tests := []struct { - name string - args args - want string - }{ - { - name: "test request name from context", - args: args{ - name: "test", - }, - want: "test", - }, - { - name: "test request name from context", - args: args{ - name: "", - }, - want: "", - }, - } - for _, tt := range tests { - tt := tt - args := tt.args - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - ctx := withRequestName(context.TODO(), args.name) - if got := RequestNameFromContext(ctx); got != tt.want { - t.Errorf("RequestNameFromContext() = %v, want %v", got, tt.want) - } - }) - } -} - func Test_extractRequestBody(t *testing.T) { t.Parallel() type user struct { @@ -310,3 +272,31 @@ func Test_extractRequestBody(t *testing.T) { }) } } + +func TestRequestTypeFromContext(t *testing.T) { + t.Parallel() + inputs := []RequestType{ + RequestTypeTextMessage, + RequestTypeLocation, + RequestTypeMedia, + RequestTypeReply, + RequestTypeTemplate, + RequestTypeReact, + RequestTypeContacts, + RequestTypeInteractiveTemplate, + RequestTypeTextTemplate, + RequestTypeMediaTemplate, + RequestTypeMarkMessageRead, + RequestTypeInteractiveMessage, + } + + t.Run("test assigning and retrieving request type", func(t *testing.T) { + t.Parallel() + for _, input := range inputs { + ctx := attachRequestType(context.TODO(), input) + if got := RequestTypeFromContext(ctx); ParseRequestType(got) != input { + t.Errorf("RequestTypeFromContext(\"%s\") = %v, want %v", input, got, input) + } + } + }) +} diff --git a/pkg/http/slog.go b/pkg/http/slog.go new file mode 100644 index 0000000..2bedda9 --- /dev/null +++ b/pkg/http/slog.go @@ -0,0 +1,64 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "log/slog" + "net/url" +) + +func (request *Request) LogValue() slog.Value { + if request == nil { + return slog.StringValue("nil") + } + var reqURL string + if request.Context != nil { + reqURL, _ = url.JoinPath(request.Context.BaseURL, request.Context.Endpoints...) + } + + var metadataAttr []any + + for key, value := range request.Metadata { + metadataAttr = append(metadataAttr, slog.String(key, value)) + } + + var headersAttr []any + + for key, value := range request.Headers { + headersAttr = append(headersAttr, slog.String(key, value)) + } + + var queryAttr []any + + for key, value := range request.Query { + queryAttr = append(queryAttr, slog.String(key, value)) + } + + value := slog.GroupValue( + slog.String("type", request.Context.RequestType.String()), + slog.String("method", request.Method), + slog.String("url", reqURL), + slog.Group("metadata", metadataAttr...), + slog.Group("headers", headersAttr...), + slog.Group("query", queryAttr...), + ) + + return value +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 6c232fa..3aa7763 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -173,7 +173,7 @@ type ( Product string `json:"messaging_product"` To string `json:"to"` RecipientType string `json:"recipient_type"` - Type string `json:"type"` + Type MessageType `json:"type"` PreviewURL bool `json:"preview_url,omitempty"` Context *Context `json:"context,omitempty"` Template *Template `json:"template,omitempty"` @@ -239,3 +239,14 @@ func (m *Message) SetTemplate(template *Template) { m.Type = "template" m.Template = template } + +type MessageType string + +const ( + MessageTypeTemplate MessageType = "template" + MessageTypeText MessageType = "text" + MessageTypeReaction MessageType = "reaction" + MessageTypeLocation MessageType = "location" + MessageTypeContacts MessageType = "contacts" + MessageTypeInteractive MessageType = "interactive" +) diff --git a/qr.go b/qr.go index 239c825..379fc67 100644 --- a/qr.go +++ b/qr.go @@ -24,6 +24,7 @@ import ( "fmt" "net/http" + "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" ) @@ -71,7 +72,7 @@ func (c *BaseClient) CreateQR(ctx context.Context, rtx *whttp.RequestContext, "access_token": rtx.Bearer, } reqCtx := &whttp.RequestContext{ - Name: "create qr code", + RequestType: "create qr code", BaseURL: rtx.BaseURL, ApiVersion: rtx.ApiVersion, PhoneNumberID: rtx.PhoneNumberID, @@ -95,7 +96,7 @@ func (c *BaseClient) CreateQR(ctx context.Context, rtx *whttp.RequestContext, func (c *BaseClient) ListQR(ctx context.Context, request *RequestContext) (*ListResponse, error) { reqCtx := &whttp.RequestContext{ - Name: "list qr codes", + RequestType: "list qr codes", BaseURL: request.BaseURL, ApiVersion: request.ApiVersion, PhoneNumberID: request.PhoneID, @@ -132,19 +133,17 @@ func (c *BaseClient) Get(ctx context.Context, request *whttp.RequestContext, qrC list ListResponse resp Information ) - reqCtx := &whttp.RequestContext{ - Name: "get qr code", + + reqCtx := whttp.MakeRequestContext(&config.Values{ BaseURL: request.BaseURL, - ApiVersion: request.ApiVersion, + Version: request.ApiVersion, PhoneNumberID: request.PhoneNumberID, - Endpoints: []string{"message_qrdls", qrCodeID}, - } + }, whttp.RequestTypeGetQRCode, "message_qrdls", qrCodeID) - req := &whttp.Request{ - Context: reqCtx, - Method: http.MethodGet, - Query: map[string]string{"access_token": request.Bearer}, - } + req := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithMethod(http.MethodGet), + whttp.WithQuery(map[string]string{"access_token": request.Bearer}), + ) err := c.base.Do(ctx, req, &list) if err != nil { @@ -163,13 +162,11 @@ func (c *BaseClient) Get(ctx context.Context, request *whttp.RequestContext, qrC func (c *BaseClient) UpdateQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, req *CreateRequest) (*SuccessResponse, error, ) { - reqCtx := &whttp.RequestContext{ - Name: "update qr code", + reqCtx := whttp.MakeRequestContext(&config.Values{ BaseURL: rtx.BaseURL, - ApiVersion: rtx.ApiVersion, + Version: rtx.ApiVersion, PhoneNumberID: rtx.PhoneNumberID, - Endpoints: []string{"message_qrdls", qrCodeID}, - } + }, whttp.RequestTypeUpdateQRCode, "message_qrdls", qrCodeID) request := &whttp.Request{ Context: reqCtx, @@ -193,7 +190,7 @@ func (c *BaseClient) UpdateQR(ctx context.Context, rtx *whttp.RequestContext, qr func (c *BaseClient) DeleteQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, ) (*SuccessResponse, error) { reqCtx := &whttp.RequestContext{ - Name: "delete qr code", + RequestType: "delete qr code", BaseURL: rtx.BaseURL, ApiVersion: rtx.ApiVersion, PhoneNumberID: rtx.PhoneNumberID, diff --git a/whatsapp.go b/whatsapp.go index a6af284..6ea6182 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -29,6 +29,7 @@ import ( "strings" "time" + "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" "github.com/piusalfred/whatsapp/pkg/models" ) @@ -48,14 +49,6 @@ const ( DateFormatContactBirthday = time.DateOnly // YYYY-MM-DD ) -const ( - templateMessageType = "template" - textMessageType = "text" - reactionMessageType = "reaction" - locationMessageType = "location" - contactsMessageType = "contacts" -) - const ( MaxAudioSize = 16 * 1024 * 1024 // 16 MB MaxDocSize = 100 * 1024 * 1024 // 100 MB @@ -119,20 +112,13 @@ func (r *ResponseMessage) LogValue() slog.Value { var _ slog.LogValuer = (*ResponseMessage)(nil) type ( - // MessageType represents the type of message currently supported. - // Which are Text messages,Reaction messages,MediaInformation messages,Location messages,Contact messages, - // and Interactive messages. - // You may also send any of these message types as a reply, except reaction messages. - // For more go to https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages - MessageType string - // Client is a struct that holds the configuration for the whatsapp client. // It is used to create a new whatsapp client for a single user. Uses the BaseClient // to make requests to the whatsapp api. If you want a client that's flexible and can // make requests to the whatsapp api for different users, use the TransparentClient. Client struct { bc *BaseClient - config *Config + config *config.Values } ClientOption func(*Client) @@ -205,7 +191,7 @@ type ( ReplyRequest struct { Recipient string Context string // this is ID of the message to reply to - MessageType MessageType + MessageType models.MessageType Content any // this is a Text if MessageType is Text } @@ -337,16 +323,16 @@ func WithBaseClient(base *BaseClient) ClientOption { } } -func NewClient(reader ConfigReader, options ...ClientOption) (*Client, error) { - config, err := reader.Read(context.Background()) +func NewClient(reader config.Reader, options ...ClientOption) (*Client, error) { + values, err := reader.Read(context.Background()) if err != nil { - return nil, fmt.Errorf("failed to read config: %w", err) + return nil, fmt.Errorf("failed to read values: %w", err) } - return NewClientWithConfig(config, options...) + return NewClientWithConfig(values, options...) } -func NewClientWithConfig(config *Config, options ...ClientOption) (*Client, error) { +func NewClientWithConfig(config *config.Values, options ...ClientOption) (*Client, error) { if config == nil { return nil, ErrConfigNil } @@ -387,23 +373,13 @@ func (client *Client) Reply(ctx context.Context, request *ReplyRequest, if err != nil { return nil, fmt.Errorf("reply: %w", err) } - reqCtx := &whttp.RequestContext{ - Name: "reply to message", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{MessageEndpoint}, - } - req := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/json"}, - Query: nil, - Bearer: client.config.AccessToken, - Form: nil, - Payload: payload, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeReply, MessageEndpoint) + + req := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(client.config.AccessToken), + whttp.WithPayload(payload), + ) var message ResponseMessage err = client.bc.base.Do(ctx, req, &message) @@ -445,14 +421,14 @@ func (client *Client) SendText(ctx context.Context, recipient string, Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: textMessageType, + Type: models.MessageTypeText, Text: &models.Text{ PreviewURL: message.PreviewURL, Body: message.Message, }, } - res, err := client.SendMessage(ctx, "send text", text) + res, err := client.SendMessage(ctx, whttp.RequestTypeTextMessage, text) if err != nil { return nil, fmt.Errorf("failed to send text message: %w", err) } @@ -460,58 +436,18 @@ func (client *Client) SendText(ctx context.Context, recipient string, return res, nil } -// React sends a reaction to a message. -// To send reaction messages, make a POST call to /PHONE_NUMBER_ID/messages and attach a message object -// with type=reaction. Then, add a reaction object. -// -// Sample request: -// -// curl -X POST \ -// 'https://graph.facebook.com/v15.0/FROM_PHONE_NUMBER_ID/messages' \ -// -H 'Authorization: Bearer ACCESS_TOKEN' \ -// -H 'Content-Type: application/json' \ -// -d '{ -// "messaging_product": "whatsapp", -// "recipient_type": "individual", -// "to": "PHONE_NUMBER", -// "type": "reaction", -// "reaction": { -// "message_id": "wamid.HBgLM...", -// "emoji": "\uD83D\uDE00" -// } -// }' -// -// If the message you are reacting to is more than 30 days old, doesn't correspond to any message -// in the conversation, has been deleted, or is itself a reaction message, the reaction message will -// not be delivered, and you will receive a webhooks with the code 131009. -// -// A successful Resp includes an object with an identifier prefixed with wamid. Use the ID listed -// after wamid to track your message status. -// -// Example Resp: -// -// { -// "messaging_product": "whatsapp", -// "contacts": [{ -// "input": "PHONE_NUMBER", -// "wa_id": "WHATSAPP_ID", -// }] -// "messages": [{ -// "id": "wamid.ID", -// }] -// } func (client *Client) React(ctx context.Context, recipient string, msg *ReactMessage) (*ResponseMessage, error) { reaction := &models.Message{ Product: MessagingProduct, To: recipient, - Type: reactionMessageType, + Type: models.MessageTypeReaction, Reaction: &models.Reaction{ MessageID: msg.MessageID, Emoji: msg.Emoji, }, } - res, err := client.SendMessage(ctx, "react", reaction) + res, err := client.SendMessage(ctx, whttp.RequestTypeReact, reaction) if err != nil { return nil, fmt.Errorf("failed to send reaction message: %w", err) } @@ -527,20 +463,11 @@ func (client *Client) SendContacts(ctx context.Context, recipient string, contac Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: contactsMessageType, + Type: models.MessageTypeContacts, Contacts: contacts, } - req := &whttp.RequestContext{ - Name: "send contacts", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - Endpoints: []string{MessageEndpoint}, - } - - return client.bc.Send(ctx, req, contact) + return client.SendMessage(ctx, whttp.RequestTypeContacts, contact) } // SendLocation sends a location message to a WhatsApp Business Account. @@ -551,7 +478,7 @@ func (client *Client) SendLocation(ctx context.Context, recipient string, Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: locationMessageType, + Type: models.MessageTypeLocation, Location: &models.Location{ Name: message.Name, Address: message.Address, @@ -560,43 +487,21 @@ func (client *Client) SendLocation(ctx context.Context, recipient string, }, } - req := &whttp.RequestContext{ - Name: "send location", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - Endpoints: []string{MessageEndpoint}, - } - - return client.bc.Send(ctx, req, location) + return client.SendMessage(ctx, whttp.RequestTypeLocation, location) } // SendMessage sends a message. -func (client *Client) SendMessage(ctx context.Context, name string, message *models.Message) ( +func (client *Client) SendMessage(ctx context.Context, name whttp.RequestType, message *models.Message) ( *ResponseMessage, error, ) { - req := &whttp.RequestContext{ - Name: name, - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - Endpoints: []string{MessageEndpoint}, - } + req := whttp.MakeRequestContext(client.config, name, MessageEndpoint) return client.bc.Send(ctx, req, message) } // MarkMessageRead sends a read receipt for a message. func (client *Client) MarkMessageRead(ctx context.Context, messageID string) (*StatusResponse, error) { - req := &whttp.RequestContext{ - Name: "mark message read", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{MessageEndpoint}, - } + req := whttp.MakeRequestContext(client.config, whttp.RequestTypeMarkMessageRead, MessageEndpoint) return client.bc.MarkMessageRead(ctx, req, messageID) } @@ -615,27 +520,16 @@ func (client *Client) SendMediaTemplate(ctx context.Context, recipient string, r Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: templateMessageType, + Type: models.MessageTypeTemplate, Template: template, } - reqCtx := &whttp.RequestContext{ - Name: "send media template", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{"messages"}, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeMediaTemplate, MessageEndpoint) - params := &whttp.Request{ - Method: http.MethodPost, - Payload: payload, - Context: reqCtx, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Bearer: client.config.AccessToken, - } + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithPayload(payload), + whttp.WithBearer(client.config.AccessToken), + ) var message ResponseMessage err := client.bc.base.Do(ctx, params, &message) @@ -657,23 +551,10 @@ func (client *Client) SendTextTemplate(ctx context.Context, recipient string, re } template := models.NewTextTemplate(req.Name, tmpLanguage, req.Body) payload := models.NewMessage(recipient, models.WithTemplate(template)) - reqCtx := &whttp.RequestContext{ - Name: "send text template", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{"messages"}, - } - - params := &whttp.Request{ - Method: http.MethodPost, - Payload: payload, - Context: reqCtx, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Bearer: client.config.AccessToken, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeTextTemplate, MessageEndpoint) + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithPayload(payload), + whttp.WithBearer(client.config.AccessToken)) var message ResponseMessage err := client.bc.base.Do(ctx, params, &message) @@ -697,7 +578,7 @@ func (client *Client) SendTemplate(ctx context.Context, recipient string, templa Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: templateMessageType, + Type: models.MessageTypeTemplate, Template: &models.Template{ Language: &models.TemplateLanguage{ Code: template.LanguageCode, @@ -708,14 +589,7 @@ func (client *Client) SendTemplate(ctx context.Context, recipient string, templa }, } - req := &whttp.RequestContext{ - Name: "send message", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - Endpoints: []string{"messages"}, - } + req := whttp.MakeRequestContext(client.config, whttp.RequestTypeTemplate, MessageEndpoint) return client.bc.Send(ctx, req, message) } @@ -728,18 +602,11 @@ func (client *Client) SendInteractiveMessage(ctx context.Context, recipient stri Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: "interactive", + Type: models.MessageTypeInteractive, Interactive: req, } - reqc := &whttp.RequestContext{ - Name: "send interactive message", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - Endpoints: []string{"messages"}, - } + reqc := whttp.MakeRequestContext(client.config, whttp.RequestTypeInteractiveMessage, MessageEndpoint) return client.bc.Send(ctx, reqc, template) } @@ -770,21 +637,11 @@ func (client *Client) SendMedia(ctx context.Context, recipient string, req *Medi return nil, err } - reqCtx := &whttp.RequestContext{ - Name: "send media", - BaseURL: request.BaseURL, - ApiVersion: request.ApiVersion, - PhoneNumberID: request.PhoneNumberID, - Endpoints: []string{"messages"}, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeMedia, MessageEndpoint) - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Bearer: request.AccessToken, - Headers: map[string]string{"Content-Type": "application/json"}, - Payload: payload, - } + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(request.AccessToken), + whttp.WithPayload(payload)) if request.CacheOptions != nil { if request.CacheOptions.CacheControl != "" { @@ -810,7 +667,7 @@ func (client *Client) SendMedia(ctx context.Context, recipient string, req *Medi return &message, nil } -// SendInteractiveTemplate send an interactive template message which contains some buttons for user intraction. +// SendInteractiveTemplate send an interactive template message which contains some buttons for user interaction. // Interactive message templates expand the content you can send recipients beyond the standard message template // and media messages template types to include interactive buttons using the components object. There are two types // of predefined buttons: @@ -832,26 +689,16 @@ func (client *Client) SendInteractiveTemplate(ctx context.Context, recipient str Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, - Type: templateMessageType, + Type: models.MessageTypeTemplate, Template: template, } - reqCtx := &whttp.RequestContext{ - Name: "send template", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Endpoints: []string{"messages"}, - } - params := &whttp.Request{ - Method: http.MethodPost, - Payload: payload, - Context: reqCtx, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Bearer: client.config.AccessToken, - } + reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeInteractiveTemplate, MessageEndpoint) + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(client.config.AccessToken), + whttp.WithPayload(payload)) + var message ResponseMessage + err := client.bc.base.Do(ctx, params, &message) if err != nil { return nil, fmt.Errorf("send template: %w", err) @@ -918,7 +765,7 @@ func (c *BaseClient) SendTemplate(ctx context.Context, req *SendTemplateRequest, Product: MessagingProduct, To: req.Recipient, RecipientType: RecipientTypeIndividual, - Type: templateMessageType, + Type: models.MessageTypeTemplate, Template: &models.Template{ Language: &models.TemplateLanguage{ Code: req.TemplateLanguageCode, @@ -929,7 +776,7 @@ func (c *BaseClient) SendTemplate(ctx context.Context, req *SendTemplateRequest, }, } reqCtx := &whttp.RequestContext{ - Name: "send template", + RequestType: whttp.RequestTypeTemplate, BaseURL: req.BaseURL, ApiVersion: req.ApiVersion, PhoneNumberID: req.PhoneNumberID, @@ -964,7 +811,7 @@ Be sure to keep the following in mind: - Generated download URLs only last five minutes - Always save the media ID when you upload a file -Here’s a list of the currently supported media types. Check out Supported MediaInformation Types for more information. +Here’s a list of the currently supported media types. Check out Supported Media Types for more information. - Audio (<16 MB) – ACC, MP4, MPEG, AMR, and OGG formats - Documents (<100 MB) – text, PDF, Office, and OpenOffice formats - Images (<5 MB) – JPEG and PNG formats @@ -1029,21 +876,15 @@ func (c *BaseClient) SendMedia(ctx context.Context, req *SendMediaRequest, return nil, err } - reqCtx := &whttp.RequestContext{ - Name: "send media", + reqCtx := whttp.MakeRequestContext(&config.Values{ BaseURL: req.BaseURL, - ApiVersion: req.ApiVersion, + Version: req.ApiVersion, + AccessToken: req.AccessToken, PhoneNumberID: req.PhoneNumberID, - Endpoints: []string{"messages"}, - } + }, whttp.RequestTypeMedia, MessageEndpoint) - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Bearer: req.AccessToken, - Headers: map[string]string{"Content-Type": "application/json"}, - Payload: payload, - } + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(req.AccessToken), whttp.WithPayload(payload)) if req.CacheOptions != nil { if req.CacheOptions.CacheControl != "" { @@ -1107,7 +948,7 @@ func (c *BaseClient) Send(ctx context.Context, req *whttp.RequestContext, resp, err := fs.Send(ctx, req, message) if err != nil { - return nil, fmt.Errorf("base client: %s: %w", req.Name, err) + return nil, fmt.Errorf("base client: %s: %w", req.RequestType, err) } return resp, nil @@ -1116,18 +957,15 @@ func (c *BaseClient) Send(ctx context.Context, req *whttp.RequestContext, func (c *BaseClient) send(ctx context.Context, req *whttp.RequestContext, msg *models.Message, ) (*ResponseMessage, error) { - request := &whttp.Request{ - Context: req, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/json"}, - Bearer: req.Bearer, - Payload: msg, - } + request := whttp.MakeRequest( + whttp.WithRequestContext(req), + whttp.WithBearer(req.Bearer), + whttp.WithPayload(msg)) var resp ResponseMessage err := c.base.Do(ctx, request, &resp) if err != nil { - return nil, fmt.Errorf("%s: %w", req.Name, err) + return nil, fmt.Errorf("%s: %w", req.RequestType, err) } return &resp, nil @@ -1142,13 +980,11 @@ func (c *BaseClient) MarkMessageRead(ctx context.Context, req *whttp.RequestCont MessageID: messageID, } - params := &whttp.Request{ - Context: req, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/json"}, - Bearer: req.Bearer, - Payload: reqBody, - } + params := whttp.MakeRequest( + whttp.WithRequestContext(req), + whttp.WithBearer(req.Bearer), + whttp.WithPayload(reqBody), + ) var success StatusResponse err := c.base.Do(ctx, params, &success) From 3d44e723ad5031bfc216a38f5c985d8070a58d36 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Tue, 26 Dec 2023 23:37:53 +0100 Subject: [PATCH 02/10] refactor --- base.go | 248 +++++++++++++++++++++ pkg/http/endpoints.go | 44 ++++ pkg/http/endpoints_test.go | 67 ++++++ pkg/http/http.go | 2 +- pkg/models/interactive.go | 60 +++++- pkg/models/models.go | 7 - sender.go | 61 ++++++ transaparent.go | 48 +++++ whatsapp.go | 429 +++---------------------------------- 9 files changed, 544 insertions(+), 422 deletions(-) create mode 100644 base.go create mode 100644 pkg/http/endpoints.go create mode 100644 pkg/http/endpoints_test.go create mode 100644 sender.go create mode 100644 transaparent.go diff --git a/base.go b/base.go new file mode 100644 index 0000000..c15a8a6 --- /dev/null +++ b/base.go @@ -0,0 +1,248 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package whatsapp + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/piusalfred/whatsapp/pkg/config" + whttp "github.com/piusalfred/whatsapp/pkg/http" + "github.com/piusalfred/whatsapp/pkg/models" +) + +type ( + // BaseClient wraps the http client only and is used to make requests to the whatsapp api, + // It does not have the context. This is ideally for making requests to the whatsapp api for + // different users. The Client struct is used to make requests to the whatsapp api for a + // single user. + BaseClient struct { + base *whttp.Client + mw []SendMiddleware + } + + // BaseClientOption is a function that implements the BaseClientOption interface. + BaseClientOption func(*BaseClient) + + InteractiveCTAButtonURLRequest struct { + Recipient string + Params *models.CTAButtonURLParameters + } +) + +// WithBaseClientMiddleware adds a middleware to the base client. +func WithBaseClientMiddleware(mw ...SendMiddleware) BaseClientOption { + return func(client *BaseClient) { + client.mw = append(client.mw, mw...) + } +} + +// WithBaseHTTPClient sets the http client for the base client. +func WithBaseHTTPClient(httpClient *whttp.Client) BaseClientOption { + return func(client *BaseClient) { + client.base = httpClient + } +} + +// NewBaseClient creates a new base client. +func NewBaseClient(options ...BaseClientOption) *BaseClient { + b := &BaseClient{base: whttp.NewClient()} + + for _, option := range options { + option(b) + } + + return b +} + +func (c *BaseClient) SendTemplate(ctx context.Context, config *config.Values, req *SendTemplateRequest, +) (*ResponseMessage, error) { + message := &models.Message{ + Product: MessagingProduct, + To: req.Recipient, + RecipientType: RecipientTypeIndividual, + Type: models.MessageTypeTemplate, + Template: &models.Template{ + Language: &models.TemplateLanguage{ + Code: req.TemplateLanguageCode, + Policy: req.TemplateLanguagePolicy, + }, + Name: req.TemplateName, + Components: req.TemplateComponents, + }, + } + + reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeTemplate, MessageEndpoint) + + response, err := c.Send(ctx, reqCtx, message) + if err != nil { + return nil, err + } + + return response, nil +} + +func (c *BaseClient) SendInteractiveCTAURLButton(ctx context.Context, config *config.Values, + req *InteractiveCTAButtonURLRequest, +) (*ResponseMessage, error) { + message := &models.Message{ + Product: MessagingProduct, + To: req.Recipient, + RecipientType: RecipientTypeIndividual, + Type: models.MessageTypeInteractive, + Interactive: models.NewInteractiveCTAURLButton(req.Params), + } + + reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeInteractiveMessage, MessageEndpoint) + + response, err := c.Send(ctx, reqCtx, message) + if err != nil { + return nil, err + } + + return response, nil +} + +func (c *BaseClient) SendMedia(ctx context.Context, config *config.Values, req *SendMediaRequest, +) (*ResponseMessage, error) { + if req == nil { + return nil, fmt.Errorf("request is nil: %w", ErrBadRequestFormat) + } + + payload, err := formatMediaPayload(req) + if err != nil { + return nil, err + } + + reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeMedia, MessageEndpoint) + + params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), + whttp.WithBearer(config.AccessToken), whttp.WithPayload(payload)) + + if req.CacheOptions != nil { + if req.CacheOptions.CacheControl != "" { + params.Headers["Cache-Control"] = req.CacheOptions.CacheControl + } else if req.CacheOptions.Expires > 0 { + params.Headers["Cache-Control"] = fmt.Sprintf("max-age=%d", req.CacheOptions.Expires) + } + if req.CacheOptions.LastModified != "" { + params.Headers["Last-Modified"] = req.CacheOptions.LastModified + } + if req.CacheOptions.ETag != "" { + params.Headers["ETag"] = req.CacheOptions.ETag + } + } + + var message ResponseMessage + + err = c.base.Do(ctx, params, &message) + if err != nil { + return nil, fmt.Errorf("send media: %w", err) + } + + return &message, nil +} + +// formatMediaPayload builds the payload for a media message. It accepts SendMediaOptions +// and returns a byte array and an error. This function is used internally by SendMedia. +// if neither ID nor Link is specified, it returns an error. +func formatMediaPayload(options *SendMediaRequest) ([]byte, error) { + media := &models.Media{ + ID: options.MediaID, + Link: options.MediaLink, + Caption: options.Caption, + Filename: options.Filename, + Provider: options.Provider, + } + mediaJSON, err := json.Marshal(media) + if err != nil { + return nil, fmt.Errorf("format media payload: %w", err) + } + recipient := options.Recipient + mediaType := string(options.Type) + payloadBuilder := strings.Builder{} + payloadBuilder.WriteString(`{"messaging_product":"whatsapp","recipient_type":"individual","to":"`) + payloadBuilder.WriteString(recipient) + payloadBuilder.WriteString(`","type": "`) + payloadBuilder.WriteString(mediaType) + payloadBuilder.WriteString(`","`) + payloadBuilder.WriteString(mediaType) + payloadBuilder.WriteString(`":`) + payloadBuilder.Write(mediaJSON) + payloadBuilder.WriteString(`}`) + + return []byte(payloadBuilder.String()), nil +} + +func (c *BaseClient) MarkMessageRead(ctx context.Context, req *whttp.RequestContext, + messageID string, +) (*StatusResponse, error) { + reqBody := &MessageStatusUpdateRequest{ + MessagingProduct: MessagingProduct, + Status: MessageStatusRead, + MessageID: messageID, + } + + params := whttp.MakeRequest( + whttp.WithRequestContext(req), + whttp.WithBearer(req.Bearer), + whttp.WithPayload(reqBody), + ) + + var success StatusResponse + err := c.base.Do(ctx, params, &success) + if err != nil { + return nil, fmt.Errorf("mark message read: %w", err) + } + + return &success, nil +} + +func (c *BaseClient) Send(ctx context.Context, req *whttp.RequestContext, + message *models.Message, +) (*ResponseMessage, error) { + fs := WrapSender(SenderFunc(c.send), c.mw...) + + resp, err := fs.Send(ctx, req, message) + if err != nil { + return nil, fmt.Errorf("base client: %s: %w", req.RequestType, err) + } + + return resp, nil +} + +func (c *BaseClient) send(ctx context.Context, req *whttp.RequestContext, + msg *models.Message, +) (*ResponseMessage, error) { + request := whttp.MakeRequest( + whttp.WithRequestContext(req), + whttp.WithBearer(req.Bearer), + whttp.WithPayload(msg)) + + var resp ResponseMessage + err := c.base.Do(ctx, request, &resp) + if err != nil { + return nil, fmt.Errorf("%s: %w", req.RequestType, err) + } + + return &resp, nil +} diff --git a/pkg/http/endpoints.go b/pkg/http/endpoints.go new file mode 100644 index 0000000..6b57883 --- /dev/null +++ b/pkg/http/endpoints.go @@ -0,0 +1,44 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "fmt" + "net/url" + + "github.com/piusalfred/whatsapp/pkg/config" +) + +const ( + EndpointMessages = "messages" +) + +// CreateMessagesURL takes config.Values and return a messages URL which is +// something like this +// https://graph.facebook.com/v18.0/FROM_PHONE_NUMBER_ID/messages +// BaseURL + APiVersion + PhoneNumberID + MessagesEndpoint. +func CreateMessagesURL(config *config.Values) (string, error) { + path, err := url.JoinPath(config.BaseURL, config.Version, config.PhoneNumberID, EndpointMessages) + if err != nil { + return "", fmt.Errorf("create messages url: %w", err) + } + + return path, nil +} diff --git a/pkg/http/endpoints_test.go b/pkg/http/endpoints_test.go new file mode 100644 index 0000000..c6c1ce5 --- /dev/null +++ b/pkg/http/endpoints_test.go @@ -0,0 +1,67 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "testing" + + "github.com/piusalfred/whatsapp/pkg/config" +) + +func TestCreateMessagesURL(t *testing.T) { + t.Parallel() + tests := []struct { + name string + conf *config.Values + want string + wantErr bool + }{ + { + name: "full config", + conf: &config.Values{ + BaseURL: BaseURL, + Version: DefaultAPIVersion, + AccessToken: "[access-token]", + PhoneNumberID: "AC526244884", + BusinessAccountID: "9GSTSGSECDGD", + }, + want: "https://graph.facebook.com/v16.0/AC526244884/messages", + wantErr: false, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := CreateMessagesURL(tt.conf) + if (err != nil) != tt.wantErr { + t.Errorf("CreateMessagesURL() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if got != tt.want { + t.Errorf("CreateMessagesURL() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/http/http.go b/pkg/http/http.go index 0b3a85b..520fb27 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -663,7 +663,7 @@ func attachRequestType(ctx context.Context, name RequestType) context.Context { func RequestTypeFromContext(ctx context.Context) string { rt, ok := ctx.Value(requestTypeKey(requestTypeValue)).(RequestType) if !ok { - return "unknown request name" + return "" } return rt.String() diff --git a/pkg/models/interactive.go b/pkg/models/interactive.go index e113cb7..29ec3ae 100644 --- a/pkg/models/interactive.go +++ b/pkg/models/interactive.go @@ -19,12 +19,16 @@ package models +const ( + InteractiveMessageReplyButton = "button" + InteractiveMessageList = "list" + InteractiveMessageProduct = "product" + InteractiveMessageProductList = "product_list" + InteractiveMessageCTAButton = "cta_url" +) + type ( - // InteractiveMessage is the type of interactive message you want to send. Supported values are: - // - button: Use it for Reply Buttons. - // - list: Use it for ListQR Messages. - // - product: Use for Single Product Messages. - // - product_list: Use for Multi-Product Messages. + // InteractiveMessage is the type of interactive message you want to send. InteractiveMessage string // InteractiveButton contains information about a button in an interactive message. @@ -121,11 +125,13 @@ type ( // - Sections, sections (array of objects) Required for ListQR Messages and Multi-Product Messages. Array of // section objects. Minimum of 1, maximum of 10. See InteractiveSection object. InteractiveAction struct { - Button string `json:"button,omitempty"` - Buttons []*InteractiveButton `json:"buttons,omitempty"` - CatalogID string `json:"catalog_id,omitempty"` - ProductRetailerID string `json:"product_retailer_id,omitempty"` - Sections []*InteractiveSection `json:"sections,omitempty"` + Name string `json:"name,omitempty"` + Parameters *InteractiveActionParameters `json:"parameters,omitempty"` + Button string `json:"button,omitempty"` + Buttons []*InteractiveButton `json:"buttons,omitempty"` + CatalogID string `json:"catalog_id,omitempty"` + ProductRetailerID string `json:"product_retailer_id,omitempty"` + Sections []*InteractiveSection `json:"sections,omitempty"` } // InteractiveHeader contains information about an interactive header. @@ -193,6 +199,16 @@ type ( Header *InteractiveHeader `json:"header,omitempty"` } + // "parameters": { + // "display_text": "", + // "url": "" + // } + + InteractiveActionParameters struct { + URL string `json:"url,omitempty"` + DisplayText string `json:"display_text,omitempty"` + } + InteractiveOption func(*Interactive) ) @@ -276,3 +292,27 @@ func InteractiveHeaderDocument(document *Media) *InteractiveHeader { Document: document, } } + +type CTAButtonURLParameters struct { + DisplayText string + URL string + Body string + Footer string + Header string +} + +func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *Interactive { + return &Interactive{ + Type: InteractiveMessageCTAButton, + Action: &InteractiveAction{ + Name: InteractiveMessageCTAButton, + Parameters: &InteractiveActionParameters{ + URL: parameters.URL, + DisplayText: parameters.DisplayText, + }, + }, + Body: &InteractiveBody{Text: parameters.Body}, + Footer: &InteractiveFooter{Text: parameters.Footer}, + Header: InteractiveHeaderText(parameters.Header), + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 3aa7763..44ca17b 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -19,13 +19,6 @@ package models -const ( - InteractiveMessageButton = "button" - InteractiveMessageList = "list" - InteractiveMessageProduct = "product" - InteractiveMessageProductList = "product_list" -) - type ( Reaction struct { MessageID string `json:"message_id"` diff --git a/sender.go b/sender.go new file mode 100644 index 0000000..2e496ed --- /dev/null +++ b/sender.go @@ -0,0 +1,61 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package whatsapp + +import ( + "context" + + whttp "github.com/piusalfred/whatsapp/pkg/http" + "github.com/piusalfred/whatsapp/pkg/models" +) + +// Sender implementors. +var _ Sender = (*BaseClient)(nil) + +// Sender is an interface that represents a sender of a message. +type Sender interface { + Send(ctx context.Context, req *whttp.RequestContext, message *models.Message) (*ResponseMessage, error) +} + +// SenderFunc is a function that implements the Sender interface. +type SenderFunc func(ctx context.Context, req *whttp.RequestContext, + message *models.Message) (*ResponseMessage, error) + +// Send calls the function that implements the Sender interface. +func (f SenderFunc) Send(ctx context.Context, req *whttp.RequestContext, + message *models.Message) (*ResponseMessage, + error, +) { + return f(ctx, req, message) +} + +// SendMiddleware that takes a Sender and returns a new Sender that will wrap the original +// Sender and execute the middleware function before sending the message. +type SendMiddleware func(Sender) Sender + +// WrapSender wraps a Sender with a SendMiddleware. +func WrapSender(sender Sender, middleware ...SendMiddleware) Sender { + // iterate backwards so that the middleware is executed in the right order + for i := len(middleware) - 1; i >= 0; i-- { + sender = middleware[i](sender) + } + + return sender +} diff --git a/transaparent.go b/transaparent.go new file mode 100644 index 0000000..a8d58cc --- /dev/null +++ b/transaparent.go @@ -0,0 +1,48 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package whatsapp + +import ( + "context" + "fmt" + + whttp "github.com/piusalfred/whatsapp/pkg/http" + "github.com/piusalfred/whatsapp/pkg/models" +) + +// TransparentClient is a client that can send messages to a recipient without knowing the configuration of the client. +// It uses Sender instead of already configured clients. It is ideal for having a client for different environments. +type TransparentClient struct { + Middlewares []SendMiddleware +} + +// Send sends a message to the recipient. +func (client *TransparentClient) Send(ctx context.Context, sender Sender, + req *whttp.RequestContext, message *models.Message, mw ...SendMiddleware, +) (*ResponseMessage, error) { + s := WrapSender(WrapSender(sender, client.Middlewares...), mw...) + + response, err := s.Send(ctx, req, message) + if err != nil { + return nil, fmt.Errorf("transparent client: %w", err) + } + + return response, nil +} diff --git a/whatsapp.go b/whatsapp.go index 6ea6182..0eea3c1 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -25,7 +25,6 @@ import ( "errors" "fmt" "log/slog" - "net/http" "strings" "time" @@ -238,10 +237,6 @@ type ( } SendTemplateRequest struct { - BaseURL string - AccessToken string - PhoneNumberID string - ApiVersion string //nolint: revive,stylecheck Recipient string TemplateLanguageCode string TemplateLanguagePolicy string @@ -302,18 +297,14 @@ type ( } SendMediaRequest struct { - BaseURL string - AccessToken string - PhoneNumberID string - ApiVersion string //nolint: revive,stylecheck - Recipient string - Type MediaType - MediaID string - MediaLink string - Caption string - Filename string - Provider string - CacheOptions *CacheOptions + Recipient string + Type MediaType + MediaID string + MediaLink string + Caption string + Filename string + Provider string + CacheOptions *CacheOptions } ) @@ -526,18 +517,12 @@ func (client *Client) SendMediaTemplate(ctx context.Context, recipient string, r reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeMediaTemplate, MessageEndpoint) - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithPayload(payload), - whttp.WithBearer(client.config.AccessToken), - ) - - var message ResponseMessage - err := client.bc.base.Do(ctx, params, &message) + response, err := client.bc.Send(ctx, reqCtx, payload) if err != nil { - return nil, fmt.Errorf("client: send media template: %w", err) + return nil, err } - return &message, nil + return response, nil } // SendTextTemplate sends a text template message to the recipient. This kind of template message has a text @@ -618,53 +603,22 @@ func (client *Client) SendMedia(ctx context.Context, recipient string, req *Medi cacheOptions *CacheOptions, ) (*ResponseMessage, error) { request := &SendMediaRequest{ - BaseURL: client.config.BaseURL, - AccessToken: client.config.AccessToken, - PhoneNumberID: client.config.PhoneNumberID, - ApiVersion: client.config.Version, - Recipient: recipient, - Type: req.Type, - MediaID: req.MediaID, - MediaLink: req.MediaLink, - Caption: req.Caption, - Filename: req.Filename, - Provider: req.Provider, - CacheOptions: cacheOptions, + Recipient: recipient, + Type: req.Type, + MediaID: req.MediaID, + MediaLink: req.MediaLink, + Caption: req.Caption, + Filename: req.Filename, + Provider: req.Provider, + CacheOptions: cacheOptions, } - payload, err := formatMediaPayload(request) + response, err := client.bc.SendMedia(ctx, client.config, request) if err != nil { return nil, err } - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeMedia, MessageEndpoint) - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(request.AccessToken), - whttp.WithPayload(payload)) - - if request.CacheOptions != nil { - if request.CacheOptions.CacheControl != "" { - params.Headers["Cache-Control"] = request.CacheOptions.CacheControl - } else if request.CacheOptions.Expires > 0 { - params.Headers["Cache-Control"] = fmt.Sprintf("max-age=%d", request.CacheOptions.Expires) - } - if request.CacheOptions.LastModified != "" { - params.Headers["Last-Modified"] = request.CacheOptions.LastModified - } - if request.CacheOptions.ETag != "" { - params.Headers["ETag"] = request.CacheOptions.ETag - } - } - - var message ResponseMessage - - err = client.bc.base.Do(ctx, params, &message) - if err != nil { - return nil, fmt.Errorf("send media: %w", err) - } - - return &message, nil + return response, nil } // SendInteractiveTemplate send an interactive template message which contains some buttons for user interaction. @@ -685,7 +639,7 @@ func (client *Client) SendInteractiveTemplate(ctx context.Context, recipient str Code: req.LanguageCode, } template := models.NewInteractiveTemplate(req.Name, tmpLanguage, req.Headers, req.Body, req.Buttons) - payload := &models.Message{ + message := &models.Message{ Product: MessagingProduct, To: recipient, RecipientType: RecipientTypeIndividual, @@ -693,18 +647,13 @@ func (client *Client) SendInteractiveTemplate(ctx context.Context, recipient str Template: template, } reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeInteractiveTemplate, MessageEndpoint) - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(client.config.AccessToken), - whttp.WithPayload(payload)) - - var message ResponseMessage - err := client.bc.base.Do(ctx, params, &message) + response, err := client.bc.Send(ctx, reqCtx, message) if err != nil { - return nil, fmt.Errorf("send template: %w", err) + return nil, err } - return &message, nil + return response, nil } // Whatsapp is an interface that represents a whatsapp client. @@ -719,331 +668,3 @@ type Whatsapp interface { } var _ Whatsapp = (*Client)(nil) - -type ( - // BaseClient wraps the http client only and is used to make requests to the whatsapp api, - // It does not have the context. This is idealy for making requests to the whatsapp api for - // different users. The Client struct is used to make requests to the whatsapp api for a - // single user. - BaseClient struct { - base *whttp.Client - mw []SendMiddleware - } - - // BaseClientOption is a function that implements the BaseClientOption interface. - BaseClientOption func(*BaseClient) -) - -// WithBaseClientMiddleware adds a middleware to the base client. -func WithBaseClientMiddleware(mw ...SendMiddleware) BaseClientOption { - return func(client *BaseClient) { - client.mw = append(client.mw, mw...) - } -} - -// WithBaseHTTPClient sets the http client for the base client. -func WithBaseHTTPClient(httpClient *whttp.Client) BaseClientOption { - return func(client *BaseClient) { - client.base = httpClient - } -} - -// NewBaseClient creates a new base client. -func NewBaseClient(options ...BaseClientOption) *BaseClient { - b := &BaseClient{base: whttp.NewClient()} - - for _, option := range options { - option(b) - } - - return b -} - -func (c *BaseClient) SendTemplate(ctx context.Context, req *SendTemplateRequest, -) (*ResponseMessage, error) { - template := &models.Message{ - Product: MessagingProduct, - To: req.Recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeTemplate, - Template: &models.Template{ - Language: &models.TemplateLanguage{ - Code: req.TemplateLanguageCode, - Policy: req.TemplateLanguagePolicy, - }, - Name: req.TemplateName, - Components: req.TemplateComponents, - }, - } - reqCtx := &whttp.RequestContext{ - RequestType: whttp.RequestTypeTemplate, - BaseURL: req.BaseURL, - ApiVersion: req.ApiVersion, - PhoneNumberID: req.PhoneNumberID, - Endpoints: []string{"messages"}, - } - params := &whttp.Request{ - Method: http.MethodPost, - Payload: template, - Context: reqCtx, - Headers: map[string]string{ - "Content-Type": "application/json", - }, - Bearer: req.AccessToken, - } - var message ResponseMessage - err := c.base.Do(ctx, params, &message) - if err != nil { - return nil, fmt.Errorf("send template: %w", err) - } - - return &message, nil -} - -/* -SendMedia sends a media message to the recipient. To send a media message, make a POST call to the -/PHONE_NUMBER_ID/messages endpoint with type parameter set to audio, document, image, sticker, or -video, and the corresponding information for the media type such as its ID or -link (see MediaInformation http Caching). - -Be sure to keep the following in mind: - - Uploaded media only lasts thirty days - - Generated download URLs only last five minutes - - Always save the media ID when you upload a file - -Here’s a list of the currently supported media types. Check out Supported Media Types for more information. - - Audio (<16 MB) – ACC, MP4, MPEG, AMR, and OGG formats - - Documents (<100 MB) – text, PDF, Office, and OpenOffice formats - - Images (<5 MB) – JPEG and PNG formats - - Video (<16 MB) – MP4 and 3GP formats - - Stickers (<100 KB) – WebP format - -Sample request using image with link: - - curl -X POST \ - 'https://graph.facebook.com/v15.0/FROM-PHONE-NUMBER-ID/messages' \ - -H 'Authorization: Bearer ACCESS_TOKEN' \ - -H 'Content-Type: application/json' \ - -d '{ - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": "PHONE-NUMBER", - "type": "image", - "image": { - "link" : "https://IMAGE_URL" - } - }' - -Sample request using media ID: - - curl -X POST \ - 'https://graph.facebook.com/v15.0/FROM-PHONE-NUMBER-ID/messages' \ - -H 'Authorization: Bearer ACCESS_TOKEN' \ - -H 'Content-Type: application/json' \ - -d '{ - "messaging_product": "whatsapp", - "recipient_type": "individual", - "to": "PHONE-NUMBER", - "type": "image", - "image": { - "id" : "MEDIA-OBJECT-ID" - } - }' - -A successful Resp includes an object with an identifier prefixed with wamid. If you are using a link to -send the media, please check the callback events delivered to your Webhook server whether the media has been -downloaded successfully. - - { - "messaging_product": "whatsapp", - "contacts": [{ - "input": "PHONE_NUMBER", - "wa_id": "WHATSAPP_ID", - }] - "messages": [{ - "id": "wamid.ID", - }] - } -*/ -func (c *BaseClient) SendMedia(ctx context.Context, req *SendMediaRequest, -) (*ResponseMessage, error) { - if req == nil { - return nil, fmt.Errorf("request is nil: %w", ErrBadRequestFormat) - } - - payload, err := formatMediaPayload(req) - if err != nil { - return nil, err - } - - reqCtx := whttp.MakeRequestContext(&config.Values{ - BaseURL: req.BaseURL, - Version: req.ApiVersion, - AccessToken: req.AccessToken, - PhoneNumberID: req.PhoneNumberID, - }, whttp.RequestTypeMedia, MessageEndpoint) - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(req.AccessToken), whttp.WithPayload(payload)) - - if req.CacheOptions != nil { - if req.CacheOptions.CacheControl != "" { - params.Headers["Cache-Control"] = req.CacheOptions.CacheControl - } else if req.CacheOptions.Expires > 0 { - params.Headers["Cache-Control"] = fmt.Sprintf("max-age=%d", req.CacheOptions.Expires) - } - if req.CacheOptions.LastModified != "" { - params.Headers["Last-Modified"] = req.CacheOptions.LastModified - } - if req.CacheOptions.ETag != "" { - params.Headers["ETag"] = req.CacheOptions.ETag - } - } - - var message ResponseMessage - - err = c.base.Do(ctx, params, &message) - if err != nil { - return nil, fmt.Errorf("send media: %w", err) - } - - return &message, nil -} - -// formatMediaPayload builds the payload for a media message. It accepts SendMediaOptions -// and returns a byte array and an error. This function is used internally by SendMedia. -// if neither ID nor Link is specified, it returns an error. -func formatMediaPayload(options *SendMediaRequest) ([]byte, error) { - media := &models.Media{ - ID: options.MediaID, - Link: options.MediaLink, - Caption: options.Caption, - Filename: options.Filename, - Provider: options.Provider, - } - mediaJSON, err := json.Marshal(media) - if err != nil { - return nil, fmt.Errorf("format media payload: %w", err) - } - recipient := options.Recipient - mediaType := string(options.Type) - payloadBuilder := strings.Builder{} - payloadBuilder.WriteString(`{"messaging_product":"whatsapp","recipient_type":"individual","to":"`) - payloadBuilder.WriteString(recipient) - payloadBuilder.WriteString(`","type": "`) - payloadBuilder.WriteString(mediaType) - payloadBuilder.WriteString(`","`) - payloadBuilder.WriteString(mediaType) - payloadBuilder.WriteString(`":`) - payloadBuilder.Write(mediaJSON) - payloadBuilder.WriteString(`}`) - - return []byte(payloadBuilder.String()), nil -} - -func (c *BaseClient) Send(ctx context.Context, req *whttp.RequestContext, - message *models.Message, -) (*ResponseMessage, error) { - fs := WrapSender(SenderFunc(c.send), c.mw...) - - resp, err := fs.Send(ctx, req, message) - if err != nil { - return nil, fmt.Errorf("base client: %s: %w", req.RequestType, err) - } - - return resp, nil -} - -func (c *BaseClient) send(ctx context.Context, req *whttp.RequestContext, - msg *models.Message, -) (*ResponseMessage, error) { - request := whttp.MakeRequest( - whttp.WithRequestContext(req), - whttp.WithBearer(req.Bearer), - whttp.WithPayload(msg)) - - var resp ResponseMessage - err := c.base.Do(ctx, request, &resp) - if err != nil { - return nil, fmt.Errorf("%s: %w", req.RequestType, err) - } - - return &resp, nil -} - -func (c *BaseClient) MarkMessageRead(ctx context.Context, req *whttp.RequestContext, - messageID string, -) (*StatusResponse, error) { - reqBody := &MessageStatusUpdateRequest{ - MessagingProduct: MessagingProduct, - Status: MessageStatusRead, - MessageID: messageID, - } - - params := whttp.MakeRequest( - whttp.WithRequestContext(req), - whttp.WithBearer(req.Bearer), - whttp.WithPayload(reqBody), - ) - - var success StatusResponse - err := c.base.Do(ctx, params, &success) - if err != nil { - return nil, fmt.Errorf("mark message read: %w", err) - } - - return &success, nil -} - -var _ Sender = (*BaseClient)(nil) - -// Sender is an interface that represents a sender of a message. -type Sender interface { - Send(ctx context.Context, req *whttp.RequestContext, message *models.Message) (*ResponseMessage, error) -} - -// SenderFunc is a function that implements the Sender interface. -type SenderFunc func(ctx context.Context, req *whttp.RequestContext, - message *models.Message) (*ResponseMessage, error) - -// Send calls the function that implements the Sender interface. -func (f SenderFunc) Send(ctx context.Context, req *whttp.RequestContext, - message *models.Message) (*ResponseMessage, - error, -) { - return f(ctx, req, message) -} - -// SendMiddleware that takes a Sender and returns a new Sender that will wrap the original -// Sender and execute the middleware function before sending the message. -type SendMiddleware func(Sender) Sender - -// WrapSender wraps a Sender with a SendMiddleware. -func WrapSender(sender Sender, middleware ...SendMiddleware) Sender { - // iterate backwards so that the middleware is executed in the right order - for i := len(middleware) - 1; i >= 0; i-- { - sender = middleware[i](sender) - } - - return sender -} - -// TransparentClient is a client that can send messages to a recipient without knowing the configuration of the client. -// It uses Sender instead of already configured clients. It is ideal for having a client for different environments. -type TransparentClient struct { - Middlewares []SendMiddleware -} - -// Send sends a message to the recipient. -func (client *TransparentClient) Send(ctx context.Context, sender Sender, - req *whttp.RequestContext, message *models.Message, mw ...SendMiddleware, -) (*ResponseMessage, error) { - s := WrapSender(WrapSender(sender, client.Middlewares...), mw...) - - response, err := s.Send(ctx, req, message) - if err != nil { - return nil, fmt.Errorf("transparent client: %w", err) - } - - return response, nil -} From d2a8a3522120612fb0892f4e9ca8ee38040a66e6 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Wed, 3 Jan 2024 18:54:18 +0100 Subject: [PATCH 03/10] moved models creators to factories --- base.go | 5 +- pkg/models/contacts.go | 61 ------ pkg/models/factories/contacts.go | 77 +++++++ pkg/models/factories/interactive.go | 190 ++++++++++++++++++ pkg/models/factories/message.go | 34 ++++ .../{templates => factories}/templates.go | 32 +-- pkg/models/interactive.go | 112 ----------- pkg/models/models.go | 29 --- pkg/models/template.go | 104 ++-------- whatsapp.go | 9 +- 10 files changed, 323 insertions(+), 330 deletions(-) create mode 100644 pkg/models/factories/contacts.go create mode 100644 pkg/models/factories/interactive.go create mode 100644 pkg/models/factories/message.go rename pkg/models/{templates => factories}/templates.go (57%) diff --git a/base.go b/base.go index c15a8a6..f595f43 100644 --- a/base.go +++ b/base.go @@ -28,6 +28,7 @@ import ( "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" "github.com/piusalfred/whatsapp/pkg/models" + "github.com/piusalfred/whatsapp/pkg/models/factories" ) type ( @@ -45,7 +46,7 @@ type ( InteractiveCTAButtonURLRequest struct { Recipient string - Params *models.CTAButtonURLParameters + Params *factories.CTAButtonURLParameters } ) @@ -109,7 +110,7 @@ func (c *BaseClient) SendInteractiveCTAURLButton(ctx context.Context, config *co To: req.Recipient, RecipientType: RecipientTypeIndividual, Type: models.MessageTypeInteractive, - Interactive: models.NewInteractiveCTAURLButton(req.Params), + Interactive: factories.NewInteractiveCTAURLButton(req.Params), } reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeInteractiveMessage, MessageEndpoint) diff --git a/pkg/models/contacts.go b/pkg/models/contacts.go index 7203194..4587767 100644 --- a/pkg/models/contacts.go +++ b/pkg/models/contacts.go @@ -19,8 +19,6 @@ package models -import "time" - type ( Address struct { Street string `json:"street"` @@ -82,63 +80,4 @@ type ( } Contacts []*Contact - - ContactOption func(*Contact) ) - -func NewContact(name string, options ...ContactOption) *Contact { - contact := &Contact{ - Name: &Name{ - FormattedName: name, - }, - } - for _, option := range options { - option(contact) - } - - return contact -} - -func WithContactName(name *Name) ContactOption { - return func(c *Contact) { - c.Name = name - } -} - -func WithContactAddresses(addresses ...*Address) ContactOption { - return func(c *Contact) { - c.Addresses = addresses - } -} - -func WithContactOrganization(organization *Org) ContactOption { - return func(c *Contact) { - c.Org = organization - } -} - -func WithContactURLs(urls ...*Url) ContactOption { - return func(c *Contact) { - c.Urls = urls - } -} - -func WithContactPhones(phones ...*Phone) ContactOption { - return func(c *Contact) { - c.Phones = phones - } -} - -func WithContactBirthdays(birthday time.Time) ContactOption { - return func(c *Contact) { - // should be formatted as YYYY-MM-DD - bd := birthday.Format("2006-01-02") - c.Birthday = bd - } -} - -func WithContactEmails(emails ...*Email) ContactOption { - return func(c *Contact) { - c.Emails = emails - } -} diff --git a/pkg/models/factories/contacts.go b/pkg/models/factories/contacts.go new file mode 100644 index 0000000..c63b733 --- /dev/null +++ b/pkg/models/factories/contacts.go @@ -0,0 +1,77 @@ +package factories + +import ( + "time" + + "github.com/piusalfred/whatsapp/pkg/models" +) + +type ( + ContactOption func(*models.Contact) +) + +func NewContact(name string, options ...ContactOption) *models.Contact { + contact := &models.Contact{ + Name: &models.Name{ + FormattedName: name, + }, + } + for _, option := range options { + option(contact) + } + + return contact +} + +func WithContactName(name *models.Name) ContactOption { + return func(c *models.Contact) { + c.Name = name + } +} + +func WithContactAddresses(addresses ...*models.Address) ContactOption { + return func(c *models.Contact) { + c.Addresses = addresses + } +} + +func WithContactOrganization(organization *models.Org) ContactOption { + return func(c *models.Contact) { + c.Org = organization + } +} + +func WithContactURLs(urls ...*models.Url) ContactOption { + return func(c *models.Contact) { + c.Urls = urls + } +} + +func WithContactPhones(phones ...*models.Phone) ContactOption { + return func(c *models.Contact) { + c.Phones = phones + } +} + +func WithContactBirthdays(birthday time.Time) ContactOption { + return func(c *models.Contact) { + // should be formatted as YYYY-MM-DD + bd := birthday.Format("2006-01-02") + c.Birthday = bd + } +} + +func WithContactEmails(emails ...*models.Email) ContactOption { + return func(c *models.Contact) { + c.Emails = emails + } +} + +// NewContacts ... +func NewContacts(contacts []*models.Contact) models.Contacts { + if contacts != nil { + return models.Contacts(contacts) + } + + return nil +} diff --git a/pkg/models/factories/interactive.go b/pkg/models/factories/interactive.go new file mode 100644 index 0000000..b3efb8f --- /dev/null +++ b/pkg/models/factories/interactive.go @@ -0,0 +1,190 @@ +package factories + +import "github.com/piusalfred/whatsapp/pkg/models" + +type CTAButtonURLParameters struct { + DisplayText string + URL string + Body string + Footer string + Header string +} + +func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *models.Interactive { + return &models.Interactive{ + Type: models.InteractiveMessageCTAButton, + Action: &models.InteractiveAction{ + Name: models.InteractiveMessageCTAButton, + Parameters: &models.InteractiveActionParameters{ + URL: parameters.URL, + DisplayText: parameters.DisplayText, + }, + }, + Body: &models.InteractiveBody{Text: parameters.Body}, + Footer: &models.InteractiveFooter{Text: parameters.Footer}, + Header: InteractiveHeaderText(parameters.Header), + } +} + +func InteractiveHeaderText(text string) *models.InteractiveHeader { + return &models.InteractiveHeader{ + Type: "text", + Text: text, + } +} + +func InteractiveHeaderImage(image *models.Media) *models.InteractiveHeader { + return &models.InteractiveHeader{ + Type: "image", + Image: image, + } +} + +func InteractiveHeaderVideo(video *models.Media) *models.InteractiveHeader { + return &models.InteractiveHeader{ + Type: "video", + Video: video, + } +} + +func InteractiveHeaderDocument(document *models.Media) *models.InteractiveHeader { + return &models.InteractiveHeader{ + Type: "document", + Document: document, + } +} + +// CreateInteractiveRelyButtonList creates a list of InteractiveButton with type reply, A max of +// 3 buttons can be added to a message. So do not add more than 3 buttons. +func CreateInteractiveRelyButtonList(buttons ...*models.InteractiveReplyButton) []*models.InteractiveButton { + var list []*models.InteractiveButton + for _, button := range buttons { + list = append(list, &models.InteractiveButton{ + Type: "reply", + Reply: button, + }) + } + + return list +} + +// NewInteractiveTemplate creates a new interactive template. +func NewInteractiveTemplate(name string, language *models.TemplateLanguage, headers []*models.TemplateParameter, + bodies []*models.TemplateParameter, buttons []*models.InteractiveButtonTemplate, +) *models.Template { + var components []*models.TemplateComponent + headerTemplate := &models.TemplateComponent{ + Type: "header", + Parameters: headers, + } + components = append(components, headerTemplate) + + bodyTemplate := &models.TemplateComponent{ + Type: "body", + Parameters: bodies, + } + components = append(components, bodyTemplate) + + for _, button := range buttons { + b := &models.TemplateComponent{ + Type: "button", + SubType: button.SubType, + Index: button.Index, + Parameters: []*models.TemplateParameter{ + { + Type: button.Button.Type, + Text: button.Button.Text, + Payload: button.Button.Payload, + }, + }, + } + + components = append(components, b) + } + + return &models.Template{ + Name: name, + Language: language, + Components: components, + } +} + +func NewTextTemplate(name string, language *models.TemplateLanguage, parameters []*models.TemplateParameter) *models.Template { + component := &models.TemplateComponent{ + Type: "body", + Parameters: parameters, + } + + return &models.Template{ + Name: name, + Language: language, + Components: []*models.TemplateComponent{component}, + } +} + +// NewMediaTemplate create a media based template. +func NewMediaTemplate(name string, language *models.TemplateLanguage, header *models.TemplateParameter, + bodies []*models.TemplateParameter, +) *models.Template { + var components []*models.TemplateComponent + headerTemplate := &models.TemplateComponent{ + Type: "header", + Parameters: []*models.TemplateParameter{header}, + } + components = append(components, headerTemplate) + + bodyTemplate := &models.TemplateComponent{ + Type: "body", + Parameters: bodies, + } + components = append(components, bodyTemplate) + + return &models.Template{ + Name: name, + Language: language, + Components: components, + } +} + +type ( + InteractiveOption func(*models.Interactive) +) + +func WithInteractiveFooter(footer string) InteractiveOption { + return func(i *models.Interactive) { + i.Footer = &models.InteractiveFooter{ + Text: footer, + } + } +} + +func WithInteractiveBody(body string) InteractiveOption { + return func(i *models.Interactive) { + i.Body = &models.InteractiveBody{ + Text: body, + } + } +} + +func WithInteractiveHeader(header *models.InteractiveHeader) InteractiveOption { + return func(i *models.Interactive) { + i.Header = header + } +} + +func WithInteractiveAction(action *models.InteractiveAction) InteractiveOption { + return func(i *models.Interactive) { + i.Action = action + } +} + +func NewInteractiveMessage(interactiveType string, options ...InteractiveOption) *models.Interactive { + interactive := &models.Interactive{ + Type: interactiveType, + } + for _, option := range options { + option(interactive) + } + + return interactive +} diff --git a/pkg/models/factories/message.go b/pkg/models/factories/message.go new file mode 100644 index 0000000..fb9d404 --- /dev/null +++ b/pkg/models/factories/message.go @@ -0,0 +1,34 @@ +package factories + +import "github.com/piusalfred/whatsapp/pkg/models" + +type ( + MessageOption func(*models.Message) +) + +func NewMessage(recipient string, options ...MessageOption) *models.Message { + message := &models.Message{ + Product: "whatsapp", + RecipientType: "individual", + To: recipient, + } + for _, option := range options { + option(message) + } + + return message +} + +func WithMessageTemplate(template *models.Template) MessageOption { + return func(m *models.Message) { + m.Type = "template" + m.Template = template + } +} + +func WithMessageText(text *models.Text) MessageOption { + return func(m *models.Message) { + m.Type = "text" + m.Text = text + } +} diff --git a/pkg/models/templates/templates.go b/pkg/models/factories/templates.go similarity index 57% rename from pkg/models/templates/templates.go rename to pkg/models/factories/templates.go index 06c496e..0b48706 100644 --- a/pkg/models/templates/templates.go +++ b/pkg/models/factories/templates.go @@ -21,34 +21,4 @@ their API calls with the access token generated in the App Dashboard > WhatsApp Business Solution Providers (BSPs) need to authenticate themselves with an access token that has the 'whatsapp_business_messaging' permission. */ -package templates - -type Message struct { - MessagingProduct string `json:"messaging_product"` - RecipientType string `json:"recipient_type"` - To string `json:"to"` - Type string `json:"type"` - Template *Template `json:"template"` -} - -type Template struct { - Name string `json:"name"` - Language Language `json:"language"` - Components []Component `json:"components"` -} - -type Language struct { - Code string `json:"code"` -} - -type Component struct { - Type string `json:"type"` - SubType string `json:"sub_type,omitempty"` - Index string `json:"index,omitempty"` - Parameters []Parameter `json:"parameters"` -} - -type Parameter struct { - Type string `json:"type"` - Text string `json:"text"` -} +package factories diff --git a/pkg/models/interactive.go b/pkg/models/interactive.go index 29ec3ae..180ce35 100644 --- a/pkg/models/interactive.go +++ b/pkg/models/interactive.go @@ -199,120 +199,8 @@ type ( Header *InteractiveHeader `json:"header,omitempty"` } - // "parameters": { - // "display_text": "", - // "url": "" - // } - InteractiveActionParameters struct { URL string `json:"url,omitempty"` DisplayText string `json:"display_text,omitempty"` } - - InteractiveOption func(*Interactive) ) - -// CreateInteractiveRelyButtonList creates a list of InteractiveButton with type reply, A max of -// 3 buttons can be added to a message. So do not add more than 3 buttons. -func CreateInteractiveRelyButtonList(buttons ...*InteractiveReplyButton) []*InteractiveButton { - var list []*InteractiveButton - for _, button := range buttons { - list = append(list, &InteractiveButton{ - Type: "reply", - Reply: button, - }) - } - - return list -} - -func WithInteractiveFooter(footer string) InteractiveOption { - return func(i *Interactive) { - i.Footer = &InteractiveFooter{ - Text: footer, - } - } -} - -func WithInteractiveBody(body string) InteractiveOption { - return func(i *Interactive) { - i.Body = &InteractiveBody{ - Text: body, - } - } -} - -func WithInteractiveHeader(header *InteractiveHeader) InteractiveOption { - return func(i *Interactive) { - i.Header = header - } -} - -func WithInteractiveAction(action *InteractiveAction) InteractiveOption { - return func(i *Interactive) { - i.Action = action - } -} - -func NewInteractiveMessage(interactiveType string, options ...InteractiveOption) *Interactive { - interactive := &Interactive{ - Type: interactiveType, - } - for _, option := range options { - option(interactive) - } - - return interactive -} - -func InteractiveHeaderText(text string) *InteractiveHeader { - return &InteractiveHeader{ - Type: "text", - Text: text, - } -} - -func InteractiveHeaderImage(image *Media) *InteractiveHeader { - return &InteractiveHeader{ - Type: "image", - Image: image, - } -} - -func InteractiveHeaderVideo(video *Media) *InteractiveHeader { - return &InteractiveHeader{ - Type: "video", - Video: video, - } -} - -func InteractiveHeaderDocument(document *Media) *InteractiveHeader { - return &InteractiveHeader{ - Type: "document", - Document: document, - } -} - -type CTAButtonURLParameters struct { - DisplayText string - URL string - Body string - Footer string - Header string -} - -func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *Interactive { - return &Interactive{ - Type: InteractiveMessageCTAButton, - Action: &InteractiveAction{ - Name: InteractiveMessageCTAButton, - Parameters: &InteractiveActionParameters{ - URL: parameters.URL, - DisplayText: parameters.DisplayText, - }, - }, - Body: &InteractiveBody{Text: parameters.Body}, - Footer: &InteractiveFooter{Text: parameters.Footer}, - Header: InteractiveHeaderText(parameters.Header), - } -} diff --git a/pkg/models/models.go b/pkg/models/models.go index 44ca17b..9dbc0c6 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -182,8 +182,6 @@ type ( Interactive *Interactive `json:"interactive,omitempty"` } - MessageOption func(*Message) - // InteractiveHeaderType represent required value of InteractiveHeader.Type // The header type you would like to use. Supported values: // text: Used for ListQR Messages, Reply Buttons, and Multi-Product Messages. @@ -206,33 +204,6 @@ const ( FooterMaxLength = 60 ) -// NewMessage creates a new message. -func NewMessage(recipient string, options ...MessageOption) *Message { - message := &Message{ - Product: "whatsapp", - RecipientType: "individual", - To: recipient, - } - for _, option := range options { - option(message) - } - - return message -} - -func WithTemplate(template *Template) MessageOption { - return func(m *Message) { - m.Type = "template" - m.Template = template - } -} - -// SetTemplate sets the template of the message. -func (m *Message) SetTemplate(template *Template) { - m.Type = "template" - m.Template = template -} - type MessageType string const ( diff --git a/pkg/models/template.go b/pkg/models/template.go index 13516c9..54c2926 100644 --- a/pkg/models/template.go +++ b/pkg/models/template.go @@ -19,6 +19,11 @@ package models +const ( + TemplateComponentTypeHeader TemplateComponentType = "header" + TemplateComponentTypeBody TemplateComponentType = "body" +) + type ( // TemplateDateTime contains information about a date_time parameter. // FallbackValue, fallback_value. Required. Default text if localization fails. @@ -186,97 +191,14 @@ type ( Code string `json:"code,omitempty"` Amount1000 int `json:"amount_1000"` } -) - -// TemplateComponentType is a type of component of a template message. -// It can be a header, body. -type TemplateComponentType string - -const ( - TemplateComponentTypeHeader TemplateComponentType = "header" - TemplateComponentTypeBody TemplateComponentType = "body" -) - -func NewTextTemplate(name string, language *TemplateLanguage, parameters []*TemplateParameter) *Template { - component := &TemplateComponent{ - Type: "body", - Parameters: parameters, - } - - return &Template{ - Name: name, - Language: language, - Components: []*TemplateComponent{component}, - } -} - -// NewMediaTemplate create a media based template. -func NewMediaTemplate(name string, language *TemplateLanguage, header *TemplateParameter, - bodies []*TemplateParameter, -) *Template { - var components []*TemplateComponent - headerTemplate := &TemplateComponent{ - Type: "header", - Parameters: []*TemplateParameter{header}, - } - components = append(components, headerTemplate) - - bodyTemplate := &TemplateComponent{ - Type: "body", - Parameters: bodies, - } - components = append(components, bodyTemplate) - - return &Template{ - Name: name, - Language: language, - Components: components, - } -} -type InteractiveButtonTemplate struct { - SubType string - Index int - Button *TemplateButton -} + // TemplateComponentType is a type of component of a template message. + // It can be a header, body. + TemplateComponentType string -// NewInteractiveTemplate creates a new interactive template. -func NewInteractiveTemplate(name string, language *TemplateLanguage, headers []*TemplateParameter, - bodies []*TemplateParameter, buttons []*InteractiveButtonTemplate, -) *Template { - var components []*TemplateComponent - headerTemplate := &TemplateComponent{ - Type: "header", - Parameters: headers, + InteractiveButtonTemplate struct { + SubType string + Index int + Button *TemplateButton } - components = append(components, headerTemplate) - - bodyTemplate := &TemplateComponent{ - Type: "body", - Parameters: bodies, - } - components = append(components, bodyTemplate) - - for _, button := range buttons { - b := &TemplateComponent{ - Type: "button", - SubType: button.SubType, - Index: button.Index, - Parameters: []*TemplateParameter{ - { - Type: button.Button.Type, - Text: button.Button.Text, - Payload: button.Button.Payload, - }, - }, - } - - components = append(components, b) - } - - return &Template{ - Name: name, - Language: language, - Components: components, - } -} +) diff --git a/whatsapp.go b/whatsapp.go index 0eea3c1..b37a3c2 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -31,6 +31,7 @@ import ( "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" "github.com/piusalfred/whatsapp/pkg/models" + "github.com/piusalfred/whatsapp/pkg/models/factories" ) var ( @@ -506,7 +507,7 @@ func (client *Client) SendMediaTemplate(ctx context.Context, recipient string, r Policy: req.LanguagePolicy, Code: req.LanguageCode, } - template := models.NewMediaTemplate(req.Name, tmpLanguage, req.Header, req.Body) + template := factories.NewMediaTemplate(req.Name, tmpLanguage, req.Header, req.Body) payload := &models.Message{ Product: MessagingProduct, To: recipient, @@ -534,8 +535,8 @@ func (client *Client) SendTextTemplate(ctx context.Context, recipient string, re Policy: req.LanguagePolicy, Code: req.LanguageCode, } - template := models.NewTextTemplate(req.Name, tmpLanguage, req.Body) - payload := models.NewMessage(recipient, models.WithTemplate(template)) + template := factories.NewTextTemplate(req.Name, tmpLanguage, req.Body) + payload := factories.NewMessage(recipient, factories.WithMessageTemplate(template)) reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeTextTemplate, MessageEndpoint) params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), whttp.WithPayload(payload), @@ -638,7 +639,7 @@ func (client *Client) SendInteractiveTemplate(ctx context.Context, recipient str Policy: req.LanguagePolicy, Code: req.LanguageCode, } - template := models.NewInteractiveTemplate(req.Name, tmpLanguage, req.Headers, req.Body, req.Buttons) + template := factories.NewInteractiveTemplate(req.Name, tmpLanguage, req.Headers, req.Body, req.Buttons) message := &models.Message{ Product: MessagingProduct, To: recipient, From 304eaa4466be249d5a7eca7a6ded9f5659eb980d Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sat, 13 Jan 2024 22:37:16 +0100 Subject: [PATCH 04/10] refactor the base client --- .golangci.yml | 2 + Makefile | 14 - base.go | 283 ++++--- examples/base/.envrc | 5 + examples/base/.gitignore | 2 + examples/base/Makefile | 2 + examples/base/go.mod | 10 + examples/base/go.sum | 2 + examples/base/main.go | 141 ++++ media.go | 542 ++++++------- media_test.go | 73 -- phone_numbers.go | 450 +++++------ pkg/config/config.go | 6 + pkg/http/endpoints.go | 44 -- pkg/http/exp.go | 114 +++ pkg/http/generic.go | 147 ++++ pkg/http/hooks.go | 87 --- pkg/http/http.go | 645 ++++------------ pkg/http/http_test.go | 302 -------- pkg/http/request.go | 429 +++++++++++ .../{endpoints_test.go => request_test.go} | 55 +- pkg/http/response.go | 139 ++++ pkg/http/slog.go | 64 -- pkg/models/factories/contacts.go | 21 +- pkg/models/factories/interactive.go | 37 +- pkg/models/factories/message.go | 286 ++++++- pkg/models/factories/templates.go | 60 +- pkg/models/interactive.go | 13 +- pkg/models/models.go | 54 +- pkg/models/template.go | 8 +- qr.go | 383 ++++----- sender.go | 8 +- templates_test.go | 20 - transaparent.go | 48 -- webhooks/listener.go | 1 + webhooks/models.go | 4 +- whatsapp.go | 726 +++--------------- whatsapp_test.go | 62 -- 38 files changed, 2524 insertions(+), 2765 deletions(-) create mode 100644 examples/base/.envrc create mode 100644 examples/base/.gitignore create mode 100644 examples/base/Makefile create mode 100644 examples/base/go.mod create mode 100644 examples/base/go.sum create mode 100644 examples/base/main.go delete mode 100644 media_test.go delete mode 100644 pkg/http/endpoints.go create mode 100644 pkg/http/exp.go create mode 100644 pkg/http/generic.go delete mode 100644 pkg/http/hooks.go delete mode 100644 pkg/http/http_test.go create mode 100644 pkg/http/request.go rename pkg/http/{endpoints_test.go => request_test.go} (61%) create mode 100644 pkg/http/response.go delete mode 100644 pkg/http/slog.go delete mode 100644 templates_test.go delete mode 100644 transaparent.go delete mode 100644 whatsapp_test.go diff --git a/.golangci.yml b/.golangci.yml index 3bed823..f7001c2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -52,6 +52,7 @@ linters: enable-all: true # enable some additional linters that are not enabled by default disable: + - ireturn - cyclop - testpackage - prealloc @@ -77,6 +78,7 @@ linters: - varnamelen - depguard - forbidigo + - bodyclose # specify the output format for linter results diff --git a/Makefile b/Makefile index 701af96..e69de29 100644 --- a/Makefile +++ b/Makefile @@ -1,14 +0,0 @@ -build: - go build -v -race ./... - -test: - go test -v -race -parallel 32 ./... - -build-cli: - go build -o bin/whatsapp cmd/main.go - -format: - go fmt ./... && find . -type f -name "*.go" | cut -c 3- | xargs -I{} gofumpt -w "{}" - -gci: - find . -type f -name "*.go" | cut -c 3- | xargs -I{} gci write -s standard -s default -s "prefix(github.com/piusalfred/whatsapp)" "{}" \ No newline at end of file diff --git a/base.go b/base.go index f595f43..011b341 100644 --- a/base.go +++ b/base.go @@ -21,9 +21,8 @@ package whatsapp import ( "context" - "encoding/json" "fmt" - "strings" + "net/http" "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" @@ -32,17 +31,12 @@ import ( ) type ( - // BaseClient wraps the http client only and is used to make requests to the whatsapp api, - // It does not have the context. This is ideally for making requests to the whatsapp api for - // different users. The Client struct is used to make requests to the whatsapp api for a - // single user. - BaseClient struct { - base *whttp.Client + Client struct { + base *whttp.BaseClient mw []SendMiddleware } - // BaseClientOption is a function that implements the BaseClientOption interface. - BaseClientOption func(*BaseClient) + ClientOption func(*Client) InteractiveCTAButtonURLRequest struct { Recipient string @@ -50,166 +44,187 @@ type ( } ) -// WithBaseClientMiddleware adds a middleware to the base client. -func WithBaseClientMiddleware(mw ...SendMiddleware) BaseClientOption { - return func(client *BaseClient) { - client.mw = append(client.mw, mw...) +func (c *Client) Image(ctx context.Context, params *RequestParams, image *models.Image, + options *whttp.CacheOptions, +) (*whttp.ResponseMessage, error) { + message, err := factories.ImageMessage(params.Recipient, image, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("image message: %w", err) } + + return c.Send(ctx, fmtParamsToContext(params, options), message) } -// WithBaseHTTPClient sets the http client for the base client. -func WithBaseHTTPClient(httpClient *whttp.Client) BaseClientOption { - return func(client *BaseClient) { - client.base = httpClient +func (c *Client) Audio(ctx context.Context, params *RequestParams, audio *models.Audio, + options *whttp.CacheOptions, +) (*whttp.ResponseMessage, error) { + message, err := factories.AudioMessage(params.Recipient, audio, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("audio message: %w", err) } -} -// NewBaseClient creates a new base client. -func NewBaseClient(options ...BaseClientOption) *BaseClient { - b := &BaseClient{base: whttp.NewClient()} + return c.Send(ctx, fmtParamsToContext(params, options), message) +} - for _, option := range options { - option(b) +func (c *Client) Video(ctx context.Context, params *RequestParams, video *models.Video, + options *whttp.CacheOptions, +) (*whttp.ResponseMessage, error) { + message, err := factories.VideoMessage(params.Recipient, video, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("video message: %w", err) } - return b + return c.Send(ctx, fmtParamsToContext(params, options), message) } -func (c *BaseClient) SendTemplate(ctx context.Context, config *config.Values, req *SendTemplateRequest, -) (*ResponseMessage, error) { - message := &models.Message{ - Product: MessagingProduct, - To: req.Recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeTemplate, - Template: &models.Template{ - Language: &models.TemplateLanguage{ - Code: req.TemplateLanguageCode, - Policy: req.TemplateLanguagePolicy, - }, - Name: req.TemplateName, - Components: req.TemplateComponents, - }, +func (c *Client) Document(ctx context.Context, params *RequestParams, document *models.Document, + options *whttp.CacheOptions, +) (*whttp.ResponseMessage, error) { + message, err := factories.DocumentMessage(params.Recipient, document, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("document message: %w", err) } - reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeTemplate, MessageEndpoint) + return c.Send(ctx, fmtParamsToContext(params, options), message) +} - response, err := c.Send(ctx, reqCtx, message) +func (c *Client) Sticker(ctx context.Context, params *RequestParams, sticker *models.Sticker, + options *whttp.CacheOptions, +) (*whttp.ResponseMessage, error) { + message, err := factories.StickerMessage(params.Recipient, sticker, + factories.WithReplyToMessageID(params.ReplyID)) if err != nil { - return nil, err + return nil, fmt.Errorf("sticker message: %w", err) } - return response, nil + return c.Send(ctx, fmtParamsToContext(params, options), message) } -func (c *BaseClient) SendInteractiveCTAURLButton(ctx context.Context, config *config.Values, - req *InteractiveCTAButtonURLRequest, -) (*ResponseMessage, error) { - message := &models.Message{ - Product: MessagingProduct, - To: req.Recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeInteractive, - Interactive: factories.NewInteractiveCTAURLButton(req.Params), +func (c *Client) InteractiveMessage(ctx context.Context, params *RequestParams, + interactive *models.Interactive, +) (*whttp.ResponseMessage, error) { + message, err := factories.InteractiveMessage(params.Recipient, interactive, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("interactive message: %w", err) } - reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeInteractiveMessage, MessageEndpoint) + return c.Send(ctx, fmtParamsToContext(params, nil), message) +} - response, err := c.Send(ctx, reqCtx, message) +func (c *Client) Template(ctx context.Context, params *RequestParams, + template *models.Template, +) (*whttp.ResponseMessage, error) { + message, err := factories.TemplateMessage(params.Recipient, template, + factories.WithReplyToMessageID(params.ReplyID)) if err != nil { - return nil, err + return nil, fmt.Errorf("template message: %w", err) } - return response, nil + return c.Send(ctx, fmtParamsToContext(params, nil), message) } -func (c *BaseClient) SendMedia(ctx context.Context, config *config.Values, req *SendMediaRequest, -) (*ResponseMessage, error) { - if req == nil { - return nil, fmt.Errorf("request is nil: %w", ErrBadRequestFormat) +func (c *Client) Contacts(ctx context.Context, params *RequestParams, contacts []*models.Contact) ( + *whttp.ResponseMessage, error, +) { + message, err := factories.ContactsMessage(params.Recipient, contacts, + factories.WithReplyToMessageID(params.ReplyID)) + if err != nil { + return nil, fmt.Errorf("contacts message: %w", err) } - payload, err := formatMediaPayload(req) + return c.Send(ctx, fmtParamsToContext(params, nil), message) +} + +func (c *Client) Location(ctx context.Context, params *RequestParams, + request *models.Location, +) (*whttp.ResponseMessage, error) { + message, err := factories.LocationMessage(params.Recipient, request, + factories.WithReplyToMessageID(params.ReplyID)) if err != nil { - return nil, err + return nil, fmt.Errorf("location message: %w", err) } - reqCtx := whttp.MakeRequestContext(config, whttp.RequestTypeMedia, MessageEndpoint) - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(config.AccessToken), whttp.WithPayload(payload)) + return c.Send(ctx, fmtParamsToContext(params, nil), message) +} - if req.CacheOptions != nil { - if req.CacheOptions.CacheControl != "" { - params.Headers["Cache-Control"] = req.CacheOptions.CacheControl - } else if req.CacheOptions.Expires > 0 { - params.Headers["Cache-Control"] = fmt.Sprintf("max-age=%d", req.CacheOptions.Expires) - } - if req.CacheOptions.LastModified != "" { - params.Headers["Last-Modified"] = req.CacheOptions.LastModified - } - if req.CacheOptions.ETag != "" { - params.Headers["ETag"] = req.CacheOptions.ETag - } +func (c *Client) React(ctx context.Context, params *RequestParams, + msg *models.Reaction, +) (*whttp.ResponseMessage, error) { + message, err := factories.ReactionMessage(params.Recipient, msg) + if err != nil { + return nil, fmt.Errorf("reaction message: %w", err) } - var message ResponseMessage + return c.Send(ctx, fmtParamsToContext(params, nil), message) +} - err = c.base.Do(ctx, params, &message) +func (c *Client) Text(ctx context.Context, params *RequestParams, text *models.Text) (*whttp.ResponseMessage, error) { + message, err := factories.TextMessage(params.Recipient, text, + factories.WithReplyToMessageID(params.ReplyID)) if err != nil { - return nil, fmt.Errorf("send media: %w", err) + return nil, fmt.Errorf("text message: %w", err) } - return &message, nil + return c.Send(ctx, fmtParamsToContext(params, nil), message) } -// formatMediaPayload builds the payload for a media message. It accepts SendMediaOptions -// and returns a byte array and an error. This function is used internally by SendMedia. -// if neither ID nor Link is specified, it returns an error. -func formatMediaPayload(options *SendMediaRequest) ([]byte, error) { - media := &models.Media{ - ID: options.MediaID, - Link: options.MediaLink, - Caption: options.Caption, - Filename: options.Filename, - Provider: options.Provider, +// WithBaseClientMiddleware adds a middleware to the base client. +func WithBaseClientMiddleware(mw ...SendMiddleware) ClientOption { + return func(client *Client) { + client.mw = append(client.mw, mw...) + } +} + +// WithBaseHTTPClient sets the http client for the base client. +func WithBaseHTTPClient(httpClient *whttp.BaseClient) ClientOption { + return func(client *Client) { + client.base = httpClient + } +} + +// WithBaseClientOptions sets the options for the base client. +func WithBaseClientOptions(options []whttp.BaseClientOption) ClientOption { + return func(client *Client) { + client.base.ApplyOptions(options...) } - mediaJSON, err := json.Marshal(media) +} + +// NewBaseClient creates a new base client. +func NewBaseClient(ctx context.Context, configure config.Reader, options ...ClientOption) (*Client, error) { + inner, err := whttp.InitBaseClient(ctx, configure) if err != nil { - return nil, fmt.Errorf("format media payload: %w", err) + return nil, fmt.Errorf("init base client: %w", err) + } + + b := &Client{base: inner} + + for _, option := range options { + option(b) } - recipient := options.Recipient - mediaType := string(options.Type) - payloadBuilder := strings.Builder{} - payloadBuilder.WriteString(`{"messaging_product":"whatsapp","recipient_type":"individual","to":"`) - payloadBuilder.WriteString(recipient) - payloadBuilder.WriteString(`","type": "`) - payloadBuilder.WriteString(mediaType) - payloadBuilder.WriteString(`","`) - payloadBuilder.WriteString(mediaType) - payloadBuilder.WriteString(`":`) - payloadBuilder.Write(mediaJSON) - payloadBuilder.WriteString(`}`) - return []byte(payloadBuilder.String()), nil + return b, nil } -func (c *BaseClient) MarkMessageRead(ctx context.Context, req *whttp.RequestContext, +func (c *Client) MarkMessageRead(ctx context.Context, req *whttp.RequestContext, messageID string, -) (*StatusResponse, error) { - reqBody := &MessageStatusUpdateRequest{ - MessagingProduct: MessagingProduct, - Status: MessageStatusRead, +) (*whttp.ResponseStatus, error) { + reqBody := &statusUpdateRequest{ + MessagingProduct: factories.MessagingProductWhatsApp, + Status: "read", MessageID: messageID, } params := whttp.MakeRequest( whttp.WithRequestContext(req), - whttp.WithBearer(req.Bearer), - whttp.WithPayload(reqBody), + whttp.WithRequestPayload(reqBody), ) - var success StatusResponse + var success whttp.ResponseStatus err := c.base.Do(ctx, params, &success) if err != nil { return nil, fmt.Errorf("mark message read: %w", err) @@ -218,32 +233,46 @@ func (c *BaseClient) MarkMessageRead(ctx context.Context, req *whttp.RequestCont return &success, nil } -func (c *BaseClient) Send(ctx context.Context, req *whttp.RequestContext, +func (c *Client) Send(ctx context.Context, req *whttp.RequestContext, message *models.Message, -) (*ResponseMessage, error) { +) (*whttp.ResponseMessage, error) { fs := WrapSender(SenderFunc(c.send), c.mw...) resp, err := fs.Send(ctx, req, message) if err != nil { - return nil, fmt.Errorf("base client: %s: %w", req.RequestType, err) + return nil, fmt.Errorf("base client: %w", err) } return resp, nil } -func (c *BaseClient) send(ctx context.Context, req *whttp.RequestContext, +func (c *Client) send(ctx context.Context, req *whttp.RequestContext, msg *models.Message, -) (*ResponseMessage, error) { +) (*whttp.ResponseMessage, error) { request := whttp.MakeRequest( whttp.WithRequestContext(req), - whttp.WithBearer(req.Bearer), - whttp.WithPayload(msg)) + whttp.WithRequestPayload(msg), + whttp.WithRequestMethod(http.MethodPost), + whttp.WithRequestHeaders(map[string]string{ + "Content-Type": "application/json", + }), + ) - var resp ResponseMessage - err := c.base.Do(ctx, request, &resp) + resp, err := c.base.Send(ctx, request) if err != nil { - return nil, fmt.Errorf("%s: %w", req.RequestType, err) + return nil, fmt.Errorf("%s: %w", req.String(), err) } - return &resp, nil + return resp, nil +} + +func fmtParamsToContext(params *RequestParams, cache *whttp.CacheOptions) *whttp.RequestContext { + return whttp.MakeRequestContext( + whttp.WithRequestContextID(params.ID), + whttp.WithRequestContextMetadata(params.Metadata), + whttp.WithRequestContextAction(whttp.RequestActionSend), + whttp.WithRequestContextCategory(whttp.RequestCategoryMessage), + whttp.WithRequestContextName(whttp.RequestNameLocation), + whttp.WithRequestContextCacheOptions(cache), + ) } diff --git a/examples/base/.envrc b/examples/base/.envrc new file mode 100644 index 0000000..44261c4 --- /dev/null +++ b/examples/base/.envrc @@ -0,0 +1,5 @@ +BASE_URL=https://graph.facebook.com +VERSION=v16.0 +ACCESS_TOKEN=EAACEdEos +PHONE_NUMBER_ID=123456789 +BUSINESS_ACCOUNT_ID=123456789 \ No newline at end of file diff --git a/examples/base/.gitignore b/examples/base/.gitignore new file mode 100644 index 0000000..1ec431e --- /dev/null +++ b/examples/base/.gitignore @@ -0,0 +1,2 @@ +.env +base \ No newline at end of file diff --git a/examples/base/Makefile b/examples/base/Makefile new file mode 100644 index 0000000..7a68a74 --- /dev/null +++ b/examples/base/Makefile @@ -0,0 +1,2 @@ +run: + go build -o base main.go && ./base \ No newline at end of file diff --git a/examples/base/go.mod b/examples/base/go.mod new file mode 100644 index 0000000..89d5866 --- /dev/null +++ b/examples/base/go.mod @@ -0,0 +1,10 @@ +module github.com/piusalfred/whatsapp/examples/base + +replace github.com/piusalfred/whatsapp => ../.. + +go 1.21.5 + +require ( + github.com/joho/godotenv v1.5.1 + github.com/piusalfred/whatsapp v0.0.0-00010101000000-000000000000 +) diff --git a/examples/base/go.sum b/examples/base/go.sum new file mode 100644 index 0000000..d61b19e --- /dev/null +++ b/examples/base/go.sum @@ -0,0 +1,2 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= diff --git a/examples/base/main.go b/examples/base/main.go new file mode 100644 index 0000000..160ce7f --- /dev/null +++ b/examples/base/main.go @@ -0,0 +1,141 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/joho/godotenv" + "github.com/piusalfred/whatsapp" + "github.com/piusalfred/whatsapp/pkg/config" + whttp "github.com/piusalfred/whatsapp/pkg/http" + "github.com/piusalfred/whatsapp/pkg/models" +) + +var _ config.Reader = (*dotEnvReader)(nil) + +type dotEnvReader struct { + filePath string +} + +func (d *dotEnvReader) Read(ctx context.Context) (*config.Values, error) { + vm, err := godotenv.Read(d.filePath) + if err != nil { + return nil, err + } + + return &config.Values{ + BaseURL: vm["BASE_URL"], + Version: vm["VERSION"], + AccessToken: vm["ACCESS_TOKEN"], + PhoneNumberID: vm["PHONE_NUMBER_ID"], + BusinessAccountID: vm["BUSINESS_ACCOUNT_ID"], + }, nil +} + +func initBaseClient(ctx context.Context) (*whatsapp.Client, error) { + reader := &dotEnvReader{filePath: ".env"} + b, err := whatsapp.NewBaseClient(ctx, reader, + whatsapp.WithBaseClientOptions( + []whttp.BaseClientOption{ + whttp.WithHTTPClient(http.DefaultClient), + }, + )) + if err != nil { + return nil, err + } + + return b, nil +} + +type textRequest struct { + Recipient string + Message string +} + +// sendTextMessage sends a text message to a whatsapp user. +func sendTextMessage(ctx context.Context, request *textRequest) error { + b, err := initBaseClient(ctx) + if err != nil { + return err + } + + rc := whttp.MakeRequestContext( + whttp.WithRequestContextAction(whttp.RequestActionSend), + whttp.WithRequestContextCategory(whttp.RequestCategoryMessage), + ) + + tmp := &models.Message{ + Product: "whatsapp", + Type: "template", + RecipientType: "individual", + To: request.Recipient, + Template: &models.Template{ + Language: &models.TemplateLanguage{ + Code: "en_US", + }, + Name: "hello_world", + }, + } + + // send a template message first and make sure the user has accepted the template message (replied). + resp, err := b.Send(ctx, rc, tmp) + if err != nil { + return err + } + + fmt.Printf("\n%+v\n", resp) + + time.Sleep(2 * time.Second) + + message := &models.Message{ + Product: "whatsapp", + Type: "text", + RecipientType: "individual", + To: request.Recipient, + Text: &models.Text{ + Body: request.Message, + PreviewURL: true, + }, + } + + resp, err = b.Send(ctx, rc, message) + if err != nil { + return err + } + + fmt.Printf("\n%+v\n", resp) + + return nil +} + +func main() { + ctx := context.Background() + err := sendTextMessage(ctx, &textRequest{ + Recipient: "+255767001828", + Message: "Hello World From github.com/piusalfred/whatsapp", + }) + if err != nil { + panic(err) + } +} diff --git a/media.go b/media.go index 6aefc61..4f362f7 100644 --- a/media.go +++ b/media.go @@ -19,273 +19,275 @@ package whatsapp -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "mime" - "mime/multipart" - "net/http" - "net/textproto" - "path/filepath" - - whttp "github.com/piusalfred/whatsapp/pkg/http" -) - -type ( - MediaInformation struct { - MessagingProduct string `json:"messaging_product"` - URL string `json:"url"` - MimeType string `json:"mime_type"` - Sha256 string `json:"sha256"` - FileSize int64 `json:"file_size"` - ID string `json:"id"` - } - - MediaType string - - // UploadMediaRequest contains the information needed to upload a media file. - // File Path to the file stored in your local directory. For example: "@/local/path/file.jpg". - // Type - type of media file being uploaded. See Supported MediaInformation Types for more information. - // Product Messaging service used for the request. In this case, use whatsapp. - // MediaID - ID of the media file. This is the ID that you will use to send the media file. - UploadMediaRequest struct { - MediaID string - FilePath string - Type MediaType - Product string - } - - MediaRequestParams struct { - Token string - MediaID string - } - - UploadMediaResponse struct { - ID string `json:"id"` - } - - DeleteMediaResponse struct { - Success bool `json:"success"` - } -) - -// GetMediaInformation retrieve the media object by using its corresponding media ID. -func (client *Client) GetMediaInformation(ctx context.Context, mediaID string) (*MediaInformation, error) { - reqCtx := &whttp.RequestContext{ - RequestType: "get media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{mediaID}, - } - - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodGet, - Bearer: client.config.AccessToken, - Payload: nil, - } - - var media MediaInformation - - err := client.bc.base.Do(ctx, params, &media) - if err != nil { - return nil, fmt.Errorf("get media: %w", err) - } - - return &media, nil -} - -// DeleteMedia delete the media by using its corresponding media ID. -func (client *Client) DeleteMedia(ctx context.Context, mediaID string) (*DeleteMediaResponse, error) { - reqCtx := &whttp.RequestContext{ - RequestType: "delete media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{mediaID}, - } - - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodDelete, - Headers: map[string]string{"Content-Type": "application/json"}, - Bearer: client.config.AccessToken, - Payload: nil, - } - - resp := new(DeleteMediaResponse) - err := client.bc.base.Do(ctx, params, &resp) - if err != nil { - return nil, fmt.Errorf("delete media: %w", err) - } - - return resp, nil -} - -func (client *Client) UploadMedia(ctx context.Context, mediaType MediaType, filename string, - fr io.Reader, -) (*UploadMediaResponse, error) { - payload, contentType, err := uploadMediaPayload(mediaType, filename, fr) - if err != nil { - return nil, err - } - - reqCtx := &whttp.RequestContext{ - RequestType: "upload media", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - Endpoints: []string{client.config.PhoneNumberID, "media"}, - } - - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": contentType}, - Bearer: client.config.AccessToken, - Payload: payload, - } - - resp := new(UploadMediaResponse) - err = client.bc.base.Do(ctx, params, &resp) - if err != nil { - return nil, fmt.Errorf("upload media: %w", err) - } - - return resp, nil -} - -var ErrMediaDownload = fmt.Errorf("failed to download media") - -type DownloadMediaResponse struct { - Headers http.Header - Body io.Reader - StatusCode int -} - -type DownloadResponseDecoder struct { - Resp *DownloadMediaResponse - response *http.Response -} - -func (d *DownloadResponseDecoder) Decode(response *http.Response) error { - d.Resp.Headers = response.Header - d.Resp.Body = response.Body - - return nil -} - -// DownloadMedia download the media by using its corresponding media ID. It uses the media ID to retrieve -// the media URL. All media URLs expire after 5 minutes —you need to retrieve the media URL again if it -// expires. -// If successful, *DownloadMediaResponse will be returned. It contains headers and io.Reader. From the headers -// you can check a content-type header to indicate the mime type of returned data. -// -// If media fails to download, Facebook returns a 404 http status code. It is recommended to try to retrieve -// a new media URL and download it again. This will go on for an n retries. If doing so doesn't resolve the issue, -// please try to renew the access token, then retry downloading the media. -func (client *Client) DownloadMedia(ctx context.Context, mediaID string, retries int) (*DownloadMediaResponse, error) { - // create a for loop to retry the download if it fails with a 404 http status code. - for i := 0; i <= retries; i++ { - select { - case <-ctx.Done(): - return nil, fmt.Errorf("media download: %w", ctx.Err()) - default: - } - media, err := client.GetMediaInformation(ctx, mediaID) - if err != nil { - return nil, err - } - - request := whttp.MakeRequest( - whttp.WithRequestContext(&whttp.RequestContext{ - RequestType: "download media", - BaseURL: media.URL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - Bearer: client.config.AccessToken, - BusinessAccountID: "", - Endpoints: nil, - }), - whttp.WithRequestType("download media"), - whttp.WithMethod(http.MethodGet), - whttp.WithBearer(client.config.AccessToken)) - decoder := &DownloadResponseDecoder{} - if err := client.bc.base.DoWithDecoder( - ctx, - request, - whttp.RawResponseDecoder(decoder.Decode), - nil); err != nil { - return nil, fmt.Errorf("media download: %w", err) - } - - // retry - resp := decoder.response - if resp.StatusCode == http.StatusNotFound { - _ = resp.Body.Close() - - continue - } - - if resp.StatusCode != http.StatusOK { - _ = resp.Body.Close() - - return nil, fmt.Errorf("%w: status %d", ErrMediaDownload, resp.StatusCode) - } - - var buf bytes.Buffer - _, err = io.CopyN(&buf, resp.Body, MaxDocSize) - if err != nil && !errors.Is(err, io.EOF) { - _ = resp.Body.Close() - - return nil, fmt.Errorf("media download: %w", err) - } - - _ = resp.Body.Close() - - return &DownloadMediaResponse{ - Headers: resp.Header, - Body: &buf, - }, nil - } - - return nil, fmt.Errorf("%w: retries exceeded", ErrMediaDownload) -} - -// uploadMediaPayload creates upload media request payload. -// If nor error, payload content and request content type is returned. -func uploadMediaPayload(mediaType MediaType, filename string, fr io.Reader) ([]byte, string, error) { - var payload bytes.Buffer - writer := multipart.NewWriter(&payload) - - header := make(textproto.MIMEHeader) - header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=file; filename="%s"`, filename)) - - contentType := mime.TypeByExtension(filepath.Ext(filename)) - header.Set("Content-Type", contentType) - - part, err := writer.CreatePart(header) - if err != nil { - return nil, "", fmt.Errorf("media upload: %w", err) - } - - _, err = io.Copy(part, fr) - if err != nil { - return nil, "", fmt.Errorf("media upload: %w", err) - } - - err = writer.WriteField("type", string(mediaType)) - if err != nil { - return nil, "", fmt.Errorf("media upload: %w", err) - } - - err = writer.WriteField("messaging_product", "whatsapp") - if err != nil { - return nil, "", fmt.Errorf("media upload: %w", err) - } - - _ = writer.Close() - - return payload.Bytes(), writer.FormDataContentType(), nil -} +// +// import ( +// "bytes" +// "context" +// "errors" +// "fmt" +// "io" +// "mime" +// "mime/multipart" +// "net/http" +// "net/textproto" +// "path/filepath" +// +// whttp "github.com/piusalfred/whatsapp/pkg/http" +//) +// +// type ( +// MediaInformation struct { +// MessagingProduct string `json:"messaging_product"` +// URL string `json:"url"` +// MimeType string `json:"mime_type"` +// Sha256 string `json:"sha256"` +// FileSize int64 `json:"file_size"` +// ID string `json:"id"` +// } +// +// MediaType string +// +// // UploadMediaRequest contains the information needed to upload a media file. +// // File Path to the file stored in your local directory. For example: "@/local/path/file.jpg". +// // Type - type of media file being uploaded. See Supported MediaInformation Types for more information. +// // Product Messaging service used for the request. In this case, use whatsapp. +// // MediaID - ID of the media file. This is the ID that you will use to send the media file. +// UploadMediaRequest struct { +// MediaID string +// FilePath string +// Type MediaType +// Product string +// } +// +// MediaRequestParams struct { +// Token string +// MediaID string +// } +// +// UploadMediaResponse struct { +// ID string `json:"id"` +// } +// +// DeleteMediaResponse struct { +// Success bool `json:"success"` +// } +//) +// +//// GetMediaInformation retrieve the media object by using its corresponding media ID. +// func (client *Client) GetMediaInformation(ctx context.Context, mediaID string) (*MediaInformation, error) { +// reqCtx := &whttp.RequestContext{ +// RequestType: "get media", +// BaseURL: client.config.BaseURL, +// ApiVersion: client.config.Version, +// Endpoints: []string{mediaID}, +// } +// +// params := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodGet, +// Bearer: client.config.AccessToken, +// Payload: nil, +// } +// +// var media MediaInformation +// +// err := client.bc.base.Do(ctx, params, &media) +// if err != nil { +// return nil, fmt.Errorf("get media: %w", err) +// } +// +// return &media, nil +//} +// +//// DeleteMedia delete the media by using its corresponding media ID. +// func (client *Client) DeleteMedia(ctx context.Context, mediaID string) (*DeleteMediaResponse, error) { +// reqCtx := &whttp.RequestContext{ +// RequestType: "delete media", +// BaseURL: client.config.BaseURL, +// ApiVersion: client.config.Version, +// Endpoints: []string{mediaID}, +// } +// +// params := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodDelete, +// Headers: map[string]string{"Content-MessageType": "application/json"}, +// Bearer: client.config.AccessToken, +// Payload: nil, +// } +// +// resp := new(DeleteMediaResponse) +// err := client.bc.base.Do(ctx, params, &resp) +// if err != nil { +// return nil, fmt.Errorf("delete media: %w", err) +// } +// +// return resp, nil +//} +// +// func (client *Client) UploadMedia(ctx context.Context, mediaType MediaType, filename string, +// fr io.Reader, +//) (*UploadMediaResponse, error) { +// payload, contentType, err := uploadMediaPayload(mediaType, filename, fr) +// if err != nil { +// return nil, err +// } +// +// reqCtx := &whttp.RequestContext{ +// RequestType: "upload media", +// BaseURL: client.config.BaseURL, +// ApiVersion: client.config.Version, +// Endpoints: []string{client.config.PhoneNumberID, "media"}, +// } +// +// params := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodPost, +// Headers: map[string]string{"Content-MessageType": contentType}, +// Bearer: client.config.AccessToken, +// Payload: payload, +// } +// +// resp := new(UploadMediaResponse) +// err = client.bc.base.Do(ctx, params, &resp) +// if err != nil { +// return nil, fmt.Errorf("upload media: %w", err) +// } +// +// return resp, nil +//} +// +//var ErrMediaDownload = fmt.Errorf("failed to download media") +// +//type DownloadMediaResponse struct { +// Headers http.Header +// Body io.Reader +// StatusCode int +//} +// +//type DownloadResponseDecoder struct { +// Resp *DownloadMediaResponse +// response *http.Response +//} +// +//func (d *DownloadResponseDecoder) Decode(response *http.Response) error { +// d.Resp.Headers = response.Header +// d.Resp.Body = response.Body +// +// return nil +//} +// +//// DownloadMedia download the media by using its corresponding media ID. It uses the media ID to retrieve +//// the media URL. All media URLs expire after 5 minutes —you need to retrieve the media URL again if it +//// expires. +//// If successful, *DownloadMediaResponse will be returned. It contains headers and io.Reader. From the headers +//// you can check a content-type header to indicate the mime type of returned data. +//// +//// If media fails to download, Facebook returns a 404 http status code. It is recommended to try to retrieve +//// a new media URL and download it again. This will go on for an n retries. If doing so doesn't resolve the issue, +//// please try to renew the access token, then retry downloading the media. +//func (client *Client) DownloadMedia(ctx context.Context, mediaID string, retries int) +//(*DownloadMediaResponse, error) { +// // create a for loop to retry the download if it fails with a 404 http status code. +// for i := 0; i <= retries; i++ { +// select { +// case <-ctx.Done(): +// return nil, fmt.Errorf("media download: %w", ctx.Err()) +// default: +// } +// media, err := client.GetMediaInformation(ctx, mediaID) +// if err != nil { +// return nil, err +// } +// +// request := whttp.MakeRequest( +// whttp.WithRequestContext(&whttp.RequestContext{ +// RequestType: "download media", +// BaseURL: media.URL, +// ApiVersion: client.config.Version, +// PhoneNumberID: client.config.PhoneNumberID, +// Bearer: client.config.AccessToken, +// BusinessAccountID: "", +// Endpoints: nil, +// }), +// whttp.WithRequestType("download media"), +// whttp.WithRequestMethod(http.MethodGet), +// whttp.WithRequestBearer(client.config.AccessToken)) +// decoder := &DownloadResponseDecoder{} +// if err := client.bc.base.DoWithDecoder( +// ctx, +// request, +// whttp.RawResponseDecoder(decoder.Decode), +// nil); err != nil { +// return nil, fmt.Errorf("media download: %w", err) +// } +// +// // retry +// resp := decoder.response +// if resp.StatusCode == http.StatusNotFound { +// _ = resp.Body.Close() +// +// continue +// } +// +// if resp.StatusCode != http.StatusOK { +// _ = resp.Body.Close() +// +// return nil, fmt.Errorf("%w: status %d", ErrMediaDownload, resp.StatusCode) +// } +// +// var buf bytes.Buffer +// _, err = io.CopyN(&buf, resp.Body, MaxDocSize) +// if err != nil && !errors.Is(err, io.EOF) { +// _ = resp.Body.Close() +// +// return nil, fmt.Errorf("media download: %w", err) +// } +// +// _ = resp.Body.Close() +// +// return &DownloadMediaResponse{ +// Headers: resp.Header, +// Body: &buf, +// }, nil +// } +// +// return nil, fmt.Errorf("%w: retries exceeded", ErrMediaDownload) +//} +// +//// uploadMediaPayload creates upload media request payload. +//// If nor error, payload content and request content type is returned. +//func uploadMediaPayload(mediaType MediaType, filename string, fr io.Reader) ([]byte, string, error) { +// var payload bytes.Buffer +// writer := multipart.NewWriter(&payload) +// +// header := make(textproto.MIMEHeader) +// header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=file; filename="%s"`, filename)) +// +// contentType := mime.TypeByExtension(filepath.Ext(filename)) +// header.Set("Content-MessageType", contentType) +// +// part, err := writer.CreatePart(header) +// if err != nil { +// return nil, "", fmt.Errorf("media upload: %w", err) +// } +// +// _, err = io.Copy(part, fr) +// if err != nil { +// return nil, "", fmt.Errorf("media upload: %w", err) +// } +// +// err = writer.WriteField("type", string(mediaType)) +// if err != nil { +// return nil, "", fmt.Errorf("media upload: %w", err) +// } +// +// err = writer.WriteField("messaging_product", "whatsapp") +// if err != nil { +// return nil, "", fmt.Errorf("media upload: %w", err) +// } +// +// _ = writer.Close() +// +// return payload.Bytes(), writer.FormDataContentType(), nil +//} diff --git a/media_test.go b/media_test.go deleted file mode 100644 index 0b5d616..0000000 --- a/media_test.go +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package whatsapp - -import ( - "bytes" - "testing" -) - -func TestBuildPayloadForAudioMessage(t *testing.T) { //nolint:paralleltest - request := &SendMediaRequest{ - Recipient: "2348123456789", - Type: "audio", - MediaID: "1234567890", - MediaLink: "https://example.com/audio.mp3", - Caption: "Audio caption", - Filename: "audio.mp3", - Provider: "whatsapp", - - CacheOptions: nil, - } - - payload, err := formatMediaPayload(request) - if err != nil { - t.Errorf("formatMediaPayload() error = %v", err) - } - - expected := `{"messaging_product":"whatsapp","recipient_type":"individual","to":"2348123456789","type": "audio","audio":{"id":"1234567890","link":"https://example.com/audio.mp3","caption":"Audio caption","filename":"audio.mp3","provider":"whatsapp"}}` //nolint:lll - - if !bytes.Equal(payload, []byte(expected)) { - t.Errorf("formatMediaPayload() got = %v, want %v", payload, expected) - } - - t.Logf("audio payload: %s", payload) -} - -func BenchmarkBuildPayloadForMediaMessage(b *testing.B) { - for i := 0; i < b.N; i++ { - _, err := formatMediaPayload(&SendMediaRequest{ - Recipient: "2348123456789", - Type: "audio", - MediaID: "1234567890", - MediaLink: "https://example.com/audio.mp3", - Caption: "Audio caption", - Filename: "audio.mp3", - Provider: "whatsapp", - - CacheOptions: nil, - }) - if err != nil { - b.Errorf("formatMediaPayload() error = %v", err) - - return - } - } -} diff --git a/phone_numbers.go b/phone_numbers.go index 6352e4f..25b42db 100644 --- a/phone_numbers.go +++ b/phone_numbers.go @@ -19,227 +19,235 @@ package whatsapp -import ( - "context" - "encoding/json" - "fmt" - "net/http" - - whttp "github.com/piusalfred/whatsapp/pkg/http" -) - -////// PHONE NUMBERS - -const ( - SMSVerificationMethod VerificationMethod = "SMS" - VoiceVerificationMethod VerificationMethod = "VOICE" -) - -type ( - // VerificationMethod is the method to use to verify the phone number. It can be SMS or VOICE. - VerificationMethod string - - PhoneNumber struct { - VerifiedName string `json:"verified_name"` - DisplayPhoneNumber string `json:"display_phone_number"` - ID string `json:"id"` - QualityRating string `json:"quality_rating"` - } - - PhoneNumbersList struct { - Data []*PhoneNumber `json:"data,omitempty"` - Paging *Paging `json:"paging,omitempty"` - Summary *Summary `json:"summary,omitempty"` - } - - Paging struct { - Cursors *Cursors `json:"cursors,omitempty"` - } - - Cursors struct { - Before string `json:"before,omitempty"` - After string `json:"after,omitempty"` - } - - Summary struct { - TotalCount int `json:"total_count,omitempty"` - } - - // PhoneNumberNameStatus value can be one of the following: - // APPROVED: The name has been approved. You can download your certificate now. - // AVAILABLE_WITHOUT_REVIEW: The certificate for the phone is available and display name is ready to use - // without review. - // DECLINED: The name has not been approved. You cannot download your certificate. - // EXPIRED: Your certificate has expired and can no longer be downloaded. - // PENDING_REVIEW: Your name request is under review. You cannot download your certificate. - // NONE: No certificate is available. - PhoneNumberNameStatus string - - FilterParams struct { - Field string `json:"field,omitempty"` - Operator string `json:"operator,omitempty"` - Value string `json:"value,omitempty"` - } -) - -// RequestVerificationCode requests a verification code to be sent via SMS or VOICE. -// doc link: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/phone-numbers -// -// You need to verify the phone number you want to use to send messages to your customers. After the -// API call, you will receive your verification code via the method you selected. To finish the verification -// process, include your code in the VerifyCode method. -func (client *Client) RequestVerificationCode(ctx context.Context, - codeMethod VerificationMethod, language string, -) error { - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeRequestCode, "request_code") - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(client.config.AccessToken), - whttp.WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded"}), - whttp.WithForm(map[string]string{"code_method": string(codeMethod), "language": language}), - ) - - err := client.bc.base.Do(ctx, params, nil) - if err != nil { - return fmt.Errorf("failed to send request: %w", err) - } - - return nil -} - -// VerifyCode should be run to verify the code retrieved by RequestVerificationCode. -func (client *Client) VerifyCode(ctx context.Context, code string) (*StatusResponse, error) { - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeVerifyCode, "verify_code") - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(client.config.AccessToken), - whttp.WithHeaders(map[string]string{"Content-Type": "application/x-www-form-urlencoded"}), - whttp.WithForm(map[string]string{"code": code}), - ) - - var resp StatusResponse - err := client.bc.base.Do(ctx, params, &resp) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - - return &resp, nil -} - -// ListPhoneNumbers returns a list of phone numbers that are associated with the business account. -// using the WhatsApp Business Management API. -// -// You will need to have -// - The WhatsApp Business Account ID for the business' phone numbers you want to retrieve -// - A System User access token linked to your WhatsApp Business Account -// - The whatsapp_business_management permission -// -// Limitations -// This API can only retrieve phone numbers that have been registered. Adding, updating, or -// deleting phone numbers is not permitted using the API. -// -// The equivalent curl command to retrieve phone numbers is (formatted for readability): -// -// curl -X GET "https://graph.facebook.com/v16.0/{whatsapp-business-account-id}/phone_numbers -// ?access_token={system-user-access-token}" -// -// On success, a JSON object is returned with a list of all the business names, phone numbers, -// phone number IDs, and quality ratings associated with a business. -// -// { -// "data": [ -// { -// "verified_name": "Jasper's Market", -// "display_phone_number": "+1 631-555-5555", -// "id": "1906385232743451", -// "quality_rating": "GREEN" -// -// }, -// { -// "verified_name": "Jasper's Ice Cream", -// "display_phone_number": "+1 631-555-5556", -// "id": "1913623884432103", -// "quality_rating": "NA" -// } -// ], -// } // -// Filter Phone Numbers -// You can query phone numbers and filter them based on their account_mode. This filtering option -// is currently being tested in beta mode. Not all developers have access to it. -// -// Sample Request -// -// curl -i -X GET "https://graph.facebook.com/v16.0/{whatsapp-business-account-ID}/phone_numbers?\ -// filtering=[{"field":"account_mode","operator":"EQUAL","value":"SANDBOX"}]&access_token=access-token" -// -// Sample Response -// -// { -// "data": [ -// { -// "id": "1972385232742141", -// "display_phone_number": "+1 631-555-1111", -// "verified_name": "John’s Cake Shop", -// "quality_rating": "UNKNOWN", -// } -// ], -// "paging": { -// "cursors": { -// "before": "abcdefghij", -// "after": "klmnopqr" +// import ( +// "context" +// "encoding/json" +// "fmt" +// "net/http" +// +// whttp "github.com/piusalfred/whatsapp/pkg/http" +//) +// +//////// PHONE NUMBERS +// +// const ( +// SMSVerificationMethod VerificationMethod = "SMS" +// VoiceVerificationMethod VerificationMethod = "VOICE" +//) +// +// type ( +// // VerificationMethod is the method to use to verify the phone number. It can be SMS or VOICE. +// VerificationMethod string +// +// PhoneNumber struct { +// VerifiedName string `json:"verified_name"` +// DisplayPhoneNumber string `json:"display_phone_number"` +// ID string `json:"id"` +// QualityRating string `json:"quality_rating"` +// CodeVerificationStatus string `json:"code_verification_status"` +// PlatformType string `json:"platform_type"` +// Throughput *Throughput `json:"throughput"` +// } +// +// Throughput struct { +// Level string `json:"level"` +// } +// +// PhoneNumbersList struct { +// Data []*PhoneNumber `json:"data,omitempty"` +// Paging *Paging `json:"paging,omitempty"` +// Summary *Summary `json:"summary,omitempty"` +// } +// +// Paging struct { +// Cursors *Cursors `json:"cursors,omitempty"` +// } +// +// Cursors struct { +// Before string `json:"before,omitempty"` +// After string `json:"after,omitempty"` +// } +// +// Summary struct { +// TotalCount int `json:"total_count,omitempty"` +// } +// +// // PhoneNumberNameStatus value can be one of the following: +// // APPROVED: The name has been approved. You can download your certificate now. +// // AVAILABLE_WITHOUT_REVIEW: The certificate for the phone is available and display name is ready to use +// // without review. +// // DECLINED: The name has not been approved. You cannot download your certificate. +// // EXPIRED: Your certificate has expired and can no longer be downloaded. +// // PENDING_REVIEW: Your name request is under review. You cannot download your certificate. +// // NONE: No certificate is available. +// PhoneNumberNameStatus string +// +// FilterParams struct { +// Field string `json:"field,omitempty"` +// Operator string `json:"operator,omitempty"` +// Value string `json:"value,omitempty"` +// } +//) +// +//// RequestVerificationCode requests a verification code to be sent via SMS or VOICE. +//// doc link: https://developers.facebook.com/docs/whatsapp/cloud-api/reference/phone-numbers +//// +//// You need to verify the phone number you want to use to send messages to your customers. After the +//// API call, you will receive your verification code via the method you selected. To finish the verification +//// process, include your code in the VerifyCode method. +// func (client *Client) RequestVerificationCode(ctx context.Context, +// codeMethod VerificationMethod, language string, +// ) error { +// reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeRequestCode, "request_code") +// +// params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), +// whttp.WithRequestBearer(client.config.AccessToken), +// whttp.WithRequestHeaders(map[string]string{"Content-MessageType": "application/x-www-form-urlencoded"}), +// whttp.WithRequestForm(map[string]string{"code_method": string(codeMethod), "language": language}), +// ) +// +// err := client.bc.base.Do(ctx, params, nil) +// if err != nil { +// return fmt.Errorf("failed to send request: %w", err) +// } +// +// return nil +//} +// +//// VerifyCode should be run to verify the code retrieved by RequestVerificationCode. +//func (client *Client) VerifyCode(ctx context.Context, code string) (*StatusResponse, error) { +// reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeVerifyCode, "verify_code") +// +// params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), +// whttp.WithRequestBearer(client.config.AccessToken), +// whttp.WithRequestHeaders(map[string]string{"Content-MessageType": "application/x-www-form-urlencoded"}), +// whttp.WithRequestForm(map[string]string{"code": code}), +// ) +// +// var resp StatusResponse +// err := client.bc.base.Do(ctx, params, &resp) +// if err != nil { +// return nil, fmt.Errorf("failed to send request: %w", err) +// } +// +// return &resp, nil +//} +// +//// ListPhoneNumbers returns a list of phone numbers that are associated with the business account. +//// using the WhatsApp Business Management API. +//// +//// You will need to have +//// - The WhatsApp Business Account ID for the business' phone numbers you want to retrieve +//// - A System User access token linked to your WhatsApp Business Account +//// - The whatsapp_business_management permission +//// +//// Limitations +//// This API can only retrieve phone numbers that have been registered. Adding, updating, or +//// deleting phone numbers is not permitted using the API. +//// +//// The equivalent curl command to retrieve phone numbers is (formatted for readability): +//// +//// curl -X GET "https://graph.facebook.com/v16.0/{whatsapp-business-account-id}/phone_numbers +//// ?access_token={system-user-access-token}" +//// +//// On success, a JSON object is returned with a list of all the business names, phone numbers, +//// phone number IDs, and quality ratings associated with a business. +//// +//// { +//// "data": [ +//// { +//// "verified_name": "Jasper's Market", +//// "display_phone_number": "+1 631-555-5555", +//// "id": "1906385232743451", +//// "quality_rating": "GREEN" +//// +//// }, +//// { +//// "verified_name": "Jasper's Ice Cream", +//// "display_phone_number": "+1 631-555-5556", +//// "id": "1913623884432103", +//// "quality_rating": "NA" +//// } +//// ], +//// } +//// +//// Filter Phone Numbers +//// You can query phone numbers and filter them based on their account_mode. This filtering option +//// is currently being tested in beta mode. Not all developers have access to it. +//// +//// Sample Request +//// +//// curl -i -X GET "https://graph.facebook.com/v16.0/{whatsapp-business-account-ID}/phone_numbers?\ +//// filtering=[{"field":"account_mode","operator":"EQUAL","value":"SANDBOX"}]&access_token=access-token" +//// +//// Sample Response +//// +//// { +//// "data": [ +//// { +//// "id": "1972385232742141", +//// "display_phone_number": "+1 631-555-1111", +//// "verified_name": "John’s Cake Shop", +//// "quality_rating": "UNKNOWN", +//// } +//// ], +//// "paging": { +//// "cursors": { +//// "before": "abcdefghij", +//// "after": "klmnopqr" +//// } +//// } +//// } +//func (client *Client) ListPhoneNumbers(ctx context.Context, filters []*FilterParams) (*PhoneNumbersList, error) { +// reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeListPhoneNumbers, "phone_numbers") +// +// params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), +// whttp.WithRequestMethod(http.MethodGet), +// whttp.WithRequestQuery(map[string]string{"access_token": client.config.AccessToken}), +// ) +// +// if filters != nil { +// p := filters +// jsonParams, err := json.Marshal(p) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal filter params: %w", err) // } -// } +// +// params.Query["filtering"] = string(jsonParams) // } -func (client *Client) ListPhoneNumbers(ctx context.Context, filters []*FilterParams) (*PhoneNumbersList, error) { - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeListPhoneNumbers, "phone_numbers") - - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithMethod(http.MethodGet), - whttp.WithQuery(map[string]string{"access_token": client.config.AccessToken}), - ) - - if filters != nil { - p := filters - jsonParams, err := json.Marshal(p) - if err != nil { - return nil, fmt.Errorf("failed to marshal filter params: %w", err) - } - - params.Query["filtering"] = string(jsonParams) - } - - var phoneNumbersList PhoneNumbersList - - err := client.bc.base.Do(ctx, params, &phoneNumbersList) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - - return &phoneNumbersList, nil -} - -// PhoneNumberByID returns the phone number associated with the given ID. -func (client *Client) PhoneNumberByID(ctx context.Context) (*PhoneNumber, error) { - reqCtx := &whttp.RequestContext{ - RequestType: "get phone number by id", - BaseURL: client.config.BaseURL, - ApiVersion: client.config.Version, - PhoneNumberID: client.config.PhoneNumberID, - } - - request := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithMethod(http.MethodGet), - whttp.WithHeaders(map[string]string{ - "Authorization": "Bearer " + client.config.AccessToken, - }), - ) - - var phoneNumber PhoneNumber - - if err := client.bc.base.Do(ctx, request, &phoneNumber); err != nil { - return nil, fmt.Errorf("get phone muber by id: %w", err) - } - - return &phoneNumber, nil -} +// +// var phoneNumbersList PhoneNumbersList +// +// err := client.bc.base.Do(ctx, params, &phoneNumbersList) +// if err != nil { +// return nil, fmt.Errorf("failed to send request: %w", err) +// } +// +// return &phoneNumbersList, nil +//} +// +//// PhoneNumberByID returns the phone number associated with the given ID. +//func (client *Client) PhoneNumberByID(ctx context.Context) (*PhoneNumber, error) { +// reqCtx := &whttp.RequestContext{ +// RequestType: "get phone number by id", +// BaseURL: client.config.BaseURL, +// ApiVersion: client.config.Version, +// PhoneNumberID: client.config.PhoneNumberID, +// } +// +// request := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), +// whttp.WithRequestMethod(http.MethodGet), +// whttp.WithRequestHeaders(map[string]string{ +// "Authorization": "Bearer " + client.config.AccessToken, +// }), +// ) +// +// var phoneNumber PhoneNumber +// +// if err := client.bc.base.Do(ctx, request, &phoneNumber); err != nil { +// return nil, fmt.Errorf("get phone muber by id: %w", err) +// } +// +// return &phoneNumber, nil +//} diff --git a/pkg/config/config.go b/pkg/config/config.go index e0b77db..7430abc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -21,6 +21,11 @@ package config import "context" +const ( + BaseURL = "https://graph.facebook.com" + DefaultAPIVersion = "v16.0" // This is the lowest version of the API that is supported +) + type ( // Values is a struct that holds the configuration for the whatsapp client. // It is used to create a new whatsapp client. @@ -38,6 +43,7 @@ type ( Read(ctx context.Context) (*Values, error) } + // ReaderFunc is a function that implements the Reader interface. ReaderFunc func(ctx context.Context) (*Values, error) ) diff --git a/pkg/http/endpoints.go b/pkg/http/endpoints.go deleted file mode 100644 index 6b57883..0000000 --- a/pkg/http/endpoints.go +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package http - -import ( - "fmt" - "net/url" - - "github.com/piusalfred/whatsapp/pkg/config" -) - -const ( - EndpointMessages = "messages" -) - -// CreateMessagesURL takes config.Values and return a messages URL which is -// something like this -// https://graph.facebook.com/v18.0/FROM_PHONE_NUMBER_ID/messages -// BaseURL + APiVersion + PhoneNumberID + MessagesEndpoint. -func CreateMessagesURL(config *config.Values) (string, error) { - path, err := url.JoinPath(config.BaseURL, config.Version, config.PhoneNumberID, EndpointMessages) - if err != nil { - return "", fmt.Errorf("create messages url: %w", err) - } - - return path, nil -} diff --git a/pkg/http/exp.go b/pkg/http/exp.go new file mode 100644 index 0000000..b017f67 --- /dev/null +++ b/pkg/http/exp.go @@ -0,0 +1,114 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" +) + +// DoWithDecoder sends a http request to the server and returns the response, It accepts a context, +// a request, a pointer to a variable to decode the response into and a response decoder. +func (client *BaseClient) DoWithDecoder(ctx context.Context, r *Request, decoder ResponseDecoder, v any) error { + request, err := client.prepareRequest(ctx, r) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } + + response, err := client.http.Do(request) //nolint:bodyclose + if err != nil { + return fmt.Errorf("http send: %w", err) + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + // send error to error channel + client.errorChannel <- fmt.Errorf("closing response body: %w", err) + } + }(response.Body) + + bodyBytes, err := io.ReadAll(response.Body) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading response body: %w", err) + } + + response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if err = client.runResponseHooks(ctx, response); err != nil { + return fmt.Errorf("response hooks: %w", err) + } + + response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + rawResponseDecoder, ok := decoder.(RawResponseDecoder) + if ok { + if err := rawResponseDecoder(response); err != nil { + return fmt.Errorf("raw response decoder: %w", err) + } + + return nil + } + + if err := decoder.DecodeResponse(response, v); err != nil { + return fmt.Errorf("response decoder: %w", err) + } + + return nil +} + +// Do send a http request to the server and returns the response, It accepts a context, +// a request and a pointer to a variable to decode the response into. +func (client *BaseClient) Do(ctx context.Context, r *Request, v any) error { + request, err := client.prepareRequest(ctx, r) + if err != nil { + return fmt.Errorf("prepare request: %w", err) + } + + response, err := client.http.Do(request) + if err != nil { + return fmt.Errorf("http send: %w", err) + } + + defer func(Body io.ReadCloser) { + err := Body.Close() + if err != nil { + // send error to error channel + client.errorChannel <- fmt.Errorf("closing response body: %w", err) + } + }(response.Body) + + bodyBytes, err := io.ReadAll(response.Body) + if err != nil && !errors.Is(err, io.EOF) { + return fmt.Errorf("reading response body: %w", err) + } + + response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + if err = client.runResponseHooks(ctx, response); err != nil { + return fmt.Errorf("response hooks: %w", err) + } + response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + + return decodeResponseJSON(response, v) +} diff --git a/pkg/http/generic.go b/pkg/http/generic.go new file mode 100644 index 0000000..13a4b38 --- /dev/null +++ b/pkg/http/generic.go @@ -0,0 +1,147 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + + "github.com/piusalfred/whatsapp/pkg/models" +) + +var ErrInvalidPayload = fmt.Errorf("invalid payload") + +type ( + MessageContent interface { + models.Location | + models.Text | + models.Image | + models.Audio | + models.Video | + models.Sticker | + models.Document | + models.Contacts | + models.Template | + models.Interactive + } + + Message[M MessageContent] struct { + Product string `json:"messaging_product,omitempty"` + To string `json:"to,omitempty"` + RecipientType string `json:"recipient_type,omitempty"` + Type string `json:"type,omitempty"` + Context *models.Context `json:"context,omitempty"` + Content *M `json:"content,omitempty"` + } + + GenericRequest[P MessageContent] struct { + Recipient string + Reply string + Context *RequestContext + Content *P + } +) + +func contentType[P MessageContent](content *P) string { + t := reflect.TypeOf(content) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + return strings.ToLower(t.Name()) +} + +// EncodeMessageJSON encodes the given interface into the request body bytes. +func EncodeMessageJSON[P MessageContent](recipient string, reply string, content *P) ([]byte, error) { + if recipient == "" { + return nil, fmt.Errorf("%w: recipient is empty", ErrInvalidPayload) + } + + if content == nil { + return nil, fmt.Errorf("%w: content is nil", ErrInvalidPayload) + } + + c := &Message[P]{ + Product: "whatsapp", + To: recipient, + RecipientType: "individual", + Type: contentType(content), + Content: content, + } + + if reply != "" { + c.Context = &models.Context{ + MessageID: reply, + } + } + + b, err := json.Marshal(c.Content) + if err != nil { + return nil, fmt.Errorf("content json: %w", err) + } + + var recipientTypeContext string + + if c.Context != nil && c.Context.MessageID != "" { + recipientTypeContext = fmt.Sprintf(`"context":{"message_id":"%s"},`, c.Context.MessageID) + } else { + // recipient_type is not required for reply messages. + recipientTypeContext = `"recipient_type":"individual",` + } + + jsonString := fmt.Sprintf( + `{"messaging_product":"whatsapp",%s"to":"%s","type":"%s","%s":%s}`, + recipientTypeContext, + recipient, + c.Type, + c.Type, + b, + ) + + return []byte(jsonString), nil +} + +// Send ... +func Send[P MessageContent](ctx context.Context, sender Sender, req *GenericRequest[P]) (*ResponseMessage, error) { + payload, err := EncodeMessageJSON(req.Recipient, req.Reply, req.Content) + if err != nil { + return nil, fmt.Errorf("encode message json: %w", err) + } + + request := MakeRequest( + WithRequestContext(req.Context), + WithRequestPayload(payload), + WithRequestMethod(http.MethodPost), + WithRequestHeaders(map[string]string{ + "Content-Type": "application/json", + }), + ) + + resp, err := sender.Send(ctx, request) + if err != nil { + return nil, fmt.Errorf("send message: %w", err) + } + + return resp, nil +} diff --git a/pkg/http/hooks.go b/pkg/http/hooks.go deleted file mode 100644 index 0ecf681..0000000 --- a/pkg/http/hooks.go +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package http - -import ( - "bytes" - "context" - "fmt" - "log/slog" - "net/http" - "strings" -) - -type ( - RequestHook func(ctx context.Context, request *http.Request) error - ResponseHook func(ctx context.Context, response *http.Response) error -) - -func LogRequestHook(logger *slog.Logger) RequestHook { - return func(ctx context.Context, request *http.Request) error { - name := RequestTypeFromContext(ctx) - reader, err := request.GetBody() - if err != nil { - return fmt.Errorf("log request hook: %w", err) - } - buf := new(bytes.Buffer) - if _, err = buf.ReadFrom(reader); err != nil { - return fmt.Errorf("log request hook: %w", err) - } - - hb := &strings.Builder{} - hb.WriteString("[") - for k, v := range request.Header { - hb.WriteString(fmt.Sprintf("%s: %s, ", k, v)) - } - hb.WriteString("]") - - logger.LogAttrs(ctx, slog.LevelDebug, "request", slog.String("name", name), - slog.String("body", buf.String()), slog.String("headers", hb.String())) - - return nil - } -} - -func LogResponseHook(logger *slog.Logger) ResponseHook { - return func(ctx context.Context, response *http.Response) error { - name := RequestTypeFromContext(ctx) - reader := response.Body - buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(reader); err != nil { - return fmt.Errorf("log response hook: %w", err) - } - - hb := &strings.Builder{} - hb.WriteString("[") - for k, v := range response.Header { - hb.WriteString(fmt.Sprintf("%s: %s, ", k, v)) - } - hb.WriteString("]") - - logger.LogAttrs(ctx, slog.LevelDebug, "response", slog.String("name", name), - slog.Int("status", response.StatusCode), - slog.String("status_text", response.Status), - slog.String("headers", hb.String()), - slog.String("body", buf.String()), - ) - - return nil - } -} diff --git a/pkg/http/http.go b/pkg/http/http.go index 520fb27..4d2d2d7 100644 --- a/pkg/http/http.go +++ b/pkg/http/http.go @@ -26,105 +26,153 @@ import ( "errors" "fmt" "io" - "log/slog" "net/http" "net/url" - "slices" "strings" + "sync" "github.com/piusalfred/whatsapp/pkg/config" - werrors "github.com/piusalfred/whatsapp/pkg/errors" ) -const ( - BaseURL = "https://graph.facebook.com" - DefaultAPIVersion = "v16.0" // This is the lowest version of the API that is supported +var ( + ErrInvalidRequestValue = errors.New("invalid request value") + ErrRequestFailed = errors.New("request failed") ) type ( - Client struct { + BaseClient struct { + mu *sync.Mutex http *http.Client requestHooks []RequestHook responseHooks []ResponseHook + mw []SendMiddleware errorChannel chan error + config *config.Values } - ClientOption func(*Client) + BaseClientOption func(*BaseClient) + + RequestHook func(ctx context.Context, request *http.Request) error + ResponseHook func(ctx context.Context, response *http.Response) error + SenderFunc func(ctx context.Context, r *Request) (*ResponseMessage, error) + + Sender interface { + Send(ctx context.Context, r *Request) (*ResponseMessage, error) + } + + SendMiddleware func(Sender) Sender ) +func wrapSender(sender Sender, middleware ...SendMiddleware) Sender { + for i := len(middleware) - 1; i >= 0; i-- { + sender = middleware[i](sender) + } + + return sender +} + +func (f SenderFunc) Send(ctx context.Context, r *Request) (*ResponseMessage, error) { + return f(ctx, r) +} + +// InitBaseClient initializes the whatsapp cloud api http client. +func InitBaseClient(ctx context.Context, c config.Reader, opts ...BaseClientOption) (*BaseClient, error) { + values, err := c.Read(ctx) + if err != nil { + return nil, fmt.Errorf("init client: load values: %w", err) + } + + client := &BaseClient{ + mu: &sync.Mutex{}, + http: http.DefaultClient, + requestHooks: []RequestHook{}, + responseHooks: []ResponseHook{}, + errorChannel: make(chan error), + config: values, + } + + client.ApplyOptions(opts...) + + return client, nil +} + +// ApplyOptions applies the options to the client. +func (client *BaseClient) ApplyOptions(opts ...BaseClientOption) { + for _, opt := range opts { + opt(client) + } +} + +// ReloadConfig reloads the config for the client. +func (client *BaseClient) ReloadConfig(ctx context.Context, c config.Reader) error { + client.mu.Lock() + defer client.mu.Unlock() + + values, err := c.Read(ctx) + if err != nil { + return fmt.Errorf("reload config: load values: %w", err) + } + + client.config = values + + return nil +} + // ListenErrors takes a func(error) and returns nothing. // Every error sent to the client's errorChannel will be passed to the function. -func (client *Client) ListenErrors(errorHandler func(error)) { +func (client *BaseClient) ListenErrors(errorHandler func(error)) { for err := range client.errorChannel { errorHandler(err) } } // Close closes the client. -func (client *Client) Close() error { +func (client *BaseClient) Close() error { close(client.errorChannel) return nil } -// NewClient creates a new client with the given options, The client is used -// to create a new http request and send it to the server. -// Example: -// -// client := NewClient( -// WithHTTPClient(http.DefaultClient), -// WithRequestHooks( -// // Add your request hooks here -// ), -// WithResponseHooks( -// // Add your response hooks here -// ), -// ) -func NewClient(options ...ClientOption) *Client { - client := &Client{ - http: http.DefaultClient, - errorChannel: make(chan error), - } - for _, option := range options { - option(client) - } - - return client -} - -func WithHTTPClient(httpClient *http.Client) ClientOption { - return func(client *Client) { +func WithHTTPClient(httpClient *http.Client) BaseClientOption { + return func(client *BaseClient) { client.http = httpClient } } -func WithRequestHooks(hooks ...RequestHook) ClientOption { - return func(client *Client) { +func WithRequestHooks(hooks ...RequestHook) BaseClientOption { + return func(client *BaseClient) { client.requestHooks = hooks } } -func WithResponseHooks(hooks ...ResponseHook) ClientOption { - return func(client *Client) { +func WithResponseHooks(hooks ...ResponseHook) BaseClientOption { + return func(client *BaseClient) { client.responseHooks = hooks } } +// WithSendMiddleware adds a middleware to the base client. +func WithSendMiddleware(mw ...SendMiddleware) BaseClientOption { + return func(client *BaseClient) { + client.mw = append(client.mw, mw...) + } +} + // SetRequestHooks sets the request hooks for the client, This removes any previously set request hooks. // and replaces it with the new ones. -func (client *Client) SetRequestHooks(hooks ...RequestHook) { +func (client *BaseClient) SetRequestHooks(hooks ...RequestHook) { client.requestHooks = hooks } // AppendRequestHooks appends the request hooks to the client, This adds the new request hooks to the // existing ones. -func (client *Client) AppendRequestHooks(hooks ...RequestHook) { +func (client *BaseClient) AppendRequestHooks(hooks ...RequestHook) { client.requestHooks = append(client.requestHooks, hooks...) } // PrependRequestHooks prepends the request hooks to the client, This adds the new request hooks to the // existing ones. -func (client *Client) PrependRequestHooks(hooks ...RequestHook) { +func (client *BaseClient) PrependRequestHooks(hooks ...RequestHook) { if hooks == nil { return } @@ -133,72 +181,49 @@ func (client *Client) PrependRequestHooks(hooks ...RequestHook) { // SetResponseHooks sets the response hooks for the client, This removes any previously set response hooks. // and replaces it with the new ones. -func (client *Client) SetResponseHooks(hooks ...ResponseHook) { +func (client *BaseClient) SetResponseHooks(hooks ...ResponseHook) { client.responseHooks = hooks } // AppendResponseHooks appends the response hooks to the client, This adds the new response hooks to the // existing ones. -func (client *Client) AppendResponseHooks(hooks ...ResponseHook) { +func (client *BaseClient) AppendResponseHooks(hooks ...ResponseHook) { client.responseHooks = append(client.responseHooks, hooks...) } // PrependResponseHooks prepends the response hooks to the client, This adds the new response hooks to the // existing ones. -func (client *Client) PrependResponseHooks(hooks ...ResponseHook) { +func (client *BaseClient) PrependResponseHooks(hooks ...ResponseHook) { if hooks == nil { return } client.responseHooks = append(hooks, client.responseHooks...) } -// Do send a http request to the server and returns the response, It accepts a context, -// a request and a pointer to a variable to decode the response into. -func (client *Client) Do(ctx context.Context, r *Request, v any) error { - request, err := prepareRequest(ctx, r, client.requestHooks...) - if err != nil { - return fmt.Errorf("prepare request: %w", err) - } +// Send sends a message to the server. This is used to Send requests to /messages endpoint. +// They all have the same response structure as represented by ResponseMessage. +func (client *BaseClient) Send(ctx context.Context, r *Request) (*ResponseMessage, error) { + client.mu.Lock() + defer client.mu.Unlock() - response, err := client.http.Do(request) + sender := wrapSender(SenderFunc(client.send), client.mw...) + resp, err := sender.Send(ctx, r) if err != nil { - return fmt.Errorf("http send: %w", err) - } - - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - // send error to error channel - client.errorChannel <- fmt.Errorf("closing response body: %w", err) - } - }(response.Body) - - bodyBytes, err := io.ReadAll(response.Body) - if err != nil && !errors.Is(err, io.EOF) { - return fmt.Errorf("reading response body: %w", err) - } - - response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - - if err = runResponseHooks(ctx, response, client.responseHooks...); err != nil { - return fmt.Errorf("response hooks: %w", err) + return nil, fmt.Errorf("base client: %w", err) } - response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - return decodeResponseJSON(response, v) + return resp, nil } -// DoWithDecoder sends a http request to the server and returns the response, It accepts a context, -// a request, a pointer to a variable to decode the response into and a response decoder. -func (client *Client) DoWithDecoder(ctx context.Context, r *Request, decoder ResponseDecoder, v any) error { - request, err := prepareRequest(ctx, r, client.requestHooks...) +func (client *BaseClient) send(ctx context.Context, r *Request) (*ResponseMessage, error) { + request, err := client.prepareRequest(ctx, r) if err != nil { - return fmt.Errorf("prepare request: %w", err) + return nil, fmt.Errorf("prepare request: %w", err) } response, err := client.http.Do(request) //nolint:bodyclose if err != nil { - return fmt.Errorf("http send: %w", err) + return nil, fmt.Errorf("http send: %w", err) } defer func(Body io.ReadCloser) { @@ -211,75 +236,27 @@ func (client *Client) DoWithDecoder(ctx context.Context, r *Request, decoder Res bodyBytes, err := io.ReadAll(response.Body) if err != nil && !errors.Is(err, io.EOF) { - return fmt.Errorf("reading response body: %w", err) + return nil, fmt.Errorf("reading response body: %w", err) } response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - if err = runResponseHooks(ctx, response, client.responseHooks...); err != nil { - return fmt.Errorf("response hooks: %w", err) + if err = client.runResponseHooks(ctx, response); err != nil { + return nil, fmt.Errorf("response hooks: %w", err) } - response.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) - rawResponseDecoder, ok := decoder.(RawResponseDecoder) - if ok { - if err := rawResponseDecoder(response); err != nil { - return fmt.Errorf("raw response decoder: %w", err) - } + var resp ResponseMessage - return nil - } - - if err := decoder.DecodeResponse(response, v); err != nil { - return fmt.Errorf("response decoder: %w", err) + if err := decodeResponseJSON(response, &resp); err != nil { + return nil, fmt.Errorf("response decoder: %w", err) } - return nil + return &resp, nil } -var ErrRequestFailed = errors.New("request failed") - -func decodeResponseJSON(response *http.Response, v interface{}) error { - if v == nil || response == nil { - return nil - } - - responseBody, err := io.ReadAll(response.Body) - if err != nil { - return fmt.Errorf("error reading response body: %w", err) - } - - defer func() { - response.Body = io.NopCloser(bytes.NewBuffer(responseBody)) - }() - - isResponseOk := response.StatusCode >= http.StatusOK && response.StatusCode <= http.StatusIMUsed - - if !isResponseOk { - if len(responseBody) == 0 { - return fmt.Errorf("%w: status code: %d", ErrRequestFailed, response.StatusCode) - } - - var errorResponse ResponseError - if err := json.Unmarshal(responseBody, &errorResponse); err != nil { - return fmt.Errorf("error decoding response error body: %w", err) - } - - return &errorResponse - } - - if len(responseBody) != 0 { - if err := json.Unmarshal(responseBody, v); err != nil { - return fmt.Errorf("error decoding response body: %w", err) - } - } - - return nil -} - -func runResponseHooks(ctx context.Context, response *http.Response, hooks ...ResponseHook) error { - for _, hook := range hooks { +func (client *BaseClient) runResponseHooks(ctx context.Context, response *http.Response) error { + for _, hook := range client.responseHooks { if hook != nil { if err := hook(ctx, response); err != nil { return fmt.Errorf("response hooks: %w", err) @@ -290,17 +267,31 @@ func runResponseHooks(ctx context.Context, response *http.Response, hooks ...Res return nil } -func prepareRequest(ctx context.Context, r *Request, hooks ...RequestHook) (*http.Request, error) { - // create a new request, run hooks and return the request after restoring the body - ctx = attachRequestType(ctx, r.Context.RequestType) +// authorize attaches the bearer token to the request. +func (client *BaseClient) authorize(ctx *RequestContext, request *http.Request) error { + if request == nil || ctx == nil { + return fmt.Errorf("%w: request or request context should not be nil", ErrInvalidRequestValue) + } + + if ctx.Category == RequestCategoryMessage { + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", client.config.AccessToken)) - request, err := NewRequestWithContext(ctx, r) + return nil + } + + return nil +} + +// prepareRequest prepares the request for sending. +func (client *BaseClient) prepareRequest(ctx1 context.Context, r *Request) (*http.Request, error) { + ctx := attachRequestContext(ctx1, r.Context) + request, err := client.newHTTPRequest(ctx, r) if err != nil { return nil, fmt.Errorf("prepare request: %w", err) } // run request hooks - for _, hook := range hooks { + for _, hook := range client.requestHooks { if hook != nil { if err = hook(ctx, request); err != nil { return nil, fmt.Errorf("prepare request: %w", err) @@ -319,232 +310,8 @@ func prepareRequest(ctx context.Context, r *Request, hooks ...RequestHook) (*htt return request, nil } -var _ slog.LogValuer = (*Request)(nil) - -type ( - BasicAuth struct { - Username string - Password string - } - - // Request is a struct that holds the details that can be used to make a http request. - // It is used by the Do function to make a request. - // It contains Payload which is an interface that can be used to pass any data type - // to the Do function. Payload is expected to be a struct that can be marshalled - // to json, or a slice of bytes or an io.Reader. - Request struct { - Context *RequestContext - Method string - BasicAuth *BasicAuth - Headers map[string]string - Query map[string]string - Bearer string - Form map[string]string - Payload any - Metadata map[string]string // This is used to pass metadata for other uses cases like logging, instrumentation etc. - } - - RequestOption func(*Request) - - RequestType string - - RequestContext struct { - RequestType RequestType - BaseURL string - ApiVersion string //nolint: revive,stylecheck - PhoneNumberID string - Bearer string - BusinessAccountID string - Endpoints []string - } -) - -// MakeRequestContext creates a new request context. -func MakeRequestContext(config *config.Values, name RequestType, endpoints ...string) *RequestContext { - return &RequestContext{ - RequestType: name, - BaseURL: config.BaseURL, - ApiVersion: config.Version, - PhoneNumberID: config.PhoneNumberID, - Bearer: config.AccessToken, - BusinessAccountID: config.BusinessAccountID, - Endpoints: endpoints, - } -} - -const ( - RequestTypeTextMessage RequestType = "text message" - RequestTypeLocation RequestType = "location message" - RequestTypeMedia RequestType = "media message" - RequestTypeReply RequestType = "reply message" - RequestTypeTemplate RequestType = "template message" - RequestTypeReact RequestType = "react message" - RequestTypeContacts RequestType = "contact message" - RequestTypeInteractiveTemplate RequestType = "interactive template message" - RequestTypeTextTemplate RequestType = "text template message" - RequestTypeMediaTemplate RequestType = "media template message" - RequestTypeMarkMessageRead RequestType = "mark message read" - RequestTypeInteractiveMessage RequestType = "interactive message" - RequestTypeRequestCode RequestType = "request verification code" - RequestTypeVerifyCode RequestType = "verify verification code" - RequestTypeListPhoneNumbers RequestType = "list phone numbers" - RequestTypeCreateQRCode RequestType = "create qr code" - RequestTypeDeleteQRCode RequestType = "delete qr code" - RequestTypeListQRCodes RequestType = "list qr codes" - RequestTypeUpdateQRCode RequestType = "update qr code" - RequestTypeGetQRCode RequestType = "get qr code" -) - -// ParseRequestType parses the string representation of the request type into a RequestType. -func ParseRequestType(name string) RequestType { - all := []string{ - RequestTypeTextMessage.String(), - RequestTypeLocation.String(), - RequestTypeMedia.String(), - RequestTypeReply.String(), - RequestTypeTemplate.String(), - RequestTypeReact.String(), - RequestTypeContacts.String(), - RequestTypeInteractiveTemplate.String(), - RequestTypeTextTemplate.String(), - RequestTypeMediaTemplate.String(), - RequestTypeMarkMessageRead.String(), - RequestTypeInteractiveMessage.String(), - RequestTypeRequestCode.String(), - RequestTypeVerifyCode.String(), - RequestTypeListPhoneNumbers.String(), - RequestTypeCreateQRCode.String(), - RequestTypeDeleteQRCode.String(), - RequestTypeListQRCodes.String(), - RequestTypeUpdateQRCode.String(), - RequestTypeGetQRCode.String(), - } - - index := slices.Index[[]string](all, name) - if index == -1 { - return "" - } - - return RequestType(all[index]) -} - -// String returns the string representation of the request type. -func (r RequestType) String() string { - return string(r) -} - -func MakeRequest(options ...RequestOption) *Request { - request := &Request{ - Context: &RequestContext{ - BaseURL: BaseURL, - ApiVersion: DefaultAPIVersion, - }, - Method: http.MethodPost, - Headers: map[string]string{"Content-Type": "application/json"}, - } - for _, option := range options { - option(request) - } - - return request -} - -func WithBasicAuth(username, password string) RequestOption { - return func(request *Request) { - request.BasicAuth = &BasicAuth{ - Username: username, - Password: password, - } - } -} - -func WithRequestContext(ctx *RequestContext) RequestOption { - return func(request *Request) { - request.Context = ctx - } -} - -func WithRequestType(name RequestType) RequestOption { - return func(request *Request) { - request.Context.RequestType = name - } -} - -func WithForm(form map[string]string) RequestOption { - return func(request *Request) { - request.Form = form - } -} - -func WithPayload(payload any) RequestOption { - return func(request *Request) { - request.Payload = payload - } -} - -func WithMethod(method string) RequestOption { - return func(request *Request) { - request.Method = method - } -} - -func WithHeaders(headers map[string]string) RequestOption { - return func(request *Request) { - request.Headers = headers - } -} - -func WithQuery(query map[string]string) RequestOption { - return func(request *Request) { - request.Query = query - } -} - -func WithEndpoints(endpoints ...string) RequestOption { - return func(request *Request) { - request.Context.Endpoints = endpoints - } -} - -func WithBearer(bearer string) RequestOption { - return func(request *Request) { - request.Bearer = bearer - } -} - -// ReaderFunc is a function that takes a *Request and returns a func that takes nothing -// but returns an io.Reader and an error. -func (request *Request) ReaderFunc() func() (io.Reader, error) { - return func() (io.Reader, error) { - return extractRequestBody(request.Payload) - } -} - -// BodyBytes takes a *Request and returns a slice of bytes or an error. -func (request *Request) BodyBytes() ([]byte, error) { - if request.Payload == nil { - return nil, nil - } - - body, err := request.ReaderFunc()() - if err != nil { - return nil, fmt.Errorf("reader func: %w", err) - } - - buf := new(bytes.Buffer) - - _, err = buf.ReadFrom(body) - if err != nil { - return nil, fmt.Errorf("read from: %w", err) - } - - return buf.Bytes(), nil -} - -var ErrInvalidRequestValue = errors.New("invalid request value") - // NewRequestWithContext takes a context and a *Request and returns a new *http.Request. -func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request, error) { +func (client *BaseClient) newHTTPRequest(ctx context.Context, request *Request) (*http.Request, error) { if request == nil || request.Context == nil { return nil, fmt.Errorf("%w: request or request context should not be nil", ErrInvalidRequestValue) } @@ -559,7 +326,7 @@ func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request form.Add(key, value) } body = strings.NewReader(form.Encode()) - headers["Content-Type"] = "application/x-www-form-urlencoded" + headers["Content-MessageType"] = "application/x-www-form-urlencoded" } else if request.Payload != nil { rdr, err := extractRequestBody(request.Payload) if err != nil { @@ -569,7 +336,7 @@ func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request body = rdr } - requestURL, err := RequestURLFromContext(request.Context) + requestURL, err := RequestURLFmt(client.config, request.Context) if err != nil { return nil, fmt.Errorf("failed to create request url: %w", err) } @@ -580,10 +347,6 @@ func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request return nil, fmt.Errorf("failed to create new request: %w", err) } - if request.BasicAuth != nil { - req.SetBasicAuth(request.BasicAuth.Username, request.BasicAuth.Password) - } - // Set the request headers if request.Headers != nil { for key, value := range request.Headers { @@ -596,11 +359,6 @@ func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request req.Header.Add(key, value) } - // Set the bearer token header - if request.Bearer != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", request.Bearer)) - } - // Add the query parameters to the request URL if request.Query != nil { query := req.URL.Query() @@ -610,117 +368,48 @@ func NewRequestWithContext(ctx context.Context, request *Request) (*http.Request req.URL.RawQuery = query.Encode() } - return req, nil -} - -// extractRequestBody takes an interface{} and returns an io.Reader. -// It is called by the NewRequestWithContext function to convert the payload in the -// Request to an io.Reader. The io.Reader is then used to set the body of the http.Request. -// Only the following types are supported: -// 1. []byte -// 2. io.Reader -// 3. string -// 4. any value that can be marshalled to json -// 5. nil. -func extractRequestBody(payload interface{}) (io.Reader, error) { - if payload == nil { - return nil, nil - } - switch p := payload.(type) { - case []byte: - return bytes.NewReader(p), nil - case io.Reader: - return p, nil - case string: - return strings.NewReader(p), nil - default: - buf := &bytes.Buffer{} - err := json.NewEncoder(buf).Encode(p) - if err != nil { - return nil, fmt.Errorf("failed to encode payload: %w", err) - } - - return buf, nil + // authorize the request + if err = client.authorize(request.Context, req); err != nil { + return nil, fmt.Errorf("failed to authorize request: %w", err) } -} - -// requestTypeKey is a type that holds the name of a request. This is usually passed -// extracted from Request.Context.RequestType and passed down to the Do function. -// then passed down with to the request hooks. In request hooks, the name can be -// used to identify the request and other multiple use cases like instrumentation, -// logging etc. -type requestTypeKey string - -const requestTypeValue = "request-name" -// attachRequestType takes a string and a context and returns a new context with the string -// as the request name. -func attachRequestType(ctx context.Context, name RequestType) context.Context { - return context.WithValue(ctx, requestTypeKey(requestTypeValue), name) + return req, nil } -// RequestTypeFromContext returns the request name from the context. -func RequestTypeFromContext(ctx context.Context) string { - rt, ok := ctx.Value(requestTypeKey(requestTypeValue)).(RequestType) - if !ok { - return "" +func decodeResponseJSON(response *http.Response, v interface{}) error { + if v == nil || response == nil { + return nil } - return rt.String() -} - -// RequestURLFromContext returns the request url from the context. -func RequestURLFromContext(ctx *RequestContext) (string, error) { - elems := append([]string{ctx.ApiVersion, ctx.PhoneNumberID}, ctx.Endpoints...) - path, err := url.JoinPath(ctx.BaseURL, elems...) + responseBody, err := io.ReadAll(response.Body) if err != nil { - return "", fmt.Errorf("failed to join url path: %w", err) + return fmt.Errorf("error reading response body: %w", err) } - return path, nil -} - -type ResponseError struct { - Code int `json:"code,omitempty"` - Err *werrors.Error `json:"error,omitempty"` -} + defer func() { + response.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + }() -// Error returns the error message for ResponseError. -func (e *ResponseError) Error() string { - return fmt.Sprintf("whatsapp error: http code: %d, %s", e.Code, strings.ToLower(e.Err.Error())) -} + isResponseOk := response.StatusCode >= http.StatusOK && response.StatusCode <= http.StatusIMUsed -// Unwrap returns the underlying error for ResponseError. -func (e *ResponseError) Unwrap() error { - return e.Err -} + if !isResponseOk { + if len(responseBody) == 0 { + return fmt.Errorf("%w: status code: %d", ErrRequestFailed, response.StatusCode) + } -type ( + var errorResponse ResponseError + if err := json.Unmarshal(responseBody, &errorResponse); err != nil { + return fmt.Errorf("error decoding response error body: %w", err) + } - // ResponseDecoder decodes the response body into the given interface. - ResponseDecoder interface { - DecodeResponse(response *http.Response, v interface{}) error + return &errorResponse } - // ResponseDecoderFunc is an adapter to allow the use of ordinary functions as - // response decoders. If f is a function with the appropriate signature, - // ResponseDecoderFunc(f) is a ResponseDecoder that calls f. - ResponseDecoderFunc func(response *http.Response, v interface{}) error - - // RawResponseDecoder ... - RawResponseDecoder func(response *http.Response) error -) - -// DecodeResponse calls f(response, v). -func (f RawResponseDecoder) DecodeResponse(response *http.Response, - _ interface{}, -) error { - return f(response) -} + if len(responseBody) != 0 { + if err := json.Unmarshal(responseBody, v); err != nil { + return fmt.Errorf("error decoding response body: %w", err) + } + } -// DecodeResponse calls f(ctx, response, v). -func (f ResponseDecoderFunc) DecodeResponse(response *http.Response, - v interface{}, -) error { - return f(response, v) + return nil } diff --git a/pkg/http/http_test.go b/pkg/http/http_test.go deleted file mode 100644 index f1d046c..0000000 --- a/pkg/http/http_test.go +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package http - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" -) - -type Context struct { - Method string - StatusCode int - Headers map[string]string - Body interface{} -} - -func testServer(t *testing.T, ctx *Context) *httptest.Server { - t.Helper() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != ctx.Method { - w.WriteHeader(http.StatusNotFound) - - return - } - - for key, value := range ctx.Headers { - w.Header().Add(key, value) - } - - w.WriteHeader(ctx.StatusCode) - if ctx.Body != nil { - body, err := json.Marshal(ctx.Body) - if err != nil { - t.Errorf("failed to marshal response body: %v", err) - - return - } - if _, err := w.Write(body); err != nil { - t.Errorf("failed to write response body: %v", err) - } - } - }) - - return httptest.NewServer(handler) -} - -type User struct { - Name string `json:"name"` - Age int `json:"age"` - Male bool `json:"male"` -} - -func TestSend(t *testing.T) { //nolint:paralleltest - ctx := &Context{ - Method: http.MethodGet, - StatusCode: http.StatusOK, - Headers: map[string]string{"Content-Type": "application/json"}, - Body: &User{ - Name: "Pius Alfred", - Age: 77, - Male: true, - }, - } - - server := testServer(t, ctx) - defer server.Close() - - reqCtx := &RequestContext{ - RequestType: "test", - BaseURL: server.URL, - ApiVersion: "", - PhoneNumberID: "", - Endpoints: nil, - } - - request := &Request{ - Context: reqCtx, - Method: http.MethodGet, - Headers: map[string]string{"Content-Type": "application/json"}, - Query: nil, - Bearer: "", - Form: nil, - Payload: nil, - } - - var user User - base := &Client{http: http.DefaultClient} - if err := base.Do(context.TODO(), request, &user); err != nil { - t.Errorf("failed to send request: %v", err) - } - - // Compare the response body with the expected response body - usr, ok := ctx.Body.(*User) - if !ok { - t.Errorf("failed to cast body to user type") - } - - if user != *usr { - t.Errorf("response body mismatch: got %v, want %v", user, *usr) - } - - t.Logf("user: %+v", user) -} - -func Test_extractRequestBody(t *testing.T) { - t.Parallel() - type user struct { - Name string `json:"name"` - Age int `json:"age"` - Wise bool `json:"wise"` - } - type args struct { - payload interface{} - } - tests := []struct { - name string - args args - want []byte - wantErr bool - }{ - { - name: "test []byte payload", - args: args{ - payload: []byte("test"), - }, - want: []byte("test"), - wantErr: false, - }, - { - name: "test string payload", - args: args{ - payload: "test", - }, - want: []byte("test"), - wantErr: false, - }, - { - name: "test struct payload", - args: args{ - payload: user{ - Name: "test", - Age: 10, - Wise: true, - }, - }, - want: []byte(`{"name":"test","age":10,"wise":true}`), - wantErr: false, - }, - { - name: "test pointer to struct payload", - args: args{ - payload: &user{ - Name: "test", - Age: 10, - Wise: true, - }, - }, - want: []byte(`{"name":"test","age":10,"wise":true}`), - wantErr: false, - }, - { - name: "slice of struct payload", - args: args{ - payload: []user{ - { - Name: "test", - Age: 10, - Wise: true, - }, - { - Name: "test2", - Age: 20, - Wise: false, - }, - }, - }, - want: []byte(`[{"name":"test","age":10,"wise":true},{"name":"test2","age":20,"wise":false}]`), - wantErr: false, - }, - { - name: "slice of pointer to struct payload", - args: args{ - payload: []*user{ - { - Name: "test", - Age: 10, - Wise: true, - }, - { - Name: "test2", - Age: 20, - Wise: false, - }, - }, - }, - - want: []byte(`[{"name":"test","age":10,"wise":true},{"name":"test2","age":20,"wise":false}]`), - wantErr: false, - }, - { - name: "test nil payload", - args: args{ - payload: nil, - }, - want: []byte(""), - wantErr: false, - }, - { - name: "test invalid payload", - args: args{ - payload: make(chan int), - }, - wantErr: true, - }, - } - for _, tt := range tests { - tt := tt - args := tt.args - name := tt.name - t.Run(name, func(t *testing.T) { - t.Parallel() - got, err := extractRequestBody(args.payload) - if (err != nil) != tt.wantErr { - t.Errorf("%s: extractRequestBody() error = %v, wantErr %v", name, err, tt.wantErr) - - return - } - - if (tt.wantErr && (err != nil)) || got == nil { - t.Logf("%s: extractPaylodFromRequest has error as expected: %v or returned a nil io.Reader", name, err) - - return - } - - // create a new buffer to read the payload - buf := new(bytes.Buffer) - _, err = buf.ReadFrom(got) - if err != nil { - t.Errorf("%s extractRequestBody() error = %v, wantErr %v", name, err, tt.wantErr) - - return - } - - // Json encoder by default adds a new line at the end of the payload, - // So we need to remove it to compare the payload. - bytesGot := bytes.TrimRight(buf.Bytes(), "\n") - if !bytes.Equal(bytesGot, tt.want) { - t.Errorf("%s: extractRequestBody() got = %s, want %s", name, - string(bytesGot), string(tt.want)) - } - }) - } -} - -func TestRequestTypeFromContext(t *testing.T) { - t.Parallel() - inputs := []RequestType{ - RequestTypeTextMessage, - RequestTypeLocation, - RequestTypeMedia, - RequestTypeReply, - RequestTypeTemplate, - RequestTypeReact, - RequestTypeContacts, - RequestTypeInteractiveTemplate, - RequestTypeTextTemplate, - RequestTypeMediaTemplate, - RequestTypeMarkMessageRead, - RequestTypeInteractiveMessage, - } - - t.Run("test assigning and retrieving request type", func(t *testing.T) { - t.Parallel() - for _, input := range inputs { - ctx := attachRequestType(context.TODO(), input) - if got := RequestTypeFromContext(ctx); ParseRequestType(got) != input { - t.Errorf("RequestTypeFromContext(\"%s\") = %v, want %v", input, got, input) - } - } - }) -} diff --git a/pkg/http/request.go b/pkg/http/request.go new file mode 100644 index 0000000..413ba12 --- /dev/null +++ b/pkg/http/request.go @@ -0,0 +1,429 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/piusalfred/whatsapp/pkg/config" +) + +const ( + EndpointMessages = "messages" +) + +type ( + + // Request is a struct that holds the details that can be used to make a http request. + // It is used by the Do function to make a request. + // It contains Payload which is an interface that can be used to pass any data type + // to the Do function. Payload is expected to be a struct that can be marshalled + // to json, or a slice of bytes or an io.Reader. + Request struct { + Context *RequestContext + Method string + Headers map[string]string + Query map[string]string + Form map[string]string + Payload any + Endpoints []string + } + + RequestOption func(*Request) +) + +// MakeRequest creates a new request with the given options. Default values are used +// for the request if no options are passed. +// The default values are: +// 1. RequestContext.BaseURL: https://graph.facebook.com +// 2. RequestContext.ApiVersion: v16.0 +// 3. Request.Method: http.MethodPost +// 4. Request.Headers: map[string]string{"Content-MessageType": "application/json"} +// 5. RequestContext.Endpoints: []string{"/messages"} +// +// Most importantly remember to set the request payload and the request type and the +// bearer token and phone number id if needed. +func MakeRequest(options ...RequestOption) *Request { + req := &Request{ + Context: &RequestContext{ + Action: RequestActionSend, + Category: RequestCategoryMessage, + Name: RequestNameTextMessage, + }, + Method: http.MethodPost, + Headers: map[string]string{"Content-MessageType": "application/json"}, + } + for _, option := range options { + option(req) + } + + if req.Context.CacheOptions != nil { + co := req.Context.CacheOptions + if co.CacheControl != "" { + req.Headers["Cache-Control"] = co.CacheControl + } else if co.Expires > 0 { + req.Headers["Cache-Control"] = fmt.Sprintf("max-age=%d", co.Expires) + } + if co.LastModified != "" { + req.Headers["Last-Modified"] = co.LastModified + } + if co.ETag != "" { + req.Headers["ETag"] = co.ETag + } + } + + return req +} + +func WithRequestContext(ctx *RequestContext) RequestOption { + return func(request *Request) { + request.Context = ctx + } +} + +func WithRequestForm(form map[string]string) RequestOption { + return func(request *Request) { + request.Form = form + } +} + +func WithRequestPayload(payload any) RequestOption { + return func(request *Request) { + request.Payload = payload + } +} + +func WithRequestMethod(method string) RequestOption { + return func(request *Request) { + request.Method = method + } +} + +func WithRequestHeaders(headers map[string]string) RequestOption { + return func(request *Request) { + request.Headers = headers + } +} + +func WithRequestQuery(query map[string]string) RequestOption { + return func(request *Request) { + request.Query = query + } +} + +func WithRequestEndpoints(endpoints ...string) RequestOption { + return func(request *Request) { + request.Context.Endpoints = endpoints + } +} + +// ReaderFunc is a function that takes a *Request and returns a func that takes nothing +// but returns an io.Reader and an error. +func (request *Request) ReaderFunc() func() (io.Reader, error) { + return func() (io.Reader, error) { + return extractRequestBody(request.Payload) + } +} + +// BodyBytes takes a *Request and returns a slice of bytes or an error. +func (request *Request) BodyBytes() ([]byte, error) { + if request.Payload == nil { + return nil, nil + } + + body, err := request.ReaderFunc()() + if err != nil { + return nil, fmt.Errorf("reader func: %w", err) + } + + buf := new(bytes.Buffer) + + _, err = buf.ReadFrom(body) + if err != nil { + return nil, fmt.Errorf("read from: %w", err) + } + + return buf.Bytes(), nil +} + +// extractRequestBody takes an interface{} and returns an io.Reader. +// It is called by the NewRequestWithContext function to convert the payload in the +// Request to an io.Reader. The io.Reader is then used to set the body of the http.Request. +// Only the following types are supported: +// 1. []byte +// 2. io.Reader +// 3. string +// 4. any value that can be marshalled to json +// 5. nil. +func extractRequestBody(payload interface{}) (io.Reader, error) { + if payload == nil { + return nil, nil + } + switch p := payload.(type) { + case []byte: + return bytes.NewReader(p), nil + case io.Reader: + return p, nil + case string: + return strings.NewReader(p), nil + default: + buf := &bytes.Buffer{} + err := json.NewEncoder(buf).Encode(p) + if err != nil { + return nil, fmt.Errorf("failed to encode payload: %w", err) + } + + return buf, nil + } +} + +// RequestURLFmt returns the request url from the context. +func RequestURLFmt(values *config.Values, request *RequestContext) (string, error) { + if request == nil { + return "", fmt.Errorf("%w: request should not be nil", ErrInvalidRequestValue) + } + + if request.Category == RequestCategoryMessage { + return fmtRequestURL(values.BaseURL, values.Version, values.PhoneNumberID, EndpointMessages) + } + + elems := append([]string{values.BusinessAccountID}, request.Endpoints...) + + return fmtRequestURL(values.BaseURL, values.Version, elems...) +} + +// fmtRequestURL returns the request url. It accepts base url, api version, endpoints. +func fmtRequestURL(baseURL, apiVersion string, endpoints ...string) (string, error) { + elems := append([]string{apiVersion}, endpoints...) + path, err := url.JoinPath(baseURL, elems...) + if err != nil { + return "", fmt.Errorf("failed to join url path: %w", err) + } + + return path, nil +} + +type RequestCategory string + +const ( + RequestCategoryMessage RequestCategory = "message" + RequestCategoryPhoneNumbers RequestCategory = "phone numbers" + RequestCategoryQRCodes RequestCategory = "qr codes" + RequestCategoryMedia RequestCategory = "media" + RequestCategoryWebhooks RequestCategory = "webhooks" + RequestCategoryVerification RequestCategory = "verification" +) + +type RequestAction string + +const ( + RequestActionSend RequestAction = "send" + RequestActionList RequestAction = "list" + RequestActionCreate RequestAction = "create" + RequestActionDelete RequestAction = "delete" + RequestActionUpdate RequestAction = "update" + RequestActionGet RequestAction = "get" + RequestActionUpload RequestAction = "upload" + RequestActionDownload RequestAction = "download" + RequestActionRead RequestAction = "read" + RequestActionVerify RequestAction = "verify" + RequestActionReact RequestAction = "react" +) + +type RequestName string + +const ( + RequestNameTextMessage RequestName = "text" + RequestNameLocation RequestName = "location" + RequestNameMedia RequestName = "media" + RequestNameTemplate RequestName = "template" + RequestNameReaction RequestName = "reaction" + RequestNameContacts RequestName = "contacts" + RequestNameInteractive RequestName = "interactive" + RequestNameAudio RequestName = "audio" + RequestNameDocument RequestName = "document" + RequestNameImage RequestName = "image" + RequestNameVideo RequestName = "video" + RequestNameSticker RequestName = "sticker" +) + +type ( + /* CacheOptions contains the options on how to send a media message. You can specify either the + ID or the link of the media. Also, it allows you to specify caching options. + + The Cloud API supports media http caching. If you are using a link (link) to a media asset on your + server (as opposed to the ID (id) of an asset you have uploaded to our servers),you can instruct us + to cache your asset for reuse with future messages by including the headers below + in your server Resp when we request the asset. If none of these headers are included, we will + not cache your asset. + + Cache-Control: + Last-Modified: + ETag: + + # CacheControl + + The Cache-Control header tells us how to handle asset caching. We support the following directives: + + max-age=n: Indicates how many seconds (n) to cache the asset. We will reuse the cached asset in subsequent + messages until this time is exceeded, after which we will request the asset again, if needed. + Example: Cache-Control: max-age=604800. + + no-cache: Indicates the asset can be cached but should be updated if the Last-Modified header value + is different from a previous Resp.Requires the Last-Modified header. + Example: Cache-Control: no-cache. + + no-store: Indicates that the asset should not be cached. Example: Cache-Control: no-store. + + private: Indicates that the asset is personalized for the recipient and should not be cached. + + # LastModified + + Last-Modified Indicates when the asset was last modified. Used with Cache-Control: no-cache. If the + goLast-Modified value + is different from a previous Resp and Cache-Control: no-cache is included in the Resp, + we will update our cached ApiVersion of the asset with the asset in the Resp. + Example: Date: Tue, 22 Feb 2022 22:22:22 GMT. + + # ETag + + The ETag header is a unique string that identifies a specific ApiVersion of an asset. + Example: ETag: "33a64df5". This header is ignored unless both Cache-Control and Last-Modified headers + are not included in the Resp. In this case, we will cache the asset according to our own, internal + logic (which we do not disclose). + */ + CacheOptions struct { + CacheControl string `json:"cache_control,omitempty"` + LastModified string `json:"last_modified,omitempty"` + ETag string `json:"etag,omitempty"` + Expires int64 `json:"expires,omitempty"` + } + RequestContext struct { + ID string + Action RequestAction + Category RequestCategory + Name RequestName + Endpoints []string + Metadata map[string]string + CacheOptions *CacheOptions + } + + RequestContextOption func(*RequestContext) +) + +// String returns the string representation of the request context. +func (requestContext *RequestContext) String() string { + if requestContext == nil { + return "request context: nil" + } + + return fmt.Sprintf("request context: [id: %s, action: %s, category: %s, name: %s, endpoints: %v, metadata: %v]", + requestContext.ID, requestContext.Action, + requestContext.Category, requestContext.Name, + requestContext.Endpoints, requestContext.Metadata) +} + +// MakeRequestContext creates a new request context with the given options. +func MakeRequestContext(options ...RequestContextOption) *RequestContext { + requestContext := &RequestContext{ + ID: "", + Action: RequestActionSend, + Category: RequestCategoryMessage, + Name: RequestNameTextMessage, + Endpoints: []string{"/messages"}, + Metadata: nil, + } + for _, option := range options { + option(requestContext) + } + + return requestContext +} + +func WithRequestContextCacheOptions(cacheOptions *CacheOptions) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.CacheOptions = cacheOptions + } +} + +func WithRequestContextID(id string) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.ID = id + } +} + +func WithRequestContextAction(action RequestAction) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.Action = action + } +} + +func WithRequestContextCategory(category RequestCategory) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.Category = category + } +} + +func WithRequestContextName(name RequestName) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.Name = name + } +} + +func WithRequestContextEndpoints(endpoints ...string) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.Endpoints = endpoints + } +} + +func WithRequestContextMetadata(metadata map[string]string) RequestContextOption { + return func(requestContext *RequestContext) { + requestContext.Metadata = metadata + } +} + +// requestContextKey is the key used to store the request context in the request context. +type requestContextKey string + +// requestContextValue is the value used to store the request context in the request context. +const requestContextValue = "github.com/piusalfred/whatsapp/pkg/http/request_context" + +// attachRequestContext takes a request context and a context and returns a new +// context with the request context. +func attachRequestContext(ctx context.Context, reqCtx *RequestContext) context.Context { + return context.WithValue(ctx, requestContextKey(requestContextValue), reqCtx) +} + +// RetrieveRequestContext returns the request context from the context. +func RetrieveRequestContext(ctx context.Context) *RequestContext { + reqCtx, ok := ctx.Value(requestContextKey(requestContextValue)).(*RequestContext) + if !ok { + return nil + } + + return reqCtx +} diff --git a/pkg/http/endpoints_test.go b/pkg/http/request_test.go similarity index 61% rename from pkg/http/endpoints_test.go rename to pkg/http/request_test.go index c6c1ce5..f8347a9 100644 --- a/pkg/http/endpoints_test.go +++ b/pkg/http/request_test.go @@ -20,47 +20,48 @@ package http import ( + "context" + "reflect" "testing" - - "github.com/piusalfred/whatsapp/pkg/config" ) -func TestCreateMessagesURL(t *testing.T) { +func TestRetrieveRequestContext(t *testing.T) { t.Parallel() tests := []struct { - name string - conf *config.Values - want string - wantErr bool + name string + input *RequestContext }{ { - name: "full config", - conf: &config.Values{ - BaseURL: BaseURL, - Version: DefaultAPIVersion, - AccessToken: "[access-token]", - PhoneNumberID: "AC526244884", - BusinessAccountID: "9GSTSGSECDGD", + name: "should return the request context", + input: &RequestContext{ + ID: "BF87030B-3A3B-449E-97F3-08BEA461E2BB", + Action: RequestActionSend, + Category: RequestCategoryMessage, + Name: RequestNameTextMessage, + Metadata: map[string]string{ + "platform": "whatsapp", + "client": "go", + "version": "v1.0.0", + "plan": "free", + }, }, - want: "https://graph.facebook.com/v16.0/AC526244884/messages", - wantErr: false, + }, + { + name: "put nil in the context", + input: nil, + }, + { + name: "put empty in the context", + input: &RequestContext{}, }, } - for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - - got, err := CreateMessagesURL(tt.conf) - if (err != nil) != tt.wantErr { - t.Errorf("CreateMessagesURL() error = %v, wantErr %v", err, tt.wantErr) - - return - } - - if got != tt.want { - t.Errorf("CreateMessagesURL() got = %v, want %v", got, tt.want) + ctx := attachRequestContext(context.Background(), tt.input) + if got := RetrieveRequestContext(ctx); !reflect.DeepEqual(got, tt.input) { + t.Errorf("RetrieveRequestContext() = %v, want %v", got, tt.input) } }) } diff --git a/pkg/http/response.go b/pkg/http/response.go new file mode 100644 index 0000000..8c61af3 --- /dev/null +++ b/pkg/http/response.go @@ -0,0 +1,139 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package http + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + werrors "github.com/piusalfred/whatsapp/pkg/errors" +) + +type ( + Response interface { + ResponseStatus | ResponseMessage + } + + ResponseStatus struct { + Success bool `json:"success,omitempty"` + } + + ResponseMessage struct { + Product string `json:"messaging_product,omitempty"` + Contacts []*ResponseContact `json:"contacts,omitempty"` + Messages []*MessageID `json:"messages,omitempty"` + } + + MessageID struct { + ID string `json:"id,omitempty"` + } + + ResponseContact struct { + Input string `json:"input"` + WhatsappID string `json:"wa_id"` + } +) + +// DecodeResponseJSON decodes the response body into the given interface. +func DecodeResponseJSON[T Response](response *http.Response, v *T) error { + if v == nil || response == nil { + return nil + } + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("error reading response body: %w", err) + } + + defer func() { + response.Body = io.NopCloser(bytes.NewBuffer(responseBody)) + }() + + isResponseOk := response.StatusCode >= http.StatusOK && response.StatusCode <= http.StatusIMUsed + + if !isResponseOk { + if len(responseBody) == 0 { + return fmt.Errorf("%w: status code: %d", ErrRequestFailed, response.StatusCode) + } + + var errorResponse ResponseError + if err := json.Unmarshal(responseBody, &errorResponse); err != nil { + return fmt.Errorf("error decoding response error body: %w", err) + } + + return &errorResponse + } + + if len(responseBody) != 0 { + if err := json.Unmarshal(responseBody, v); err != nil { + return fmt.Errorf("error decoding response body: %w", err) + } + } + + return nil +} + +type ResponseError struct { + Code int `json:"code,omitempty"` + Err *werrors.Error `json:"error,omitempty"` +} + +// Error returns the error message for ResponseError. +func (e *ResponseError) Error() string { + return fmt.Sprintf("whatsapp error: http code: %d, %s", e.Code, strings.ToLower(e.Err.Error())) +} + +// Unwrap returns the underlying error for ResponseError. +func (e *ResponseError) Unwrap() error { + return e.Err +} + +type ( + // ResponseDecoder decodes the response body into the given interface. + ResponseDecoder interface { + DecodeResponse(response *http.Response, v interface{}) error + } + + // ResponseDecoderFunc is an adapter to allow the use of ordinary functions as + // response decoders. If f is a function with the appropriate signature, + // ResponseDecoderFunc(f) is a ResponseDecoder that calls f. + ResponseDecoderFunc func(response *http.Response, v interface{}) error + + // RawResponseDecoder ... + RawResponseDecoder func(response *http.Response) error +) + +// DecodeResponse calls f(response, v). +func (f RawResponseDecoder) DecodeResponse(response *http.Response, + _ interface{}, +) error { + return f(response) +} + +// DecodeResponse calls f(ctx, response, v). +func (f ResponseDecoderFunc) DecodeResponse(response *http.Response, + v interface{}, +) error { + return f(response, v) +} diff --git a/pkg/http/slog.go b/pkg/http/slog.go deleted file mode 100644 index 2bedda9..0000000 --- a/pkg/http/slog.go +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package http - -import ( - "log/slog" - "net/url" -) - -func (request *Request) LogValue() slog.Value { - if request == nil { - return slog.StringValue("nil") - } - var reqURL string - if request.Context != nil { - reqURL, _ = url.JoinPath(request.Context.BaseURL, request.Context.Endpoints...) - } - - var metadataAttr []any - - for key, value := range request.Metadata { - metadataAttr = append(metadataAttr, slog.String(key, value)) - } - - var headersAttr []any - - for key, value := range request.Headers { - headersAttr = append(headersAttr, slog.String(key, value)) - } - - var queryAttr []any - - for key, value := range request.Query { - queryAttr = append(queryAttr, slog.String(key, value)) - } - - value := slog.GroupValue( - slog.String("type", request.Context.RequestType.String()), - slog.String("method", request.Method), - slog.String("url", reqURL), - slog.Group("metadata", metadataAttr...), - slog.Group("headers", headersAttr...), - slog.Group("query", queryAttr...), - ) - - return value -} diff --git a/pkg/models/factories/contacts.go b/pkg/models/factories/contacts.go index c63b733..619b134 100644 --- a/pkg/models/factories/contacts.go +++ b/pkg/models/factories/contacts.go @@ -1,3 +1,22 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package factories import ( @@ -70,7 +89,7 @@ func WithContactEmails(emails ...*models.Email) ContactOption { // NewContacts ... func NewContacts(contacts []*models.Contact) models.Contacts { if contacts != nil { - return models.Contacts(contacts) + return contacts } return nil diff --git a/pkg/models/factories/interactive.go b/pkg/models/factories/interactive.go index b3efb8f..f0a3f04 100644 --- a/pkg/models/factories/interactive.go +++ b/pkg/models/factories/interactive.go @@ -1,3 +1,22 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package factories import "github.com/piusalfred/whatsapp/pkg/models" @@ -12,9 +31,9 @@ type CTAButtonURLParameters struct { func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *models.Interactive { return &models.Interactive{ - Type: models.InteractiveMessageCTAButton, + Type: InteractiveMessageCTAButton, Action: &models.InteractiveAction{ - Name: models.InteractiveMessageCTAButton, + Name: InteractiveMessageCTAButton, Parameters: &models.InteractiveActionParameters{ URL: parameters.URL, DisplayText: parameters.DisplayText, @@ -109,7 +128,9 @@ func NewInteractiveTemplate(name string, language *models.TemplateLanguage, head } } -func NewTextTemplate(name string, language *models.TemplateLanguage, parameters []*models.TemplateParameter) *models.Template { +func NewTextTemplate(name string, language *models.TemplateLanguage, + parameters []*models.TemplateParameter, +) *models.Template { component := &models.TemplateComponent{ Type: "body", Parameters: parameters, @@ -188,3 +209,13 @@ func NewInteractiveMessage(interactiveType string, options ...InteractiveOption) return interactive } + +type InteractiveHeaderType string + +const ( + // InteractiveHeaderTypeText is used for ListQR Messages, Reply Buttons, and Multi-Product Messages. + InteractiveHeaderTypeText InteractiveHeaderType = "text" + InteractiveHeaderTypeVideo InteractiveHeaderType = "video" + InteractiveHeaderTypeImage InteractiveHeaderType = "image" + InteractiveHeaderTypeDoc InteractiveHeaderType = "document" +) diff --git a/pkg/models/factories/message.go b/pkg/models/factories/message.go index fb9d404..21dd03d 100644 --- a/pkg/models/factories/message.go +++ b/pkg/models/factories/message.go @@ -1,34 +1,286 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + package factories -import "github.com/piusalfred/whatsapp/pkg/models" +import ( + "github.com/piusalfred/whatsapp/pkg/models" +) + +const ( + BodyMaxLength = 1024 + FooterMaxLength = 60 +) + +type MessageType string + +const ( + MessageTypeTemplate MessageType = "template" + MessageTypeText MessageType = "text" + MessageTypeReaction MessageType = "reaction" + MessageTypeLocation MessageType = "location" + MessageTypeContacts MessageType = "contacts" + MessageTypeInteractive MessageType = "interactive" +) + +const ( + TemplateComponentTypeHeader TemplateComponentType = "header" + TemplateComponentTypeBody TemplateComponentType = "body" +) + +type TemplateComponentType string + +const ( + InteractiveMessageReplyButton = "button" + InteractiveMessageList = "list" + InteractiveMessageProduct = "product" + InteractiveMessageProductList = "product_list" + InteractiveMessageCTAButton = "cta_url" +) -type ( - MessageOption func(*models.Message) +const ( + MessagingProductWhatsApp = "whatsapp" + RecipientTypeIndividual = "individual" ) -func NewMessage(recipient string, options ...MessageOption) *models.Message { - message := &models.Message{ - Product: "whatsapp", - RecipientType: "individual", +type MessageOption func(*models.Message) error + +// WithReplyToMessageID ... +func WithReplyToMessageID(id string) MessageOption { + return func(m *models.Message) error { + if id != "" { + m.Context = &models.Context{ + MessageID: id, + } + } + + return nil + } +} + +// TextMessage ... +func TextMessage(recipient string, text *models.Text, options ...MessageOption) (*models.Message, error) { + t := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeText), + Text: text, + } + + for _, option := range options { + if err := option(t); err != nil { + return nil, err + } + } + + return t, nil +} + +// ReactionMessage ... +func ReactionMessage(recipient string, reaction *models.Reaction, options ...MessageOption) (*models.Message, error) { + r := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeReaction), + Reaction: reaction, + } + + for _, option := range options { + if err := option(r); err != nil { + return nil, err + } + } + + return r, nil +} + +// LocationMessage ... +func LocationMessage(recipient string, location *models.Location, options ...MessageOption) (*models.Message, error) { + l := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeLocation), + Location: location, + } + + for _, option := range options { + if err := option(l); err != nil { + return nil, err + } + } + + return l, nil +} + +// TemplateMessage ... +func TemplateMessage(recipient string, template *models.Template, options ...MessageOption) (*models.Message, error) { + t := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeTemplate), + Template: template, + } + + for _, option := range options { + if err := option(t); err != nil { + return nil, err + } + } + + return t, nil +} + +// InteractiveMessage ... +func InteractiveMessage(recipient string, interactive *models.Interactive, + options ...MessageOption, +) (*models.Message, error) { + i := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeInteractive), + Interactive: interactive, + } + + for _, option := range options { + if err := option(i); err != nil { + return nil, err + } + } + + return i, nil +} + +// ContactsMessage ... +func ContactsMessage(recipient string, contacts []*models.Contact, options ...MessageOption) (*models.Message, error) { + c := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: string(MessageTypeContacts), + Contacts: contacts, + } + + for _, option := range options { + if err := option(c); err != nil { + return nil, err + } + } + + return c, nil +} + +// AudioMessage ... +func AudioMessage(recipient string, audio *models.Audio, options ...MessageOption) (*models.Message, error) { + a := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: "audio", + Audio: audio, + } + + for _, option := range options { + if err := option(a); err != nil { + return nil, err + } + } + + return a, nil +} + +// ImageMessage ... +func ImageMessage(recipient string, image *models.Image, options ...MessageOption) (*models.Message, error) { + i := &models.Message{ + Product: MessagingProductWhatsApp, To: recipient, + RecipientType: RecipientTypeIndividual, + Type: "image", + Image: image, } + for _, option := range options { - option(message) + if err := option(i); err != nil { + return nil, err + } } - return message + return i, nil } -func WithMessageTemplate(template *models.Template) MessageOption { - return func(m *models.Message) { - m.Type = "template" - m.Template = template +// VideoMessage ... +func VideoMessage(recipient string, video *models.Video, options ...MessageOption) (*models.Message, error) { + v := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: "video", + Video: video, + } + + for _, option := range options { + if err := option(v); err != nil { + return nil, err + } } + + return v, nil } -func WithMessageText(text *models.Text) MessageOption { - return func(m *models.Message) { - m.Type = "text" - m.Text = text +// DocumentMessage ... +func DocumentMessage(recipient string, document *models.Document, options ...MessageOption) (*models.Message, error) { + d := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: "document", + Document: document, } + + for _, option := range options { + if err := option(d); err != nil { + return nil, err + } + } + + return d, nil +} + +// StickerMessage ... +func StickerMessage(recipient string, sticker *models.Sticker, options ...MessageOption) (*models.Message, error) { + s := &models.Message{ + Product: MessagingProductWhatsApp, + To: recipient, + RecipientType: RecipientTypeIndividual, + Type: "sticker", + Sticker: sticker, + } + + for _, option := range options { + if err := option(s); err != nil { + return nil, err + } + } + + return s, nil } diff --git a/pkg/models/factories/templates.go b/pkg/models/factories/templates.go index 0b48706..f36c5be 100644 --- a/pkg/models/factories/templates.go +++ b/pkg/models/factories/templates.go @@ -1,24 +1,46 @@ /* -Package templates provides structures and utilities for creating and manipulating WhatsApp message -templates. + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ -WhatsApp message templates are specific message formats that businesses use to send out notifications -or customer care messages to people that have opted in to notifications. These notifications can include -a variety of messages such as appointment reminders, shipping information, issue resolution, or payment -updates. +package factories -This package supports the following template types: +import ( + "strings" + "time" -- Text-based message templates -- Media-based message templates -- Interactive message templates -- Location-based message templates -- Authentication templates with one-time password buttons -- Multi-Product Message templates + "github.com/piusalfred/whatsapp/pkg/models" +) -All API calls made using this package must be authenticated with an access token.Developers can authenticate -their API calls with the access token generated in the App Dashboard > WhatsApp > API Setup panel. -Business Solution Providers (BSPs) need to authenticate themselves with an access token that has the -'whatsapp_business_messaging' permission. -*/ -package factories +const ( + CalendarGregorian = "GREGORIAN" + CalendarSolarHijri = "SOLAR_HIJRI" +) + +// TemplateDateTimeGregorian returns a new date time template component for the given date and time. +func TemplateDateTimeGregorian(fallback string, dt time.Time) *models.TemplateDateTime { + return &models.TemplateDateTime{ + FallbackValue: fallback, + DayOfWeek: strings.ToUpper(dt.Weekday().String()), + Year: dt.Year(), + Month: int(dt.Month()), + DayOfMonth: dt.Day(), + Hour: dt.Hour(), + Minute: dt.Minute(), + Calendar: CalendarGregorian, + } +} diff --git a/pkg/models/interactive.go b/pkg/models/interactive.go index 180ce35..751944d 100644 --- a/pkg/models/interactive.go +++ b/pkg/models/interactive.go @@ -19,18 +19,7 @@ package models -const ( - InteractiveMessageReplyButton = "button" - InteractiveMessageList = "list" - InteractiveMessageProduct = "product" - InteractiveMessageProductList = "product_list" - InteractiveMessageCTAButton = "cta_url" -) - type ( - // InteractiveMessage is the type of interactive message you want to send. - InteractiveMessage string - // InteractiveButton contains information about a button in an interactive message. // A button object can contain the following parameters: // - Type: only supported type is reply (for Reply Button) @@ -105,7 +94,7 @@ type ( // // - Buttons, buttons (array of objects) Required for Reply Buttons. A button object can contain // the following parameters: - // - Type: only supported type is reply (for Reply Button) + // - MessageType: only supported type is reply (for Reply Button) // - Title: Button title. It cannot be an empty string and must be unique within the message. // Emojis are supported,markdown is not. Maximum length: 20 characters. // - ID: Unique identifier for your button. This ID is returned in the webhook when the button diff --git a/pkg/models/models.go b/pkg/models/models.go index 9dbc0c6..155e635 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -26,7 +26,7 @@ type ( } Text struct { - PreviewURL bool `json:"preview_url,omitempty"` + PreviewURL bool `json:"preview_url"` Body string `json:"body,omitempty"` } @@ -77,7 +77,8 @@ type ( // - See Media http Caching if you would like us to cache the media asset for future messages. // // - When we request the media asset from your server you must indicate the media's MIME type by including - // the Content-Type http header. For example: Content-Type: video/mp4. See Supported Media Types for a + // the Content-MessageType http header. For example: Content-MessageType: + // video/mp4. See Supported Media Types for a // list of supported media and their MIME types. // // - Caption, caption (string). For On-Premises API users on v2.41.2 or newer, this field is required when type @@ -166,51 +167,24 @@ type ( Product string `json:"messaging_product"` To string `json:"to"` RecipientType string `json:"recipient_type"` - Type MessageType `json:"type"` - PreviewURL bool `json:"preview_url,omitempty"` + Type string `json:"type"` Context *Context `json:"context,omitempty"` Template *Template `json:"template,omitempty"` Text *Text `json:"text,omitempty"` - Image *Media `json:"image,omitempty"` - Audio *Media `json:"audio,omitempty"` - Video *Media `json:"video,omitempty"` - Document *Media `json:"document,omitempty"` - Sticker *Media `json:"sticker,omitempty"` + Image *Image `json:"image,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Video *Video `json:"video,omitempty"` + Document *Document `json:"document,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` Reaction *Reaction `json:"reaction,omitempty"` Location *Location `json:"location,omitempty"` Contacts Contacts `json:"contacts,omitempty"` Interactive *Interactive `json:"interactive,omitempty"` } - // InteractiveHeaderType represent required value of InteractiveHeader.Type - // The header type you would like to use. Supported values: - // text: Used for ListQR Messages, Reply Buttons, and Multi-Product Messages. - // video: Used for Reply Buttons. - // image: Used for Reply Buttons. - // document: Used for Reply Buttons. - InteractiveHeaderType string -) - -const ( - // InteractiveHeaderTypeText is used for ListQR Messages, Reply Buttons, and Multi-Product Messages. - InteractiveHeaderTypeText InteractiveHeaderType = "text" - InteractiveHeaderTypeVideo InteractiveHeaderType = "video" - InteractiveHeaderTypeImage InteractiveHeaderType = "image" - InteractiveHeaderTypeDoc InteractiveHeaderType = "document" -) - -const ( - BodyMaxLength = 1024 - FooterMaxLength = 60 -) - -type MessageType string - -const ( - MessageTypeTemplate MessageType = "template" - MessageTypeText MessageType = "text" - MessageTypeReaction MessageType = "reaction" - MessageTypeLocation MessageType = "location" - MessageTypeContacts MessageType = "contacts" - MessageTypeInteractive MessageType = "interactive" + Image Media + Audio Media + Video Media + Document Media + Sticker Media ) diff --git a/pkg/models/template.go b/pkg/models/template.go index 54c2926..1c190da 100644 --- a/pkg/models/template.go +++ b/pkg/models/template.go @@ -19,11 +19,6 @@ package models -const ( - TemplateComponentTypeHeader TemplateComponentType = "header" - TemplateComponentTypeBody TemplateComponentType = "body" -) - type ( // TemplateDateTime contains information about a date_time parameter. // FallbackValue, fallback_value. Required. Default text if localization fails. @@ -48,7 +43,7 @@ type ( // } TemplateDateTime struct { FallbackValue string `json:"fallback_value,omitempty"` - DayOfWeek int `json:"day_of_week"` + DayOfWeek string `json:"day_of_week"` Year int `json:"year"` Month int `json:"month"` DayOfMonth int `json:"day_of_month"` @@ -194,7 +189,6 @@ type ( // TemplateComponentType is a type of component of a template message. // It can be a header, body. - TemplateComponentType string InteractiveButtonTemplate struct { SubType string diff --git a/qr.go b/qr.go index 379fc67..87faf8d 100644 --- a/qr.go +++ b/qr.go @@ -19,194 +19,195 @@ package whatsapp -import ( - "context" - "fmt" - "net/http" - - "github.com/piusalfred/whatsapp/pkg/config" - whttp "github.com/piusalfred/whatsapp/pkg/http" -) - -const ( - ImageFormatPNG ImageFormat = "PNG" - ImageFormatSVG ImageFormat = "SVG" -) - -type ( - ImageFormat string - - CreateRequest struct { - PrefilledMessage string `json:"prefilled_message"` - ImageFormat ImageFormat `json:"generate_qr_image"` - } - - CreateResponse struct { - Code string `json:"code"` - PrefilledMessage string `json:"prefilled_message"` - DeepLinkURL string `json:"deep_link_url"` - QRImageURL string `json:"qr_image_url"` - } - - Information struct { - Code string `json:"code"` - PrefilledMessage string `json:"prefilled_message"` - DeepLinkURL string `json:"deep_link_url"` - } - - ListResponse struct { - Data []*Information `json:"data,omitempty"` - } - - SuccessResponse struct { - Success bool `json:"success"` - } -) - -func (c *BaseClient) CreateQR(ctx context.Context, rtx *whttp.RequestContext, - req *CreateRequest, -) (*CreateResponse, error) { - queryParams := map[string]string{ - "prefilled_message": req.PrefilledMessage, - "generate_qr_image": string(req.ImageFormat), - "access_token": rtx.Bearer, - } - reqCtx := &whttp.RequestContext{ - RequestType: "create qr code", - BaseURL: rtx.BaseURL, - ApiVersion: rtx.ApiVersion, - PhoneNumberID: rtx.PhoneNumberID, - Endpoints: []string{"message_qrdls"}, - } - params := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Query: queryParams, - } - - var response CreateResponse - - err := c.base.Do(ctx, params, &response) - if err != nil { - return nil, fmt.Errorf("qr code create: %w", err) - } - - return &response, nil -} - -func (c *BaseClient) ListQR(ctx context.Context, request *RequestContext) (*ListResponse, error) { - reqCtx := &whttp.RequestContext{ - RequestType: "list qr codes", - BaseURL: request.BaseURL, - ApiVersion: request.ApiVersion, - PhoneNumberID: request.PhoneID, - Endpoints: []string{"message_qrdls"}, - } - - req := &whttp.Request{ - Context: reqCtx, - Method: http.MethodGet, - Query: map[string]string{"access_token": request.AccessToken}, - } - - var response ListResponse - err := c.base.Do(ctx, req, &response) - if err != nil { - return nil, fmt.Errorf("qr code list: %w", err) - } - - return &response, nil -} - -type RequestContext struct { - BaseURL string `json:"-"` - PhoneID string `json:"-"` - ApiVersion string `json:"-"` //nolint: revive,stylecheck - AccessToken string `json:"-"` -} - -var ErrNoDataFound = fmt.Errorf("no data found") - -func (c *BaseClient) Get(ctx context.Context, request *whttp.RequestContext, qrCodeID string, -) (*Information, error) { - var ( - list ListResponse - resp Information - ) - - reqCtx := whttp.MakeRequestContext(&config.Values{ - BaseURL: request.BaseURL, - Version: request.ApiVersion, - PhoneNumberID: request.PhoneNumberID, - }, whttp.RequestTypeGetQRCode, "message_qrdls", qrCodeID) - - req := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithMethod(http.MethodGet), - whttp.WithQuery(map[string]string{"access_token": request.Bearer}), - ) - - err := c.base.Do(ctx, req, &list) - if err != nil { - return nil, fmt.Errorf("qr code get: %w", err) - } - - if len(list.Data) == 0 { - return nil, fmt.Errorf("qr code get: %w", ErrNoDataFound) - } - - resp = *list.Data[0] - - return &resp, nil -} - -func (c *BaseClient) UpdateQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, - req *CreateRequest) (*SuccessResponse, error, -) { - reqCtx := whttp.MakeRequestContext(&config.Values{ - BaseURL: rtx.BaseURL, - Version: rtx.ApiVersion, - PhoneNumberID: rtx.PhoneNumberID, - }, whttp.RequestTypeUpdateQRCode, "message_qrdls", qrCodeID) - - request := &whttp.Request{ - Context: reqCtx, - Method: http.MethodPost, - Query: map[string]string{ - "prefilled_message": req.PrefilledMessage, - "generate_qr_image": string(req.ImageFormat), - "access_token": rtx.Bearer, - }, - } - - var resp SuccessResponse - err := c.base.Do(ctx, request, &resp) - if err != nil { - return nil, fmt.Errorf("qr code update (%s): %w", qrCodeID, err) - } - - return &resp, nil -} - -func (c *BaseClient) DeleteQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, -) (*SuccessResponse, error) { - reqCtx := &whttp.RequestContext{ - RequestType: "delete qr code", - BaseURL: rtx.BaseURL, - ApiVersion: rtx.ApiVersion, - PhoneNumberID: rtx.PhoneNumberID, - Endpoints: []string{"message_qrdls", qrCodeID}, - } - - req := &whttp.Request{ - Context: reqCtx, - Method: http.MethodDelete, - Query: map[string]string{"access_token": rtx.Bearer}, - } - var resp SuccessResponse - err := c.base.Do(ctx, req, &resp) - if err != nil { - return nil, fmt.Errorf("qr code delete: %w", err) - } - - return &resp, nil -} +// +// import ( +// "context" +// "fmt" +// "net/http" +// +// "github.com/piusalfred/whatsapp/pkg/config" +// whttp "github.com/piusalfred/whatsapp/pkg/http" +//) +// +// const ( +// ImageFormatPNG ImageFormat = "PNG" +// ImageFormatSVG ImageFormat = "SVG" +//) +// +// type ( +// ImageFormat string +// +// CreateRequest struct { +// PrefilledMessage string `json:"prefilled_message"` +// ImageFormat ImageFormat `json:"generate_qr_image"` +// } +// +// CreateResponse struct { +// Code string `json:"code"` +// PrefilledMessage string `json:"prefilled_message"` +// DeepLinkURL string `json:"deep_link_url"` +// QRImageURL string `json:"qr_image_url"` +// } +// +// Information struct { +// Code string `json:"code"` +// PrefilledMessage string `json:"prefilled_message"` +// DeepLinkURL string `json:"deep_link_url"` +// } +// +// ListResponse struct { +// Data []*Information `json:"data,omitempty"` +// } +// +// SuccessResponse struct { +// Success bool `json:"success"` +// } +//) +// +// func (c *Client) CreateQR(ctx context.Context, rtx *whttp.RequestContext, +// req *CreateRequest, +// ) (*CreateResponse, error) { +// queryParams := map[string]string{ +// "prefilled_message": req.PrefilledMessage, +// "generate_qr_image": string(req.ImageFormat), +// "access_token": rtx.Bearer, +// } +// reqCtx := &whttp.RequestContext{ +// RequestType: "create qr code", +// BaseURL: rtx.BaseURL, +// ApiVersion: rtx.ApiVersion, +// PhoneNumberID: rtx.PhoneNumberID, +// Endpoints: []string{"message_qrdls"}, +// } +// params := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodPost, +// Query: queryParams, +// } +// +// var response CreateResponse +// +// err := c.base.Do(ctx, params, &response) +// if err != nil { +// return nil, fmt.Errorf("qr code create: %w", err) +// } +// +// return &response, nil +//} +// +//func (c *Client) ListQR(ctx context.Context, request *RequestContext) (*ListResponse, error) { +// reqCtx := &whttp.RequestContext{ +// RequestType: "list qr codes", +// BaseURL: request.BaseURL, +// ApiVersion: request.ApiVersion, +// PhoneNumberID: request.PhoneID, +// Endpoints: []string{"message_qrdls"}, +// } +// +// req := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodGet, +// Query: map[string]string{"access_token": request.AccessToken}, +// } +// +// var response ListResponse +// err := c.base.Do(ctx, req, &response) +// if err != nil { +// return nil, fmt.Errorf("qr code list: %w", err) +// } +// +// return &response, nil +//} +// +//type RequestContext struct { +// BaseURL string `json:"-"` +// PhoneID string `json:"-"` +// ApiVersion string `json:"-"` //nolint: revive,stylecheck +// AccessToken string `json:"-"` +//} +// +//var ErrNoDataFound = fmt.Errorf("no data found") +// +//func (c *Client) Get(ctx context.Context, request *whttp.RequestContext, qrCodeID string, +//) (*Information, error) { +// var ( +// list ListResponse +// resp Information +// ) +// +// reqCtx := whttp.MakeRequestContext(&config.Values{ +// BaseURL: request.BaseURL, +// Version: request.ApiVersion, +// PhoneNumberID: request.PhoneNumberID, +// }, whttp.RequestTypeGetQRCode, "message_qrdls", qrCodeID) +// +// req := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), +// whttp.WithRequestMethod(http.MethodGet), +// whttp.WithRequestQuery(map[string]string{"access_token": request.Bearer}), +// ) +// +// err := c.base.Do(ctx, req, &list) +// if err != nil { +// return nil, fmt.Errorf("qr code get: %w", err) +// } +// +// if len(list.Data) == 0 { +// return nil, fmt.Errorf("qr code get: %w", ErrNoDataFound) +// } +// +// resp = *list.Data[0] +// +// return &resp, nil +//} +// +//func (c *Client) UpdateQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, +// req *CreateRequest) (*SuccessResponse, error, +//) { +// reqCtx := whttp.MakeRequestContext(&config.Values{ +// BaseURL: rtx.BaseURL, +// Version: rtx.ApiVersion, +// PhoneNumberID: rtx.PhoneNumberID, +// }, whttp.RequestTypeUpdateQRCode, "message_qrdls", qrCodeID) +// +// request := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodPost, +// Query: map[string]string{ +// "prefilled_message": req.PrefilledMessage, +// "generate_qr_image": string(req.ImageFormat), +// "access_token": rtx.Bearer, +// }, +// } +// +// var resp SuccessResponse +// err := c.base.Do(ctx, request, &resp) +// if err != nil { +// return nil, fmt.Errorf("qr code update (%s): %w", qrCodeID, err) +// } +// +// return &resp, nil +//} +// +//func (c *Client) DeleteQR(ctx context.Context, rtx *whttp.RequestContext, qrCodeID string, +//) (*SuccessResponse, error) { +// reqCtx := &whttp.RequestContext{ +// RequestType: "delete qr code", +// BaseURL: rtx.BaseURL, +// ApiVersion: rtx.ApiVersion, +// PhoneNumberID: rtx.PhoneNumberID, +// Endpoints: []string{"message_qrdls", qrCodeID}, +// } +// +// req := &whttp.Request{ +// Context: reqCtx, +// Method: http.MethodDelete, +// Query: map[string]string{"access_token": rtx.Bearer}, +// } +// var resp SuccessResponse +// err := c.base.Do(ctx, req, &resp) +// if err != nil { +// return nil, fmt.Errorf("qr code delete: %w", err) +// } +// +// return &resp, nil +//} diff --git a/sender.go b/sender.go index 2e496ed..1269c67 100644 --- a/sender.go +++ b/sender.go @@ -27,20 +27,20 @@ import ( ) // Sender implementors. -var _ Sender = (*BaseClient)(nil) +var _ Sender = (*Client)(nil) // Sender is an interface that represents a sender of a message. type Sender interface { - Send(ctx context.Context, req *whttp.RequestContext, message *models.Message) (*ResponseMessage, error) + Send(ctx context.Context, req *whttp.RequestContext, message *models.Message) (*whttp.ResponseMessage, error) } // SenderFunc is a function that implements the Sender interface. type SenderFunc func(ctx context.Context, req *whttp.RequestContext, - message *models.Message) (*ResponseMessage, error) + message *models.Message) (*whttp.ResponseMessage, error) // Send calls the function that implements the Sender interface. func (f SenderFunc) Send(ctx context.Context, req *whttp.RequestContext, - message *models.Message) (*ResponseMessage, + message *models.Message) (*whttp.ResponseMessage, error, ) { return f(ctx, req, message) diff --git a/templates_test.go b/templates_test.go deleted file mode 100644 index 8027050..0000000 --- a/templates_test.go +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package whatsapp diff --git a/transaparent.go b/transaparent.go deleted file mode 100644 index a8d58cc..0000000 --- a/transaparent.go +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package whatsapp - -import ( - "context" - "fmt" - - whttp "github.com/piusalfred/whatsapp/pkg/http" - "github.com/piusalfred/whatsapp/pkg/models" -) - -// TransparentClient is a client that can send messages to a recipient without knowing the configuration of the client. -// It uses Sender instead of already configured clients. It is ideal for having a client for different environments. -type TransparentClient struct { - Middlewares []SendMiddleware -} - -// Send sends a message to the recipient. -func (client *TransparentClient) Send(ctx context.Context, sender Sender, - req *whttp.RequestContext, message *models.Message, mw ...SendMiddleware, -) (*ResponseMessage, error) { - s := WrapSender(WrapSender(sender, client.Middlewares...), mw...) - - response, err := s.Send(ctx, req, message) - if err != nil { - return nil, fmt.Errorf("transparent client: %w", err) - } - - return response, nil -} diff --git a/webhooks/listener.go b/webhooks/listener.go index b00ad80..4f37950 100644 --- a/webhooks/listener.go +++ b/webhooks/listener.go @@ -307,6 +307,7 @@ func (ls *EventListener) GlobalHandler() http.Handler { }() if err != nil { writer.WriteHeader(http.StatusInternalServerError) + return } diff --git a/webhooks/models.go b/webhooks/models.go index d687083..0705739 100644 --- a/webhooks/models.go +++ b/webhooks/models.go @@ -40,7 +40,7 @@ type ( // These conversations are always customer-initiated. // - customer_initiated – The business replied to a customer message within 24 hours of the last customer message // - // PricingModel, string – Type of pricing model used by the business. Current supported value is CBP + // PricingModel, string – MessageType of pricing model used by the business. Current supported value is CBP Pricing struct { Billable bool `json:"billable,omitempty"` // Deprecated Category string `json:"category,omitempty"` @@ -159,7 +159,7 @@ type ( Button *Button `json:"button,omitempty"` Context *Context `json:"context,omitempty"` Document *models.MediaInfo `json:"document,omitempty"` - Errors []*werrors.Error `json:"werrors,omitempty"` + Errors []*werrors.Error `json:"errors,omitempty"` From string `json:"from,omitempty"` ID string `json:"id,omitempty"` Identity *Identity `json:"identity,omitempty"` diff --git a/whatsapp.go b/whatsapp.go index b37a3c2..33d15c7 100644 --- a/whatsapp.go +++ b/whatsapp.go @@ -21,651 +21,113 @@ package whatsapp import ( "context" - "encoding/json" - "errors" - "fmt" - "log/slog" - "strings" - "time" - "github.com/piusalfred/whatsapp/pkg/config" whttp "github.com/piusalfred/whatsapp/pkg/http" "github.com/piusalfred/whatsapp/pkg/models" - "github.com/piusalfred/whatsapp/pkg/models/factories" ) -var ( - ErrConfigNil = errors.New("config is nil") - ErrBadRequestFormat = errors.New("bad request") -) - -const ( - MessageStatusRead = "read" - MessageEndpoint = "messages" - MessagingProduct = "whatsapp" - RecipientTypeIndividual = "individual" - BaseURL = "https://graph.facebook.com/" - LowestSupportedVersion = "v16.0" - DateFormatContactBirthday = time.DateOnly // YYYY-MM-DD -) - -const ( - MaxAudioSize = 16 * 1024 * 1024 // 16 MB - MaxDocSize = 100 * 1024 * 1024 // 100 MB - MaxImageSize = 5 * 1024 * 1024 // 5 MB - MaxVideoSize = 16 * 1024 * 1024 // 16 MB - MaxStickerSize = 100 * 1024 // 100 KB - UploadedMediaTTL = 30 * 24 * time.Hour - MediaDownloadLinkTTL = 5 * time.Minute -) - -const ( - MediaTypeAudio MediaType = "audio" - MediaTypeDocument MediaType = "document" - MediaTypeImage MediaType = "image" - MediaTypeSticker MediaType = "sticker" - MediaTypeVideo MediaType = "video" -) - -// MediaMaxAllowedSize returns the allowed maximum size for media. It returns -// -1 for unknown media type. Currently, it checks for MediaTypeAudio,MediaTypeVideo, -// MediaTypeImage, MediaTypeSticker,MediaTypeDocument. -func MediaMaxAllowedSize(mediaType MediaType) int { - sizeMap := map[MediaType]int{ - MediaTypeAudio: MaxAudioSize, - MediaTypeDocument: MaxDocSize, - MediaTypeSticker: MaxStickerSize, - MediaTypeImage: MaxImageSize, - MediaTypeVideo: MaxVideoSize, - } - - size, ok := sizeMap[mediaType] - if ok { - return size - } - - return -1 -} - -func (r *ResponseMessage) LogValue() slog.Value { - if r == nil { - return slog.StringValue("nil") - } - - attr := []slog.Attr{ - slog.String("product", r.Product), - } - - for i, message := range r.Messages { - attr = append(attr, slog.String("message", fmt.Sprintf("%d.%s", i+1, message.ID))) - } - - for i, contact := range r.Contacts { - input := slog.String(fmt.Sprintf("contact.input.%d", i+1), contact.Input) - waID := slog.String(fmt.Sprintf("contact.wa_id.%d", i+1), contact.WhatsappID) - attr = append(attr, input, waID) - } - - return slog.GroupValue(attr...) -} - -var _ slog.LogValuer = (*ResponseMessage)(nil) - +// var ( +// +// ErrConfigNil = errors.New("config is nil") +// ErrBadRequestFormat = errors.New("bad request") +// +// ) +// +// const ( +// +// MessageStatusRead = "read" +// MessageEndpoint = "messages" +// MessagingProduct = "whatsapp" +// RecipientTypeIndividual = "individual" +// BaseURL = "https://graph.facebook.com/" +// LowestSupportedVersion = "v16.0" +// DateFormatContactBirthday = time.DateOnly // YYYY-MM-DD +// +// ) +// +// const ( +// +// MaxAudioSize = 16 * 1024 * 1024 // 16 MB +// MaxDocSize = 100 * 1024 * 1024 // 100 MB +// MaxImageSize = 5 * 1024 * 1024 // 5 MB +// MaxVideoSize = 16 * 1024 * 1024 // 16 MB +// MaxStickerSize = 100 * 1024 // 100 KB +// UploadedMediaTTL = 30 * 24 * time.Hour +// MediaDownloadLinkTTL = 5 * time.Minute +// +// ) +// +// const ( +// +// MediaTypeAudio MediaType = "audio" +// MediaTypeDocument MediaType = "document" +// MediaTypeImage MediaType = "image" +// MediaTypeSticker MediaType = "sticker" +// MediaTypeVideo MediaType = "video" +// +// ) +// +// // MediaMaxAllowedSize returns the allowed maximum size for media. It returns +// // -1 for unknown media type. Currently, it checks for MediaTypeAudio,MediaTypeVideo, +// // MediaTypeImage, MediaTypeSticker,MediaTypeDocument. +// +// func MediaMaxAllowedSize(mediaType MediaType) int { +// sizeMap := map[MediaType]int{ +// MediaTypeAudio: MaxAudioSize, +// MediaTypeDocument: MaxDocSize, +// MediaTypeSticker: MaxStickerSize, +// MediaTypeImage: MaxImageSize, +// MediaTypeVideo: MaxVideoSize, +// } +// +// size, ok := sizeMap[mediaType] +// if ok { +// return size +// } +// +// return -1 +// } type ( - // Client is a struct that holds the configuration for the whatsapp client. - // It is used to create a new whatsapp client for a single user. Uses the BaseClient - // to make requests to the whatsapp api. If you want a client that's flexible and can - // make requests to the whatsapp api for different users, use the TransparentClient. - Client struct { - bc *BaseClient - config *config.Values - } - - ClientOption func(*Client) - - ResponseMessage struct { - Product string `json:"messaging_product,omitempty"` - Contacts []*ResponseContact `json:"contacts,omitempty"` - Messages []*MessageID `json:"messages,omitempty"` - } - MessageID struct { - ID string `json:"id,omitempty"` - } - - ResponseContact struct { - Input string `json:"input"` - WhatsappID string `json:"wa_id"` - } - - TextMessage struct { - Message string - PreviewURL bool - } - - ReactMessage struct { - MessageID string - Emoji string - } - - TextTemplateRequest struct { - Name string - LanguageCode string - LanguagePolicy string - Body []*models.TemplateParameter - } - - Template struct { - LanguageCode string - LanguagePolicy string - Name string - Components []*models.TemplateComponent - } - - InteractiveTemplateRequest struct { - Name string - LanguageCode string - LanguagePolicy string - Headers []*models.TemplateParameter - Body []*models.TemplateParameter - Buttons []*models.InteractiveButtonTemplate - } - - MediaMessage struct { - Type MediaType - MediaID string - MediaLink string - Caption string - Filename string - Provider string - } - - MediaTemplateRequest struct { - Name string - LanguageCode string - LanguagePolicy string - Header *models.TemplateParameter - Body []*models.TemplateParameter - } - - // ReplyRequest contains options for replying to a message. - ReplyRequest struct { - Recipient string - Context string // this is ID of the message to reply to - MessageType models.MessageType - Content any // this is a Text if MessageType is Text - } - - StatusResponse struct { - Success bool `json:"success,omitempty"` - } - - MessageStatusUpdateRequest struct { + statusUpdateRequest struct { MessagingProduct string `json:"messaging_product,omitempty"` // always whatsapp Status string `json:"status,omitempty"` // always read MessageID string `json:"message_id,omitempty"` } - SendTextRequest struct { - BaseURL string - AccessToken string - PhoneNumberID string - ApiVersion string //nolint: revive,stylecheck - Recipient string - Message string - PreviewURL bool - } - - SendLocationRequest struct { - BaseURL string - AccessToken string - PhoneNumberID string - ApiVersion string //nolint: revive,stylecheck - Recipient string - Name string - Address string - Latitude float64 - Longitude float64 - } - - ReactRequest struct { - BaseURL string - AccessToken string - PhoneNumberID string - ApiVersion string //nolint: revive,stylecheck - Recipient string - MessageID string - Emoji string - } - - SendTemplateRequest struct { - Recipient string - TemplateLanguageCode string - TemplateLanguagePolicy string - TemplateName string - TemplateComponents []*models.TemplateComponent - } - - /* - CacheOptions contains the options on how to send a media message. You can specify either the - ID or the link of the media. Also, it allows you to specify caching options. - - The Cloud API supports media http caching. If you are using a link (link) to a media asset on your - server (as opposed to the ID (id) of an asset you have uploaded to our servers),you can instruct us - to cache your asset for reuse with future messages by including the headers below - in your server Resp when we request the asset. If none of these headers are included, we will - not cache your asset. - - Cache-Control: - Last-Modified: - ETag: - - # CacheControl - - The Cache-Control header tells us how to handle asset caching. We support the following directives: - - max-age=n: Indicates how many seconds (n) to cache the asset. We will reuse the cached asset in subsequent - messages until this time is exceeded, after which we will request the asset again, if needed. - Example: Cache-Control: max-age=604800. - - no-cache: Indicates the asset can be cached but should be updated if the Last-Modified header value - is different from a previous Resp.Requires the Last-Modified header. - Example: Cache-Control: no-cache. - - no-store: Indicates that the asset should not be cached. Example: Cache-Control: no-store. - - private: Indicates that the asset is personalized for the recipient and should not be cached. - - # LastModified - - Last-Modified Indicates when the asset was last modified. Used with Cache-Control: no-cache. If the - goLast-Modified value - is different from a previous Resp and Cache-Control: no-cache is included in the Resp, - we will update our cached ApiVersion of the asset with the asset in the Resp. - Example: Date: Tue, 22 Feb 2022 22:22:22 GMT. - - # ETag - - The ETag header is a unique string that identifies a specific ApiVersion of an asset. - Example: ETag: "33a64df5". This header is ignored unless both Cache-Control and Last-Modified headers - are not included in the Resp. In this case, we will cache the asset according to our own, internal - logic (which we do not disclose). - */ - CacheOptions struct { - CacheControl string `json:"cache_control,omitempty"` - LastModified string `json:"last_modified,omitempty"` - ETag string `json:"etag,omitempty"` - Expires int64 `json:"expires,omitempty"` - } - - SendMediaRequest struct { - Recipient string - Type MediaType - MediaID string - MediaLink string - Caption string - Filename string - Provider string - CacheOptions *CacheOptions + RequestParams struct { + ID string + Metadata map[string]string + Recipient string + ReplyID string } ) -func WithBaseClient(base *BaseClient) ClientOption { - return func(client *Client) { - client.bc = base - } -} - -func NewClient(reader config.Reader, options ...ClientOption) (*Client, error) { - values, err := reader.Read(context.Background()) - if err != nil { - return nil, fmt.Errorf("failed to read values: %w", err) - } - - return NewClientWithConfig(values, options...) +// MessageSender .. +type MessageSender interface { + Text(ctx context.Context, params *RequestParams, text *models.Text) (*whttp.ResponseMessage, error) + React(ctx context.Context, params *RequestParams, reaction *models.Reaction) (*whttp.ResponseMessage, error) + Contacts(ctx context.Context, params *RequestParams, contacts []*models.Contact) (*whttp.ResponseMessage, error) + Location(ctx context.Context, params *RequestParams, request *models.Location) (*whttp.ResponseMessage, error) + InteractiveMessage(ctx context.Context, params *RequestParams, + interactive *models.Interactive) (*whttp.ResponseMessage, error) + Template(ctx context.Context, params *RequestParams, template *models.Template) (*whttp.ResponseMessage, error) +} + +// MediaSender .. +type MediaSender interface { + Image(ctx context.Context, params *RequestParams, image *models.Image, + options *whttp.CacheOptions) (*whttp.ResponseMessage, error) + Audio(ctx context.Context, params *RequestParams, audio *models.Audio, + options *whttp.CacheOptions) (*whttp.ResponseMessage, error) + Video(ctx context.Context, params *RequestParams, video *models.Video, + options *whttp.CacheOptions) (*whttp.ResponseMessage, error) + Document(ctx context.Context, params *RequestParams, document *models.Document, + options *whttp.CacheOptions) (*whttp.ResponseMessage, error) + Sticker(ctx context.Context, params *RequestParams, sticker *models.Sticker, + options *whttp.CacheOptions) (*whttp.ResponseMessage, error) } -func NewClientWithConfig(config *config.Values, options ...ClientOption) (*Client, error) { - if config == nil { - return nil, ErrConfigNil - } - client := &Client{ - bc: NewBaseClient(), - config: config, - } - - if client.config.BaseURL == "" { - client.config.BaseURL = BaseURL - } - - if client.config.Version == "" { - client.config.Version = LowestSupportedVersion - } - - for _, option := range options { - if option == nil { - // skip nil options - continue - } - option(client) - } - - return client, nil -} - -// Reply is used to reply to a message. It accepts a ReplyRequest and returns a Response and an error. -// You can send any message as a reply to a previous message in a conversation by including the previous -// message's ID set as Ctx in ReplyRequest. The recipient will receive the new message along with a -// contextual bubble that displays the previous message's content. -func (client *Client) Reply(ctx context.Context, request *ReplyRequest, -) (*ResponseMessage, error) { - if request == nil { - return nil, fmt.Errorf("reply request is nil: %w", ErrBadRequestFormat) - } - payload, err := formatReplyPayload(request) - if err != nil { - return nil, fmt.Errorf("reply: %w", err) - } - - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeReply, MessageEndpoint) - - req := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithBearer(client.config.AccessToken), - whttp.WithPayload(payload), - ) - - var message ResponseMessage - err = client.bc.base.Do(ctx, req, &message) - if err != nil { - return nil, fmt.Errorf("reply: %w", err) - } - - return &message, nil -} - -// formatReplyPayload builds the payload for a reply. It accepts ReplyRequest and returns a byte array -// and an error. This function is used internally by Reply. -func formatReplyPayload(options *ReplyRequest) ([]byte, error) { - contentByte, err := json.Marshal(options.Content) - if err != nil { - return nil, fmt.Errorf("format reply payload: %w", err) - } - payloadBuilder := strings.Builder{} - payloadBuilder.WriteString(`{"messaging_product":"whatsapp","context":{"message_id":"`) - payloadBuilder.WriteString(options.Context) - payloadBuilder.WriteString(`"},"to":"`) - payloadBuilder.WriteString(options.Recipient) - payloadBuilder.WriteString(`","type":"`) - payloadBuilder.WriteString(string(options.MessageType)) - payloadBuilder.WriteString(`","`) - payloadBuilder.WriteString(string(options.MessageType)) - payloadBuilder.WriteString(`":`) - payloadBuilder.Write(contentByte) - payloadBuilder.WriteString(`}`) - - return []byte(payloadBuilder.String()), nil -} - -// SendText sends a text message to a WhatsApp Business Account. -func (client *Client) SendText(ctx context.Context, recipient string, - message *TextMessage, -) (*ResponseMessage, error) { - text := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeText, - Text: &models.Text{ - PreviewURL: message.PreviewURL, - Body: message.Message, - }, - } - - res, err := client.SendMessage(ctx, whttp.RequestTypeTextMessage, text) - if err != nil { - return nil, fmt.Errorf("failed to send text message: %w", err) - } - - return res, nil -} - -func (client *Client) React(ctx context.Context, recipient string, msg *ReactMessage) (*ResponseMessage, error) { - reaction := &models.Message{ - Product: MessagingProduct, - To: recipient, - Type: models.MessageTypeReaction, - Reaction: &models.Reaction{ - MessageID: msg.MessageID, - Emoji: msg.Emoji, - }, - } - - res, err := client.SendMessage(ctx, whttp.RequestTypeReact, reaction) - if err != nil { - return nil, fmt.Errorf("failed to send reaction message: %w", err) - } - - return res, nil -} - -// SendContacts sends a contact message. Contacts can be easily built using the models.NewContact() function. -func (client *Client) SendContacts(ctx context.Context, recipient string, contacts []*models.Contact) ( - *ResponseMessage, error, -) { - contact := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeContacts, - Contacts: contacts, - } - - return client.SendMessage(ctx, whttp.RequestTypeContacts, contact) -} - -// SendLocation sends a location message to a WhatsApp Business Account. -func (client *Client) SendLocation(ctx context.Context, recipient string, - message *models.Location, -) (*ResponseMessage, error) { - location := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeLocation, - Location: &models.Location{ - Name: message.Name, - Address: message.Address, - Latitude: message.Latitude, - Longitude: message.Longitude, - }, - } - - return client.SendMessage(ctx, whttp.RequestTypeLocation, location) -} - -// SendMessage sends a message. -func (client *Client) SendMessage(ctx context.Context, name whttp.RequestType, message *models.Message) ( - *ResponseMessage, error, -) { - req := whttp.MakeRequestContext(client.config, name, MessageEndpoint) - - return client.bc.Send(ctx, req, message) -} - -// MarkMessageRead sends a read receipt for a message. -func (client *Client) MarkMessageRead(ctx context.Context, messageID string) (*StatusResponse, error) { - req := whttp.MakeRequestContext(client.config, whttp.RequestTypeMarkMessageRead, MessageEndpoint) - - return client.bc.MarkMessageRead(ctx, req, messageID) -} - -// SendMediaTemplate sends a media template message to the recipient. This kind of template message has a media -// message as a header. This is its main distinguishing feature from the text based template message. -func (client *Client) SendMediaTemplate(ctx context.Context, recipient string, req *MediaTemplateRequest) ( - *ResponseMessage, error, -) { - tmpLanguage := &models.TemplateLanguage{ - Policy: req.LanguagePolicy, - Code: req.LanguageCode, - } - template := factories.NewMediaTemplate(req.Name, tmpLanguage, req.Header, req.Body) - payload := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeTemplate, - Template: template, - } - - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeMediaTemplate, MessageEndpoint) - - response, err := client.bc.Send(ctx, reqCtx, payload) - if err != nil { - return nil, err - } - - return response, nil -} - -// SendTextTemplate sends a text template message to the recipient. This kind of template message has a text -// message as a header. This is its main distinguishing feature from the media based template message. -func (client *Client) SendTextTemplate(ctx context.Context, recipient string, req *TextTemplateRequest) ( - *ResponseMessage, error, -) { - tmpLanguage := &models.TemplateLanguage{ - Policy: req.LanguagePolicy, - Code: req.LanguageCode, - } - template := factories.NewTextTemplate(req.Name, tmpLanguage, req.Body) - payload := factories.NewMessage(recipient, factories.WithMessageTemplate(template)) - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeTextTemplate, MessageEndpoint) - params := whttp.MakeRequest(whttp.WithRequestContext(reqCtx), - whttp.WithPayload(payload), - whttp.WithBearer(client.config.AccessToken)) - - var message ResponseMessage - err := client.bc.base.Do(ctx, params, &message) - if err != nil { - return nil, fmt.Errorf("client: send text template: %w", err) - } - - return &message, nil -} - -// SendTemplate sends a template message to the recipient. There are at the moment three types of templates messages -// you can send to the user, Text Based Templates, Media Based Templates and Interactive Templates. Text Based templates -// have a text message for a Header and Media Based templates have a Media message for a Header. Interactive Templates -// can have any of the above as a Header and also have a list of buttons that the user can interact with. -// You can use models.NewTextTemplate, models.NewMediaTemplate and models.NewInteractiveTemplate to create a Template. -// These are helper functions that will make your life easier. -func (client *Client) SendTemplate(ctx context.Context, recipient string, template *Template) ( - *ResponseMessage, error, -) { - message := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeTemplate, - Template: &models.Template{ - Language: &models.TemplateLanguage{ - Code: template.LanguageCode, - Policy: template.LanguagePolicy, - }, - Name: template.Name, - Components: template.Components, - }, - } - - req := whttp.MakeRequestContext(client.config, whttp.RequestTypeTemplate, MessageEndpoint) - - return client.bc.Send(ctx, req, message) -} - -// SendInteractiveMessage sends an interactive message to the recipient. -func (client *Client) SendInteractiveMessage(ctx context.Context, recipient string, req *models.Interactive) ( - *ResponseMessage, error, -) { - template := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeInteractive, - Interactive: req, - } - - reqc := whttp.MakeRequestContext(client.config, whttp.RequestTypeInteractiveMessage, MessageEndpoint) - - return client.bc.Send(ctx, reqc, template) -} - -// SendMedia sends a media message to the recipient. Media can be sent using ID or Link. If using id, you must -// first upload your media asset to our servers and capture the returned media ID. If using link, your asset must -// be on a publicly accessible server or the message will fail to send. -func (client *Client) SendMedia(ctx context.Context, recipient string, req *MediaMessage, - cacheOptions *CacheOptions, -) (*ResponseMessage, error) { - request := &SendMediaRequest{ - Recipient: recipient, - Type: req.Type, - MediaID: req.MediaID, - MediaLink: req.MediaLink, - Caption: req.Caption, - Filename: req.Filename, - Provider: req.Provider, - CacheOptions: cacheOptions, - } - - response, err := client.bc.SendMedia(ctx, client.config, request) - if err != nil { - return nil, err - } - - return response, nil -} - -// SendInteractiveTemplate send an interactive template message which contains some buttons for user interaction. -// Interactive message templates expand the content you can send recipients beyond the standard message template -// and media messages template types to include interactive buttons using the components object. There are two types -// of predefined buttons: -// -// - Call-to-Action — Allows your customer to call a phone number and visit a website. -// - Quick Reply — Allows your customer to return a simple text message. -// -// These buttons can be attached to text messages or media messages. Once your interactive message templates have been -// created and approved, you can use them in notification messages as well as customer service/care messages. -func (client *Client) SendInteractiveTemplate(ctx context.Context, recipient string, req *InteractiveTemplateRequest) ( - *ResponseMessage, error, -) { - tmpLanguage := &models.TemplateLanguage{ - Policy: req.LanguagePolicy, - Code: req.LanguageCode, - } - template := factories.NewInteractiveTemplate(req.Name, tmpLanguage, req.Headers, req.Body, req.Buttons) - message := &models.Message{ - Product: MessagingProduct, - To: recipient, - RecipientType: RecipientTypeIndividual, - Type: models.MessageTypeTemplate, - Template: template, - } - reqCtx := whttp.MakeRequestContext(client.config, whttp.RequestTypeInteractiveTemplate, MessageEndpoint) - - response, err := client.bc.Send(ctx, reqCtx, message) - if err != nil { - return nil, err - } - - return response, nil -} - -// Whatsapp is an interface that represents a whatsapp client. -type Whatsapp interface { - SendText(ctx context.Context, recipient string, message *TextMessage) (*ResponseMessage, error) - React(ctx context.Context, recipient string, msg *ReactMessage) (*ResponseMessage, error) - SendContacts(ctx context.Context, recipient string, contacts []*models.Contact) (*ResponseMessage, error) - SendLocation(ctx context.Context, recipient string, location *models.Location) (*ResponseMessage, error) - SendInteractiveMessage(ctx context.Context, recipient string, req *models.Interactive) (*ResponseMessage, error) - SendTemplate(ctx context.Context, recipient string, template *Template) (*ResponseMessage, error) - SendMedia(ctx context.Context, recipient string, media *MediaMessage, options *CacheOptions) (*ResponseMessage, error) -} - -var _ Whatsapp = (*Client)(nil) +var ( + _ MessageSender = (*Client)(nil) + _ MediaSender = (*Client)(nil) +) diff --git a/whatsapp_test.go b/whatsapp_test.go deleted file mode 100644 index 15fa318..0000000 --- a/whatsapp_test.go +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2023 Pius Alfred - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of this software - * and associated documentation files (the “Software”), to deal in the Software without restriction, - * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, - * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all copies or substantial - * portions of the Software. - * - * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT - * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, - * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -package whatsapp - -import ( - "testing" -) - -func TestMediaMaxAllowedSize(t *testing.T) { - t.Parallel() - type args struct { - mediaType MediaType - } - tests := []struct { - name string - args args - want int - }{ - { - name: "video", - args: args{ - MediaTypeVideo, - }, - want: MaxVideoSize, - }, - - { - name: "unknown", - args: args{ - MediaType("unknown"), - }, - want: -1, - }, - } - for _, tt := range tests { - tt := tt - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - args := tt.args - if got := MediaMaxAllowedSize(args.mediaType); got != tt.want { - t.Errorf("MediaMaxAllowedSize() = %v, want %v", got, tt.want) - } - }) - } -} From 809c52d1d5582babf94fad87637bfacd355348e1 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sun, 14 Jan 2024 02:27:15 +0100 Subject: [PATCH 05/10] update README --- README.md | 34 +++++++++++++--------------------- pkg/models/models.go | 43 +++++++++++++++++++++++++++---------------- webhooks/models.go | 15 ++++++++------- 3 files changed, 48 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 55aedb2..96965dc 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,20 @@ # whatsapp -## implemented -- [x] send text message -- [x] send image message -- [x] send location message -- [x] send contact message -- [x] send template message -- [x] react to message -- [x] mark message as read -- [x] webhooks -# SETUP +## set up +- Have golang installed +- Register as a Meta Developer here https://developers.facebook.com/docs/development/register +- Create an application here https://developers.facebook.com/docs/development/create-an-app and configure +it to enable access to WhatsApp Business Cloud API and Webhooks. -## pre requisites +- You can manage your apps here https://developers.facebook.com/apps/ -To be able to test/use this api you need Access Token, Phone number ID and Business ID. For that -tou need to register as a Meta Developer. You can register here https://developers.facebook.com/docs/development/register +- From Whatsapp Developer Dashboard you can try and send a test message to your phone number. +to be sure that everything is working fine before you start using this api. Also you need to +reply to that message to be able to send other messages. -Then create an application here https://developers.facebook.com/docs/development/create-an-app and configre -it to enable access to WhatsApp Business Cloud API. +- Go to [examples/base](examples/base) then create `.env` file that looks like [examples/base/.envrc](examples/base/.envrc) + and add your credentials there. -You can manage your apps here https://developers.facebook.com/apps/ - -From Whatsapp Developer Dashboard you can try and send a test message to your phone number. -to be sure that everything is working fine before you start using this api. - -When all the above is done you can start using this api. \ No newline at end of file +- Run `make run` and wait to receive a message on your phone. Make sure you have sent the template message +first from the Whatsapp Developer Dashboard. diff --git a/pkg/models/models.go b/pkg/models/models.go index 155e635..9a6126d 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -108,6 +108,16 @@ type ( // Message is a WhatsApp message. It contains the following fields: // + // BizOpaqueCallbackData biz_opaque_callback_data (string) Optional. An arbitrary + // 256B string, useful for tracking. For example, you could pass the message template + // ID in this field to track your customer's journey starting from the first message you + // send. You could then track the ROI of different message template types to determine the + // most effective one. + // Any app subscribed to the messages webhook field on the WhatsApp Business Account can get + // this string, as it is included in statuses object within webhook payloads. + // Cloud API does not process this field, it just returns it as part of sent/delivered/read + // message webhooks. + // // Audio (object) Required when type=audio. A media object containing audio. // // Contacts (object) Required when type=contacts. A contacts object. @@ -164,22 +174,23 @@ type ( // // Type (string). Optional. The type of message you want to send. Default: text. Message struct { - Product string `json:"messaging_product"` - To string `json:"to"` - RecipientType string `json:"recipient_type"` - Type string `json:"type"` - Context *Context `json:"context,omitempty"` - Template *Template `json:"template,omitempty"` - Text *Text `json:"text,omitempty"` - Image *Image `json:"image,omitempty"` - Audio *Audio `json:"audio,omitempty"` - Video *Video `json:"video,omitempty"` - Document *Document `json:"document,omitempty"` - Sticker *Sticker `json:"sticker,omitempty"` - Reaction *Reaction `json:"reaction,omitempty"` - Location *Location `json:"location,omitempty"` - Contacts Contacts `json:"contacts,omitempty"` - Interactive *Interactive `json:"interactive,omitempty"` + Product string `json:"messaging_product"` + BizOpaqueCallbackData string `json:"biz_opaque_callback_data,omitempty"` + To string `json:"to"` + RecipientType string `json:"recipient_type"` + Type string `json:"type"` + Context *Context `json:"context,omitempty"` + Template *Template `json:"template,omitempty"` + Text *Text `json:"text,omitempty"` + Image *Image `json:"image,omitempty"` + Audio *Audio `json:"audio,omitempty"` + Video *Video `json:"video,omitempty"` + Document *Document `json:"document,omitempty"` + Sticker *Sticker `json:"sticker,omitempty"` + Reaction *Reaction `json:"reaction,omitempty"` + Location *Location `json:"location,omitempty"` + Contacts Contacts `json:"contacts,omitempty"` + Interactive *Interactive `json:"interactive,omitempty"` } Image Media diff --git a/webhooks/models.go b/webhooks/models.go index 0705739..50a0836 100644 --- a/webhooks/models.go +++ b/webhooks/models.go @@ -115,13 +115,14 @@ type ( // back, as it is implied that a message has been delivered if it has been read. The reason for this // behavior is internal optimization. Status struct { - ID string `json:"id,omitempty"` - RecipientID string `json:"recipient_id,omitempty"` - StatusValue string `json:"status,omitempty"` - Timestamp int `json:"timestamp,omitempty"` - Conversation *Conversation `json:"conversation,omitempty"` - Pricing *Pricing `json:"pricing,omitempty"` - Errors []*werrors.Error `json:"werrors,omitempty"` + ID string `json:"id,omitempty"` + BizOpaqueCallbackData string `json:"biz_opaque_callback_data,omitempty"` + RecipientID string `json:"recipient_id,omitempty"` + StatusValue string `json:"status,omitempty"` + Timestamp int `json:"timestamp,omitempty"` + Conversation *Conversation `json:"conversation,omitempty"` + Pricing *Pricing `json:"pricing,omitempty"` + Errors []*werrors.Error `json:"errors,omitempty"` } // Event is the type of event that occurred and leads to the notification being sent. From a6142e738b83c944e5aab60d0a028331636f7e4c Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sun, 14 Jan 2024 13:54:13 +0100 Subject: [PATCH 06/10] update README --- README.md | 143 ++++++++++++++++++++++++++++ base.go | 16 ++++ health/health.go | 77 +++++++++++++++ pkg/http/request.go | 8 +- pkg/models/factories/interactive.go | 39 ++++++++ pkg/models/factories/message.go | 1 + pkg/models/models.go | 2 +- templates/templates.go | 86 +++++++++++++++++ 8 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 health/health.go create mode 100644 templates/templates.go diff --git a/README.md b/README.md index 96965dc..85ed8b5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # whatsapp +Configurable easy to use Go wrapper for the WhatsApp Cloud API. + ## set up - Have golang installed @@ -18,3 +20,144 @@ reply to that message to be able to send other messages. - Run `make run` and wait to receive a message on your phone. Make sure you have sent the template message first from the Whatsapp Developer Dashboard. + +## Usage + +1. [Messages](#1-messages) ✅ + * [1.1 Normal Messages](#11-normal-messages) 🚧 + * [1.2 Reply Messages](#12-reply-messages) 🚧 + * [1.3 Media Messages](#13-media-messages) 🚧 + * [1.4 Interactive Messages](#14-interactive-messages) 🚧 + * [1.5 Template Messages](#15-template-messages) 🚧 + + [1.5.1 Text-based Message Templates](#151-text-based-message-templates) 🚧 + + [1.5.2 Media-based Message Templates](#152-media-based-message-templates) 🚧 + + [1.5.3 Interactive Message Templates](#153-interactive-message-templates) 🚧 + + [1.5.4 Location-based Message Templates](#154-location-based-message-templates) 🚧 + + [1.5.5 Authentication Templates with OTP Buttons](#155-authentication-templates-with-otp-buttons) 🚧 + + [1.5.6 Multi-Product Message Templates](#156-multi-product-message-templates) 🚧 + +2. [Webhooks](#2-webhooks) ✅ + * [2.1 Verify Requests](#21-verify-requests) 🚧 + * [2.2 Listen To Requests](#22-listen-to-requests) 🚧 + +3. [Health Status](#3-health-status) 🚧 + +4. [Templates Management](#4-templates-management) ✅ + +5. [PhoneNumbers](#5-phonenumbers) 🚧 + * [5.1 Register](#51-register) 🚧 + * [5.2 Delete](#52-delete) 🚧 + * [5.3 Set PIN](#53-set-pin) 🚧 + +6. [QR Codes Management](#6-qr-codes-management) ✅ + +7. [Media Management](#7-media-management) ✅ + * [7.1 Upload](#71-upload) 🚧 + * [7.2 Delete](#72-delete) 🚧 + * [7.3 List](#73-list) 🚧 + * [7.4 Download](#74-download) 🚧 + * [7.5 Retrieve Information](#75-retrieve-information) 🚧 + +8. [WhatsApp Business Account](#8-whatsapp-business-account) ✅ + +9. [WhatsApp Business Encryption](#9-whatsapp-business-encryption) ✅ + * Description coming soon. + +10. [Flows](#10-flows) 🚧 + + +# 1. Messages +Description coming soon. + +## 1.1 Normal Messages +Description coming soon. + +## 1.2 Reply Messages +Description coming soon. + +## 1.3 Media Messages +Description coming soon. + +## 1.4 Interactive Messages +Description coming soon. + +## 1.5 Template Messages +Description coming soon. + +### 1.5.1 Text-based Message Templates +Description coming soon. + +### 1.5.2 Media-based Message Templates +Description coming soon. + +### 1.5.3 Interactive Message Templates +Description coming soon. + +### 1.5.4 Location-based Message Templates +Description coming soon. + +### 1.5.5 Authentication Templates with OTP Buttons +Description coming soon. + +### 1.5.6 Multi-Product Message Templates +Description coming soon. + +## 2. Webhooks +Description coming soon. + +## 2.1 Verify Requests +Description coming soon. + +## 2.2 Listen To Requests +Description coming soon. + +# 3. Health Status +Description coming soon. + +# 4. Templates Management +Description coming soon. + +# 5. PhoneNumbers +Description coming soon. + +## 5.1 Register +Description coming soon. + +## 5.2 Delete +Description coming soon. + +## 5.3 Set PIN +Description coming soon. + +# 6. QR Codes Management +Description coming soon. + +# 7. Media Management +Description coming soon. + +## 7.1 Upload +Description coming soon. + +## 7.2 Delete +Description coming soon. + +## 7.3 List +Description coming soon. + +## 7.4 Download +Description coming soon. + +## 7.5 Retrieve Information +Description coming soon. + +# 8. WhatsApp Business Account +Description coming soon. + +# 9. WhatsApp Business Encryption +Description coming soon. + +# 10. Flows +Description coming soon. + + + diff --git a/base.go b/base.go index 011b341..a5a8313 100644 --- a/base.go +++ b/base.go @@ -42,8 +42,24 @@ type ( Recipient string Params *factories.CTAButtonURLParameters } + + LocationRequestParams struct { + Recipient string + ReplyID string + Message string + } ) +// RequestLocation sends a location request to the recipient. +// LINK: https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages/location-request-messages +func (c *Client) RequestLocation(ctx context.Context, params *LocationRequestParams) ( + *whttp.ResponseMessage, error, +) { + message := factories.LocationRequestMessage(params.Recipient, params.ReplyID, params.Message) + + return c.Send(ctx, nil, message) +} + func (c *Client) Image(ctx context.Context, params *RequestParams, image *models.Image, options *whttp.CacheOptions, ) (*whttp.ResponseMessage, error) { diff --git a/health/health.go b/health/health.go new file mode 100644 index 0000000..1380619 --- /dev/null +++ b/health/health.go @@ -0,0 +1,77 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Package health describes how to determine whether or not you can send messages successfully +// using a given API resource. +// The following nodes have a health_status field: +// +// - WhatsApp Business Account +// +// - WhatsApp Business Phone Number +// +// - WhatsApp Message Template +// +// If you request the health_status field on any of these nodes, the API will return a summary +// of the messaging health of all the nodes involved in messaging requests if using the targeted +// node. This summary indicates if you will be able to use the API to send messages successfully, +// if you will have limited success due to some limitation on one or more nodes, or if you will be +// prevented from messaging entirely. +// +// When you attempt to send a message, multiple nodes are involved, including the app, +// the business that owns or has claimed it, a WABA, a business phone number, and a template (if sending a template message). +// +// Each of these nodes can have one of the following health statuses assigned to the can_send_message property: +// +// AVAILABLE: Indicates that the node meets all messaging requirements. +// LIMITED: Indicates that the node meets messaging requirements, but has some limitations. +// If a given node has this value, additional info will be included. +// BLOCKED: Indicates that the node does not meet one or more messaging requirements. If a given node +// has this value, the errors property will be included which describes the error and a possible solution. +// Overall Status +// +// The overall health status property (health_status.can_send_message) will be set as follows: +// +// If one or more nodes is blocked, it will be set to BLOCKED. +// If no nodes are blocked, but one or more nodes is limited, it will be set to LIMITED. +// If all nodes are available, it will be set to AVAILABLE. +package health + +type Status struct { + CanSendMessage string `json:"can_send_message,omitempty"` + Entities []*Entity `json:"entities,omitempty"` +} + +type Entity struct { + EntityType string `json:"entity_type,omitempty"` + ID string `json:"id,omitempty"` + CanSendMessage string `json:"can_send_message,omitempty"` + AdditionalInfo []string `json:"additional_info,omitempty"` // Optional field + Errors []Error `json:"errors,omitempty"` // Optional field +} + +type Error struct { + ErrorCode int `json:"error_code"` + ErrorDescription string `json:"error_description"` + PossibleSolution string `json:"possible_solution"` +} + +type Response struct { + HealthStatus *Status `json:"health_status,omitempty"` + ID string `json:"id,omitempty"` +} diff --git a/pkg/http/request.go b/pkg/http/request.go index 413ba12..abec9ce 100644 --- a/pkg/http/request.go +++ b/pkg/http/request.go @@ -33,7 +33,8 @@ import ( ) const ( - EndpointMessages = "messages" + EndpointMessages = "messages" + EndpointTemplates = "message_templates" ) type ( @@ -211,6 +212,10 @@ func RequestURLFmt(values *config.Values, request *RequestContext) (string, erro return fmtRequestURL(values.BaseURL, values.Version, values.PhoneNumberID, EndpointMessages) } + if request.Category == RequestCategoryTemplates { + return fmtRequestURL(values.BaseURL, values.Version, values.BusinessAccountID, EndpointTemplates) + } + elems := append([]string{values.BusinessAccountID}, request.Endpoints...) return fmtRequestURL(values.BaseURL, values.Version, elems...) @@ -236,6 +241,7 @@ const ( RequestCategoryMedia RequestCategory = "media" RequestCategoryWebhooks RequestCategory = "webhooks" RequestCategoryVerification RequestCategory = "verification" + RequestCategoryTemplates RequestCategory = "templates" ) type RequestAction string diff --git a/pkg/models/factories/interactive.go b/pkg/models/factories/interactive.go index f0a3f04..b47abfd 100644 --- a/pkg/models/factories/interactive.go +++ b/pkg/models/factories/interactive.go @@ -29,6 +29,45 @@ type CTAButtonURLParameters struct { Header string } +func LocationRequestMessage(recipient, reply, message string) *models.Message { + //{ + // "messaging_product": "whatsapp", + // "recipient_type": "individual", + // "type": "interactive", + // "to": "+15551234567", + // "interactive": { + // "type": "location_request_message", + // "body": { + // "text": "Let us start with your pickup. You can either manually *enter an address* or *share your current location*." + // }, + // "action": { + // "name": "send_location" + // } + // } + //}' + + i := &models.Interactive{ + Type: InteractiveLocationRequest, + Action: &models.InteractiveAction{ + Name: "send_location", + }, + Body: &models.InteractiveBody{Text: message}, + Footer: nil, + Header: nil, + } + + return &models.Message{ + Context: &models.Context{ + MessageID: reply, + }, + Product: MessagingProductWhatsApp, + RecipientType: RecipientTypeIndividual, + Type: "interactive", + To: recipient, + Interactive: i, + } +} + func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *models.Interactive { return &models.Interactive{ Type: InteractiveMessageCTAButton, diff --git a/pkg/models/factories/message.go b/pkg/models/factories/message.go index 21dd03d..af46f94 100644 --- a/pkg/models/factories/message.go +++ b/pkg/models/factories/message.go @@ -52,6 +52,7 @@ const ( InteractiveMessageProduct = "product" InteractiveMessageProductList = "product_list" InteractiveMessageCTAButton = "cta_url" + InteractiveLocationRequest = "location_request_message" ) const ( diff --git a/pkg/models/models.go b/pkg/models/models.go index 9a6126d..52ea3f6 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -47,7 +47,7 @@ type ( // - replying with an image, video, PTT, or audio, and the recipient is on KaiOS // These are known bugs which we are addressing. Context struct { - MessageID string `json:"message_id"` + MessageID string `json:"message_id,omitempty"` } // MediaInfo provides information about a media be it an Audio, Video, etc. diff --git a/templates/templates.go b/templates/templates.go new file mode 100644 index 0000000..f25c0c7 --- /dev/null +++ b/templates/templates.go @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Package templates provides a set of functions for creating and managing templates. +// +// LINK: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates +package templates // import "github.com/piusalfred/whatsapp/templates" + +const ( + StatusApproved Status = "APPROVED" + StatusPending Status = "PENDING" + StatusRejected Status = "REJECTED" +) + +const ( + CategoryAuthentication Category = "AUTHENTICATION" + CategoryMarketing Category = "MARKETING" + CategoryUtility Category = "UTILITY" +) + +const ( + TemplateEndpoint = "message_templates" +) + +type ( + // Status is the status of the template. There are 3 possible values: + // + // APPROVED — The template has passed template review and been approved, and can now be sent + // in template messages. + // + // PENDING — The template passed category validation and is undergoing template review. + // + // REJECTED — The template failed category validation or template review. You can request the + // rejected_reason field on the template to get the reason. + Status string + + // Category is the category of the template. Templates must be categorized as one of the following + // categories. Categories factor into pricing and the category you designate will be validated at the + // time of template creation. + // + // - AUTHENTICATION + // + // - MARKETING + // + // - UTILITY + // + // For more LINK: https://developers.facebook.com/docs/whatsapp/updates-to-pricing/new-template-guidelines + Category string + + // CreateRequest is the request body for creating a template. + // + // SUPPORTED LANGUAGES: + // https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/supported-languages + CreateRequest struct { + Name string `json:"name,omitempty"` + Language string `json:"language,omitempty"` + Category Category `json:"category,omitempty"` + AllowCategoryChange bool `json:"allow_category_change"` + Components []*Component `json:"components,omitempty"` + } + + // CreateResponse is the response body for creating a template. + CreateResponse struct { + ID string `json:"id,omitempty"` + Status Status `json:"status,omitempty"` + Category Category `json:"category,omitempty"` + } + + Component struct{} +) From ab6ac1951a399e07692e6b9d10556eb703c02888 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sun, 14 Jan 2024 14:08:33 +0100 Subject: [PATCH 07/10] update README --- README.md | 208 +++++++++++++++++++----------------------------------- 1 file changed, 72 insertions(+), 136 deletions(-) diff --git a/README.md b/README.md index 85ed8b5..3286b8f 100644 --- a/README.md +++ b/README.md @@ -23,141 +23,77 @@ first from the Whatsapp Developer Dashboard. ## Usage -1. [Messages](#1-messages) ✅ - * [1.1 Normal Messages](#11-normal-messages) 🚧 - * [1.2 Reply Messages](#12-reply-messages) 🚧 - * [1.3 Media Messages](#13-media-messages) 🚧 - * [1.4 Interactive Messages](#14-interactive-messages) 🚧 - * [1.5 Template Messages](#15-template-messages) 🚧 - + [1.5.1 Text-based Message Templates](#151-text-based-message-templates) 🚧 - + [1.5.2 Media-based Message Templates](#152-media-based-message-templates) 🚧 - + [1.5.3 Interactive Message Templates](#153-interactive-message-templates) 🚧 - + [1.5.4 Location-based Message Templates](#154-location-based-message-templates) 🚧 - + [1.5.5 Authentication Templates with OTP Buttons](#155-authentication-templates-with-otp-buttons) 🚧 - + [1.5.6 Multi-Product Message Templates](#156-multi-product-message-templates) 🚧 - -2. [Webhooks](#2-webhooks) ✅ - * [2.1 Verify Requests](#21-verify-requests) 🚧 - * [2.2 Listen To Requests](#22-listen-to-requests) 🚧 - -3. [Health Status](#3-health-status) 🚧 - -4. [Templates Management](#4-templates-management) ✅ - -5. [PhoneNumbers](#5-phonenumbers) 🚧 - * [5.1 Register](#51-register) 🚧 - * [5.2 Delete](#52-delete) 🚧 - * [5.3 Set PIN](#53-set-pin) 🚧 - -6. [QR Codes Management](#6-qr-codes-management) ✅ - -7. [Media Management](#7-media-management) ✅ - * [7.1 Upload](#71-upload) 🚧 - * [7.2 Delete](#72-delete) 🚧 - * [7.3 List](#73-list) 🚧 - * [7.4 Download](#74-download) 🚧 - * [7.5 Retrieve Information](#75-retrieve-information) 🚧 - -8. [WhatsApp Business Account](#8-whatsapp-business-account) ✅ - -9. [WhatsApp Business Encryption](#9-whatsapp-business-encryption) ✅ - * Description coming soon. - -10. [Flows](#10-flows) 🚧 - - -# 1. Messages -Description coming soon. - -## 1.1 Normal Messages -Description coming soon. - -## 1.2 Reply Messages -Description coming soon. - -## 1.3 Media Messages -Description coming soon. - -## 1.4 Interactive Messages -Description coming soon. - -## 1.5 Template Messages -Description coming soon. - -### 1.5.1 Text-based Message Templates -Description coming soon. - -### 1.5.2 Media-based Message Templates -Description coming soon. - -### 1.5.3 Interactive Message Templates -Description coming soon. - -### 1.5.4 Location-based Message Templates -Description coming soon. - -### 1.5.5 Authentication Templates with OTP Buttons -Description coming soon. - -### 1.5.6 Multi-Product Message Templates -Description coming soon. - -## 2. Webhooks -Description coming soon. - -## 2.1 Verify Requests -Description coming soon. - -## 2.2 Listen To Requests -Description coming soon. - -# 3. Health Status -Description coming soon. - -# 4. Templates Management -Description coming soon. - -# 5. PhoneNumbers -Description coming soon. - -## 5.1 Register -Description coming soon. - -## 5.2 Delete -Description coming soon. - -## 5.3 Set PIN -Description coming soon. - -# 6. QR Codes Management -Description coming soon. - -# 7. Media Management -Description coming soon. - -## 7.1 Upload -Description coming soon. - -## 7.2 Delete -Description coming soon. - -## 7.3 List -Description coming soon. - -## 7.4 Download -Description coming soon. - -## 7.5 Retrieve Information -Description coming soon. - -# 8. WhatsApp Business Account -Description coming soon. - -# 9. WhatsApp Business Encryption -Description coming soon. - -# 10. Flows -Description coming soon. - +1. [Messages](##messages) ✅ + * [1.1 Normal Messages](###11-normal-messages) 🚧 + * [1.2 Reply Messages](###12-reply-messages) 🚧 + * [1.3 Media Messages](###13-media-messages) 🚧 + * [1.4 Interactive Messages](###14-interactive-messages) 🚧 + * [1.5 Template Messages](###15-template-messages) 🚧 + + [1.5.1 Text-based Message Templates](####151-text-based-message-templates) 🚧 + + [1.5.2 Media-based Message Templates](#####152-media-based-message-templates) 🚧 + + [1.5.3 Interactive Message Templates](#####153-interactive-message-templates) 🚧 + + [1.5.4 Location-based Message Templates](####154-location-based-message-templates) 🚧 + + [1.5.5 Authentication Templates with OTP Buttons](#####155-authentication-templates-with-otp-buttons) 🚧 + + [1.5.6 Multi-Product Message Templates](#####156-multi-product-message-templates) 🚧 +2. [Webhooks](##2-webhooks) ✅ + * [2.1 Verify Requests](####21-verify-requests) 🚧 + * [2.2 Listen To Requests](####22-listen-to-requests) 🚧 +3. [Health Status](##3-health-status) 🚧 +4. [Templates Management](##4-templates-management) ✅ +5. [PhoneNumbers](##5-phonenumbers) 🚧 + * [5.1 Register](###51-register) 🚧 + * [5.2 Delete](###52-delete) 🚧 + * [5.3 Set PIN](###53-set-pin) 🚧 +6. [QR Codes Management](##6-qr-codes-management) ✅ +7. [Media Management](##7-media-management) ✅ + * [7.1 Upload](###71-upload) 🚧 + * [7.2 Delete](###72-delete) 🚧 + * [7.3 List](###73-list) 🚧 + * [7.4 Download](###74-download) 🚧 + * [7.5 Retrieve Information](###75-retrieve-information) 🚧 +8. [WhatsApp Business Account](##8-whatsapp-business-account) ✅ +9. [WhatsApp Business Encryption](##9-whatsapp-business-encryption) ✅ +10. [Flows](##10-flows) 🚧 + + + +## Messages +### 1.1 Normal Messages +### 1.2 Reply Messages +### 1.3 Media Messages +### 1.4 Interactive Messages +### 1.5 Template Messages +#### 1.5.1 Text-based Message Templates +#### 1.5.2 Media-based Message Templates +#### 1.5.3 Interactive Message Templates +#### 1.5.4 Location-based Message Templates +#### 1.5.5 Authentication Templates with OTP Buttons +#### 1.5.6 Multi-Product Message Templates + +## Webhooks +### 2.1 Verify Requests +### 2.2 Listen To Requests + +## Health Status + +## Templates Management + +## PhoneNumbers +### 5.1 Register +### 5.2 Delete +### 5.3 Set PIN + +## QR Codes Management + +## Media Management +### 7.1 Upload +### 7.2 Delete +### 7.3 List +### 7.4 Download +### 7.5 Retrieve Information + +## WhatsApp Business Account + +## WhatsApp Business Encryption From 5fbd84cfcd8373303ec292da25f541f49a2d336e Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sun, 14 Jan 2024 14:25:29 +0100 Subject: [PATCH 08/10] update README --- README.md | 21 +++++++++++++++++++-- base.go | 8 ++++---- examples/base/main.go | 9 +++++++-- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3286b8f..b521cee 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ reply to that message to be able to send other messages. - Run `make run` and wait to receive a message on your phone. Make sure you have sent the template message first from the Whatsapp Developer Dashboard. -## Usage +## usage 1. [Messages](##messages) ✅ * [1.1 Normal Messages](###11-normal-messages) 🚧 @@ -57,7 +57,24 @@ first from the Whatsapp Developer Dashboard. -## Messages +### messages +Create a `client` instance and use it to send messages. +```go + client, err := whatsapp.NewClient(ctx, reader, + whatsapp.WithBaseClientOptions( + []whttp.BaseClientOption{ + whttp.WithHTTPClient(http.DefaultClient), + whttp.WithRequestHooks(), // can access the *http.Request + whttp.WithResponseHooks(),// can access the *http.Response + whttp.WithSendMiddleware(), + }, + ), + whatsapp.WithSendMiddlewares(), + ) + if err != nil { + return nil, err + } +``` ### 1.1 Normal Messages ### 1.2 Reply Messages ### 1.3 Media Messages diff --git a/base.go b/base.go index a5a8313..3aab9c9 100644 --- a/base.go +++ b/base.go @@ -189,8 +189,8 @@ func (c *Client) Text(ctx context.Context, params *RequestParams, text *models.T return c.Send(ctx, fmtParamsToContext(params, nil), message) } -// WithBaseClientMiddleware adds a middleware to the base client. -func WithBaseClientMiddleware(mw ...SendMiddleware) ClientOption { +// WithSendMiddlewares adds a middleware to the base client. +func WithSendMiddlewares(mw ...SendMiddleware) ClientOption { return func(client *Client) { client.mw = append(client.mw, mw...) } @@ -210,8 +210,8 @@ func WithBaseClientOptions(options []whttp.BaseClientOption) ClientOption { } } -// NewBaseClient creates a new base client. -func NewBaseClient(ctx context.Context, configure config.Reader, options ...ClientOption) (*Client, error) { +// NewClient creates a new base client. +func NewClient(ctx context.Context, configure config.Reader, options ...ClientOption) (*Client, error) { inner, err := whttp.InitBaseClient(ctx, configure) if err != nil { return nil, fmt.Errorf("init base client: %w", err) diff --git a/examples/base/main.go b/examples/base/main.go index 160ce7f..3b772a0 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -55,12 +55,17 @@ func (d *dotEnvReader) Read(ctx context.Context) (*config.Values, error) { func initBaseClient(ctx context.Context) (*whatsapp.Client, error) { reader := &dotEnvReader{filePath: ".env"} - b, err := whatsapp.NewBaseClient(ctx, reader, + b, err := whatsapp.NewClient(ctx, reader, whatsapp.WithBaseClientOptions( []whttp.BaseClientOption{ whttp.WithHTTPClient(http.DefaultClient), + whttp.WithRequestHooks(), + whttp.WithResponseHooks(), + whttp.WithSendMiddleware(), }, - )) + ), + whatsapp.WithSendMiddlewares(), + ) if err != nil { return nil, err } From 0c2cd8de0d318f22376c723c15b8348b68a6fee2 Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sun, 14 Jan 2024 14:53:02 +0100 Subject: [PATCH 09/10] update README --- README.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b521cee..d5c5eed 100644 --- a/README.md +++ b/README.md @@ -58,15 +58,58 @@ first from the Whatsapp Developer Dashboard. ### messages -Create a `client` instance and use it to send messages. + +Create a `client` instance and use it to send messages. It uses `config.Reader` to read +configuration values from a source. You can implement your own `config.Reader`.The `dotEnvReader` +is an example of a `config.Reader` that reads configuration values from a `.env` file. + + ```go - client, err := whatsapp.NewClient(ctx, reader, +package main + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/joho/godotenv" + "github.com/piusalfred/whatsapp" + "github.com/piusalfred/whatsapp/pkg/config" + whttp "github.com/piusalfred/whatsapp/pkg/http" + "github.com/piusalfred/whatsapp/pkg/models" +) + +var _ config.Reader = (*dotEnvReader)(nil) + +type dotEnvReader struct { + filePath string +} + +func (d *dotEnvReader) Read(ctx context.Context) (*config.Values, error) { + vm, err := godotenv.Read(d.filePath) + if err != nil { + return nil, err + } + + return &config.Values{ + BaseURL: vm["BASE_URL"], + Version: vm["VERSION"], + AccessToken: vm["ACCESS_TOKEN"], + PhoneNumberID: vm["PHONE_NUMBER_ID"], + BusinessAccountID: vm["BUSINESS_ACCOUNT_ID"], + }, nil +} + +func initBaseClient(ctx context.Context) (*whatsapp.Client, error) { + reader := &dotEnvReader{filePath: ".env"} + b, err := whatsapp.NewClient(ctx, reader, whatsapp.WithBaseClientOptions( []whttp.BaseClientOption{ whttp.WithHTTPClient(http.DefaultClient), - whttp.WithRequestHooks(), // can access the *http.Request - whttp.WithResponseHooks(),// can access the *http.Response - whttp.WithSendMiddleware(), + whttp.WithRequestHooks(), + whttp.WithResponseHooks(), + whttp.WithSendMiddleware(), }, ), whatsapp.WithSendMiddlewares(), @@ -74,8 +117,40 @@ Create a `client` instance and use it to send messages. if err != nil { return nil, err } + + return b, nil +} + ``` ### 1.1 Normal Messages +An example to send a text message +```go +func send(ctx context.Context)error{ + client, err := initBaseClient(ctx) + response, err := client.Text(ctx, &whatsapp.RequestParams{ + ID: "", // Optional + Metadata: map[string]string{ // Optional -for stuffs like observability + "key": "value", + "context": "demo", + }, + Recipient: "+2557XXXXXXX", + ReplyID: "",// Put the message ID here if you want to reply to that message. + }, &models.Text{ + Body: "Hello World From github.com/piusalfred/whatsapp", + PreviewURL: true, + }) + if err != nil { + return err + } + + fmt.Printf("\n%+v\n", response) + + return nil +} +``` +There are other client methods that you can use to send other types of messages. +like `client.Location` for sending location messages, `client.Image` for sending image messages +and so on. ### 1.2 Reply Messages ### 1.3 Media Messages ### 1.4 Interactive Messages From 97075412584db1cb296878d099722107146f001e Mon Sep 17 00:00:00 2001 From: Pius Alfred Date: Sat, 20 Jan 2024 20:25:23 +0100 Subject: [PATCH 10/10] add flows --- base.go | 19 ++- examples/base/main.go | 28 ++++ flows/flows.go | 243 ++++++++++++++++++++++++++++ flows/flows_api.go | 55 +++++++ pkg/models/factories/interactive.go | 11 +- 5 files changed, 349 insertions(+), 7 deletions(-) create mode 100644 flows/flows.go create mode 100644 flows/flows_api.go diff --git a/base.go b/base.go index 3aab9c9..c7e9a7b 100644 --- a/base.go +++ b/base.go @@ -50,14 +50,27 @@ type ( } ) +// InteractiveCTAButtonURL sends a CTA button url request to the recipient. +func (c *Client) InteractiveCTAButtonURL(ctx context.Context, params *RequestParams, + request *factories.CTAButtonURLParameters, +) (*whttp.ResponseMessage, error) { + i := factories.NewInteractiveCTAURLButton(request) + message, err := factories.InteractiveMessage(params.Recipient, i) + if err != nil { + return nil, fmt.Errorf("interactive message: %w", err) + } + + return c.Send(ctx, fmtParamsToContext(params, nil), message) +} + // RequestLocation sends a location request to the recipient. // LINK: https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-messages/location-request-messages -func (c *Client) RequestLocation(ctx context.Context, params *LocationRequestParams) ( +func (c *Client) RequestLocation(ctx context.Context, params *RequestParams, message string) ( *whttp.ResponseMessage, error, ) { - message := factories.LocationRequestMessage(params.Recipient, params.ReplyID, params.Message) + m := factories.LocationRequestMessage(params.Recipient, params.ReplyID, message) - return c.Send(ctx, nil, message) + return c.Send(ctx, fmtParamsToContext(params, nil), m) } func (c *Client) Image(ctx context.Context, params *RequestParams, image *models.Image, diff --git a/examples/base/main.go b/examples/base/main.go index 3b772a0..83adf52 100644 --- a/examples/base/main.go +++ b/examples/base/main.go @@ -22,6 +22,7 @@ package main import ( "context" "fmt" + "github.com/piusalfred/whatsapp/pkg/models/factories" "net/http" "time" @@ -131,6 +132,33 @@ func sendTextMessage(ctx context.Context, request *textRequest) error { fmt.Printf("\n%+v\n", resp) + params := &whatsapp.RequestParams{ + ID: "1234567890", + Metadata: map[string]string{"foo": "bar"}, + Recipient: "+255767001828", + ReplyID: "", + } + + resp, err = b.RequestLocation(ctx, params, "Where are you mate?") + if err != nil { + return err + } + + fmt.Printf("\n%+v\n", resp) + + buttonURL, err := b.InteractiveCTAButtonURL(ctx, params, &factories.CTAButtonURLParameters{ + DisplayText: "link to github repo", + URL: "https://github.com/piusalfred/whatsapp", + Body: "The Golang client for the WhatsApp Business API offers a rich set of features for building interactive WhatsApp experiences.", + Footer: "You can fork,stargaze and contribute to this repo", + Header: "Hey look", + }) + if err != nil { + return err + } + + fmt.Printf("\n%+v\n", buttonURL) + return nil } diff --git a/flows/flows.go b/flows/flows.go new file mode 100644 index 0000000..e38062f --- /dev/null +++ b/flows/flows.go @@ -0,0 +1,243 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +// Package flows provides a set of functions for creating and sending FlowJSON messages. +package flows + +//{ +// "version": "3.1", +// "data_api_version": "3.0", +// "routing_model": {"MY_FIRST_SCREEN": ["MY_SECOND_SCREEN"] }, +// "screens": [...] +//} + +const ( + LowestSupportedVersion = "3.1" + LowestSupportedDataApiVersion = "3.0" +) + +type ( + Flow struct { + Version string `json:"version,omitempty"` + DataAPIVersion string `json:"data_api_version,omitempty"` + RoutingModel map[string][]string `json:"routing_model,omitempty"` + Screens []*Screen `json:"screens,omitempty"` + } + + Screen struct { + ID string `json:"id,omitempty"` + Terminal bool `json:"terminal,omitempty"` + Success bool `json:"success,omitempty"` + Title string `json:"title,omitempty"` + RefreshOnBack bool `json:"refresh_on_back,omitempty"` + Data Data `json:"data,omitempty"` + Layout *Layout `json:"layout,omitempty"` + } + + DataValue struct { + Type string `json:"type,omitempty"` + Example string `json:"__example__,omitempty"` + } + + Data map[string]DataValue + + Layout struct { + Type string `json:"type,omitempty"` + // Children a list of components that are rendered in the layout. + } + + // Components + //Text (Heading, Subheading, Caption, Body) + // + //TextEntry + // + //CheckboxGroup + // + //RadioButtonsGroup + // + //Footer + // + //OptIn + // + //Dropdown + // + //EmbeddedLink + // + //DatePicker + // + //Image + + TextComponent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + Visible bool `json:"visible,omitempty"` + FontWeight FontWeight `json:"font-weight,omitempty"` + Strikethrough bool `json:"strikethrough,omitempty"` + } + + TextInputComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + InputType string `json:"input-type,omitempty"` //enum {'text','number','email', 'password', 'passcode', 'phone'} + Required bool `json:"required,omitempty"` + MinChars int `json:"min-chars,omitempty"` + MaxChars int `json:"max-chars,omitempty"` + HelperText string `json:"helper-text,omitempty"` + Name string `json:"name,omitempty"` + Visible bool `json:"visible,omitempty"` + OnClickAction *Action `json:"on-click-action,omitempty"` + } + + TextAreaComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + MaxLength int `json:"max-length,omitempty"` + Name string `json:"name,omitempty"` + HelperText string `json:"helper-text,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Visible bool `json:"visible,omitempty"` + OnClickAction *Action `json:"on-click-action,omitempty"` + } + + CheckboxGroupComponent struct { + Type string `json:"type,omitempty"` + DataSource []*DropDownData `json:"data-source,omitempty"` + Name string `json:"name,omitempty"` + MinSelectedItems int `json:"min-selected-items,omitempty"` + MaxSelectedItems int `json:"max-selected-items,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + Visible bool `json:"visible,omitempty"` + OnSelectAction *Action `json:"on-select-action,omitempty"` + } + + RadioButtonsGroupComponent struct { + Type string `json:"type,omitempty"` + DataSource []*DropDownData `json:"data-source,omitempty"` + Name string `json:"name,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + Visible bool `json:"visible,omitempty"` + OnSelectAction *Action `json:"on-select-action,omitempty"` + } + + FooterComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + LeftCaption string `json:"left-caption,omitempty"` + CenterCaption string `json:"center-caption,omitempty"` + RightCaption string `json:"right-caption,omitempty"` + Enabled bool `json:"enabled,omitempty"` + OnClickAction *Action `json:"on-click-action,omitempty"` + } + + OptInComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + Required bool `json:"required,omitempty"` + Name string `json:"name,omitempty"` + OnClickAction *Action `json:"on-click-action,omitempty"` + Visible bool `json:"visible,omitempty"` + } + + DropdownComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + DataSource []*DropDownData `json:"data-source,omitempty"` + Required bool `json:"required,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Visible bool `json:"visible,omitempty"` + OnSelectAction *Action `json:"on-select-action,omitempty"` + } + + DropDownData struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Enabled bool `json:"enabled,omitempty"` + Description string `json:"description,omitempty"` + Metadata string `json:"metadata,omitempty"` + } + + EmbeddedLinkComponent struct { + Type string `json:"type,omitempty"` + Text string `json:"text,omitempty"` + OnClickAction *Action `json:"on-click-action,omitempty"` + Visible bool `json:"visible,omitempty"` + } + + DatePickerComponent struct { + Type string `json:"type,omitempty"` + Label string `json:"label,omitempty"` + MinDate string `json:"min-date,omitempty"` + MaxDate string `json:"max-date,omitempty"` + Name string `json:"name,omitempty"` + Unavailable []string `json:"unavailable-dates,omitempty"` + Visible bool `json:"visible,omitempty"` + HelperText string `json:"helper-text,omitempty"` + Enabled bool `json:"enabled,omitempty"` + OnSelect *Action `json:"on-select-action,omitempty"` + } + + ImageComponent struct { + Type string `json:"type,omitempty"` + Src string `json:"src,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + ScaleType string `json:"scale-type,omitempty"` + AspectRatio float64 `json:"aspect-ratio,omitempty"` + AltText string `json:"alt-text,omitempty"` + } + + Action struct { + Name string `json:"name,omitempty"` + } +) + +type ImageScaleType string + +const ( + ScaleTypeCover ImageScaleType = "cover" + ScaleTypeContain ImageScaleType = "contain" +) + +const ( + ComponentTypeFooter = "Footer" + ComponentTypeOptIn = "OptIn" +) + +type TextComponentType string + +const ( + TextComponentTypeHeading TextComponentType = "TextHeading" + TextComponentTypeSubheading TextComponentType = "TextSubheading" + TextComponentTypeBody TextComponentType = "TextBody" + TextComponentTypeCaption TextComponentType = "TextCaption" +) + +type FontWeight string + +const ( + FontWeightBold FontWeight = "bold" + FontWeightItalic FontWeight = "italic" + FontWeightBoldItalic FontWeight = "bold_italic" + FontWeightNormal FontWeight = "normal" +) diff --git a/flows/flows_api.go b/flows/flows_api.go new file mode 100644 index 0000000..7cad291 --- /dev/null +++ b/flows/flows_api.go @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Pius Alfred + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the “Software”), to deal in the Software without restriction, + * including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, + * and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all copies or substantial + * portions of the Software. + * + * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT + * LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +package flows + +type ( + // ListFlowsResponse represents the response from the list flows endpoint. + ListFlowsResponse struct { + Data []*Details `json:"data,omitempty"` + Paging *Paging `json:"paging,omitempty"` + } + + Details struct { + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + Categories []string `json:"categories,omitempty"` + ValidationErrors []*ValidationError `json:"validation_errors,omitempty"` + ID string `json:"id,omitempty"` + } + + ValidationError struct { + Error string `json:"error,omitempty"` + ErrorType string `json:"error_type,omitempty"` + Message string `json:"message,omitempty"` + LineStart int `json:"line_start,omitempty"` + LineEnd int `json:"line_end,omitempty"` + ColumnStart int `json:"column_start,omitempty"` + ColumnEnd int `json:"column_end,omitempty"` + } + + Paging struct { + Cursors *Cursors `json:"cursors,omitempty"` + } + + Cursors struct { + Before string `json:"before,omitempty"` + After string `json:"after,omitempty"` + } +) diff --git a/pkg/models/factories/interactive.go b/pkg/models/factories/interactive.go index b47abfd..0a35a9c 100644 --- a/pkg/models/factories/interactive.go +++ b/pkg/models/factories/interactive.go @@ -56,16 +56,19 @@ func LocationRequestMessage(recipient, reply, message string) *models.Message { Header: nil, } - return &models.Message{ - Context: &models.Context{ - MessageID: reply, - }, + m := &models.Message{ Product: MessagingProductWhatsApp, RecipientType: RecipientTypeIndividual, Type: "interactive", To: recipient, Interactive: i, } + + if reply != "" { + m.Context = &models.Context{MessageID: reply} + } + + return m } func NewInteractiveCTAURLButton(parameters *CTAButtonURLParameters) *models.Interactive {