feat: добавил бота
This commit is contained in:
parent
7dd67b037a
commit
5d5cd18661
3
bot/.dockerignore
Normal file
3
bot/.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
.venv
|
||||
14
bot/Dockerfile
Normal file
14
bot/Dockerfile
Normal 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
0
bot/app/__init__.py
Normal file
71
bot/app/api.py
Normal file
71
bot/app/api.py
Normal 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
16
bot/app/config.py
Normal 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
44
bot/app/dedup.py
Normal 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
128
bot/app/handlers.py
Normal 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
101
bot/app/main.py
Normal 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
4
bot/requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
aiogram==3.29.0
|
||||
asyncpg==0.31.0
|
||||
httpx==0.28.1
|
||||
pydantic-settings==2.14.2
|
||||
Loading…
Reference in New Issue
Block a user