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