dana-medical-site/internal/megafon/client.go
2026-10-14 13:33:13 +00:00

227 lines
7.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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{}
}