# Внутренние сервисы

# SMS-шлюз: beeline-service

*Для разработчиков. Состояние на 28.09.2026. Переменные, все ручки и выкатка по шагам — README репозитория `Buharestskie/beeline-service`. Как лендинги работают целиком — статья о квиз-лендингах в этой же книге.*

## Коротко

`beeline-service` — общий шлюз исходящих SMS. Сервис присылает номер и готовый текст, шлюз проверяет, что номер российский мобильный, применяет антиспам-лимиты и отдаёт сообщение Билайну (A2P) или хабу МегаФона. Шаблонов у шлюза нет: текст целиком приходит от вызывающего. Снаружи шлюз доступен по адресу `https://donula26.ru/service/beeline`, пускает только адреса из своего списка доступа.

## Кто вызывает

Сейчас — три квиз-лендинга, которые подтверждают телефон посетителя кодом из SMS («Ваш код подтверждения: 1234»):

<table id="bkmrk-%D0%9B%D0%B5%D0%BD%D0%B4%D0%B8%D0%BD%D0%B3%D0%A7%D1%82%D0%BE-%D0%BF%D0%B5%D1%80%D0%B5%D0%B4%D0%B0%D1%91%D1%82-"><thead><tr><th>Лендинг</th><th>Что передаёт</th></tr></thead><tbody><tr><td>`do-nula-refinancing-2`</td><td rowspan="3">`phone`, `message`, `ip` посетителя. `lead_id` не передаёт никто</td></tr><tr><td>`bfl_fspp`</td></tr><tr><td>`mfcspisan` (на стороннем сервере)</td></tr></tbody></table>

`mfctest` шлюз не вызывает: SMS-подтверждения у него нет, после телефона посетитель уходит в мессенджер. Все вызывающие ходят через nginx по публичному адресу, поэтому адрес каждого нового вызывающего нужно заранее внести в `ALLOWED_CALLER_CIDRS`, иначе он получит `403 Caller IP is not permitted.`

## Как устроен

- **Один процесс FastAPI** (Python 3.12, httpx, APScheduler) и свой Redis в том же compose. Redis нужен только для лимитов. Базы нет, тестов нет.
- **Номер приводится к `+7XXXXXXXXXX`.** Принимаются `+7…`, `8…` и десять цифр с пробелами, скобками и дефисами. Всё, что не складывается в десять цифр с первой девяткой, получает `400` «Введите правильный российский номер» — до лимитов и до провайдера.
- **Четыре лимита** проверяются по порядку, каждый отвечает `429` с заголовком `Retry-After`: <table><thead><tr><th>Лимит</th><th>Порог</th><th>Когда действует</th></tr></thead><tbody><tr><td>Повтор на тот же номер</td><td>1 SMS в 60 секунд</td><td>только если передан `ip`</td></tr><tr><td>Разных номеров с одного `ip`</td><td>3 за 24 часа</td><td>только если передан `ip`</td></tr><tr><td>SMS на один номер</td><td>3 за 24 часа</td><td>всегда</td></tr><tr><td>Всего SMS</td><td>1000 за сутки по UTC</td><td>всегда</td></tr></tbody></table>
    
    `ip` — адрес конечного посетителя, который видел бэкенд лендинга, а не адрес самого лендинга. Пороги — константы в `app/constants.py`, меняются только вместе с кодом.
- **Отправитель** в обоих каналах — `donulya.ru`. Вызывающий получает `202` и сырой ответ провайдера строкой в `provider_response`. Ошибка провайдера — `502`.
- **Примечание в amoCRM.** Если в запросе есть `lead_id`, после ответа фоновой задачей в сделку добавляется примечание «исходящая SMS» с номером и текстом. Шлюз пишет его своим токеном напрямую, не через `amocrm-service` и не через `amo_queue`, со своим ограничением 6 запросов в секунду. Ошибка примечания только пишется в лог.

## Билайн или МегаФон

SMS уходит в хаб МегаФона, только если выполнены оба условия:

1. на странице настроек шлюза включён переключатель «Использовать сервис Мегафон»;
2. номер по плану нумерации выделен МегаФону: таблица диапазонов лежит в `app/services/megafon_ranges.json` (25 DEF-кодов) и читается один раз при старте.

Всё остальное идёт через Билайн. По умолчанию переключатель выключен, то есть без ручного включения все SMS уходят Билайну.

- **Страница настроек** — `https://donula26.ru/service/beeline/megafon-sms-settings`: переключатель и кнопка «Сохранить». Нужны одновременно адрес из списка доступа и отдельный логин и пароль страницы (`MEGAFON_SETTINGS_PAGE_LOGIN` / `MEGAFON_SETTINGS_PAGE_PASSWORD`, к учётке МегаФона отношения не имеют).
- **Первого числа каждого месяца в 00:00 по Москве** переключатель выключается сам. Если в этот момент сервис лежал, выключение сработает при старте в тот же день.
- **Оператор определяется без учёта переноса номера.** Номер, ушедший от МегаФона к другому оператору, всё равно пойдёт через МегаФон, и наоборот. Скрипта для обновления диапазонов в репозитории нет.

## Как вызвать

```
curl -X POST https://donula26.ru/service/beeline/sms/send \
  -H 'Content-Type: application/json' \
  -d '{"phone": "+7 000 000-00-01", "message": "Ваш код подтверждения: 4821", "ip": "<ip посетителя>"}'
```

<table id="bkmrk-%D0%9F%D0%BE%D0%BB%D0%B5%D0%9E%D0%B1%D1%8F%D0%B7%D0%B0%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D0%BE%D0%A7%D1%82%D0%BE-%D1%8D"><thead><tr><th>Поле</th><th>Обязательно</th><th>Что это</th></tr></thead><tbody><tr><td>`phone`</td><td>да</td><td>Российский мобильный в любом написании, 5–32 символа</td></tr><tr><td>`message`</td><td>да</td><td>Текст, 1–1000 символов, уходит провайдеру как есть</td></tr><tr><td>`ip`</td><td>нет</td><td>Адрес посетителя. Без него не действуют лимит повтора и лимит номеров на IP</td></tr><tr><td>`lead_id`</td><td>нет</td><td>Сделка amoCRM для примечания об SMS</td></tr></tbody></table>

Проверка живости — `GET /sms/health`, открыта без списка доступа, отвечает `{"status":"ok"}`.

## Где код и как выкатывается

- Репозиторий `Buharestskie/beeline-service`, ветка `main`.
- На сервере — git-клон `main`: `git pull --ff-only` и `docker compose up -d --build`. Контейнер опубликован только на локальном интерфейсе сервера, снаружи — через nginx с префиксом `/service/beeline/`.
- Внешней пробы в мониторинге у шлюза нет, поэтому окно обслуживания на выкатку объявить не на что.
- Локально: `cp .env.example .env`, заполнить, поднять Redis и `uvicorn app.main:app --port 8001`. С боевыми ключами SMS уйдут на настоящие номера.

## На что наступают

- **После выкатки переключатель МегаФона снова выключен.** Его состояние хранится в файле внутри контейнера, тома под него нет. Если маршрут был включён — перед выкаткой проверьте его (`GET /megafon-sms-settings/api`) и после неё включите заново на странице настроек.
- **`202` от шлюза не значит, что МегаФон принял SMS.** Хаб отвечает HTTP 200 и при отказе, вердикт лежит в теле. Шлюз такой отказ только пишет в лог. Смотрите в `provider_response`: у МегаФона там `error.code`, `0` — принято.
- **Передавайте номер всегда в одном формате.** Лимиты считаются по строке `phone` как её прислали: `+79000000001` и `8 900 000-00-01` — это разные счётчики.
- **Счётчики растут и на отказах.** Если сработал другой лимит или провайдер вернул `502`, повторить отправку на тот же номер можно только через 60 секунд, а суточный счётчик номера уже увеличен.
- **Суточный лимит в 1000 SMS обнуляется в 03:00 по Москве** (сутки считаются по UTC), а ежемесячный сброс МегаФона срабатывает в 00:00 по Москве.
- **Адрес шлюза в `.env.example` лендингов неправильный.** У `bfl_fspp` и `mfcspisan` там `/services/beeline` с «s», у `do-nula-refinancing-2` — адрес внутри чужой docker-сети. Рабочий адрес один: `https://donula26.ru/service/beeline`.
- **Страница настроек локально не загрузит состояние:** её скрипт ходит на боевой путь `/service/beeline/megafon-sms-settings/api`. Локально переключайте маршрут через `curl` на `/megafon-sms-settings/api`.
- **Примечания с `lead_id` нагружают общий лимит amoCRM** мимо общего шлюза. Если начнёте передавать `lead_id` массово, учитывайте это вместе с остальными сервисами (см. «Как ходить в amoCRM из своего сервиса»).

# Воронка найма: hiring_project

*Для разработчиков. Состояние на 28.09.2026. Переменные, все ручки, порядок выкатки и отката — README и RUNBOOK репозитория `Buharestskie/hiring_project`.*

## Коротко

`hiring_project` — воронка найма менеджеров по продажам («охотников») для самой компании: от входа кандидата на сайт до выхода на обучение. Кандидат входит через Telegram, VK или MAX, заполняет анкету и записывает голосовой ответ. Голос оценивает Gemini, решение принимает РОП прямо в Google Sheet, дальше кандидату приходит сообщение в мессенджер, приглашение на групповое собеседование в Телемосте и ссылка на онлайн-обучение. В amoCRM кандидат попадает только после собеседования.

Сейчас сервис на наших серверах не развёрнут: проект передан новому владельцу и ждёт переезда. Домен `hiring.donula.online`, заложенный в целевую схему, пока не настроен. Внешней пробы в мониторинге нет.

## Путь кандидата

1. **Вход.** Сайт запрашивает одноразовый токен (`/api/auth/token`, живёт 10 минут) и открывает бота в выбранном мессенджере. Кандидат отправляет боту телефон, сайт опрашивает `/api/auth/check` и получает имя и телефон из мессенджера, а не из формы.
2. **Анкета.** `/api/submit` принимает анкету и голос (`audio/webm`, `audio/mp4`, `audio/m4a`, от 1 KiB до 10 MiB). Анкета сначала пишется в журнал на диске, затем строкой в лист «Кандидаты» через Apps Script. Повтор с тем же `request_id` вторую строку не создаёт.
3. **Оценка.** Бэкенд отправляет голос в Gemini (через ProxyAPI): пять критериев от 1 до 5. 20 баллов и выше — PASS, кандидату сразу уходит приглашение на собеседование; 10–19 — GRAY, решает РОП; меньше 10 — FAIL, кандидату уходит отказ. Если модель недоступна, ставится GRAY с пометкой «AI недоступен, слушайте вручную».
4. **Решение РОПа** — в таблице: столбец T («Одобрен» / «Отклонён») и W — итог собеседования («Принят» / «Отклонён» / «Не пришел»). Apps Script складывает изменения в лист «Webhook Outbox» и доставляет их в бэкенд подписанным запросом на `/api/webhook/rop`; бэкенд пишет кандидату.
5. **Собеседование** — ближайший понедельник, среда или пятница не раньше чем через сутки, в 12:00 по Москве, в Телемосте. Напоминания — за 24 часа и за час.
6. **Обучение.** После «Принят» создаются контакт и сделка `Найм: <имя>` в воронке найма amoCRM, кандидат получает персональную ссылку на курс (живёт 90 дней). Сайт обучения отмечает визит и прохождение, сделка двигается только вперёд: принят → зашёл на обучение → готов к ролевке. После курса открывается ссылка в закрытое сообщество.

## Как устроен

- **Бэкенд** — один процесс FastAPI на Python 3.12 (`runtime/backend/`). Состояние — в каталоге `DATA_DIR`: SQLite для переписки, JSON-файлы для соответствия чатов, записей на собеседование и токенов amoCRM, и журнал анкет. Журнал одновременно служит очередью эффектов: после рестарта недоделанное доигрывается.
- **Google Sheet и Apps Script** (`runtime/apps-script/google-apps-script-hiring.js`) — рабочее место РОПа: листы «Кандидаты», «Обучение», «Трафик», «Расписание МР», «Дашборд», «Системное», «Webhook Outbox». Скрипт выкатывается вручную в редакторе таблицы.
- **Мессенджеры** — своя реализация, не общая с `messenger_connector`: Telegram через long polling, VK и MAX через вебхуки.
- **Inbox** (`runtime/sites/inbox`) — общая лента переписки со всеми кандидатами из трёх мессенджеров и ответ из веб-интерфейса.
- **Фоновые циклы внутри процесса:** напоминания, вечерняя сводка РОПу в Telegram-группу в 20:00 и утренняя в 09:00 по Москве.
- **Статика** `runtime/sites/{auth,inbox,training}` отдаётся nginx и обращается к бэкенду по относительным `/api/…` — открывать её нужно с того же адреса, что и бэкенд.

## Где код и как запустить

- Репозиторий `Buharestskie/hiring_project`. Документы о передаче проекта (`STATUS.md`, `TRANSFER.md`, `ROUTE_MAP.md`) описывают старое размещение и устарели.
- Офлайн-тесты (внешняя сеть заблокирована, нужен ещё Node.js для тестов Apps Script): `bash runtime/tests/run_all.sh` после установки `runtime/backend/requirements.lock` в venv.
- Бэкенд локально — `uvicorn main:app` из `runtime/backend` с явными `DATA_DIR`, `DISABLE_SCHEDULED=true` и `TG_POLLING_ENABLED=false`. Без явного `DATA_DIR` запуск упадёт на правах доступа. `/api/submit` без настоящего Apps Script тестовой таблицы отвечает `503`.
- Целевая выкатка — релизными архивами: `scripts/build-release.sh` собирает архив, `scripts/deploy-release.sh` раскладывает его на хост и переключает симлинк, при провале `/health` возвращает предыдущий; `scripts/rollback-release.sh` — откат. systemd-юниты и шаблон nginx — в `infra/`. Когда сервис будет развёрнут, сначала завести внешнюю пробу, потом выкатывать в окне обслуживания.

## На что наступают

- **Только один Telegram-поллер и один планировщик.** Второй процесс на тех же токенах без `DISABLE_SCHEDULED=true` и `TG_POLLING_ENABLED=false` даёт дубли сообщений кандидатам.
- **`DISABLE_SCHEDULED` не выключает эффекты анкеты.** Оценка, сообщение кандидату и уведомление РОПу запускаются прямо из `/api/submit`. Стенд с боевыми токенами пишет живым людям.
- **Утренняя сводка включена по умолчанию.** Если `ENABLE_ROP_MORNING_NOTIFICATION` не задана, она уходит, хотя по решению проекта выключена.
- **Сессии входа живут в памяти.** Рестарт сбрасывает незавершённые входы кандидатов и начатую авторизацию amoCRM. Токен входа удаляется через 10 минут, поэтому напоминания «через 30/60 минут» почти не срабатывают.
- **Строки в «Кандидатах» не удалять и не сортировать.** Строки находятся по скрытому `request_id` в столбце X. Листы «Системное» и «Webhook Outbox» руками не редактируются.
- **Триггер Apps Script должен быть installable** и работает от имени создателя. Потерял аккаунт доступ — решения РОПа копятся в «Webhook Outbox» и не уходят.
- **Поток GRAY с пометкой «AI недоступен»** — почти всегда кончился ключ или баланс ProxyAPI.
- **Сделку в amoCRM бэкенд ищет по названию** `Найм: <имя>` (первый результат) — однофамильцы могут перепутаться. Ошибка создания сделки только логируется.
- **MAX:** `user_id` не равен `chat_id`, соответствие хранится в JSON-файле в `DATA_DIR`. Потеря файла ломает доставку в MAX.
- **Запись голоса во встроенном браузере мессенджера не работает** — сайт просит открыть ссылку в Safari или Chrome.
- **`build-release.sh` не проверяет чистоту дерева** (проверка унаследована из монорепозитория и смотрит не туда): незакоммиченные правки попадут в архив под чужим SHA. Перед сборкой — `git status`.
- **`runtime/backend/Dockerfile`, `backup_sheets.sh` и `legacy/`** — наследие старого размещения, целевая схема их не использует.