Add referral system and user balance tracking

Introduce balance field to User model with Alembic migration. Add
referral menu, admin /refpay command, and deep link sharing. Update
Dockerfile to multi-stage build and add Postgres service to compose.
Fix deep link parsing logic and conditional proxy initialization.
This commit is contained in:
2026-04-10 22:20:53 +07:00
parent 60de871e0c
commit 0ed0ad2b92
17 changed files with 337 additions and 80 deletions

29
.env.example Normal file
View File

@@ -0,0 +1,29 @@
# Telegram Bot Token (Required)
# Get it from @BotFather on Telegram
BOT_TOKEN=your_bot_token_here
# Telegram Admin Group ID (Required)
# The numeric ID of the group where admin notifications will be sent
ADMIN_GROUP_ID=your_admin_group_id_here
# Remnawave Configuration (Required)
REMNAWAVE_URL=https://your-remnawave-instance.com
REMNAWAVE_TOKEN=your_remnawave_api_token_here
SUBSCRIPTION_URL=https://your-subscription-page.com
# PostgreSQL Database Configuration
# These variables are used by docker-compose to set up the database container
POSTGRES_USER=malenia
POSTGRES_PASSWORD=change_me_to_a_secure_password
POSTGRES_DB=malenia_db
# Proxy Configuration (Optional)
# Leave empty if no proxy is needed
PROXY=
# Pricing and Limits Configuration (Optional - defaults provided in code)
MIN_DEVICES=3
MAX_DEVICES=12
PER_DEVICE_COST=50
WHITELIST_COST=100
WHITELIST_DEVICE_THRESHOLD=6

4
.gitignore vendored
View File

@@ -130,6 +130,7 @@ celerybeat.pid
# Environments
.env
dev.env
.venv
env/
venv/
@@ -173,6 +174,3 @@ cython_debug/
# PyPI configuration file
.pypirc
# Static / IMG
static/img

View File

@@ -1,24 +1,38 @@
FROM python:3.13-slim
# 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 system dependencies
# Install runtime dependencies required by asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better layer caching
COPY requirements.txt .
# Copy installed Python packages from the builder stage
COPY --from=builder /install /usr/local
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy entrypoint script
COPY entrypoints/startup.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Copy application code (will be overridden by volume mount in development)
# Copy application source code
COPY . .
ENTRYPOINT ["/entrypoint.sh"]
# Ensure the startup script is executable
RUN chmod +x entrypoints/startup.sh
# Default command runs linting, migrations, and starts the bot
CMD ["entrypoints/startup.sh"]

View File

@@ -0,0 +1,32 @@
"""user -> +balance.
Revision ID: f40cab941564
Revises: 5985b2df0830
Create Date: 2026-04-10 21:11:15.209314
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f40cab941564"
down_revision: Union[str, Sequence[str], None] = "5985b2df0830"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("balance", sa.INTEGER(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "balance")
# ### end Alembic commands ###

View File

@@ -1,4 +1,4 @@
from sqlalchemy import BIGINT, TEXT
from sqlalchemy import BIGINT, INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
@@ -12,3 +12,4 @@ class User(Base):
)
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)

View File

@@ -1,14 +1,46 @@
services:
postgres:
image: postgres:16-alpine
container_name: maleniabot_db
environment:
POSTGRES_USER: ${POSTGRES_USER:-malenia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-malenia} -d ${POSTGRES_DB:-malenia_db}",
]
interval: 5s
timeout: 10s
retries: 5
bot:
build:
context: .
dockerfile: Dockerfile
volumes:
- .:/app
env_file:
- .env
container_name: maleniabot_app
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:-}
ADMIN_GROUP_ID: ${ADMIN_GROUP_ID}
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}
restart: unless-stopped
# Optional: uncomment to run with auto-reload for development
# command: python -m watchfiles main.py
stdin_open: true
tty: true
volumes:
postgres_data:

View File

@@ -1,5 +1,6 @@
from .referals import router as referal_router
from .menus import router as menus_router
from .support import router as support_router
from .buy import router as buy_router
routers = [menus_router, support_router, buy_router]
routers = [referal_router, menus_router, support_router, buy_router]

View File

@@ -12,7 +12,7 @@ from texts import GENERAL_PROCESSING, build_main_menu_text, FAQ_TEXT
router = Router()
@router.message(CommandStart(deep_link=True, deep_link_encoded=False))
@router.message(CommandStart(deep_link=True, deep_link_encoded=True))
async def fetch_referal(
msg: Message,
command: CommandObject,
@@ -22,12 +22,12 @@ async def fetch_referal(
):
await state.clear()
data = command.args.split()
data = command.args.split("_")
if not data:
await standard_start(msg, command, state)
return
referal = data[0]
referal = int(data[1])
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
)
@@ -41,7 +41,6 @@ async def fetch_referal(
@router.message(Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message,
command: CommandObject,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,

63
handlers/referals.py Normal file
View File

@@ -0,0 +1,63 @@
from aiogram import Router, F
from aiogram.filters import Command, CommandObject
from aiogram.utils.deep_linking import create_start_link
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import referals_kb
from misc.utils import format_share_deep_link
from schemas.di import DependenciesDTO
from texts import GENERAL_PROCESSING, REFERALS_MENU
from config import settings
router = Router()
@router.callback_query(F.data == "referal")
async def referal_menu(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
referal_link = await create_start_link(cb.bot, f"ref_{user.id}", encode=True)
share_link = format_share_deep_link(referal_link)
await cb.message.edit_text(
REFERALS_MENU.format(balance=user.balance, link=referal_link),
reply_markup=referals_kb(share_link),
)
@router.message(F.chat.id == settings.admin_group_id, Command("refpay"))
async def refpay(
msg: Message,
command: CommandObject,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
await msg.delete()
status_msg = await msg.answer(GENERAL_PROCESSING)
user = await deps.user_service.fetch_user_by_thread(session, msg.message_thread_id)
referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
if not referal:
await status_msg.edit_text("У пользователя нет реферала.")
return
amount = command.args
if not amount.isdigit():
await status_msg.edit_text(
"❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
)
return
amount = int(amount)
await deps.user_repository.increase_user_balance(session, user.id, amount)
await status_msg.edit_text(
f"<b>💸 Баланс реферала ({referal.id}) пополнен на {amount}₽</b>"
)

View File

@@ -51,6 +51,7 @@ async def received_message(
F.message_thread_id.is_not(None),
F.from_user.is_bot == False, # noqa: E712
~Command("close"),
~Command("refpay"),
F.text,
)
async def handle_admin_message(

View File

@@ -5,7 +5,7 @@ from config import settings
from misc.utils import convert_duration_to_int
from schemas.subscriptions import SubscriptionDuration
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
main_menu = InlineKeyboardBuilder(
[
[
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
@@ -16,6 +16,7 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
],
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
]
).as_markup()
@@ -23,6 +24,17 @@ _return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[_return_to_menu_btn]]
).as_markup()
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="✍️ Перейти в поддержку", callback_data="checkout")],
[_return_to_menu_btn],
]
).as_markup()
def _return_btn(
data: str = "menu:main", text: str = "⬅️ Назад"
@@ -30,11 +42,6 @@ def _return_btn(
return InlineKeyboardButton(text=text, callback_data=data)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[_return_to_menu_btn]]
).as_markup()
def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
builder = InlineKeyboardBuilder()
@@ -89,9 +96,10 @@ def duration_selector(
return builder.as_markup()
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
def referals_kb(share_link: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="✍️ Перейти в поддержку", callback_data="checkout")],
[InlineKeyboardButton(text="🔗 Поделиться ссылкой", url=share_link)],
[_return_to_menu_btn],
]
).as_markup()

View File

@@ -38,7 +38,7 @@ async def main():
)
dp.update.middleware.register(DIMiddleware(deps, async_session))
aiohttp_session = AiohttpSession(proxy=settings.proxy)
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)

View File

@@ -1,3 +1,4 @@
import urllib.parse
from typing import Optional
from random import choice
from datetime import datetime, timedelta, timezone
@@ -175,10 +176,15 @@ def format_subscription_link(link: Optional[str] = None):
return f"<code>{link or '...'}</code>"
def format_share_deep_link(link: str):
return f"https://t.me/share/url/?url={urllib.parse.quote_plus(link)}"
def format_support_subscription(
subscription: SubscriptionPlan,
full_name: str,
user_id: int,
balance: int,
username: Optional[str] = None,
) -> str:
username = format_username(username)
@@ -196,6 +202,7 @@ def format_support_subscription(
devices=subscription.devices,
whitelist_description=whitelist_description,
price=calculate_price(subscription),
balance=balance,
)

1
prob
View File

@@ -1 +0,0 @@
Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать?

View File

@@ -47,3 +47,25 @@ class UserRepository:
await session.commit()
return user
async def update_user_balance(
self, session: AsyncSession, user_id: int, balance: int
) -> Optional[User]:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
user.balance = balance
await session.commit()
return user
async def increase_user_balance(
self, session: AsyncSession, user_id: int, amount: int
) -> Optional[User]:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
user.balance += amount
await session.commit()
return user

View File

@@ -49,7 +49,7 @@ class UserService:
@staticmethod
async def format_admin_support_message(
ticket_number: int, user: User, rw_user: Optional[RWUserInfo]
ticket_number: int, user: User, rw_user: Optional[RWUserInfo], balance: int
):
username_display = utils.format_username(user.username)
@@ -76,6 +76,7 @@ class UserService:
full_name=user.full_name,
username_display=username_display,
telegram_id=user.id,
balance=balance,
status_icon=status_icon,
status=status_text,
expire_date=expire_date,
@@ -98,8 +99,10 @@ class UserService:
return user
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid)
referal = int(referal) if str(referal).isdigit() else None
link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
referal = (
int(referal) if str(referal).isdigit() and user_id != int(referal) else None
)
model = NewUserDTO(id=user_id, referal=referal, link=link)
return await self.user_repository.create_user(session, model)
@@ -114,6 +117,7 @@ class UserService:
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
session, user.id
)
db_user = await self.user_repository.get_user_by_id(session, user.id)
if ticket:
try:
@@ -121,7 +125,11 @@ class UserService:
chat_id=settings.admin_group_id,
message_thread_id=ticket.thread_id,
text=utils.format_support_subscription(
subscription, user.full_name, user.id, user.username
subscription=subscription,
full_name=user.full_name,
user_id=user.id,
username=user.username,
balance=db_user.balance,
),
)
return
@@ -148,12 +156,17 @@ class UserService:
ticket = await self.ticket_repository.update_ticket_thread_id(
session, ticket, thread.message_thread_id
)
db_user = await self.user_repository.get_user_by_id(session, ticket.creator_id)
await bot.send_message(
chat_id=settings.admin_group_id,
message_thread_id=ticket.thread_id,
text=utils.format_support_subscription(
subscription, user.full_name, user.id, user.username
subscription=subscription,
full_name=user.full_name,
user_id=user.id,
username=user.username,
balance=db_user,
),
)
@@ -204,7 +217,10 @@ class UserService:
settings.admin_group_id,
message_thread_id=thread.message_thread_id,
text=await self.format_admin_support_message(
ticket_number=ticket.id, user=msg.from_user, rw_user=rw_user
ticket_number=ticket.id,
user=msg.from_user,
rw_user=rw_user,
balance=user.balance,
),
)
await msg.copy_to(
@@ -269,3 +285,23 @@ class UserService:
user = await self.user_repository.update_user_link(session, user_id, link)
return user
async def fetch_user_by_thread(
self, session: AsyncSession, thread_id: int
) -> Optional[User]:
ticket = await self.ticket_repository.get_ticket_by_thread_id(
session, thread_id=thread_id
)
return await self.user_repository.get_user_by_id(session, ticket.creator_id)
async def fetch_referal_by_thread(
self, session: AsyncSession, thread_id: int
) -> Optional[User]:
ticket = await self.ticket_repository.get_ticket_by_thread_id(
session, thread_id=thread_id
)
return await self.user_repository.get_referal_by_user_id(
session, ticket.creator_id
)

View File

@@ -1,11 +1,11 @@
from misc.utils import fetch_quote
from db.models.tickets import TicketStatus
# ============================================================
# PREMIUM EMOJI
# ============================================================
class PremiumEmoji:
def __init__(self, emoji_id: int, emoji: str):
self.emoji_id = emoji_id
@@ -109,6 +109,18 @@ CONTEXT_REINITIATED = (
)
# ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА
# ============================================================
REFERALS_MENU = (
"<b>👥 Реферальная система</b>\n\n"
f"<b>{MALENIA_COINS} Ваш реферальный баланс:</b> "
"<code>{balance}₽</code>\n"
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
"<code>{link}</code>"
)
# ============================================================
# ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================
@@ -126,8 +138,7 @@ WHITELISTS_ALREADY_ON = "уже включены!"
TEMP_SUPPORT_FORM = "обратитесь в поддержку, прикрепив текст ниже:\n\n"
CHECKOUT_PAYMENT_CHOICE = (
"в поддержку чирканите епта там всё обьяснят\n"
+ TEMP_SUPPORT_FORM
"в поддержку чирканите епта там всё обьяснят\n" + TEMP_SUPPORT_FORM
)
@@ -159,6 +170,7 @@ _SUPPORT_USER_DESCRIPTION = (
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"💰 <b>Реферальный баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
@@ -171,7 +183,8 @@ _DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━
SUPPORT_SUBSCRIPTION = (
"<b>🎫 ЗАЯВКА НА ПОКУПКУ:</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n" + _DIVIDER
+ "\n"
+ _DIVIDER
+ "🗓️ Подписка на <b>{duration}</b> месяцев.\n"
+ "📱 Количество устройств: <b>{devices}</b>\n"
+ "🏳️ Белые списки: <b>{whitelist_description}</b>\n\n"
@@ -181,7 +194,8 @@ SUPPORT_SUBSCRIPTION = (
ADMIN_TICKET_WITH_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n" + _DIVIDER
+ "\n"
+ _DIVIDER
+ "📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
+ "{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
@@ -211,5 +225,6 @@ ADMIN_STANDARD_FALLBACK = (
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================
def build_main_menu_text(link: str) -> str:
return MAIN_MENU.format(quote=fetch_quote(), link=link)