feat: implement referral payment system and enhance ticket management

This commit is contained in:
2026-04-18 22:34:15 +07:00
parent 3d19d9189b
commit 78a0ef9750
14 changed files with 165 additions and 31 deletions

View File

@@ -31,8 +31,11 @@ COPY --from=builder /install /usr/local
# Copy application source code # Copy application source code
COPY . . COPY . .
RUN sed -i 's/\r$//' ./entrypoints/startup.sh && \
chmod +x ./entrypoints/startup.sh
# Ensure the startup script is executable # Ensure the startup script is executable
RUN chmod +x entrypoints/startup.sh RUN chmod +x ./entrypoints/startup.sh
# Default command runs linting, migrations, and starts the bot # Default command runs linting, migrations, and starts the bot
CMD ["entrypoints/startup.sh"] CMD ["sh", "./entrypoints/startup.sh"]

View File

@@ -16,8 +16,8 @@ services:
"CMD-SHELL", "CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-malenia} -d ${POSTGRES_DB:-malenia_db}", "pg_isready -U ${POSTGRES_USER:-malenia} -d ${POSTGRES_DB:-malenia_db}",
] ]
interval: 5s interval: 10s
timeout: 10s timeout: 5s
retries: 5 retries: 5
bot: bot:

22
entrypoints/dev.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
echo "[+] Formatting and checking..."
echo
ruff check --fix
black .
echo "[+] checking psql availability"
if ! pg_isready -h localhost -p 5432 ; then
echo "psql is down." >&2
exit 1
fi
echo "[+] psql is available!"
echo
echo "[+] Running migrations..."
echo
alembic upgrade head
echo "[+] Starting bot..."
python main.py

View File

@@ -1,19 +1,6 @@
#!/bin/bash #!/bin/bash
set -e set -e
echo "[+] Formatting and checking..."
echo
ruff check --fix
black .
echo "[+] checking psql availability"
if ! pg_isready -h localhost -p 5432 ; then
echo "psql is down." >&2
exit 1
fi
echo "[+] psql is available!"
echo
echo "[+] Running migrations..." echo "[+] Running migrations..."
echo echo
alembic upgrade head alembic upgrade head

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from keyboards.admins import promotion_confirmation from keyboards.admins import promotion_confirmation
from keyboards.client import build_return_menu from keyboards.client import close_kb
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.promotions import NewPromotionContext from schemas.promotions import NewPromotionContext
from states.admins import AdminStorage from states.admins import AdminStorage
@@ -28,7 +28,7 @@ async def push_cmd(msg: Message, state: FSMContext):
ctx = NewPromotionContext(creator_id=msg.from_user.id) ctx = NewPromotionContext(creator_id=msg.from_user.id)
await msg.delete() await msg.delete()
await msg.answer(ADMIN_PROMOTION_INIT, reply_markup=build_return_menu("delete", "")) await msg.answer(ADMIN_PROMOTION_INIT, reply_markup=close_kb)
await state.set_state(AdminStorage.promotion_msg) await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx}) await state.set_data({"ctx": ctx})

View File

@@ -6,10 +6,19 @@ from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from keyboards.client import referals_kb from keyboards.client import close_kb, referals_kb
from misc.utils import format_share_deep_link from misc.utils import format_share_deep_link
from schemas.common import GeneralMessageContext
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from texts import GENERAL_PROCESSING, REFERALS_MENU from states.admins import AdminStorage
from texts import (
GENERAL_PROCESSING,
INVALID_ARGUMENT_REFPAY,
REFERALS_MENU,
SPECIFY_REFERAL_PAYMENT_AMOUNT,
SUCCESSFUL_REFPAY,
USER_NO_REFERAL,
)
router = Router() router = Router()
@@ -45,17 +54,70 @@ async def refpay(
user = await deps.user_service.fetch_user_by_thread(session, msg.message_thread_id) 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) referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
if not referal: if not referal:
await status_msg.edit_text("У пользователя нет реферала.") await status_msg.edit_text(USER_NO_REFERAL)
return return
amount = command.args amount = command.args
if not amount.isdigit(): if not amount.isdigit():
await status_msg.edit_text( await status_msg.edit_text(INVALID_ARGUMENT_REFPAY)
"❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
)
return return
amount = int(amount) amount = int(amount)
await deps.user_repository.increase_user_balance(session, referal.id, amount) await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text(f"<b>💸 Баланс реферала ({referal.id}) пополнен на {amount}₽</b>") await status_msg.edit_text(SUCCESSFUL_REFPAY.format(referal_id=referal.id, amount=amount))
@router.callback_query(F.message.chat.id == settings.admin_group_id, F.data == "refpay")
async def refpay_cb(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
user = await deps.user_service.fetch_user_by_thread(session, cb.message.message_thread_id)
referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
if not referal:
await cb.message.answer(USER_NO_REFERAL, reply_markup=close_kb)
return
msg = await cb.message.answer(
SPECIFY_REFERAL_PAYMENT_AMOUNT, reply_markup=close_kb
)
ctx = GeneralMessageContext(msg=msg)
await state.set_state(AdminStorage.refpay_amount)
await state.set_data({"ctx": ctx})
@router.message(
AdminStorage.refpay_amount,
F.chat.id == settings.admin_group_id,
F.from_user.is_bot == False, # noqa: E712
)
async def refpay_execute(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
await msg.delete()
ctx: GeneralMessageContext | None = data.get("ctx")
if ctx:
status_msg = ctx.msg
else:
status_msg = await msg.answer(GENERAL_PROCESSING)
amount = msg.text
if not amount.isdigit():
await status_msg.edit_text(INVALID_ARGUMENT_REFPAY, reply_markup=close_kb)
return
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)
amount = int(amount)
await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text(SUCCESSFUL_REFPAY.format(referal_id=referal.id, amount=amount))

View File

@@ -79,3 +79,12 @@ async def close_ticket(
await state.clear() await state.clear()
await deps.user_service.close_ticket(session, msg) await deps.user_service.close_ticket(session, msg)
@router.callback_query(F.message.chat.id == settings.admin_group_id, F.data == "close")
async def close_ticket_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
await deps.user_service.close_ticket(session, cb.message)

View File

@@ -7,3 +7,10 @@ promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
[InlineKeyboardButton(text="", callback_data="decline")], [InlineKeyboardButton(text="", callback_data="decline")],
] ]
).as_markup() ).as_markup()
ticket_actions: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🔒", callback_data="close")],
[InlineKeyboardButton(text="💸 Пополнить баланс реферала", callback_data="refpay")],
]
).as_markup()

View File

@@ -33,6 +33,7 @@ payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
] ]
).as_markup() ).as_markup()
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder([[InlineKeyboardButton(text="", callback_data="delete")]]).as_markup()
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton: def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data) return InlineKeyboardButton(text=text, callback_data=data)

View File

@@ -131,6 +131,12 @@ def format_username(username: str | None) -> str:
return texts.NO_USERNAME return texts.NO_USERNAME
def format_referal_id(referal_id: int | None) -> str:
if referal_id:
return str(referal_id)
return texts.NOTHING_PLACEHOLDER
def convert_duration_to_int(duration: SubscriptionDuration) -> int: def convert_duration_to_int(duration: SubscriptionDuration) -> int:
mapping: dict[str, int] = { mapping: dict[str, int] = {
SubscriptionDuration.MONTH.value: 1, SubscriptionDuration.MONTH.value: 1,
@@ -187,6 +193,7 @@ def format_support_subscription(
user_id: int, user_id: int,
balance: int, balance: int,
username: str | None = None, username: str | None = None,
referal_id: int | None = None,
) -> str: ) -> str:
username = format_username(username) username = format_username(username)
whitelist_description = "ВЫКЛ" whitelist_description = "ВЫКЛ"
@@ -204,6 +211,7 @@ def format_support_subscription(
whitelist_description=whitelist_description, whitelist_description=whitelist_description,
price=calculate_price(subscription), price=calculate_price(subscription),
balance=balance, balance=balance,
referal_id=referal_id,
) )

8
schemas/common.py Normal file
View File

@@ -0,0 +1,8 @@
from dataclasses import dataclass
from aiogram.types import Message
@dataclass
class GeneralMessageContext:
msg: Message

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from db.models.tickets import TicketStatus from db.models.tickets import TicketStatus
from db.models.users import User from db.models.users import User
from keyboards.admins import ticket_actions
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
from repositories.tickets import TicketRepository from repositories.tickets import TicketRepository
@@ -51,9 +52,14 @@ class UserService:
@staticmethod @staticmethod
async def format_admin_support_message( async def format_admin_support_message(
ticket_number: int, user: TelegramUser, rw_user: RWUserInfo | None, balance: int ticket_number: int,
user: TelegramUser,
rw_user: RWUserInfo | None,
balance: int,
referal_id: int | None = None,
): ):
username_display = utils.format_username(user.username) username_display = utils.format_username(user.username)
referal_display = utils.format_referal_id(referal_id)
if rw_user is None: if rw_user is None:
# Пользователь не найден в Remnawave # Пользователь не найден в Remnawave
@@ -62,6 +68,8 @@ class UserService:
full_name=user.full_name, full_name=user.full_name,
username_display=username_display, username_display=username_display,
telegram_id=user.id, telegram_id=user.id,
referal_id=referal_display,
balance=balance,
) )
status_icon, status_text = utils.format_status(rw_user.status) status_icon, status_text = utils.format_status(rw_user.status)
@@ -85,6 +93,7 @@ class UserService:
hwid_limit=hwid_limit, hwid_limit=hwid_limit,
squads=squads, squads=squads,
remnawave_uuid=rw_user.uuid, remnawave_uuid=rw_user.uuid,
referal_id=referal_display,
) )
async def add_user( async def add_user(
@@ -126,7 +135,9 @@ class UserService:
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
balance=db_user.balance, balance=db_user.balance,
referal_id=db_user.referal_id,
), ),
reply_markup=ticket_actions,
) )
return return
except Exception: except Exception:
@@ -159,7 +170,9 @@ class UserService:
user_id=user.id, user_id=user.id,
username=user.username, username=user.username,
balance=db_user.balance, balance=db_user.balance,
referal_id=db_user.referal_id,
), ),
reply_markup=ticket_actions,
) )
async def support_forward_message(self, session: AsyncSession, msg: Message, rw: RemnawaveSDK): async def support_forward_message(self, session: AsyncSession, msg: Message, rw: RemnawaveSDK):
@@ -205,7 +218,9 @@ class UserService:
user=msg.from_user, user=msg.from_user,
rw_user=rw_user, rw_user=rw_user,
balance=user.balance, balance=user.balance,
referal_id=user.referal_id,
), ),
reply_markup=ticket_actions,
) )
await msg.copy_to(settings.admin_group_id, message_thread_id=thread.message_thread_id) await msg.copy_to(settings.admin_group_id, message_thread_id=thread.message_thread_id)
@@ -230,6 +245,8 @@ class UserService:
async def close_ticket(self, session: AsyncSession, msg: Message): async def close_ticket(self, session: AsyncSession, msg: Message):
thread_id = msg.message_thread_id thread_id = msg.message_thread_id
if not thread_id:
return
ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id) ticket = await self.ticket_repository.get_ticket_by_thread_id(session, thread_id=thread_id)
await msg.bot.edit_forum_topic( await msg.bot.edit_forum_topic(
@@ -247,9 +264,14 @@ class UserService:
async def update_user_link( async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> User | None: ) -> User:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id) rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
user = await self.user_repository.get_user_by_id(session, user_id)
if not rw_user:
return user
link = utils.get_subscription_link(rw_user.short_uuid) link = utils.get_subscription_link(rw_user.short_uuid)
user = await self.user_repository.update_user_link(session, user_id, link) user = await self.user_repository.update_user_link(session, user_id, link)

View File

@@ -3,3 +3,4 @@ from aiogram.fsm.state import State, StatesGroup
class AdminStorage(StatesGroup): class AdminStorage(StatesGroup):
promotion_msg = State() promotion_msg = State()
refpay_amount = State()

View File

@@ -163,6 +163,7 @@ _SUPPORT_USER_DESCRIPTION = (
"👤 <b>Профиль:</b> {full_name}\n" "👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n" "🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n" "🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>" "💰 <b>Баланс:</b> <code>{balance}</code>"
) )
@@ -202,9 +203,8 @@ ADMIN_TICKET_WITH_RW = (
ADMIN_TICKET_WITHOUT_RW = ( ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n" "🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n" + _SUPPORT_USER_DESCRIPTION
"🔗 <b>Alias:</b> {username_display}\n" + "\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
+ _DIVIDER + _DIVIDER
+ "⚠️ <b>Remnawave-профиль отсутствует</b>" + "⚠️ <b>Remnawave-профиль отсутствует</b>"
) )
@@ -219,3 +219,7 @@ ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Про
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки" ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?" ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям." SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."
SPECIFY_REFERAL_PAYMENT_AMOUNT = "Укажите количество средств для перевода рефералу пользователя."
USER_NO_REFERAL = "У пользователя нет реферала."
INVALID_ARGUMENT_REFPAY = "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
SUCCESSFUL_REFPAY = "<b>💸 Баланс реферала ({referal_id}) пополнен на {amount}₽</b>"