// Package megafon — клиент REST API Виртуальной АТС МегаФон (раздел «Интеграция с CRM» → «Ваша CRM / REST API»). // // Запросы: GET https://<домен>.megapbx.ru/crmapi/v1/<команда>, заголовок X-API-KEY. // История звонков — /history/json?start=…&end=…&type=…, время периода в UTC, формат 20060102T150405Z. package megafon import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" ) // ErrUnauthorized — АТС отклонила ключ: он неверный, отозван или API выключен в настройках ВАТС. var ErrUnauthorized = errors.New("megafon: ключ API не принят") type Client struct { Base string // https://vats970345.megapbx.ru/crmapi/v1 Key string HTTP *http.Client } // New собирает адрес API из домена ВАТС или полного адреса. func New(baseOrDomain, key string) *Client { b := strings.TrimRight(strings.TrimSpace(baseOrDomain), "/") if b != "" && !strings.Contains(b, "://") { b = "https://" + b } if b != "" && !strings.HasSuffix(strings.ToLower(b), "/crmapi/v1") { b += "/crmapi/v1" } return &Client{Base: b, Key: strings.TrimSpace(key), HTTP: &http.Client{Timeout: 60 * time.Second}} } func (c *Client) Configured() bool { return c != nil && c.Base != "" && c.Key != "" } // Domain — домен ВАТС для показа в админке (без ключа). func (c *Client) Domain() string { if c == nil { return "" } if u, err := url.Parse(c.Base); err == nil { return u.Host } return "" } // Call — звонок из истории. Номера — как их прислала АТС. type Call struct { UID string Type string // in | out | missed | … Status string // success | missed | busy | notavailable | notallowed | … Client string // номер клиента Via string // номер, на который звонил клиент или с которого звонили («Через») User string // логин сотрудника АТС UserName string // имя сотрудника («DANA - 2ГИС», «КЦ DANA общий») Start time.Time Wait int // секунды ожидания Duration int // секунды разговора RecordURL string Raw json.RawMessage } // History — звонки за период [from, to). typ: "" или "all", "in", "out", "missed". func (c *Client) History(ctx context.Context, from, to time.Time, typ string) ([]Call, error) { q := url.Values{} q.Set("start", from.UTC().Format("20060102T150405Z")) q.Set("end", to.UTC().Format("20060102T150405Z")) if typ != "" { q.Set("type", typ) } body, err := c.get(ctx, "/history/json", q) if err != nil { return nil, err } return ParseHistory(body) } // User — сотрудник АТС. type User struct { Login string `json:"login"` Name string `json:"name"` Ext string `json:"ext"` Telnum string `json:"telnum"` Email string `json:"email"` } // Users — сотрудники АТС; заодно проверка, что ключ рабочий. func (c *Client) Users(ctx context.Context) ([]User, error) { body, err := c.get(ctx, "/users", nil) if err != nil { return nil, err } var out []User if err := json.Unmarshal(body, &out); err != nil { var wrap struct { Items []User `json:"items"` } if err2 := json.Unmarshal(body, &wrap); err2 != nil { return nil, fmt.Errorf("megafon: ответ /users не разобран: %w", err) } out = wrap.Items } return out, nil } func (c *Client) get(ctx context.Context, path string, q url.Values) ([]byte, error) { if !c.Configured() { return nil, errors.New("megafon: не задан адрес или ключ API") } u := c.Base + path if len(q) > 0 { u += "?" + q.Encode() } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return nil, err } req.Header.Set("X-API-KEY", c.Key) req.Header.Set("Accept", "application/json") resp, err := c.HTTP.Do(req) if err != nil { return nil, fmt.Errorf("megafon: АТС не ответила: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20)) if err != nil { return nil, err } switch { case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden: return nil, ErrUnauthorized case resp.StatusCode >= 300: msg := strings.TrimSpace(string(body)) if len(msg) > 200 { msg = msg[:200] } return nil, fmt.Errorf("megafon: %s — HTTP %d %s", path, resp.StatusCode, msg) } return body, nil } // ParseHistory разбирает ответ /history/json. Разбор терпимый к версиям API: имена полей // и типы значений (число или строка) у разных ВАТС отличаются; исходная запись сохраняется в Raw. func ParseHistory(body []byte) ([]Call, error) { var items []json.RawMessage if err := json.Unmarshal(body, &items); err != nil { var wrap struct { Items []json.RawMessage `json:"items"` Data []json.RawMessage `json:"data"` } if err2 := json.Unmarshal(body, &wrap); err2 != nil { return nil, fmt.Errorf("megafon: история не разобрана: %w", err) } items = append(wrap.Items, wrap.Data...) } out := make([]Call, 0, len(items)) for _, raw := range items { var m map[string]any if err := json.Unmarshal(raw, &m); err != nil { continue } c := Call{ UID: str(m, "uid", "callid", "id"), Type: strings.ToLower(str(m, "type", "direction")), Status: strings.ToLower(str(m, "status")), Client: str(m, "client", "phone", "from"), Via: str(m, "diversion", "via", "line", "telnum", "to"), User: str(m, "user", "account", "login"), UserName: str(m, "user_name", "username", "name"), Wait: num(m, "wait"), Duration: num(m, "duration"), RecordURL: str(m, "record", "link", "record_url"), Raw: raw, } c.Start = parseTime(str(m, "start", "date", "time")) if c.UID == "" || c.Start.IsZero() { continue // без идентификатора и времени звонок не сохранить } out = append(out, c) } return out, nil } func str(m map[string]any, keys ...string) string { for _, k := range keys { switch v := m[k].(type) { case string: if v = strings.TrimSpace(v); v != "" { return v } case float64: return strconv.FormatFloat(v, 'f', -1, 64) } } return "" } func num(m map[string]any, key string) int { switch v := m[key].(type) { case float64: return int(v) case string: n, _ := strconv.Atoi(strings.TrimSpace(v)) return n } return 0 } func parseTime(s string) time.Time { for _, f := range []string{time.RFC3339, "2006-01-02T15:04:05Z", "20060102T150405Z", "2006-01-02 15:04:05"} { if t, err := time.Parse(f, s); err == nil { return t } } if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 1e9 { return time.Unix(n, 0) } return time.Time{} }