first com2
This commit is contained in:
37
.dockerignore
Normal file
37
.dockerignore
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.13
|
||||
24
Dockerfile
Normal file
24
Dockerfile
Normal 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"]
|
||||
48
alembic/versions/3582c3285f91_ticket_state_ticket_status.py
Normal file
48
alembic/versions/3582c3285f91_ticket_state_ticket_status.py
Normal 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 ###
|
||||
36
alembic/versions/53992c092918_ticket_new_field_thread_id.py
Normal file
36
alembic/versions/53992c092918_ticket_new_field_thread_id.py
Normal 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 ###
|
||||
32
alembic/versions/756210f564a5_ticket_thread_id_nullable.py
Normal file
32
alembic/versions/756210f564a5_ticket_thread_id_nullable.py
Normal 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 ###
|
||||
27
alembic/versions/b833fa0e6dd9_ticket_status_pending.py
Normal file
27
alembic/versions/b833fa0e6dd9_ticket_status_pending.py
Normal 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
|
||||
@@ -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 ###
|
||||
53
alembic/versions/fad6b6da6569_ticket_table_created.py
Normal file
53
alembic/versions/fad6b6da6569_ticket_table_created.py
Normal 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 ###
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .users import User
|
||||
from .tickets import Ticket
|
||||
|
||||
__all__ = ["User"]
|
||||
__all__ = ["User", "Ticket"]
|
||||
|
||||
24
db/models/tickets.py
Normal file
24
db/models/tickets.py
Normal 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)
|
||||
@@ -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
14
docker-compose.yml
Normal 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
|
||||
@@ -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
6
dto/tickets.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewTicketDTO:
|
||||
creator_id: int
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
10
handlers/buy.py
Normal 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()
|
||||
@@ -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
69
handlers/support.py
Normal 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)
|
||||
@@ -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
18
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)
|
||||
|
||||
127
misc/utils.py
Normal file
127
misc/utils.py
Normal 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
1
prob
Normal file
@@ -0,0 +1 @@
|
||||
Мне нужно, чтобы ссылка для подключения пользователя была доступна в главном меню, но если при каждом /start я буду делать запрос, каждая обработка будет занимать по году. Как это реализовать?
|
||||
62
repositories/tickets.py
Normal file
62
repositories/tickets.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
remnawave>=2.6.1
|
||||
320
services/rw.py
Normal file
320
services/rw.py
Normal 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
|
||||
@@ -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
0
states/__init__.py
Normal file
5
states/support.py
Normal file
5
states/support.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from aiogram.fsm.state import StatesGroup, State
|
||||
|
||||
|
||||
class SupportStorage(StatesGroup):
|
||||
prompt = State()
|
||||
124
texts.py
Normal file
124
texts.py
Normal 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}"
|
||||
Reference in New Issue
Block a user