Заказчик - владелец валютного обменника. Ему нужен был внутренний инструмент для себя и операторов: четыре аккаунта по жёсткому белому списку, публичных пользователей нет. Оператор в Telegram выбирает терминал и способ оплаты, вводит сумму в рублях и идентификатор покупателя, подтверждает, и бот через REST API платёжного провайдера создаёт заявку на обмен фиата в криптовалюту. В ответ приходит карточка с реквизитами для приёма платежа, суммой к оплате, курсом и статусом.
Заказчик нетехнический, срок четыре дня от брифа до боевой эксплуатации, бюджет фиксированный.
Особенность задачи: каждая заявка это реальные деньги, поэтому «повторить запрос на всякий случай» нельзя, а всё необратимое должно быть выключено по умолчанию. Провайдер работает по спецификации OpenAPI 3.0.3, авторизация токеном терминала в заголовке; ответ на создание заявки приходит не мгновенно, потому что провайдер подбирает свободные реквизиты под сумму и способ оплаты.
Диалог как конечный автомат на сессиях grammY: меню из шести кнопок, проверка суммы (целое или с копейками, запятая и точка, лимиты min и max), проверка идентификатора покупателя, отмена на любом шаге кнопкой и командой. Кнопки описаны в JSON-файле: новый терминал это одна строка в файле и одна переменная окружения, без правки кода; несколько кнопок могут работать на одном ключе провайдера, в проекте шесть кнопок на пяти терминалах.
Три режима запуска как защита от дорогих ошибок: инертный (в API не ходим, ответ фейковый), тестовый (реальный запрос, заявка помечена как демо) и боевой; переключаются переменными окружения. Клиент API без автоматических повторов POST: повтор создал бы дубль настоящей финансовой заявки, это сознательное ограничение. Секреты маскируются на уровне логгера, и есть тест, который это доказывает; текст транспортных ошибок наружу не отдаётся. Кнопка подтверждения несёт одноразовый маркер: устаревшая кнопка из прошлого черновика заявку не создаст. Все попытки создания пишутся в JSONL-журнал: время, оператор, терминал, сумма, ID заявки, статус. Два терминала работают по расписанию: бот считает московское время и предупреждает на экране подтверждения, но кнопку не прячет, решение за оператором. 34 кода способов оплаты переведены в понятные русские названия. Заголовки ошибок честные: «Заявка не создана» только при отказе провайдера, «Результат неизвестен» когда ответа не было.
Нетривиальное место: таймаут и сверка. На тестовых заявках ответ приходил за доли секунды, и таймаут HTTP-клиента стоял 15 секунд. На первых боевых заявках ответ пошёл десятками секунд: одна крупная заявка пришла через 19,5 секунды, то есть через 4,5 секунды после того, как бот оборвал соединение. Вывод: обрыв запроса на стороне клиента не отменяет операцию у провайдера. Заявка была создана с настоящими реквизитами, а оператор увидел «результат неизвестен». Худший сценарий для такой системы: покупатель платит по реквизитам, которых никто не видел.
Что сделал. Таймаут поднят до 60 секунд и вынесен в переменную окружения. В каждую заявку добавляется собственный маркер в служебное текстовое поле запроса. Если ответ не пришёл, бот трижды, через 3, 8 и 15 секунд, перечитывает список заявок терминала и ищет свою по маркеру. Сверять по сумме нельзя: провайдер сам немного меняет сумму к оплате, чтобы различать платежи, а идентификатор покупателя в ответе не возвращается. Оператор получает один из трёх честных ответов: «заявка найдена сверкой» с полной карточкой, «новой заявки нет, можно повторять» или «список прочитать не удалось, проверь кабинет». Пока идёт сверка, оператор видит сообщение, что провайдер отвечает дольше обычного. Запасной путь, если провайдер не сохранит маркер: одна свежая заявка в окне считается своей, несколько непомеченных бот не угадывает и признаёт неопределённость. Сетевой обрыв сверкой не проверяется: запрос до провайдера не дошёл. По сути это идемпотентность там, где повтор запроса это реальные деньги: не повторы, а сверка состояния по своему маркеру.
Инфраструктура. Docker multi-stage, docker compose с перезапуском unless-stopped, паузой на остановку 30 секунд и ротацией логов по 10 МБ. GitHub Actions: проверки типов, линта и тестов, затем деплой по SSH с пересборкой образа и проверкой, что контейнер поднялся, иначе деплой падает громко. Сервер приводится к ветке жёстким сбросом, ручные правки невозможны. Секреты только в окружении сервера, в репозитории файл-пример с плейсхолдерами. Минимальный VPS на Ubuntu: SSH только по ключу, fail2ban, обновления ядра. Long polling, входящие порты не открываются.
Стек: Node.js 20+ (в проде node:22-alpine), TypeScript 6 в strict-режиме, чистый ESM, grammY 1.46, pino 10, dotenv 17, Vitest 5, ESLint 10 с typescript-eslint 8, Docker, GitHub Actions.
Бот в боевой эксплуатации через четыре дня после брифа: разведка API, разработка и запуск уложились в срок при фиксированном бюджете.
Заявки создаются без дублей: один POST без повторов, одноразовые кнопки подтверждения, сверка по маркеру после таймаута. После починки таймаута ни одна заявка не остаётся невидимой для оператора: либо карточка, либо честное «можно повторять», либо «проверь кабинет».
17 файлов исходников, около 1560 строк, и 10 файлов тестов, около 1090 строк: 118 автотестов в Vitest, тестов почти столько же, сколько кода. Секреты в логах замаскированы, и это проверено тестом.
Два документа: техническое README и отдельная инструкция для нетехнического владельца: как поменять ключ, добавить оператора, добавить кнопку, перезапустить бота, где смотреть журнал. Сервер подготовлен к передаче: парольный вход по SSH отключён, fail2ban, обновления применены.