diff --git a/Dockerfile b/Dockerfile index cae46cf..22ad9d7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,8 +31,11 @@ COPY --from=builder /install /usr/local # Copy application source code COPY . . +RUN sed -i 's/\r$//' ./entrypoints/startup.sh && \ + chmod +x ./entrypoints/startup.sh + # 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 -CMD ["entrypoints/startup.sh"] +CMD ["sh", "./entrypoints/startup.sh"] diff --git a/docker-compose.yml b/docker-compose.yml index d651a01..142465b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,8 +16,8 @@ services: "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-malenia} -d ${POSTGRES_DB:-malenia_db}", ] - interval: 5s - timeout: 10s + interval: 10s + timeout: 5s retries: 5 bot: diff --git a/entrypoints/dev.sh b/entrypoints/dev.sh new file mode 100644 index 0000000..283f080 --- /dev/null +++ b/entrypoints/dev.sh @@ -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 \ No newline at end of file diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index 283f080..4ed47f8 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -1,19 +1,6 @@ #!/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 diff --git a/handlers/ads.py b/handlers/ads.py index 2a033fb..cd796cd 100644 --- a/handlers/ads.py +++ b/handlers/ads.py @@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import settings 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.promotions import NewPromotionContext 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) 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_data({"ctx": ctx}) diff --git a/handlers/referals.py b/handlers/referals.py index d72b7dd..593afaa 100644 --- a/handlers/referals.py +++ b/handlers/referals.py @@ -6,10 +6,19 @@ from aiogram.utils.deep_linking import create_start_link from sqlalchemy.ext.asyncio import AsyncSession 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 schemas.common import GeneralMessageContext 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() @@ -45,17 +54,70 @@ async def refpay( 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("❌ У пользователя нет реферала.") + await status_msg.edit_text(USER_NO_REFERAL) return amount = command.args if not amount.isdigit(): - await status_msg.edit_text( - "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя." - ) + await status_msg.edit_text(INVALID_ARGUMENT_REFPAY) return amount = int(amount) await deps.user_repository.increase_user_balance(session, referal.id, amount) - await status_msg.edit_text(f"💸 Баланс реферала ({referal.id}) пополнен на {amount}₽") + 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)) diff --git a/handlers/support.py b/handlers/support.py index a6f8c53..37e388e 100644 --- a/handlers/support.py +++ b/handlers/support.py @@ -79,3 +79,12 @@ async def close_ticket( await state.clear() 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) diff --git a/keyboards/admins.py b/keyboards/admins.py index 2d1d0e6..75375f5 100644 --- a/keyboards/admins.py +++ b/keyboards/admins.py @@ -7,3 +7,10 @@ promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder( [InlineKeyboardButton(text="❌", callback_data="decline")], ] ).as_markup() + +ticket_actions: InlineKeyboardMarkup = InlineKeyboardBuilder( + [ + [InlineKeyboardButton(text="🔒", callback_data="close")], + [InlineKeyboardButton(text="💸 Пополнить баланс реферала", callback_data="refpay")], + ] +).as_markup() diff --git a/keyboards/client.py b/keyboards/client.py index f509194..c76e873 100644 --- a/keyboards/client.py +++ b/keyboards/client.py @@ -33,6 +33,7 @@ payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder( ] ).as_markup() +close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder([[InlineKeyboardButton(text="❌", callback_data="delete")]]).as_markup() def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton: return InlineKeyboardButton(text=text, callback_data=data) diff --git a/misc/utils.py b/misc/utils.py index 14d2a3f..69c237f 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -131,6 +131,12 @@ def format_username(username: str | None) -> str: 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: mapping: dict[str, int] = { SubscriptionDuration.MONTH.value: 1, @@ -187,6 +193,7 @@ def format_support_subscription( user_id: int, balance: int, username: str | None = None, + referal_id: int | None = None, ) -> str: username = format_username(username) whitelist_description = "ВЫКЛ" @@ -204,6 +211,7 @@ def format_support_subscription( whitelist_description=whitelist_description, price=calculate_price(subscription), balance=balance, + referal_id=referal_id, ) diff --git a/schemas/common.py b/schemas/common.py new file mode 100644 index 0000000..e53b22d --- /dev/null +++ b/schemas/common.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from aiogram.types import Message + + +@dataclass +class GeneralMessageContext: + msg: Message diff --git a/services/user_service.py b/services/user_service.py index 0582b27..f7cc1fe 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from config import settings from db.models.tickets import TicketStatus from db.models.users import User +from keyboards.admins import ticket_actions from misc import utils from repositories import UserRepository from repositories.tickets import TicketRepository @@ -51,9 +52,14 @@ class UserService: @staticmethod 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) + referal_display = utils.format_referal_id(referal_id) if rw_user is None: # Пользователь не найден в Remnawave @@ -62,6 +68,8 @@ class UserService: full_name=user.full_name, username_display=username_display, telegram_id=user.id, + referal_id=referal_display, + balance=balance, ) status_icon, status_text = utils.format_status(rw_user.status) @@ -85,6 +93,7 @@ class UserService: hwid_limit=hwid_limit, squads=squads, remnawave_uuid=rw_user.uuid, + referal_id=referal_display, ) async def add_user( @@ -126,7 +135,9 @@ class UserService: user_id=user.id, username=user.username, balance=db_user.balance, + referal_id=db_user.referal_id, ), + reply_markup=ticket_actions, ) return except Exception: @@ -159,7 +170,9 @@ class UserService: user_id=user.id, username=user.username, 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): @@ -205,7 +218,9 @@ class UserService: user=msg.from_user, rw_user=rw_user, 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) @@ -230,6 +245,8 @@ class UserService: async def close_ticket(self, session: AsyncSession, msg: Message): 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) await msg.bot.edit_forum_topic( @@ -247,9 +264,14 @@ class UserService: async def update_user_link( self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK - ) -> User | None: + ) -> User: 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) user = await self.user_repository.update_user_link(session, user_id, link) diff --git a/states/admins.py b/states/admins.py index b35d285..517e455 100644 --- a/states/admins.py +++ b/states/admins.py @@ -3,3 +3,4 @@ from aiogram.fsm.state import State, StatesGroup class AdminStorage(StatesGroup): promotion_msg = State() + refpay_amount = State() diff --git a/texts.py b/texts.py index d0268ed..b8d1dec 100644 --- a/texts.py +++ b/texts.py @@ -163,6 +163,7 @@ _SUPPORT_USER_DESCRIPTION = ( "👤 Профиль: {full_name}\n" "🔗 Alias: {username_display}\n" "🆔 UID: {telegram_id}\n\n" + "❤️ Referal UID: {referal_id}\n\n" "💰 Баланс: {balance}" ) @@ -202,9 +203,8 @@ ADMIN_TICKET_WITH_RW = ( ADMIN_TICKET_WITHOUT_RW = ( "🎫 ТИКЕТ T-{ticket_number:04d}\n\n" - "👤 Имя: {full_name}\n" - "🔗 Alias: {username_display}\n" - "🆔 UID: {telegram_id}\n\n" + + _SUPPORT_USER_DESCRIPTION + + "\n" + _DIVIDER + "⚠️ Remnawave-профиль отсутствует" ) @@ -219,3 +219,7 @@ ADMIN_STANDARD_FALLBACK = "❌ Ошибка выполнения. Про ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки" ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?" SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям." +SPECIFY_REFERAL_PAYMENT_AMOUNT = "Укажите количество средств для перевода рефералу пользователя." +USER_NO_REFERAL = "❌ У пользователя нет реферала." +INVALID_ARGUMENT_REFPAY = "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя." +SUCCESSFUL_REFPAY = "💸 Баланс реферала ({referal_id}) пополнен на {amount}₽"