154 lines
5.2 KiB
Go
154 lines
5.2 KiB
Go
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
|
||
}
|