// Package bitrix — минимальный клиент REST API Битрикс24 через входящий вебхук. // Используется для выгрузки лидов и контактов в «Отчёт по каналам» и для лидов онлайн-записи, // которые подтверждает оператор (см. leads.go). package bitrix import ( "context" "encoding/json" "fmt" "net/http" "strings" "time" ) type Client struct { WebhookURL string HTTP *http.Client } func New(webhookURL string) *Client { return &Client{ WebhookURL: strings.TrimRight(strings.TrimSpace(webhookURL), "/"), HTTP: &http.Client{Timeout: 30 * time.Second}, } } func (c *Client) Configured() bool { return c != nil && c.WebhookURL != "" } type phoneField struct { Value string `json:"VALUE"` } type crmItem struct { ID string `json:"ID"` SourceID string `json:"SOURCE_ID"` Phone []phoneField `json:"PHONE"` } type listResponse struct { Result []crmItem `json:"result"` Total int `json:"total"` Error string `json:"error"` Descr string `json:"error_description"` } // Record — лид или контакт CRM: нужные нам поля, без лишнего. type Record struct { ID string SourceID string // у контактов всегда пусто — поле есть только у лидов Phones []string } const pageSize = 50 // Progress — колбэк хода постраничной выгрузки: сколько записей забрано из скольких всего (по ответу API). type Progress func(done, total int) // list — одна выборка (лиды или контакты) целиком, со всеми страницами. func (c *Client) list(ctx context.Context, method string, selectFields []string, filter map[string]any, onPage Progress) ([]Record, error) { var out []Record start := 0 for { body := map[string]any{ "select": selectFields, "order": map[string]string{"ID": "ASC"}, "start": start, } if len(filter) > 0 { body["filter"] = filter } b, err := json.Marshal(body) if err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.WebhookURL+"/"+method+".json", strings.NewReader(string(b))) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("битрикс %s: %w", method, redactURL(err)) } var lr listResponse decErr := json.NewDecoder(resp.Body).Decode(&lr) resp.Body.Close() if decErr != nil { return nil, fmt.Errorf("битрикс %s: не разобрать ответ: %w", method, decErr) } if lr.Error != "" { return nil, fmt.Errorf("битрикс %s: %s (%s)", method, lr.Error, lr.Descr) } for _, item := range lr.Result { r := Record{ID: item.ID, SourceID: item.SourceID} for _, p := range item.Phone { if v := strings.TrimSpace(p.Value); v != "" { r.Phones = append(r.Phones, v) } } out = append(out, r) } if onPage != nil { onPage(len(out), lr.Total) } start += pageSize if start >= lr.Total || len(lr.Result) == 0 { break } } return out, nil } // dateFilter — ограничение по дате создания записи (DATE_CREATE), чтобы не тянуть всю историю CRM // за годы. from/to нулевые — фильтр не применяется (выгружается всё, для старых мест вызова). func dateFilter(from, to time.Time) map[string]any { if from.IsZero() || to.IsZero() { return nil } return map[string]any{ ">=DATE_CREATE": from.Format(time.RFC3339), "