Compare commits
No commits in common. "leads-query" and "main" have entirely different histories.
leads-quer
...
main
55 changed files with 2427 additions and 644 deletions
19
.gitignore
vendored
19
.gitignore
vendored
|
@ -1,16 +1,7 @@
|
|||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
.idea
|
||||
.env
|
||||
secret.json
|
||||
*.json
|
||||
/exe/
|
||||
/tmp/
|
||||
|
|
201
LICENSE
201
LICENSE
|
@ -1,201 +0,0 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -1 +0,0 @@
|
|||
# go-amo
|
|
@ -1,69 +0,0 @@
|
|||
package amo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/qdimka/go-amo/api"
|
||||
"github.com/qdimka/go-amo/models/companies"
|
||||
"github.com/qdimka/go-amo/models/contacts"
|
||||
"github.com/qdimka/go-amo/models/leads"
|
||||
"github.com/qdimka/go-amo/models/users"
|
||||
)
|
||||
|
||||
type AmoClient struct {
|
||||
api *api.Client
|
||||
}
|
||||
|
||||
func NewAmoClient(options *api.ClientOptions) (*AmoClient, error) {
|
||||
apiClient, err := api.NewClient(options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AmoClient{
|
||||
api: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (client *AmoClient) 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 *AmoClient) 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 *AmoClient) UpdateLead(lead *leads.Lead) error {
|
||||
err := client.api.Patch(fmt.Sprintf("/api/v4/leads/%d", lead.ID), lead, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (client *AmoClient) 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 *AmoClient) 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
|
||||
}
|
34
amocrm.go
Normal file
34
amocrm.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
package amo
|
||||
|
||||
import (
|
||||
"surdeus.su/core/amo/api"
|
||||
"surdeus.su/core/amo/leads"
|
||||
"surdeus.su/core/amo/companies"
|
||||
"surdeus.su/core/amo/contacts"
|
||||
"surdeus.su/core/amo/events"
|
||||
)
|
||||
|
||||
type Lead = leads.Lead
|
||||
type Company = companies.Company
|
||||
type Contact = contacts.Contact
|
||||
type Event = events.Event
|
||||
|
||||
const (
|
||||
MEPR = api.MaxEntitiesPerRequest
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
API *api.Client
|
||||
}
|
||||
|
||||
func NewClient(secretPath string) (*Client, error) {
|
||||
apiClient, err := api.NewAPI(secretPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Client{
|
||||
API: apiClient,
|
||||
}, nil
|
||||
}
|
||||
|
230
api/api.go
230
api/api.go
|
@ -1,164 +1,162 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultContentType = "application/json"
|
||||
DefaultAccept = DefaultContentType
|
||||
DefaultCacheControl = "no-cache"
|
||||
MaxEntitiesPerRequest = 250
|
||||
)
|
||||
|
||||
type ClientOptions struct {
|
||||
Url string
|
||||
ClientId string
|
||||
ClientSecret string
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
URL string `json:"url"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
|
||||
AuthCode string `json:"auth_code"`
|
||||
|
||||
ClientId string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
ExpirationAt time.Time `json:"expiration_at,omitempty"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
options *ClientOptions
|
||||
BaseUrl *url.URL
|
||||
*log.Logger
|
||||
options ClientOptions
|
||||
BaseURL *url.URL
|
||||
secretStoreFilePath string
|
||||
Debug bool
|
||||
|
||||
availableRequests int
|
||||
mrps int
|
||||
|
||||
ticker *time.Ticker
|
||||
req, reqNeed chan struct{}
|
||||
|
||||
requestsMade int64
|
||||
}
|
||||
|
||||
type requestOptions struct {
|
||||
HttpMethod string
|
||||
Body interface{}
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
type OauthTokenResponse struct {
|
||||
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")
|
||||
type TokenPair struct {
|
||||
Access, Refresh string
|
||||
}
|
||||
|
||||
resolvedUrl, err := url.Parse(options.Url)
|
||||
func NewAPI(secretPath string) (*Client, error) {
|
||||
client := &Client{
|
||||
Logger: log.New(
|
||||
os.Stdout,
|
||||
"AmoCRM client: ",
|
||||
log.Ldate | log.Ltime,
|
||||
),
|
||||
secretStoreFilePath: secretPath,
|
||||
}
|
||||
options, err := client.readSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, NewErrorAPI(err)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
options: options,
|
||||
BaseUrl: resolvedUrl,
|
||||
}, nil
|
||||
if client.Debug {
|
||||
client.Printf("ClientOptions: %v\n", options)
|
||||
}
|
||||
|
||||
func (api *Client) doRequest(resourceUrl string, requestParams requestOptions, result interface{}) error {
|
||||
resolvedUrl, err := url.Parse(resourceUrl)
|
||||
if options.URL == "" || options.RedirectURL == "" {
|
||||
return nil, NewErrorAPI(ErrInvalidOptionsURL)
|
||||
}
|
||||
|
||||
parsedURL, err := url.Parse(options.URL)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, NewErrorAPI(err)
|
||||
}
|
||||
|
||||
requestUrl := api.BaseUrl.ResolveReference(resolvedUrl)
|
||||
client.BaseURL = parsedURL
|
||||
client.options = options
|
||||
|
||||
requestBody := new(bytes.Buffer)
|
||||
if requestParams.Body != nil {
|
||||
encoder := json.NewEncoder(requestBody)
|
||||
if err := encoder.Encode(requestParams.Body); err != nil {
|
||||
return err
|
||||
}
|
||||
var (
|
||||
//pair *TokenPair
|
||||
exchangeErr error
|
||||
exchanged bool
|
||||
)
|
||||
if client.options.AccessToken == "" &&
|
||||
client.options.RefreshToken == "" {
|
||||
|
||||
if client.options.ClientSecret == "" ||
|
||||
client.options.ClientId == "" ||
|
||||
client.options.AuthCode == "" {
|
||||
return nil, NewErrorAPI(
|
||||
ErrInvalidExchangeAuthOptions,
|
||||
)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(requestParams.HttpMethod, requestUrl.String(), requestBody)
|
||||
_, exchangeErr = client.ExchangeAuth()
|
||||
exchanged = true
|
||||
}
|
||||
|
||||
if (!exchanged || exchangeErr != nil) &&
|
||||
options.RefreshToken != "" {
|
||||
|
||||
// Refreshing token before the work.
|
||||
// Should think of how often should refresh
|
||||
// the token. (see the ExpiresIn)
|
||||
_, err = client.RefreshToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if requestParams.Headers != nil {
|
||||
for k, v := range requestParams.Headers {
|
||||
request.Header.Set(k, v)
|
||||
return nil, NewErrorAPI(err)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
return client, nil
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
decoder := json.NewDecoder(response.Body)
|
||||
err = decoder.Decode(result)
|
||||
if err != nil {
|
||||
return err
|
||||
// Set maximum requests per second.
|
||||
func (client *Client) SetMRPS(rps int) *Client {
|
||||
client.mrps = rps
|
||||
client.req = make(chan struct{})
|
||||
client.reqNeed = make(chan struct{})
|
||||
client.ticker = time.NewTicker(
|
||||
time.Second/time.Duration(rps),
|
||||
)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <- client.reqNeed :
|
||||
client.req <- struct{}{}
|
||||
}
|
||||
<-client.ticker.C
|
||||
}
|
||||
}()
|
||||
return client
|
||||
}
|
||||
|
||||
return nil
|
||||
func (client *Client) waitInQueue() bool {
|
||||
// Just leaving if MRPS is not set.
|
||||
if client.mrps == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
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,
|
||||
client.reqNeed <- struct{}{}
|
||||
<- client.req
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
err := api.doRequest("/oauth2/access_token", requestOptions{
|
||||
HttpMethod: http.MethodPost,
|
||||
Body: request,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}, result)
|
||||
|
||||
return result, err
|
||||
func (client *Client) finishRequest() {
|
||||
}
|
||||
|
||||
func (api *Client) Get(resource string, result interface{}) error {
|
||||
return api.doRequest(resource, requestOptions{
|
||||
HttpMethod: http.MethodGet,
|
||||
Body: nil,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("Bearer %s", api.options.AccessToken),
|
||||
},
|
||||
}, result)
|
||||
func (client *Client) RequestsMade() int64 {
|
||||
return client.requestsMade
|
||||
}
|
||||
|
||||
func (api *Client) Post(resource string, request interface{}, result interface{}) error {
|
||||
return api.doRequest(resource, requestOptions{
|
||||
HttpMethod: http.MethodPost,
|
||||
Body: request,
|
||||
Headers: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("Bearer %s", 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: map[string]string{
|
||||
"Accept": "application/json",
|
||||
"Cache-Control": "no-cache",
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": fmt.Sprintf("Bearer %s", api.options.AccessToken),
|
||||
},
|
||||
}, result)
|
||||
}
|
||||
|
|
93
api/auth.go
Normal file
93
api/auth.go
Normal file
|
@ -0,0 +1,93 @@
|
|||
package api
|
||||
|
||||
import "time"
|
||||
import "net/http"
|
||||
|
||||
func (api *Client) ExchangeAuth() (*TokenPair, error) {
|
||||
result := &OAuthTokenResponse{}
|
||||
request := map[string] string {
|
||||
"client_id": api.options.ClientId,
|
||||
"client_secret": api.options.ClientSecret,
|
||||
"grant_type": "authorization_code",
|
||||
"code": api.options.AuthCode,
|
||||
"redirect_uri": api.options.RedirectURL,
|
||||
}
|
||||
|
||||
err := api.doRequest("/oauth2/access_token", RequestOptions{
|
||||
Method: http.MethodPost,
|
||||
Body: request,
|
||||
Headers: makeHeaders(""),
|
||||
}, result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &TokenPair{
|
||||
Access: result.AccessToken,
|
||||
Refresh: result.RefreshToken,
|
||||
}
|
||||
|
||||
api.options.AccessToken = result.AccessToken
|
||||
api.options.RefreshToken = result.RefreshToken
|
||||
now := time.Now()
|
||||
api.options.ExpirationAt = now.Add(
|
||||
time.Second*time.Duration(result.ExpiresIn),
|
||||
)
|
||||
err = api.writeSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (api *Client) RefreshTokenIfExpired() error {
|
||||
if api.options.RefreshToken == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
if now.After(api.options.ExpirationAt) || now.Equal(api.options.ExpirationAt){
|
||||
_, err := api.RefreshToken()
|
||||
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.RedirectURL,
|
||||
}
|
||||
|
||||
err := api.doRequest(
|
||||
"/oauth2/access_token",
|
||||
RequestOptions{
|
||||
Method: http.MethodPost,
|
||||
Body: request,
|
||||
Headers: makeHeaders(""),
|
||||
},
|
||||
result,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
api.options.AccessToken = result.AccessToken
|
||||
api.options.RefreshToken = result.RefreshToken
|
||||
now := time.Now()
|
||||
api.options.ExpirationAt = now.Add(
|
||||
time.Second*time.Duration(result.ExpiresIn),
|
||||
)
|
||||
err = api.writeSecret()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
40
api/endpoint.go
Normal file
40
api/endpoint.go
Normal file
|
@ -0,0 +1,40 @@
|
|||
package api
|
||||
|
||||
import "net/http"
|
||||
|
||||
func (api *Client) Get(
|
||||
u string,
|
||||
result interface{},
|
||||
) error {
|
||||
params := RequestOptions{
|
||||
Method: http.MethodGet,
|
||||
Headers: makeHeaders(api.options.AccessToken),
|
||||
}
|
||||
return api.doRequest(u, params, result)
|
||||
}
|
||||
|
||||
func (api *Client) Post(
|
||||
u string,
|
||||
request any,
|
||||
result any,
|
||||
) error {
|
||||
params := RequestOptions{
|
||||
Method: http.MethodPost,
|
||||
Body: request,
|
||||
Headers: makeHeaders(api.options.AccessToken),
|
||||
}
|
||||
return api.doRequest(u, params, result)
|
||||
}
|
||||
|
||||
func (api *Client) Patch(
|
||||
u string,
|
||||
request any,
|
||||
result any,
|
||||
) error {
|
||||
params := RequestOptions{
|
||||
Method: http.MethodPatch,
|
||||
Body: request,
|
||||
Headers: makeHeaders(api.options.AccessToken),
|
||||
}
|
||||
return api.doRequest(u, params, result)
|
||||
}
|
26
api/errors.go
Normal file
26
api/errors.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNoContent = errors.New("no content")
|
||||
ErrNoAuth = errors.New("not authorized")
|
||||
ErrUnreachable = errors.New("unreachable")
|
||||
ErrInvalidOptionsURL = errors.New("invalid URL options")
|
||||
ErrInvalidExchangeAuthOptions= errors.New(
|
||||
"invalid ExchangeAuth options",
|
||||
)
|
||||
ErrUrlParsing= errors.New("URL parsing")
|
||||
)
|
||||
|
||||
func NewErrorAPI(err error) error {
|
||||
return fmt.Errorf("NewAPI: %w", err)
|
||||
}
|
||||
|
||||
func NewRequestError(err error) error {
|
||||
return fmt.Errorf("RequestError: %w", err)
|
||||
}
|
||||
|
169
api/request.go
Normal file
169
api/request.go
Normal file
|
@ -0,0 +1,169 @@
|
|||
package api
|
||||
|
||||
import "encoding/json"
|
||||
import "net/http"
|
||||
import "net/url"
|
||||
import "errors"
|
||||
import "bytes"
|
||||
import "fmt"
|
||||
import "io"
|
||||
|
||||
type RequestOptions struct {
|
||||
Method string
|
||||
Body interface{}
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
func (client *Client) doRawRequest(
|
||||
u *url.URL,
|
||||
params RequestOptions,
|
||||
result interface{},
|
||||
) error {
|
||||
var (
|
||||
err error
|
||||
)
|
||||
client.requestsMade++
|
||||
|
||||
reqBody := new(bytes.Buffer)
|
||||
if params.Body != nil {
|
||||
encoder := json.NewEncoder(reqBody)
|
||||
err := encoder.Encode(params.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(
|
||||
params.Method,
|
||||
u.String(),
|
||||
reqBody,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if params.Headers != nil {
|
||||
for k, v := range params.Headers {
|
||||
request.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(request)
|
||||
if client.Debug {
|
||||
client.Printf(
|
||||
"Request: %+v\n\nAmo response: %+v\n\n",
|
||||
request,
|
||||
res,
|
||||
)
|
||||
}
|
||||
if res == nil {
|
||||
if err != nil {
|
||||
return NewRequestError(err)
|
||||
}
|
||||
return NewRequestError(ErrUnreachable)
|
||||
}
|
||||
if err != nil {
|
||||
return NewRequestError(fmt.Errorf(
|
||||
"%w: %d %s %s",
|
||||
err,
|
||||
res.StatusCode,
|
||||
params.Method,
|
||||
u,
|
||||
))
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode == 204 {
|
||||
return NewRequestError(ErrNoContent)
|
||||
}
|
||||
|
||||
if res.StatusCode >= 400 {
|
||||
var specErr error
|
||||
if res.StatusCode == 401 {
|
||||
specErr = ErrNoAuth
|
||||
}
|
||||
|
||||
resErrInfo, _ := io.ReadAll(res.Body)
|
||||
return NewRequestError(fmt.Errorf(
|
||||
"%w: %s %s %q %d",
|
||||
specErr,
|
||||
params.Method,
|
||||
u,
|
||||
string(resErrInfo),
|
||||
res.StatusCode,
|
||||
))
|
||||
}
|
||||
|
||||
if result != nil {
|
||||
decoder := json.NewDecoder(res.Body)
|
||||
err = decoder.Decode(result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (client *Client) doRequest(
|
||||
u string,
|
||||
params RequestOptions,
|
||||
result any,
|
||||
) error {
|
||||
var (
|
||||
err error
|
||||
parsedURL *url.URL
|
||||
)
|
||||
if client.waitInQueue() {
|
||||
defer client.finishRequest()
|
||||
}
|
||||
|
||||
parsedURL, err = url.Parse(u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reqURL := client.BaseURL.ResolveReference(parsedURL)
|
||||
|
||||
err = client.doRawRequest(
|
||||
reqURL,
|
||||
params,
|
||||
result,
|
||||
)
|
||||
if errors.Is(err, ErrNoAuth) &&
|
||||
client.options.RefreshToken != "" {
|
||||
|
||||
_, err = client.RefreshToken()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return client.doRawRequest(
|
||||
reqURL,
|
||||
params,
|
||||
result,
|
||||
)
|
||||
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeHeaders(
|
||||
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
|
||||
}
|
||||
|
39
api/secret.go
Normal file
39
api/secret.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package api
|
||||
|
||||
import "encoding/json"
|
||||
import "os"
|
||||
import "io"
|
||||
|
||||
func (api *Client) readSecret() (ClientOptions, error) {
|
||||
f, err := os.Open(api.secretStoreFilePath)
|
||||
if err != nil {
|
||||
return ClientOptions{}, err
|
||||
}
|
||||
|
||||
bts, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return ClientOptions{}, err
|
||||
}
|
||||
|
||||
|
||||
ret := ClientOptions{}
|
||||
err = json.Unmarshal(bts, &ret)
|
||||
if err != nil {
|
||||
return ClientOptions{}, 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
|
||||
}
|
3
btest.sh
Executable file
3
btest.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
#
|
||||
go build -o ./exe/ ./cmd/test
|
4
build.sh
Executable file
4
build.sh
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/bin/sh
|
||||
|
||||
go build -o ./exe/ ./cmd/amocli/
|
||||
|
202
cmd/amocli/common.go
Normal file
202
cmd/amocli/common.go
Normal file
|
@ -0,0 +1,202 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
import "surdeus.su/core/amo"
|
||||
import "sync"
|
||||
import "os"
|
||||
import "strconv"
|
||||
import "log"
|
||||
import "bufio"
|
||||
import "math"
|
||||
|
||||
type DefaultFlags struct {
|
||||
SecretPath string
|
||||
Threads int
|
||||
Verbose bool
|
||||
MRPS int
|
||||
MEPR int
|
||||
|
||||
All bool
|
||||
StartPage int
|
||||
EndPage int
|
||||
|
||||
Indent bool
|
||||
}
|
||||
|
||||
func MakeDefaultFlags(opts *DefaultFlags, flags *mtool.Flags) {
|
||||
flags.StringVar(
|
||||
&opts.SecretPath,
|
||||
"secret",
|
||||
"secret.json",
|
||||
"path to JSON file with AMO CRM secrets",
|
||||
"AMO_SECRET",
|
||||
)
|
||||
flags.IntVar(
|
||||
&opts.MRPS,
|
||||
"mrps",
|
||||
9,
|
||||
"maximum requests per second",
|
||||
)
|
||||
flags.IntVar(
|
||||
&opts.Threads,
|
||||
"threads",
|
||||
5,
|
||||
"amount of threads to run the requests",
|
||||
)
|
||||
flags.BoolVar(
|
||||
&opts.Verbose,
|
||||
"no-verbose",
|
||||
true,
|
||||
"disable verbose mode",
|
||||
)
|
||||
flags.IntVar(
|
||||
&opts.MEPR,
|
||||
"mepr",
|
||||
amo.MEPR,
|
||||
"max entities per request",
|
||||
)
|
||||
}
|
||||
|
||||
func MakeGetterFlags(
|
||||
opts *DefaultFlags,
|
||||
flags *mtool.Flags,
|
||||
) {
|
||||
flags.BoolVar(
|
||||
&opts.All,
|
||||
"all",
|
||||
false,
|
||||
"get all entities",
|
||||
)
|
||||
flags.IntVar(
|
||||
&opts.StartPage,
|
||||
"startpage",
|
||||
1,
|
||||
"the page to start at (works only with -all)",
|
||||
)
|
||||
flags.IntVar(
|
||||
&opts.EndPage,
|
||||
"endpage",
|
||||
math.MaxInt,
|
||||
"the page to end the requests",
|
||||
)
|
||||
flags.BoolVar(
|
||||
&opts.Indent,
|
||||
"no-indent",
|
||||
true,
|
||||
"disable indenting for JSON output",
|
||||
)
|
||||
}
|
||||
|
||||
// Run function for slice's parts in different threads.
|
||||
// Returns a channel that ticks that the threads finished.
|
||||
func RunForSliceInThreads[V any](
|
||||
threadN, mepr int, // Thread amount and MERP.
|
||||
slice []V,
|
||||
// The function that takes
|
||||
// the thread number and the slice of slice.
|
||||
fn func(int, []V),
|
||||
) (chan struct{}) {
|
||||
ret := make(chan struct{})
|
||||
|
||||
// No need for threads if
|
||||
// there are so few entities.
|
||||
if len(slice) <= mepr {
|
||||
go func() {
|
||||
fn(1, slice)
|
||||
ret <- struct{}{}
|
||||
}()
|
||||
return ret
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
runFn :=func(thread int, s []V) {
|
||||
defer wg.Done()
|
||||
iterN := (len(s) / mepr) + 1
|
||||
for j := 0 ; j<iterN ; j++ {
|
||||
start := j*mepr
|
||||
end := start + mepr
|
||||
if end > len(s) {
|
||||
end = len(s)
|
||||
}
|
||||
if len(s[start:end]) == 0 {
|
||||
continue
|
||||
}
|
||||
fn(thread, s[start:end])
|
||||
}
|
||||
}
|
||||
// Maximizing speed on small data.
|
||||
|
||||
|
||||
//threadSize := len(slice) / threadN
|
||||
threadMeprRest := len(slice) % mepr
|
||||
//threadSize := (len(slice)-threadMeprRest)/threadN
|
||||
preThreadSize :=
|
||||
((len(slice)-threadMeprRest)/threadN)
|
||||
threadSize := (preThreadSize/mepr+1)*mepr
|
||||
|
||||
if threadSize < mepr {
|
||||
threadSize = mepr
|
||||
threadN = len(slice) / mepr
|
||||
runFn = func(thread int, s []V) {
|
||||
defer wg.Done()
|
||||
fn(thread, s)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0 ; i<threadN ; i++ {
|
||||
first := i * threadSize
|
||||
last := first + threadSize
|
||||
if last > len(slice) {
|
||||
last = len(slice)
|
||||
}
|
||||
|
||||
// Got an empty slice.
|
||||
if len(slice[first:last]) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go runFn(i+1, slice[first:last])
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
ret <- struct{}{}
|
||||
}()
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func ReadIDs(idStrs []string) []int {
|
||||
var ids []int
|
||||
if len(idStrs) > 0 {
|
||||
ids = make([]int, 0, len(idStrs))
|
||||
for _, idStr := range idStrs {
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Printf("Error: Atoi(%q): %s\n", err)
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
} else {
|
||||
ids = make([]int, 0)
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
for scanner.Scan() {
|
||||
txt := scanner.Text()
|
||||
|
||||
id, err := strconv.Atoi(txt)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
"strconv.Atoi(%q): %s\n",
|
||||
txt, err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
34
cmd/amocli/getcom.go
Normal file
34
cmd/amocli/getcom.go
Normal file
|
@ -0,0 +1,34 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
//import "encoding/json"
|
||||
//import "strconv"
|
||||
//import "log"
|
||||
//import "fmt"
|
||||
//import "os"
|
||||
|
||||
|
||||
type CompanyGetter struct{}
|
||||
|
||||
func (l CompanyGetter) GetValues(
|
||||
c *amo.Client,
|
||||
opts ...urlenc.Builder,
|
||||
) ([]amo.Company, amo.NextFunc[[]amo.Company], error) {
|
||||
return c.GetCompanies(opts...)
|
||||
}
|
||||
|
||||
func (l CompanyGetter) GetNameMul() string {
|
||||
return "companies"
|
||||
}
|
||||
|
||||
func (l CompanyGetter) GetFuncName() string {
|
||||
return "amo.GetCompanies"
|
||||
}
|
||||
|
||||
var getComs = mtool.T("get-companies").Func(func(
|
||||
flags *mtool.Flags,
|
||||
){
|
||||
RunGetter(CompanyGetter{}, flags)
|
||||
})
|
26
cmd/amocli/getcontact.go
Normal file
26
cmd/amocli/getcontact.go
Normal file
|
@ -0,0 +1,26 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
|
||||
type ContactGetter struct{}
|
||||
func (l ContactGetter) GetValues(
|
||||
c *amo.Client,
|
||||
opts ...urlenc.Builder,
|
||||
) ([]amo.Contact, amo.NextFunc[[]amo.Contact], error) {
|
||||
return c.GetContacts(opts...)
|
||||
}
|
||||
|
||||
func (l ContactGetter) GetNameMul() string {
|
||||
return "companies"
|
||||
}
|
||||
|
||||
func (l ContactGetter) GetFuncName() string {
|
||||
return "amo.GetCompanies"
|
||||
}
|
||||
|
||||
var getContacts = mtool.T("get-contacts").
|
||||
Func(func(flags *mtool.Flags){
|
||||
RunGetter(ContactGetter{}, flags)
|
||||
})
|
29
cmd/amocli/getlead.go
Normal file
29
cmd/amocli/getlead.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
|
||||
type LeadGetter struct {}
|
||||
|
||||
func (l LeadGetter) GetValues(
|
||||
c *amo.Client,
|
||||
opts ...urlenc.Builder,
|
||||
) ([]amo.Lead, amo.NextFunc[[]amo.Lead], error) {
|
||||
return c.GetLeads(opts...)
|
||||
}
|
||||
|
||||
func (l LeadGetter) GetNameMul() string {
|
||||
return "leads"
|
||||
}
|
||||
|
||||
func (l LeadGetter) GetFuncName() string {
|
||||
return "amo.GetLeads"
|
||||
}
|
||||
var getLead = mtool.T("get-leads").Func(func(
|
||||
flags *mtool.Flags,
|
||||
){
|
||||
RunGetter(LeadGetter{}, flags)
|
||||
}).Usage(
|
||||
"[id1 id2 ... idN]",
|
||||
)
|
33
cmd/amocli/getleadtup.go
Normal file
33
cmd/amocli/getleadtup.go
Normal file
|
@ -0,0 +1,33 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
|
||||
type LeadTupleGetter struct{}
|
||||
func (l LeadTupleGetter) GetValues(
|
||||
c *amo.Client,
|
||||
opts ...urlenc.Builder,
|
||||
) (
|
||||
[]amo.LeadTuple,
|
||||
amo.NextFunc[[]amo.LeadTuple],
|
||||
error,
|
||||
) {
|
||||
return c.GetLeadTuples(opts...)
|
||||
}
|
||||
|
||||
func (l LeadTupleGetter) GetNameMul() string {
|
||||
return "lead tuples"
|
||||
}
|
||||
|
||||
func (l LeadTupleGetter) GetFuncName() string {
|
||||
return "amo.GetLeadTuples"
|
||||
}
|
||||
|
||||
var getLeadTuple = mtool.T("get-lead-tuples").Func(func(
|
||||
flags *mtool.Flags,
|
||||
){
|
||||
RunGetter(LeadTupleGetter{}, flags)
|
||||
}).Usage(
|
||||
"[id1 id2 ... idN]",
|
||||
)
|
188
cmd/amocli/getter.go
Normal file
188
cmd/amocli/getter.go
Normal file
|
@ -0,0 +1,188 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
|
||||
//import "fmt"
|
||||
import "log"
|
||||
import "time"
|
||||
import "os"
|
||||
import "os/signal"
|
||||
import "encoding/json"
|
||||
|
||||
type Getter[V any] interface {
|
||||
GetValues(*amo.Client, ...urlenc.Builder) ([]V, amo.NextFunc[[]V], error)
|
||||
GetNameMul() string
|
||||
GetFuncName() string
|
||||
}
|
||||
|
||||
func RunGetter[V any, G Getter[V]](g G, flags *mtool.Flags) {
|
||||
var (
|
||||
opts DefaultFlags
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
MakeDefaultFlags(&opts, flags)
|
||||
MakeGetterFlags(&opts, flags)
|
||||
|
||||
idStrs := flags.Parse()
|
||||
c, err := amo.NewClient(opts.SecretPath)
|
||||
if err != nil {
|
||||
log.Fatalf("NewAmoClient(...): %s\n", err)
|
||||
}
|
||||
c.API.SetMRPS(opts.MRPS)
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
if opts.Indent {
|
||||
enc.SetIndent("", " ")
|
||||
}
|
||||
|
||||
inter := make(chan os.Signal, 1)
|
||||
signal.Notify(inter, os.Interrupt)
|
||||
go func() {
|
||||
<-inter
|
||||
os.Stdout.Sync()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
finalNum := 0
|
||||
if opts.All {
|
||||
page := opts.StartPage
|
||||
values, next, err := g.GetValues(
|
||||
c,
|
||||
urlenc.Value[int]{"page", page},
|
||||
urlenc.Value[int]{"limit", opts.MEPR},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatalf(
|
||||
"%s(...): %s\n", g.GetFuncName(), err,
|
||||
)
|
||||
}
|
||||
finalNum += len(values)
|
||||
|
||||
if opts.Verbose {
|
||||
log.Printf("Got %d %s (%d, page %d)\n",
|
||||
len(values), g.GetNameMul(), finalNum, page)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
err := enc.Encode(value)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Encode(...): %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
page++
|
||||
for page <= opts.EndPage && next != nil {
|
||||
values, next, err = next()
|
||||
if err != nil {
|
||||
log.Fatalf(
|
||||
"%s(...): %s\n",
|
||||
g.GetFuncName(), err,
|
||||
)
|
||||
}
|
||||
finalNum += len(values)
|
||||
if opts.Verbose {
|
||||
log.Printf("Got %d %s (%d, page %d)\n",
|
||||
len(values), g.GetNameMul(),
|
||||
finalNum, page)
|
||||
}
|
||||
|
||||
for _, value := range values {
|
||||
err = enc.Encode(value)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Encode(...): %s\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
page++
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
ids := ReadIDs(idStrs)
|
||||
if len(ids) == 0 {
|
||||
log.Fatalf("Got no IDs to read %s", g.GetNameMul())
|
||||
return
|
||||
}
|
||||
|
||||
valueChan := make(chan []V)
|
||||
finish := RunForSliceInThreads[int](
|
||||
opts.Threads, opts.MEPR,
|
||||
ids, func(thread int, s []int){
|
||||
values, _, err := g.GetValues(
|
||||
c,
|
||||
urlenc.Array[int]{
|
||||
"id",
|
||||
s,
|
||||
},
|
||||
urlenc.Value[string]{
|
||||
"with",
|
||||
"contacts",
|
||||
},
|
||||
urlenc.Value[int]{
|
||||
"limit",
|
||||
opts.MEPR,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("thread(%d): %s(...): %s\n",
|
||||
thread, g.GetFuncName(), err)
|
||||
return
|
||||
}
|
||||
valueChan <- values
|
||||
if opts.Verbose {
|
||||
log.Printf(
|
||||
"thread(%d): Got %d %s\n",
|
||||
thread, len(values),
|
||||
g.GetNameMul(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
//var wg sync.WaitGroup
|
||||
go func(){
|
||||
// Waiting for appending so we do not lose data.
|
||||
<-finish
|
||||
for len(valueChan) > 0 {}
|
||||
close(valueChan)
|
||||
}()
|
||||
|
||||
for values := range valueChan {
|
||||
finalNum += len(values)
|
||||
for _, value := range values {
|
||||
err := enc.Encode(value)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Encode(...): %s\n", err)
|
||||
}
|
||||
}
|
||||
if opts.Verbose {
|
||||
log.Printf(
|
||||
"thtread(main) %.2f%%, sument=%d",
|
||||
float32(finalNum)/float32(len(ids))*100.,
|
||||
finalNum,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Verbose {
|
||||
rm := c.API.RequestsMade()
|
||||
log.Printf(
|
||||
"Summarized got %d %s\n",
|
||||
finalNum, g.GetNameMul(),
|
||||
)
|
||||
log.Printf(
|
||||
"Made %d requests in process\n",
|
||||
rm,
|
||||
)
|
||||
took := time.Since(now).Seconds()
|
||||
log.Printf(
|
||||
"Took %f seconds\n",
|
||||
took,
|
||||
)
|
||||
log.Printf("RPS = %f\n", float64(rm)/took)
|
||||
}
|
||||
|
||||
}
|
32
cmd/amocli/main.go
Normal file
32
cmd/amocli/main.go
Normal file
|
@ -0,0 +1,32 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
import "runtime/debug"
|
||||
import "os"
|
||||
import "log"
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
tool.Run(os.Args[1:])
|
||||
}
|
||||
|
||||
var tool = mtool.T("amocli").Subs(
|
||||
getLead,
|
||||
|
||||
getComs,
|
||||
updateComs,
|
||||
|
||||
getContacts,
|
||||
getLeadTuple,
|
||||
|
||||
mtool.T("version").Func(func(flags *mtool.Flags){
|
||||
bi, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
log.Fatalf("could not read build info\n")
|
||||
return
|
||||
}
|
||||
fmt.Println(bi.Main.Version)
|
||||
}).Desc(
|
||||
"print program version",
|
||||
),
|
||||
)
|
30
cmd/amocli/updatecom.go
Normal file
30
cmd/amocli/updatecom.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import "surdeus.su/core/amo"
|
||||
//import "surdeus.su/core/amo/api"
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
//import "surdeus.su/core/amo/companies"
|
||||
//import "encoding/json"
|
||||
//import "log"
|
||||
//import "os"
|
||||
|
||||
type CompanyUpdater struct{}
|
||||
|
||||
func (u CompanyUpdater) UpdateValues(
|
||||
c *amo.Client, cs []amo.Company,
|
||||
) ([]amo.Company, error) {
|
||||
return c.UpdateCompanies(cs)
|
||||
}
|
||||
|
||||
func (u CompanyUpdater) GetNameMul() string {
|
||||
return "companies"
|
||||
}
|
||||
|
||||
func (u CompanyUpdater) GetFuncName() string {
|
||||
return "amo.UpdateCompanies"
|
||||
}
|
||||
|
||||
var updateComs =
|
||||
mtool.T("update-companies").Func(func(flags *mtool.Flags){
|
||||
RunUpdater(CompanyUpdater{}, flags)
|
||||
})
|
76
cmd/amocli/updater.go
Normal file
76
cmd/amocli/updater.go
Normal file
|
@ -0,0 +1,76 @@
|
|||
package main
|
||||
|
||||
//import "surdeus.su/core/ss/u"
|
||||
import "surdeus.su/core/amo"
|
||||
import "surdeus.su/core/cli/mtool"
|
||||
import "encoding/json"
|
||||
import "os"
|
||||
import "log"
|
||||
import "time"
|
||||
|
||||
type Updater[V, VR any] interface {
|
||||
UpdateValues(*amo.Client, []V) (VR, error)
|
||||
GetNameMul() string
|
||||
GetFuncName() string
|
||||
}
|
||||
|
||||
func RunUpdater[V, VR any, U Updater[V, VR]](
|
||||
u U, flags *mtool.Flags,
|
||||
) {
|
||||
now := time.Now()
|
||||
var (
|
||||
opts DefaultFlags
|
||||
)
|
||||
MakeDefaultFlags(&opts, flags)
|
||||
flags.Parse()
|
||||
|
||||
client, err := amo.NewClient(opts.SecretPath)
|
||||
if err != nil {
|
||||
log.Fatalf("NewAmoClient(...): %s\n", err)
|
||||
}
|
||||
|
||||
values := []V{}
|
||||
dec := json.NewDecoder(os.Stdin)
|
||||
err = dec.Decode(&values)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Decode(...): %s\n", err)
|
||||
}
|
||||
|
||||
finish := RunForSliceInThreads[V](
|
||||
opts.Threads, opts.MEPR,
|
||||
values, func(thread int, s []V){
|
||||
_, err := u.UpdateValues(client, s)
|
||||
if err != nil {
|
||||
log.Printf(
|
||||
"thread(%d): %s(...): %s\n",
|
||||
thread, u.GetFuncName(), err,
|
||||
)
|
||||
return
|
||||
}
|
||||
if opts.Verbose {
|
||||
log.Printf(
|
||||
"thread(%d): Updated %d %s\n",
|
||||
thread, len(s), u.GetNameMul(),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
<-finish
|
||||
if opts.Verbose {
|
||||
rm := client.API.RequestsMade()
|
||||
/*log.Printf(
|
||||
"Summarized got %d %s\n",
|
||||
len(finalValues), g.GetNameMul(),
|
||||
)*/
|
||||
log.Printf(
|
||||
"Made %d requests in process\n",
|
||||
rm,
|
||||
)
|
||||
took := time.Since(now).Seconds()
|
||||
log.Printf(
|
||||
"Took %f seconds\n",
|
||||
took,
|
||||
)
|
||||
log.Printf("RPS = %f\n", float64(rm)/took)
|
||||
}
|
||||
}
|
59
cmd/test/main.go
Normal file
59
cmd/test/main.go
Normal file
|
@ -0,0 +1,59 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"surdeus.su/core/amo"
|
||||
"surdeus.su/core/amo/api"
|
||||
//"surdeus.su/core/amo/webhooks"
|
||||
"surdeus.su/core/amo/events"
|
||||
//"surdeus.su/core/ss"
|
||||
//"surdeus.su/core/ss/statuses"
|
||||
//"os"
|
||||
"fmt"
|
||||
//"io"
|
||||
//"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
//fmt.Println(opts)
|
||||
client, err := amo.NewAmoClient("secret.json")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
client.Api.Debug = true
|
||||
|
||||
company, err := client.GetCompany(80828925, "")
|
||||
if err != nil && err != api.NoContentErr {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
45
common/common.go
Normal file
45
common/common.go
Normal file
|
@ -0,0 +1,45 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
type Value struct {
|
||||
Value any `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 []Value `json:"values"`
|
||||
}
|
||||
|
||||
type CustomFieldsValues []CustomFieldsValue
|
||||
|
||||
func (vs CustomFieldsValues) GetByID(
|
||||
id int,
|
||||
) (CustomFieldsValue, bool) {
|
||||
for _, v := range vs {
|
||||
if v.FieldID == id {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return CustomFieldsValue{}, false
|
||||
}
|
||||
|
||||
func (fields CustomFieldsValues) FilterByNameRegex(
|
||||
re *regexp.Regexp,
|
||||
) CustomFieldsValues {
|
||||
ret := CustomFieldsValues{}
|
||||
for _, field := range fields {
|
||||
if re.MatchString(field.FieldName) {
|
||||
ret = append(ret, field)
|
||||
}
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
|
92
companies.go
Normal file
92
companies.go
Normal file
|
@ -0,0 +1,92 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "surdeus.su/core/amo/companies"
|
||||
import "surdeus.su/core/amo/api"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
|
||||
func (client *Client) GetCompany(
|
||||
companyID int,
|
||||
opts ...urlenc.Builder,
|
||||
) (*Company, error) {
|
||||
deal := new(companies.Company)
|
||||
resource := fmt.Sprintf(
|
||||
"/api/v4/companies/%d?%s",
|
||||
companyID,
|
||||
urlenc.Join(opts...),
|
||||
)
|
||||
|
||||
err := client.API.Get(resource, deal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deal, nil
|
||||
}
|
||||
// Get list of leads.
|
||||
func (client *Client) GetCompanies(
|
||||
opts ...urlenc.Builder,
|
||||
) ([]Company, NextFunc[[]Company], error) {
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/companies?%s",
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
return client.GetCompaniesByURL(res)
|
||||
}
|
||||
|
||||
func (client *Client) GetCompaniesByURL(
|
||||
u string,
|
||||
) ([]Company, NextFunc[[]Company], error) {
|
||||
var fn NextFunc[[]Company]
|
||||
|
||||
coms := companies.Companies{}
|
||||
err := client.API.Get(u, &coms)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNoContent) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
//ret = append(ret, coms.Embedded.Companies...)
|
||||
|
||||
nextHref := coms.Links.Next.Href
|
||||
if nextHref != "" {
|
||||
fn = MakeNextFunc(
|
||||
nextHref,
|
||||
client.GetCompaniesByURL,
|
||||
)
|
||||
}
|
||||
return coms.Embedded.Companies, fn, nil
|
||||
}
|
||||
|
||||
func (client *Client) UpdateCompany(
|
||||
company *companies.Company,
|
||||
) error {
|
||||
return client.updateEntity(
|
||||
"/api/v4/companies",
|
||||
company.ID,
|
||||
company,
|
||||
)
|
||||
}
|
||||
|
||||
func (client *Client) UpdateCompanies(
|
||||
cs []companies.Company,
|
||||
) ([]Company, error) {
|
||||
if len(cs) > MEPR {
|
||||
return nil, ErrTooManyEntities
|
||||
}
|
||||
|
||||
//ret := []Company{}
|
||||
err := client.API.Patch(
|
||||
"/api/v4/companies",
|
||||
cs,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
51
companies/companies.go
Normal file
51
companies/companies.go
Normal file
|
@ -0,0 +1,51 @@
|
|||
package companies
|
||||
|
||||
import "surdeus.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.CustomFieldsValues `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"`
|
||||
}
|
||||
|
||||
type Companies 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 {
|
||||
Companies []Company `json:"companies"`
|
||||
} `json:"_embedded"`
|
||||
}
|
72
contacts.go
Normal file
72
contacts.go
Normal file
|
@ -0,0 +1,72 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/amo/contacts"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "errors"
|
||||
import "surdeus.su/core/amo/api"
|
||||
import "fmt"
|
||||
//import "log"
|
||||
|
||||
func (client *Client) GetContact(
|
||||
contactID int,
|
||||
opts ...urlenc.Builder,
|
||||
) (*contacts.Contact, error) {
|
||||
deal := new(contacts.Contact)
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/contacts/%d?%s",
|
||||
contactID,
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
err := client.API.Get(res, deal)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return deal, nil
|
||||
}
|
||||
// Get list of contacts.
|
||||
func (client *Client) GetContacts(
|
||||
opts ...urlenc.Builder,
|
||||
) ([]contacts.Contact, NextFunc[[]Contact], error) {
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/contacts?%s",
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
return client.GetContactsByURL(res)
|
||||
}
|
||||
|
||||
func (client *Client) GetContactsByURL(
|
||||
u string,
|
||||
) ([]Contact, NextFunc[[]Contact], error) {
|
||||
var fn NextFunc[[]Contact]
|
||||
|
||||
contacts := contacts.Contacts{}
|
||||
err := client.API.Get(u, &contacts)
|
||||
if err != nil {
|
||||
if errors.Is(err, api.ErrNoContent) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
//ret = append(ret, coms.Embedded.Companies...)
|
||||
|
||||
nextHref := contacts.Links.Next.Href
|
||||
if nextHref != "" {
|
||||
fn = MakeNextFunc(
|
||||
nextHref,
|
||||
client.GetContactsByURL,
|
||||
)
|
||||
}
|
||||
return contacts.Embedded.Contacts, fn, nil
|
||||
}
|
||||
|
||||
func (client *Client) UpdateContact(contact *contacts.Contact) error {
|
||||
return client.updateEntity(
|
||||
"/api/v4/contacts",
|
||||
contact.ID,
|
||||
contact,
|
||||
)
|
||||
|
||||
}
|
73
contacts/contacts.go
Normal file
73
contacts/contacts.go
Normal file
|
@ -0,0 +1,73 @@
|
|||
package contacts
|
||||
|
||||
import "surdeus.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.CustomFieldsValues `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 Company 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 []Company `json:"companies"`
|
||||
}
|
||||
|
||||
type Contacts 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 {
|
||||
Contacts []Contact `json:"contacts"`
|
||||
} `json:"_embedded"`
|
||||
}
|
||||
|
18
entity.go
Normal file
18
entity.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package amo
|
||||
|
||||
import "fmt"
|
||||
|
||||
func (client *Client) updateEntity(
|
||||
u string,
|
||||
id int,
|
||||
body any,
|
||||
) error {
|
||||
err := client.API.Patch(
|
||||
fmt.Sprintf(
|
||||
"%s/%d",
|
||||
u, id,
|
||||
),
|
||||
body, nil,
|
||||
)
|
||||
return err
|
||||
}
|
5
env.sh
Normal file
5
env.sh
Normal file
|
@ -0,0 +1,5 @@
|
|||
lines=$(cat .env)
|
||||
for line in $lines ; do
|
||||
echo $line
|
||||
eval "export $line"
|
||||
done
|
7
errors.go
Normal file
7
errors.go
Normal file
|
@ -0,0 +1,7 @@
|
|||
package amo
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrTooManyEntities = errors.New("too many entities")
|
||||
)
|
39
events.go
Normal file
39
events.go
Normal file
|
@ -0,0 +1,39 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/amo/events"
|
||||
import "surdeus.su/core/amo/api"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
|
||||
// Returns the events from AmoCRM by specified request.
|
||||
// If there are no such events returns an empty slice of events.
|
||||
func (client *Client) GetEvents(
|
||||
opts ...urlenc.Builder,
|
||||
) ([]events.Event, error) {
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/events?%s",
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
ret := []events.Event{}
|
||||
for {
|
||||
resp := events.EventsResponse{}
|
||||
err := client.API.Get(res, &resp)
|
||||
if err != nil {
|
||||
// Return empty if no content avialable.
|
||||
if errors.Is(err, api.ErrNoContent) {
|
||||
break
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
ret = append(ret, resp.Embedded.Events...)
|
||||
if resp.Links.Next.Href == "" {
|
||||
break
|
||||
}
|
||||
//time.Sleep(time.Millisecond * 300)
|
||||
res = resp.Links.Next.Href
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
25
events/custom-field.go
Normal file
25
events/custom-field.go
Normal 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
43
events/event.go
Normal 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
61
events/main.go
Normal 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
|
||||
}
|
||||
|
15
go.mod
15
go.mod
|
@ -1,8 +1,15 @@
|
|||
module github.com/qdimka/go-amo
|
||||
module surdeus.su/core/amo
|
||||
|
||||
go 1.15
|
||||
go 1.21.3
|
||||
|
||||
require (
|
||||
github.com/gorilla/schema v1.2.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
surdeus.su/core/ss v0.1.4
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
surdeus.su/core/cli v0.0.2 // indirect
|
||||
)
|
||||
|
|
25
go.sum
25
go.sum
|
@ -1,13 +1,20 @@
|
|||
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/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
surdeus.su/core/cli v0.0.2 h1:RdHHk3/Fhwxz9PjaE+vTlCuF9KmhrmNUb5y4oqulrYI=
|
||||
surdeus.su/core/cli v0.0.2/go.mod h1:UKwCmcSX+x7XX9aF3gOaaAaJcJA3gtUmL4vdnM43+fM=
|
||||
surdeus.su/core/ss v0.1.1 h1:CXYBVRk4fv+Z7Cu51Nw5wDs5CoaEf2VpjB7XmFqu3DQ=
|
||||
surdeus.su/core/ss v0.1.1/go.mod h1:4gPfk0OjdJ1maKHyYiZwai7jIIwpVE5vgiBF2nubdrU=
|
||||
surdeus.su/core/ss v0.1.2 h1:gCVeAypwD1pO6mFZg1rOV5oYQMkIERrKxEmpGq6PGBI=
|
||||
surdeus.su/core/ss v0.1.2/go.mod h1:4gPfk0OjdJ1maKHyYiZwai7jIIwpVE5vgiBF2nubdrU=
|
||||
surdeus.su/core/ss v0.1.3 h1:/oUi9427THM4NqSzZZCXTmu8OJKWHVOb8P2kSvlq3Uw=
|
||||
surdeus.su/core/ss v0.1.3/go.mod h1:4gPfk0OjdJ1maKHyYiZwai7jIIwpVE5vgiBF2nubdrU=
|
||||
surdeus.su/core/ss v0.1.4 h1:rswGlNbjlxbYSYUcTCPTzJr1d13YkDtQw9JvFNmQsxQ=
|
||||
surdeus.su/core/ss v0.1.4/go.mod h1:4gPfk0OjdJ1maKHyYiZwai7jIIwpVE5vgiBF2nubdrU=
|
||||
|
|
3
install.sh
Executable file
3
install.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
|
||||
go install ./cmd/amocli/
|
143
lead-tuple.go
Normal file
143
lead-tuple.go
Normal file
|
@ -0,0 +1,143 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "fmt"
|
||||
|
||||
// The type describes tuple
|
||||
// contaning everything related
|
||||
// to THE lead.
|
||||
type LeadTuple struct {
|
||||
Lead *Lead `json:"lead,omitempty"`
|
||||
MainContact *Contact `json:"main_contact,omitempty"`
|
||||
Company *Company `json:"company,omitempty"`
|
||||
}
|
||||
|
||||
func (client *Client) GetLeadTuples(
|
||||
opts ...urlenc.Builder,
|
||||
) ([]LeadTuple, NextFunc[[]LeadTuple], error) {
|
||||
opts = append(
|
||||
opts, urlenc.Value[string]{"with", "contacts"},
|
||||
)
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/leads?%s",
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
return client.GetLeadTuplesByURL(res)
|
||||
}
|
||||
|
||||
func (client *Client) GetLeadTuplesByURL(
|
||||
u string,
|
||||
) ([]LeadTuple, NextFunc[[]LeadTuple], error) {
|
||||
return client.getLeadTuplesByFuncURL(
|
||||
func() ([]Lead, NextFunc[[]Lead], error){
|
||||
return client.GetLeadsByURL(u)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (client *Client) getLeadTuplesByFuncURL(
|
||||
// The function describes way of getting leads.
|
||||
callback func() (
|
||||
[]Lead,
|
||||
NextFunc[[]Lead],
|
||||
error,
|
||||
),
|
||||
) ([]LeadTuple, NextFunc[[]LeadTuple], error) {
|
||||
var fn NextFunc[[]LeadTuple]
|
||||
leads, next, err := callback()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("amo.GetLeads(...): %s\n", err)
|
||||
}
|
||||
|
||||
comIDs := []int{}
|
||||
contactIDs := []int{}
|
||||
leadToCom := map[int]int{}
|
||||
leadToContact := map[int]int{}
|
||||
|
||||
for _, lead := range leads {
|
||||
coms := lead.Embedded.Companies
|
||||
if len(coms) > 0 {
|
||||
com := coms[0]
|
||||
leadToCom[lead.ID] = com.ID
|
||||
comIDs = append(comIDs, com.ID)
|
||||
}
|
||||
mainContact, hasMain := lead.Embedded.
|
||||
Contacts.GetMain()
|
||||
if hasMain {
|
||||
leadToContact[lead.ID] = mainContact.ID
|
||||
contactIDs = append(contactIDs, mainContact.ID)
|
||||
}
|
||||
}
|
||||
|
||||
comMap := map[int] *Company{}
|
||||
var coms []Company
|
||||
if len(comIDs) > 0 {
|
||||
coms, _, err = client.GetCompanies(
|
||||
urlenc.Array[int]{
|
||||
"id",
|
||||
comIDs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"amo.GetCompanies(...): %s\n", err)
|
||||
}
|
||||
for i := range coms {
|
||||
comMap[coms[i].ID] = &coms[i]
|
||||
}
|
||||
|
||||
}
|
||||
contactMap := map[int] *Contact{}
|
||||
var contacts []Contact
|
||||
if len(contactIDs) > 0 {
|
||||
contacts, _, err = client.GetContacts(
|
||||
urlenc.Array[int]{
|
||||
"id",
|
||||
contactIDs,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"client.GetContacts(...): %s\n", err)
|
||||
}
|
||||
}
|
||||
for i := range contacts {
|
||||
contactMap[contacts[i].ID] = &contacts[i]
|
||||
}
|
||||
|
||||
ret := []LeadTuple{}
|
||||
for i := range leads {
|
||||
leadID := leads[i].ID
|
||||
tup := LeadTuple{
|
||||
Lead: &leads[i],
|
||||
}
|
||||
contactID, ok := leadToContact[leadID]
|
||||
if ok {
|
||||
contact, ok := contactMap[contactID]
|
||||
if ok {
|
||||
tup.MainContact = contact
|
||||
}
|
||||
}
|
||||
companyID, ok := leadToCom[leadID]
|
||||
if ok {
|
||||
company, ok := comMap[companyID]
|
||||
if ok {
|
||||
tup.Company = company
|
||||
}
|
||||
}
|
||||
ret = append(ret, tup)
|
||||
}
|
||||
|
||||
if next != nil {
|
||||
fn = NextFunc[[]LeadTuple](
|
||||
func() ([]LeadTuple, NextFunc[[]LeadTuple], error) {
|
||||
return client.getLeadTuplesByFuncURL(
|
||||
next,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return ret, fn, nil
|
||||
|
||||
}
|
71
leads.go
Normal file
71
leads.go
Normal file
|
@ -0,0 +1,71 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/amo/api"
|
||||
import "surdeus.su/core/amo/leads"
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "errors"
|
||||
import "fmt"
|
||||
//import "log"
|
||||
|
||||
// Get list of leads.
|
||||
func (client *Client) GetLeads(
|
||||
opts ...urlenc.Builder,
|
||||
) ([]Lead, NextFunc[[]Lead], error) {
|
||||
res := fmt.Sprintf(
|
||||
"/api/v4/leads?%s",
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
return client.GetLeadsByURL(res)
|
||||
}
|
||||
|
||||
func (client *Client) GetLeadsByURL(
|
||||
u string,
|
||||
) ([]Lead, NextFunc[[]Lead], error) {
|
||||
var fn NextFunc[[]Lead]
|
||||
|
||||
lds := leads.Leads{}
|
||||
err := client.API.Get(u, &lds)
|
||||
if err != nil {
|
||||
// Check for empty.
|
||||
if errors.Is(err, api.ErrNoContent) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
// Some other error.
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
nextHref := lds.Links.Next.Href
|
||||
if nextHref != "" {
|
||||
fn = MakeNextFunc(
|
||||
nextHref,
|
||||
client.GetLeadsByURL,
|
||||
)
|
||||
}
|
||||
|
||||
return lds.Embedded.Leads, fn, nil
|
||||
|
||||
}
|
||||
|
||||
// Get lead with the specified ID.
|
||||
func (client *Client) GetLead(
|
||||
leadID int,
|
||||
opts ...urlenc.Builder,
|
||||
) (*leads.Lead, error) {
|
||||
deal := new(leads.Lead)
|
||||
resource := fmt.Sprintf(
|
||||
"/api/v4/leads/%d?%s",
|
||||
leadID,
|
||||
urlenc.Join(opts...).Encode(),
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
25
leads/array.go
Normal file
25
leads/array.go
Normal file
|
@ -0,0 +1,25 @@
|
|||
package leads
|
||||
|
||||
// The structure represents
|
||||
// response on the */leads .
|
||||
type Leads struct {
|
||||
Page int `json:"_page"`
|
||||
Links struct {
|
||||
Self struct {
|
||||
Href string `json:"href"`
|
||||
} `json:"self"`
|
||||
Next struct {
|
||||
Href string `json:"href"`
|
||||
} `json:"next"`
|
||||
First struct {
|
||||
Href string `json:"first"`
|
||||
} `json:"first"`
|
||||
Prev struct {
|
||||
Href string `json:"href"`
|
||||
} `json:"prev"`
|
||||
} `json:"_links"`
|
||||
Embedded struct {
|
||||
Leads []Lead `json:"leads"`
|
||||
} `json:"_embedded"`
|
||||
}
|
||||
|
91
leads/leads.go
Normal file
91
leads/leads.go
Normal file
|
@ -0,0 +1,91 @@
|
|||
package leads
|
||||
|
||||
import "surdeus.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.CustomFieldsValues `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 Contact struct {
|
||||
ID int `json:"id"`
|
||||
IsMain bool `json:"is_main"`
|
||||
Links Links `json:"_links"`
|
||||
}
|
||||
type Contacts []Contact
|
||||
|
||||
func (cs Contacts) GetMain() (Contact, bool) {
|
||||
for _, contact := range cs {
|
||||
if contact.IsMain {
|
||||
return contact, true
|
||||
}
|
||||
}
|
||||
return Contact{}, false
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
22
license.txt
Normal file
22
license.txt
Normal file
|
@ -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.
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
package companies
|
||||
|
||||
type Company struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ResponsibleUserID int `json:"responsible_user_id"`
|
||||
GroupID int `json:"group_id"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
UpdatedAt int `json:"updated_at"`
|
||||
ClosestTaskAt interface{} `json:"closest_task_at"`
|
||||
CustomFieldsValues []CustomFieldsValues `json:"custom_fields_values"`
|
||||
AccountID int `json:"account_id"`
|
||||
Links Links `json:"_links"`
|
||||
Embedded Embedded `json:"_embedded"`
|
||||
}
|
||||
type Values struct {
|
||||
Value string `json:"value"`
|
||||
EnumID int `json:"enum_id"`
|
||||
Enum string `json:"enum"`
|
||||
}
|
||||
type CustomFieldsValues struct {
|
||||
FieldID int `json:"field_id"`
|
||||
FieldName string `json:"field_name"`
|
||||
FieldCode string `json:"field_code"`
|
||||
FieldType string `json:"field_type"`
|
||||
Values []Values `json:"values"`
|
||||
}
|
||||
type Self struct {
|
||||
Href string `json:"href"`
|
||||
}
|
||||
type Links struct {
|
||||
Self Self `json:"self"`
|
||||
}
|
||||
type Embedded struct {
|
||||
Tags []interface{} `json:"tags"`
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
package contacts
|
||||
|
||||
type Contact struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
ResponsibleUserID int `json:"responsible_user_id"`
|
||||
GroupID int `json:"group_id"`
|
||||
CreatedBy int `json:"created_by"`
|
||||
UpdatedBy int `json:"updated_by"`
|
||||
CreatedAt int `json:"created_at"`
|
||||
UpdatedAt int `json:"updated_at"`
|
||||
ClosestTaskAt interface{} `json:"closest_task_at"`
|
||||
CustomFieldsValues []CustomFieldsValues `json:"custom_fields_values"`
|
||||
AccountID int `json:"account_id"`
|
||||
Links Links `json:"_links"`
|
||||
Embedded Embedded `json:"_embedded"`
|
||||
}
|
||||
type Values struct {
|
||||
Value string `json:"value"`
|
||||
EnumID int `json:"enum_id"`
|
||||
Enum string `json:"enum"`
|
||||
}
|
||||
type CustomFieldsValues struct {
|
||||
FieldID int `json:"field_id"`
|
||||
FieldName string `json:"field_name"`
|
||||
FieldCode string `json:"field_code"`
|
||||
FieldType string `json:"field_type"`
|
||||
Values []Values `json:"values"`
|
||||
}
|
||||
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"`
|
||||
}
|
|
@ -1,87 +0,0 @@
|
|||
package leads
|
||||
|
||||
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 []*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"`
|
||||
}
|
||||
|
||||
type Value struct {
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type CustomFieldsValue struct {
|
||||
FieldID int `json:"field_id"`
|
||||
FieldCode string `json:"field_code,omitempty"`
|
||||
Values []*Value `json:"values"`
|
||||
}
|
13
next.go
Normal file
13
next.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package amo
|
||||
|
||||
type NextFunc[V any] func() (V, NextFunc[V], error)
|
||||
|
||||
func MakeNextFunc[V any](
|
||||
href string,
|
||||
fn func(string) (V, NextFunc[V], error),
|
||||
) NextFunc[V] {
|
||||
return NextFunc[V](func() (V, NextFunc[V], error){
|
||||
return fn(href)
|
||||
})
|
||||
}
|
||||
|
4
readme.md
Normal file
4
readme.md
Normal file
|
@ -0,0 +1,4 @@
|
|||
# amo
|
||||
|
||||
AmoCRM API implementation in Go.
|
||||
|
118
scripts/inn-check.xgo
Normal file
118
scripts/inn-check.xgo
Normal file
|
@ -0,0 +1,118 @@
|
|||
os := import("os")
|
||||
fmt := import("fmt")
|
||||
json := import("json")
|
||||
|
||||
args := os.args()[2:]
|
||||
file_path := args[0]
|
||||
|
||||
contact_field_id := 231711
|
||||
company_field_id := 1300614
|
||||
lead_field_id := 686597
|
||||
|
||||
holders := json.decode(os.read_file(file_path))
|
||||
//fmt.println(len(holders))
|
||||
//os.exit(0)
|
||||
patch := []
|
||||
|
||||
find_by_field_id := func(id, fields){
|
||||
for _, field in fields {
|
||||
if field.field_id == id {
|
||||
return field
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is_field_correct := func(field){
|
||||
if !int(field.values[0].value) {
|
||||
return false
|
||||
}
|
||||
str := string(field.values[0].value)
|
||||
if len(str) <9 || len(str) > 12 {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
//holders = holders[:2]
|
||||
for i, h in holders {
|
||||
company := {}
|
||||
if !h.company_id {
|
||||
continue
|
||||
}
|
||||
|
||||
company.id = h.company_id
|
||||
|
||||
company_inn := find_by_field_id(company_field_id, h.company_inns)
|
||||
//fmt.println(i, " reachd", company_inn)
|
||||
if company_inn {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
contact_inn := find_by_field_id(contact_field_id, h.contact_inns)
|
||||
lead_inn := find_by_field_id(lead_field_id, h.lead_inns)
|
||||
|
||||
if !contact_inn {
|
||||
if !lead_inn || !is_field_correct(lead_inn) {
|
||||
continue
|
||||
}
|
||||
|
||||
company.__reason = "Взято из лида (в контакте не было)"
|
||||
company.custom_fields_values = [{
|
||||
field_id: company_field_id,
|
||||
values: [{
|
||||
value: string(lead_inn.values[0].value)
|
||||
}]
|
||||
}]
|
||||
patch += [company]
|
||||
continue
|
||||
}
|
||||
|
||||
if !lead_inn {
|
||||
if !is_field_correct(contact_inn) {
|
||||
continue
|
||||
}
|
||||
company.__reason = "Взято из контакта (в лиде не было)"
|
||||
company.custom_fields_values =[{
|
||||
field_id: company_field_id,
|
||||
values: [{
|
||||
value: string(contact_inn.values[0].value)
|
||||
}]
|
||||
}]
|
||||
} else {
|
||||
if int(contact_inn.values[0].value) != int(lead_inn.values[0].value) {
|
||||
continue
|
||||
}
|
||||
if !is_field_correct(contact_inn) {
|
||||
continue
|
||||
}
|
||||
company.__reason = "Взято из лида и контакта"
|
||||
company.custom_fields_values =[{
|
||||
field_id: company_field_id,
|
||||
values: [{
|
||||
value: string(contact_inn.values[0].value)
|
||||
}]
|
||||
}]
|
||||
}
|
||||
patch += [company]
|
||||
|
||||
/*if h.company_inns {
|
||||
for _, field in h.company_inns {
|
||||
company.custom_fields_values += [{
|
||||
field_id: field.field_id,
|
||||
values: [{
|
||||
value: field.values[0].value
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
companies += [company]*/
|
||||
}
|
||||
|
||||
//patch = patch[:500]
|
||||
//fmt.println(len(patch))
|
||||
|
||||
bts := json.indent(json.encode(patch), "", " ")
|
||||
fmt.println(string(bts))
|
||||
|
17
scripts/len-uniq-tuple-leads.xgo
Normal file
17
scripts/len-uniq-tuple-leads.xgo
Normal file
|
@ -0,0 +1,17 @@
|
|||
|
||||
os := import("os")
|
||||
fmt := import("fmt")
|
||||
json := import("json")
|
||||
|
||||
args := os.args()[2:]
|
||||
file_path := args[0]
|
||||
|
||||
arr := json.decode(os.read_file(file_path))
|
||||
ret := {}
|
||||
for _, v in arr {
|
||||
if !v.lead || !v.lead.id{
|
||||
continue
|
||||
}
|
||||
ret[string(v.lead.id)] = 1
|
||||
}
|
||||
fmt.println(len(ret))
|
15
scripts/len.xgo
Normal file
15
scripts/len.xgo
Normal file
|
@ -0,0 +1,15 @@
|
|||
|
||||
os := import("os")
|
||||
fmt := import("fmt")
|
||||
sjson := import("cjson")
|
||||
|
||||
dec := cjson.new_decoder("<stdin>")
|
||||
//file_path := args[0]
|
||||
n := 0
|
||||
for {
|
||||
v := dec.decode()
|
||||
if !v {break}
|
||||
n++
|
||||
fmt.println(n)
|
||||
}
|
||||
fmt.println(n)
|
18
users.go
Normal file
18
users.go
Normal file
|
@ -0,0 +1,18 @@
|
|||
package amo
|
||||
|
||||
import "surdeus.su/core/ss/urlenc"
|
||||
import "surdeus.su/core/amo/users"
|
||||
import "fmt"
|
||||
|
||||
func (client *Client) GetUser(
|
||||
userId int,
|
||||
opts ...urlenc.Builder,
|
||||
) (*users.User, error) {
|
||||
user := new(users.User)
|
||||
err := client.API.Get(fmt.Sprintf(
|
||||
"/api/v4/users/%d?%s",
|
||||
userId,
|
||||
urlenc.Join(opts...).Encode(),
|
||||
), user)
|
||||
return user, err
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
package users
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Lang string `json:"lang"`
|
2
webhooks/handler.go
Normal file
2
webhooks/handler.go
Normal file
|
@ -0,0 +1,2 @@
|
|||
package webhooks
|
||||
|
|
@ -1,62 +1,104 @@
|
|||
package webhooks
|
||||
|
||||
import (
|
||||
"github.com/gorilla/schema"
|
||||
"log"
|
||||
//"surdeus.su/core/ss/urlenc"
|
||||
"surdeus.su/core/ss/jsons"
|
||||
/*"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"strings"*/
|
||||
)
|
||||
|
||||
type WebhookRequest struct {
|
||||
Leads Leads `schema:"leads"`
|
||||
Account Account `schema:"account"`
|
||||
type CustomFields jsons.ArrayMap[CustomField]
|
||||
type CustomField struct {
|
||||
Id jsons.Int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Values jsons.ArrayMap[any]
|
||||
}
|
||||
|
||||
type Leads struct {
|
||||
Status []Status `schema:"status"`
|
||||
Add []Status `schema:"add"`
|
||||
type Request struct {
|
||||
Account Account `json:"account"`
|
||||
Leads *Leads `json:"leads,omitempty"`
|
||||
Contacts *Contacts `json:"contacts,omitempty"`
|
||||
}
|
||||
type Type int
|
||||
const (
|
||||
TypeNone Type = iota
|
||||
TypeLeads
|
||||
TypeContacts
|
||||
)
|
||||
|
||||
func (r *Request) Type() Type {
|
||||
if r.Leads != nil {
|
||||
return TypeLeads
|
||||
}
|
||||
if r.Contacts != nil {
|
||||
return TypeContacts
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
Id string `schema:"id"`
|
||||
StatusId string `schema:"status_id"`
|
||||
PipelineId string `schema:"pipeline_id"`
|
||||
OldStatusId string `schema:"old_status_id"`
|
||||
OldPipelineId string `schema:"old_pipeline_id"`
|
||||
return TypeNone
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Id string `schema:"id"`
|
||||
SubDomain string `schema:"subdomain"`
|
||||
Id jsons.Int `json:"id"`
|
||||
SubDomain string `json:"subdomain"`
|
||||
Links struct {
|
||||
Self string `json:"self"`
|
||||
} `json:"_links"`
|
||||
}
|
||||
|
||||
func NewFromString(body string) (*WebhookRequest, error) {
|
||||
decodedBody, err := url.QueryUnescape(body)
|
||||
type Leads struct {
|
||||
Status jsons.ArrayMap[LeadStatus] `json:"status"`
|
||||
Add jsons.ArrayMap[LeadStatus] `json:"add"`
|
||||
}
|
||||
type LeadStatus struct {
|
||||
Id jsons.Int `json:"id"`
|
||||
StatusId jsons.Int `json:"status_id"`
|
||||
|
||||
PipelineId jsons.Int `json:"pipeline_id"`
|
||||
|
||||
OldStatusId jsons.Int `json:"old_status_id"`
|
||||
OldPipelineId jsons.Int `json:"old_pipeline_id"`
|
||||
}
|
||||
|
||||
type Contacts struct {
|
||||
Add jsons.ArrayMap[Contact] `json:"add"`
|
||||
}
|
||||
type Contact struct {
|
||||
Type ContactType `json:"type"`
|
||||
|
||||
Id jsons.Int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
AccountId jsons.Int `json:"account_id"`
|
||||
CreatedUserId jsons.Int `json:"created_user_id"`
|
||||
ModifiedUserId jsons.Int `json:"modified_user_id"`
|
||||
ResponsibleUserId jsons.Int `json:"responsible_user_id"`
|
||||
|
||||
CreatedAt jsons.Int `json:"created_at"`
|
||||
UpdatedAt jsons.Int `json:"updated_at"`
|
||||
LastModified jsons.Int `json:"last_modified"`
|
||||
DateCreate jsons.Int `json:"date_create"`
|
||||
|
||||
CustomFields CustomFields`json:"custom_fields,omitempty"`
|
||||
}
|
||||
type ContactType string
|
||||
const (
|
||||
NoContactType = ""
|
||||
ContactContactType = "contacts"
|
||||
CompanyContactType = "company"
|
||||
)
|
||||
|
||||
/*func NewFromString(body string) (*Request, error) {
|
||||
ret := &Request{}
|
||||
err := urlenc.Unmarsal(ret)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
return ret, nil
|
||||
}*/
|
||||
|
||||
replacer := strings.NewReplacer("][", ".", "[", ".", "]", "")
|
||||
decodedBody = replacer.Replace(decodedBody)
|
||||
|
||||
bodyMap := make(map[string][]string)
|
||||
|
||||
for _, value := range strings.Split(decodedBody, "&") {
|
||||
parameter := strings.Split(value, "=")
|
||||
bodyMap[parameter[0]] = []string{parameter[1]}
|
||||
}
|
||||
|
||||
webhook := new(WebhookRequest)
|
||||
|
||||
err = schema.NewDecoder().Decode(webhook, bodyMap)
|
||||
|
||||
return webhook, err
|
||||
}
|
||||
|
||||
func (hook *WebhookRequest) GetLeadId() string {
|
||||
func (hook *Request) GetLeadId() int {
|
||||
if hook.Leads.Status != nil {
|
||||
return hook.Leads.Status[0].Id
|
||||
return int(hook.Leads.Status[0].Id)
|
||||
}
|
||||
return hook.Leads.Add[0].Id
|
||||
return int(hook.Leads.Add[0].Id)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue