diff --git a/Dockerfile.api b/Dockerfile.api new file mode 100644 index 0000000..ad9437e --- /dev/null +++ b/Dockerfile.api @@ -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"] diff --git a/keyboards/__init__.py b/api/__init__.py similarity index 100% rename from keyboards/__init__.py rename to api/__init__.py diff --git a/api/core/deps.py b/api/core/deps.py new file mode 100644 index 0000000..cb05f73 --- /dev/null +++ b/api/core/deps.py @@ -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 diff --git a/api/core/notifications.py b/api/core/notifications.py new file mode 100644 index 0000000..09714de --- /dev/null +++ b/api/core/notifications.py @@ -0,0 +1,13 @@ +from aiogram import Bot +from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +SUCCESSFUL_PAYMENT = "💸 Зачислен платёж на {amount}₽" + +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) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..e1f38ae --- /dev/null +++ b/api/main.py @@ -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="", +) diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..7ec49c0 --- /dev/null +++ b/api/routes/__init__.py @@ -0,0 +1,3 @@ +from .pally import router as pally_router + +routers = [pally_router] diff --git a/api/routes/pally.py b/api/routes/pally.py new file mode 100644 index 0000000..500b77a --- /dev/null +++ b/api/routes/pally.py @@ -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" diff --git a/states/__init__.py b/bot/__init__.py similarity index 100% rename from states/__init__.py rename to bot/__init__.py diff --git a/handlers/__init__.py b/bot/handlers/__init__.py similarity index 100% rename from handlers/__init__.py rename to bot/handlers/__init__.py diff --git a/handlers/ads.py b/bot/handlers/ads.py similarity index 100% rename from handlers/ads.py rename to bot/handlers/ads.py diff --git a/handlers/billing.py b/bot/handlers/billing.py similarity index 82% rename from handlers/billing.py rename to bot/handlers/billing.py index 9381569..a6e489e 100644 --- a/handlers/billing.py +++ b/bot/handlers/billing.py @@ -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 diff --git a/handlers/buy.py b/bot/handlers/buy.py similarity index 97% rename from handlers/buy.py rename to bot/handlers/buy.py index 60da736..4068594 100644 --- a/handlers/buy.py +++ b/bot/handlers/buy.py @@ -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__) diff --git a/handlers/common.py b/bot/handlers/common.py similarity index 91% rename from handlers/common.py rename to bot/handlers/common.py index 4e60da4..131818f 100644 --- a/handlers/common.py +++ b/bot/handlers/common.py @@ -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() diff --git a/handlers/menus.py b/bot/handlers/menus.py similarity index 96% rename from handlers/menus.py rename to bot/handlers/menus.py index 74dc2f0..80b743e 100644 --- a/handlers/menus.py +++ b/bot/handlers/menus.py @@ -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() diff --git a/handlers/prehandling.py b/bot/handlers/prehandling.py similarity index 100% rename from handlers/prehandling.py rename to bot/handlers/prehandling.py diff --git a/handlers/referals.py b/bot/handlers/referals.py similarity index 91% rename from handlers/referals.py rename to bot/handlers/referals.py index 9280cff..6453f6e 100644 --- a/handlers/referals.py +++ b/bot/handlers/referals.py @@ -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() diff --git a/handlers/support.py b/bot/handlers/support.py similarity index 88% rename from handlers/support.py rename to bot/handlers/support.py index ff6d42c..5a4702c 100644 --- a/handlers/support.py +++ b/bot/handlers/support.py @@ -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__) diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/keyboards/admins.py b/bot/keyboards/admins.py similarity index 100% rename from keyboards/admins.py rename to bot/keyboards/admins.py diff --git a/keyboards/client.py b/bot/keyboards/client.py similarity index 100% rename from keyboards/client.py rename to bot/keyboards/client.py diff --git a/main.py b/bot/main.py similarity index 95% rename from main.py rename to bot/main.py index 9875e9a..4634da9 100644 --- a/main.py +++ b/bot/main.py @@ -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 diff --git a/middlewares/__init__.py b/bot/middlewares/__init__.py similarity index 100% rename from middlewares/__init__.py rename to bot/middlewares/__init__.py diff --git a/middlewares/di.py b/bot/middlewares/di.py similarity index 100% rename from middlewares/di.py rename to bot/middlewares/di.py diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/states/admins.py b/bot/states/admins.py similarity index 100% rename from states/admins.py rename to bot/states/admins.py diff --git a/states/buy.py b/bot/states/buy.py similarity index 100% rename from states/buy.py rename to bot/states/buy.py diff --git a/states/support.py b/bot/states/support.py similarity index 100% rename from states/support.py rename to bot/states/support.py diff --git a/texts.py b/bot/texts.py similarity index 100% rename from texts.py rename to bot/texts.py diff --git a/config.py b/config.py index 418e319..c95f557 100644 --- a/config.py +++ b/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") diff --git a/docker-compose.yml b/docker-compose.yml index 445c082..1b2e242 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/entrypoints/api.sh b/entrypoints/api.sh new file mode 100644 index 0000000..9c4c4c5 --- /dev/null +++ b/entrypoints/api.sh @@ -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 diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index 4ed47f8..c6814b0 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -6,4 +6,4 @@ echo alembic upgrade head echo "[+] Starting bot..." -python main.py \ No newline at end of file +python -m bot.main \ No newline at end of file diff --git a/misc/utils.py b/misc/utils.py index daf1fcd..8eecb86 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 7c9bd03..811689d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,8 @@ ignore = [ "PLR0913", "RUF001", "RUF002", - "RUF003" + "RUF003", + "B008" ] [tool.ruff.lint.isort] diff --git a/requirements.txt b/requirements.txt index 027aa20..ffec835 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,4 +7,7 @@ pydantic-settings>=2.7.0 python-dotenv aiohttp-socks alembic -remnawave>=2.6.1 \ No newline at end of file +remnawave>=2.6.1 +fastapi>=0.100.0 +uvicorn[standard]>=0.24.0 +python-multipart \ No newline at end of file diff --git a/schemas/di.py b/schemas/di.py index a1a6fe7..19b77ac 100644 --- a/schemas/di.py +++ b/schemas/di.py @@ -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 diff --git a/services/user_service.py b/services/user_service.py index a13ad47..d9da2e4 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -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__)