feat: добавил бота

This commit is contained in:
Илья Братских 2026-11-01 17:59:29 +03:00
parent 7dd67b037a
commit 5d5cd18661
9 changed files with 381 additions and 0 deletions

3
bot/.dockerignore Normal file
View File

@ -0,0 +1,3 @@
**/__pycache__
**/*.pyc
.venv

14
bot/Dockerfile Normal file
View File

@ -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"]

0
bot/app/__init__.py Normal file
View File

71
bot/app/api.py Normal file
View File

@ -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},
)

16
bot/app/config.py Normal file
View File

@ -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()

44
bot/app/dedup.py Normal file
View File

@ -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

128
bot/app/handlers.py Normal file
View File

@ -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']}"
)

101
bot/app/main.py Normal file
View File

@ -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())

4
bot/requirements.txt Normal file
View File

@ -0,0 +1,4 @@
aiogram==3.29.0
asyncpg==0.31.0
httpx==0.28.1
pydantic-settings==2.14.2