MAX-бот для приёма
Я сделал собственного MAX-бота для приёма заявок и FAQ по услугам. Не просто «бота, который отвечает привет», а рабочий инструмент: человек открывает чат, выбирает тему, описывает задачу, оставляет контакт, указывает срочность и подтверждает заявку.
Рабочее название проекта в коде: Krivoshein Code Support. В MAX бот отображается как Krivoshein_Bot_support. Смысл простой: убрать хаос на входе. Вместо переписки в стиле «что случилось?», «какой сайт?», «а контакт?», «а срочно?» бот собирает всё по шагам.
Это именно MAX-бот. Не Telegram, не aiogram, не копия Telegram Bot API с переклеенной вывеской. В проекте используется собственный клиент MAX API, FastAPI webhook, SQLite и самописная FSM-логика.
Открыть бота можно здесь: Krivoshein_Bot_support в MAX.
Код проекта: github.com/A-Krivoshen/krivoshein-code-support.
Зачем я сделал этого бота
Когда работаешь с сайтами, WordPress, рекламой, серверами и ботами, большинство первых обращений похожи друг на друга. Человек пишет: «нужна помощь», а дальше начинается ручной сбор данных.
Что именно нужно сделать? Это сайт, реклама, сервер, плагин, бот, форма, ошибка WordPress? Насколько срочно? Какой контакт для связи? Есть ли описание задачи? Есть ли доступы, скриншоты, домен, ссылка?
Вручную это быстро съедает время. Причём не на решение задачи, а на подготовку к её решению. Поэтому я решил сделать MAX-бота, который принимает первичную заявку по сценарию.
Бот не заменяет человека. Он делает скучную, но полезную работу: задаёт правильные вопросы в правильном порядке. А я уже получаю более понятное обращение и могу быстрее оценить, что делать дальше.
Что умеет текущая версия
Сейчас бот уже работает как MVP. Он не пытается быть CRM, helpdesk-системой и центром управления полётами. Он решает одну задачу: принять обращение и отправить его в админ-канал.
- показывает главное меню;
- открывает раздел FAQ;
- принимает заявку по шагам;
- спрашивает тему обращения;
- принимает описание задачи;
- проверяет контакт, телефон или email;
- даёт выбрать срочность;
- показывает сводку перед отправкой;
- отправляет заявку администратору;
- позволяет отменить заявку;
- возвращает пользователя в главное меню.
Это звучит просто, но именно такие простые сценарии чаще всего и приносят пользу. Не надо сразу строить гигантскую систему. Сначала пусть бот стабильно принимает заявки и не падает от кнопки «Назад в меню».
Почему не Telegram и не aiogram
Здесь важный момент. В черновиках легко по привычке написать «Telegram-бот» или «aiogram», потому что вокруг Telegram уже огромная экосистема. Но этот проект сделан под MAX.
В зависимостях проекта нет aiogram. Используются другие библиотеки: FastAPI, Uvicorn, httpx, aiosqlite, pydantic-settings и python-dotenv.
httpx pydantic-settings python-dotenv aiosqlite fastapi uvicorn
То есть архитектура такая: MAX отправляет события на webhook, FastAPI принимает JSON, BotRouter разбирает событие, FSM понимает текущий шаг пользователя, SQLite хранит состояние, а MaxApiClient отправляет ответы обратно в MAX.
Если совсем коротко:
MAX → webhook → FastAPI → BotRouter → FSM → SQLite → MAX API
Это честнее, чем притворяться, что любой бот в мире автоматически Telegram-бот. MAX API другой, события другие, клавиатуры другие, значит и слой работы с API должен быть свой.
Структура проекта
Проект небольшой, но уже разделён на нормальные слои. Это удобно: API-клиент не смешан с FSM, тексты не размазаны по обработчикам, а база не живёт где-то между кнопками и логами.
krivoshein-code-support/ ├── app/ │ ├── bot/ │ │ ├── faq.py │ │ ├── keyboards.py │ │ ├── router.py │ │ ├── states.py │ │ └── texts.py │ ├── max_api/ │ │ ├── client.py │ │ ├── exceptions.py │ │ └── types.py │ ├── tickets/ │ │ ├── models.py │ │ ├── service.py │ │ └── storage.py │ ├── config.py │ ├── db.py │ ├── main.py │ └── webhook.py ├── scripts/ │ └── register_webhook.py ├── data/ ├── requirements.txt └── .env.example
Основные части проекта:
app/max_api/отвечает за работу с MAX API.app/bot/содержит роутер, состояния, клавиатуры, тексты и FAQ.app/tickets/хранит модели заявки, форматирование и работу с SQLite.app/db.pyсоздаёт подключение к базе и таблицы.app/webhook.pyподнимает FastAPI-приложение и принимает события.scripts/register_webhook.pyрегистрирует webhook в MAX API.
Для MVP такая структура нормальная. Код ещё можно держать в голове, но он уже не выглядит как один файл на тысячу строк, который потом страшно открыть после отпуска.
Настройки через .env
Все важные настройки вынесены в переменные окружения. Это токен бота, путь к базе, уровень логирования, id админ-канала и URL webhook.
MAX_BOT_TOKEN= DATABASE_PATH=data/bot.sqlite3 LOG_LEVEL=INFO ADMIN_CHANNEL_ID= WEBHOOK_PATH=/webhook WEBHOOK_URL=https://support.krivoshein.site/webhook
В коде настройки читаются через pydantic-settings:
from pydantic_settings import BaseSettings from pydantic import Field class Settings(BaseSettings): max_bot_token: str = Field(..., alias="MAX_BOT_TOKEN") database_path: str = Field(default="data/bot.sqlite3", alias="DATABASE_PATH") log_level: str = Field(default="INFO", alias="LOG_LEVEL") admin_channel_id: int | None = Field(default=None, alias="ADMIN_CHANNEL_ID") webhook_path: str = Field(default="/webhook", alias="WEBHOOK_PATH") webhook_url: str = Field( default="https://support.krivoshein.site/webhook", alias="WEBHOOK_URL", ) model_config = { "env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore", } settings = Settings()
Рабочий .env в репозиторий, конечно, не кладётся. Токены и секреты должны жить на сервере, а не в публичном GitHub. Да, звучит очевидно, но интернет видел такие утечки, что уже ничему не удивляется.
Клиент MAX API
Для общения с MAX API сделан отдельный клиент на httpx. Это хороший слой абстракции: роутер бота не должен знать, как именно отправляется HTTP-запрос, какие там заголовки, base URL и как парсить ответ.
Упрощённый фрагмент клиента:
class MaxApiClient: def __init__(self, token: str, base_url: str = "https://platform-api.max.ru") -> None: self.token = token self.base_url = base_url.rstrip("/") self.http = httpx.AsyncClient( base_url=self.base_url, headers={"Authorization": token} if token else {}, timeout=30.0, ) async def get_me(self) -> BotInfo: payload = await self._request("GET", "/me") return self._parse_bot_info(payload) async def register_webhook(self, url: str, update_types: list[str]) -> dict[str, Any]: return await self._request( "POST", "/subscriptions", json={"url": url, "update_types": update_types}, ) async def get_webhook_subscriptions(self) -> dict[str, Any]: return await self._request("GET", "/subscriptions") async def send_message( self, chat_id: int, text: str, reply_markup: ReplyMarkup | None = None, ) -> SendMessageResponse: body: dict[str, Any] = {"text": text} attachments = self._build_attachments(reply_markup) if attachments: body["attachments"] = attachments payload = await self._request( "POST", "/messages", params={"chat_id": chat_id}, json=body, ) return self._parse_send_message_response(payload)
В этом клиенте есть несколько ключевых методов:
get_me()проверяет токен и получает данные бота;register_webhook()регистрирует webhook;get_webhook_subscriptions()показывает текущие подписки;send_message()отправляет сообщение пользователю или в админ-канал.
Если MAX API поменяется, править нужно будет в основном этот слой. Это лучше, чем искать HTTP-запросы по всему проекту, как потерянный include в старой WordPress-теме.
FastAPI webhook
Боевой режим работает через webhook. MAX отправляет событие на публичный HTTPS-адрес, а FastAPI принимает его и передаёт в роутер.
В app/webhook.py при старте приложения выполняется инициализация:
- настраивается логирование;
- создаётся
MaxApiClient; - открывается SQLite;
- создаются таблицы;
- проверяется токен через
get_me(); - создаётся
BotRouter.
@asynccontextmanager async def lifespan(application: FastAPI): setup_logging(settings.log_level) client = MaxApiClient(settings.max_bot_token) db = await connect_db(settings.database_path) try: await init_db(db) bot = await client.get_me() logger.info("Бот подключён: %s (user_id=%s)", bot.name, bot.user_id) except MaxApiError as exc: await client.aclose() await db.close() logger.error("Не удалось проверить MAX API при старте: %s", exc) raise RuntimeError("MAX API token check failed") from exc application.state.db = db application.state.max_client = client application.state.router = BotRouter(client, TicketStorage(db)) yield await client.aclose() await db.close()
Сам обработчик webhook короткий:
@application.post(settings.webhook_path) async def receive_update(request: Request) -> dict[str, bool]: try: update = await request.json() except json.JSONDecodeError as exc: raise HTTPException(status_code=400, detail="Invalid JSON body") from exc if not isinstance(update, dict): raise HTTPException(status_code=400, detail="Update must be a JSON object") router: BotRouter = request.app.state.router try: await router.handle_update(update) except Exception: logger.exception("Ошибка обработки апдейта: update_type=%s", update.get("update_type")) return {"ok": True}
Логика здесь простая: принять JSON, проверить, что это объект, передать в роутер. Если внутри сценария случилась ошибка, она логируется, но наружу всё равно возвращается {"ok": true}. Иначе внешний сервис может начать повторять одно и то же событие, а нам не нужен маленький DDOS из собственных ошибок.
Ещё есть healthcheck:
@application.get("/health") async def health() -> dict[str, str]: return {"status": "ok"}
Проверяется он так:
curl -i https://support.krivoshein.site/health
Регистрация webhook в MAX
Webhook нужно зарегистрировать в MAX API. Для этого в проекте есть отдельный скрипт:
python -m scripts.register_webhook
Это лучше, чем регистрировать webhook при каждом старте приложения. Обычно подписку надо обновлять только после изменения URL, домена или списка событий.
Типы событий, которые нужны боту:
DEFAULT_UPDATE_TYPES = [ "bot_started", "message_created", "message_callback", ]
На сервере я запускал регистрацию от отдельного пользователя:
cd /opt/krivoshein-code-support sudo -u bot bash -c ' source .venv/bin/activate python -m scripts.register_webhook '
На скриншоте видно, что webhook зарегистрирован на адрес:
https://support.krivoshein.site/webhook
А подписка принимает события:
message_callback bot_started message_created
Если webhook не зарегистрирован, бот может быть запущен, healthcheck может отвечать, Nginx может работать, но события от MAX приходить не будут. Поэтому после деплоя это один из первых пунктов проверки.
FSM: как бот понимает, на каком шаге пользователь
Для заявки используется конечный автомат состояний. Без FSM бот быстро превратился бы в кашу: пользователь нажал кнопку, потом написал текст, потом передумал, потом вернулся назад, потом снова начал заявку.
Состояния описаны так:
from enum import StrEnum class TicketState(StrEnum): IDLE = "idle" TICKET_TOPIC = "ticket_topic" TICKET_DESCRIPTION = "ticket_description" TICKET_CONTACT = "ticket_contact" TICKET_URGENCY = "ticket_urgency" TICKET_CONFIRM = "ticket_confirm"
Маршрут заявки:
Главное меню → выбор темы → описание задачи → контакт → срочность → подтверждение → отправка администратору → возврат в главное меню
Для пользователя всё выглядит спокойно: кнопка, вопрос, ответ, следующий шаг. А внутри на каждом шаге бот сохраняет текущее состояние и черновик заявки в SQLite.
Модель черновика заявки
Черновик заявки описан через Pydantic-модель. Там всего четыре поля: тема, описание, контакт и срочность.
class TicketDraft(BaseModel): topic: str = "" description: str = "" contact: str = "" urgency: str = "" class TicketSession(BaseModel): chat_id: int state: TicketState = TicketState.IDLE draft: TicketDraft = Field(default_factory=TicketDraft)
Пока пользователь не нажал «Отправить», это именно черновик. Его можно отменить, перезаполнить или довести до подтверждения. После успешной отправки сессия удаляется.
Это важный момент. Не каждое начатое обращение должно становиться настоящей заявкой. Иначе база быстро превратится в кладбище недописанных «Здравствуйте, у меня…».
SQLite: где хранится состояние
Для MVP используется SQLite. Сейчас база хранит активные пользовательские сессии в таблице ticket_sessions.
CREATE TABLE IF NOT EXISTS ticket_sessions ( chat_id INTEGER PRIMARY KEY, state TEXT NOT NULL, topic TEXT NOT NULL DEFAULT '', description TEXT NOT NULL DEFAULT '', contact TEXT NOT NULL DEFAULT '', urgency TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL );
Подключение к базе:
async def connect_db(database_path: str | Path) -> aiosqlite.Connection: path = Path(database_path) path.parent.mkdir(parents=True, exist_ok=True) connection = await aiosqlite.connect(path) await connection.execute("PRAGMA foreign_keys = ON") await connection.execute("PRAGMA busy_timeout = 5000") return connection
Сохранение сессии сделано через upsert: если запись уже есть, она обновляется, если нет, создаётся новая.
async def save_session(self, session: TicketSession) -> None: await self._db.execute( """ INSERT INTO ticket_sessions ( chat_id, state, topic, description, contact, urgency, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(chat_id) DO UPDATE SET state = excluded.state, topic = excluded.topic, description = excluded.description, contact = excluded.contact, urgency = excluded.urgency, updated_at = excluded.updated_at """, ( session.chat_id, session.state.value, session.draft.topic, session.draft.description, session.draft.contact, session.draft.urgency, datetime.now(UTC).isoformat(), ), ) await self._db.commit()
Удаление сессии:
async def delete_session(self, chat_id: int) -> None: await self._db.execute("DELETE FROM ticket_sessions WHERE chat_id = ?", (chat_id,)) await self._db.commit()
Для текущей задачи SQLite хватает. Бот не обрабатывает тысячи заявок в секунду, а состояние простое. Поднимать PostgreSQL ради нескольких полей можно, но это уже будет немного DevOps-театром.
BotRouter: центральная логика бота
BotRouter принимает update от webhook и решает, что делать дальше. Он не отправляет HTTP-запросы напрямую и не хранит базу сам. Его задача: маршрутизация событий и управление FSM.
Основные типы событий:
bot_started: пользователь запустил бота;message_created: пользователь отправил сообщение;message_callback: пользователь нажал кнопку.
Упрощённый обработчик:
async def handle_update(self, update: dict[str, Any]) -> None: update_type = update.get("update_type", "unknown") chat_id = extract_chat_id(update) self.logger.info("Получен апдейт: update_type=%s, chat_id=%s", update_type, chat_id) if update_type == "bot_started": await self._handle_bot_started(chat_id) return if update_type == "message_created": await self._handle_message_created(update, chat_id) return if update_type == "message_callback": await self._handle_message_callback(update, chat_id) return self.logger.debug("Апдейт %s пока не обрабатывается", update_type)
Роутер также извлекает chat_id, текст сообщения и payload кнопки. В MAX это важно, потому что обычное сообщение и callback-событие имеют разную структуру.
Главное меню и кнопки
В боте есть главное меню. Из него пользователь может подать заявку, открыть FAQ, перейти к документации или выбрать раздел «Другое».
def get_main_menu() -> dict[str, Any]: return { "type": "inline_keyboard", "payload": { "buttons": [ [callback_button("Подать заявку", MENU_TICKET)], [callback_button("Частые вопросы", MENU_FAQ)], [callback_button("Документация", MENU_DOCS)], [callback_button("Другое", MENU_OTHER)], ], }, }
Темы заявки задаются отдельным словарём:
TICKET_TOPIC_LABELS = { TICKET_TOPIC_SUPPORT: "Техподдержка", TICKET_TOPIC_WEBSITE: "Разработка сайта", TICKET_TOPIC_ADS: "Контекстная реклама", TICKET_TOPIC_OTHER: "Другое", }
Срочность тоже выбирается кнопками:
TICKET_URGENCY_LABELS = { TICKET_URGENCY_NORMAL: "Обычная", TICKET_URGENCY_URGENT: "Срочно", TICKET_URGENCY_VERY_URGENT: "Очень срочно", }
Кнопки сильно упрощают жизнь пользователю. Особенно в мобильном интерфейсе. Там не хочется писать длинные команды, хочется нажать понятную кнопку и идти дальше.
FAQ: быстрые ответы до заявки
Отдельный блок бота, это FAQ. Он нужен, чтобы человек мог быстро получить ответ до оформления заявки. Например, сколько стоит сайт, какие сроки разработки, как проходит диагностика, что входит в поддержку, можно ли заказать настройку VPS или разработку бота.
На скриншоте видно FAQ-меню с кнопками по основным услугам. Это удобно: человек не ищет страницу на сайте, не пишет отдельное сообщение, а сразу выбирает нужную тему в чате.
Обработка FAQ устроена просто:
async def _handle_faq_answer(self, chat_id: int, payload: str) -> None: question, answer = FAQ_ANSWERS[payload] self.logger.info("FAQ вопрос от chat_id=%s: %s", chat_id, question) await self._send_message( chat_id, f"{question}\n\n{answer}", reply_markup=get_faq_back_keyboard(), )
Это не нейросетевая поддержка. И это хорошо. Для типовых вопросов лучше заранее написанный точный ответ, чем фантазия модели, которая сегодня пообещает сайт за три часа и бесплатный сервер в подарок. Не надо так.
Проверка контакта
После описания задачи бот просит контакт для связи. Сейчас принимается email или телефон. Проверка простая, но для MVP её достаточно.
_EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") _PHONE_RE = re.compile(r"^\+?[\d\s\-()]{7,20}$") def is_valid_contact(value: str) -> bool: normalized = value.strip() if not normalized: return False if _EMAIL_RE.match(normalized): return True digits = re.sub(r"\D", "", normalized) return len(digits) >= 10 and _PHONE_RE.match(normalized) is not None
Если контакт не проходит проверку, бот не переводит пользователя дальше. Это лучше, чем получить заявку с контактом «ну ты меня найди» и потом играть в детектива.
Отправка заявки в админ-канал
Когда пользователь дошёл до подтверждения, бот формирует итоговую заявку и отправляет её в админ-канал. В сообщении есть тема, срочность, chat_id, контакт, описание и время создания.
def format_admin_message(draft: TicketDraft, chat_id: int) -> str: urgency = draft.urgency or "—" urgency_emoji = URGENCY_EMOJI.get(urgency, "⚪") created_label = datetime.now(UTC).strftime("%d.%m.%Y %H:%M") return ( "Новая заявка\n" "━━━━━━━━━━━━━━━━\n" f"Тема: {draft.topic}\n" f"Срочность: {urgency}\n" f"Chat ID: {chat_id}\n" f"Контакт: {draft.contact}\n" "Описание:\n" f"{draft.description}\n" f"Время: {created_label}" )
В рабочем коде сообщение можно оформить красивее, но для статьи я убрал лишние символы, чтобы пример был спокойнее и лучше читался в блоке кода.
Отправка выглядит так:
async def _submit_ticket(self, chat_id: int, session: TicketSession) -> None: admin_channel_id = settings.admin_channel_id if admin_channel_id is None: self.logger.error("ADMIN_CHANNEL_ID не настроен, заявка не отправлена") await self._send_message(chat_id, TICKET_ADMIN_NOT_CONFIGURED_TEXT) return admin_text = format_admin_message(session.draft, chat_id) try: await self.client.send_message(admin_channel_id, admin_text) except MaxApiError: self.logger.exception( "Не удалось отправить заявку в admin_channel_id=%s", admin_channel_id, ) await self._send_message(chat_id, TICKET_ADMIN_SEND_FAILED_TEXT) return await self.storage.delete_session(chat_id) await self._send_message(chat_id, TICKET_SUBMITTED_SUCCESS_TEXT) await self._send_main_menu(chat_id)
Если ADMIN_CHANNEL_ID не настроен, бот не пытается делать вид, что всё хорошо. Он логирует ошибку и сообщает пользователю, что отправить заявку сейчас не удалось.
Деплой на сервер
На сервере бот лежит в каталоге:
/opt/krivoshein-code-support
Запускать такой сервис руками не нужно. Для этого используется systemd. Пример unit-файла:
[Unit] Description=Krivoshein Code Support Bot (Webhook) After=network.target [Service] Type=simple User=bot Group=bot WorkingDirectory=/opt/krivoshein-code-support EnvironmentFile=/opt/krivoshein-code-support/.env ExecStart=/opt/krivoshein-code-support/.venv/bin/uvicorn app.webhook:app --host 127.0.0.1 --port 8000 Restart=on-failure RestartSec=5 [Install] WantedBy=multi-user.target
Здесь важна мелочь: путь к виртуальному окружению должен совпадать с реальным. Если окружение называется .venv, а в unit указан venv, systemd ничего не придумает за вас. Он просто не запустит сервис. И будет прав, как бы неприятно это ни звучало.
Проверка сервиса:
sudo systemctl status krivoshein-code-support sudo journalctl -u krivoshein-code-support -f
После обновления кода:
sudo systemctl restart krivoshein-code-support sudo journalctl -u krivoshein-code-support -f
Nginx как reverse proxy
Uvicorn слушает локальный порт, например 127.0.0.1:8000. Наружу его напрямую выставлять не нужно. Внешний HTTPS принимает Nginx, а потом проксирует запросы в приложение.
server { listen 443 ssl http2; server_name support.krivoshein.site; ssl_certificate /etc/letsencrypt/live/support.krivoshein.site/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/support.krivoshein.site/privkey.pem; location / { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } }
После правок конфиг обязательно проверяем:
sudo nginx -t sudo systemctl reload nginx
Потом проверяем снаружи:
curl -i https://support.krivoshein.site/health
Если healthcheck не открывается, MAX тоже не достучится до webhook. Тут всё просто: сначала сеть, DNS, TLS и Nginx, потом уже логика бота.
Проверки после запуска
После деплоя я бы проверял бота не только по принципу «запустился и ладно». Минимальный чек-лист такой:
- сервис systemd активен;
- healthcheck отвечает;
- Nginx отдаёт HTTPS без ошибок;
- webhook зарегистрирован;
- MAX показывает текущие подписки;
- бот отвечает на запуск;
- FAQ открывается;
- заявка проходит все шаги;
- неправильный контакт не пропускается;
- срочность выбирается кнопкой;
- подтверждение показывает сводку;
- отправка уходит в админ-канал;
- отмена удаляет сессию;
- после рестарта активная сессия не ломает сценарий.
Команды для базовой проверки:
python -m compileall app scripts sudo systemctl status krivoshein-code-support sudo journalctl -u krivoshein-code-support -f curl -i https://support.krivoshein.site/health python -m scripts.register_webhook
Да, ручная проверка занимает время. Зато потом меньше шансов, что первый пользователь нажмёт кнопку и найдёт дыру в логике быстрее, чем вы успеете открыть логи.
Где обычно ломается
Webhook не зарегистрирован
Самая простая ловушка. Приложение работает, healthcheck отвечает, но события от MAX не приходят. Причина: webhook не зарегистрирован или зарегистрирован старый URL.
Неверный WEBHOOK_URL
В .env должен быть тот же URL, который реально доступен снаружи. Если приложение слушает /webhook, а в настройках указан другой путь, события уйдут мимо.
Нет прав на SQLite
Сервис работает от пользователя bot. Значит этот пользователь должен иметь доступ на чтение и запись к каталогу проекта, файлу базы и папке data.
ADMIN_CHANNEL_ID не настроен
Если id админ-канала пустой, бот не сможет отправить заявку. Пользователь сценарий пройдёт, а на финале получит ошибку. Поэтому переменные окружения надо проверять до запуска.
Пользователь нажимает старые кнопки
В мессенджерах это обычная история. Пользователь может нажать кнопку из старого сообщения, когда состояние уже изменилось. FSM должна проверять текущий шаг и не принимать callback не по сценарию.
AI-агент поправил слишком широко
Я использовал Grok Build Beta при разработке, и он реально помогал. Но с AI-агентами надо работать аккуратно: маленькая задача, маленький diff, проверка руками. Если попросить «улучши всё», он может улучшить так, что потом придётся улучшать обратно.
Что уже хорошо в текущей версии
Мне нравится, что бот не пытается сразу быть огромной системой. Он делает одну полезную вещь и делает её понятно.
- Есть отдельный MAX API client.
- Есть FastAPI webhook.
- Есть healthcheck.
- Есть FSM.
- Есть сохранение состояния в SQLite.
- Есть FAQ.
- Есть главное меню.
- Есть регистрация webhook отдельным скриптом.
- Есть деплой через systemd.
- Есть Nginx reverse proxy.
Для MVP это нормальная база. Дальше уже можно расширять проект, но не ломая то, что уже работает.
Что можно добавить дальше
Первое, что я бы добавил, это отдельную таблицу отправленных заявок. Сейчас активная сессия хранится в ticket_sessions, а после отправки удаляется. Для MVP нормально, но для истории обращений нужна таблица tickets.
CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, chat_id INTEGER NOT NULL, topic TEXT NOT NULL, description TEXT NOT NULL, contact TEXT NOT NULL, urgency TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'new', created_at TEXT NOT NULL );
После этого можно сделать статусы: новая, в работе, закрыта. А дальше уже админку, историю, фильтры и экспорт.
Ещё полезные доработки:
- редактирование заявки перед отправкой;
- обработка вложений;
- email-уведомления;
- экспорт заявок;
- простая веб-админка;
- ограничение частоты запросов;
- автотесты для FSM;
- резервное копирование SQLite;
- команда проверки webhook-подписок;
- интеграция с сайтом или CRM.
Но добавлять всё сразу не нужно. Хорошая автоматизация начинается не с двадцати функций, а с одного сценария, который стабильно работает.
Кому может пригодиться такой MAX-бот
Такой бот подходит не только для моих задач. Его можно адаптировать под разные сценарии, где нужно принимать обращения по структуре.
- заявки на разработку сайта;
- техподдержка WordPress-плагина;
- обращения по рекламе;
- заявки на обслуживание сервера;
- предварительный бриф перед консультацией;
- FAQ по услугам;
- заявки от клиентов;
- уведомления в админ-канал.
Главная польза бота в том, что он убирает хаос на входе. Клиенту проще оставить заявку, исполнителю проще понять задачу. Все довольны, кроме хаоса. Но его никто не спрашивал.
Вывод
MAX-бот для приёма заявок получился небольшим, но практичным проектом. Внутри Python, FastAPI, httpx, SQLite, webhook, FSM и собственный MAX API client. Без Telegram-зависимостей и лишней магии.
Сейчас бот уже умеет показывать FAQ, принимать заявку по шагам, проверять контакт, выбирать срочность и отправлять обращение в админ-канал.
Для меня это хороший пример полезной автоматизации: меньше ручной переписки, больше структурированных заявок, меньше потерянных деталей.
Если вам тоже нужен бот для приёма заявок, поддержки, FAQ, консультаций или сбора обращений в MAX, Telegram или другой связке, можно обращаться. Сначала разберём сценарий, потом соберём минимальную рабочую версию, а дальше добавим интеграции, статусы, уведомления и всё, что действительно нужно проекту.
Контакты и услуги: krivoshein.site/contacts/