package bitrix import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strings" ) // Семантика стадии лида в Битрикс24 (поле STATUS_SEMANTIC_ID): по ней решаем судьбу записи, // не завися от того, как в CRM названы и сколько заведено стадий. const ( SemanticProcess = "P" // в работе SemanticSuccess = "S" // успех — «Клиент записался» SemanticFailure = "F" // любой провал — «Клиент не записался», «Спам», «Нет ответа»… ) // FailedStatusID — стадия, в которую сайт сам переводит лид, отменив неподтверждённую запись. const FailedStatusID = "JUNK" // LeadDraft — новый лид из онлайн-записи. type LeadDraft struct { Title string Name string Phone string SourceID string SourceDescr string Comments string } // call — один вызов метода REST. Ответ {"result": …} разбирается в out (если out не nil). func (c *Client) call(ctx context.Context, method string, body any, out any) error { b, err := json.Marshal(body) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.WebhookURL+"/"+method+".json", bytes.NewReader(b)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return fmt.Errorf("битрикс %s: %w", method, redactURL(err)) } defer resp.Body.Close() var r struct { Result json.RawMessage `json:"result"` Error string `json:"error"` Descr string `json:"error_description"` } if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return fmt.Errorf("битрикс %s: не разобрать ответ (HTTP %d): %w", method, resp.StatusCode, err) } if r.Error != "" { return fmt.Errorf("битрикс %s: %s (%s)", method, r.Error, r.Descr) } if resp.StatusCode >= 300 { return fmt.Errorf("битрикс %s: HTTP %d", method, resp.StatusCode) } if out != nil { if err := json.Unmarshal(r.Result, out); err != nil { return fmt.Errorf("битрикс %s: неожиданный ответ: %w", method, err) } } return nil } // AddLead заводит лид в стадии «Не обработан» и возвращает его ID. func (c *Client) AddLead(ctx context.Context, d LeadDraft) (string, error) { fields := map[string]any{ "TITLE": d.Title, "NAME": d.Name, "STATUS_ID": "NEW", "OPENED": "Y", "COMMENTS": d.Comments, } if d.Phone != "" { fields["PHONE"] = []map[string]string{{"VALUE": d.Phone, "VALUE_TYPE": "MOBILE"}} } if d.SourceID != "" { fields["SOURCE_ID"] = d.SourceID } if d.SourceDescr != "" { fields["SOURCE_DESCRIPTION"] = d.SourceDescr } // result — число, но на всякий случай принимаем и строку var id json.Number if err := c.call(ctx, "crm.lead.add", map[string]any{ "fields": fields, "params": map[string]string{"REGISTER_SONET_EVENT": "Y"}, }, &id); err != nil { return "", err } s := strings.Trim(id.String(), `"`) if s == "" || s == "0" { return "", fmt.Errorf("битрикс crm.lead.add: пустой ID лида") } return s, nil } // LeadSemantics — семантика стадии (P/S/F) для каждого найденного лида. Удалённых лидов в ответе нет. func (c *Client) LeadSemantics(ctx context.Context, ids []string) (map[string]string, error) { out := map[string]string{} for len(ids) > 0 { n := min(len(ids), pageSize) chunk := ids[:n] ids = ids[n:] var items []struct { ID string `json:"ID"` Semantic string `json:"STATUS_SEMANTIC_ID"` } if err := c.call(ctx, "crm.lead.list", map[string]any{ "select": []string{"ID", "STATUS_SEMANTIC_ID"}, "filter": map[string]any{"@ID": chunk}, }, &items); err != nil { return nil, err } for _, it := range items { out[it.ID] = it.Semantic } } return out, nil } // AddLeadComment оставляет комментарий в ленте лида — оператор видит, что сделал сайт. func (c *Client) AddLeadComment(ctx context.Context, leadID, text string) error { return c.call(ctx, "crm.timeline.comment.add", map[string]any{ "fields": map[string]any{"ENTITY_ID": leadID, "ENTITY_TYPE": "lead", "COMMENT": text}, }, nil) } // FailLead переводит лид в «Клиент не записался» — после автоматической отмены записи. func (c *Client) FailLead(ctx context.Context, leadID string) error { return c.call(ctx, "crm.lead.update", map[string]any{ "id": leadID, "fields": map[string]any{"STATUS_ID": FailedStatusID}, }, nil) } // redactURL убирает адрес запроса из сетевой ошибки: в адресе вебхука секретный ключ, // а текст ошибки попадает в лог, в базу и в админку. func redactURL(err error) error { var ue *url.Error if errors.As(err, &ue) { return fmt.Errorf("%s: %w", ue.Op, ue.Err) } return err }