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