From 4b088f5617a9f580d960863c8e09a5a190befea9 Mon Sep 17 00:00:00 2001 From: surdeus Date: Fri, 19 Jan 2024 08:14:23 +0300 Subject: [PATCH] feat: implemented the way to acquire events in a Go structured way. --- .gitignore | 1 + amocrm.go | 42 +++++++++-- api/api.go | 168 ++++++++++++++++++++++++++++++++++------- api/errors.go | 9 +++ cmd/test/main.go | 63 ++++++++++------ events/custom-field.go | 25 ++++++ events/event.go | 43 +++++++++++ events/main.go | 61 +++++++++++++++ go.mod | 2 +- go.sum | 2 + webhooks/handler.go | 2 + 11 files changed, 361 insertions(+), 57 deletions(-) create mode 100644 api/errors.go create mode 100644 events/custom-field.go create mode 100644 events/event.go create mode 100644 events/main.go create mode 100644 webhooks/handler.go diff --git a/.gitignore b/.gitignore index b04b0f3..41e5e54 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.exe *.exe~ .env +secret.json diff --git a/amocrm.go b/amocrm.go index 35f716b..82c8556 100644 --- a/amocrm.go +++ b/amocrm.go @@ -7,6 +7,7 @@ import ( "vultras.su/core/amo/contacts" "vultras.su/core/amo/leads" "vultras.su/core/amo/users" + "vultras.su/core/amo/events" ) type IAmoClient interface { @@ -28,15 +29,15 @@ type Client struct { type OauthTokenResponse = api.OauthTokenResponse -func NewAmoClient(options *Options ) (*Client, *TokenPair, error) { - apiClient, pair, err := api.NewClient(options) +func NewAmoClient(secretPath string) (*Client, error) { + apiClient, err := api.NewApi(secretPath) if err != nil { - return nil, nil, err + return nil, err } return &Client{ Api: apiClient, - }, pair, nil + }, nil } func (client *Client) updateEntity(url string, id int, body interface{}) error { @@ -68,7 +69,7 @@ func (client *Client) UpdateLead(lead *leads.Lead) error { func (client *Client) GetCompany(companyId string, query string) (*companies.Company, error) { deal := new(companies.Company) resource := fmt.Sprintf("/api/v4/companies/%s", companyId) - if len(query) != 0 { + if query != "" { resource = resource + "?" + query } @@ -76,6 +77,37 @@ func (client *Client) GetCompany(companyId string, query string) (*companies.Com return deal, err } +// Returns the events from AmoCRM by specified request. +// If there are no such events returns an empty slice of events. +func (client *Client) GetEvents(req events.EventsRequest) ([]events.Event, error) { + res := "/api/v4/events" + format := req.Format() + if format != "" { + res += "?" + format + } + + var abs bool + ret := []events.Event{} + for { + resp := events.EventsResponse{} + err := client.Api.Get(res, &resp, abs) + if err != nil { + if err == api.NoContentErr { + return ret, nil + } + return nil, err + } + ret = append(ret, resp.Embedded.Events...) + if resp.Links.Next.Href == "" { + break + } + abs = true + res = resp.Links.Next.Href + } + + return ret, nil +} + func (client *Client) UpdateCompany(company *companies.Company) error { return client.updateEntity("/api/v4/companies", company.Id, company) } diff --git a/api/api.go b/api/api.go index ced96a5..1dcad28 100644 --- a/api/api.go +++ b/api/api.go @@ -8,6 +8,9 @@ import ( "io/ioutil" "net/http" "net/url" + "io" + "os" + "time" ) const ( @@ -17,27 +20,31 @@ const ( ) type ClientOptions struct { - Url string - RedirectUrl string + Url string `json:"url"` + RedirectUrl string `json:"redirect_url"` - AuthCode string + AuthCode string `json:"auth_code"` - ClientId string - ClientSecret string + ClientId string `json:"client_id"` + ClientSecret string `json:"client_secret"` - AccessToken string - RefreshToken string + AccessToken string `json:"access_token"` + ExpirationDate time.Time `json:"access"` + RefreshToken string `json:"refresh_token"` } type Client struct { options *ClientOptions BaseUrl *url.URL + secretStoreFilePath string + Debug bool `json` } type requestOptions struct { HttpMethod string Body interface{} Headers map[string]string + Abs bool } type OauthTokenResponse struct { @@ -51,42 +58,114 @@ type TokenPair struct { Access, Refresh string } -func NewClient(options *ClientOptions) (*Client, *TokenPair, error) { - if options.Url == "" { - return nil, nil, errors.New("AmoCrm: Invalid options: Url") +func NewApi(secretPath string) (*Client, error) { + ret := &Client{ + secretStoreFilePath: secretPath, + } + options, err := ret.readSecret() + if err != nil { + return nil, err + } + + if options.Url == "" || options.RedirectUrl == "" { + return nil, errors.New("AmoCrm: Invalid options: Url") } resolvedUrl, err := url.Parse(options.Url) if err != nil { - return nil, nil, err + return nil, err } - ret := &Client{ - options: options, - } ret.BaseUrl = resolvedUrl - var pair *TokenPair - if options.AccessToken == "" || options.RefreshToken == "" { - pair, err = ret.ExchangeAuth() - if err != nil { - return nil, nil, err + if err != nil { + return nil, err + } + ret.options = options + + var ( + //pair *TokenPair + exchangeErr error + exchanged bool + ) + if ret.options.AccessToken == "" || ret.options.RefreshToken == "" { + if ret.options.ClientSecret == "" || + ret.options.ClientId == "" || + ret.options.AuthCode == "" { + return nil, errors.New("AmoCrm: invalid options: ExchangeAuth") } + _, exchangeErr = ret.ExchangeAuth() + exchanged = true } + if !exchanged || exchangeErr != nil { + // Refreshing token before the work. + // Should think of how often should refresh + // the token. (see the ExpiresIn) + _, err = ret.RefreshToken() + if err != nil { + return nil, err + } + } return &Client{ options: options, BaseUrl: resolvedUrl, - }, pair, nil + }, nil } -func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, result interface{}) error { - resolvedUrl, err := url.Parse(resourceUrl) +func (api *Client) readSecret() (*ClientOptions, error) { + f, err := os.Open(api.secretStoreFilePath) + if err != nil { + return nil, err + } + + bts, err := io.ReadAll(f) + if err != nil { + return nil, err + } + + + ret := ClientOptions{} + err = json.Unmarshal(bts, &ret) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (api *Client) writeSecret() error { + bts, err := json.MarshalIndent(api.options, "", "\t") if err != nil { return err } - requestUrl := api.BaseUrl.ResolveReference(resolvedUrl) + err = os.WriteFile(api.secretStoreFilePath, bts, 0644) + if err != nil { + return err + } + return err +} + +func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, result interface{}) error { + var ( + requestUrl *url.URL + err error + ) + + if !requestParams.Abs { + resolvedUrl, err := url.Parse(resourceUrl) + if err != nil { + return err + } + + requestUrl = api.BaseUrl.ResolveReference(resolvedUrl) + } else { + requestUrl, err = url.Parse(resourceUrl) + if err != nil { + return err + } + } requestBody := new(bytes.Buffer) if requestParams.Body != nil { @@ -108,6 +187,9 @@ func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, r } response, err := http.DefaultClient.Do(request) + if api.Debug { + fmt.Printf("\nAmo request: %+v\nAmo repsonse: %+v\n", request, response) + } if err != nil { return errors.New(fmt.Sprintf( "Request error: %s %d %s %s", @@ -118,8 +200,23 @@ func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, r )) } defer response.Body.Close() + if response.StatusCode == 204 { + return NoContentErr + } if response.StatusCode >= 400 { + /*if response.StatusCode == 401 { + _, err := api.RefreshToken() + if err != nil { + return err + } + return api.doRequest( + resourceUrl, + requestParams, + result, + ) + }*/ + bodyBytes, _ := ioutil.ReadAll(response.Body) return errors.New(fmt.Sprintf( "%s %d %s %s", @@ -168,6 +265,10 @@ func (api *Client) ExchangeAuth() (*TokenPair, error) { api.options.AccessToken = result.AccessToken api.options.RefreshToken = result.RefreshToken + err = api.writeSecret() + if err != nil { + return nil, err + } return ret, nil } @@ -178,7 +279,7 @@ func (api *Client) RefreshToken() (*OauthTokenResponse, error) { "client_secret": api.options.ClientSecret, "grant_type": "refresh_token", "refresh_token": api.options.RefreshToken, - "redirect_uri": api.options.Url, + "redirect_uri": api.options.RedirectUrl, } err := api.doRequest("/oauth2/access_token", requestOptions{ @@ -186,15 +287,30 @@ func (api *Client) RefreshToken() (*OauthTokenResponse, error) { Body: request, Headers: getHeaders(""), }, result) + if err != nil { + return nil, err + } - return result, err + api.options.AccessToken = result.AccessToken + api.options.RefreshToken = result.RefreshToken + err = api.writeSecret() + if err != nil { + return nil, err + } + + return result, nil } -func (api *Client) Get(resource string, result interface{}) error { +func (api *Client) Get(resource string, result interface{}, abs ...bool) error { + var a bool + if len(abs) > 0 { + a = abs[0] + } return api.doRequest(resource, requestOptions{ HttpMethod: http.MethodGet, Body: nil, Headers: getHeaders(api.options.AccessToken), + Abs: a, }, result) } diff --git a/api/errors.go b/api/errors.go new file mode 100644 index 0000000..feed2f6 --- /dev/null +++ b/api/errors.go @@ -0,0 +1,9 @@ +package api + +import ( + "errors" +) + +var ( + NoContentErr = errors.New("no content") +) diff --git a/cmd/test/main.go b/cmd/test/main.go index c442875..46a302d 100644 --- a/cmd/test/main.go +++ b/cmd/test/main.go @@ -2,13 +2,16 @@ package main import ( "vultras.su/core/amo" + "vultras.su/core/amo/api" "vultras.su/core/amo/webhooks" + "vultras.su/core/amo/events" "vultras.su/core/bond" "vultras.su/core/bond/statuses" - "os" + //"os" "fmt" "io" "encoding/json" + "time" ) type Context = bond.Context @@ -54,36 +57,46 @@ Def( )) func main() { - - opts := &amo.Options{ - Url: os.Getenv("AMO_URL"), - RedirectUrl: os.Getenv("AMO_REDIRECT_URL"), - AccessToken: os.Getenv("AMO_ACCESS"), - RefreshToken: os.Getenv("AMO_REFRESH"), - ClientSecret: os.Getenv("AMO_CLIENT_SECRET"), - ClientId: os.Getenv("AMO_CLIENT_ID"), - AuthCode: os.Getenv("AMO_AUTH_CODE"), - } - fmt.Println(opts) - client, pair, err := amo.NewAmoClient(opts) + //fmt.Println(opts) + client, err := amo.NewAmoClient("secret.json") if err != nil { panic(err) } + client.Api.Debug = true gclient = client - if pair != nil { - fmt.Println("PAIR: %q", pair) - } - /*company, err := client.GetCompany("80699047", "") - if err != nil { + company, err := client.GetCompany("80828925", "") + if err != nil && err != api.NoContentErr { panic(err) - }*/ - srv := bond.Server{ - Addr: ":15080", - Handler: root, } - err = srv.ListenAndServe() - if err != nil { - panic(err) + fmt.Printf("company: %+v\n", company) + interval := time.Second * 10 + + now := time.Now() + lastChanged := now + for { + time.Sleep(interval) + req := events.EventsRequest{} + req.Limit = 10 + req.Filter.Entity = []string{"company", "contact"} + req.Filter.Type = events.CustomFieldValueChanged( + 2192301, + 2678095, + ) + req.With = []string{"company_name"} + req.Filter.CreatedAt.From = lastChanged + events, err := client.GetEvents(req) + if err != nil { + fmt.Printf("Error: %s", err) + continue + } + if len(events) == 0 { + fmt.Printf("nothing changed") + continue + } + lastChanged = time.Now() + for i, event := range events { + fmt.Printf("event %d: %v\n", i, event) + } } } diff --git a/events/custom-field.go b/events/custom-field.go new file mode 100644 index 0000000..f5a4279 --- /dev/null +++ b/events/custom-field.go @@ -0,0 +1,25 @@ +package events + +import ( + "fmt" +) + +type Value struct { + CustomFieldValue *CustomFieldValue `json:"custom_field_value,omitempty"` +} + +type CustomFieldValue struct { + EnumId int `json:"enum_id,omitempty"` + FieldId int `json:"field_id"` + FieldType int `json:"field_type"` + Text string `json:"text"` +} + +func CustomFieldValueChanged(ids ...int64) []string { + ret := make([]string, len(ids)) + for i, id := range ids { + ret[i] = fmt.Sprintf("custom_field_%d_value_changed", id) + } + return ret +} + diff --git a/events/event.go b/events/event.go new file mode 100644 index 0000000..443f414 --- /dev/null +++ b/events/event.go @@ -0,0 +1,43 @@ +package events + +import ( + "encoding/json" +) + +type EventsResponse struct { + Page int `json:"_page"` + Links struct { + Self struct { + Href string `json:"href"` + } `json:"self"` + Next struct { + Href string `json:"href"` + } `json:"next"` + } `json:"_links"` + + Embedded struct{ + Events []Event `json:"events"` + } `json:"_embedded"` +} + +//type Events []Event + +type Event struct { + Id string `json:"id"` + Type string `json:"type"` + EntityId int `json:"entity_id"` + EntityType string `json:"entity_type"` + CreatedBy int64 `json:"created_by"` + CreatedAt int64 `json:"created_at"` + ValueAfter []Value `json:"value_after"` + ValueBefore []Value `json:"value_before,omitempty"` + AccountId int `json:"account_id"` + Embedded struct { + } `json:"_embedded"` +} + +func (e Event) String() string { + bts, _ := json.MarshalIndent(e, "", "\t") + return string(bts) +} + diff --git a/events/main.go b/events/main.go new file mode 100644 index 0000000..3a89177 --- /dev/null +++ b/events/main.go @@ -0,0 +1,61 @@ +package events + +import ( + "time" + "strings" + "fmt" +) + +type EventsRequest struct { + Filter struct { + CreatedAt struct { + From, To time.Time + } + Type []string + Entity []string + } + Page, Limit uint + Ids, CreatedBy []int + With []string +} + +func (req EventsRequest) Format() string { + opts := []string{} + + if len(req.With) > 0 { + buf := fmt.Sprintf("with=%s", req.With[0]) + for _, with := range req.With[1:] { + buf += ","+with + } + opts = append(opts, buf) + } + if req.Page > 0 { + opts = append(opts, fmt.Sprintf("page=%d", req.Page)) + } + if req.Limit > 0 { + opts = append(opts, fmt.Sprintf("limit=%d", req.Limit)) + } + if !req.Filter.CreatedAt.From.IsZero() && req.Filter.CreatedAt.To.IsZero() { + opts = append(opts, fmt.Sprintf("filter[created_at]=%d", + req.Filter.CreatedAt.From.Unix(), + )) + } else if !req.Filter.CreatedAt.From.IsZero() && !req.Filter.CreatedAt.To.IsZero() { + opts = append( + opts, + fmt.Sprintf("filter[created_at][from]=%d", req.Filter.CreatedAt.From.Unix()), + fmt.Sprintf("filter[created_at][to]=%d", req.Filter.CreatedAt.To.Unix()), + ) + } + + for i, typ := range req.Filter.Type { + opts = append(opts, fmt.Sprintf("filter[type][%d]=%s", i, typ)) + } + + for i, ent := range req.Filter.Entity { + opts = append(opts, fmt.Sprintf("filter[entity][%d]=%s", i, ent)) + } + + ret := strings.Join(opts, "&") + return ret +} + diff --git a/go.mod b/go.mod index 6de8cdc..84f551c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.21.3 require ( github.com/stretchr/testify v1.6.1 - vultras.su/core/bond v0.0.0-20240114204709-a9c2c8810682 + vultras.su/core/bond v0.0.0-20240118183558-6fa4ef4cf402 ) require ( diff --git a/go.sum b/go.sum index 5fca702..cc7d419 100644 --- a/go.sum +++ b/go.sum @@ -11,3 +11,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= vultras.su/core/bond v0.0.0-20240114204709-a9c2c8810682 h1:NiT5kAwzjTO+C4/y2EyRI6N8DOl5YFLuyZEUqrYwfFE= vultras.su/core/bond v0.0.0-20240114204709-a9c2c8810682/go.mod h1:d8O5wwQlZrVAeoV7qIwxXabB9RuqgopP7wEyRl3++Tc= +vultras.su/core/bond v0.0.0-20240118183558-6fa4ef4cf402 h1:XUEdQesLiMX8mK2ZQpJyfE0p+MfmgnOdM1Mt72F+FW4= +vultras.su/core/bond v0.0.0-20240118183558-6fa4ef4cf402/go.mod h1:d8O5wwQlZrVAeoV7qIwxXabB9RuqgopP7wEyRl3++Tc= diff --git a/webhooks/handler.go b/webhooks/handler.go new file mode 100644 index 0000000..574781d --- /dev/null +++ b/webhooks/handler.go @@ -0,0 +1,2 @@ +package webhooks +