Add subscription link field to user model
- Add subscription_link column to users table (nullable, unique) - Add SUBSCRIPTION_URL to config settings - Update user creation to include subscription link from Remnawave - Add support subscription ticket creation with formatted message - Add "update link" button to main menu - Refactor support subscription formatting to include user details
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
"""user -> +subscription_link optional text.
|
||||||
|
|
||||||
|
Revision ID: 5985b2df0830
|
||||||
|
Revises: 756210f564a5
|
||||||
|
Create Date: 2026-04-09 20:38:15.157762
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "5985b2df0830"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "756210f564a5"
|
||||||
|
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("users", sa.Column("subscription_link", sa.TEXT(), nullable=True))
|
||||||
|
op.create_unique_constraint(None, "users", ["subscription_link"])
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, "users", type_="unique")
|
||||||
|
op.drop_column("users", "subscription_link")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -6,12 +6,13 @@ from dotenv import load_dotenv
|
|||||||
load_dotenv(override=True)
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
|
||||||
class settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
bot_token: str = Field(alias="BOT_TOKEN")
|
bot_token: str = Field(alias="BOT_TOKEN")
|
||||||
postgres_url: str = Field(alias="POSTGRES_URL")
|
postgres_url: str = Field(alias="POSTGRES_URL")
|
||||||
proxy: Optional[str] = Field(None, alias="PROXY")
|
proxy: Optional[str] = Field(None, alias="PROXY")
|
||||||
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
|
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
|
||||||
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
||||||
|
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
|
||||||
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
|
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
|
||||||
min_devices: int = Field(3, alias="MIN_DEVICES")
|
min_devices: int = Field(3, alias="MIN_DEVICES")
|
||||||
max_devices: int = Field(12, alias="MAX_DEVICES")
|
max_devices: int = Field(12, alias="MAX_DEVICES")
|
||||||
@@ -20,4 +21,4 @@ class settings(BaseSettings):
|
|||||||
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
|
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
|
||||||
|
|
||||||
|
|
||||||
settings = settings() # type: ignore
|
settings = Settings() # type: ignore
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import BIGINT
|
from sqlalchemy import BIGINT, TEXT
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from db.base import Base
|
from db.base import Base
|
||||||
@@ -11,3 +11,4 @@ class User(Base):
|
|||||||
BIGINT, nullable=False, unique=True, primary_key=True
|
BIGINT, nullable=False, unique=True, primary_key=True
|
||||||
)
|
)
|
||||||
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
|
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
|
||||||
|
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import logging
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from aiogram import Router, F
|
from aiogram import Router, F
|
||||||
from aiogram.types import CallbackQuery
|
from aiogram.types import CallbackQuery
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from keyboards.client import (
|
from keyboards.client import (
|
||||||
devices_selector,
|
devices_selector,
|
||||||
@@ -12,22 +14,26 @@ from keyboards.client import (
|
|||||||
from misc.utils import (
|
from misc.utils import (
|
||||||
calculate_price,
|
calculate_price,
|
||||||
convert_int_to_duration,
|
convert_int_to_duration,
|
||||||
format_support_subscription,
|
|
||||||
)
|
)
|
||||||
|
from schemas.di import DependenciesDTO
|
||||||
from schemas.subscriptions import SubscriptionPlan
|
from schemas.subscriptions import SubscriptionPlan
|
||||||
|
from services.user_service import UserServiceException
|
||||||
from states.buy import SubscriptionStorage
|
from states.buy import SubscriptionStorage
|
||||||
from texts import (
|
from texts import (
|
||||||
CALLBACK_FALLBACK,
|
CALLBACK_FALLBACK,
|
||||||
CHECKOUT_PAYMENT_CHOICE,
|
CHECKOUT_PAYMENT_CHOICE,
|
||||||
CONTEXT_REINITIATED,
|
CONTEXT_REINITIATED,
|
||||||
|
GENERAL_PROCESSING,
|
||||||
NOTHING_PLACEHOLDER,
|
NOTHING_PLACEHOLDER,
|
||||||
STANDARD_FALLBACK,
|
STANDARD_FALLBACK,
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR,
|
SUBSCRIPTION_DEVICE_SELECTOR,
|
||||||
SUBSCRIPTION_DURATION_SELECTOR,
|
SUBSCRIPTION_DURATION_SELECTOR,
|
||||||
|
SUPPORT_MSG_SENT,
|
||||||
)
|
)
|
||||||
from config import settings
|
from config import settings
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "buy")
|
@router.callback_query(F.data == "buy")
|
||||||
@@ -160,6 +166,36 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
|
|||||||
return
|
return
|
||||||
|
|
||||||
await cb.message.edit_text(
|
await cb.message.edit_text(
|
||||||
CHECKOUT_PAYMENT_CHOICE.format(code=format_support_subscription(ctx)),
|
CHECKOUT_PAYMENT_CHOICE,
|
||||||
reply_markup=payment_gateways,
|
reply_markup=payment_gateways,
|
||||||
) # FIXME: temp solution that's incredibly stupid
|
) # FIXME: temp solution that's incredibly stupid
|
||||||
|
|
||||||
|
await state.set_state(SubscriptionStorage.checkout)
|
||||||
|
await state.set_data({"ctx": ctx})
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "checkout")
|
||||||
|
async def checkout_support(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||||
|
):
|
||||||
|
data = await state.get_data()
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
ctx: Optional[SubscriptionPlan] = data.get("ctx")
|
||||||
|
if not ctx:
|
||||||
|
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
|
||||||
|
return
|
||||||
|
|
||||||
|
await cb.message.edit_text(GENERAL_PROCESSING)
|
||||||
|
try:
|
||||||
|
await deps.user_service.create_subscription_ticket(
|
||||||
|
session, cb.from_user, ctx, cb.bot
|
||||||
|
)
|
||||||
|
await cb.message.edit_text(SUPPORT_MSG_SENT)
|
||||||
|
except UserServiceException:
|
||||||
|
await cb.message.edit_text(STANDARD_FALLBACK)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await cb.message.edit_text(STANDARD_FALLBACK)
|
||||||
|
logger.exception("Exception occured while trying to forward a msg to support")
|
||||||
|
raise
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ from aiogram.types import CallbackQuery, Message
|
|||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from misc.utils import format_subscription_link
|
||||||
from schemas.di import DependenciesDTO
|
from schemas.di import DependenciesDTO
|
||||||
from keyboards.client import main_menu
|
from keyboards.client import main_menu, return_to_menu
|
||||||
from texts import build_main_menu_text
|
from texts import GENERAL_PROCESSING, build_main_menu_text, FAQ_TEXT
|
||||||
|
|
||||||
router = Router()
|
router = Router()
|
||||||
|
|
||||||
@@ -27,9 +28,14 @@ async def fetch_referal(
|
|||||||
return
|
return
|
||||||
|
|
||||||
referal = data[0]
|
referal = data[0]
|
||||||
await deps.user_service.add_user(session, user_id=msg.from_user.id, referal=referal)
|
user = await deps.user_service.add_user(
|
||||||
|
session, user_id=msg.from_user.id, referal=referal, rw_sdk=deps.rw_sdk
|
||||||
|
)
|
||||||
|
|
||||||
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
|
await msg.reply(
|
||||||
|
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||||
|
reply_markup=main_menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command(commands=["start", "cancel"]))
|
@router.message(Command(commands=["start", "cancel"]))
|
||||||
@@ -42,13 +48,48 @@ async def standard_start(
|
|||||||
):
|
):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await deps.user_service.add_user(session, user_id=msg.from_user.id)
|
user = await deps.user_service.add_user(
|
||||||
|
session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk
|
||||||
|
)
|
||||||
|
|
||||||
await msg.reply(build_main_menu_text(), reply_markup=main_menu)
|
await msg.reply(
|
||||||
|
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||||
|
reply_markup=main_menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.callback_query(F.data == "menu:main")
|
@router.callback_query(F.data == "menu:main")
|
||||||
async def main_menu_cb(cb: CallbackQuery, state: FSMContext):
|
async def main_menu_cb(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||||
|
):
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
await cb.message.edit_text(build_main_menu_text(), reply_markup=main_menu)
|
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
|
||||||
|
await cb.message.edit_text(
|
||||||
|
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||||
|
reply_markup=main_menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "update_link")
|
||||||
|
async def update_link(
|
||||||
|
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
|
||||||
|
):
|
||||||
|
await state.clear()
|
||||||
|
await cb.message.edit_text(GENERAL_PROCESSING)
|
||||||
|
|
||||||
|
user = await deps.user_service.update_user_link(
|
||||||
|
session, cb.from_user.id, deps.rw_sdk
|
||||||
|
)
|
||||||
|
await cb.answer("✅")
|
||||||
|
await cb.message.edit_text(
|
||||||
|
build_main_menu_text(format_subscription_link(user.subscription_link)),
|
||||||
|
reply_markup=main_menu,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "faq")
|
||||||
|
async def faq(cb: CallbackQuery, state: FSMContext):
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
await cb.message.edit_text(FAQ_TEXT, reply_markup=return_to_menu)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
|||||||
InlineKeyboardButton(text="Наш Канал", url="https://goo.gle"),
|
InlineKeyboardButton(text="Наш Канал", url="https://goo.gle"),
|
||||||
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
|
InlineKeyboardButton(text="Тех. Поддержка", callback_data="support"),
|
||||||
],
|
],
|
||||||
|
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
|
||||||
]
|
]
|
||||||
).as_markup()
|
).as_markup()
|
||||||
|
|
||||||
@@ -90,7 +91,7 @@ def duration_selector(
|
|||||||
|
|
||||||
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||||
[
|
[
|
||||||
[InlineKeyboardButton(text="✍️ Поддержка", callback_data="support")],
|
[InlineKeyboardButton(text="✍️ Перейти в поддержку", callback_data="checkout")],
|
||||||
[_return_to_menu_btn],
|
[_return_to_menu_btn],
|
||||||
]
|
]
|
||||||
).as_markup()
|
).as_markup()
|
||||||
|
|||||||
@@ -171,16 +171,33 @@ def calculate_price(subscription: SubscriptionPlan):
|
|||||||
) * convert_duration_to_int(subscription.duration)
|
) * convert_duration_to_int(subscription.duration)
|
||||||
|
|
||||||
|
|
||||||
def format_support_subscription(subscription: SubscriptionPlan) -> str:
|
def format_subscription_link(link: Optional[str] = None):
|
||||||
|
return f"<code>{link or '...'}</code>"
|
||||||
|
|
||||||
|
|
||||||
|
def format_support_subscription(
|
||||||
|
subscription: SubscriptionPlan,
|
||||||
|
full_name: str,
|
||||||
|
user_id: int,
|
||||||
|
username: Optional[str] = None,
|
||||||
|
) -> str:
|
||||||
|
username = format_username(username)
|
||||||
whitelist_description = "ВЫКЛ"
|
whitelist_description = "ВЫКЛ"
|
||||||
if subscription.devices >= settings.whitelist_device_threshold:
|
if subscription.devices >= settings.whitelist_device_threshold:
|
||||||
whitelist_description = "ВКЛ (Бесплатно)"
|
whitelist_description = "ВКЛ (Бесплатно)"
|
||||||
elif subscription.whitelists:
|
elif subscription.whitelists:
|
||||||
whitelist_description = "ВКЛ (Платно)"
|
whitelist_description = "ВКЛ (Платно)"
|
||||||
|
|
||||||
return (
|
return texts.SUPPORT_SUBSCRIPTION.format(
|
||||||
f"Подписка на {convert_duration_to_int(subscription.duration)} месяцев.\n"
|
full_name=full_name,
|
||||||
f"Количество устройств: {subscription.devices}\n"
|
telegram_id=user_id,
|
||||||
f"Белые списки: {whitelist_description}\n\n"
|
username_display=username,
|
||||||
f"Итого: {calculate_price(subscription)}₽"
|
duration=convert_duration_to_int(subscription.duration),
|
||||||
|
devices=subscription.devices,
|
||||||
|
whitelist_description=whitelist_description,
|
||||||
|
price=calculate_price(subscription),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscription_link(short_uuid: str):
|
||||||
|
return settings.subscription_url + "/" + short_uuid
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ class UserRepository:
|
|||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def create_user(self, session: AsyncSession, user_data: NewUserDTO) -> User:
|
async def create_user(self, session: AsyncSession, user_data: NewUserDTO) -> User:
|
||||||
user = User(id=user_data.id, referal_id=user_data.referal)
|
user = User(
|
||||||
|
id=user_data.id,
|
||||||
|
referal_id=user_data.referal,
|
||||||
|
subscription_link=user_data.link,
|
||||||
|
)
|
||||||
session.add(user)
|
session.add(user)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -32,3 +36,14 @@ class UserRepository:
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
async def update_user_link(
|
||||||
|
self, session: AsyncSession, user_id: int, link: str
|
||||||
|
) -> Optional[User]:
|
||||||
|
user = await self.get_user_by_id(session, user_id=user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
user.subscription_link = link
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ from typing import Optional
|
|||||||
@dataclass
|
@dataclass
|
||||||
class NewUserDTO:
|
class NewUserDTO:
|
||||||
id: int
|
id: int
|
||||||
referal: int
|
referal: Optional[int] = None
|
||||||
|
link: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class RWUserInfo:
|
|||||||
hwid_device_limit: Optional[int] # None = unlimited
|
hwid_device_limit: Optional[int] # None = unlimited
|
||||||
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
|
active_squads: list[dict] # [{"uuid": "...", "name": "..."}]
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
|
short_uuid: str
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────
|
||||||
@@ -61,6 +62,7 @@ def _parse_user(user_dto) -> RWUserInfo:
|
|||||||
hwid_device_limit=user_dto.hwid_device_limit,
|
hwid_device_limit=user_dto.hwid_device_limit,
|
||||||
active_squads=active_squads,
|
active_squads=active_squads,
|
||||||
description=user_dto.description,
|
description=user_dto.description,
|
||||||
|
short_uuid=user_dto.short_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from aiogram import Bot
|
||||||
from aiogram.types import Message, ReactionTypeEmoji
|
from aiogram.types import Message, ReactionTypeEmoji
|
||||||
|
from aiogram.types import User as TelegramUser
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from remnawave import RemnawaveSDK
|
from remnawave import RemnawaveSDK
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from db.models.tickets import TicketStatus
|
from db.models.tickets import TicketStatus
|
||||||
from db.models.users import User
|
from db.models.users import User
|
||||||
|
from schemas.subscriptions import SubscriptionPlan
|
||||||
from schemas.tickets import NewTicketDTO
|
from schemas.tickets import NewTicketDTO
|
||||||
from schemas.users import NewUserDTO, TicketCreator
|
from schemas.users import NewUserDTO, TicketCreator
|
||||||
from repositories import UserRepository
|
from repositories import UserRepository
|
||||||
@@ -84,16 +87,76 @@ class UserService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def add_user(
|
async def add_user(
|
||||||
self, session: AsyncSession, user_id: int, referal: Optional[int] = None
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
rw_sdk: RemnawaveSDK,
|
||||||
|
referal: Optional[int] = None,
|
||||||
) -> User:
|
) -> User:
|
||||||
user = await self.user_repository.get_user_by_id(session, user_id)
|
user = await self.user_repository.get_user_by_id(session, user_id)
|
||||||
if user:
|
if user:
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
|
||||||
|
link = utils.get_subscription_link(rw_user.short_uuid)
|
||||||
referal = int(referal) if str(referal).isdigit() else None
|
referal = int(referal) if str(referal).isdigit() else None
|
||||||
model = NewUserDTO(id=user_id, referal=referal)
|
|
||||||
|
model = NewUserDTO(id=user_id, referal=referal, link=link)
|
||||||
return await self.user_repository.create_user(session, model)
|
return await self.user_repository.create_user(session, model)
|
||||||
|
|
||||||
|
async def create_subscription_ticket(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
user: TelegramUser,
|
||||||
|
subscription: SubscriptionPlan,
|
||||||
|
bot: Bot,
|
||||||
|
):
|
||||||
|
ticket = await self.ticket_repository.get_available_ticket_by_user_id(
|
||||||
|
session, user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if ticket:
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=settings.admin_group_id,
|
||||||
|
message_thread_id=ticket.thread_id,
|
||||||
|
text=utils.format_support_subscription(
|
||||||
|
subscription, user.full_name, user.id, user.username
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
await self.ticket_repository.update_ticket_status(
|
||||||
|
session, ticket, TicketStatus.CLOSED
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
creator = TicketCreator(id=user.id, username=user.username)
|
||||||
|
|
||||||
|
ticket_dto = NewTicketDTO(
|
||||||
|
creator_id=user.id,
|
||||||
|
)
|
||||||
|
ticket = await self.ticket_repository.create_ticket(
|
||||||
|
session, ticket_data=ticket_dto
|
||||||
|
)
|
||||||
|
|
||||||
|
thread = await 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 bot.send_message(
|
||||||
|
chat_id=settings.admin_group_id,
|
||||||
|
message_thread_id=ticket.thread_id,
|
||||||
|
text=utils.format_support_subscription(
|
||||||
|
subscription, user.full_name, user.id, user.username
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
async def support_forward_message(
|
async def support_forward_message(
|
||||||
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
|
self, session: AsyncSession, msg: Message, rw: RemnawaveSDK
|
||||||
):
|
):
|
||||||
@@ -195,3 +258,14 @@ class UserService:
|
|||||||
await msg.bot.close_forum_topic(
|
await msg.bot.close_forum_topic(
|
||||||
settings.admin_group_id, message_thread_id=thread_id
|
settings.admin_group_id, message_thread_id=thread_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def update_user_link(
|
||||||
|
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
||||||
|
) -> Optional[User]:
|
||||||
|
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
|
||||||
|
|
||||||
|
link = utils.get_subscription_link(rw_user.short_uuid)
|
||||||
|
|
||||||
|
user = await self.user_repository.update_user_link(session, user_id, link)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ from aiogram.fsm.state import StatesGroup, State
|
|||||||
class SubscriptionStorage(StatesGroup):
|
class SubscriptionStorage(StatesGroup):
|
||||||
devices = State()
|
devices = State()
|
||||||
duration = State()
|
duration = State()
|
||||||
|
checkout = State()
|
||||||
|
|||||||
250
texts.py
250
texts.py
@@ -2,6 +2,10 @@ from misc.utils import fetch_quote
|
|||||||
from db.models.tickets import TicketStatus
|
from db.models.tickets import TicketStatus
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# PREMIUM EMOJI
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
class PremiumEmoji:
|
class PremiumEmoji:
|
||||||
def __init__(self, emoji_id: int, emoji: str):
|
def __init__(self, emoji_id: int, emoji: str):
|
||||||
self.emoji_id = emoji_id
|
self.emoji_id = emoji_id
|
||||||
@@ -11,135 +15,201 @@ class PremiumEmoji:
|
|||||||
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
|
return f'<tg-emoji emoji-id="{self.emoji_id}">{self.emoji}</tg-emoji>'
|
||||||
|
|
||||||
|
|
||||||
## EMOJIS
|
# — Иконки бота
|
||||||
|
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
|
||||||
|
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
|
||||||
MALENIA_LOGO = PremiumEmoji(5265053829522562789, "☀️")
|
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
|
||||||
MALENIA_HEART = PremiumEmoji(5276516908257025723, "❤️")
|
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
|
||||||
MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
|
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
|
||||||
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
|
|
||||||
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
|
|
||||||
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
|
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
|
||||||
|
|
||||||
## MAPPING
|
|
||||||
|
# ============================================================
|
||||||
|
# МАППИНГИ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
TICKET_STATUS_EMOJIS = {
|
TICKET_STATUS_EMOJIS = {
|
||||||
TicketStatus.CLOSED.value: "🔴",
|
TicketStatus.CLOSED.value: "🔴",
|
||||||
TicketStatus.OPEN.value: "🟢",
|
TicketStatus.OPEN.value: "🟢",
|
||||||
TicketStatus.PENDING.value: "🟡",
|
TicketStatus.PENDING.value: "🟡",
|
||||||
}
|
}
|
||||||
|
|
||||||
## TEXTS
|
|
||||||
|
# ============================================================
|
||||||
|
# СТАТУСЫ ПОДПИСКИ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# — Иконки статусов
|
||||||
|
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_LEFT_POSITIVE = "{days} дн."
|
||||||
|
DAYS_LEFT_EXPIRED = "истекла {days} дн. назад"
|
||||||
|
DAYS_LEFT_TODAY = "истекает сегодня"
|
||||||
|
|
||||||
|
# — Username
|
||||||
|
NO_USERNAME = "нет username"
|
||||||
|
USERNAME_DISPLAY = "@{username}"
|
||||||
|
|
||||||
|
# — Internal Squads
|
||||||
|
NO_SQUADS = "нет"
|
||||||
|
|
||||||
|
# — HWID лимит
|
||||||
|
UNLIMITED_HWID = "без ограничений"
|
||||||
|
|
||||||
|
# — Трафик
|
||||||
|
NO_TRAFFIC_LIMIT = "без ограничений"
|
||||||
|
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
|
||||||
|
TRAFFIC_NO_LIMIT = "{used}"
|
||||||
|
|
||||||
|
# — Прочее
|
||||||
|
NOTHING_PLACEHOLDER = "🍃"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ПОЛЬЗОВАТЕЛЬСКИЙ ИНТЕРФЕЙС
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
MAIN_MENU = (
|
MAIN_MENU = (
|
||||||
str(MALENIA_LOGO)
|
str(MALENIA_LOGO)
|
||||||
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
|
||||||
+ f"<i>{MALENIA_COINS} Личный кабинет</i>"
|
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
|
||||||
|
+ f"<b>{MALENIA_LINK} Ваша ссылка на подписку: {{link}}</b>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
FAQ_TEXT = "faq"
|
||||||
|
|
||||||
|
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
|
||||||
|
|
||||||
STANDARD_FALLBACK = (
|
STANDARD_FALLBACK = (
|
||||||
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
|
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
|
||||||
)
|
)
|
||||||
|
|
||||||
TEMP_SUPPORT_FORM = (
|
|
||||||
"обратитесь в поддержку, прикрепив текст ниже:\n\n" "<code>\n" "{code}\n" "</code>"
|
|
||||||
)
|
|
||||||
CHECKOUT_PAYMENT_CHOICE = (
|
|
||||||
"в поддержку чирканите епта там всё обьяснят\n" + TEMP_SUPPORT_FORM
|
|
||||||
)
|
|
||||||
|
|
||||||
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
|
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
|
||||||
CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами."
|
|
||||||
|
|
||||||
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
|
CONTEXT_REINITIATED = (
|
||||||
|
"🔴 Контекст данного взаимодействия сброшен. "
|
||||||
|
"Данные могут отличаться от указанных вами."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ОФОРМЛЕНИЕ ПОДПИСКИ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
SUBSCRIPTION_DEVICE_SELECTOR = (
|
SUBSCRIPTION_DEVICE_SELECTOR = (
|
||||||
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
|
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
|
||||||
)
|
)
|
||||||
WHITELISTS_ALREADY_ON = "уже включены!"
|
|
||||||
SUBSCRIPTION_DURATION_SELECTOR = (
|
SUBSCRIPTION_DURATION_SELECTOR = (
|
||||||
"тут короче сколько время. а ещё цена смотри ОПА {price}"
|
"тут короче сколько время. а ещё цена смотри ОПА {price}"
|
||||||
)
|
)
|
||||||
|
|
||||||
SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>"
|
WHITELISTS_ALREADY_ON = "уже включены!"
|
||||||
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
|
|
||||||
|
TEMP_SUPPORT_FORM = "обратитесь в поддержку, прикрепив текст ниже:\n\n"
|
||||||
|
|
||||||
|
CHECKOUT_PAYMENT_CHOICE = (
|
||||||
|
"в поддержку чирканите епта там всё обьяснят\n"
|
||||||
|
+ TEMP_SUPPORT_FORM
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ПОДДЕРЖКА
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SUPPORT_INIT = (
|
||||||
|
f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n"
|
||||||
|
"— всё сюда, желательно со скриншотами. "
|
||||||
|
"Чем детальнее описание, тем быстрее ответ.\n\n"
|
||||||
|
"<i>❌ Для отмены — /cancel</i>"
|
||||||
|
)
|
||||||
|
|
||||||
|
SUPPORT_MSG_SENT = (
|
||||||
|
"⏳ Сообщение отправлено. Для выхода — /cancel, "
|
||||||
|
"вы можете вернуться сюда из главного меню в любое время."
|
||||||
|
)
|
||||||
|
|
||||||
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
|
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
|
||||||
ADMIN_TICKET_WITH_RW = (
|
|
||||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# Используется в SUPPORT_SUBSCRIPTION и ADMIN_TICKET_WITH_RW
|
||||||
|
_SUPPORT_USER_DESCRIPTION = (
|
||||||
"👤 <b>Имя:</b> {full_name}\n"
|
"👤 <b>Имя:</b> {full_name}\n"
|
||||||
"🔗 <b>Username:</b> {username_display}\n"
|
"🔗 <b>Username:</b> {username_display}\n"
|
||||||
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\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>"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SUPPORT_SUBSCRIPTION = (
|
||||||
|
"<b>🎫 ЗАЯВКА НА ПОКУПКУ:</b>\n\n"
|
||||||
|
+ _SUPPORT_USER_DESCRIPTION
|
||||||
|
+ "\n" + _DIVIDER
|
||||||
|
+ "🗓️ Подписка на <b>{duration}</b> месяцев.\n"
|
||||||
|
+ "📱 Количество устройств: <b>{devices}</b>\n"
|
||||||
|
+ "🏳️ Белые списки: <b>{whitelist_description}</b>\n\n"
|
||||||
|
+ "💰 Итого: <b>{price}₽</b>"
|
||||||
|
)
|
||||||
|
|
||||||
|
ADMIN_TICKET_WITH_RW = (
|
||||||
|
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
||||||
|
+ _SUPPORT_USER_DESCRIPTION
|
||||||
|
+ "\n" + _DIVIDER
|
||||||
|
+ "📋 <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"
|
||||||
|
+ _DIVIDER
|
||||||
|
+ "🔑 <b>UUID в Remnawave:</b> <code>{remnawave_uuid}</code>"
|
||||||
|
)
|
||||||
|
|
||||||
ADMIN_TICKET_WITHOUT_RW = (
|
ADMIN_TICKET_WITHOUT_RW = (
|
||||||
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
|
||||||
"👤 <b>Имя:</b> {full_name}\n"
|
"👤 <b>Имя:</b> {full_name}\n"
|
||||||
"🔗 <b>Username:</b> {username_display}\n"
|
"🔗 <b>Username:</b> {username_display}\n"
|
||||||
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
|
"🆔 <b>Telegram ID:</b> <code>{telegram_id}</code>\n\n"
|
||||||
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
|
+ _DIVIDER
|
||||||
"⚠️ <b>Аккаунт в Remnawave не найден</b>"
|
+ "⚠️ <b>Аккаунт в Remnawave не найден</b>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
ADMIN_STANDARD_FALLBACK = (
|
ADMIN_STANDARD_FALLBACK = (
|
||||||
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
|
"<b>❌ Что-то пошло не так</b>, обратитесь к логам для подробностей."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
## FUNCTIONS
|
# ============================================================
|
||||||
def build_main_menu_text() -> str:
|
# ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ
|
||||||
return MAIN_MENU.format(quote=fetch_quote())
|
# ============================================================
|
||||||
|
|
||||||
|
def build_main_menu_text(link: str) -> str:
|
||||||
NO_USERNAME = "нет юзернейма"
|
return MAIN_MENU.format(quote=fetch_quote(), link=link)
|
||||||
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}"
|
|
||||||
|
|
||||||
|
|
||||||
NOTHING_PLACEHOLDER = "🍃"
|
|
||||||
Reference in New Issue
Block a user