first com2

This commit is contained in:
2026-04-08 17:10:36 +07:00
parent 8a597f880f
commit fc45816eba
34 changed files with 1310 additions and 29 deletions

37
.dockerignore Normal file
View File

@@ -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

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

24
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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

View File

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

24
db/models/tickets.py Normal file
View File

@@ -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)

View File

@@ -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)

14
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -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

6
dto/tickets.py Normal file
View File

@@ -0,0 +1,6 @@
from dataclasses import dataclass
@dataclass
class NewTicketDTO:
creator_id: int

View File

@@ -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

View File

@@ -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

View File

@@ -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]

10
handlers/buy.py Normal file
View File

@@ -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()

View File

@@ -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
@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)

69
handlers/support.py Normal file
View File

@@ -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)

View File

@@ -13,3 +13,7 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
],
]
).as_markup()
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]]
).as_markup()

18
main.py
View File

@@ -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)

127
misc/utils.py Normal file
View File

@@ -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

1
prob Normal file
View File

@@ -0,0 +1 @@
Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать?

62
repositories/tickets.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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
alembic
remnawave>=2.6.1

320
services/rw.py Normal file
View File

@@ -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

View File

@@ -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
)

0
states/__init__.py Normal file
View File

5
states/support.py Normal file
View File

@@ -0,0 +1,5 @@
from aiogram.fsm.state import StatesGroup, State
class SupportStorage(StatesGroup):
prompt = State()

124
texts.py Normal file
View File

@@ -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'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-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)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>"
)
STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
)
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>"
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
ADMIN_TICKET_WITH_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"📋 <b>ПОДПИСКА REMNAWAVE</b>\n\n"
"{status_icon} <b>Статус:</b> {status}\n"
"📅 <b>Истекает:</b> {expire_date}\n"
"⏳ <b>Осталось:</b> {days_left}\n"
"📊 <b>Трафик:</b> {used_traffic}\n"
"📱 <b>HWID лимит:</b> {hwid_limit}\n"
"🎯 <b>Internal Squads:</b> {squads}\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
)
ADMIN_TICKET_WITHOUT_RW = (
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
"👤 <b>Имя:</b> {full_name}\n"
"🔗 <b>Username:</b> {username_display}\n"
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
"⚠️ <b>Аккаунт в Remnawave не найден</b>"
)
ADMIN_STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
)
## 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}"