diff --git a/bot/.dockerignore b/bot/.dockerignore new file mode 100644 index 0000000..b4a900e --- /dev/null +++ b/bot/.dockerignore @@ -0,0 +1,3 @@ +**/__pycache__ +**/*.pyc +.venv diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..18f1dbe --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim AS build +WORKDIR /build +COPY requirements.txt . +RUN pip install --prefix=/install --no-cache-dir -r requirements.txt + +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 +RUN useradd --system --uid 1000 --home /app --create-home app +WORKDIR /app +COPY --from=build /install /usr/local +COPY app ./app +USER app +CMD ["python", "-m", "app.main"] diff --git a/bot/app/__init__.py b/bot/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/app/api.py b/bot/app/api.py new file mode 100644 index 0000000..55d901f --- /dev/null +++ b/bot/app/api.py @@ -0,0 +1,71 @@ +import httpx + + +class ApiError(Exception): + def __init__(self, detail: str) -> None: + super().__init__(detail) + self.detail = detail + + +class ShopApi: + def __init__(self, base_url: str, secret: str) -> None: + self.client = httpx.AsyncClient( + base_url=base_url.rstrip("/"), + headers={"X-Bot-Secret": secret}, + timeout=10, + ) + + async def close(self) -> None: + await self.client.aclose() + + async def _request(self, method: str, path: str, json: dict | None = None): + response = await self.client.request(method, path, json=json) + if response.status_code >= 400: + detail = "Ошибка запроса" + try: + body = response.json() + if isinstance(body.get("detail"), str): + detail = body["detail"] + except Exception: + pass + raise ApiError(detail) + if response.status_code == 204 or not response.content: + return None + return response.json() + + async def products(self) -> list[dict]: + data = await self._request("GET", "/products?limit=100") + return data["products"] + + async def product(self, product_id: int) -> dict: + return await self._request("GET", f"/products/{product_id}") + + async def add(self, telegram_user_id: int, product_id: int) -> dict: + return await self._request( + "POST", + "/internal/cart/items", + {"telegram_user_id": telegram_user_id, "product_id": product_id, "qty": 1}, + ) + + async def cart(self, telegram_user_id: int) -> dict: + return await self._request("GET", f"/internal/cart/{telegram_user_id}") + + async def dialog(self, telegram_user_id: int) -> dict | None: + return await self._request("GET", f"/internal/dialog/{telegram_user_id}") + + async def save_dialog(self, telegram_user_id: int, step: str, name: str | None) -> dict: + return await self._request( + "PUT", + f"/internal/dialog/{telegram_user_id}", + {"step": step, "name": name}, + ) + + async def clear_dialog(self, telegram_user_id: int) -> None: + await self._request("DELETE", f"/internal/dialog/{telegram_user_id}") + + async def order(self, telegram_user_id: int, name: str, phone: str) -> dict: + return await self._request( + "POST", + "/internal/orders", + {"telegram_user_id": telegram_user_id, "customer_name": name, "customer_phone": phone}, + ) diff --git a/bot/app/config.py b/bot/app/config.py new file mode 100644 index 0000000..35c6b50 --- /dev/null +++ b/bot/app/config.py @@ -0,0 +1,16 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(extra="ignore") + + database_url: str = "postgresql://shop:shop@localhost:5432/shop" + api_url: str = "http://localhost:8000" + bot_token: str = "" + bot_secret: str = "dev-bot-secret" + bot_mode: str = "polling" + webhook_url: str = "" + webhook_path: str = "/telegram/webhook" + + +settings = Settings() diff --git a/bot/app/dedup.py b/bot/app/dedup.py new file mode 100644 index 0000000..d586e9c --- /dev/null +++ b/bot/app/dedup.py @@ -0,0 +1,44 @@ +import asyncpg +from aiogram import BaseMiddleware +from aiogram.types import Update + + +def _chat_id(update: Update) -> int | None: + if update.message is not None: + return update.message.chat.id + if update.callback_query is not None and update.callback_query.message is not None: + return update.callback_query.message.chat.id + return None + + +class DedupMiddleware(BaseMiddleware): + """Один update_id обрабатывается один раз, даже если Telegram прислал повтор. + + Повторная доставка на другую реплику упирается в первичный ключ. + Сообщения одного чата сериализуются advisory-lock'ом в Postgres. + """ + + def __init__(self, pool: asyncpg.Pool) -> None: + self.pool = pool + + async def __call__(self, handler, event, data): + if not isinstance(event, Update): + return await handler(event, data) + + async with self.pool.acquire() as conn: + inserted = await conn.fetchrow( + "INSERT INTO telegram_updates (update_id) VALUES ($1) " + "ON CONFLICT DO NOTHING RETURNING update_id", + event.update_id, + ) + if inserted is None: + return None + try: + async with conn.transaction(): + chat_id = _chat_id(event) + if chat_id is not None: + await conn.execute("SELECT pg_advisory_xact_lock($1)", chat_id) + return await handler(event, data) + except Exception: + await conn.execute("DELETE FROM telegram_updates WHERE update_id = $1", event.update_id) + raise diff --git a/bot/app/handlers.py b/bot/app/handlers.py new file mode 100644 index 0000000..623dc7c --- /dev/null +++ b/bot/app/handlers.py @@ -0,0 +1,128 @@ +from aiogram import F, Router +from aiogram.filters import Command, CommandStart +from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message + +from app.api import ApiError, ShopApi + +router = Router() + + +def _catalog_keyboard(products: list[dict]) -> InlineKeyboardMarkup: + rows = [ + [InlineKeyboardButton(text=f"{item['title']} — {item['price']} ₽", callback_data=f"prod:{item['id']}")] + for item in products + ] + return InlineKeyboardMarkup(inline_keyboard=rows) + + +def _product_keyboard(product_id: int) -> InlineKeyboardMarkup: + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text="В корзину", callback_data=f"add:{product_id}"), + InlineKeyboardButton(text="Оформить", callback_data="checkout"), + ] + ] + ) + + +def _cart_text(cart: dict) -> str: + if not cart["items"]: + return "Корзина пуста." + lines = [f"{item['title']} × {item['qty']} = {item['line_total']} ₽" for item in cart["items"]] + lines.append(f"Итого: {cart['total']} ₽") + lines.append("Оформление: /checkout") + return "\n".join(lines) + + +@router.message(CommandStart()) +async def start(message: Message) -> None: + await message.answer("Магазин. /catalog — товары, /cart — корзина, /checkout — заказ без оплаты.") + + +@router.message(Command("catalog")) +async def catalog(message: Message, api: ShopApi) -> None: + products = await api.products() + if not products: + await message.answer("Каталог пуст.") + return + await message.answer("Каталог", reply_markup=_catalog_keyboard(products)) + + +@router.callback_query(F.data.startswith("prod:")) +async def show_product(query: CallbackQuery, api: ShopApi) -> None: + product_id = int(query.data.split(":", 1)[1]) + try: + product = await api.product(product_id) + except ApiError as exc: + await query.answer(exc.detail, show_alert=True) + return + text = f"{product['title']}\n{product['description']}\n{product['price']} ₽\n{product['category_name']}" + if query.message is not None: + await query.message.answer(text, reply_markup=_product_keyboard(product_id)) + await query.answer() + + +@router.callback_query(F.data.startswith("add:")) +async def add_product(query: CallbackQuery, api: ShopApi) -> None: + if query.from_user is None: + await query.answer() + return + product_id = int(query.data.split(":", 1)[1]) + try: + cart = await api.add(query.from_user.id, product_id) + except ApiError as exc: + await query.answer(exc.detail, show_alert=True) + return + await query.answer("Добавлено") + if query.message is not None: + await query.message.answer(_cart_text(cart)) + + +@router.message(Command("cart")) +async def cart(message: Message, api: ShopApi) -> None: + if message.from_user is None: + return + data = await api.cart(message.from_user.id) + await message.answer(_cart_text(data)) + + +@router.message(Command("checkout")) +@router.callback_query(F.data == "checkout") +async def checkout_start(event: Message | CallbackQuery, api: ShopApi) -> None: + user = event.from_user + if user is None: + return + await api.save_dialog(user.id, "name", None) + text = "Как вас зовут?" + if isinstance(event, CallbackQuery): + await event.answer() + if event.message is not None: + await event.message.answer(text) + return + await event.answer(text) + + +@router.message(F.text) +async def dialog(message: Message, api: ShopApi) -> None: + if message.from_user is None or message.text is None or message.text.startswith("/"): + return + state = await api.dialog(message.from_user.id) + if state is None: + return + if state["step"] == "name": + await api.save_dialog(message.from_user.id, "phone", message.text.strip()[:120]) + await message.answer("Телефон? Можно цифры, пробелы и +()-.") + return + if state["step"] != "phone" or not state.get("name"): + await api.clear_dialog(message.from_user.id) + return + try: + order = await api.order(message.from_user.id, state["name"], message.text.strip()) + except ApiError as exc: + await message.answer(exc.detail) + return + await api.clear_dialog(message.from_user.id) + await message.answer( + f"Заказ {order['id']} создан, без оплаты.\nСумма: {order['total']} ₽\nИмя: {order['customer_name']}" + ) diff --git a/bot/app/main.py b/bot/app/main.py new file mode 100644 index 0000000..6d66e6f --- /dev/null +++ b/bot/app/main.py @@ -0,0 +1,101 @@ +import asyncio +import logging + +import asyncpg +from aiogram import Bot, Dispatcher +from aiogram.types import BotCommand +from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application +from aiohttp import web + +from app.api import ShopApi +from app.config import settings +from app.dedup import DedupMiddleware +from app.handlers import router + +log = logging.getLogger("shop-bot") +LEADER_LOCK = (1, 42001) + + +async def _wait_for_db() -> asyncpg.Connection: + delay = 1 + while True: + try: + return await asyncpg.connect(settings.database_url) + except (OSError, asyncpg.PostgresError) as exc: + log.warning("postgres is not ready: %s", exc) + await asyncio.sleep(delay) + delay = min(delay * 2, 10) + + +async def _become_leader(conn: asyncpg.Connection) -> None: + while True: + locked = await conn.fetchval("SELECT pg_try_advisory_lock($1, $2)", *LEADER_LOCK) + if locked: + log.info("this replica is the polling leader") + return + log.info("another replica holds getUpdates, waiting") + await asyncio.sleep(2) + + +async def _run_bot(bot: Bot, dp: Dispatcher, api: ShopApi) -> None: + pool = await asyncpg.create_pool(settings.database_url, min_size=1, max_size=4) + dp.update.middleware(DedupMiddleware(pool)) + dp.include_router(router) + await bot.set_my_commands( + [ + BotCommand(command="catalog", description="Каталог"), + BotCommand(command="cart", description="Корзина"), + BotCommand(command="checkout", description="Оформить заказ"), + ] + ) + try: + if settings.bot_mode == "webhook": + if not settings.webhook_url: + raise RuntimeError("WEBHOOK_URL is required when BOT_MODE=webhook") + await bot.set_webhook( + settings.webhook_url, + secret_token=settings.bot_secret[:256], + drop_pending_updates=False, + ) + application = web.Application() + SimpleRequestHandler(dispatcher=dp, bot=bot, secret_token=settings.bot_secret[:256]).register( + application, path=settings.webhook_path + ) + setup_application(application, dp, bot=bot) + runner = web.AppRunner(application) + await runner.setup() + await web.TCPSite(runner, "0.0.0.0", 8081).start() + log.info("webhook listening on :8081%s", settings.webhook_path) + await asyncio.Event().wait() + else: + await bot.delete_webhook(drop_pending_updates=False) + await dp.start_polling(bot, api=api) + finally: + await api.close() + await pool.close() + + +async def main() -> None: + logging.basicConfig(level=logging.INFO) + if not settings.bot_token: + log.warning("BOT_TOKEN is empty; bot replicas stay idle. The website works without it.") + await asyncio.Event().wait() + return + + api = ShopApi(settings.api_url, settings.bot_secret) + bot = Bot(settings.bot_token) + dp = Dispatcher() + leader: asyncpg.Connection | None = None + try: + if settings.bot_mode != "webhook": + leader = await _wait_for_db() + await _become_leader(leader) + await _run_bot(bot, dp, api) + finally: + await bot.session.close() + if leader is not None: + await leader.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..ba7db0c --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,4 @@ +aiogram==3.29.0 +asyncpg==0.31.0 +httpx==0.28.1 +pydantic-settings==2.14.2