From 2c765d9bfd38039268fe82eeb0bff606bb11edd3 Mon Sep 17 00:00:00 2001 From: surdeus Date: Mon, 15 Jan 2024 00:04:00 +0300 Subject: [PATCH] init --- .gitignore | 3 + amocrm.go | 92 ++++++++++++++++++++++ api/api.go | 165 +++++++++++++++++++++++++++++++++++++++ common/common.go | 14 ++++ companies/companies.go | 36 +++++++++ contacts/contacts.go | 57 ++++++++++++++ go.mod | 8 ++ go.sum | 13 +++ leads/leads.go | 79 +++++++++++++++++++ license.txt | 22 ++++++ readme.md | 4 + users/users.go | 10 +++ webhooks/webhook.go | 48 ++++++++++++ webhooks/webhook_test.go | 77 ++++++++++++++++++ 14 files changed, 628 insertions(+) create mode 100644 .gitignore create mode 100644 amocrm.go create mode 100644 api/api.go create mode 100644 common/common.go create mode 100644 companies/companies.go create mode 100644 contacts/contacts.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 leads/leads.go create mode 100644 license.txt create mode 100644 readme.md create mode 100644 users/users.go create mode 100644 webhooks/webhook.go create mode 100644 webhooks/webhook_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0d80f2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.exe +*.exe~ + diff --git a/amocrm.go b/amocrm.go new file mode 100644 index 0000000..891c39e --- /dev/null +++ b/amocrm.go @@ -0,0 +1,92 @@ +package amo + +import ( + "fmt" + "vultras.su/core/amo/api" + "vultras.su/core/amo/companies" + "vultras.su/core/amo/contacts" + "vultras.su/core/amo/leads" + "vultras.su/core/amo/users" +) + +type IAmoClient interface { + GetUser(userId string) (*users.User, error) + GetLead(leadId string, query string) (*leads.Lead, error) + UpdateLead(lead *leads.Lead) error + GetCompany(companyId string, query string) (*companies.Company, error) + UpdateCompany(company *companies.Company) error + GetContact(contactId string, query string) (*contacts.Contact, error) + UpdateContact(contact *contacts.Contact) error +} + +type Client struct { + Api *api.Client +} + +//goland:noinspection GoUnusedExportedFunction +func NewAmoClient(options *api.ClientOptions) (*Client, error) { + apiClient, err := api.NewClient(options) + if err != nil { + return nil, err + } + + return &Client{ + Api: apiClient, + }, nil +} + +func (client *Client) updateEntity(url string, id int, body interface{}) error { + err := client.Api.Patch(fmt.Sprintf("%s/%d", url, id), body, nil) + return err +} + +func (client *Client) GetUser(userId string) (*users.User, error) { + user := new(users.User) + err := client.Api.Get(fmt.Sprintf("/api/v4/users/%s", userId), user) + return user, err +} + +func (client *Client) GetLead(leadId string, query string) (*leads.Lead, error) { + deal := new(leads.Lead) + resource := fmt.Sprintf("/api/v4/leads/%s", leadId) + if len(query) != 0 { + resource = resource + "?" + query + } + + err := client.Api.Get(resource, deal) + return deal, err +} + +func (client *Client) UpdateLead(lead *leads.Lead) error { + return client.updateEntity("/api/v4/leads", lead.ID, lead) +} + +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 { + resource = resource + "?" + query + } + + err := client.Api.Get(resource, deal) + return deal, err +} + +func (client *Client) UpdateCompany(company *companies.Company) error { + return client.updateEntity("/api/v4/companies", company.ID, company) +} + +func (client *Client) GetContact(contactId string, query string) (*contacts.Contact, error) { + deal := new(contacts.Contact) + resource := fmt.Sprintf("/api/v4/contacts/%s", contactId) + if len(query) != 0 { + resource = resource + "?" + query + } + + err := client.Api.Get(resource, deal) + return deal, err +} + +func (client *Client) UpdateContact(contact *contacts.Contact) error { + return client.updateEntity("/api/v4/contacts", contact.ID, contact) +} diff --git a/api/api.go b/api/api.go new file mode 100644 index 0000000..741dcb1 --- /dev/null +++ b/api/api.go @@ -0,0 +1,165 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" +) + +const ( + DefaultContentType = "application/json" + DefaultAccept = DefaultContentType + DefaultCacheControl = "no-cache" +) + +type ClientOptions struct { + Url string + ClientId string + ClientSecret string + AccessToken string + RefreshToken string +} + +type Client struct { + options *ClientOptions + BaseUrl *url.URL +} + +type requestOptions struct { + HttpMethod string + Body interface{} + Headers map[string]string +} + +type OauthTokenResponse struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +func NewClient(options *ClientOptions) (*Client, error) { + if len(options.AccessToken) == 0 || len(options.RefreshToken) == 0 || len(options.Url) == 0 { + return nil, errors.New("AmoCrm: Invalid options") + } + + resolvedUrl, err := url.Parse(options.Url) + if err != nil { + return nil, err + } + + return &Client{ + options: options, + BaseUrl: resolvedUrl, + }, nil +} + +func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, result interface{}) error { + resolvedUrl, err := url.Parse(resourceUrl) + if err != nil { + return err + } + + requestUrl := api.BaseUrl.ResolveReference(resolvedUrl) + + requestBody := new(bytes.Buffer) + if requestParams.Body != nil { + encoder := json.NewEncoder(requestBody) + if err := encoder.Encode(requestParams.Body); err != nil { + return err + } + } + + request, err := http.NewRequest(requestParams.HttpMethod, requestUrl.String(), requestBody) + if err != nil { + return err + } + + if requestParams.Headers != nil { + for k, v := range requestParams.Headers { + request.Header.Set(k, v) + } + } + + response, err := http.DefaultClient.Do(request) + if err != nil { + return errors.New(fmt.Sprintf("Request error: %s %d %s %s", err.Error(), response.StatusCode, requestParams.HttpMethod, resourceUrl)) + } + defer response.Body.Close() + + if response.StatusCode >= 400 { + bodyBytes, _ := ioutil.ReadAll(response.Body) + return errors.New(fmt.Sprintf("%s %d %s %s", string(bodyBytes), response.StatusCode, requestParams.HttpMethod, resourceUrl)) + } + + if result != nil { + decoder := json.NewDecoder(response.Body) + err = decoder.Decode(result) + if err != nil { + return err + } + } + + return nil +} + +func (api *Client) RefreshToken() (*OauthTokenResponse, error) { + result := new(OauthTokenResponse) + request := map[string]string{ + "client_id": api.options.ClientId, + "client_secret": api.options.ClientSecret, + "grant_type": "refresh_token", + "refresh_token": api.options.RefreshToken, + "redirect_uri": api.options.Url, + } + + err := api.doRequest("/oauth2/access_token", requestOptions{ + HttpMethod: http.MethodPost, + Body: request, + Headers: getHeaders(""), + }, result) + + return result, err +} + +func (api *Client) Get(resource string, result interface{}) error { + return api.doRequest(resource, requestOptions{ + HttpMethod: http.MethodGet, + Body: nil, + Headers: getHeaders(api.options.AccessToken), + }, result) +} + +func (api *Client) Post(resource string, request interface{}, result interface{}) error { + return api.doRequest(resource, requestOptions{ + HttpMethod: http.MethodPost, + Body: request, + Headers: getHeaders(api.options.AccessToken), + }, result) +} + +func (api *Client) Patch(resource string, request interface{}, result interface{}) error { + return api.doRequest(resource, requestOptions{ + HttpMethod: http.MethodPatch, + Body: request, + Headers: getHeaders(api.options.AccessToken), + }, result) +} + +func getHeaders(token string) map[string]string { + headers := map[string]string{ + "Accept": DefaultAccept, + "Cache-Control": DefaultCacheControl, + "Content-Type": DefaultContentType, + } + + if len(token) > 0 { + headers["Authorization"] = fmt.Sprintf("Bearer %s", token) + } + + return headers +} diff --git a/common/common.go b/common/common.go new file mode 100644 index 0000000..d56192e --- /dev/null +++ b/common/common.go @@ -0,0 +1,14 @@ +package common + +type Values struct { + Value interface{} `json:"value,omitempty"` + EnumID int `json:"enum_id,omitempty"` + Enum string `json:"enum,omitempty"` +} +type CustomFieldsValue struct { + FieldID int `json:"field_id"` + FieldName string `json:"field_name,omitempty"` + FieldCode string `json:"field_code,omitempty"` + FieldType string `json:"field_type,omitempty"` + Values []Values `json:"values"` +} diff --git a/companies/companies.go b/companies/companies.go new file mode 100644 index 0000000..5d477b4 --- /dev/null +++ b/companies/companies.go @@ -0,0 +1,36 @@ +package companies + +import "vultras.su/core/amo/common" + +type Company struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + ResponsibleUserID int `json:"responsible_user_id,omitempty"` + GroupID int `json:"group_id,omitempty"` + CreatedBy int `json:"created_by,omitempty"` + UpdatedBy int `json:"updated_by,omitempty"` + CreatedAt int `json:"created_at,omitempty"` + UpdatedAt int `json:"updated_at,omitempty"` + ClosestTaskAt interface{} `json:"closest_task_at,omitempty"` + CustomFieldsValues []common.CustomFieldsValue `json:"custom_fields_values,omitempty"` + AccountID int `json:"account_id,omitempty"` + Links Links `json:"_links,omitempty"` + Embedded Embedded `json:"_embedded,omitempty"` +} + +type Self struct { + Href string `json:"href"` +} + +type Links struct { + Self Self `json:"self"` +} + +type Contacts struct { + ID int `json:"id"` +} + +type Embedded struct { + Tags []interface{} `json:"tags"` + Contacts []*Contacts `json:"contacts"` +} diff --git a/contacts/contacts.go b/contacts/contacts.go new file mode 100644 index 0000000..0ce41b1 --- /dev/null +++ b/contacts/contacts.go @@ -0,0 +1,57 @@ +package contacts + +import "vultras.su/core/amo/common" + +type Contact struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + ResponsibleUserID int `json:"responsible_user_id,omitempty"` + GroupID int `json:"group_id,omitempty"` + CreatedBy int `json:"created_by,omitempty"` + UpdatedBy int `json:"updated_by,omitempty"` + CreatedAt int `json:"created_at,omitempty"` + UpdatedAt int `json:"updated_at,omitempty"` + ClosestTaskAt interface{} `json:"closest_task_at,omitempty"` + CustomFieldsValues []common.CustomFieldsValue `json:"custom_fields_values,omitempty"` + AccountID int `json:"account_id,omitempty"` + Links Links `json:"_links,omitempty"` + Embedded Embedded `json:"_embedded,omitempty"` +} +type Values struct { + Value string `json:"value"` + EnumID int `json:"enum_id"` + Enum string `json:"enum"` +} + +type Self struct { + Href string `json:"href"` +} + +type Links struct { + Self Self `json:"self"` +} + +type Leads struct { + ID int `json:"id"` + Links Links `json:"_links"` +} + +type Customers struct { + ID int `json:"id"` + Links Links `json:"_links"` +} + +type Companies struct { + ID int `json:"id"` + Links Links `json:"_links"` +} + +type Embedded struct { + Tags []interface{} `json:"tags"` + Leads []Leads `json:"leads"` + Customers []Customers `json:"customers"` + CatalogElements []interface{} `json:"catalog_elements"` + Companies []Companies `json:"companies"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..948d44e --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module vultras.su/core/amo + +go 1.15 + +require ( + github.com/gorilla/schema v1.2.0 + github.com/stretchr/testify v1.6.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ba8b901 --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +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/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc= +github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU= +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/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/leads/leads.go b/leads/leads.go new file mode 100644 index 0000000..12c1013 --- /dev/null +++ b/leads/leads.go @@ -0,0 +1,79 @@ +package leads + +import "vultras.su/core/amo/common" + +type Lead struct { + ID int `json:"id"` + Name string `json:"name,omitempty"` + Price int `json:"price,omitempty"` + ResponsibleUserID int `json:"responsible_user_id,omitempty"` + GroupID int `json:"group_id,omitempty"` + StatusID int `json:"status_id,omitempty"` + PipelineID int `json:"pipeline_id,omitempty"` + LossReasonID int `json:"loss_reason_id,omitempty"` + SourceID interface{} `json:"source_id,omitempty"` + CreatedBy int `json:"created_by,omitempty"` + UpdatedBy int `json:"updated_by,omitempty"` + CreatedAt int `json:"created_at,omitempty"` + UpdatedAt int `json:"updated_at,omitempty"` + ClosedAt int `json:"closed_at,omitempty"` + ClosestTaskAt interface{} `json:"closest_task_at,omitempty"` + IsDeleted bool `json:"is_deleted,omitempty"` + CustomFieldsValues []*common.CustomFieldsValue `json:"custom_fields_values,omitempty"` + Score interface{} `json:"score,omitempty"` + AccountID int `json:"account_id,omitempty"` + IsPriceModifiedByRobot bool `json:"is_price_modified_by_robot,omitempty"` + Links Links `json:"_links,omitempty"` + Embedded Embedded `json:"_embedded,omitempty"` +} + +type Self struct { + Href string `json:"href"` +} + +type Links struct { + Self Self `json:"self"` +} + +type Tags struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type Metadata struct { + Quantity int `json:"quantity"` + CatalogID int `json:"catalog_id"` +} + +type CatalogElements struct { + ID int `json:"id"` + Metadata Metadata `json:"metadata"` +} + +type LossReason struct { + ID int `json:"id"` + Name string `json:"name"` + Sort int `json:"sort"` + CreatedAt int `json:"created_at"` + UpdatedAt int `json:"updated_at"` + Links Links `json:"_links"` +} + +type Companies struct { + ID int `json:"id"` + Links Links `json:"_links"` +} + +type Contacts struct { + ID int `json:"id"` + IsMain bool `json:"is_main"` + Links Links `json:"_links"` +} + +type Embedded struct { + Tags []*Tags `json:"tags"` + CatalogElements []*CatalogElements `json:"catalog_elements"` + LossReason []*LossReason `json:"loss_reason"` + Companies []*Companies `json:"companies"` + Contacts []*Contacts `json:"contacts"` +} diff --git a/license.txt b/license.txt new file mode 100644 index 0000000..efae794 --- /dev/null +++ b/license.txt @@ -0,0 +1,22 @@ +Copyright 2024 (c) surdeus + +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. + diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..2addd7d --- /dev/null +++ b/readme.md @@ -0,0 +1,4 @@ +# amo + +AmoCRM API implementation in Go. + diff --git a/users/users.go b/users/users.go new file mode 100644 index 0000000..b08d497 --- /dev/null +++ b/users/users.go @@ -0,0 +1,10 @@ +package users + +type User struct { + ID int `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Lang string `json:"lang"` + Rights interface{} `json:"rights"` + Links interface{} `json:"_links"` +} diff --git a/webhooks/webhook.go b/webhooks/webhook.go new file mode 100644 index 0000000..defc66f --- /dev/null +++ b/webhooks/webhook.go @@ -0,0 +1,48 @@ +package webhooks + +import ( + "vultras.su/core/bond/urlenc" + "vultras.su/core/bond/jsons" + "log" + "net/url" + "strings" +) + +type Request struct { + Leads Leads `schema:"leads"` + Account Account `schema:"account"` +} + +type Leads struct { + Status jsons.ArrayMap[Status] `schema:"status"` + Add jsons.ArrayMap[Status] `schema:"add"` +} + +type Status struct { + Id jsons.Int `schema:"id"` + StatusId jsons.Int `schema:"status_id"` + PipelineId jsons.Int `schema:"pipeline_id"` + OldStatusId jsons.Int `schema:"old_status_id"` + OldPipelineId jsons.Int `schema:"old_pipeline_id"` +} + +type Account struct { + Id jsons.Int `schema:"id"` + SubDomain string `schema:"subdomain"` +} + +func NewFromString(body string) (*WebhookRequest, error) { + ret := &WebhookRequest{} + err := urlenc.Unmarsal(ret) + if err != nil { + return nil, err + } + return ret, nil +} + +func (hook *WebhookRequest) GetLeadId() string { + if hook.Leads.Status != nil { + return hook.Leads.Status[0].Id + } + return hook.Leads.Add[0].Id +} diff --git a/webhooks/webhook_test.go b/webhooks/webhook_test.go new file mode 100644 index 0000000..aae77ba --- /dev/null +++ b/webhooks/webhook_test.go @@ -0,0 +1,77 @@ +package webhooks + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func Test_Webhook_MoveIntoStage(t *testing.T) { + requestString := "leads[status][0][id]=2050297&" + + "leads[status][0][status_id]=35573056&" + + "leads[status][0][pipeline_id]=3643927&" + + "leads[status][0][old_status_id]=35572897&" + + "leads[status][0][old_pipeline_id]=3643927&" + + "account[id]=29085955&" + + "account[subdomain]=domain" + + expected := &WebhookRequest{ + Leads: Leads{ + Status: []Status{ + { + Id: "2050297", + StatusId: "35573056", + PipelineId: "3643927", + OldStatusId: "35572897", + OldPipelineId: "3643927", + }, + }, + }, + Account: Account{ + Id: "29085955", + SubDomain: "domain", + }, + } + + webhook, err := NewFromString(requestString) + + if err != nil { + t.Fail() + } + + if assert.NotNil(t, webhook) { + assert.Equal(t, webhook, expected) + } +} + +func Test_Webhook_CreateIntoStage(t *testing.T) { + requestString := "leads[add][0][id]=2232929&" + + "leads[add][0][status_id]=35573056&" + + "leads[add][0][pipeline_id]=3643927&" + + "account[id]=29085955&account[subdomain]=domain" + + expected := &WebhookRequest{ + Leads: Leads{ + Add: []Status{ + { + Id: "2232929", + StatusId: "35573056", + PipelineId: "3643927", + }, + }, + }, + Account: Account{ + Id: "29085955", + SubDomain: "domain", + }, + } + + webhook, err := NewFromString(requestString) + + if err != nil { + t.Fail() + } + + if assert.NotNil(t, webhook) { + assert.Equal(t, webhook, expected) + } +}