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 dotenv import load_dotenv
from pydantic import Field
from pydantic_settings import BaseSettings
load_dotenv(override=True) load_dotenv(override=True)
@@ -9,7 +8,7 @@ load_dotenv(override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN") bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field(alias="POSTGRES_URL") 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") admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL") subscription_url: str = Field(alias="SUBSCRIPTION_URL")

View File

@@ -1,4 +1,4 @@
from .users import User
from .tickets import Ticket 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 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 sqlalchemy.orm import Mapped, mapped_column
from db.base import Base from db.base import Base

View File

@@ -7,9 +7,7 @@ from db.base import Base
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column( id: Mapped[int] = mapped_column(BIGINT, nullable=False, unique=True, primary_key=True)
BIGINT, nullable=False, unique=True, primary_key=True
)
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True) referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True) subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0) 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 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 .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] routers = [referal_router, menus_router, support_router, buy_router]

View File

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

View File

@@ -1,13 +1,13 @@
from aiogram import Router, F from aiogram import F, Router
from aiogram.filters import CommandStart, CommandObject, Command from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession 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 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() router = Router()
@@ -47,9 +47,7 @@ async def standard_start(
): ):
await state.clear() await state.clear()
user = await deps.user_service.add_user( user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk
)
await msg.reply( await msg.reply(
build_main_menu_text(format_subscription_link(user.subscription_link)), build_main_menu_text(format_subscription_link(user.subscription_link)),
@@ -77,9 +75,7 @@ async def update_link(
await state.clear() await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING) await cb.message.edit_text(GENERAL_PROCESSING)
user = await deps.user_service.update_user_link( user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
session, cb.from_user.id, deps.rw_sdk
)
await cb.answer("") await cb.answer("")
await cb.message.edit_text( await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)), 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.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.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 sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import referals_kb from keyboards.client import referals_kb
from misc.utils import format_share_deep_link from misc.utils import format_share_deep_link
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from texts import GENERAL_PROCESSING, REFERALS_MENU from texts import GENERAL_PROCESSING, REFERALS_MENU
from config import settings
router = Router() router = Router()
@@ -57,7 +57,5 @@ async def refpay(
return return
amount = int(amount) amount = int(amount)
await deps.user_repository.increase_user_balance(session, user.id, amount) await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text( await status_msg.edit_text(f"<b>💸 Баланс реферала ({referal.id}) пополнен на {amount}₽</b>")
f"<b>💸 Баланс реферала ({referal.id}) пополнен на {amount}₽</b>"
)

View File

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

View File

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

10
main.py
View File

@@ -1,19 +1,19 @@
import logging
import asyncio import asyncio
import logging
from remnawave import RemnawaveSDK
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession 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 handlers import routers
from middlewares import DIMiddleware from middlewares import DIMiddleware
from config import settings
from repositories.tickets import TicketRepository from repositories.tickets import TicketRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.di import DependenciesDTO
from services.user_service import UserService from services.user_service import UserService
from db.session import async_session
logging.basicConfig(level=logging.DEBUG) 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 import BaseMiddleware
from aiogram.types import TelegramObject from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
@@ -7,9 +9,7 @@ from schemas.di import DependenciesDTO
class DIMiddleware(BaseMiddleware): class DIMiddleware(BaseMiddleware):
def __init__( def __init__(self, deps: DependenciesDTO, session_factory: async_sessionmaker) -> None:
self, deps: DependenciesDTO, session_factory: async_sessionmaker
) -> None:
self.deps = deps self.deps = deps
self.session_factory = session_factory self.session_factory = session_factory

View File

@@ -1,11 +1,10 @@
import urllib.parse import urllib.parse
from typing import Optional from datetime import UTC, datetime, timedelta
from random import choice from random import choice
from datetime import datetime, timedelta, timezone
import texts
from config import settings from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
import texts
QUOTES: list[str] = [ QUOTES: list[str] = [
"Верните себе свободный интернет.", "Верните себе свободный интернет.",
@@ -47,12 +46,16 @@ def fetch_quote() -> str:
def format_bytes(num_bytes: float) -> str: def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б.""" """Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
if num_bytes >= 1_073_741_824: # >= 1 ГБ gb_capacity = 1_073_741_824
return f"{num_bytes / 1_073_741_824:.1f} ГБ" mb_capacity = 1_048_576
if num_bytes >= 1_048_576: # >= 1 МБ kb_capacity = 1_024
return f"{num_bytes / 1_048_576:.1f} МБ"
if num_bytes >= 1_024: # >= 1 КБ if num_bytes >= gb_capacity: # >= 1 ГБ
return f"{num_bytes / 1_024:.1f} КБ" 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} Б" 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) 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: число или «без ограничений».""" """Форматировать лимит HWID: число или «без ограничений»."""
if limit is None or limit == 0: if limit is None or limit == 0:
return texts.UNLIMITED_HWID return texts.UNLIMITED_HWID
@@ -90,11 +93,11 @@ def format_days_left(expire_at: datetime) -> str:
- «истекает сегодня» — если осталось 0 дней - «истекает сегодня» — если осталось 0 дней
- «истекла N дн. назад» — если уже истекла - «истекла N дн. назад» — если уже истекла
""" """
now = datetime.now(tz=timezone.utc) now = datetime.now(tz=UTC)
# Убедимся что expire_at timezone-aware # Убедимся что expire_at timezone-aware
if expire_at.tzinfo is None: 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 delta = expire_at - now
days = delta.days # целое, может быть отрицательным days = delta.days # целое, может быть отрицательным
@@ -118,12 +121,10 @@ def format_status(status: str) -> tuple[str, str]:
"LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED), "LIMITED": (texts.STATUS_ICON_LIMITED, texts.STATUS_LIMITED),
"ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN), "ON_HOLD": (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN),
} }
return mapping.get( return mapping.get(status.upper(), (texts.STATUS_ICON_UNKNOWN, texts.STATUS_UNKNOWN))
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».""" """Форматировать username: «@user» или «нет username»."""
if username: if username:
return "@" + username return "@" + username
@@ -151,14 +152,14 @@ def convert_int_to_duration(value: int) -> SubscriptionDuration:
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta: def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
MONTH_DELTA = timedelta(days=30) month_delta = timedelta(days=30)
mapping: dict[str, timedelta] = { mapping: dict[str, timedelta] = {
SubscriptionDuration.MONTH.value: MONTH_DELTA, SubscriptionDuration.MONTH.value: month_delta,
SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3, SubscriptionDuration.THREE_MONTHS.value: month_delta * 3,
SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6, SubscriptionDuration.SIX_MONTHS.value: month_delta * 6,
SubscriptionDuration.YEAR.value: MONTH_DELTA * 12, 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): def calculate_price(subscription: SubscriptionPlan):
@@ -172,7 +173,7 @@ def calculate_price(subscription: SubscriptionPlan):
) * convert_duration_to_int(subscription.duration) ) * 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>" return f"<code>{link or '...'}</code>"
@@ -185,7 +186,7 @@ def format_support_subscription(
full_name: str, full_name: str,
user_id: int, user_id: int,
balance: int, balance: int,
username: Optional[str] = None, username: str | None = None,
) -> str: ) -> str:
username = format_username(username) username = format_username(username)
whitelist_description = "ВЫКЛ" whitelist_description = "ВЫКЛ"
@@ -208,3 +209,7 @@ def format_support_subscription(
def get_subscription_link(short_uuid: str): def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -8,7 +7,7 @@ from schemas.tickets import NewTicketDTO
class TicketRepository: class TicketRepository:
async def get_ticket_by_user_id( 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]: ) -> list[Ticket]:
stmt = select(Ticket).where(Ticket.creator_id == user_id) stmt = select(Ticket).where(Ticket.creator_id == user_id)
if status: if status:
@@ -19,7 +18,7 @@ class TicketRepository:
async def get_available_ticket_by_user_id( async def get_available_ticket_by_user_id(
self, session: AsyncSession, user_id: int self, session: AsyncSession, user_id: int
) -> Optional[Ticket]: ) -> Ticket | None:
stmt = ( stmt = (
select(Ticket) select(Ticket)
.where(Ticket.creator_id == user_id) .where(Ticket.creator_id == user_id)
@@ -30,17 +29,13 @@ class TicketRepository:
tickets = list(res.scalars().all()) tickets = list(res.scalars().all())
return tickets[0] if tickets else None return tickets[0] if tickets else None
async def get_ticket_by_thread_id( async def get_ticket_by_thread_id(self, session: AsyncSession, thread_id: int) -> Ticket | None:
self, session: AsyncSession, thread_id: int
) -> Optional[Ticket]:
stmt = select(Ticket).where(Ticket.thread_id == thread_id) stmt = select(Ticket).where(Ticket.thread_id == thread_id)
res = await session.execute(stmt) res = await session.execute(stmt)
return res.scalar_one_or_none() return res.scalar_one_or_none()
async def create_ticket( async def create_ticket(self, session: AsyncSession, ticket_data: NewTicketDTO) -> Ticket:
self, session: AsyncSession, ticket_data: NewTicketDTO
) -> Ticket:
ticket = Ticket(creator_id=ticket_data.creator_id) ticket = Ticket(creator_id=ticket_data.creator_id)
session.add(ticket) session.add(ticket)
await session.commit() await session.commit()
@@ -54,9 +49,7 @@ class TicketRepository:
await session.commit() await session.commit()
return ticket return ticket
async def update_ticket_thread_id( async def update_ticket_thread_id(self, session: AsyncSession, ticket: Ticket, thread_id: int):
self, session: AsyncSession, ticket: Ticket, thread_id: int
):
ticket.thread_id = thread_id ticket.thread_id = thread_id
await session.commit() await session.commit()
return ticket return ticket

View File

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

View File

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

View File

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

View File

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

View File

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

143
texts.py
View File

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