Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

`ModuleMegafonPbx` — расширение для MikoPBX (PHP 7.4, Phalcon 4). Назначение: периодически читать историю звонков из MegaPBX (МегаФон ВАТС) через CRM API и записывать её в CDR MikoPBX. Модуль read‑only по отношению к MegaPBX и не управляет звонками. Загруженную историю далее можно выгружать в 1С (см. `getHistory.epf` и REST‑эндпоинт `Lib/RestAPI/GetController.php`).
`ModuleMegafonPbx` — расширение для MikoPBX (PHP 7.4, Phalcon 4). Назначение: интеграция с MegaPBX (МегаФон ВАТС) через её CRM API v1 (спецификация: <https://api.megapbx.ru/#/docs/crmapi/v1/requests>). Работает в двух направлениях: **pull** — крон‑воркер периодически читает историю звонков и записи разговоров и пишет их в CDR MikoPBX; **push** — `EventController` принимает входящие webhook‑уведомления ВАТС (реалтайм‑состояния звонков и поиск контактов) и при наличии `ModuleCTIClient` транслирует их в 1С по SOAP. Модуль read‑only по отношению к звонкам MegaPBX (не инициирует и не управляет вызовами). Загруженную историю далее можно выгружать в 1С (см. `getHistory.epf` и REST‑эндпоинт `Lib/RestAPI/GetController.php`).

PSR‑4: `Modules\ModuleMegafonPbx\` → корень репозитория. Зависит от `mikopbx/core >= 2020.2.757` (см. `composer.json`, `module.json`).

Expand All @@ -18,30 +18,46 @@ PSR‑4: `Modules\ModuleMegafonPbx\` → корень репозитория. З

## Архитектура

Три слабо связанные части, объединённые одной моделью настроек `ModuleMegafonPbx`:
Четыре слабо связанные части, объединённые одной моделью настроек `ModuleMegafonPbx`:

1. **Импорт CDR (cron worker)** — `bin/synchCdr.php`.
- Регистрируется в crontab из `Lib/MegafonPbxConf.php::createCronTasks()` с расписанием `*/1 * * * *`.
- Берёт окно `[settings.offset, now]` (по умолчанию −10 дней), запрашивает `https://{host}/crmapi/v1/history/json` и `/crmapi/v1/users` по `X-API-KEY`.
- Регистрируется в crontab из `Lib/MegafonPbxConf.php::createCronTasks()` двумя задачами: основной проход `*/1 * * * *` и реконсиляционный `*/8 * * * * … -- --reconcile`.
- **Два режима одного скрипта** (по `$argv`): `sync` (без аргументов) — окно `[settings.offset, now]` (по умолчанию −10 дней), двигает `offset`; `reconcile` (`--reconcile`) — фиксированное окно последних 24 ч, `offset` НЕ трогает. Reconcile нужен потому, что API ВАТС отдаёт каждую запись истории лишь один раз (в момент появления) и при перечитывании интервала повторно не возвращает — поэтому перекрытие окна от пропусков не защищает; reconcile добирает пропущенное широким запросом. У каждого режима свой flock-файл (`/tmp/megafon_synchCdr_{sync|reconcile}.lock`) — single-instance guard, но sync и reconcile идут параллельно. Идемпотентность CDR — по `UNIQUEID` на стороне ядра.
- Запись скачивается и перекодируется во ВРЕМЕННЫЙ файл с уникальным именем (`pid+uniqid`) в каталоге `Storage::getMonitorDir()/.megafon_tmp/` (та же FS → `rename()` атомарен), затем атомарно `rename()` в финальный путь — на финальном пути не бывает частичных/неперекодированных файлов, поэтому skip-existing (`is_file && filesize>0`, пропуск уже скачанного) безопасен при параллельных sync/reconcile. Осиротевшие temp (после `kill -9`) подчищаются в начале прохода по mtime (>2ч). Запись в temp проверяется на короткую запись (диск полный) до публикации.
- **Дедуп публикации**: локальный кэш опубликованных `UNIQUEID` (`/tmp/megafon_published.json`, TTL 48ч, атомарная запись temp+rename) отсекает повторную отправку строки CDR в beanstalkd ещё до неё — иначе reconcile гнал бы одни и те же `insert_cdr` ~180 раз/сутки на звонок. Потеря кэша при ребуте безвредна (разовая ре-публикация + дедуп ядра по `UNIQUEID`). skip-existing остаётся как второй слой для случая холодного кэша (файл есть, но кэш потерян).
- Запрашивает `https://{host}/crmapi/v1/history/json` и `/crmapi/v1/users` по `X-API-KEY`.
- Маппит пользователей: `users[login] = user[extField] ?? user[telnum]` (см. константы `EXTENSION_FIELD_EXT|TEL` в `Models/ModuleMegafonPbx.php`).
- Для каждой записи синтезирует CDR с фиксированным `from_account = 'fs-megapbx'` и `UNIQUEID/linkedid = "fs-megapbx-{ts}.{uid}"` — этот префикс является единственным способом отличить «свои» строки. Каналы PJSIP формируются как `PJSIP/{ext|megapbx}-{uid}`.
- Запись‑файл скачивается с того же API и кладётся в `Storage::getMonitorDir()/Y/m/d/H/`.
- CDR публикуются батчами по >9 строк в beanstalkd‑тьюб `WorkerCallEvents` с `action = insert_cdr` (insert делает `WorkerCallEvents` ядра MikoPBX, не модуль).
- `settings.offset` (строка `Ymd\THis\Z`) обновляется только если все скачивания записей прошли без ошибок — иначе окно просто перепрочитается на следующей минуте.
- `settings.gap` — сдвиг времени в часах; применяется к `start/answer/endtime`. Для расчёта `startTime` окна используется удвоенный модуль gap при отрицательном значении (см. строки 38–43 в `synchCdr.php`).
- `settings.gap` — сдвиг времени в часах (компенсация TZ между ВАТС и PBX); применяется к `start/answer/endtime` записи и к `startTime` окна (start расширяется назад на gap часов). Прим.: в ветке `else` исходного кода была недостижимая ветка «удвоение модуля gap при отрицательном значении» — `strpos("-3",'-')===0` (falsy) уводил управление всегда в `else`, поэтому удвоение никогда не работало; ветка удалена, реальное поведение не изменилось.

2. **REST‑эндпоинт для внешних потребителей (1С)** — `Lib/RestAPI/GetController.php`.
- Регистрируется через `MegafonPbxConf::getPBXCoreRESTAdditionalRoutes()` как `GET /pbxcore/mega-pbx/cdr`.
- Запрашивает CDR через beanstalkd‑тьюб `WorkerCdr::SELECT_CDR_TUBE` (фильтр `id > offset`, лимит ≤600), пропускает всё, у чего `linkedid` не начинается с `fs-megapbx-`, и отдаёт XML `<history>...</history>`. Возвращает заголовки `X-MIN-OFFSET`/`X-MAX-OFFSET` для пагинации потребителем.

3. **Админ‑UI** — `App/Controllers/ModuleMegafonPbxController.php` + `App/Forms/ModuleMegafonPbxForm.php` + `App/Views/index.volt`.
- Форма редактирует только `authApiKey`, `host`, `gap`, `extField`. Прочие поля модели (`text_area_field`, `password_field`, `integer_field`, `checkbox_field`, `toggle_field`, `dropdown_field`) — наследие шаблона `moduletemplate`, в UI не отображаются, но `saveAction()` их перебирает по именам атрибутов — при добавлении новой колонки в модель достаточно отрендерить её в форме.
3. **Приём webhook от ВАТС (push)** — `Lib/RestAPI/EventController.php`.
- Регистрируется через `MegafonPbxConf::getPBXCoreRESTAdditionalRoutes()` как `POST /pbxcore/mega-pbx/event` (тоже `noAuth = true` — авторизация внутри контроллера, не штатным PBXCoreREST).
- Аутентификация: поле `crm_token` в теле сверяется с `settings.crmToken` через `hash_equals` (несовпадение/пустой токен → `401`). Тело принимается как `x-www-form-urlencoded` или `application/json` — автодетект по `Content-Type` с фоллбэком на оба варианта (`parseRequestBody`).
- Диспетчеризация по полю `cmd` (`eventAction`, всё прочее → `400 unsupported cmd`):
- `cmd=event` — реалтайм‑состояние звонка. Обязательные поля: `type, callid, phone, user, direction` (`direction` ∈ `in|out`). Поле `type` маппится в `state` через `TYPE_TO_STATE`: `INCOMING|OUTGOING → Calling`, `ACCEPTED|TRANSFERRED → Connected`, `COMPLETED|CANCELLED → Finished`; иной `type` → `400 unknown type`.
- `cmd=history` — финальная запись звонка; **намеренно игнорируется** (просто `200 ok`). Историю и mp3 грузит крон‑воркер `bin/synchCdr.php` через `/crmapi/v1/history/json` — push дублировать не нужно.
- `cmd=contact` — поиск клиента по номеру для попап‑карточки на телефоне; делегируется `ModuleCTIClient\Lib\AmigoDaemons::getCallerId()`. Если CTI нет или контакт не найден — отдаёт `{}` (ВАТС трактует как «не найдено», а не ошибку).
- Трансляция в 1С (только для `cmd=event`): при установленном `ModuleCTIClient` сотрудник ВАТС сопоставляется с пользователем 1С по `ext`/`telnum` (режим `settings.userMatchMode`: `USER_MATCH_BY_EXT|MOBILE|BOTH`), и при **однозначном** матче (ровно 1 кандидат) шлётся SOAP `call_event` (`Subject = provider.v1.calls`) на `miko_crm_api.1cws`. Без CTI / при неоднозначном матче событие только логируется, ответ всё равно `200 ok` — чтобы ВАТС не показывала ошибку.
- SOAP‑клиент к 1С — отдельный `GuzzleHttp\Client` (таймаут 10 с, `FileCookieJar`, тихий retry с `IBSession: start` при 400/500). Настройки CTI и список юзеров 1С кешируются в `/tmp/megafon_*.json` (TTL 60/120 с).
- Лог — `Lib\Logger('EventController')` в `Directories::CORE_LOGS_DIR/ModuleMegafonPbx/EventController.log` (ротация 40 MB × 9). `crm_token` в логах усекается до 4 символов.

4. **Админ‑UI** — `App/Controllers/ModuleMegafonPbxController.php` + `App/Forms/ModuleMegafonPbxForm.php` + `App/Views/index.volt`.
- Форма редактирует: `authApiKey`, `host`, `gap` (pull‑импорт), `crmToken` + `userMatchMode` (push‑webhook/1С), `extField`, `recodeRecording`, `excludedNumbers` (см. `App/Forms/ModuleMegafonPbxForm.php`). Прочие поля модели (`text_area_field`, `password_field`, `integer_field`, `checkbox_field`, `toggle_field`, `dropdown_field`) — наследие шаблона `moduletemplate`, в UI не отображаются, но `saveAction()` их перебирает по именам атрибутов — при добавлении новой колонки в модель достаточно отрендерить её в форме.
- `getTablesDescription()` и DataTables‑эндпоинты (`getNewRecordsAction`, `saveTableDataAction`, `deleteAction`, `changePriorityAction`) ссылаются на класс `Modules\ModuleMegafonPbx\Models\PhoneBook`, которого в репозитории нет — это «спящий» каркас под отсутствующую модель; вызовы к таблице `PhoneBook` упадут до момента её добавления.
- JS источник — `public/assets/js/src/module-megafon-pbx-index.js`; собранный артефакт лежит рядом (`module-megafon-pbx-index.js` + `.js.map`). Минификации/бандлинга в этом репо нет — собранный файл коммитится напрямую.

## Гайдлайны для правок

- HTTP к MegaPBX везде идёт через GuzzleHttp\Client с короткими таймаутами (5 с) — сохраняйте этот паттерн, чтобы крон‑воркер не висел.
- HTTP к MegaPBX (pull) везде идёт через GuzzleHttp\Client с короткими таймаутами (5 с) — сохраняйте этот паттерн, чтобы крон‑воркер не висел. SOAP‑клиент `EventController` к 1С — отдельный, с таймаутом 10 с.
- **Webhook‑контракт ВАТС (`EventController`)**: на любую распознанную команду отвечайте `200` даже при внутренней ошибке (нет CTI, юзер не сопоставлен, SOAP упал) — иначе ВАТС показывает ошибку интеграции в ЛК и/или ретраит. Явные `4xx` — только на нарушение контракта запроса (плохой токен, отсутствующие/невалидные поля, неизвестные `cmd`/`type`). Не расширяйте множество `type`/`cmd` молча: оба перечня закрыты (`TYPE_TO_STATE` и `switch` в `eventAction`) и сверяются со спецификацией CRM API v1 (<https://api.megapbx.ru/#/docs/crmapi/v1/requests>).
- `crmToken` — секрет аутентификации входящих webhook; сверяется через `hash_equals` (constant‑time). Не логируйте его целиком (в `EventController::log` он усекается до 4 символов) и не отдавайте наружу.
- Не меняйте формат `UNIQUEID/linkedid` (`fs-megapbx-…`): по нему фильтруют записи REST‑контроллер и (предположительно) внешние потребители; смена формата сломает совместимость с уже импортированными CDR.
- Идемпотентность импорта обеспечивается ключом `UNIQUEID` на стороне ядра MikoPBX (через `WorkerCallEvents`), а не самим модулем.
- Локализация: `Messages/en.php` и `Messages/ru.php`. Часть ключей (`module_megafon_pbxTextField...`) осталась от шаблона и не используется — при чистке убедитесь, что ключ не дёргается из Volt/JS.
Expand Down
Loading
Loading