/
mikopbx
/
ModuleBeelinePbx
Обзор
Документация
Войти
/
mikopbx
/
ModuleBeelinePbx
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
master
Lib/BeelineApi.php
353 строки
13 KB
boffart
Инициализация модуля ModuleBeelinePbx
08 июл 2026, 17:11
08 июл 2026, 17:11
1cc4e1a
Код
Авторство
О чём код?
<?php /* * MikoPBX - free phone system for small business * Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License along with this program. * If not, see <https://www.gnu.org/licenses/>. */ namespace Modules\ModuleBeelinePbx\Lib; use GuzzleHttp\Client; /** * Тонкий клиент Облачной АТС Билайн (https://cloudpbx.beeline.ru/apis/portal). * * Все запросы авторизуются заголовком X-MPBX-API-AUTH-TOKEN. * Документация: раздел «Интеграция по API» личного кабинета Билайн. */ class BeelineApi { public const AUTH_HEADER = 'X-MPBX-API-AUTH-TOKEN'; /** * Хост REST API Облачной АТС Билайн. Значение фиксированное — не выносится в настройки. */ public const DEFAULT_HOST = 'cloudpbx.beeline.ru'; /** * Максимальный размер страницы статистики (ограничение Beeline: 10..100). */ public const STATISTICS_PAGE_SIZE = 100; private string $baseUri; private string $token; private Logger $logger; private Client $client; public function __construct(string $token, Logger $logger) { $this->baseUri = 'https://' . self::DEFAULT_HOST . '/apis/portal/'; $this->token = $token; $this->logger = $logger; $this->client = new Client(); } /** * Заголовки авторизации для JSON-ответов. * * @return array<string,string> */ private function jsonHeaders(): array { return [ self::AUTH_HEADER => $this->token, 'Accept' => 'application/json', ]; } /** * Обрезает длинное тело ответа для лога. */ private static function truncateForLog(string $value, int $maxLen = 3000): string { if (strlen($value) <= $maxLen) { return $value; } $tailLen = strlen($value) - $maxLen; return substr($value, 0, $maxLen) . "... [truncated {$tailLen} bytes]"; } /** * Форматирует дату для параметров dateFrom/dateTo Beeline API. * Формат: 2026-06-16T03:00:00.000+03:00 (ISO 8601 с миллисекундами и смещением). */ public static function formatDate(\DateTimeInterface $date): string { return $date->format('Y-m-d\TH:i:s.vP'); } /** * Нормализует телефон в российский формат: снимает '+', 10-значные дополняет '7'. */ public static function normalizePhone(string $phone): string { $phone = trim($phone); if ($phone === '') { return ''; } $phone = ltrim($phone, '+'); // Оставляем только цифры (Beeline иногда отдаёт userId/SIP — их не трогаем). if (!ctype_digit($phone)) { return $phone; } if (strlen($phone) === 10) { $phone = '7' . $phone; } return $phone; } /** * Получение страницы статистики звонков (GET /v2/statistics). * * @return array<int,array>|null массив звонков или null при ошибке запроса */ public function getStatisticsPage(string $dateFrom, string $dateTo, int $page, int $pageSize): ?array { $query = [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, 'page' => $page, 'pageSize' => $pageSize, ]; $this->logger->writeInfo($query, 'GET /v2/statistics'); try { $response = $this->client->request('GET', $this->baseUri . 'v2/statistics', [ 'query' => $query, 'headers' => $this->jsonHeaders(), 'timeout' => 30, 'connect_timeout' => 5, 'read_timeout' => 30, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = $response->getBody()->getContents(); } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'GET /v2/statistics failed'); return null; } if ($code === 200) { $data = json_decode($body, true); return is_array($data) ? $data : []; } // 204 — данных за период нет, это не ошибка. if ($code === 204) { return []; } $this->logger->writeError([ 'status' => $code, 'body' => self::truncateForLog($body), 'query' => $query, ], 'GET /v2/statistics unexpected status'); return null; } /** * Получение списка всех абонентов (GET /abonents). * * @return array<int,array> */ public function getAbonents(): array { $this->logger->writeInfo('GET /abonents'); try { $response = $this->client->request('GET', $this->baseUri . 'abonents', [ 'headers' => $this->jsonHeaders(), 'timeout' => 15, 'connect_timeout' => 5, 'read_timeout' => 15, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = $response->getBody()->getContents(); } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'GET /abonents failed'); return []; } if ($code !== 200) { $this->logger->writeError([ 'status' => $code, 'body' => self::truncateForLog($body), ], 'GET /abonents unexpected status'); return []; } $data = json_decode($body, true); return is_array($data) ? $data : []; } /** * Список записей разговоров (GET /records). Пагинация курсором по id: * возвращаются записи, следующие ПОСЛЕ переданного id (или с первой, если пусто), * не более 100 за запрос. * * @param string $dateFrom начало окна (ISO 8601) * @param string $dateTo конец окна (ISO 8601) * @param string $cursorId начальный id (пусто — с первой записи) * @return array<int,array>|null массив записей или null при ошибке */ public function getRecordsPage(string $dateFrom, string $dateTo, string $cursorId = ''): ?array { $query = [ 'dateFrom' => $dateFrom, 'dateTo' => $dateTo, ]; if ($cursorId !== '') { $query['id'] = $cursorId; } $this->logger->writeInfo($query, 'GET /records'); try { $response = $this->client->request('GET', $this->baseUri . 'records', [ 'query' => $query, 'headers' => $this->jsonHeaders(), 'timeout' => 30, 'connect_timeout' => 5, 'read_timeout' => 30, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = $response->getBody()->getContents(); } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'GET /records failed'); return null; } if ($code === 200) { $data = json_decode($body, true); return is_array($data) ? $data : []; } if ($code === 204) { return []; } $this->logger->writeError([ 'status' => $code, 'body' => self::truncateForLog($body, 500), 'query' => $query, ], 'GET /records unexpected status'); return null; } /** * Создание подписки на реалтайм-события Xsi-Events (PUT /subscription). * * Билайн начинает слать XML-события жизненного цикла вызова на переданный callback-URL. * Подписка временная — до истечения `expires` (секунды) её нужно пересоздавать. * * @param string $url callback-URL приёмника событий (обязателен) * @param int $expires желаемая длительность подписки в секундах (0 — не задавать) * @param string $pattern абонент/номер (пусто — весь клиент) * @param string $subscriptionType пакет событий (пусто — по умолчанию) * @return array|null {subscriptionId, expires} при 200, иначе null */ public function createSubscription( string $url, int $expires = 0, string $pattern = '', string $subscriptionType = '' ): ?array { $payload = ['url' => $url]; if ($expires > 0) { $payload['expires'] = $expires; } if ($pattern !== '') { $payload['pattern'] = $pattern; } if ($subscriptionType !== '') { $payload['subscriptionType'] = $subscriptionType; } $this->logger->writeInfo($payload, 'PUT /subscription'); try { $response = $this->client->request('PUT', $this->baseUri . 'subscription', [ 'json' => $payload, 'headers' => $this->jsonHeaders(), 'timeout' => 20, 'connect_timeout' => 5, 'read_timeout' => 20, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = $response->getBody()->getContents(); } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'PUT /subscription failed'); return null; } if ($code === 200) { $data = json_decode($body, true); return is_array($data) ? $data : []; } $this->logger->writeError([ 'status' => $code, 'body' => self::truncateForLog($body, 1000), ], 'PUT /subscription unexpected status'); return null; } /** * Отключение подписки Xsi-Events (DELETE /subscription?subscriptionId=…). * * @return bool true — подписка снята (200) либо её уже не было (404) */ public function deleteSubscription(string $subscriptionId): bool { $this->logger->writeInfo(['subscriptionId' => $subscriptionId], 'DELETE /subscription'); try { $response = $this->client->request('DELETE', $this->baseUri . 'subscription', [ 'query' => ['subscriptionId' => $subscriptionId], 'headers' => $this->jsonHeaders(), 'timeout' => 20, 'connect_timeout' => 5, 'read_timeout' => 20, 'http_errors' => false, ]); $code = $response->getStatusCode(); $body = $response->getBody()->getContents(); } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'DELETE /subscription failed'); return false; } // 404 — подписки уже нет; для наших целей это тоже «снята». if ($code === 200 || $code === 404) { return true; } $this->logger->writeError([ 'status' => $code, 'body' => self::truncateForLog($body, 1000), ], 'DELETE /subscription unexpected status'); return false; } /** * Скачивание файла записи разговора по её id (GET /v2/records/{recordId}/download). * * @return array{code:int, body:string} HTTP-код и тело (бинарь при 200) */ public function downloadRecordById(string $recordId): array { $url = $this->baseUri . 'v2/records/' . rawurlencode($recordId) . '/download'; $this->logger->writeInfo(['recordId' => $recordId], 'GET record download by id'); try { $response = $this->client->request('GET', $url, [ 'headers' => [self::AUTH_HEADER => $this->token], 'timeout' => 60, 'connect_timeout' => 5, 'read_timeout' => 60, 'http_errors' => false, ]); return ['code' => $response->getStatusCode(), 'body' => $response->getBody()->getContents()]; } catch (\Throwable $e) { $this->logger->writeError(['exception' => $e->getMessage()], 'GET record download by id failed'); return ['code' => 0, 'body' => $e->getMessage()]; } } }