diff --git a/README.md b/README.md index 6004751..4c9c787 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ This is a Go library for making bots with Smooch service. +_**Note** : This a modified version version of [EddyTravels/smooch](https://github.com/EddyTravels/smooch) library with additional features. Please refer to the original repo for the original features._ + +## Additional Feature + +- Token expiration & its checking. +- Pre-create app user and link app user to specified channel functionality. +- Send message in whatsapp HSM format. +- Renew token functionality whenever token is expired. +- Support smooch basic auth and JWT auth. +- Redis support as a centralized storage to store JWT token for supporting autoscaling environment. Use redigo as redis library. + ## Tips Smooch documentation: https://docs.smooch.io/rest/ @@ -9,24 +20,51 @@ Smooch documentation: https://docs.smooch.io/rest/ ## Installing ``` -$ go get -u github.com/EddyTravels/smooch +$ go get -u github.com/kitabisa/smooch ``` ## Example +Using basic authentication : + + +``` +import ( + "os" + + "github.com/kitabisa/smooch" +) + +func main() { + smoochClient, err := smooch.New(smooch.Options{ + Auth: smooch.AuthBasic, + AppID: os.Getenv("SMOOCH_APP_ID"), + KeyID: os.Getenv("SMOOCH_KEY_ID"), + Secret: os.Getenv("SMOOCH_SECRET"), + }) + + if err != nil { + panic(err) + } +} +``` + +Using JWT authentication : + ``` import ( "os" - "github.com/EddyTravels/smooch" + "github.com/kitabisa/smooch" ) func main() { smoochClient, err := smooch.New(smooch.Options{ + Auth: smooch.AuthJWT, AppID: os.Getenv("SMOOCH_APP_ID"), KeyID: os.Getenv("SMOOCH_KEY_ID"), Secret: os.Getenv("SMOOCH_SECRET"), - VerifySecret: os.Getenv("SMOOCH_VERIFY_SECRET"), + RedisPool: redisPool, }) if err != nil { diff --git a/error.go b/error.go index e707cfb..8b1049e 100644 --- a/error.go +++ b/error.go @@ -6,6 +6,12 @@ import ( "net/http" ) +// ResponseData defines data for every response +type ResponseData struct { + HTTPCode int + Flag string +} + type SmoochError struct { message string code int @@ -19,11 +25,11 @@ func (e *SmoochError) Error() string { return e.message } -func checkSmoochError(r *http.Response) error { +func checkSmoochError(r *http.Response) (*ResponseData, error) { var errorPayload ErrorPayload decodeErr := json.NewDecoder(r.Body).Decode(&errorPayload) if decodeErr != nil { - return decodeErr + return nil, decodeErr } err := &SmoochError{ @@ -35,5 +41,9 @@ func checkSmoochError(r *http.Response) error { code: r.StatusCode, } - return err + respData := &ResponseData{ + HTTPCode: r.StatusCode, + Flag: errorPayload.Details.Code, + } + return respData, err } diff --git a/error_test.go b/error_test.go index 121abb2..78d3549 100644 --- a/error_test.go +++ b/error_test.go @@ -27,7 +27,7 @@ func TestCheckSmoochError(t *testing.T) { Body: r, } - err := checkSmoochError(response) + _, err := checkSmoochError(response) assert.Error(t, err) assert.EqualError(t, err, "StatusCode: 401 Code: unauthorized Message: Authorization is required") } diff --git a/go.mod b/go.mod index 6cd34a4..83b9531 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,10 @@ -module github.com/EddyTravels/smooch +module github.com/kitabisa/smooch require ( github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/gomodule/redigo v2.0.0+incompatible + github.com/mitchellh/mapstructure v1.1.2 // indirect github.com/stretchr/testify v1.3.0 ) + +go 1.13 diff --git a/go.sum b/go.sum index d7b73c7..0496525 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,14 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/gomodule/redigo v2.0.0+incompatible h1:K/R+8tc58AaqLkqG2Ol3Qk+DR/TlNuhuh457pBFPtt0= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/kitabisa/smooch v0.1.0 h1:dS+ouObVdoNFVZWMIqULMr/VbQoYhsBuSUdmp0VjbcM= +github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/jwt.go b/jwt.go index f8a33a8..96241f2 100644 --- a/jwt.go +++ b/jwt.go @@ -1,12 +1,18 @@ package smooch import ( + "time" + jwt "github.com/dgrijalva/jwt-go" ) +// JWTExpiration defines how many seconds jwt token is valid +const JWTExpiration = 3600 + func GenerateJWT(scope string, keyID string, secret string) (string, error) { token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ "scope": scope, + "exp": time.Now().Unix() + JWTExpiration, }) token.Header = map[string]interface{}{ "alg": "HS256", @@ -16,3 +22,38 @@ func GenerateJWT(scope string, keyID string, secret string) (string, error) { return token.SignedString([]byte(secret)) } + +// getJWTExpiration will get jwt expiration time +func getJWTExpiration(jwtToken string, secret string) (int64, error) { + claims := jwt.MapClaims{} + + _, err := jwt.ParseWithClaims(jwtToken, &claims, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil { + return -1, err + } + + expiredIn := claims["exp"].(int64) - time.Now().Unix() + return expiredIn, nil +} + +// isJWTExpired will check whether Smooch JWT is expired or not. +func isJWTExpired(jwtToken string, secret string) (bool, error) { + _, err := jwt.ParseWithClaims(jwtToken, jwt.MapClaims{}, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + + if err == nil { + return false, nil + } + + switch err.(type) { + case *jwt.ValidationError: + vErr := err.(*jwt.ValidationError) + if vErr.Errors == jwt.ValidationErrorExpired { + return true, nil + } + } + return false, err +} diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..45426a3 --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,23 @@ +## What does this PR do? +_Place what this pull request changes and anything affected. If your PR block or require another PR, also need to mention here_ + +## Why are we doing this? Any context or related work? +_You may put your JIRA card link here_ + +## Where should a reviewer start? +_optional -- if your changes affected so much files, it is encouraged to give helper for reviewer_ + +## Screenshots +_optional -- You may put the database, sequence or any diagram needed_ + +## Manual testing steps? +_Steps to do tests. including all possible that can hape_ + +## Database changes +_optional -- If there's database changes, put it here_ + +## Config changes +_optional -- If there's config changes, put it here_ + +## Deployment instructions +_optional -- Better to put it if there's some 'special case' for deployment_ diff --git a/smooch.go b/smooch.go index f531871..6bb44ef 100644 --- a/smooch.go +++ b/smooch.go @@ -13,14 +13,27 @@ import ( "os" "path" "strings" + "sync" + + "github.com/gomodule/redigo/redis" + "github.com/kitabisa/smooch/storage" ) var ( - ErrUserIDEmpty = errors.New("user id is empty") - ErrMessageNil = errors.New("message is nil") - ErrMessageRoleEmpty = errors.New("message.Role is empty") - ErrMessageTypeEmpty = errors.New("message.Type is empty") - ErrVerifySecretEmpty = errors.New("verify secret is empty") + ErrUserIDEmpty = errors.New("user id is empty") + ErrSurnameEmpty = errors.New("surname is empty") + ErrGivenNameEmpty = errors.New("givenName is empty") + ErrPhonenumberEmpty = errors.New("phonenumber is empty") + ErrChannelTypeEmpty = errors.New("channel type is empty") + ErrConfirmationTypeEmpty = errors.New("confirmation type is empty") + ErrKeyIDEmpty = errors.New("key id is empty") + ErrSecretEmpty = errors.New("secret is empty") + ErrRedisNil = errors.New("redis pool is nil") + ErrMessageNil = errors.New("message is nil") + ErrMessageRoleEmpty = errors.New("message.Role is empty") + ErrMessageTypeEmpty = errors.New("message.Type is empty") + ErrDecodeToken = errors.New("error decode token") + ErrWrongAuth = errors.New("error wrong authentication") ) const ( @@ -37,43 +50,55 @@ const ( ) type Options struct { - AppID string - KeyID string - Secret string - VerifySecret string - WebhookURL string - Mux *http.ServeMux - Logger Logger - Region string - HttpClient *http.Client + Auth string + AppID string + KeyID string + Secret string + WebhookURL string + Mux *http.ServeMux + Logger Logger + Region string + HttpClient *http.Client + RedisPool *redis.Pool } type WebhookEventHandler func(payload *Payload) type Client interface { Handler() http.Handler + IsJWTExpired() (bool, error) + RenewToken() (string, error) AddWebhookEventHandler(handler WebhookEventHandler) Send(userID string, message *Message) (*ResponsePayload, error) - VerifyRequest(r *http.Request) bool + SendHSM(userID string, hsmMessage *HsmMessage) (*ResponsePayload, error) GetAppUser(userID string) (*AppUser, error) + PreCreateAppUser(userID, surname, givenName string) (*AppUser, error) + LinkAppUserToChannel(channelType, confirmationType, phoneNumber string) (*AppUser, error) UploadFileAttachment(filepath string, upload AttachmentUpload) (*Attachment, error) UploadAttachment(r io.Reader, upload AttachmentUpload) (*Attachment, error) } -type smoochClient struct { - mux *http.ServeMux - appID string - jwtToken string - verifySecret string - logger Logger - region string - webhookEventHandlers []WebhookEventHandler - httpClient *http.Client +type SmoochClient struct { + Mux *http.ServeMux + Auth string + AppID string + KeyID string + Secret string + Logger Logger + Region string + WebhookEventHandlers []WebhookEventHandler + HttpClient *http.Client + Mtx sync.Mutex + RedisStorage *storage.RedisStorage } -func New(o Options) (*smoochClient, error) { - if o.VerifySecret == "" { - return nil, ErrVerifySecretEmpty +func New(o Options) (*SmoochClient, error) { + if o.KeyID == "" { + return nil, ErrKeyIDEmpty + } + + if o.Secret == "" { + return nil, ErrSecretEmpty } if o.Mux == nil { @@ -101,111 +126,271 @@ func New(o Options) (*smoochClient, error) { region = RegionEU } - jwtToken, err := GenerateJWT("app", o.KeyID, o.Secret) - if err != nil { - return nil, err + if o.Auth != AuthBasic && o.Auth != AuthJWT { + return nil, ErrWrongAuth } - sc := &smoochClient{ - mux: o.Mux, - appID: o.AppID, - verifySecret: o.VerifySecret, - logger: o.Logger, - region: region, - httpClient: o.HttpClient, - jwtToken: jwtToken, + sc := &SmoochClient{ + Auth: o.Auth, + Mux: o.Mux, + AppID: o.AppID, + KeyID: o.KeyID, + Secret: o.Secret, + Logger: o.Logger, + Region: region, + HttpClient: o.HttpClient, } - sc.mux.HandleFunc(o.WebhookURL, sc.handle) + if sc.Auth == AuthJWT { + if o.RedisPool == nil { + return nil, ErrRedisNil + } + + sc.RedisStorage = storage.NewRedisStorage(o.RedisPool) + + _, err := sc.RedisStorage.GetTokenFromRedis() + if err != nil { + _, err := sc.RenewToken() + if err != nil { + return nil, err + } + } + } + + sc.Mux.HandleFunc(o.WebhookURL, sc.handle) return sc, nil } -func (sc *smoochClient) Handler() http.Handler { - return sc.mux +func (sc *SmoochClient) Handler() http.Handler { + return sc.Mux } -func (sc *smoochClient) AddWebhookEventHandler(handler WebhookEventHandler) { - sc.webhookEventHandlers = append(sc.webhookEventHandlers, handler) +// IsJWTExpired will check whether Smooch JWT is expired or not. +func (sc *SmoochClient) IsJWTExpired() (bool, error) { + jwtToken, err := sc.RedisStorage.GetTokenFromRedis() + if err != nil { + if err == redis.ErrNil { + return true, nil + } + return false, err + } + return isJWTExpired(jwtToken, sc.Secret) } -func (sc *smoochClient) Send(userID string, message *Message) (*ResponsePayload, error) { +// RenewToken will generate new Smooch JWT token. +func (sc *SmoochClient) RenewToken() (string, error) { + sc.Mtx.Lock() + defer sc.Mtx.Unlock() + + jwtToken, err := GenerateJWT("app", sc.KeyID, sc.Secret) + if err != nil { + return "", err + } + + err = sc.RedisStorage.SaveTokenToRedis(jwtToken, JWTExpiration) + if err != nil { + return "", err + } + + return jwtToken, nil +} + +func (sc *SmoochClient) AddWebhookEventHandler(handler WebhookEventHandler) { + sc.WebhookEventHandlers = append(sc.WebhookEventHandlers, handler) +} + +func (sc *SmoochClient) Send(userID string, message *Message) (*ResponsePayload, *ResponseData, error) { if userID == "" { - return nil, ErrUserIDEmpty + return nil, nil, ErrUserIDEmpty } if message == nil { - return nil, ErrMessageNil + return nil, nil, ErrMessageNil } if message.Role == "" { - return nil, ErrMessageRoleEmpty + return nil, nil, ErrMessageRoleEmpty } if message.Type == "" { - return nil, ErrMessageTypeEmpty + return nil, nil, ErrMessageTypeEmpty } url := sc.getURL( - fmt.Sprintf("/v1.1/apps/%s/appusers/%s/messages", sc.appID, userID), + fmt.Sprintf("/v1.1/apps/%s/appusers/%s/messages", sc.AppID, userID), nil, ) buf := new(bytes.Buffer) err := json.NewEncoder(buf).Encode(message) if err != nil { - return nil, err + return nil, nil, err } req, err := sc.createRequest(http.MethodPost, url, buf, nil) if err != nil { - return nil, err + return nil, nil, err } var responsePayload ResponsePayload - err = sc.sendRequest(req, &responsePayload) + respData, err := sc.sendRequest(req, &responsePayload) if err != nil { - return nil, err + return nil, respData, err } - return &responsePayload, nil + return &responsePayload, respData, nil } -func (sc *smoochClient) VerifyRequest(r *http.Request) bool { - givenSecret := r.Header.Get("X-Api-Key") - return sc.verifySecret == givenSecret +// SendHSM will send message using Whatsapp HSM template +func (sc *SmoochClient) SendHSM(userID string, hsmMessage *HsmMessage) (*ResponsePayload, *ResponseData, error) { + url := sc.getURL( + fmt.Sprintf("/v1.1/apps/%s/appusers/%s/messages", sc.AppID, userID), + nil, + ) + + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(hsmMessage) + if err != nil { + return nil, nil, err + } + + req, err := sc.createRequest(http.MethodPost, url, buf, nil) + if err != nil { + return nil, nil, err + } + + var responsePayload ResponsePayload + respData, err := sc.sendRequest(req, &responsePayload) + if err != nil { + return nil, respData, err + } + + return &responsePayload, respData, nil } -func (sc *smoochClient) GetAppUser(userID string) (*AppUser, error) { +func (sc *SmoochClient) GetAppUser(userID string) (*AppUser, *ResponseData, error) { url := sc.getURL( - fmt.Sprintf("/v1.1/apps/%s/appusers/%s", sc.appID, userID), + fmt.Sprintf("/v1.1/apps/%s/appusers/%s", sc.AppID, userID), nil, ) req, err := sc.createRequest(http.MethodGet, url, nil, nil) if err != nil { - return nil, err + return nil, nil, err } var response GetAppUserResponse - err = sc.sendRequest(req, &response) + respData, err := sc.sendRequest(req, &response) if err != nil { - return nil, err + return nil, respData, err + } + + return response.AppUser, respData, nil +} + +// PreCreateAppUser will register user to smooch +func (sc *SmoochClient) PreCreateAppUser(userID, surname, givenName string) (*AppUser, *ResponseData, error) { + url := sc.getURL( + fmt.Sprintf("/v1.1/apps/%s/appusers", sc.AppID), + nil, + ) + + if userID == "" { + return nil, nil, ErrUserIDEmpty + } + + if givenName == "" { + return nil, nil, ErrGivenNameEmpty + } + + payload := PreCreateAppUserPayload{ + UserID: userID, + Surname: surname, + GivenName: givenName, + } + + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, nil, err } - return response.AppUser, nil + req, err := sc.createRequest(http.MethodPost, url, buf, nil) + if err != nil { + return nil, nil, err + } + + var response PreCreateAppUserResponse + respData, err := sc.sendRequest(req, &response) + if err != nil { + return nil, respData, err + } + + return response.AppUser, respData, nil +} + +// LinkAppUserToChannel will link user to specifiied channel +func (sc *SmoochClient) LinkAppUserToChannel(userID, channelType, confirmationType, phoneNumber string) (*AppUser, *ResponseData, error) { + url := sc.getURL( + fmt.Sprintf("/v1.1/apps/%s/appusers/%s/channels", sc.AppID, userID), + nil, + ) + + if userID == "" { + return nil, nil, ErrUserIDEmpty + } + + if channelType == "" { + return nil, nil, ErrChannelTypeEmpty + } + + if confirmationType == "" { + return nil, nil, ErrConfirmationTypeEmpty + } + + if phoneNumber == "" { + return nil, nil, ErrPhonenumberEmpty + } + + payload := LinkAppUserToChannelPayload{ + Type: channelType, + Confirmation: LinkAppConfirmationData{ + Type: confirmationType, + }, + PhoneNumber: phoneNumber, + } + + buf := new(bytes.Buffer) + err := json.NewEncoder(buf).Encode(payload) + if err != nil { + return nil, nil, err + } + + req, err := sc.createRequest(http.MethodPost, url, buf, nil) + if err != nil { + return nil, nil, err + } + + var response LinkAppUserToChannelResponse + respData, err := sc.sendRequest(req, &response) + if err != nil { + return nil, respData, err + } + + return response.AppUser, respData, nil } -func (sc *smoochClient) UploadFileAttachment(filepath string, upload AttachmentUpload) (*Attachment, error) { +func (sc *SmoochClient) UploadFileAttachment(filepath string, upload AttachmentUpload) (*Attachment, *ResponseData, error) { r, err := os.Open(filepath) if err != nil { - return nil, err + return nil, nil, err } defer r.Close() return sc.UploadAttachment(r, upload) } -func (sc *smoochClient) UploadAttachment(r io.Reader, upload AttachmentUpload) (*Attachment, error) { +func (sc *SmoochClient) UploadAttachment(r io.Reader, upload AttachmentUpload) (*Attachment, *ResponseData, error) { queryParams := url.Values{ "access": []string{upload.Access}, @@ -221,7 +406,7 @@ func (sc *smoochClient) UploadAttachment(r io.Reader, upload AttachmentUpload) ( } url := sc.getURL( - fmt.Sprintf("/v1.1/apps/%s/attachments", sc.appID), + fmt.Sprintf("/v1.1/apps/%s/attachments", sc.AppID), queryParams, ) @@ -232,46 +417,46 @@ func (sc *smoochClient) UploadAttachment(r io.Reader, upload AttachmentUpload) ( req, err := sc.createMultipartRequest(url, formData) if err != nil { - return nil, err + return nil, nil, err } var response Attachment - err = sc.sendRequest(req, &response) + respData, err := sc.sendRequest(req, &response) if err != nil { - return nil, err + return nil, respData, err } - return &response, nil + return &response, respData, nil } -func (sc *smoochClient) DeleteAttachment(attachment *Attachment) error { +func (sc *SmoochClient) DeleteAttachment(attachment *Attachment) (*ResponseData, error) { url := sc.getURL( - fmt.Sprintf("/v1.1/apps/%s/attachments", sc.appID), + fmt.Sprintf("/v1.1/apps/%s/attachments", sc.AppID), nil, ) buf := new(bytes.Buffer) err := json.NewEncoder(buf).Encode(attachment) if err != nil { - return err + return nil, err } req, err := sc.createRequest(http.MethodPost, url, buf, nil) if err != nil { - return err + return nil, err } - err = sc.sendRequest(req, nil) + respData, err := sc.sendRequest(req, nil) if err != nil { - return err + return respData, err } - return nil + return respData, nil } -func (sc *smoochClient) handle(w http.ResponseWriter, r *http.Request) { +func (sc *SmoochClient) handle(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost || !sc.VerifyRequest(r) { + if r.Method != http.MethodPost { w.WriteHeader(http.StatusBadRequest) return } @@ -279,7 +464,7 @@ func (sc *smoochClient) handle(w http.ResponseWriter, r *http.Request) { body, err := ioutil.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusInternalServerError) - sc.logger.Errorw("request body read failed", "err", err) + sc.Logger.Errorw("request body read failed", "err", err) return } @@ -287,7 +472,7 @@ func (sc *smoochClient) handle(w http.ResponseWriter, r *http.Request) { err = json.Unmarshal(body, &payload) if err != nil { w.WriteHeader(http.StatusUnprocessableEntity) - sc.logger.Errorw("could not decode response", "err", err) + sc.Logger.Errorw("could not decode response", "err", err) return } @@ -296,15 +481,15 @@ func (sc *smoochClient) handle(w http.ResponseWriter, r *http.Request) { sc.dispatch(&payload) } -func (sc *smoochClient) dispatch(p *Payload) { - for _, handler := range sc.webhookEventHandlers { +func (sc *SmoochClient) dispatch(p *Payload) { + for _, handler := range sc.WebhookEventHandlers { handler(p) } } -func (sc *smoochClient) getURL(endpoint string, values url.Values) string { +func (sc *SmoochClient) getURL(endpoint string, values url.Values) string { rootURL := usRootURL - if sc.region == RegionEU { + if sc.Region == RegionEU { rootURL = euRootURL } @@ -320,12 +505,16 @@ func (sc *smoochClient) getURL(endpoint string, values url.Values) string { return u.String() } -func (sc *smoochClient) createRequest( +func (sc *SmoochClient) createRequest( method string, url string, buf *bytes.Buffer, header http.Header) (*http.Request, error) { + var req *http.Request + var err error + var jwtToken string + if header == nil { header = http.Header{} } @@ -333,24 +522,47 @@ func (sc *smoochClient) createRequest( if header.Get(contentTypeHeaderKey) == "" { header.Set(contentTypeHeaderKey, contentTypeJSON) } - header.Set(authorizationHeaderKey, fmt.Sprintf("Bearer %s", sc.jwtToken)) - var req *http.Request - var err error + if sc.Auth == AuthJWT { + isExpired, err := sc.IsJWTExpired() + if err != nil { + return nil, err + } + + if isExpired { + jwtToken, err = sc.RenewToken() + if err != nil { + return nil, err + } + } else { + jwtToken, err = sc.RedisStorage.GetTokenFromRedis() + if err != nil { + return nil, err + } + } + + header.Set(authorizationHeaderKey, fmt.Sprintf("Bearer %s", jwtToken)) + } + if buf == nil { req, err = http.NewRequest(method, url, nil) } else { req, err = http.NewRequest(method, url, buf) } + if err != nil { return nil, err } req.Header = header + if sc.Auth == AuthBasic { + req.SetBasicAuth(sc.KeyID, sc.Secret) + } + return req, nil } -func (sc *smoochClient) createMultipartRequest( +func (sc *SmoochClient) createMultipartRequest( url string, values map[string]io.Reader) (*http.Request, error) { buf := new(bytes.Buffer) @@ -394,10 +606,10 @@ func (sc *smoochClient) createMultipartRequest( return req, nil } -func (sc *smoochClient) sendRequest(req *http.Request, v interface{}) error { - response, err := sc.httpClient.Do(req) +func (sc *SmoochClient) sendRequest(req *http.Request, v interface{}) (*ResponseData, error) { + response, err := sc.HttpClient.Do(req) if err != nil { - return err + return nil, err } defer response.Body.Close() @@ -405,10 +617,14 @@ func (sc *smoochClient) sendRequest(req *http.Request, v interface{}) error { if v != nil { err := json.NewDecoder(response.Body).Decode(&v) if err != nil { - return err + return nil, err } } - return nil + + respData := &ResponseData{ + HTTPCode: response.StatusCode, + } + return respData, nil } return checkSmoochError(response) } diff --git a/smooch_test.go b/smooch_test.go index 6756cfa..d490884 100644 --- a/smooch_test.go +++ b/smooch_test.go @@ -221,23 +221,22 @@ func TestSendOKResponse(t *testing.T) { } sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - HttpClient: NewTestClient(fn), + HttpClient: NewTestClient(fn), }) assert.NoError(t, err) message := &Message{} - response, err := sc.Send("", message) + response, _, err := sc.Send("", message) assert.Nil(t, response) assert.Error(t, err) assert.EqualError(t, err, ErrUserIDEmpty.Error()) - response, err = sc.Send("TestUser", nil) + response, _, err = sc.Send("TestUser", nil) assert.Nil(t, response) assert.Error(t, err) assert.EqualError(t, err, ErrMessageNil.Error()) - response, err = sc.Send("TestUser", message) + response, _, err = sc.Send("TestUser", message) assert.Nil(t, response) assert.Error(t, err) assert.EqualError(t, err, ErrMessageRoleEmpty.Error()) @@ -245,7 +244,7 @@ func TestSendOKResponse(t *testing.T) { message = &Message{ Role: RoleAppUser, } - response, err = sc.Send("TestUser", message) + response, _, err = sc.Send("TestUser", message) assert.Nil(t, response) assert.Error(t, err) assert.EqualError(t, err, ErrMessageTypeEmpty.Error()) @@ -254,7 +253,7 @@ func TestSendOKResponse(t *testing.T) { Role: RoleAppUser, Type: MessageTypeText, } - response, err = sc.Send("TestUser", message) + response, _, err = sc.Send("TestUser", message) assert.NotNil(t, response) assert.NoError(t, err) @@ -288,8 +287,7 @@ func TestSendErrorResponse(t *testing.T) { } sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - HttpClient: NewTestClient(fn), + HttpClient: NewTestClient(fn), }) assert.NoError(t, err) @@ -297,7 +295,7 @@ func TestSendErrorResponse(t *testing.T) { Role: RoleAppUser, Type: MessageTypeText, } - response, err := sc.Send("TestUser", message) + response, _, err := sc.Send("TestUser", message) assert.Nil(t, response) assert.Error(t, err) @@ -310,9 +308,7 @@ func TestSendErrorResponse(t *testing.T) { } func TestHandlerOK(t *testing.T) { - sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - }) + sc, err := New(Options{}) assert.NoError(t, err) handlerInvokeCounter := 0 @@ -342,43 +338,6 @@ func TestHandlerOK(t *testing.T) { assert.Equal(t, 2, handlerInvokeCounter) } -func TestVerifyRequest(t *testing.T) { - sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - }) - r := &http.Request{} - assert.NoError(t, err) - assert.False(t, sc.VerifyRequest(r)) - - r = &http.Request{ - Header: http.Header{}, - } - r.Header.Set("X-Api-Key", "very-secure-test-secret") - assert.NoError(t, err) - assert.True(t, sc.VerifyRequest(r)) - - sc, err = New(Options{ - VerifySecret: "very-secure-test-secret", - }) - assert.NoError(t, err) - - headers := http.Header{} - headers.Set("X-Api-Key", "very-secure-test-secret-wrong") - r = &http.Request{ - Header: headers, - } - assert.NoError(t, err) - assert.False(t, sc.VerifyRequest(r)) - - headers = http.Header{} - headers.Set("X-Api-Key", "very-secure-test-secret") - r = &http.Request{ - Header: headers, - } - assert.NoError(t, err) - assert.True(t, sc.VerifyRequest(r)) -} - func TestGetAppUser(t *testing.T) { fn := func(req *http.Request) *http.Response { @@ -393,12 +352,11 @@ func TestGetAppUser(t *testing.T) { } sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - HttpClient: NewTestClient(fn), + HttpClient: NewTestClient(fn), }) assert.NoError(t, err) - appUser, err := sc.GetAppUser("123") + appUser, _, err := sc.GetAppUser("123") assert.NotNil(t, appUser) assert.NoError(t, err) @@ -448,12 +406,11 @@ func TestUploadAttachment(t *testing.T) { } sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - HttpClient: NewTestClient(fn), + HttpClient: NewTestClient(fn), }) assert.NoError(t, err) - r, err := sc.UploadFileAttachment("fixtures/smooch.png", NewAttachmentUpload("image/png")) + r, _, err := sc.UploadFileAttachment("fixtures/smooch.png", NewAttachmentUpload("image/png")) assert.NotNil(t, r) assert.NoError(t, err) @@ -463,7 +420,7 @@ func TestUploadAttachment(t *testing.T) { r.MediaURL, ) - r, err = sc.UploadFileAttachment("fixtures/smooch-not-exists.png", NewAttachmentUpload("image/png")) + r, _, err = sc.UploadFileAttachment("fixtures/smooch-not-exists.png", NewAttachmentUpload("image/png")) assert.Nil(t, r) assert.Error(t, err) } @@ -483,12 +440,11 @@ func TestDeleteAttachment(t *testing.T) { } sc, err := New(Options{ - VerifySecret: "very-secure-test-secret", - HttpClient: NewTestClient(fn), + HttpClient: NewTestClient(fn), }) assert.NoError(t, err) - err = sc.DeleteAttachment(&Attachment{ + _, err = sc.DeleteAttachment(&Attachment{ MediaURL: "https://media.smooch.io/conversation/c7f6e6d6c3a637261bd9656f/a77caae4cbbd263a0938eba00016b7c8/test.png", MediaType: "", }) diff --git a/storage/redis.go b/storage/redis.go new file mode 100644 index 0000000..da65172 --- /dev/null +++ b/storage/redis.go @@ -0,0 +1,40 @@ +package storage + +import ( + "github.com/gomodule/redigo/redis" +) + +// RedisStorage defines struct property for redis storage +type RedisStorage struct { + pool *redis.Pool + jwtKey string +} + +// NewRedisStorage initializes new instance of redis storage +func NewRedisStorage(p *redis.Pool) *RedisStorage { + return &RedisStorage{ + pool: p, + jwtKey: "smooch-jwt-token", + } +} + +// SaveTokenToRedis will save jwt token to redis +func (rs *RedisStorage) SaveTokenToRedis(token string, ttl int64) error { + conn := rs.pool.Get() + defer conn.Close() + + _, err := conn.Do("SETEX", rs.jwtKey, ttl, token) + return err +} + +// GetTokenFromRedis will retrieve jwt token from redis +func (rs *RedisStorage) GetTokenFromRedis() (string, error) { + conn := rs.pool.Get() + defer conn.Close() + + val, err := redis.String(conn.Do("GET", rs.jwtKey)) + if err != nil { + return "", err + } + return val, nil +} diff --git a/types.go b/types.go index a60df4b..da49ad7 100644 --- a/types.go +++ b/types.go @@ -17,6 +17,7 @@ const ( MessageTypeLocation = MessageType("location") MessageTypeCarousel = MessageType("carousel") MessageTypeList = MessageType("list") + MessageTypeHSM = MessageType("hsm") ActionTypePostback = ActionType("postback") ActionTypeReply = ActionType("reply") @@ -26,6 +27,9 @@ const ( ActionTypeLink = ActionType("link") ActionTypeWebview = ActionType("webview") + AuthBasic = "basic_auth" + AuthJWT = "jwt" + SourceTypeWeb = "web" SourceTypeIOS = "ios" SourceTypeAndroid = "android" @@ -47,6 +51,10 @@ const ( TriggerMessageDeliveryChannel = "message:delivery:channel" TriggerMessageDeliveryUser = "message:delivery:user" + ConfirmationTypeImmediate = "immediate" + ConfirmationTypeUserActivity = "userActivity" + ConfirmationTypePrompt = "prompt" + ImageRatioHorizontal = ImageRatio("horizontal") ImageRatioSquare = ImageRatio("square") @@ -107,6 +115,7 @@ type AppUser struct { Email string `json:"email,omitempty"` GivenName string `json:"givenName,omitempty"` Surname string `json:"surname,omitempty"` + HasPaymentInfo bool `json:"hasPaymentInfo,omitmepty"` } type AppUserClient struct { @@ -199,6 +208,70 @@ func (m *Message) MarshalJSON() ([]byte, error) { return json.Marshal(aux) } +// HsmLanguage defines hsm language payload +type HsmLanguage struct { + Policy string `json:"policy" mapstructure:"policy"` + Code string `json:"code" mapstructure:"code"` +} + +// HsmLocalizableParams defines hsm localizable params data +type HsmLocalizableParams struct { + Default interface{} `json:"default" mapstructure:"default"` +} + +// HsmPayload defines payload for hsm +type HsmPayload struct { + Namespace string `json:"namespace" mapstructure:"namespace"` + ElementName string `json:"element_name" mapstructure:"element_name"` + Language HsmLanguage `json:"language" mapstructure:"language"` + LocalizableParams []HsmLocalizableParams `json:"localizable_params" mapstructure:"localizable_params"` +} + +// HsmMessageBody defines property for HSM message +type HsmMessageBody struct { + Type MessageType `json:"type" mapstructure:"type"` + Hsm HsmPayload `json:"hsm" mapstructure:"hsm"` +} + +// HsmMessage defines struct payload for Whatsapp HSM message +type HsmMessage struct { + Role Role `json:"role"` + MessageSchema string `json:"messageSchema"` + Message HsmMessageBody `json:"message"` + Received time.Time `json:"received,omitempty"` +} + +// UnmarshalJSON will unmarshall whatsapp HSM message +func (hm *HsmMessage) UnmarshalJSON(data []byte) error { + type Alias HsmMessage + aux := &struct { + Received float64 `json:"received"` + *Alias + }{ + Alias: (*Alias)(hm), + } + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + seconds := int64(aux.Received) + ns := (int64(aux.Received*1000) - seconds*1000) * nsMultiplier + hm.Received = time.Unix(seconds, ns) + return nil +} + +// MarshalJSON will marshall whatsapp HSM message +func (hm *HsmMessage) MarshalJSON() ([]byte, error) { + type Alias HsmMessage + aux := &struct { + Received float64 `json:"received"` + *Alias + }{ + Alias: (*Alias)(hm), + } + aux.Received = float64(hm.Received.UnixNano()) / nsMultiplier + return json.Marshal(aux) +} + type MenuPayload struct { Menu Menu `json:"menu"` } @@ -239,6 +312,35 @@ type GetAppUserResponse struct { AppUser *AppUser `json:"appUser,omitempty"` } +// PreCreateAppUserPayload defines payload for pre-create app user request +type PreCreateAppUserPayload struct { + UserID string `json:"userId"` + Surname string `json:"surname"` + GivenName string `json:"givenName"` +} + +// PreCreateAppUserResponse defines response for pre-create use request +type PreCreateAppUserResponse struct { + AppUser *AppUser `json:"appUser,omitempty"` +} + +// LinkAppConfirmationData defines +type LinkAppConfirmationData struct { + Type string `json:"type"` +} + +// LinkAppUserToChannelPayload will link app user to specified channel +type LinkAppUserToChannelPayload struct { + Type string `json:"type"` + Confirmation LinkAppConfirmationData `json:"confirmation"` + PhoneNumber string `json:"phoneNumber"` +} + +// LinkAppUserToChannelResponse defines reponse for link app user to channel request +type LinkAppUserToChannelResponse struct { + AppUser *AppUser `json:"appUser,omitempty"` +} + type AttachmentUpload struct { MIMEType string Access string