diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5711b89 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,37 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg +*.egg-info/ +dist/ +build/ +*.whl + +# Virtual environment +venv/ +.venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Git +.git/ +.gitignore + +# Environment +.env + +# OS +.DS_Store +Thumbs.db diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..292cc94 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better layer caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt + +# Copy entrypoint script +COPY entrypoints/startup.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Copy application code (will be overridden by volume mount in development) +COPY . . + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/alembic/versions/3582c3285f91_ticket_state_ticket_status.py b/alembic/versions/3582c3285f91_ticket_state_ticket_status.py new file mode 100644 index 0000000..788acb6 --- /dev/null +++ b/alembic/versions/3582c3285f91_ticket_state_ticket_status.py @@ -0,0 +1,48 @@ +"""ticket.state -> ticket.status. + +Revision ID: 3582c3285f91 +Revises: 53992c092918 +Create Date: 2026-04-06 19:02:31.266750 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = "3582c3285f91" +down_revision: Union[str, Sequence[str], None] = "53992c092918" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "tickets", + sa.Column( + "status", sa.Enum("OPEN", "CLOSED", name="ticketstatus"), nullable=False + ), + ) + op.drop_column("tickets", "state") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "tickets", + sa.Column( + "state", + postgresql.ENUM("OPEN", "CLOSED", name="ticketstatus"), + autoincrement=False, + nullable=False, + ), + ) + op.drop_column("tickets", "status") + # ### end Alembic commands ### diff --git a/alembic/versions/53992c092918_ticket_new_field_thread_id.py b/alembic/versions/53992c092918_ticket_new_field_thread_id.py new file mode 100644 index 0000000..808ed90 --- /dev/null +++ b/alembic/versions/53992c092918_ticket_new_field_thread_id.py @@ -0,0 +1,36 @@ +"""ticket new field -> thread_id. + +Revision ID: 53992c092918 +Revises: fad6b6da6569 +Create Date: 2026-04-06 18:59:22.573283 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "53992c092918" +down_revision: Union[str, Sequence[str], None] = "fad6b6da6569" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("tickets", sa.Column("thread_id", sa.BIGINT(), nullable=False)) + op.create_unique_constraint(None, "tickets", ["thread_id"]) + op.create_unique_constraint(None, "tickets", ["id"]) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "tickets", type_="unique") + op.drop_constraint(None, "tickets", type_="unique") + op.drop_column("tickets", "thread_id") + # ### end Alembic commands ### diff --git a/alembic/versions/756210f564a5_ticket_thread_id_nullable.py b/alembic/versions/756210f564a5_ticket_thread_id_nullable.py new file mode 100644 index 0000000..e00d083 --- /dev/null +++ b/alembic/versions/756210f564a5_ticket_thread_id_nullable.py @@ -0,0 +1,32 @@ +"""ticket.thread_id -> nullable. + +Revision ID: 756210f564a5 +Revises: d872dba3da66 +Create Date: 2026-04-06 21:43:17.136851 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "756210f564a5" +down_revision: Union[str, Sequence[str], None] = "d872dba3da66" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("tickets", "thread_id", existing_type=sa.BIGINT(), nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column("tickets", "thread_id", existing_type=sa.BIGINT(), nullable=False) + # ### end Alembic commands ### diff --git a/alembic/versions/b833fa0e6dd9_ticket_status_pending.py b/alembic/versions/b833fa0e6dd9_ticket_status_pending.py new file mode 100644 index 0000000..af2baf2 --- /dev/null +++ b/alembic/versions/b833fa0e6dd9_ticket_status_pending.py @@ -0,0 +1,27 @@ +"""ticket.status - +PENDING + +Revision ID: b833fa0e6dd9 +Revises: 3582c3285f91 +Create Date: 2026-04-06 20:16:24.211939 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "b833fa0e6dd9" +down_revision: Union[str, Sequence[str], None] = "3582c3285f91" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + op.execute("ALTER TYPE ticketstatus ADD VALUE 'PENDING'") + + +def downgrade() -> None: + """Downgrade schema.""" + pass diff --git a/alembic/versions/d872dba3da66_ticket_status_default_pending.py b/alembic/versions/d872dba3da66_ticket_status_default_pending.py new file mode 100644 index 0000000..88223d8 --- /dev/null +++ b/alembic/versions/d872dba3da66_ticket_status_default_pending.py @@ -0,0 +1,29 @@ +"""ticket.status default -> PENDING. + +Revision ID: d872dba3da66 +Revises: b833fa0e6dd9 +Create Date: 2026-04-06 20:20:00.618718 + +""" + +from typing import Sequence, Union + +# revision identifiers, used by Alembic. +revision: str = "d872dba3da66" +down_revision: Union[str, Sequence[str], None] = "b833fa0e6dd9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/fad6b6da6569_ticket_table_created.py b/alembic/versions/fad6b6da6569_ticket_table_created.py new file mode 100644 index 0000000..240e87c --- /dev/null +++ b/alembic/versions/fad6b6da6569_ticket_table_created.py @@ -0,0 +1,53 @@ +"""ticket table created. + +Revision ID: fad6b6da6569 +Revises: c447fdf8cc97 +Create Date: 2026-04-06 18:52:07.980432 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "fad6b6da6569" +down_revision: Union[str, Sequence[str], None] = "c447fdf8cc97" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "tickets", + sa.Column("id", sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column("creator_id", sa.BIGINT(), nullable=False), + sa.Column( + "state", sa.Enum("OPEN", "CLOSED", name="ticketstatus"), nullable=False + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("id"), + ) + op.drop_constraint(op.f("users_support_thread_id_key"), "users", type_="unique") + op.drop_column("users", "support_thread_id") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "users", + sa.Column("support_thread_id", sa.BIGINT(), autoincrement=False, nullable=True), + ) + op.create_unique_constraint( + op.f("users_support_thread_id_key"), + "users", + ["support_thread_id"], + postgresql_nulls_not_distinct=False, + ) + op.drop_table("tickets") + # ### end Alembic commands ### diff --git a/config.py b/config.py index 2a85f10..6393a2d 100644 --- a/config.py +++ b/config.py @@ -6,10 +6,13 @@ from dotenv import load_dotenv load_dotenv(override=True) -class Settings(BaseSettings): +class settings(BaseSettings): bot_token: str = Field(alias="BOT_TOKEN") postgres_url: str = Field(alias="POSTGRES_URL") proxy: Optional[str] = Field(None, alias="PROXY") + admin_group_id: int = Field(alias="ADMIN_GROUP_ID") + remnawave_url: str = Field(alias="REMNAWAVE_URL") + remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") -settings = Settings() # type: ignore +settings = settings() # type: ignore diff --git a/db/models/__init__.py b/db/models/__init__.py index 1741387..93ed393 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -1,3 +1,4 @@ from .users import User +from .tickets import Ticket -__all__ = ["User"] +__all__ = ["User", "Ticket"] diff --git a/db/models/tickets.py b/db/models/tickets.py new file mode 100644 index 0000000..9490e46 --- /dev/null +++ b/db/models/tickets.py @@ -0,0 +1,24 @@ +from enum import Enum as VanillaEnum +from sqlalchemy import INTEGER, BIGINT, Enum +from sqlalchemy.orm import Mapped, mapped_column + +from db.base import Base + + +class TicketStatus(VanillaEnum): + OPEN = "OPEN" + CLOSED = "CLOSED" + PENDING = "PENDING" + + +class Ticket(Base): + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column( + INTEGER, autoincrement=True, unique=True, nullable=False, primary_key=True + ) + creator_id: Mapped[int] = mapped_column(BIGINT, nullable=False) + status: Mapped[TicketStatus] = mapped_column( + Enum(TicketStatus), nullable=False, default=TicketStatus.PENDING + ) + thread_id: Mapped[int] = mapped_column(BIGINT, nullable=True, unique=True) diff --git a/db/models/users.py b/db/models/users.py index 8167734..c69e56d 100644 --- a/db/models/users.py +++ b/db/models/users.py @@ -10,5 +10,4 @@ class User(Base): id: Mapped[int] = mapped_column( BIGINT, nullable=False, unique=True, primary_key=True ) - support_thread_id: Mapped[int] = mapped_column(BIGINT, nullable=True, unique=True) referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c4a02a3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + volumes: + - .:/app + env_file: + - .env + restart: unless-stopped + # Optional: uncomment to run with auto-reload for development + # command: python -m watchfiles main.py + stdin_open: true + tty: true diff --git a/dto/di.py b/dto/di.py index 30f85af..17ea5ec 100644 --- a/dto/di.py +++ b/dto/di.py @@ -1,10 +1,14 @@ from dataclasses import dataclass +from repositories.tickets import TicketRepository from repositories.users import UserRepository from services.user_service import UserService +from remnawave import RemnawaveSDK @dataclass class DependenciesDTO: + ticket_repository: TicketRepository user_repository: UserRepository user_service: UserService + rw_sdk: RemnawaveSDK diff --git a/dto/tickets.py b/dto/tickets.py new file mode 100644 index 0000000..fa33e84 --- /dev/null +++ b/dto/tickets.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class NewTicketDTO: + creator_id: int diff --git a/dto/users.py b/dto/users.py index 777df0f..cd6466c 100644 --- a/dto/users.py +++ b/dto/users.py @@ -1,7 +1,14 @@ from dataclasses import dataclass +from typing import Optional @dataclass class NewUserDTO: id: int referal: int + + +@dataclass +class TicketCreator: + id: int + username: Optional[str] = None diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index 1615fd1..39804a7 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -1,3 +1,4 @@ +#!/bin/bash set -e echo "[+] Formatting and checking..." @@ -5,4 +6,9 @@ echo ruff check --fix black . +echo "[+] Running migrations..." +echo +alembic upgrade head + +echo "[+] Starting bot..." python main.py \ No newline at end of file diff --git a/handlers/__init__.py b/handlers/__init__.py index b415a65..be3ff80 100644 --- a/handlers/__init__.py +++ b/handlers/__init__.py @@ -1,3 +1,4 @@ from .menus import router as menus_router +from .support import router as support_router -routers = [menus_router] +routers = [menus_router, support_router] diff --git a/handlers/buy.py b/handlers/buy.py new file mode 100644 index 0000000..e1bc233 --- /dev/null +++ b/handlers/buy.py @@ -0,0 +1,10 @@ +from aiogram import Router +from aiogram.types import CallbackQuery +from aiogram.fsm.context import FSMContext + +from dto.di import DependenciesDTO + + +router = Router() + +# @router.callback_query() diff --git a/handlers/menus.py b/handlers/menus.py index 3f8ad4a..fd80459 100644 --- a/handlers/menus.py +++ b/handlers/menus.py @@ -1,11 +1,12 @@ -from aiogram import Router -from aiogram.filters import CommandStart, CommandObject -from aiogram.types import Message +from aiogram import Router, F +from aiogram.filters import CommandStart, CommandObject, Command +from aiogram.types import CallbackQuery, Message from aiogram.fsm.context import FSMContext from sqlalchemy.ext.asyncio import AsyncSession from dto.di import DependenciesDTO from keyboards.client import main_menu +from texts import build_main_menu_text router = Router() @@ -28,19 +29,26 @@ async def fetch_referal( referal = data[0] await deps.user_service.add_user(session, user_id=msg.from_user.id, referal=referal) - await msg.reply(f"hi but....{referal}", reply_markup=main_menu) + await msg.reply(build_main_menu_text(), reply_markup=main_menu) -@router.message(CommandStart(deep_link=False)) -async def standard_start(msg: Message, command: CommandObject, state: FSMContext): - await msg.reply("hii!", reply_markup=main_menu) +@router.message(Command(commands=["start", "cancel"])) +async def standard_start( + msg: Message, + command: CommandObject, + state: FSMContext, + session: AsyncSession, + deps: DependenciesDTO, +): + await state.clear() + + await deps.user_service.add_user(session, user_id=msg.from_user.id) + + await msg.reply(build_main_menu_text(), reply_markup=main_menu) -# INSANE HOW UNSTABLE THIS FUCKING SYSTEM IS -# EVEN AFTER ROLLING EVERYTHING BACK -# HALF OF MY APPS STILL DONT WORK -# I FUCKING HATE THIS SHIT -# I CANT PACMAN -SYU CUZ IT BREAKS NVIDIA DRIVERS AND MY 240HZ MONITOR -# IS IN FUCKING 3 FPS **CONSTANTLY** -# I CANT MAKE IT UP AND I REALLY CANT TAE IT ANYMORE. -# wrap it up bud. we're gonna reset everything. bye. o7 \ No newline at end of file +@router.callback_query(F.data == "menu:main") +async def main_menu_cb(cb: CallbackQuery, state: FSMContext): + await state.clear() + + await cb.message.edit_text(build_main_menu_text(), reply_markup=main_menu) diff --git a/handlers/support.py b/handlers/support.py new file mode 100644 index 0000000..fffa37a --- /dev/null +++ b/handlers/support.py @@ -0,0 +1,69 @@ +import logging +from aiogram import Router, F +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 dto.di import DependenciesDTO +from services.user_service import UserServiceException +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__) + + +@router.callback_query(F.data == "support") +async def support_init(cb: CallbackQuery, state: FSMContext): + await state.clear() + + await cb.message.edit_text(text=SUPPORT_INIT, reply_markup=return_to_menu) + await state.set_state(SupportStorage.prompt) + + +@router.message(SupportStorage.prompt) +async def received_message( + msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + await state.clear() + + notification_msg = await msg.reply(GENERAL_PROCESSING) + + try: + await deps.user_service.support_forward_message(session, msg, deps.rw_sdk) + except UserServiceException: + await msg.reply(STANDARD_FALLBACK) + raise + except Exception: + await msg.reply(STANDARD_FALLBACK) + logger.exception("Exception occured while trying to forward a msg to support") + raise + + await notification_msg.edit_text(SUPPORT_MSG_SENT, reply_markup=return_to_menu) + await state.set_state(SupportStorage.prompt) + + +@router.message( + F.chat.id == settings.admin_group_id, + F.message_thread_id.is_not(None), + F.from_user.is_bot == False, # noqa: E712 + ~Command("close"), +) +async def handle_admin_message( + msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + await state.clear() + + await deps.user_service.support_answer(session, msg) + + +@router.message(F.chat.id == settings.admin_group_id, Command("close")) +async def close_ticket( + msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + await state.clear() + + await deps.user_service.close_ticket(session, msg) diff --git a/keyboards/client.py b/keyboards/client.py index 63fee6b..0ebb909 100644 --- a/keyboards/client.py +++ b/keyboards/client.py @@ -13,3 +13,7 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( ], ] ).as_markup() + +return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( + [[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]] +).as_markup() diff --git a/main.py b/main.py index c30a289..33f6cb5 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import logging import asyncio +from remnawave import RemnawaveSDK from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.client.session.aiohttp import AiohttpSession @@ -9,6 +10,7 @@ from dto.di import DependenciesDTO from handlers import routers from middlewares import DIMiddleware from config import settings +from repositories.tickets import TicketRepository from repositories.users import UserRepository from services.user_service import UserService from db.session import async_session @@ -17,13 +19,23 @@ logging.basicConfig(level=logging.DEBUG) async def main(): - print(settings.model_dump()) dp = Dispatcher() user_repository = UserRepository() - user_service = UserService(user_repository) + ticket_repository = TicketRepository() + user_service = UserService(user_repository, ticket_repository) - deps = DependenciesDTO(user_repository=user_repository, user_service=user_service) + rw_sdk = RemnawaveSDK( + base_url=settings.remnawave_url, + token=settings.remnawave_token, + ) + + deps = DependenciesDTO( + ticket_repository=ticket_repository, + user_repository=user_repository, + user_service=user_service, + rw_sdk=rw_sdk, + ) dp.update.middleware.register(DIMiddleware(deps, async_session)) aiohttp_session = AiohttpSession(proxy=settings.proxy) diff --git a/misc/utils.py b/misc/utils.py new file mode 100644 index 0000000..ffad1b7 --- /dev/null +++ b/misc/utils.py @@ -0,0 +1,127 @@ +from typing import Optional +from random import choice +from datetime import datetime, timezone + +import texts + +QUOTES: list[str] = [ + "Верните себе свободный интернет.", + "Свобода должна быть доступной.", + "Интернет без границ — не привилегия, а норма.", + "Ваш трафик. Ваши правила.", + "Тишина в сети — это тоже выбор.", + "Открытый мир начинается здесь.", + "Без цензуры. Без компромиссов.", + "Простота — лучшая форма свободы.", + "Один клик между вами и миром.", + "Свобода в форме привычки.", + "Будьте везде. Оставайтесь невидимым.", + "Приватность — это право, а не роскошь.", + "Интернет таким, каким он задумывался.", + "Ваши данные — только ваше дело.", + "Закрытых дверей больше не существует.", + "Любой сайт. Из любой точки мира.", + "Оставьте след в истории, а не в браузере.", + "Ключ к открытой сети.", + "Фильтры придумали они. Решение выбрали вы.", + "Безопасность, которую вы не замечаете.", + "Сотрите цифровые границы. Останьтесь собой.", + "Скорость без компромиссов. Сеть без преград.", + "Включили и забыли. Вы в безопасности.", + "Блокировки иллюзорны. Свобода реальна.", + "Никакой слежки. Только вы и интернет.", + "Доступ к информации — ваше базовое право.", + "Интернет снова ваш.", + "Контроль над сетью в ваших руках.", + "Невидимый щит для вашей свободы.", + "Весь мир на экране. Без лишних вопросов.", +] + + +def fetch_quote() -> str: + return choice(QUOTES) + + +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} КБ" + return f"{num_bytes:.0f} Б" + + +def format_traffic(used: float, limit: int) -> str: + """ + Форматировать трафик. + limit == 0 → без ограничений: "X ГБ" + limit > 0 → с лимитом: "X ГБ / Y ГБ" + """ + used_str = format_bytes(used) + if limit == 0: + return texts.TRAFFIC_NO_LIMIT.format(used=used_str) + limit_str = format_bytes(float(limit)) + return texts.TRAFFIC_WITH_LIMIT.format(used=used_str, limit=limit_str) + + +def format_hwid_limit(limit: Optional[int]) -> str: + """Форматировать лимит HWID: число или «без ограничений».""" + if limit is None or limit == 0: + return texts.UNLIMITED_HWID + return str(limit) + + +def format_squads(squads: list[dict]) -> str: + """Форматировать список Internal Squads или «нет».""" + if not squads: + return texts.NO_SQUADS + return ", ".join(s["name"] for s in squads) + + +def format_days_left(expire_at: datetime) -> str: + """ + Вернуть строку с количеством оставшихся дней: + - «30 дн.» — если осталось > 0 дней + - «истекает сегодня» — если осталось 0 дней + - «истекла N дн. назад» — если уже истекла + """ + now = datetime.now(tz=timezone.utc) + + # Убедимся что expire_at timezone-aware + if expire_at.tzinfo is None: + expire_at = expire_at.replace(tzinfo=timezone.utc) + + delta = expire_at - now + days = delta.days # целое, может быть отрицательным + + if days > 0: + return texts.DAYS_LEFT_POSITIVE.format(days=days) + if days == 0: + return texts.DAYS_LEFT_TODAY + return texts.DAYS_LEFT_EXPIRED.format(days=abs(days)) + + +def format_status(status: str) -> tuple[str, str]: + """ + Вернуть (иконка, текст) для строкового статуса подписки. + Статус приводится к верхнему регистру перед сравнением. + """ + mapping: dict[str, tuple[str, str]] = { + "ACTIVE": (texts.STATUS_ICON_ACTIVE, texts.STATUS_ACTIVE), + "DISABLED": (texts.STATUS_ICON_DISABLED, texts.STATUS_DISABLED), + "EXPIRED": (texts.STATUS_ICON_EXPIRED, texts.STATUS_EXPIRED), + "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) + ) + + +def format_username(username: Optional[str]) -> str: + """Форматировать username: «@user» или «нет username».""" + if username: + return "@" + username + return texts.NO_USERNAME diff --git a/prob b/prob new file mode 100644 index 0000000..a8ca72a --- /dev/null +++ b/prob @@ -0,0 +1 @@ +Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать? \ No newline at end of file diff --git a/repositories/tickets.py b/repositories/tickets.py new file mode 100644 index 0000000..c05a181 --- /dev/null +++ b/repositories/tickets.py @@ -0,0 +1,62 @@ +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models.tickets import Ticket, TicketStatus +from dto.tickets import NewTicketDTO + + +class TicketRepository: + async def get_ticket_by_user_id( + self, session: AsyncSession, user_id: int, status: Optional[TicketStatus] = None + ) -> list[Ticket]: + stmt = select(Ticket).where(Ticket.creator_id == user_id) + if status: + stmt = stmt.where(Ticket.status == status) + res = await session.execute(stmt) + + return list(res.scalars().all()) + + async def get_available_ticket_by_user_id( + self, session: AsyncSession, user_id: int + ) -> Optional[Ticket]: + stmt = ( + select(Ticket) + .where(Ticket.creator_id == user_id) + .where(Ticket.status != TicketStatus.CLOSED) + ) + res = await session.execute(stmt) + + 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]: + 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: + ticket = Ticket(creator_id=ticket_data.creator_id) + session.add(ticket) + await session.commit() + + return ticket + + async def update_ticket_status( + self, session: AsyncSession, ticket: Ticket, status: TicketStatus + ): + ticket.status = status + await session.commit() + return ticket + + async def update_ticket_thread_id( + self, session: AsyncSession, ticket: Ticket, thread_id: int + ): + ticket.thread_id = thread_id + await session.commit() + return ticket diff --git a/repositories/users.py b/repositories/users.py index 00780d0..f9968a7 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -21,3 +21,14 @@ class UserRepository: await session.commit() return user + + async def update_user_topic( + self, session: AsyncSession, user_id: int, thread_id: int + ) -> Optional[User]: + user = await self.get_user_by_id(session, user_id=user_id) + if not user: + return None + user.support_thread_id = thread_id + + await session.commit() + return user diff --git a/requirements.txt b/requirements.txt index 4a030ea..027aa20 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,10 @@ -aiogram +aiogram>=3.20.0 sqlalchemy[asyncio] -asyncpg +asyncpg>=0.29.0 black ruff -pydantic-settings +pydantic-settings>=2.7.0 python-dotenv aiohttp-socks -alembic \ No newline at end of file +alembic +remnawave>=2.6.1 \ No newline at end of file diff --git a/services/rw.py b/services/rw.py new file mode 100644 index 0000000..03acf7d --- /dev/null +++ b/services/rw.py @@ -0,0 +1,320 @@ +import logging +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Optional +from uuid import UUID + +from remnawave import RemnawaveSDK +from remnawave.models import UpdateUserRequestDto + +logger = logging.getLogger(__name__) + + +# ───────────────────────────────────────────────────────────────── +# Dataclass — унифицированное представление пользователя RW +# ───────────────────────────────────────────────────────────────── + + +@dataclass +class RWUserInfo: + uuid: str + username: str + status: str # "ACTIVE", "DISABLED", "EXPIRED", "ON_HOLD", "LIMITED" + expire_at: datetime + used_traffic_bytes: float + traffic_limit_bytes: int # 0 = unlimited + hwid_device_limit: Optional[int] # None = unlimited + active_squads: list[dict] # [{"uuid": "...", "name": "..."}] + description: Optional[str] + + +# ───────────────────────────────────────────────────────────────── +# Внутренние хелперы +# ───────────────────────────────────────────────────────────────── + + +def _parse_user(user_dto) -> RWUserInfo: + """Преобразовать UserResponseDto → RWUserInfo.""" + active_squads: list[dict] = [] + for squad in user_dto.active_internal_squads or []: + active_squads.append({"uuid": str(squad.uuid), "name": squad.name}) + + # UserStatus может быть enum-объектом или строкой — нормализуем + status_raw = user_dto.status + if hasattr(status_raw, "value"): + status_str = str(status_raw.value).upper() + else: + status_str = str(status_raw).upper() + + # Трафик лежит в user_traffic.used_traffic_bytes + used_traffic: float = 0.0 + if user_dto.user_traffic is not None: + used_traffic = float(user_dto.user_traffic.used_traffic_bytes or 0) + + return RWUserInfo( + uuid=str(user_dto.uuid), + username=user_dto.username, + status=status_str, + expire_at=user_dto.expire_at, + used_traffic_bytes=used_traffic, + traffic_limit_bytes=int(user_dto.traffic_limit_bytes or 0), + hwid_device_limit=user_dto.hwid_device_limit, + active_squads=active_squads, + description=user_dto.description, + ) + + +# ───────────────────────────────────────────────────────────────── +# Публичные функции — обёртки с перехватом ошибок +# ───────────────────────────────────────────────────────────────── + + +async def get_user_by_telegram_id( + sdk: RemnawaveSDK, + telegram_id: int, +) -> Optional[RWUserInfo]: + """ + Получить первого пользователя Remnawave, привязанного к Telegram ID. + Возвращает None, если пользователь не найден или Remnawave недоступен. + """ + try: + users_list = await sdk.users.get_users_by_telegram_id(str(telegram_id)) + if not users_list: + return None + return _parse_user(users_list[0]) + except Exception as exc: + logger.warning( + "Failed to get RW user by telegram_id=%s: %s", + telegram_id, + exc, + ) + return None + + +async def get_all_squads(sdk: RemnawaveSDK) -> list[dict]: + """ + Получить все Internal Squads из Remnawave. + Возвращает список [{"uuid": "...", "name": "..."}]. + При ошибке возвращает пустой список. + """ + try: + response = await sdk.internal_squads.get_internal_squads() + result: list[dict] = [] + # GetAllInternalSquadsResponseDto имеет поле .internal_squads + for squad in response.internal_squads: + result.append({"uuid": str(squad.uuid), "name": squad.name}) + return result + except Exception as exc: + logger.warning("Failed to get all internal squads: %s", exc) + return [] + + +async def add_days( + sdk: RemnawaveSDK, + user_uuid: str, + days: int, +) -> Optional[datetime]: + """ + Добавить N дней к expire_at пользователя. + Возвращает новую дату окончания или None при ошибке. + """ + try: + # Получаем актуальный expire_at — нельзя слепо использовать + # закешированное значение, чтобы не потерять изменения других агентов + user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid) + if user_dto is None or user_dto.expire_at is None: + logger.warning("Cannot add days: user %s has no expire_at", user_uuid) + return None + + # Обеспечиваем timezone-aware datetime + current_expire = user_dto.expire_at + if current_expire.tzinfo is None: + current_expire = current_expire.replace(tzinfo=timezone.utc) + + new_expire = current_expire + timedelta(days=days) + + result = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + expire_at=new_expire, + ) + ) + if result is None: + return None + + 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) + return new_dt + except Exception as exc: + logger.warning("Failed to add %d days for user %s: %s", days, user_uuid, exc) + return None + + +async def remove_days( + sdk: RemnawaveSDK, + user_uuid: str, + days: int, +) -> Optional[datetime]: + """ + Вычесть N дней из expire_at пользователя. + Возвращает новую дату окончания или None при ошибке. + """ + try: + user_dto = await sdk.users.get_user_by_uuid(uuid=user_uuid) + if user_dto is None or user_dto.expire_at is None: + logger.warning("Cannot remove days: user %s has no expire_at", user_uuid) + return None + + current_expire = user_dto.expire_at + if current_expire.tzinfo is None: + current_expire = current_expire.replace(tzinfo=timezone.utc) + + new_expire = current_expire - timedelta(days=days) + + result = await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + expire_at=new_expire, + ) + ) + if result is None: + return None + + 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) + return new_dt + except Exception as exc: + logger.warning("Failed to remove %d days for user %s: %s", days, user_uuid, exc) + return None + + +async def set_hwid_limit( + sdk: RemnawaveSDK, + user_uuid: str, + limit: int, +) -> bool: + """ + Установить лимит HWID-устройств. + limit == 0 → убрать ограничение (None). + Возвращает True при успехе, False при ошибке. + """ + try: + hwid_value: Optional[int] = None if limit == 0 else limit + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + hwid_device_limit=hwid_value, + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to set HWID limit=%s for user %s: %s", + limit, + user_uuid, + exc, + ) + return False + + +async def set_description( + sdk: RemnawaveSDK, + user_uuid: str, + note: str, +) -> bool: + """ + Установить заметку (description) пользователя в Remnawave. + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + description=note, + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to set description for user %s: %s", + user_uuid, + exc, + ) + return False + + +async def reset_traffic( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Сбросить счётчик использованного трафика пользователя. + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.reset_user_traffic(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to reset traffic for user %s: %s", user_uuid, exc) + return False + + +async def disable_user( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Отключить пользователя (статус → DISABLED). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.disable_user(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to disable user %s: %s", user_uuid, exc) + return False + + +async def enable_user( + sdk: RemnawaveSDK, + user_uuid: str, +) -> bool: + """ + Включить пользователя (статус → ACTIVE). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.enable_user(uuid=user_uuid) + return True + except Exception as exc: + logger.warning("Failed to enable user %s: %s", user_uuid, exc) + return False + + +async def update_squads( + sdk: RemnawaveSDK, + user_uuid: str, + squad_uuid_list: list[str], +) -> bool: + """ + Полностью заменить список Internal Squads пользователя. + squad_uuid_list — строковые UUID всех squad'ов (полный новый список). + Возвращает True при успехе, False при ошибке. + """ + try: + await sdk.users.update_user( + UpdateUserRequestDto( + uuid=UUID(user_uuid), + active_internal_squads=[UUID(s) for s in squad_uuid_list], + ) + ) + return True + except Exception as exc: + logger.warning( + "Failed to update squads for user %s: %s", + user_uuid, + exc, + ) + return False diff --git a/services/user_service.py b/services/user_service.py index aacc137..a6a9ca5 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -1,14 +1,87 @@ from typing import Optional +from aiogram.types import Message, ReactionTypeEmoji from sqlalchemy.ext.asyncio import AsyncSession +from remnawave import RemnawaveSDK +from config import settings +from db.models.tickets import TicketStatus from db.models.users import User -from dto.users import NewUserDTO +from dto.tickets import NewTicketDTO +from dto.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, + SUPPORT_SIGNATURE, + TICKET_STATUS_EMOJIS, + ADMIN_TICKET_WITHOUT_RW, +) +from misc import utils + + +class UserServiceException(Exception): ... class UserService: - def __init__(self, user_repository: UserRepository) -> None: + def __init__( + self, user_repository: UserRepository, ticket_repository: TicketRepository + ) -> None: self.user_repository = user_repository + self.ticket_repository = ticket_repository + + @staticmethod + def format_ticket_name( + creator: TicketCreator, + status: Optional[TicketStatus] = TicketStatus.OPEN, + ticket_id: Optional[int] = None, + ): + return ( + TICKET_STATUS_EMOJIS[status.value] + + (f" T-{ticket_id:0>3}" if ticket_id else "") + + " | " + + (f"@{creator.username}" if creator.username else str(creator.id)) + ) + + @staticmethod + async def format_admin_support_message( + ticket_number: int, user: User, rw_user: Optional[RWUserInfo] + ): + username_display = utils.format_username(user.username) + + if rw_user is None: + # Пользователь не найден в Remnawave + return ADMIN_TICKET_WITHOUT_RW.format( + ticket_number=ticket_number, + full_name=user.full_name, + username_display=username_display, + telegram_id=user.id, + ) + + 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 + ) + hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit) + squads = utils.format_squads(rw_user.active_squads) + + return ADMIN_TICKET_WITH_RW.format( + ticket_number=ticket_number, + full_name=user.full_name, + username_display=username_display, + telegram_id=user.id, + status_icon=status_icon, + status=status_text, + expire_date=expire_date, + days_left=days_left, + used_traffic=used_traffic, + hwid_limit=hwid_limit, + squads=squads, + remnawave_uuid=rw_user.uuid, + ) async def add_user( self, session: AsyncSession, user_id: int, referal: Optional[int] = None @@ -20,3 +93,105 @@ class UserService: referal = int(referal) if str(referal).isdigit() else None model = NewUserDTO(id=user_id, referal=referal) return await self.user_repository.create_user(session, model) + + 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) + + ticket = await self.ticket_repository.get_available_ticket_by_user_id( + session, user.id + ) + + if ticket: + try: + print(ticket.thread_id) + await msg.copy_to( + chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id + ) + return + except Exception: + await self.ticket_repository.update_ticket_status( + session, ticket, TicketStatus.CLOSED + ) + + rw_user = await get_user_by_telegram_id(rw, user.id) + creator = TicketCreator(id=user.id, username=msg.from_user.username) + + ticket_dto = NewTicketDTO( + creator_id=user.id, + ) + 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 + ), + ) + ticket = await self.ticket_repository.update_ticket_thread_id( + session, ticket, thread.message_thread_id + ) + + await msg.bot.send_message( + settings.admin_group_id, + message_thread_id=thread.message_thread_id, + text=await self.format_admin_support_message( + ticket_number=ticket.id, user=msg.from_user, rw_user=rw_user + ), + ) + 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 + ) + + if ticket.status == TicketStatus.PENDING: + 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 + ), + ) + + try: + await msg.bot.send_message(ticket.creator_id, SUPPORT_SIGNATURE) + await msg.copy_to(ticket.creator_id) + await msg.react([ReactionTypeEmoji(emoji="⚡")]) + except Exception: + await msg.reply(ADMIN_STANDARD_FALLBACK) + + 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 + ) + + 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, + status=TicketStatus.CLOSED, + ), + ) + + 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 + ) diff --git a/states/__init__.py b/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/states/support.py b/states/support.py new file mode 100644 index 0000000..4d7c46f --- /dev/null +++ b/states/support.py @@ -0,0 +1,5 @@ +from aiogram.fsm.state import StatesGroup, State + + +class SupportStorage(StatesGroup): + prompt = State() diff --git a/texts.py b/texts.py new file mode 100644 index 0000000..6da8ea1 --- /dev/null +++ b/texts.py @@ -0,0 +1,124 @@ +from misc.utils import fetch_quote +from db.models.tickets import TicketStatus + + +class PremiumEmoji: + def __init__(self, emoji_id: int, emoji: str): + self.emoji_id = emoji_id + self.emoji = emoji + + def __str__(self) -> str: + return f'{self.emoji}' + + +## EMOJIS + + +MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️") +MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️") +MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗") +MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙") +MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒") +MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧") + +## MAPPING +TICKET_STATUS_EMOJIS = { + TicketStatus.CLOSED.value: "🔴", + TicketStatus.OPEN.value: "🟢", + TicketStatus.PENDING.value: "🟡", +} + +## TEXTS +MAIN_MENU = ( + str(MALENIA_LOGO) + + " Malenia — {quote}\n\n" + + f"{MALENIA_COINS} Личный кабинет" +) +STANDARD_FALLBACK = ( + "❌ Что-то пошло не так, прожмите /start и повторите попытку позже." +) + +GENERAL_PROCESSING = "⏳ Выполняю..." + +SUPPORT_INIT = f"{MALENIA_SUPPORT} Что-то не работает? Жалоба? Предложение?\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n❌ Для отмены — /cancel" +SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время." +SUPPORT_SIGNATURE = "✍️ Сообщение от технической поддержки:" +ADMIN_TICKET_WITH_RW = ( + "🎫 ТИКЕТ T-{ticket_number:04d}\n\n" + "👤 Имя: {full_name}\n" + "🔗 Username: {username_display}\n" + "🆔 Telegram ID: {telegram_id}\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "📋 ПОДПИСКА REMNAWAVE\n\n" + "{status_icon} Статус: {status}\n" + "📅 Истекает: {expire_date}\n" + "⏳ Осталось: {days_left}\n" + "📊 Трафик: {used_traffic}\n" + "📱 HWID лимит: {hwid_limit}\n" + "🎯 Internal Squads: {squads}\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "🔑 UUID в Remnawave: {remnawave_uuid}" +) +ADMIN_TICKET_WITHOUT_RW = ( + "🎫 ТИКЕТ T-{ticket_number:04d}\n\n" + "👤 Имя: {full_name}\n" + "🔗 Username: {username_display}\n" + "🆔 Telegram ID: {telegram_id}\n\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n" + "⚠️ Аккаунт в Remnawave не найден" +) + + +ADMIN_STANDARD_FALLBACK = ( + "❌ Что-то пошло не так, обратитесь к логам для подробностей." +) + + +## FUNCTIONS +def build_main_menu_text() -> str: + return MAIN_MENU.format(quote=fetch_quote()) + + +NO_USERNAME = "нет юзернейма" +STATUS_ICON_ACTIVE = "🟢" +STATUS_ICON_DISABLED = "🔴" +STATUS_ICON_EXPIRED = "🟡" +STATUS_ICON_LIMITED = "🟠" +STATUS_ICON_UNKNOWN = "⚪" + +# Текст статуса подписки +STATUS_ACTIVE = "Активна" +STATUS_DISABLED = "Отключена" +STATUS_EXPIRED = "Истекла" +STATUS_LIMITED = "Превышен лимит трафика" +STATUS_UNKNOWN = "Неизвестен" + +# Шаблон для остатка дней: {days} дн. +DAYS_LEFT_POSITIVE = "{days} дн." + +# Когда подписка уже истекла +DAYS_LEFT_EXPIRED = "истекла {days} дн. назад" + +# Когда подписка истекает сегодня +DAYS_LEFT_TODAY = "истекает сегодня" + +# Когда нет username в Telegram +NO_USERNAME = "нет username" + +# Формат отображения username (с @) +USERNAME_DISPLAY = "@{username}" + +# Когда у пользователя нет Internal Squads +NO_SQUADS = "нет" + +# Когда лимит HWID не установлен (0 или None) +UNLIMITED_HWID = "без ограничений" + +# Когда лимит трафика не установлен +NO_TRAFFIC_LIMIT = "без ограничений" + +# Шаблон для отображения трафика с лимитом: {used} / {limit} +TRAFFIC_WITH_LIMIT = "{used} / {limit}" + +# Шаблон для отображения трафика без лимита: {used} +TRAFFIC_NO_LIMIT = "{used}"