227 lines
7.1 KiB
Go
227 lines
7.1 KiB
Go
// 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{}
|
||
}
|