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:
2026-04-24 17:41:42 +07:00
parent d98383285e
commit 049f31118d
37 changed files with 292 additions and 41 deletions

41
Dockerfile.api Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
from .pally import router as pally_router
routers = [pally_router]

78
api/routes/pally.py Normal file
View 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"

View File

@@ -5,11 +5,11 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession 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.billing import BillingContext, BillingProviders
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from states.buy import BillingStorage
from texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -30,7 +30,9 @@ async def pally_init(
ctx.provider = BillingProviders.PALLY 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( bill = await deps.billing_service.pally_initiate_payment(
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount

View File

@@ -5,23 +5,14 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from config import settings from bot.keyboards.client import (
from keyboards.client import (
devices_selector, devices_selector,
duration_selector, duration_selector,
payment_gateways, payment_gateways,
return_to_menu, return_to_menu,
) )
from misc.utils import ( from bot.states.buy import BillingStorage, SubscriptionStorage
calculate_price, from bot.texts import (
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 (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE, CHECKOUT_PAYMENT_CHOICE,
CONTEXT_REINITIATED, CONTEXT_REINITIATED,
@@ -30,6 +21,15 @@ from texts import (
SUBSCRIPTION_DEVICE_SELECTOR, SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_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() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@@ -2,7 +2,7 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from texts import UNDEFINED_MESSAGE from bot.texts import UNDEFINED_MESSAGE
router = Router() router = Router()

View File

@@ -4,11 +4,11 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession 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 misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from services.rw import get_user_by_telegram_id from services.rw import get_user_by_telegram_id
from texts import FAQ_TEXT, GENERAL_PROCESSING
router = Router() router = Router()

View File

@@ -4,12 +4,10 @@ from aiogram.types import CallbackQuery
from aiogram.utils.deep_linking import create_start_link from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession 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 misc.utils import format_share_deep_link
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from texts import (
REFERALS_MENU,
)
router = Router() router = Router()

View File

@@ -5,8 +5,8 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from keyboards.client import support_url_kb from bot.keyboards.client import support_url_kb
from texts import SUPPORT_INIT from bot.texts import SUPPORT_INIT
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

View File

@@ -6,10 +6,10 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from bot.handlers import routers
from bot.middlewares import DIMiddleware
from config import settings from config import settings
from db.session import async_session from db.session import async_session
from handlers import routers
from middlewares import DIMiddleware
from misc.pally import PallyClient from misc.pally import PallyClient
from repositories.bills import BillsRepository from repositories.bills import BillsRepository
from repositories.users import UserRepository from repositories.users import UserRepository

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

View File

@@ -6,19 +6,23 @@ load_dotenv(override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field( postgres_url: str = Field(
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL" "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") proxy: str | None = Field(None, alias="PROXY")
remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL") subscription_url: str = Field(alias="SUBSCRIPTION_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") 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") min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES") max_devices: int = Field(12, alias="MAX_DEVICES")
per_device_cost: int = Field(50, alias="PER_DEVICE_COST") per_device_cost: int = Field(50, alias="PER_DEVICE_COST")

View File

@@ -23,7 +23,7 @@ services:
bot: bot:
build: build:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: maleniabot_app container_name: maleniabot_bot
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -43,5 +43,30 @@ services:
PALLY_SHOP_ID: ${PALLY_SHOP_ID} PALLY_SHOP_ID: ${PALLY_SHOP_ID}
restart: unless-stopped 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: volumes:
postgres_data: postgres_data:

5
entrypoints/api.sh Normal file
View 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

View File

@@ -6,4 +6,4 @@ echo
alembic upgrade head alembic upgrade head
echo "[+] Starting bot..." echo "[+] Starting bot..."
python main.py python -m bot.main

View File

@@ -3,7 +3,7 @@ import urllib.parse
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from random import choice from random import choice
import texts from bot import texts
from config import settings from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan

View File

@@ -51,7 +51,8 @@ ignore = [
"PLR0913", "PLR0913",
"RUF001", "RUF001",
"RUF002", "RUF002",
"RUF003" "RUF003",
"B008"
] ]
[tool.ruff.lint.isort] [tool.ruff.lint.isort]

View File

@@ -7,4 +7,7 @@ pydantic-settings>=2.7.0
python-dotenv python-dotenv
aiohttp-socks aiohttp-socks
alembic alembic
remnawave>=2.6.1 remnawave>=2.6.1
fastapi>=0.100.0
uvicorn[standard]>=0.24.0
python-multipart

View File

@@ -17,3 +17,11 @@ class DependenciesDTO:
bills_repository: BillsRepository bills_repository: BillsRepository
billing_service: BillingService billing_service: BillingService
pally_client: PallyClient pally_client: PallyClient
@dataclass
class APIDependenciesDTO:
user_repository: UserRepository
user_service: UserService
bills_repository: BillsRepository
billing_service: BillingService

View File

@@ -4,16 +4,16 @@ from aiogram.types import Message
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import (
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
)
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
from schemas.users import NewUserDTO from schemas.users import NewUserDTO
from services.rw import RWUserInfo, get_user_by_telegram_id from services.rw import RWUserInfo, get_user_by_telegram_id
from texts import (
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)