feat: implemented the way to acquire events in a Go structured way.

This commit is contained in:
Andrey Parhomenko 2024-01-19 08:14:23 +03:00
parent 2e5805b97b
commit 4b088f5617
11 changed files with 361 additions and 57 deletions

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
*.exe *.exe
*.exe~ *.exe~
.env .env
secret.json

View file

@ -7,6 +7,7 @@ import (
"vultras.su/core/amo/contacts" "vultras.su/core/amo/contacts"
"vultras.su/core/amo/leads" "vultras.su/core/amo/leads"
"vultras.su/core/amo/users" "vultras.su/core/amo/users"
"vultras.su/core/amo/events"
) )
type IAmoClient interface { type IAmoClient interface {
@ -28,15 +29,15 @@ type Client struct {
type OauthTokenResponse = api.OauthTokenResponse type OauthTokenResponse = api.OauthTokenResponse
func NewAmoClient(options *Options ) (*Client, *TokenPair, error) { func NewAmoClient(secretPath string) (*Client, error) {
apiClient, pair, err := api.NewClient(options) apiClient, err := api.NewApi(secretPath)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
return &Client{ return &Client{
Api: apiClient, Api: apiClient,
}, pair, nil }, nil
} }
func (client *Client) updateEntity(url string, id int, body interface{}) error { 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) { func (client *Client) GetCompany(companyId string, query string) (*companies.Company, error) {
deal := new(companies.Company) deal := new(companies.Company)
resource := fmt.Sprintf("/api/v4/companies/%s", companyId) resource := fmt.Sprintf("/api/v4/companies/%s", companyId)
if len(query) != 0 { if query != "" {
resource = resource + "?" + query resource = resource + "?" + query
} }
@ -76,6 +77,37 @@ func (client *Client) GetCompany(companyId string, query string) (*companies.Com
return deal, err 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 { func (client *Client) UpdateCompany(company *companies.Company) error {
return client.updateEntity("/api/v4/companies", company.Id, company) return client.updateEntity("/api/v4/companies", company.Id, company)
} }

View file

@ -8,6 +8,9 @@ import (
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"io"
"os"
"time"
) )
const ( const (
@ -17,27 +20,31 @@ const (
) )
type ClientOptions struct { type ClientOptions struct {
Url string Url string `json:"url"`
RedirectUrl string RedirectUrl string `json:"redirect_url"`
AuthCode string AuthCode string `json:"auth_code"`
ClientId string ClientId string `json:"client_id"`
ClientSecret string ClientSecret string `json:"client_secret"`
AccessToken string AccessToken string `json:"access_token"`
RefreshToken string ExpirationDate time.Time `json:"access"`
RefreshToken string `json:"refresh_token"`
} }
type Client struct { type Client struct {
options *ClientOptions options *ClientOptions
BaseUrl *url.URL BaseUrl *url.URL
secretStoreFilePath string
Debug bool `json`
} }
type requestOptions struct { type requestOptions struct {
HttpMethod string HttpMethod string
Body interface{} Body interface{}
Headers map[string]string Headers map[string]string
Abs bool
} }
type OauthTokenResponse struct { type OauthTokenResponse struct {
@ -51,42 +58,114 @@ type TokenPair struct {
Access, Refresh string Access, Refresh string
} }
func NewClient(options *ClientOptions) (*Client, *TokenPair, error) { func NewApi(secretPath string) (*Client, error) {
if options.Url == "" { ret := &Client{
return nil, nil, errors.New("AmoCrm: Invalid options: Url") 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) resolvedUrl, err := url.Parse(options.Url)
if err != nil { if err != nil {
return nil, nil, err return nil, err
} }
ret := &Client{
options: options,
}
ret.BaseUrl = resolvedUrl ret.BaseUrl = resolvedUrl
var pair *TokenPair
if options.AccessToken == "" || options.RefreshToken == "" {
pair, err = ret.ExchangeAuth()
if err != nil { if err != nil {
return nil, nil, err 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{ return &Client{
options: options, options: options,
BaseUrl: resolvedUrl, BaseUrl: resolvedUrl,
}, pair, nil }, nil
}
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
}
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 { 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) resolvedUrl, err := url.Parse(resourceUrl)
if err != nil { if err != nil {
return err return err
} }
requestUrl := api.BaseUrl.ResolveReference(resolvedUrl) requestUrl = api.BaseUrl.ResolveReference(resolvedUrl)
} else {
requestUrl, err = url.Parse(resourceUrl)
if err != nil {
return err
}
}
requestBody := new(bytes.Buffer) requestBody := new(bytes.Buffer)
if requestParams.Body != nil { if requestParams.Body != nil {
@ -108,6 +187,9 @@ func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, r
} }
response, err := http.DefaultClient.Do(request) response, err := http.DefaultClient.Do(request)
if api.Debug {
fmt.Printf("\nAmo request: %+v\nAmo repsonse: %+v\n", request, response)
}
if err != nil { if err != nil {
return errors.New(fmt.Sprintf( return errors.New(fmt.Sprintf(
"Request error: %s %d %s %s", "Request error: %s %d %s %s",
@ -118,8 +200,23 @@ func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, r
)) ))
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode == 204 {
return NoContentErr
}
if response.StatusCode >= 400 { 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) bodyBytes, _ := ioutil.ReadAll(response.Body)
return errors.New(fmt.Sprintf( return errors.New(fmt.Sprintf(
"%s %d %s %s", "%s %d %s %s",
@ -168,6 +265,10 @@ func (api *Client) ExchangeAuth() (*TokenPair, error) {
api.options.AccessToken = result.AccessToken api.options.AccessToken = result.AccessToken
api.options.RefreshToken = result.RefreshToken api.options.RefreshToken = result.RefreshToken
err = api.writeSecret()
if err != nil {
return nil, err
}
return ret, nil return ret, nil
} }
@ -178,7 +279,7 @@ func (api *Client) RefreshToken() (*OauthTokenResponse, error) {
"client_secret": api.options.ClientSecret, "client_secret": api.options.ClientSecret,
"grant_type": "refresh_token", "grant_type": "refresh_token",
"refresh_token": api.options.RefreshToken, "refresh_token": api.options.RefreshToken,
"redirect_uri": api.options.Url, "redirect_uri": api.options.RedirectUrl,
} }
err := api.doRequest("/oauth2/access_token", requestOptions{ err := api.doRequest("/oauth2/access_token", requestOptions{
@ -186,15 +287,30 @@ func (api *Client) RefreshToken() (*OauthTokenResponse, error) {
Body: request, Body: request,
Headers: getHeaders(""), Headers: getHeaders(""),
}, result) }, 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{ return api.doRequest(resource, requestOptions{
HttpMethod: http.MethodGet, HttpMethod: http.MethodGet,
Body: nil, Body: nil,
Headers: getHeaders(api.options.AccessToken), Headers: getHeaders(api.options.AccessToken),
Abs: a,
}, result) }, result)
} }

9
api/errors.go Normal file
View file

@ -0,0 +1,9 @@
package api
import (
"errors"
)
var (
NoContentErr = errors.New("no content")
)

View file

@ -2,13 +2,16 @@ package main
import ( import (
"vultras.su/core/amo" "vultras.su/core/amo"
"vultras.su/core/amo/api"
"vultras.su/core/amo/webhooks" "vultras.su/core/amo/webhooks"
"vultras.su/core/amo/events"
"vultras.su/core/bond" "vultras.su/core/bond"
"vultras.su/core/bond/statuses" "vultras.su/core/bond/statuses"
"os" //"os"
"fmt" "fmt"
"io" "io"
"encoding/json" "encoding/json"
"time"
) )
type Context = bond.Context type Context = bond.Context
@ -54,36 +57,46 @@ Def(
)) ))
func main() { func main() {
//fmt.Println(opts)
opts := &amo.Options{ client, err := amo.NewAmoClient("secret.json")
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)
if err != nil { if err != nil {
panic(err) panic(err)
} }
client.Api.Debug = true
gclient = client gclient = client
if pair != nil {
fmt.Println("PAIR: %q", pair)
}
/*company, err := client.GetCompany("80699047", "") company, err := client.GetCompany("80828925", "")
if err != nil { if err != nil && err != api.NoContentErr {
panic(err) panic(err)
}*/
srv := bond.Server{
Addr: ":15080",
Handler: root,
} }
err = srv.ListenAndServe() 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 { if err != nil {
panic(err) 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)
}
} }
} }

25
events/custom-field.go Normal file
View file

@ -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
}

43
events/event.go Normal file
View file

@ -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)
}

61
events/main.go Normal file
View file

@ -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
}

2
go.mod
View file

@ -4,7 +4,7 @@ go 1.21.3
require ( require (
github.com/stretchr/testify v1.6.1 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 ( require (

2
go.sum
View file

@ -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= 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 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-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=

2
webhooks/handler.go Normal file
View file

@ -0,0 +1,2 @@
package webhooks