Update project configuration and codebase

This commit is contained in:
2026-04-12 12:19:53 +07:00
parent 0ed0ad2b92
commit 0ac8ed8d64
25 changed files with 273 additions and 310 deletions

View File

@@ -1 +0,0 @@
3.13

View File

@@ -1,7 +1,6 @@
from typing import Optional
from pydantic_settings import BaseSettings
from pydantic import Field
from dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings
load_dotenv(override=True)
@@ -9,7 +8,7 @@ load_dotenv(override=True)
class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field(alias="POSTGRES_URL")
proxy: Optional[str] = Field(None, alias="PROXY")
proxy: str | None = Field(None, alias="PROXY")
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL")

View File

@@ -1,4 +1,4 @@
from .users import User
from .tickets import Ticket
from .users import User
__all__ = ["User", "Ticket"]
__all__ = ["Ticket", "User"]

View File

@@ -1,5 +1,6 @@
from enum import Enum as VanillaEnum
from sqlalchemy import INTEGER, BIGINT, Enum
from sqlalchemy import BIGINT, INTEGER, Enum
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base

View File

@@ -7,9 +7,7 @@ from db.base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
BIGINT, nullable=False, unique=True, primary_key=True
)
id: Mapped[int] = mapped_column(BIGINT, nullable=False, unique=True, primary_key=True)
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,4 +1,4 @@
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from config import settings

View File

@@ -1,6 +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
from .menus import router as menus_router
from .referals import router as referal_router
from .support import router as support_router
routers = [referal_router, menus_router, support_router, buy_router]

View File

@@ -1,15 +1,16 @@
import logging
from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import (
devices_selector,
duration_selector,
return_to_menu,
payment_gateways,
return_to_menu,
)
from misc.utils import (
calculate_price,
@@ -17,7 +18,7 @@ from misc.utils import (
)
from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan
from services.user_service import UserServiceException
from services.user_service import UserServiceError
from states.buy import SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
@@ -30,7 +31,6 @@ from texts import (
SUBSCRIPTION_DURATION_SELECTOR,
SUPPORT_MSG_SENT,
)
from config import settings
router = Router()
logger = logging.getLogger(__name__)
@@ -55,7 +55,7 @@ async def device_count_select(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan())
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
cb_data = cb.data.split(":")
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
@@ -75,17 +75,13 @@ async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan())
ctx.whitelists = (
not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
)
ctx: SubscriptionPlan | None = data.get("ctx", SubscriptionPlan())
ctx.whitelists = not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
try:
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(
current=ctx.devices, whitelists=ctx.whitelists
),
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
)
except Exception as exc:
if "message is not modified:" in str(exc):
@@ -101,7 +97,7 @@ async def select_duration_init(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.answer(CALLBACK_FALLBACK)
return
@@ -109,9 +105,7 @@ async def select_duration_init(cb: CallbackQuery, state: FSMContext):
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
if "message is not modified:" in str(exc):
@@ -129,7 +123,7 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.answer(CONTEXT_REINITIATED)
ctx = SubscriptionPlan()
@@ -140,9 +134,7 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
)
except Exception as exc:
if "message is not modified:" in str(exc):
@@ -160,7 +152,7 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
@@ -181,18 +173,16 @@ async def checkout_support(
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
ctx: SubscriptionPlan | None = data.get("ctx")
if not ctx:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
await cb.message.edit_text(GENERAL_PROCESSING)
try:
await deps.user_service.create_subscription_ticket(
session, cb.from_user, ctx, cb.bot
)
await deps.user_service.create_subscription_ticket(session, cb.from_user, ctx, cb.bot)
await cb.message.edit_text(SUPPORT_MSG_SENT)
except UserServiceException:
except UserServiceError:
await cb.message.edit_text(STANDARD_FALLBACK)
raise
except Exception:

View File

@@ -1,13 +1,13 @@
from aiogram import Router, F
from aiogram.filters import CommandStart, CommandObject, Command
from aiogram.types import CallbackQuery, Message
from aiogram import F, Router
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from misc.utils import format_subscription_link
from schemas.di import DependenciesDTO
from keyboards.client import main_menu, return_to_menu
from texts import GENERAL_PROCESSING, build_main_menu_text, FAQ_TEXT
from misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO
from texts import FAQ_TEXT, GENERAL_PROCESSING
router = Router()
@@ -47,9 +47,7 @@ async def standard_start(
):
await state.clear()
user = await deps.user_service.add_user(
session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk
)
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.reply(
build_main_menu_text(format_subscription_link(user.subscription_link)),
@@ -77,9 +75,7 @@ async def update_link(
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING)
user = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.answer("")
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),

View File

@@ -1,15 +1,15 @@
from aiogram import Router, F
from aiogram import F, Router
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 aiogram.types import CallbackQuery, Message
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 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()
@@ -57,7 +57,5 @@ async def refpay(
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>"
)
await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text(f"<b>💸 Баланс реферала ({referal.id}) пополнен на {amount}₽</b>")

View File

@@ -1,16 +1,17 @@
import logging
from aiogram import Router, F
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import return_to_menu
from schemas.di import DependenciesDTO
from services.user_service import UserServiceException
from services.user_service import UserServiceError
from states.support import SupportStorage
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT
from keyboards.client import return_to_menu
router = Router()
logger = logging.getLogger(__name__)
@@ -34,7 +35,7 @@ async def received_message(
try:
await deps.user_service.support_forward_message(session, msg, deps.rw_sdk)
except UserServiceException:
except UserServiceError:
await msg.reply(STANDARD_FALLBACK)
raise
except Exception:

View File

@@ -24,9 +24,7 @@ _return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[_return_to_menu_btn]]
).as_markup()
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
@@ -36,9 +34,7 @@ payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
).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)
@@ -88,9 +84,7 @@ def duration_selector(
callback_data=f"duration:{val}",
)
builder.row(
InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm")
)
builder.row(InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm"))
builder.row(_return_btn(back_cb))
return builder.as_markup()

10
main.py
View File

@@ -1,19 +1,19 @@
import logging
import asyncio
import logging
from remnawave import RemnawaveSDK
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK
from schemas.di import DependenciesDTO
from config import settings
from db.session import async_session
from handlers import routers
from middlewares import DIMiddleware
from config import settings
from repositories.tickets import TicketRepository
from repositories.users import UserRepository
from schemas.di import DependenciesDTO
from services.user_service import UserService
from db.session import async_session
logging.basicConfig(level=logging.DEBUG)

View File

@@ -1,4 +1,6 @@
from typing import Callable, Awaitable, Any
from collections.abc import Awaitable, Callable
from typing import Any
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -7,9 +9,7 @@ from schemas.di import DependenciesDTO
class DIMiddleware(BaseMiddleware):
def __init__(
self, deps: DependenciesDTO, session_factory: async_sessionmaker
) -> None:
def __init__(self, deps: DependenciesDTO, session_factory: async_sessionmaker) -> None:
self.deps = deps
self.session_factory = session_factory

View File

@@ -1,11 +1,10 @@
import urllib.parse
from typing import Optional
from datetime import UTC, datetime, timedelta
from random import choice
from datetime import datetime, timedelta, timezone
import texts
from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
import texts
QUOTES: list[str] = [
"Верните себе свободный интернет.",
@@ -47,12 +46,16 @@ def fetch_quote() -> str:
def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
if num_bytes >= 1_073_741_824: # >= 1 ГБ
return f"{num_bytes / 1_073_741_824:.1f} ГБ"
if num_bytes >= 1_048_576: # >= 1 МБ
return f"{num_bytes / 1_048_576:.1f} МБ"
if num_bytes >= 1_024: # >= 1 КБ
return f"{num_bytes / 1_024:.1f} КБ"
gb_capacity = 1_073_741_824
mb_capacity = 1_048_576
kb_capacity = 1_024
if num_bytes >= gb_capacity: # >= 1 ГБ
return f"{num_bytes / gb_capacity:.1f} ГБ"
if num_bytes >= mb_capacity: # >= 1 МБ
return f"{num_bytes / mb_capacity:.1f} МБ"
if num_bytes >= kb_capacity: # >= 1 КБ
return f"{num_bytes / kb_capacity:.1f} КБ"
return f"{num_bytes:.0f} Б"
@@ -69,7 +72,7 @@ def format_traffic(used: float, limit: int) -> str:
return texts.TRAFFIC_WITH_LIMIT.format(used=used_str, limit=limit_str)
def format_hwid_limit(limit: Optional[int]) -> str:
def format_hwid_limit(limit: int | None) -> str:
"""Форматировать лимит HWID: число или «без ограничений»."""
if limit is None or limit == 0:
return texts.UNLIMITED_HWID
@@ -90,11 +93,11 @@ def format_days_left(expire_at: datetime) -> str:
- «истекает сегодня» — если осталось 0 дней
- «истекла N дн. назад» — если уже истекла
"""
now = datetime.now(tz=timezone.utc)
now = datetime.now(tz=UTC)
# Убедимся что expire_at timezone-aware
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
expire_at = expire_at.replace(tzinfo=UTC)
delta = expire_at - now
days = delta.days # целое, может быть отрицательным
@@ -118,12 +121,10 @@ def format_status(status: str) -> tuple[str, str]:
"LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED),
"ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN),
}
return mapping.get(
status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN)
)
return mapping.get(status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN))
def format_username(username: Optional[str]) -> str:
def format_username(username: str | None) -> str:
"""Форматировать username: «@user» или «нет username»."""
if username:
return "@" + username
@@ -151,14 +152,14 @@ def convert_int_to_duration(value: int) -> SubscriptionDuration:
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
MONTH_DELTA = timedelta(days=30)
month_delta = timedelta(days=30)
mapping: dict[str, timedelta] = {
SubscriptionDuration.MONTH.value: MONTH_DELTA,
SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3,
SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6,
SubscriptionDuration.YEAR.value: MONTH_DELTA * 12,
SubscriptionDuration.MONTH.value: month_delta,
SubscriptionDuration.THREE_MONTHS.value: month_delta * 3,
SubscriptionDuration.SIX_MONTHS.value: month_delta * 6,
SubscriptionDuration.YEAR.value: month_delta * 12,
}
return mapping.get(duration.value, MONTH_DELTA)
return mapping.get(duration.value, month_delta)
def calculate_price(subscription: SubscriptionPlan):
@@ -172,7 +173,7 @@ def calculate_price(subscription: SubscriptionPlan):
) * convert_duration_to_int(subscription.duration)
def format_subscription_link(link: Optional[str] = None):
def format_subscription_link(link: str | None = None):
return f"<code>{link or '...'}</code>"
@@ -185,7 +186,7 @@ def format_support_subscription(
full_name: str,
user_id: int,
balance: int,
username: Optional[str] = None,
username: str | None = None,
) -> str:
username = format_username(username)
whitelist_description = "ВЫКЛ"
@@ -208,3 +209,7 @@ def format_support_subscription(
def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid
def build_main_menu_text(link: str) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link)

58
pyproject.toml Normal file
View File

@@ -0,0 +1,58 @@
[tool.black]
line-length = 100
target-version = ['py313']
include = '\.pyi?$'
extend-exclude = '''
/(
# Исключаем стандартные папки
\.git
| \.venv
| build
| dist
| alembic
)/
'''
[tool.ruff]
# Базовые настройки
line-length = 100
target-version = "py313"
# Исключаем alembic полностью
exclude = [
".git",
".venv",
"build",
"dist",
"alembic",
]
[tool.ruff.lint]
select = [
"E",
"W",
"F",
"I",
"N",
"UP",
"B",
"SIM",
"PL",
"RUF",
"TID",
"PT",
]
ignore = [
"E501",
"D100",
"D104",
"G004",
"PLR0913",
"RUF001",
"RUF002",
"RUF003"
]
[tool.ruff.lint.isort]
combine-as-imports = true

View File

@@ -1,4 +1,3 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +7,7 @@ from schemas.tickets import NewTicketDTO
class TicketRepository:
async def get_ticket_by_user_id(
self, session: AsyncSession, user_id: int, status: Optional[TicketStatus] = None
self, session: AsyncSession, user_id: int, status: TicketStatus | None = None
) -> list[Ticket]:
stmt = select(Ticket).where(Ticket.creator_id == user_id)
if status:
@@ -19,7 +18,7 @@ class TicketRepository:
async def get_available_ticket_by_user_id(
self, session: AsyncSession, user_id: int
) -> Optional[Ticket]:
) -> Ticket | None:
stmt = (
select(Ticket)
.where(Ticket.creator_id == user_id)
@@ -30,17 +29,13 @@ class TicketRepository:
tickets = list(res.scalars().all())
return tickets[0] if tickets else None
async def get_ticket_by_thread_id(
self, session: AsyncSession, thread_id: int
) -> Optional[Ticket]:
async def get_ticket_by_thread_id(self, session: AsyncSession, thread_id: int) -> Ticket | None:
stmt = select(Ticket).where(Ticket.thread_id == thread_id)
res = await session.execute(stmt)
return res.scalar_one_or_none()
async def create_ticket(
self, session: AsyncSession, ticket_data: NewTicketDTO
) -> Ticket:
async def create_ticket(self, session: AsyncSession, ticket_data: NewTicketDTO) -> Ticket:
ticket = Ticket(creator_id=ticket_data.creator_id)
session.add(ticket)
await session.commit()
@@ -54,9 +49,7 @@ class TicketRepository:
await session.commit()
return ticket
async def update_ticket_thread_id(
self, session: AsyncSession, ticket: Ticket, thread_id: int
):
async def update_ticket_thread_id(self, session: AsyncSession, ticket: Ticket, thread_id: int):
ticket.thread_id = thread_id
await session.commit()
return ticket

View File

@@ -1,4 +1,3 @@
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,9 +6,7 @@ from schemas.users import NewUserDTO
class UserRepository:
async def get_user_by_id(
self, session: AsyncSession, user_id: int
) -> Optional[User]:
async def get_user_by_id(self, session: AsyncSession, user_id: int) -> User | None:
stmt = select(User).where(User.id == user_id)
result = await session.execute(stmt)
@@ -28,7 +25,7 @@ class UserRepository:
async def update_user_topic(
self, session: AsyncSession, user_id: int, thread_id: int
) -> Optional[User]:
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
@@ -37,9 +34,7 @@ class UserRepository:
await session.commit()
return user
async def update_user_link(
self, session: AsyncSession, user_id: int, link: str
) -> Optional[User]:
async def update_user_link(self, session: AsyncSession, user_id: int, link: str) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
@@ -50,7 +45,7 @@ class UserRepository:
async def update_user_balance(
self, session: AsyncSession, user_id: int, balance: int
) -> Optional[User]:
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
@@ -61,7 +56,7 @@ class UserRepository:
async def increase_user_balance(
self, session: AsyncSession, user_id: int, amount: int
) -> Optional[User]:
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None

View File

@@ -1,9 +1,10 @@
from dataclasses import dataclass
from remnawave import RemnawaveSDK
from repositories.tickets import TicketRepository
from repositories.users import UserRepository
from services.user_service import UserService
from remnawave import RemnawaveSDK
@dataclass

View File

@@ -1,15 +1,14 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class NewUserDTO:
id: int
referal: Optional[int] = None
link: Optional[str] = None
referal: int | None = None
link: str | None = None
@dataclass
class TicketCreator:
id: int
username: Optional[str] = None
username: str | None = None

View File

@@ -1,7 +1,6 @@
import logging
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Optional
from datetime import UTC, datetime, timedelta
from uuid import UUID
from remnawave import RemnawaveSDK
@@ -23,9 +22,9 @@ class RWUserInfo:
expire_at: datetime
used_traffic_bytes: float
traffic_limit_bytes: int # 0 = unlimited
hwid_device_limit: Optional[int] # None = unlimited
hwid_device_limit: int | None # None = unlimited
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
description: Optional[str]
description: str | None
short_uuid: str
@@ -74,7 +73,7 @@ def _parse_user(user_dto) -> RWUserInfo:
async def get_user_by_telegram_id(
sdk: RemnawaveSDK,
telegram_id: int,
) -> Optional[RWUserInfo]:
) -> RWUserInfo | None:
"""
Получить первого пользователя Remnawave, привязанного к Telegram ID.
Возвращает None, если пользователь не найден или Remnawave недоступен.
@@ -115,7 +114,7 @@ async def add_days(
sdk: RemnawaveSDK,
user_uuid: str,
days: int,
) -> Optional[datetime]:
) -> datetime | None:
"""
Добавить N дней к expire_at пользователя.
Возвращает новую дату окончания или None при ошибке.
@@ -131,7 +130,7 @@ async def add_days(
# Обеспечиваем timezone-aware datetime
current_expire = user_dto.expire_at
if current_expire.tzinfo is None:
current_expire = current_expire.replace(tzinfo=timezone.utc)
current_expire = current_expire.replace(tzinfo=UTC)
new_expire = current_expire + timedelta(days=days)
@@ -146,7 +145,7 @@ async def add_days(
new_dt = result.expire_at
if new_dt is not None and new_dt.tzinfo is None:
new_dt = new_dt.replace(tzinfo=timezone.utc)
new_dt = new_dt.replace(tzinfo=UTC)
return new_dt
except Exception as exc:
logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc)
@@ -157,7 +156,7 @@ async def remove_days(
sdk: RemnawaveSDK,
user_uuid: str,
days: int,
) -> Optional[datetime]:
) -> datetime | None:
"""
Вычесть N дней из expire_at пользователя.
Возвращает новую дату окончания или None при ошибке.
@@ -170,7 +169,7 @@ async def remove_days(
current_expire = user_dto.expire_at
if current_expire.tzinfo is None:
current_expire = current_expire.replace(tzinfo=timezone.utc)
current_expire = current_expire.replace(tzinfo=UTC)
new_expire = current_expire - timedelta(days=days)
@@ -185,7 +184,7 @@ async def remove_days(
new_dt = result.expire_at
if new_dt is not None and new_dt.tzinfo is None:
new_dt = new_dt.replace(tzinfo=timezone.utc)
new_dt = new_dt.replace(tzinfo=UTC)
return new_dt
except Exception as exc:
logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc)
@@ -203,7 +202,7 @@ async def set_hwid_limit(
Возвращает True при успехе, False при ошибке.
"""
try:
hwid_value: Optional[int] = None if limit == 0 else limit
hwid_value: int | None = None if limit == 0 else limit
await sdk.users.update_user(
UpdateUserRequestDto(
uuid=UUID(user_uuid),

View File

@@ -1,30 +1,28 @@
from typing import Optional
from aiogram import Bot
from aiogram.types import Message, ReactionTypeEmoji
from aiogram.types import User as TelegramUser
from sqlalchemy.ext.asyncio import AsyncSession
from aiogram.types import Message, ReactionTypeEmoji, User as TelegramUser
from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from db.models.tickets import TicketStatus
from db.models.users import User
from misc import utils
from repositories import UserRepository
from repositories.tickets import TicketRepository
from schemas.subscriptions import SubscriptionPlan
from schemas.tickets import NewTicketDTO
from schemas.users import NewUserDTO, TicketCreator
from repositories import UserRepository
from repositories.tickets import TicketRepository
from services.rw import RWUserInfo, get_user_by_telegram_id
from texts import (
ADMIN_STANDARD_FALLBACK,
ADMIN_TICKET_WITH_RW,
ADMIN_TICKET_WITHOUT_RW,
SUPPORT_SIGNATURE,
TICKET_STATUS_EMOJIS,
ADMIN_TICKET_WITHOUT_RW,
)
from misc import utils
class UserServiceException(Exception): ...
class UserServiceError(Exception): ...
class UserService:
@@ -37,8 +35,8 @@ class UserService:
@staticmethod
def format_ticket_name(
creator: TicketCreator,
status: Optional[TicketStatus] = TicketStatus.OPEN,
ticket_id: Optional[int] = None,
status: TicketStatus | None = TicketStatus.OPEN,
ticket_id: int | None = None,
):
return (
TICKET_STATUS_EMOJIS[status.value]
@@ -49,7 +47,7 @@ class UserService:
@staticmethod
async def format_admin_support_message(
ticket_number: int, user: User, rw_user: Optional[RWUserInfo], balance: int
ticket_number: int, user: TelegramUser, rw_user: RWUserInfo | None, balance: int
):
username_display = utils.format_username(user.username)
@@ -65,9 +63,7 @@ class UserService:
status_icon, status_text = utils.format_status(rw_user.status)
expire_date = rw_user.expire_at.strftime("%d.%m.%Y")
days_left = utils.format_days_left(rw_user.expire_at)
used_traffic = utils.format_traffic(
rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes
)
used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes)
hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit)
squads = utils.format_squads(rw_user.active_squads)
@@ -92,7 +88,7 @@ class UserService:
session: AsyncSession,
user_id: int,
rw_sdk: RemnawaveSDK,
referal: Optional[int] = None,
referal: int | None = None,
) -> User:
user = await self.user_repository.get_user_by_id(session, user_id)
if user:
@@ -100,9 +96,7 @@ class UserService:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
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
)
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,9 +108,7 @@ class UserService:
subscription: SubscriptionPlan,
bot: Bot,
):
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
session, user.id
)
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:
@@ -143,15 +135,11 @@ class UserService:
ticket_dto = NewTicketDTO(
creator_id=user.id,
)
ticket = await self.ticket_repository.create_ticket(
session, ticket_data=ticket_dto
)
ticket = await self.ticket_repository.create_ticket(session, ticket_data=ticket_dto)
thread = await bot.create_forum_topic(
settings.admin_group_id,
self.format_ticket_name(
creator, ticket_id=ticket.id, status=TicketStatus.PENDING
),
self.format_ticket_name(creator, ticket_id=ticket.id, status=TicketStatus.PENDING),
)
ticket = await self.ticket_repository.update_ticket_thread_id(
session, ticket, thread.message_thread_id
@@ -166,20 +154,16 @@ class UserService:
full_name=user.full_name,
user_id=user.id,
username=user.username,
balance=db_user,
balance=db_user.balance,
),
)
async def support_forward_message(
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
):
async def support_forward_message(self, session: AsyncSession, msg: Message, rw: RemnawaveSDK):
user = await self.user_repository.get_user_by_id(session, msg.from_user.id)
if not user:
raise UserServiceException("User %d is not found.", msg.from_user.id)
raise UserServiceError("User %d is not found.", msg.from_user.id)
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
session, user.id
)
ticket = await self.ticket_repository.get_available_ticket_by_user_id(session, user.id)
if ticket:
try:
@@ -199,15 +183,11 @@ class UserService:
ticket_dto = NewTicketDTO(
creator_id=user.id,
)
ticket = await self.ticket_repository.create_ticket(
session, ticket_data=ticket_dto
)
ticket = await self.ticket_repository.create_ticket(session, ticket_data=ticket_dto)
thread = await msg.bot.create_forum_topic(
settings.admin_group_id,
self.format_ticket_name(
creator, ticket_id=ticket.id, status=TicketStatus.PENDING
),
self.format_ticket_name(creator, ticket_id=ticket.id, status=TicketStatus.PENDING),
)
ticket = await self.ticket_repository.update_ticket_thread_id(
session, ticket, thread.message_thread_id
@@ -223,26 +203,18 @@ class UserService:
balance=user.balance,
),
)
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)
async def support_answer(self, session: AsyncSession, msg: Message):
thread_id = msg.message_thread_id
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)
if ticket.status == TicketStatus.PENDING:
await self.ticket_repository.update_ticket_status(
session, ticket, TicketStatus.OPEN
)
await self.ticket_repository.update_ticket_status(session, ticket, TicketStatus.OPEN)
await msg.bot.edit_forum_topic(
settings.admin_group_id,
ticket.thread_id,
name=self.format_ticket_name(
TicketCreator(ticket.creator_id), ticket_id=ticket.id
),
name=self.format_ticket_name(TicketCreator(ticket.creator_id), ticket_id=ticket.id),
)
try:
@@ -254,9 +226,7 @@ class UserService:
async def close_ticket(self, session: AsyncSession, msg: Message):
thread_id = msg.message_thread_id
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(
settings.admin_group_id,
@@ -268,16 +238,12 @@ class UserService:
),
)
await self.ticket_repository.update_ticket_status(
session, ticket, TicketStatus.CLOSED
)
await msg.bot.close_forum_topic(
settings.admin_group_id, message_thread_id=thread_id
)
await self.ticket_repository.update_ticket_status(session, ticket, TicketStatus.CLOSED)
await msg.bot.close_forum_topic(settings.admin_group_id, message_thread_id=thread_id)
async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> Optional[User]:
) -> User | None:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid)
@@ -286,22 +252,12 @@ class UserService:
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
)
async def fetch_user_by_thread(self, session: AsyncSession, thread_id: int) -> User | None:
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
)
async def fetch_referal_by_thread(self, session: AsyncSession, thread_id: int) -> User | None:
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
)
return await self.user_repository.get_referal_by_user_id(session, ticket.creator_id)

View File

@@ -1,4 +1,4 @@
from aiogram.fsm.state import StatesGroup, State
from aiogram.fsm.state import State, StatesGroup
class SubscriptionStorage(StatesGroup):

View File

@@ -1,4 +1,4 @@
from aiogram.fsm.state import StatesGroup, State
from aiogram.fsm.state import State, StatesGroup
class SupportStorage(StatesGroup):

143
texts.py
View File

@@ -1,4 +1,3 @@
from misc.utils import fetch_quote
from db.models.tickets import TicketStatus
# ============================================================
@@ -29,9 +28,9 @@ MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
# ============================================================
TICKET_STATUS_EMOJIS = {
TicketStatus.CLOSED.value: "🔴",
TicketStatus.OPEN.value: "🟢",
TicketStatus.PENDING.value: "🟡",
TicketStatus.CLOSED.value: "🌑", # Более сдержанно
TicketStatus.OPEN.value: "📡", # В эфире
TicketStatus.PENDING.value: "",
}
@@ -41,17 +40,17 @@ TICKET_STATUS_EMOJIS = {
# — Иконки статусов
STATUS_ICON_ACTIVE = "🟢"
STATUS_ICON_DISABLED = "🔴"
STATUS_ICON_EXPIRED = "🟡"
STATUS_ICON_DISABLED = ""
STATUS_ICON_EXPIRED = "🔴"
STATUS_ICON_LIMITED = "🟠"
STATUS_ICON_UNKNOWN = ""
STATUS_ICON_UNKNOWN = ""
# — Тексты статусов
STATUS_ACTIVE = "Активна"
STATUS_DISABLED = "Отключена"
STATUS_EXPIRED = "Истекла"
STATUS_LIMITED = "Превышен лимит трафика"
STATUS_UNKNOWN = "Неизвестен"
STATUS_ACTIVE = "работает"
STATUS_DISABLED = "отключена"
STATUS_EXPIRED = "срок вышел"
STATUS_LIMITED = "трафик на нуле"
STATUS_UNKNOWN = "выясняем"
# ============================================================
@@ -61,25 +60,25 @@ STATUS_UNKNOWN = "Неизвестен"
# — Дни до истечения подписки
DAYS_LEFT_POSITIVE = "{days} дн."
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
DAYS_LEFT_TODAY = "истекает сегодня"
DAYS_LEFT_TODAY = "доживает последний день"
# — Username
NO_USERNAME = "нет username"
NO_USERNAME = "скрыт"
USERNAME_DISPLAY = "@{username}"
# — Internal Squads
NO_SQUADS = "нет"
NO_SQUADS = "отсутствуют"
# — HWID лимит
UNLIMITED_HWID = "без ограничений"
UNLIMITED_HWID = "без лимитов"
# — Трафик
NO_TRAFFIC_LIMIT = "без ограничений"
NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
TRAFFIC_NO_LIMIT = "{used}"
# — Прочее
NOTHING_PLACEHOLDER = "🍃"
NOTHING_PLACEHOLDER = ""
# ============================================================
@@ -90,23 +89,23 @@ MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
+ f"<b>{MALENIA_LINK} Ваша ссылка на подписку: {{link}}</b>"
+ f"<b>{MALENIA_LINK} Ключ доступа: <code>{{link}}</code></b>"
)
FAQ_TEXT = "faq"
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
FAQ_TEXT = (
"<b>FAQ: Технический минимум</b>\n\n"
"Здесь нет магии и «инновационных прорывов». Только обфускация трафика и стабильные узлы. "
"Если что-то не грузится — скорее всего, фильтры опять пробуют новые сигнатуры. "
"Мы это уже чиним."
)
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
GENERAL_PROCESSING = "<i>⏳ Проверяем пакеты...</i>"
CONTEXT_REINITIATED = (
"🔴 Контекст данного взаимодействия сброшен. "
"Данные могут отличаться от указанных вами."
)
STANDARD_FALLBACK = "<b>❌ Ошибка в логике.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели за кодом."
CALLBACK_FALLBACK = "❌ Пакет потерян, попробуйте снова"
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
# ============================================================
@@ -115,9 +114,10 @@ CONTEXT_REINITIATED = (
REFERALS_MENU = (
"<b>👥 Реферальная система</b>\n\n"
f"<b>{MALENIA_COINS} Ваш реферальный баланс:</b> "
"Никакого сетевого маркетинга, просто делимся ресурсами.\n"
f"<b>{MALENIA_COINS} На вашем счету:</b> "
"<code>{balance}₽</code>\n"
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
f"<b>{MALENIA_LINK} Ссылка для своих:</b> "
"<code>{link}</code>"
)
@@ -126,19 +126,15 @@ REFERALS_MENU = (
# ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = (
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
"Сколько девайсов нужно держать в сети одновременно?\n\nЦена вопроса: <b>{price}₽</b>"
)
SUBSCRIPTION_DURATION_SELECTOR = (
"тут короче сколько время. а ещё цена смотри ОПА {price}"
)
SUBSCRIPTION_DURATION_SELECTOR = "На какой срок планируем инфраструктуру?\n\nИтого: <b>{price}₽</b>"
WHITELISTS_ALREADY_ON = "уже включены!"
TEMP_SUPPORT_FORM = "обратитесь в поддержку, прикрепив текст ниже:\n\n"
WHITELISTS_ALREADY_ON = "Списки доступа уже активны."
CHECKOUT_PAYMENT_CHOICE = (
"в поддержку чирканите епта там всё обьяснят\n" + TEMP_SUPPORT_FORM
"Автоплатежи — это не к нам. Стукните в поддержку, там объяснят, куда слать деньги."
)
@@ -147,33 +143,29 @@ CHECKOUT_PAYMENT_CHOICE = (
# ============================================================
SUPPORT_INIT = (
f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n"
"всё сюда, желательно со скриншотами. "
"Чем детальнее описание, тем быстрее ответ.\n\n"
"<i>❌ Для отмены — /cancel</i>"
f"<b>{MALENIA_SUPPORT} Технический отдел</b>\n"
"если интернет превратился в тыкву или есть идеи по конфигам.\n"
"Пишите по делу, скриншоты приветствуются.\n\n"
"<i>🌑 /cancel — вернуться к делам</i>"
)
SUPPORT_MSG_SENT = (
"⏳ Сообщение отправлено. Для выхода — /cancel, "
"вы можете вернуться сюда из главного меню в любое время."
)
SUPPORT_MSG_SENT = "⏳ Принято. Инженеры посмотрят, как освободятся. " "Вернуться в меню: /cancel"
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia Team:</b>"
# ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
# ============================================================
# Используется в SUPPORT_SUBSCRIPTION и ADMIN_TICKET_WITH_RW
_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>"
"👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
_DIVIDER = "────────────────────────────\n\n"
# ============================================================
@@ -181,14 +173,14 @@ _DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━
# ============================================================
SUPPORT_SUBSCRIPTION = (
"<b>🎫 ЗАЯВКА НА ПОКУПКУ:</b>\n\n"
"<b>🎫 ВХОДЯЩИЙ ЗАПРОС:</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ "🗓️ Подписка на <b>{duration}</b> месяцев.\n"
+ "📱 Количество устройств: <b>{devices}</b>\n"
+ "🏳️ Белые списки: <b>{whitelist_description}</b>\n\n"
+ "💰 Итого: <b>{price}₽</b>"
+ "🗓️ Срок: <b>{duration} мес.</b>\n"
+ "📱 Устройства: <b>{devices}</b>\n"
+ "🏳️ Whitelists: <b>{whitelist_description}</b>\n\n"
+ "💰 К оплате: <b>{price}₽</b>"
)
ADMIN_TICKET_WITH_RW = (
@@ -196,35 +188,24 @@ ADMIN_TICKET_WITH_RW = (
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ "📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
+ "📊 <b>ТЕХ. ДАННЫЕ (Remnawave)</b>\n\n"
+ "{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Истекает:</b> {expire_date}\n"
+ "⏳ <b>Осталось:</b> {days_left}\n"
+ "📅 <b>Deadline:</b> {expire_date}\n"
+ "⏳ <b>Остаток:</b> {days_left}\n"
+ "📊 <b>Трафик:</b> {used_traffic}\n"
+ "📱 <b>HWID лимит:</b> {hwid_limit}\n"
+ "🎯 <b>Internal Squads:</b> {squads}\n\n"
+ "📱 <b>HWID:</b> {hwid_limit}\n"
+ "🎯 <b>Squads:</b> {squads}\n\n"
+ _DIVIDER
+ "🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
+ "🔑 <b>UUID:</b> <code>{remnawave_uuid}</code>"
)
ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
+ _DIVIDER
+ "⚠️ <b>Аккаунт в Remnawave не найден</b>"
+ "⚠️ <b>Remnawave-профиль отсутствует</b>"
)
ADMIN_STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
)
# ============================================================
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
# ============================================================
def build_main_menu_text(link: str) -> str:
return MAIN_MENU.format(quote=fetch_quote(), link=link)
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."