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}₽"