feat: Implement payment notification system and API structure
- Added notifications for successful payments in `api/core/notifications.py`. - Created main API entry point in `api/main.py` with FastAPI integration. - Established routing structure in `api/routes/__init__.py` and `api/routes/pally.py` for handling payment callbacks. - Developed billing handlers in `bot/handlers/billing.py` and `bot/handlers/buy.py` for subscription management. - Introduced state management for user interactions in `bot/states/`. - Created user interface elements in `bot/keyboards/` for navigation and payment processing. - Set up middleware for dependency injection in `bot/middlewares/di.py`. - Added support for user commands and interactions in `bot/handlers/common.py` and `bot/handlers/support.py`. - Implemented logging and error handling throughout the bot's functionality. - Created entry point script for API server in `entrypoints/api.sh`.
This commit is contained in:
41
Dockerfile.api
Normal file
41
Dockerfile.api
Normal file
@@ -0,0 +1,41 @@
|
||||
# Stage 1: Builder - Install dependencies
|
||||
FROM python:3.13-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Install build-time dependencies required for compiling packages like asyncpg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
||||
|
||||
# Stage 2: Runner - Final lightweight image
|
||||
FROM python:3.13-slim AS runner
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies required by asyncpg
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy installed Python packages from the builder stage
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
|
||||
RUN sed -i 's/\r$//' ./entrypoints/api.sh && \
|
||||
chmod +x ./entrypoints/api.sh
|
||||
|
||||
# Ensure the api script is executable
|
||||
RUN chmod +x ./entrypoints/api.sh
|
||||
|
||||
# Default command runs migrations and starts the API
|
||||
CMD ["sh", "./entrypoints/api.sh"]
|
||||
18
api/core/deps.py
Normal file
18
api/core/deps.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from aiogram import Bot
|
||||
from fastapi import Request
|
||||
|
||||
from db.session import async_session
|
||||
from schemas.di import APIDependenciesDTO
|
||||
|
||||
|
||||
def get_bot(request: Request) -> Bot:
|
||||
return request.app.state.bot
|
||||
|
||||
|
||||
async def get_db():
|
||||
async with async_session() as db:
|
||||
yield db
|
||||
|
||||
|
||||
def get_deps(request: Request) -> APIDependenciesDTO:
|
||||
return request.app.state.deps
|
||||
13
api/core/notifications.py
Normal file
13
api/core/notifications.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from aiogram import Bot
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
SUCCESSFUL_PAYMENT = "<b>💸 Зачислен платёж на {amount}₽</b>"
|
||||
|
||||
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[[InlineKeyboardButton(text="❌", callback_data="delete")]]
|
||||
).as_markup()
|
||||
|
||||
|
||||
async def successful_payment_notification(bot: Bot, user_id: int, amount: int):
|
||||
await bot.send_message(user_id, SUCCESSFUL_PAYMENT.format(amount=amount), reply_markup=close_kb)
|
||||
52
api/main.py
Normal file
52
api/main.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import FastAPI
|
||||
|
||||
from api.routes import routers
|
||||
from config import settings
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.users import UserRepository
|
||||
from schemas.di import APIDependenciesDTO
|
||||
from services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator:
|
||||
# Bot Init
|
||||
bot = Bot(token=settings.bot_token)
|
||||
app.state.bot = bot
|
||||
|
||||
user_repository = UserRepository()
|
||||
user_service = UserService(user_repository)
|
||||
|
||||
bills_repository = BillsRepository()
|
||||
bills_service = BillingService(
|
||||
user_repository=user_repository, bills_repository=bills_repository
|
||||
)
|
||||
|
||||
deps_dto = APIDependenciesDTO(
|
||||
user_repository=user_repository,
|
||||
user_service=user_service,
|
||||
bills_repository=bills_repository,
|
||||
billing_service=bills_service,
|
||||
)
|
||||
app.state.deps = deps_dto
|
||||
|
||||
for r in routers:
|
||||
app.include_router(r)
|
||||
|
||||
yield # App's lifespan
|
||||
|
||||
await bot.session.close() # D IE!!!!!!!!
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Malenia API",
|
||||
description="Payment webhook handler and user balance management",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="",
|
||||
)
|
||||
3
api/routes/__init__.py
Normal file
3
api/routes/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .pally import router as pally_router
|
||||
|
||||
routers = [pally_router]
|
||||
78
api/routes/pally.py
Normal file
78
api/routes/pally.py
Normal file
@@ -0,0 +1,78 @@
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
|
||||
from aiogram import Bot
|
||||
from fastapi import Depends, Form, HTTPException
|
||||
from fastapi.routing import APIRouter
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from api.core.deps import get_bot, get_db, get_deps
|
||||
from api.core.notifications import successful_payment_notification
|
||||
from config import settings
|
||||
from misc.pally import BillStatus
|
||||
from schemas.di import APIDependenciesDTO
|
||||
|
||||
router = APIRouter(prefix="/checkout/pally")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def pally_callback(
|
||||
id: str = Form(...),
|
||||
bill_id: str = Form(...),
|
||||
status: str = Form(...),
|
||||
req_amount: str = Form(...),
|
||||
currency: str = Form(...),
|
||||
order_id: str | None = Form(None),
|
||||
signature: str = Form(...),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
bot: Bot = Depends(get_bot),
|
||||
deps: APIDependenciesDTO = Depends(get_deps),
|
||||
):
|
||||
oid = order_id or ""
|
||||
raw_string = f"{id}{status}{req_amount}{currency}{oid}{settings.pally_token}"
|
||||
md5_hash = hashlib.md5(raw_string.encode("utf-8")).hexdigest()
|
||||
expected_signature = hashlib.sha1(md5_hash.encode("utf-8")).hexdigest()
|
||||
|
||||
if signature != expected_signature:
|
||||
logger.critical("Invalid signature for bill %s", bill_id)
|
||||
raise HTTPException(403, detail="Invalid signature.")
|
||||
|
||||
if status != BillStatus.SUCCESS or not req_amount.isdigit():
|
||||
logger.info("Bill %s skipped: status=%s, req_amount=%s", bill_id, status, req_amount)
|
||||
return "OK"
|
||||
|
||||
logger.info("Received successfully paid bill %s", bill_id)
|
||||
|
||||
if not oid.isdigit():
|
||||
logger.critical("Invalid order_id format for bill %s", bill_id)
|
||||
return "OK"
|
||||
|
||||
bill = await deps.bills_repository.get_bill_by_id(session, int(oid))
|
||||
|
||||
if not bill:
|
||||
logger.critical("Bill %s is not found in db", bill_id)
|
||||
return "OK"
|
||||
|
||||
try:
|
||||
amount = int(req_amount)
|
||||
|
||||
await deps.user_repository.increase_user_balance(session, bill.creator_id, amount=amount)
|
||||
await successful_payment_notification(bot, bill.creator_id, amount)
|
||||
|
||||
referal = bill.user.referal_id
|
||||
if referal is not None:
|
||||
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
|
||||
await deps.user_repository.increase_user_balance(session, referal, referal_amount)
|
||||
await successful_payment_notification(bot, referal, referal_amount)
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Exception caught while processing balances for bill %s (Creator: %s)",
|
||||
bill_id,
|
||||
bill.creator_id,
|
||||
)
|
||||
|
||||
return "OK"
|
||||
@@ -5,11 +5,11 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import pay_url, return_to_menu
|
||||
from bot.keyboards.client import pay_url, return_to_menu
|
||||
from bot.states.buy import BillingStorage
|
||||
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
|
||||
from schemas.billing import BillingContext, BillingProviders
|
||||
from schemas.di import DependenciesDTO
|
||||
from states.buy import BillingStorage
|
||||
from texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,7 +30,9 @@ async def pally_init(
|
||||
|
||||
ctx.provider = BillingProviders.PALLY
|
||||
|
||||
await cb.message.edit_text(BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title()))
|
||||
await cb.message.edit_text(
|
||||
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())
|
||||
)
|
||||
|
||||
bill = await deps.billing_service.pally_initiate_payment(
|
||||
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount
|
||||
@@ -5,23 +5,14 @@ from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery
|
||||
|
||||
from config import settings
|
||||
from keyboards.client import (
|
||||
from bot.keyboards.client import (
|
||||
devices_selector,
|
||||
duration_selector,
|
||||
payment_gateways,
|
||||
return_to_menu,
|
||||
)
|
||||
from misc.utils import (
|
||||
calculate_price,
|
||||
convert_duration_to_int,
|
||||
convert_int_to_duration,
|
||||
get_discount,
|
||||
)
|
||||
from schemas.billing import BillingContext
|
||||
from schemas.subscriptions import SubscriptionPlan
|
||||
from states.buy import BillingStorage, SubscriptionStorage
|
||||
from texts import (
|
||||
from bot.states.buy import BillingStorage, SubscriptionStorage
|
||||
from bot.texts import (
|
||||
CALLBACK_FALLBACK,
|
||||
CHECKOUT_PAYMENT_CHOICE,
|
||||
CONTEXT_REINITIATED,
|
||||
@@ -30,6 +21,15 @@ from texts import (
|
||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||
SUBSCRIPTION_DURATION_SELECTOR,
|
||||
)
|
||||
from config import settings
|
||||
from misc.utils import (
|
||||
calculate_price,
|
||||
convert_duration_to_int,
|
||||
convert_int_to_duration,
|
||||
get_discount,
|
||||
)
|
||||
from schemas.billing import BillingContext
|
||||
from schemas.subscriptions import SubscriptionPlan
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -2,7 +2,7 @@ from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from texts import UNDEFINED_MESSAGE
|
||||
from bot.texts import UNDEFINED_MESSAGE
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -4,11 +4,11 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import close_kb, main_menu, return_to_menu
|
||||
from bot.keyboards.client import close_kb, main_menu, return_to_menu
|
||||
from bot.texts import FAQ_TEXT, GENERAL_PROCESSING
|
||||
from misc.utils import build_main_menu_text, format_subscription_link
|
||||
from schemas.di import DependenciesDTO
|
||||
from services.rw import get_user_by_telegram_id
|
||||
from texts import FAQ_TEXT, GENERAL_PROCESSING
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -4,12 +4,10 @@ from aiogram.types import CallbackQuery
|
||||
from aiogram.utils.deep_linking import create_start_link
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from keyboards.client import referals_kb
|
||||
from bot.keyboards.client import referals_kb
|
||||
from bot.texts import REFERALS_MENU
|
||||
from misc.utils import format_share_deep_link
|
||||
from schemas.di import DependenciesDTO
|
||||
from texts import (
|
||||
REFERALS_MENU,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -5,8 +5,8 @@ from aiogram.filters import Command
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from keyboards.client import support_url_kb
|
||||
from texts import SUPPORT_INIT
|
||||
from bot.keyboards.client import support_url_kb
|
||||
from bot.texts import SUPPORT_INIT
|
||||
|
||||
router = Router()
|
||||
logger = logging.getLogger(__name__)
|
||||
0
bot/keyboards/__init__.py
Normal file
0
bot/keyboards/__init__.py
Normal file
@@ -6,10 +6,10 @@ from aiogram.client.default import DefaultBotProperties
|
||||
from aiogram.client.session.aiohttp import AiohttpSession
|
||||
from remnawave import RemnawaveSDK
|
||||
|
||||
from bot.handlers import routers
|
||||
from bot.middlewares import DIMiddleware
|
||||
from config import settings
|
||||
from db.session import async_session
|
||||
from handlers import routers
|
||||
from middlewares import DIMiddleware
|
||||
from misc.pally import PallyClient
|
||||
from repositories.bills import BillsRepository
|
||||
from repositories.users import UserRepository
|
||||
0
bot/states/__init__.py
Normal file
0
bot/states/__init__.py
Normal file
12
config.py
12
config.py
@@ -6,19 +6,23 @@ load_dotenv(override=True)
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
bot_token: str = Field(alias="BOT_TOKEN")
|
||||
postgres_url: str = Field(
|
||||
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
|
||||
)
|
||||
|
||||
bot_token: str = Field(alias="BOT_TOKEN")
|
||||
|
||||
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
|
||||
pally_token: str = Field(alias="PALLY_TOKEN")
|
||||
|
||||
referal_bonus: int = Field(10, alias="REFERAL_BONUS")
|
||||
|
||||
proxy: str | None = Field(None, alias="PROXY")
|
||||
|
||||
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
||||
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
|
||||
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
|
||||
|
||||
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
|
||||
pally_token: str = Field(alias="PALLY_TOKEN")
|
||||
|
||||
min_devices: int = Field(3, alias="MIN_DEVICES")
|
||||
max_devices: int = Field(12, alias="MAX_DEVICES")
|
||||
per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
|
||||
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
bot:
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
container_name: maleniabot_app
|
||||
container_name: maleniabot_bot
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -43,5 +43,30 @@ services:
|
||||
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
dockerfile: Dockerfile.api
|
||||
container_name: maleniabot_api
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||
PROXY: ${PROXY:-}
|
||||
REMNAWAVE_URL: ${REMNAWAVE_URL}
|
||||
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
|
||||
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
|
||||
MIN_DEVICES: ${MIN_DEVICES:-3}
|
||||
MAX_DEVICES: ${MAX_DEVICES:-12}
|
||||
PER_DEVICE_COST: ${PER_DEVICE_COST:-50}
|
||||
WHITELIST_COST: ${WHITELIST_COST:-100}
|
||||
WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6}
|
||||
PALLY_TOKEN: ${PALLY_TOKEN}
|
||||
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
5
entrypoints/api.sh
Normal file
5
entrypoints/api.sh
Normal file
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "[+] Starting API server..."
|
||||
python -m uvicorn api.main:app --host 0.0.0.0 --port 8000
|
||||
@@ -6,4 +6,4 @@ echo
|
||||
alembic upgrade head
|
||||
|
||||
echo "[+] Starting bot..."
|
||||
python main.py
|
||||
python -m bot.main
|
||||
@@ -3,7 +3,7 @@ import urllib.parse
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from random import choice
|
||||
|
||||
import texts
|
||||
from bot import texts
|
||||
from config import settings
|
||||
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ ignore = [
|
||||
"PLR0913",
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003"
|
||||
"RUF003",
|
||||
"B008"
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
|
||||
@@ -7,4 +7,7 @@ pydantic-settings>=2.7.0
|
||||
python-dotenv
|
||||
aiohttp-socks
|
||||
alembic
|
||||
remnawave>=2.6.1
|
||||
remnawave>=2.6.1
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
python-multipart
|
||||
@@ -17,3 +17,11 @@ class DependenciesDTO:
|
||||
bills_repository: BillsRepository
|
||||
billing_service: BillingService
|
||||
pally_client: PallyClient
|
||||
|
||||
|
||||
@dataclass
|
||||
class APIDependenciesDTO:
|
||||
user_repository: UserRepository
|
||||
user_service: UserService
|
||||
bills_repository: BillsRepository
|
||||
billing_service: BillingService
|
||||
|
||||
@@ -4,16 +4,16 @@ from aiogram.types import Message
|
||||
from remnawave import RemnawaveSDK
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.texts import (
|
||||
NO_SUBSCRIPTION,
|
||||
NOTHING_PLACEHOLDER,
|
||||
SUBSCRIPTION_INFORMATION,
|
||||
)
|
||||
from db.models.users import User
|
||||
from misc import utils
|
||||
from repositories import UserRepository
|
||||
from schemas.users import NewUserDTO
|
||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
||||
from texts import (
|
||||
NO_SUBSCRIPTION,
|
||||
NOTHING_PLACEHOLDER,
|
||||
SUBSCRIPTION_INFORMATION,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user