From ae6ce2d88615f62f528ad3e90f252061d90bd792 Mon Sep 17 00:00:00 2001 From: serhei_t Date: Wed, 17 Jun 2026 10:53:15 +0300 Subject: [PATCH] Add travel border Telegram bot --- BotBoard/.dockerignore | 8 ++ BotBoard/.env.example | 6 ++ BotBoard/.gitignore | 11 +++ BotBoard/Dockerfile | 13 +++ BotBoard/README.md | 61 ++++++++++++ BotBoard/app/__init__.py | 0 BotBoard/app/ai_client.py | 81 +++++++++++++++ BotBoard/app/border.py | 190 ++++++++++++++++++++++++++++++++++++ BotBoard/app/config.py | 23 +++++ BotBoard/app/main.py | 168 +++++++++++++++++++++++++++++++ BotBoard/docker-compose.yml | 7 ++ BotBoard/requirements.txt | 4 + 12 files changed, 572 insertions(+) create mode 100644 BotBoard/.dockerignore create mode 100644 BotBoard/.env.example create mode 100644 BotBoard/.gitignore create mode 100644 BotBoard/Dockerfile create mode 100644 BotBoard/README.md create mode 100644 BotBoard/app/__init__.py create mode 100644 BotBoard/app/ai_client.py create mode 100644 BotBoard/app/border.py create mode 100644 BotBoard/app/config.py create mode 100644 BotBoard/app/main.py create mode 100644 BotBoard/docker-compose.yml create mode 100644 BotBoard/requirements.txt diff --git a/BotBoard/.dockerignore b/BotBoard/.dockerignore new file mode 100644 index 0000000..879cc77 --- /dev/null +++ b/BotBoard/.dockerignore @@ -0,0 +1,8 @@ +.env +.env.* +.git +.pytest_cache +.mypy_cache +.ruff_cache +__pycache__ +*.pyc diff --git a/BotBoard/.env.example b/BotBoard/.env.example new file mode 100644 index 0000000..eba6cec --- /dev/null +++ b/BotBoard/.env.example @@ -0,0 +1,6 @@ +TELEGRAM_BOT_TOKEN=put_telegram_bot_token_here +BORDER_STATUS_URL=https://gpk.gov.by/situation-at-the-border/ +AI_BASE_URL=https://uat1-www.a1.by:7443/aisearch +AI_API_KEY=put_ai_api_key_here +AI_MODEL=gpt-5.5 +REQUEST_TIMEOUT_SECONDS=20 diff --git a/BotBoard/.gitignore b/BotBoard/.gitignore new file mode 100644 index 0000000..3e39112 --- /dev/null +++ b/BotBoard/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.Python +.venv/ +venv/ +.env +.env.* +!.env.example +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ diff --git a/BotBoard/Dockerfile b/BotBoard/Dockerfile new file mode 100644 index 0000000..3d9d188 --- /dev/null +++ b/BotBoard/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +CMD ["python", "-m", "app.main"] diff --git a/BotBoard/README.md b/BotBoard/README.md new file mode 100644 index 0000000..6991d60 --- /dev/null +++ b/BotBoard/README.md @@ -0,0 +1,61 @@ +# BotBoard Travel Border Bot + +Telegram-бот `@vacation_sml_bot` для помощи в поездке через границу Беларусь-Польша. + +## Возможности + +- Показывает очереди по польскому направлению с официальной страницы ГПК. +- Выделяет самые свободные пункты пропуска по текущим числовым данным. +- Отвечает на вопросы через AI с учетом свежих данных очередей. +- Работает через long polling, поэтому не требует публичного webhook URL. + +## Локальный запуск + +1. Создай `.env` рядом с `.env.example`. +2. Заполни переменные окружения: + +```env +TELEGRAM_BOT_TOKEN=... +BORDER_STATUS_URL=https://gpk.gov.by/situation-at-the-border/ +AI_BASE_URL=https://uat1-www.a1.by:7443/aisearch +AI_API_KEY=... +AI_MODEL=gpt-5.5 +REQUEST_TIMEOUT_SECONDS=20 +``` + +3. Установи зависимости и запусти: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python -m app.main +``` + +## Docker + +```bash +docker compose up -d --build +docker compose logs -f botboard +``` + +## Portainer + +1. Открой Portainer. +2. Создай новый Stack. +3. Используй содержимое `docker-compose.yml`. +4. В рабочую директорию/репозиторий стека добавь `.env` с реальными секретами. +5. Запусти Deploy the stack. + +Секреты не коммитятся: `.env` исключен через `.gitignore` и `.dockerignore`. + +## Команды бота + +- `/start` - стартовое меню. +- `/poland` - очереди по Польше. +- `/best` - лучшие пункты по минимальной очереди. +- `/ai вопрос` - вопрос AI с учетом очередей. + +## Важно + +Данные берутся из открытого официального источника ГПК. Бот помогает ориентироваться, но не заменяет официальные правила, требования к документам, визам и решения пограничных служб. diff --git a/BotBoard/app/__init__.py b/BotBoard/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/BotBoard/app/ai_client.py b/BotBoard/app/ai_client.py new file mode 100644 index 0000000..a975008 --- /dev/null +++ b/BotBoard/app/ai_client.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from urllib.parse import urljoin + +import httpx + + +SYSTEM_PROMPT = """Ты интерактивный Telegram-помощник для поездок и пересечения границы Беларусь-Польша. +Отвечай кратко, по-русски, практично. Используй текущие данные очередей из контекста. +Не выдавай юридические гарантии: если вопрос о правилах, визах, детях, документах или запретах, +советуй проверить официальный источник и указывай, что правила могут измениться. +""".strip() + + +class AiAssistant: + def __init__(self, base_url: str | None, api_key: str | None, model: str, timeout: float) -> None: + self.base_url = base_url.rstrip("/") if base_url else None + self.api_key = api_key + self.model = model + self.timeout = timeout + + @property + def enabled(self) -> bool: + return bool(self.base_url and self.api_key) + + async def ask(self, question: str, border_context: str) -> str: + if not self.enabled: + return "AI сейчас не настроен. Добавь AI_BASE_URL и AI_API_KEY в переменные окружения." + + payload = { + "model": self.model, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": f"Текущие данные:\n{border_context}\n\nВопрос пользователя:\n{question}"}, + ], + "temperature": 0.2, + } + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + + async with httpx.AsyncClient(timeout=self.timeout, follow_redirects=True) as client: + response = await self._post_chat_completion(client, payload, headers) + response.raise_for_status() + + data = response.json() + return _extract_answer(data) + + async def _post_chat_completion( + self, + client: httpx.AsyncClient, + payload: dict, + headers: dict[str, str], + ) -> httpx.Response: + assert self.base_url is not None + candidates = [self.base_url] + if not self.base_url.endswith("/v1/chat/completions"): + candidates.append(urljoin(f"{self.base_url}/", "v1/chat/completions")) + + last_response: httpx.Response | None = None + for url in candidates: + response = await client.post(url, json=payload, headers=headers) + if response.status_code not in {404, 405}: + return response + last_response = response + assert last_response is not None + return last_response + + +def _extract_answer(data: dict) -> str: + choices = data.get("choices") or [] + if choices: + message = choices[0].get("message") or {} + content = message.get("content") + if isinstance(content, str) and content.strip(): + return content.strip() + + for key in ("answer", "response", "content", "text"): + value = data.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + + return "AI вернул ответ в неожиданном формате. Попробуй переформулировать вопрос." diff --git a/BotBoard/app/border.py b/BotBoard/app/border.py new file mode 100644 index 0000000..255aee8 --- /dev/null +++ b/BotBoard/app/border.py @@ -0,0 +1,190 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass +from datetime import datetime + +import httpx +from bs4 import BeautifulSoup + + +POLAND_CHECKPOINTS = ( + "Брузги", + "Берестовица", + "Песчатка", + "Козловичи", + "Брест", + "Домачево", +) + +SECTION_NAMES = ("Легковые", "Грузовые", "Автобусы") + + +@dataclass(frozen=True) +class QueueInfo: + checkpoint: str + queue_text: str + queue_value: int | None + electronic_queue: bool + + +@dataclass(frozen=True) +class BorderSection: + name: str + checkpoints: tuple[QueueInfo, ...] + + +@dataclass(frozen=True) +class BorderSnapshot: + updated_at: str | None + fetched_at: datetime + sections: tuple[BorderSection, ...] + source_url: str + + +class BorderParseError(RuntimeError): + pass + + +async def fetch_border_snapshot(url: str, timeout: float = 20.0) -> BorderSnapshot: + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + response = await client.get(url) + response.raise_for_status() + return parse_border_page(response.text, url) + + +def parse_border_page(html: str, source_url: str) -> BorderSnapshot: + soup = BeautifulSoup(html, "html.parser") + for tag in soup(["script", "style", "noscript"]): + tag.decompose() + + lines = [line.strip() for line in soup.get_text("\n").splitlines() if line.strip()] + updated_at = _extract_updated_at(lines) + poland_blocks = _extract_poland_blocks(lines) + + if not poland_blocks: + raise BorderParseError("Не удалось найти блок очередей по Польше на странице ГПК") + + sections = tuple( + BorderSection(SECTION_NAMES[index] if index < len(SECTION_NAMES) else f"Таблица {index + 1}", block) + for index, block in enumerate(poland_blocks[:3]) + ) + return BorderSnapshot( + updated_at=updated_at, + fetched_at=datetime.now().astimezone(), + sections=sections, + source_url=source_url, + ) + + +def format_snapshot(snapshot: BorderSnapshot) -> str: + updated = snapshot.updated_at or "не указано" + parts = [f"Очереди на границе Беларуси с Польшей", f"Обновлено ГПК: {updated}"] + + for section in snapshot.sections: + parts.append("") + parts.append(f"{section.name}:") + for item in section.checkpoints: + suffix = " ЭО" if item.electronic_queue else "" + parts.append(f"- {item.checkpoint}: {item.queue_text}{suffix}") + + parts.append("") + parts.append("ЭО - электронная очередь. '-' означает, что значение не опубликовано или пункт не работает.") + return "\n".join(parts) + + +def format_best_options(snapshot: BorderSnapshot) -> str: + lines = ["Самые свободные варианты по текущим данным:"] + for section in snapshot.sections: + numeric = [item for item in section.checkpoints if item.queue_value is not None] + if not numeric: + lines.append(f"{section.name}: нет числовых данных") + continue + best_value = min(item.queue_value for item in numeric) + best_names = ", ".join(item.checkpoint for item in numeric if item.queue_value == best_value) + lines.append(f"{section.name}: {best_names} ({best_value})") + lines.append("") + lines.append("Проверяй официальную страницу перед выездом: ситуация может измениться за минуты.") + return "\n".join(lines) + + +def snapshot_context(snapshot: BorderSnapshot) -> str: + return format_snapshot(snapshot) + + +def _extract_updated_at(lines: list[str]) -> str | None: + date_pattern = re.compile(r"\b\d{2}\.\d{2}\.\d{4}\s+\d{2}:\d{2}\b") + for line in lines: + match = date_pattern.search(line) + if match: + return match.group(0) + return None + + +def _extract_poland_blocks(lines: list[str]) -> list[tuple[QueueInfo, ...]]: + blocks: list[tuple[QueueInfo, ...]] = [] + for index, line in enumerate(lines): + if line != "Польша": + continue + + block_lines: list[str] = [] + for next_line in lines[index + 1 :]: + if next_line == "Украина": + break + block_lines.append(next_line) + + parsed = _parse_checkpoint_block(block_lines) + if parsed: + blocks.append(tuple(parsed)) + return blocks + + +def _parse_checkpoint_block(lines: list[str]) -> list[QueueInfo]: + result: list[QueueInfo] = [] + for index, line in enumerate(lines): + if line not in POLAND_CHECKPOINTS: + continue + + electronic = False + value_line = None + cursor = index + 1 + while cursor < len(lines): + candidate = lines[cursor] + if candidate in POLAND_CHECKPOINTS: + break + if "Электронная очередь" in candidate: + electronic = True + elif _looks_like_queue_value(candidate): + value_line = candidate + break + cursor += 1 + + if value_line is None: + continue + result.append( + QueueInfo( + checkpoint=line, + queue_text=_clean_queue_text(value_line), + queue_value=_queue_value(value_line), + electronic_queue=electronic, + ) + ) + return result + + +def _looks_like_queue_value(value: str) -> bool: + return bool(re.fullmatch(r"\(?\d+\)?\*?|[-—]", value.strip())) + + +def _clean_queue_text(value: str) -> str: + cleaned = value.strip().replace("*", "").strip() + if cleaned.startswith("(") and cleaned.endswith(")"): + cleaned = cleaned[1:-1] + return cleaned + + +def _queue_value(value: str) -> int | None: + match = re.search(r"\d+", value) + if not match: + return None + return int(match.group(0)) diff --git a/BotBoard/app/config.py b/BotBoard/app/config.py new file mode 100644 index 0000000..dc079af --- /dev/null +++ b/BotBoard/app/config.py @@ -0,0 +1,23 @@ +from functools import lru_cache + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + telegram_bot_token: str = Field(alias="TELEGRAM_BOT_TOKEN") + border_status_url: str = Field( + default="https://gpk.gov.by/situation-at-the-border/", + alias="BORDER_STATUS_URL", + ) + ai_base_url: str | None = Field(default=None, alias="AI_BASE_URL") + ai_api_key: str | None = Field(default=None, alias="AI_API_KEY") + ai_model: str = Field(default="gpt-5.5", alias="AI_MODEL") + request_timeout_seconds: float = Field(default=20.0, alias="REQUEST_TIMEOUT_SECONDS") + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/BotBoard/app/main.py b/BotBoard/app/main.py new file mode 100644 index 0000000..44a6e00 --- /dev/null +++ b/BotBoard/app/main.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import asyncio +import logging + +from aiogram import Bot, Dispatcher, F, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.filters import Command, CommandStart +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import KeyboardButton, Message, ReplyKeyboardMarkup +from aiogram.utils.chat_action import ChatActionSender + +from app.ai_client import AiAssistant +from app.border import fetch_border_snapshot, format_best_options, format_snapshot, snapshot_context +from app.config import get_settings + + +class DialogState(StatesGroup): + waiting_for_ai_question = State() + + +router = Router() + + +def main_keyboard() -> ReplyKeyboardMarkup: + return ReplyKeyboardMarkup( + keyboard=[ + [KeyboardButton(text="Очереди Польша"), KeyboardButton(text="Лучший пункт")], + [KeyboardButton(text="Спросить AI"), KeyboardButton(text="Источник")], + ], + resize_keyboard=True, + input_field_placeholder="Выбери действие или задай вопрос", + ) + + +@router.message(CommandStart()) +async def start(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer( + "Я помощник по поездке и пересечению границы Беларусь-Польша. " + "Показываю очереди с сайта ГПК и могу подсказать маршрутные решения через AI.", + reply_markup=main_keyboard(), + ) + + +@router.message(Command("help")) +async def help_command(message: Message, state: FSMContext) -> None: + await state.clear() + await message.answer( + "Команды:\n" + "/poland - текущие очереди по Польше\n" + "/best - самые свободные пункты\n" + "/ai вопрос - спросить AI с учетом текущих очередей\n\n" + "Можно просто нажимать кнопки меню.", + reply_markup=main_keyboard(), + ) + + +@router.message(Command("poland")) +@router.message(F.text.casefold() == "очереди польша") +async def poland_queues(message: Message, state: FSMContext) -> None: + await state.clear() + settings = get_settings() + try: + async with ChatActionSender.typing(bot=message.bot, chat_id=message.chat.id): + snapshot = await fetch_border_snapshot(settings.border_status_url, settings.request_timeout_seconds) + except Exception: + logging.exception("Failed to fetch border queues") + await message.answer("Не удалось получить данные ГПК. Попробуй еще раз через несколько минут.", reply_markup=main_keyboard()) + return + await message.answer(format_snapshot(snapshot), reply_markup=main_keyboard()) + + +@router.message(Command("best")) +@router.message(F.text.casefold() == "лучший пункт") +async def best_checkpoint(message: Message, state: FSMContext) -> None: + await state.clear() + settings = get_settings() + try: + async with ChatActionSender.typing(bot=message.bot, chat_id=message.chat.id): + snapshot = await fetch_border_snapshot(settings.border_status_url, settings.request_timeout_seconds) + except Exception: + logging.exception("Failed to fetch border queues") + await message.answer("Не удалось получить данные ГПК. Попробуй еще раз через несколько минут.", reply_markup=main_keyboard()) + return + await message.answer(format_best_options(snapshot), reply_markup=main_keyboard()) + + +@router.message(F.text.casefold() == "источник") +async def source(message: Message, state: FSMContext) -> None: + await state.clear() + settings = get_settings() + await message.answer( + f"Официальный источник данных: {settings.border_status_url}\n" + "Информация обновляется на стороне ГПК, обычно каждый четный час.", + reply_markup=main_keyboard(), + ) + + +@router.message(F.text.casefold() == "спросить ai") +async def ask_ai_prompt(message: Message, state: FSMContext) -> None: + await state.set_state(DialogState.waiting_for_ai_question) + await message.answer("Напиши вопрос. Например: 'Через какой пункт лучше ехать сегодня вечером?'", reply_markup=main_keyboard()) + + +@router.message(Command("ai")) +async def ask_ai_command(message: Message, state: FSMContext) -> None: + question = (message.text or "").partition(" ")[2].strip() + if not question: + await ask_ai_prompt(message, state) + return + await answer_ai(message, question, state) + + +@router.message(DialogState.waiting_for_ai_question) +async def ask_ai_state(message: Message, state: FSMContext) -> None: + question = (message.text or "").strip() + if not question: + await message.answer("Напиши вопрос текстом.") + return + await answer_ai(message, question, state) + + +@router.message(F.text) +async def fallback_question(message: Message, state: FSMContext) -> None: + question = (message.text or "").strip() + if len(question) < 4: + await message.answer("Выбери действие в меню или задай вопрос по поездке.", reply_markup=main_keyboard()) + return + await answer_ai(message, question, state) + + +async def answer_ai(message: Message, question: str, state: FSMContext) -> None: + await state.clear() + settings = get_settings() + assistant = AiAssistant( + settings.ai_base_url, + settings.ai_api_key, + settings.ai_model, + settings.request_timeout_seconds, + ) + try: + async with ChatActionSender.typing(bot=message.bot, chat_id=message.chat.id): + snapshot = await fetch_border_snapshot(settings.border_status_url, settings.request_timeout_seconds) + answer = await assistant.ask(question, snapshot_context(snapshot)) + except Exception: + logging.exception("Failed to answer AI question") + await message.answer( + "Не удалось получить ответ сейчас: проблема с источником данных или AI. Попробуй позже.", + reply_markup=main_keyboard(), + ) + return + await message.answer(answer, reply_markup=main_keyboard()) + + +async def run() -> None: + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") + settings = get_settings() + bot = Bot(settings.telegram_bot_token, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + dispatcher = Dispatcher() + dispatcher.include_router(router) + await dispatcher.start_polling(bot) + + +if __name__ == "__main__": + asyncio.run(run()) diff --git a/BotBoard/docker-compose.yml b/BotBoard/docker-compose.yml new file mode 100644 index 0000000..6dcda61 --- /dev/null +++ b/BotBoard/docker-compose.yml @@ -0,0 +1,7 @@ +services: + botboard: + build: . + container_name: botboard-travel-border-bot + restart: unless-stopped + env_file: + - .env diff --git a/BotBoard/requirements.txt b/BotBoard/requirements.txt new file mode 100644 index 0000000..49ca07a --- /dev/null +++ b/BotBoard/requirements.txt @@ -0,0 +1,4 @@ +aiogram==3.13.1 +beautifulsoup4==4.12.3 +httpx==0.27.2 +pydantic-settings==2.6.1