mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-19 19:32:10 +00:00
Fix ticket system: add noop handlers, pagination, validation, and admin back button routing
This commit is contained in:
@@ -8,11 +8,12 @@ ADMIN_IDS=
|
||||
# Ссылка на поддержку: Telegram username (например, @support) или полный URL
|
||||
SUPPORT_USERNAME=@support
|
||||
|
||||
|
||||
# Уведомления администраторов
|
||||
ADMIN_NOTIFICATIONS_ENABLED=true
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID=-1001234567890 # Замени на ID твоего канала (-100) - ПРЕФИКС ЗАКРЫТОГО КАНАЛА! ВСТАВИТЬ СВОЙ ID СРАЗУ ПОСЛЕ (-100) БЕЗ ПРОБЕЛОВ!
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID=123 # Опционально: ID топика
|
||||
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID=126 # Опционально: ID топика для тикетов
|
||||
# Обязательная подписка на канал
|
||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||
@@ -329,4 +330,4 @@ LOG_FILE=logs/bot.log
|
||||
# ===== РАЗРАБОТКА =====
|
||||
DEBUG=false
|
||||
WEBHOOK_URL=
|
||||
WEBHOOK_PATH=/webhook
|
||||
WEBHOOK_PATH=/webhook
|
||||
60
README.md
60
README.md
@@ -13,7 +13,7 @@
|
||||
[](LICENSE)
|
||||
[](https://github.com/Fr1ngg/remnawave-bedolaga-telegram-bot/stargazers)
|
||||
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
[🚀 Быстрый старт](#-быстрый-старт) • [📖 Функционал](#-функционал) • [🐳 Docker](#-docker-развертывание) • [💻 Локальная разработка](#-локальная-разработка) • [💬 Поддержка](#-поддержка-и-сообщество)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -689,6 +689,64 @@ WEBHOOK_PATH=/webhook
|
||||
---
|
||||
|
||||
|
||||
## 💻 Локальная разработка
|
||||
|
||||
### 🚀 Быстрый запуск для разработки
|
||||
|
||||
Для локальной разработки используйте специальный Docker Compose файл:
|
||||
|
||||
```bash
|
||||
# 1. Клонируйте репозиторий
|
||||
git clone https://github.com/fr1ngg/remnawave-bedolaga-telegram-bot.git
|
||||
cd remnawave-bedolaga-telegram-bot
|
||||
|
||||
# 2. Настройте окружение
|
||||
cp env.example .env
|
||||
# Отредактируйте .env файл с вашими настройками
|
||||
|
||||
# 3. Запустите локально (Windows PowerShell)
|
||||
.\start-local.ps1
|
||||
|
||||
# Или вручную
|
||||
docker-compose -f docker-compose.local.yml up --build
|
||||
```
|
||||
|
||||
### 📁 Файлы для локальной разработки
|
||||
|
||||
- `docker-compose.local.yml` - Docker Compose для локальной разработки
|
||||
- `env.example` - Пример конфигурации
|
||||
- `start-local.ps1` - Скрипт быстрого запуска (Windows)
|
||||
- `start-local.sh` - Скрипт быстрого запуска (Linux/macOS)
|
||||
- `stop-local.ps1` - Скрипт остановки (Windows)
|
||||
- `README_LOCAL.md` - Подробная документация по локальной разработке
|
||||
|
||||
### 🔧 Особенности локальной разработки
|
||||
|
||||
- **Локальная сборка** - образ собирается из исходного кода
|
||||
- **Горячая перезагрузка** - изменения в коде автоматически применяются
|
||||
- **Доступ к БД** - PostgreSQL доступен на localhost:5432
|
||||
- **Доступ к Redis** - Redis доступен на localhost:6379
|
||||
- **Логи в реальном времени** - все логи выводятся в консоль
|
||||
- **Отладка** - полный доступ к контейнерам для отладки
|
||||
|
||||
### 📊 Мониторинг разработки
|
||||
|
||||
```bash
|
||||
# Просмотр логов
|
||||
docker-compose -f docker-compose.local.yml logs -f bot
|
||||
|
||||
# Проверка статуса
|
||||
docker-compose -f docker-compose.local.yml ps
|
||||
|
||||
# Health check
|
||||
curl http://localhost:8081/health
|
||||
|
||||
# Остановка
|
||||
docker-compose -f docker-compose.local.yml down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker развертывание
|
||||
|
||||
### 📄 docker-compose.yml
|
||||
|
||||
@@ -16,7 +16,7 @@ from app.utils.cache import cache
|
||||
|
||||
from app.handlers import (
|
||||
start, menu, subscription, balance, promocode,
|
||||
referral, support, server_status, common
|
||||
referral, support, server_status, common, tickets
|
||||
)
|
||||
from app.handlers.admin import (
|
||||
main as admin_main,
|
||||
@@ -37,6 +37,7 @@ from app.handlers.admin import (
|
||||
updates as admin_updates,
|
||||
backup as admin_backup,
|
||||
welcome_text as admin_welcome_text,
|
||||
tickets as admin_tickets,
|
||||
)
|
||||
from app.handlers.stars_payments import register_stars_handlers
|
||||
|
||||
@@ -118,6 +119,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
referral.register_handlers(dp)
|
||||
support.register_handlers(dp)
|
||||
server_status.register_handlers(dp)
|
||||
tickets.register_handlers(dp)
|
||||
admin_main.register_handlers(dp)
|
||||
admin_users.register_handlers(dp)
|
||||
admin_subscriptions.register_handlers(dp)
|
||||
@@ -136,6 +138,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
||||
admin_updates.register_handlers(dp)
|
||||
admin_backup.register_handlers(dp)
|
||||
admin_welcome_text.register_welcome_text_handlers(dp)
|
||||
admin_tickets.register_handlers(dp)
|
||||
common.register_handlers(dp)
|
||||
register_stars_handlers(dp)
|
||||
logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей")
|
||||
|
||||
@@ -13,10 +13,14 @@ class Settings(BaseSettings):
|
||||
BOT_TOKEN: str
|
||||
ADMIN_IDS: str = ""
|
||||
SUPPORT_USERNAME: str = "@support"
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
|
||||
SUPPORT_MENU_ENABLED: bool = True
|
||||
|
||||
ADMIN_NOTIFICATIONS_ENABLED: bool = False
|
||||
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None
|
||||
ADMIN_NOTIFICATIONS_TOPIC_ID: Optional[int] = None
|
||||
ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID: Optional[int] = None
|
||||
|
||||
CHANNEL_SUB_ID: Optional[str] = None
|
||||
CHANNEL_LINK: Optional[str] = None
|
||||
@@ -827,7 +831,16 @@ class Settings(BaseSettings):
|
||||
|
||||
def get_server_status_request_timeout(self) -> int:
|
||||
return max(1, self.SERVER_STATUS_REQUEST_TIMEOUT)
|
||||
|
||||
|
||||
def get_support_system_mode(self) -> str:
|
||||
mode = (self.SUPPORT_SYSTEM_MODE or "both").strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
def is_support_tickets_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"tickets", "both"}
|
||||
|
||||
def is_support_contact_enabled(self) -> bool:
|
||||
return self.get_support_system_mode() in {"contact", "both"}
|
||||
|
||||
enabled_packages = [pkg for pkg in packages if pkg["enabled"]]
|
||||
if not enabled_packages:
|
||||
|
||||
334
app/database/crud/ticket.py
Normal file
334
app/database/crud/ticket.py
Normal file
@@ -0,0 +1,334 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_, or_, update, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
from datetime import datetime
|
||||
|
||||
from app.database.models import Ticket, TicketMessage, TicketStatus, User
|
||||
|
||||
|
||||
class TicketCRUD:
|
||||
"""CRUD операции для работы с тикетами"""
|
||||
|
||||
@staticmethod
|
||||
async def create_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
title: str,
|
||||
message_text: str,
|
||||
priority: str = "normal",
|
||||
*,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None,
|
||||
) -> Ticket:
|
||||
"""Создать новый тикет с первым сообщением"""
|
||||
ticket = Ticket(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
status=TicketStatus.OPEN.value,
|
||||
priority=priority
|
||||
)
|
||||
db.add(ticket)
|
||||
await db.flush() # Получаем ID тикета
|
||||
|
||||
# Создаем первое сообщение
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket.id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=False,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
db.add(message)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(ticket)
|
||||
return ticket
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_by_id(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
load_messages: bool = True,
|
||||
load_user: bool = False
|
||||
) -> Optional[Ticket]:
|
||||
"""Получить тикет по ID"""
|
||||
query = select(Ticket).where(Ticket.id == ticket_id)
|
||||
|
||||
if load_user:
|
||||
query = query.options(selectinload(Ticket.user))
|
||||
|
||||
if load_messages:
|
||||
query = query.options(selectinload(Ticket.messages))
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
async def get_user_tickets(
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить тикеты пользователя"""
|
||||
query = select(Ticket).where(Ticket.user_id == user_id)
|
||||
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def user_has_active_ticket(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> bool:
|
||||
"""Проверить, есть ли у пользователя активный (не закрытый) тикет"""
|
||||
query = (
|
||||
select(Ticket.id)
|
||||
.where(
|
||||
Ticket.user_id == user_id,
|
||||
Ticket.status.in_([TicketStatus.OPEN.value, TicketStatus.ANSWERED.value])
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
@staticmethod
|
||||
async def is_user_globally_blocked(
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Optional[datetime]:
|
||||
"""Проверить, заблокирован ли пользователь для создания/ответов по любому тикету.
|
||||
Возвращает дату окончания блокировки, если активна, или None.
|
||||
"""
|
||||
query = select(Ticket).where(
|
||||
Ticket.user_id == user_id,
|
||||
or_(Ticket.user_reply_block_permanent == True, Ticket.user_reply_block_until.isnot(None))
|
||||
).order_by(desc(Ticket.updated_at)).limit(10)
|
||||
result = await db.execute(query)
|
||||
tickets = result.scalars().all()
|
||||
if not tickets:
|
||||
return None
|
||||
from datetime import datetime
|
||||
# Если есть вечная блокировка в любом тикете — блок активен без срока
|
||||
for t in tickets:
|
||||
if t.user_reply_block_permanent:
|
||||
return datetime.max
|
||||
# Иначе ищем максимальный срок блокировки, если он в будущем
|
||||
future_until = [t.user_reply_block_until for t in tickets if t.user_reply_block_until]
|
||||
if not future_until:
|
||||
return None
|
||||
max_until = max(future_until)
|
||||
return max_until if max_until > datetime.utcnow() else None
|
||||
|
||||
@staticmethod
|
||||
async def get_all_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None,
|
||||
priority: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
"""Получить все тикеты (для админов)"""
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
|
||||
conditions = []
|
||||
if status:
|
||||
conditions.append(Ticket.status == status)
|
||||
if priority:
|
||||
conditions.append(Ticket.priority == priority)
|
||||
|
||||
if conditions:
|
||||
query = query.where(and_(*conditions))
|
||||
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str],
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[Ticket]:
|
||||
query = select(Ticket).options(selectinload(Ticket.user))
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
query = query.order_by(desc(Ticket.updated_at)).offset(offset).limit(limit)
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets(
|
||||
db: AsyncSession,
|
||||
status: Optional[str] = None
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if status:
|
||||
query = query.where(Ticket.status == status)
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def count_tickets_by_statuses(
|
||||
db: AsyncSession,
|
||||
statuses: List[str]
|
||||
) -> int:
|
||||
query = select(func.count()).select_from(Ticket)
|
||||
if statuses:
|
||||
query = query.where(Ticket.status.in_(statuses))
|
||||
result = await db.execute(query)
|
||||
return int(result.scalar() or 0)
|
||||
|
||||
@staticmethod
|
||||
async def update_ticket_status(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
status: str,
|
||||
closed_at: Optional[datetime] = None
|
||||
) -> bool:
|
||||
"""Обновить статус тикета"""
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
|
||||
ticket.status = status
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
if status == TicketStatus.CLOSED.value and closed_at:
|
||||
ticket.closed_at = closed_at
|
||||
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def set_user_reply_block(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
permanent: bool,
|
||||
until: Optional[datetime]
|
||||
) -> bool:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
return False
|
||||
ticket.user_reply_block_permanent = bool(permanent)
|
||||
ticket.user_reply_block_until = until
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def close_ticket(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> bool:
|
||||
"""Закрыть тикет"""
|
||||
return await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_open_tickets_count(db: AsyncSession) -> int:
|
||||
"""Получить количество открытых тикетов"""
|
||||
query = select(Ticket).where(Ticket.status.in_([
|
||||
TicketStatus.OPEN.value,
|
||||
TicketStatus.ANSWERED.value
|
||||
]))
|
||||
result = await db.execute(query)
|
||||
return len(result.scalars().all())
|
||||
|
||||
|
||||
class TicketMessageCRUD:
|
||||
"""CRUD операции для работы с сообщениями тикетов"""
|
||||
|
||||
@staticmethod
|
||||
async def add_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
user_id: int,
|
||||
message_text: str,
|
||||
is_from_admin: bool = False,
|
||||
media_type: Optional[str] = None,
|
||||
media_file_id: Optional[str] = None,
|
||||
media_caption: Optional[str] = None
|
||||
) -> TicketMessage:
|
||||
"""Добавить сообщение в тикет"""
|
||||
message = TicketMessage(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
message_text=message_text,
|
||||
is_from_admin=is_from_admin,
|
||||
has_media=bool(media_type and media_file_id),
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
|
||||
# Обновляем статус тикета
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if ticket:
|
||||
# Если тикет закрыт, запрещаем изменение статуса при сообщении пользователя
|
||||
if not is_from_admin and ticket.status == TicketStatus.CLOSED.value:
|
||||
return message
|
||||
if is_from_admin:
|
||||
# Админ ответил - тикет отвечен
|
||||
ticket.status = TicketStatus.ANSWERED.value
|
||||
else:
|
||||
# Пользователь ответил - тикет открыт
|
||||
ticket.status = TicketStatus.OPEN.value
|
||||
|
||||
ticket.updated_at = datetime.utcnow()
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(message)
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
async def get_ticket_messages(
|
||||
db: AsyncSession,
|
||||
ticket_id: int,
|
||||
limit: int = 50,
|
||||
offset: int = 0
|
||||
) -> List[TicketMessage]:
|
||||
"""Получить сообщения тикета"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(TicketMessage.created_at)
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalars().all()
|
||||
|
||||
@staticmethod
|
||||
async def get_last_message(
|
||||
db: AsyncSession,
|
||||
ticket_id: int
|
||||
) -> Optional[TicketMessage]:
|
||||
"""Получить последнее сообщение в тикете"""
|
||||
query = (
|
||||
select(TicketMessage)
|
||||
.where(TicketMessage.ticket_id == ticket_id)
|
||||
.order_by(desc(TicketMessage.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
result = await db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -785,3 +785,117 @@ class AdvertisingCampaignRegistration(Base):
|
||||
@property
|
||||
def balance_bonus_rubles(self) -> float:
|
||||
return (self.balance_bonus_kopeks or 0) / 100
|
||||
|
||||
|
||||
class TicketStatus(Enum):
|
||||
OPEN = "open"
|
||||
ANSWERED = "answered"
|
||||
CLOSED = "closed"
|
||||
PENDING = "pending"
|
||||
|
||||
|
||||
class Ticket(Base):
|
||||
__tablename__ = "tickets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
status = Column(String(20), default=TicketStatus.OPEN.value, nullable=False)
|
||||
priority = Column(String(20), default="normal", nullable=False) # low, normal, high, urgent
|
||||
# Блокировка ответов пользователя в этом тикете
|
||||
user_reply_block_permanent = Column(Boolean, default=False, nullable=False)
|
||||
user_reply_block_until = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Связи
|
||||
user = relationship("User", backref="tickets")
|
||||
messages = relationship("TicketMessage", back_populates="ticket", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def is_open(self) -> bool:
|
||||
return self.status == TicketStatus.OPEN.value
|
||||
|
||||
@property
|
||||
def is_answered(self) -> bool:
|
||||
return self.status == TicketStatus.ANSWERED.value
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self.status == TicketStatus.CLOSED.value
|
||||
|
||||
@property
|
||||
def is_pending(self) -> bool:
|
||||
return self.status == TicketStatus.PENDING.value
|
||||
|
||||
@property
|
||||
def is_user_reply_blocked(self) -> bool:
|
||||
if self.user_reply_block_permanent:
|
||||
return True
|
||||
if self.user_reply_block_until:
|
||||
try:
|
||||
from datetime import datetime
|
||||
return self.user_reply_block_until > datetime.utcnow()
|
||||
except Exception:
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def status_emoji(self) -> str:
|
||||
status_emojis = {
|
||||
TicketStatus.OPEN.value: "🔴",
|
||||
TicketStatus.ANSWERED.value: "🟡",
|
||||
TicketStatus.CLOSED.value: "🟢",
|
||||
TicketStatus.PENDING.value: "⏳"
|
||||
}
|
||||
return status_emojis.get(self.status, "❓")
|
||||
|
||||
@property
|
||||
def priority_emoji(self) -> str:
|
||||
priority_emojis = {
|
||||
"low": "🟢",
|
||||
"normal": "🟡",
|
||||
"high": "🟠",
|
||||
"urgent": "🔴"
|
||||
}
|
||||
return priority_emojis.get(self.priority, "🟡")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Ticket(id={self.id}, user_id={self.user_id}, status={self.status}, title='{self.title[:30]}...')>"
|
||||
|
||||
|
||||
class TicketMessage(Base):
|
||||
__tablename__ = "ticket_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
message_text = Column(Text, nullable=False)
|
||||
is_from_admin = Column(Boolean, default=False, nullable=False)
|
||||
|
||||
# Для медиа файлов
|
||||
has_media = Column(Boolean, default=False)
|
||||
media_type = Column(String(20), nullable=True) # photo, video, document, voice, etc.
|
||||
media_file_id = Column(String(255), nullable=True)
|
||||
media_caption = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=func.now())
|
||||
|
||||
# Связи
|
||||
ticket = relationship("Ticket", back_populates="messages")
|
||||
user = relationship("User")
|
||||
|
||||
@property
|
||||
def is_user_message(self) -> bool:
|
||||
return not self.is_from_admin
|
||||
|
||||
@property
|
||||
def is_admin_message(self) -> bool:
|
||||
return self.is_from_admin
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TicketMessage(id={self.id}, ticket_id={self.ticket_id}, is_admin={self.is_from_admin}, text='{self.message_text[:30]}...')>"
|
||||
@@ -772,6 +772,49 @@ async def add_media_fields_to_broadcast_history():
|
||||
logger.error(f"Ошибка при добавлении полей медиа в broadcast_history: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def add_ticket_reply_block_columns():
|
||||
try:
|
||||
col_perm_exists = await check_column_exists('tickets', 'user_reply_block_permanent')
|
||||
col_until_exists = await check_column_exists('tickets', 'user_reply_block_until')
|
||||
|
||||
if col_perm_exists and col_until_exists:
|
||||
return True
|
||||
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if not col_perm_exists:
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT 0 NOT NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_permanent BOOLEAN DEFAULT FALSE NOT NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_permanent: {db_type}")
|
||||
return False
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка tickets.user_reply_block_permanent")
|
||||
|
||||
if not col_until_exists:
|
||||
if db_type == 'sqlite':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL"
|
||||
elif db_type == 'postgresql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until TIMESTAMP NULL"
|
||||
elif db_type == 'mysql':
|
||||
alter_sql = "ALTER TABLE tickets ADD COLUMN user_reply_block_until DATETIME NULL"
|
||||
else:
|
||||
logger.error(f"Неподдерживаемый тип БД для добавления user_reply_block_until: {db_type}")
|
||||
return False
|
||||
await conn.execute(text(alter_sql))
|
||||
logger.info("✅ Добавлена колонка tickets.user_reply_block_until")
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}")
|
||||
return False
|
||||
|
||||
async def fix_foreign_keys_for_user_deletion():
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
@@ -1057,6 +1100,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением медиа полей")
|
||||
|
||||
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ БЛОКИРОВКИ В TICKETS ===")
|
||||
tickets_block_cols_added = await add_ticket_reply_block_columns()
|
||||
if tickets_block_cols_added:
|
||||
logger.info("✅ Поля блокировок в tickets готовы")
|
||||
else:
|
||||
logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets")
|
||||
|
||||
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
|
||||
promo_groups_ready = await ensure_promo_groups_setup()
|
||||
if promo_groups_ready:
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.keyboards.admin import (
|
||||
get_admin_system_submenu_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.handlers.admin import support_settings as support_settings_handlers
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.database.crud.rules import clear_all_rules, get_rules_statistics
|
||||
from app.localization.texts import clear_rules_cache
|
||||
@@ -293,6 +294,8 @@ def register_handlers(dp: Dispatcher):
|
||||
show_system_submenu,
|
||||
F.data == "admin_submenu_system"
|
||||
)
|
||||
# Support settings module
|
||||
support_settings_handlers.register_handlers(dp)
|
||||
|
||||
dp.message.register(
|
||||
clear_rules_command,
|
||||
|
||||
201
app/handlers/admin/support_settings.py
Normal file
201
app/handlers/admin/support_settings.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import logging
|
||||
import re
|
||||
import html
|
||||
import contextlib
|
||||
from aiogram import Dispatcher, types, F
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database.models import User
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.decorators import admin_required, error_handler
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.states import SupportSettingsStates
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
mode = SupportSettingsService.get_system_mode()
|
||||
menu_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
|
||||
rows: list[list[types.InlineKeyboardButton]] = []
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(
|
||||
text=("✅ Пункт 'Техподдержка' в меню" if menu_enabled else "🚫 Пункт 'Техподдержка' в меню"),
|
||||
callback_data="admin_support_toggle_menu"
|
||||
)
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=("🔘 Тикеты" if mode == "tickets" else "⚪ Тикеты"), callback_data="admin_support_mode_tickets"),
|
||||
types.InlineKeyboardButton(text=("🔘 Контакт" if mode == "contact" else "⚪ Контакт"), callback_data="admin_support_mode_contact"),
|
||||
types.InlineKeyboardButton(text=("🔘 Оба" if mode == "both" else "⚪ Оба"), callback_data="admin_support_mode_both"),
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
|
||||
])
|
||||
|
||||
rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
])
|
||||
|
||||
return types.InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def show_support_settings(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
desc = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
"🛟 <b>Настройки поддержки</b>\n\n" +
|
||||
"Режим работы и видимость в меню. Ниже текущее описание меню поддержки:\n\n" +
|
||||
desc,
|
||||
reply_markup=_get_support_settings_keyboard(db_user.language),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def toggle_support_menu(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
current = SupportSettingsService.is_support_menu_enabled()
|
||||
SupportSettingsService.set_support_menu_enabled(not current)
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("tickets")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_contact(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("contact")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def set_mode_both(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
SupportSettingsService.set_system_mode("both")
|
||||
await show_support_settings(callback, db_user, db)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def start_edit_desc(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
texts = get_texts(db_user.language)
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
# plain text for display-only code block
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
|
||||
kb_rows: list[list[types.InlineKeyboardButton]] = []
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text="📨 Прислать текст", callback_data="admin_support_send_desc")
|
||||
])
|
||||
# Подготовим блок контакта (отдельным инлайном)
|
||||
from app.config import settings
|
||||
support_contact_display = settings.get_support_contact_display()
|
||||
kb_rows.append([
|
||||
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_support_settings")
|
||||
])
|
||||
|
||||
text_parts = [
|
||||
"📝 <b>Редактирование описания поддержки</b>",
|
||||
"",
|
||||
"Текущее описание:",
|
||||
"",
|
||||
f"<code>{html.escape(current_desc_plain)}</code>",
|
||||
]
|
||||
if support_contact_display:
|
||||
text_parts += [
|
||||
"",
|
||||
"<b>Контакт для режима \u00abКонтакт\u00bb</b>",
|
||||
f"<code>{html.escape(support_contact_display)}</code>",
|
||||
"",
|
||||
"Добавьте в описание при необходимости.",
|
||||
]
|
||||
await callback.message.edit_text(
|
||||
"\n".join(text_parts),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=kb_rows),
|
||||
parse_mode="HTML"
|
||||
)
|
||||
await state.set_state(SupportSettingsStates.waiting_for_desc)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def handle_new_desc(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
new_text = message.html_text or message.text
|
||||
SupportSettingsService.set_support_info_text(db_user.language, new_text)
|
||||
await state.clear()
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
await message.answer("✅ Описание обновлено.", reply_markup=markup)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
# send plain text for easy copying
|
||||
current_desc_html = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
current_desc_plain = re.sub(r"<[^>]+>", "", current_desc_html)
|
||||
# attach delete button to the sent message
|
||||
markup = types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
|
||||
)
|
||||
if len(current_desc_plain) <= 4000:
|
||||
await callback.message.answer(current_desc_plain, reply_markup=markup)
|
||||
else:
|
||||
# split long messages (attach delete only to the last chunk)
|
||||
chunk = 0
|
||||
while chunk < len(current_desc_plain):
|
||||
next_chunk = current_desc_plain[chunk:chunk+4000]
|
||||
is_last = (chunk + 4000) >= len(current_desc_plain)
|
||||
await callback.message.answer(next_chunk, reply_markup=(markup if is_last else None))
|
||||
chunk += 4000
|
||||
await callback.answer("Текст отправлен ниже")
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
|
||||
try:
|
||||
await callback.message.delete()
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await callback.answer("Сообщение удалено")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(show_support_settings, F.data == "admin_support_settings")
|
||||
dp.callback_query.register(toggle_support_menu, F.data == "admin_support_toggle_menu")
|
||||
dp.callback_query.register(set_mode_tickets, F.data == "admin_support_mode_tickets")
|
||||
dp.callback_query.register(set_mode_contact, F.data == "admin_support_mode_contact")
|
||||
dp.callback_query.register(set_mode_both, F.data == "admin_support_mode_both")
|
||||
dp.callback_query.register(start_edit_desc, F.data == "admin_support_edit_desc")
|
||||
dp.callback_query.register(send_desc_copy, F.data == "admin_support_send_desc")
|
||||
dp.callback_query.register(delete_sent_message, F.data == "admin_support_delete_msg")
|
||||
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
|
||||
|
||||
|
||||
694
app/handlers/admin/tickets.py
Normal file
694
app/handlers/admin/tickets.py
Normal file
@@ -0,0 +1,694 @@
|
||||
import logging
|
||||
from typing import List, Dict, Any
|
||||
from aiogram import Dispatcher, types, F, Bot
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc, and_
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
from app.database.models import User, Ticket, TicketStatus
|
||||
from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD
|
||||
from app.states import TicketStates, AdminTicketStates
|
||||
from app.keyboards.inline import (
|
||||
get_admin_tickets_keyboard,
|
||||
get_admin_ticket_view_keyboard,
|
||||
get_admin_ticket_reply_cancel_keyboard
|
||||
)
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.pagination import paginate_list, get_pagination_info
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.config import settings
|
||||
from app.utils.cache import RateLimitCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def show_admin_tickets(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Показать все тикеты для админов"""
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Определяем текущую страницу и scope
|
||||
current_page = 1
|
||||
scope = "open"
|
||||
data_str = callback.data
|
||||
if data_str == "admin_tickets_scope_open":
|
||||
scope = "open"
|
||||
elif data_str == "admin_tickets_scope_closed":
|
||||
scope = "closed"
|
||||
elif data_str.startswith("admin_tickets_page_"):
|
||||
try:
|
||||
parts = data_str.split("_")
|
||||
# format: admin_tickets_page_{scope}_{page}
|
||||
if len(parts) >= 5:
|
||||
scope = parts[3]
|
||||
current_page = int(parts[4])
|
||||
else:
|
||||
current_page = int(data_str.replace("admin_tickets_page_", ""))
|
||||
except ValueError:
|
||||
current_page = 1
|
||||
statuses = [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value] if scope == "open" else [TicketStatus.CLOSED.value]
|
||||
page_size = 10
|
||||
# total count for proper pagination
|
||||
total_count = await TicketCRUD.count_tickets_by_statuses(db, statuses)
|
||||
total_pages = max(1, (total_count + page_size - 1) // page_size) if total_count > 0 else 1
|
||||
if current_page > total_pages:
|
||||
current_page = total_pages
|
||||
offset = (current_page - 1) * page_size
|
||||
tickets = await TicketCRUD.get_tickets_by_statuses(db, statuses=statuses, limit=page_size, offset=offset)
|
||||
|
||||
# Даже если тикетов нет, показываем переключатели разделов
|
||||
|
||||
# Формируем данные для клавиатуры
|
||||
ticket_data = []
|
||||
for ticket in tickets:
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
ticket_data.append({
|
||||
'id': ticket.id,
|
||||
'title': ticket.title,
|
||||
'status_emoji': ticket.status_emoji,
|
||||
'priority_emoji': ticket.priority_emoji,
|
||||
'user_name': user_name,
|
||||
'is_closed': ticket.is_closed,
|
||||
'locked_emoji': ("🔒" if ticket.is_user_reply_blocked else "")
|
||||
})
|
||||
|
||||
# Итоговые страницы уже посчитаны выше
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_TICKETS_TITLE", "🎫 Все тикеты поддержки:"),
|
||||
reply_markup=get_admin_tickets_keyboard(ticket_data, current_page=current_page, total_pages=total_pages, language=db_user.language, scope=scope)
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def view_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession,
|
||||
state: FSMContext
|
||||
):
|
||||
"""Показать детали тикета для админа"""
|
||||
ticket_id = int(callback.data.replace("admin_view_ticket_", ""))
|
||||
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
|
||||
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_NOT_FOUND", "Тикет не найден."),
|
||||
show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
# Формируем текст тикета
|
||||
status_text = {
|
||||
TicketStatus.OPEN.value: texts.t("TICKET_STATUS_OPEN", "Открыт"),
|
||||
TicketStatus.ANSWERED.value: texts.t("TICKET_STATUS_ANSWERED", "Отвечен"),
|
||||
TicketStatus.CLOSED.value: texts.t("TICKET_STATUS_CLOSED", "Закрыт"),
|
||||
TicketStatus.PENDING.value: texts.t("TICKET_STATUS_PENDING", "В ожидании")
|
||||
}.get(ticket.status, ticket.status)
|
||||
|
||||
user_name = ticket.user.full_name if ticket.user else "Unknown"
|
||||
|
||||
ticket_text = f"🎫 Тикет #{ticket.id}\n\n"
|
||||
ticket_text += f"👤 Пользователь: {user_name}\n"
|
||||
ticket_text += f"📝 Заголовок: {ticket.title}\n"
|
||||
ticket_text += f"📊 Статус: {ticket.status_emoji} {status_text}\n"
|
||||
ticket_text += f"📅 Создан: {ticket.created_at.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
ticket_text += f"🔄 Обновлен: {ticket.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
|
||||
|
||||
if ticket.is_user_reply_blocked:
|
||||
if ticket.user_reply_block_permanent:
|
||||
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
|
||||
elif ticket.user_reply_block_until:
|
||||
ticket_text += f"⏳ Блок до: {ticket.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
|
||||
|
||||
if ticket.messages:
|
||||
ticket_text += f"💬 Сообщения ({len(ticket.messages)}):\n\n"
|
||||
|
||||
for msg in ticket.messages:
|
||||
sender = "👤 Пользователь" if msg.is_user_message else "🛠️ Поддержка"
|
||||
ticket_text += f"{sender} ({msg.created_at.strftime('%d.%m %H:%M')}):\n"
|
||||
ticket_text += f"{msg.message_text}\n\n"
|
||||
if getattr(msg, "has_media", False) and getattr(msg, "media_type", None) == "photo":
|
||||
ticket_text += "📎 Вложение: фото\n\n"
|
||||
|
||||
# Добавим кнопку "Вложения", если есть фото
|
||||
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in ticket.messages or [])
|
||||
keyboard = get_admin_ticket_view_keyboard(
|
||||
ticket_id,
|
||||
ticket.is_closed,
|
||||
db_user.language
|
||||
)
|
||||
if has_photos:
|
||||
try:
|
||||
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{ticket_id}")])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Сначала пробуем отредактировать; если не вышло — удалим и отправим новое
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except Exception:
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.message.answer(
|
||||
ticket_text,
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
# сохраняем id для дальнейших действий (ответ/статусы)
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def reply_to_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
"""Начать ответ на тикет от админа"""
|
||||
ticket_id = int(callback.data.replace("admin_reply_ticket_", ""))
|
||||
|
||||
await state.update_data(ticket_id=ticket_id, reply_mode=True)
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ADMIN_TICKET_REPLY_INPUT", "Введите ответ от поддержки:"),
|
||||
reply_markup=get_admin_ticket_reply_cancel_keyboard(db_user.language)
|
||||
)
|
||||
|
||||
await state.set_state(AdminTicketStates.waiting_for_reply)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_admin_ticket_reply(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем, что пользователь в правильном состоянии
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_reply:
|
||||
return
|
||||
|
||||
# Анти-спам: одно сообщение за короткое окно по конкретному тикету
|
||||
try:
|
||||
data_rl = await state.get_data()
|
||||
rl_ticket_id = data_rl.get("ticket_id") or "admin_reply"
|
||||
limited = await RateLimitCache.is_rate_limited(db_user.id, f"admin_ticket_reply_{rl_ticket_id}", limit=1, window=2)
|
||||
if limited:
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data_rl = await state.get_data()
|
||||
last_ts = data_rl.get("admin_rl_ts_reply")
|
||||
now_ts = time.time()
|
||||
if last_ts and (now_ts - float(last_ts)) < 2:
|
||||
return
|
||||
await state.update_data(admin_rl_ts_reply=now_ts)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
"""Обработать ответ админа на тикет"""
|
||||
# Поддержка фото вложений в ответе админа
|
||||
reply_text = (message.text or message.caption or "").strip()
|
||||
if len(reply_text) > 400:
|
||||
reply_text = reply_text[:400]
|
||||
media_type = None
|
||||
media_file_id = None
|
||||
media_caption = None
|
||||
if message.photo:
|
||||
media_type = "photo"
|
||||
media_file_id = message.photo[-1].file_id
|
||||
media_caption = message.caption
|
||||
|
||||
if len(reply_text) < 1 and not media_file_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_TOO_SHORT", "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:")
|
||||
)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
ticket_id = data.get("ticket_id")
|
||||
try:
|
||||
ticket_id = int(ticket_id) if ticket_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
ticket_id = None
|
||||
|
||||
if not ticket_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета.")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
# Если это режим ввода длительности блокировки
|
||||
if not data.get("reply_mode"):
|
||||
try:
|
||||
minutes = int(reply_text)
|
||||
minutes = max(1, min(60*24*365, minutes))
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if ok:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
await message.answer("❌ Ошибка блокировки")
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Обычный режим ответа админа
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_NOT_FOUND", "Тикет не найден.")
|
||||
)
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
# Добавляем сообщение от админа (внутри add_message статус станет ANSWERED)
|
||||
await TicketMessageCRUD.add_message(
|
||||
db,
|
||||
ticket_id,
|
||||
db_user.id,
|
||||
reply_text,
|
||||
is_from_admin=True,
|
||||
media_type=media_type,
|
||||
media_file_id=media_file_id,
|
||||
media_caption=media_caption,
|
||||
)
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await message.answer(
|
||||
texts.t("ADMIN_TICKET_REPLY_SENT", "✅ Ответ отправлен!"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"),
|
||||
callback_data=f"admin_view_ticket_{ticket_id}"
|
||||
)],
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"),
|
||||
callback_data="admin_tickets"
|
||||
)]
|
||||
])
|
||||
)
|
||||
|
||||
await state.clear()
|
||||
|
||||
# Уведомляем пользователя о новом ответе
|
||||
await notify_user_about_ticket_reply(message.bot, ticket, reply_text, db)
|
||||
# Админ-уведомления о ответе в тикет отключены по требованию
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding admin ticket reply: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(
|
||||
texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка при отправке ответа. Попробуйте позже.")
|
||||
)
|
||||
|
||||
|
||||
async def mark_ticket_as_answered(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Отметить тикет как отвеченный"""
|
||||
ticket_id = int(callback.data.replace("admin_mark_answered_", ""))
|
||||
|
||||
try:
|
||||
success = await TicketCRUD.update_ticket_status(
|
||||
db, ticket_id, TicketStatus.ANSWERED.value
|
||||
)
|
||||
|
||||
if success:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_MARKED_ANSWERED", "✅ Тикет отмечен как отвеченный."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Обновляем сообщение
|
||||
await view_admin_ticket(callback, db_user, db)
|
||||
else:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking ticket as answered: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_UPDATE_ERROR", "❌ Ошибка при обновлении тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
async def close_admin_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
"""Закрыть тикет админом"""
|
||||
ticket_id = int(callback.data.replace("admin_close_ticket_", ""))
|
||||
|
||||
try:
|
||||
success = await TicketCRUD.close_ticket(db, ticket_id)
|
||||
|
||||
if success:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
# Обновляем inline-клавиатуру в текущем сообщении без кнопок действий
|
||||
await callback.message.edit_reply_markup(
|
||||
reply_markup=get_admin_ticket_view_keyboard(ticket_id, True, db_user.language)
|
||||
)
|
||||
else:
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing admin ticket: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.answer(
|
||||
texts.t("TICKET_CLOSE_ERROR", "❌ Ошибка при закрытии тикета."),
|
||||
show_alert=True
|
||||
)
|
||||
|
||||
|
||||
async def cancel_admin_ticket_reply(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User
|
||||
):
|
||||
"""Отменить ответ админа на тикет"""
|
||||
await state.clear()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.t("TICKET_REPLY_CANCELLED", "Ответ отменен."),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("BACK_TO_TICKETS", "⬅️ К тикетам"),
|
||||
callback_data="admin_tickets"
|
||||
)]
|
||||
])
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def block_user_in_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_block_user_ticket_", ""))
|
||||
texts = get_texts(db_user.language)
|
||||
await callback.message.edit_text(
|
||||
texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"),
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
|
||||
callback_data="cancel_admin_ticket_reply"
|
||||
)]
|
||||
])
|
||||
)
|
||||
await state.update_data(ticket_id=ticket_id)
|
||||
await state.set_state(AdminTicketStates.waiting_for_block_duration)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
async def handle_admin_block_duration_input(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
# Проверяем состояние
|
||||
current_state = await state.get_state()
|
||||
if current_state != AdminTicketStates.waiting_for_block_duration:
|
||||
return
|
||||
|
||||
reply_text = message.text.strip()
|
||||
if len(reply_text) < 1:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
ticket_id = data.get("ticket_id")
|
||||
try:
|
||||
minutes = int(reply_text)
|
||||
minutes = max(1, min(60*24*365, minutes)) # максимум 1 год
|
||||
except ValueError:
|
||||
await message.answer("❌ Введите целое число минут")
|
||||
return
|
||||
|
||||
if not ticket_id:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_REPLY_ERROR", "Ошибка: не найден ID тикета."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
try:
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=False)
|
||||
if not ticket:
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."))
|
||||
await state.clear()
|
||||
return
|
||||
|
||||
until = datetime.utcnow() + timedelta(minutes=minutes)
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=until)
|
||||
if ok:
|
||||
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
|
||||
else:
|
||||
await message.answer("❌ Ошибка блокировки")
|
||||
await state.clear()
|
||||
await message.answer(
|
||||
"✅ Блокировка установлена. Откройте тикет заново для обновления состояния.",
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text="👁️ Посмотреть тикет", callback_data=f"admin_view_ticket_{ticket_id}")]])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting block duration: {e}")
|
||||
texts = get_texts(db_user.language)
|
||||
await message.answer(texts.t("TICKET_REPLY_ERROR", "❌ Произошла ошибка. Попробуйте позже."))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
async def unblock_user_in_ticket(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=False, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Блок снят")
|
||||
await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id))
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
|
||||
async def block_user_permanently(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
ticket_id = int(callback.data.replace("admin_block_user_perm_ticket_", ""))
|
||||
ok = await TicketCRUD.set_user_reply_block(db, ticket_id, permanent=True, until=None)
|
||||
if ok:
|
||||
await callback.answer("✅ Пользователь заблокирован навсегда")
|
||||
await view_admin_ticket(callback, db_user, db, FSMContext(callback.bot, callback.from_user.id))
|
||||
else:
|
||||
await callback.answer("❌ Ошибка", show_alert=True)
|
||||
|
||||
|
||||
async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession):
|
||||
"""Уведомить пользователя о новом ответе в тикете"""
|
||||
try:
|
||||
from app.localization.texts import get_texts
|
||||
|
||||
# Получаем тикет с пользователем
|
||||
ticket_with_user = await TicketCRUD.get_ticket_by_id(db, ticket.id, load_user=True)
|
||||
if not ticket_with_user or not ticket_with_user.user:
|
||||
logger.error(f"User not found for ticket #{ticket.id}")
|
||||
return
|
||||
|
||||
texts = get_texts(ticket_with_user.user.language)
|
||||
|
||||
# Формируем уведомление
|
||||
base_text = texts.t(
|
||||
"TICKET_REPLY_NOTIFICATION",
|
||||
"🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:"
|
||||
).format(
|
||||
ticket_id=ticket.id,
|
||||
reply_preview=reply_text[:100] + "..." if len(reply_text) > 100 else reply_text
|
||||
)
|
||||
# Если было фото в последнем ответе админа — отправим как фото
|
||||
last_message = await TicketMessageCRUD.get_last_message(db, ticket.id)
|
||||
if last_message and last_message.has_media and last_message.media_type == "photo" and last_message.is_from_admin:
|
||||
caption = base_text
|
||||
try:
|
||||
await bot.send_photo(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
photo=last_message.media_file_id,
|
||||
caption=caption,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить фото-уведомление: {e}")
|
||||
# Фоллбек: текстовое уведомление
|
||||
await bot.send_message(
|
||||
chat_id=ticket_with_user.user.telegram_id,
|
||||
text=base_text,
|
||||
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text=texts.t("VIEW_TICKET", "👁️ Посмотреть тикет"), callback_data=f"view_ticket_{ticket.id}")],
|
||||
[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]
|
||||
])
|
||||
)
|
||||
|
||||
logger.info(f"Ticket #{ticket.id} reply notification sent to user {ticket_with_user.user.telegram_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error notifying user about ticket reply: {e}")
|
||||
|
||||
|
||||
def register_handlers(dp: Dispatcher):
|
||||
"""Регистрация админских обработчиков тикетов"""
|
||||
|
||||
# Просмотр тикетов
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_open")
|
||||
dp.callback_query.register(show_admin_tickets, F.data == "admin_tickets_scope_closed")
|
||||
|
||||
dp.callback_query.register(view_admin_ticket, F.data.startswith("admin_view_ticket_"))
|
||||
|
||||
# Ответы на тикеты
|
||||
dp.callback_query.register(
|
||||
reply_to_admin_ticket,
|
||||
F.data.startswith("admin_reply_ticket_")
|
||||
)
|
||||
|
||||
dp.message.register(handle_admin_ticket_reply, AdminTicketStates.waiting_for_reply)
|
||||
dp.message.register(handle_admin_block_duration_input, AdminTicketStates.waiting_for_block_duration)
|
||||
|
||||
# Управление статусами: явная кнопка больше не используется (статус меняется автоматически)
|
||||
|
||||
dp.callback_query.register(
|
||||
close_admin_ticket,
|
||||
F.data.startswith("admin_close_ticket_")
|
||||
)
|
||||
dp.callback_query.register(block_user_in_ticket, F.data.startswith("admin_block_user_ticket_"))
|
||||
dp.callback_query.register(unblock_user_in_ticket, F.data.startswith("admin_unblock_user_ticket_"))
|
||||
dp.callback_query.register(block_user_permanently, F.data.startswith("admin_block_user_perm_ticket_"))
|
||||
|
||||
# Отмена операций
|
||||
dp.callback_query.register(
|
||||
cancel_admin_ticket_reply,
|
||||
F.data == "cancel_admin_ticket_reply"
|
||||
)
|
||||
|
||||
# Пагинация админских тикетов
|
||||
dp.callback_query.register(show_admin_tickets, F.data.startswith("admin_tickets_page_"))
|
||||
|
||||
# Управление компоновкой ответа — (отключено)
|
||||
|
||||
# Вложения в тикете (админ)
|
||||
async def send_admin_ticket_attachments(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
db: AsyncSession
|
||||
):
|
||||
texts = get_texts(db_user.language)
|
||||
try:
|
||||
ticket_id = int(callback.data.replace("admin_ticket_attachments_", ""))
|
||||
except ValueError:
|
||||
await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True)
|
||||
return
|
||||
ticket = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True)
|
||||
if not ticket:
|
||||
await callback.answer(texts.t("TICKET_NOT_FOUND", "Тикет не найден."), show_alert=True)
|
||||
return
|
||||
photos = [m.media_file_id for m in ticket.messages if getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" and m.media_file_id]
|
||||
if not photos:
|
||||
await callback.answer(texts.t("NO_ATTACHMENTS", "Вложений нет."), show_alert=True)
|
||||
return
|
||||
from aiogram.types import InputMediaPhoto
|
||||
chunks = [photos[i:i+10] for i in range(0, len(photos), 10)]
|
||||
last_group_message = None
|
||||
for chunk in chunks:
|
||||
media = [InputMediaPhoto(media=pid) for pid in chunk]
|
||||
try:
|
||||
messages = await callback.message.bot.send_media_group(chat_id=callback.from_user.id, media=media)
|
||||
if messages:
|
||||
last_group_message = messages[-1]
|
||||
except Exception:
|
||||
pass
|
||||
# После отправки добавим кнопку удалить под последним сообщением группы
|
||||
if last_group_message:
|
||||
try:
|
||||
kb = types.InlineKeyboardMarkup(inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("DELETE_MESSAGE", "🗑 Удалить"), callback_data=f"admin_delete_message_{last_group_message.message_id}")]])
|
||||
await callback.message.bot.send_message(chat_id=callback.from_user.id, text=texts.t("ATTACHMENTS_SENT", "Вложения отправлены."), reply_markup=kb)
|
||||
except Exception:
|
||||
await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены."))
|
||||
else:
|
||||
await callback.answer(texts.t("ATTACHMENTS_SENT", "Вложения отправлены."))
|
||||
|
||||
dp.callback_query.register(send_admin_ticket_attachments, F.data.startswith("admin_ticket_attachments_"))
|
||||
|
||||
async def admin_delete_message(
|
||||
callback: types.CallbackQuery
|
||||
):
|
||||
try:
|
||||
msg_id = int(callback.data.replace("admin_delete_message_", ""))
|
||||
except ValueError:
|
||||
await callback.answer("❌")
|
||||
return
|
||||
try:
|
||||
await callback.message.bot.delete_message(chat_id=callback.from_user.id, message_id=msg_id)
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
await callback.answer("✅")
|
||||
|
||||
dp.callback_query.register(admin_delete_message, F.data.startswith("admin_delete_message_"))
|
||||
|
||||
@@ -28,6 +28,26 @@ async def handle_unknown_callback(
|
||||
logger.warning(f"Неизвестный callback: {callback.data} от пользователя {callback.from_user.id}")
|
||||
|
||||
|
||||
async def handle_noop(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_current_page(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User
|
||||
):
|
||||
try:
|
||||
await callback.answer()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def handle_cancel(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
@@ -83,9 +103,22 @@ def register_handlers(dp: Dispatcher):
|
||||
show_rules,
|
||||
F.data == "menu_rules"
|
||||
)
|
||||
|
||||
# No-op utility handlers used in many keyboards
|
||||
dp.callback_query.register(
|
||||
handle_noop,
|
||||
F.data == "noop"
|
||||
)
|
||||
dp.callback_query.register(
|
||||
handle_current_page,
|
||||
F.data == "current_page"
|
||||
)
|
||||
|
||||
dp.callback_query.register(
|
||||
handle_cancel,
|
||||
F.data.in_(["cancel", "subscription_cancel"])
|
||||
)
|
||||
|
||||
# Самый последний: ловим любые неизвестные текстовые сообщения
|
||||
dp.message.register(handle_unknown_message)
|
||||
|
||||
@@ -15,6 +15,7 @@ from app.services.subscription_checkout_service import (
|
||||
has_subscription_checkout_draft,
|
||||
should_offer_checkout_resume,
|
||||
)
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,9 +42,10 @@ async def show_main_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
@@ -53,7 +55,7 @@ async def show_main_menu(
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
@@ -112,9 +114,10 @@ async def handle_back_to_menu(
|
||||
draft_exists = await has_subscription_checkout_draft(db_user.id)
|
||||
show_resume_checkout = should_offer_checkout_resume(db_user, draft_exists)
|
||||
|
||||
await callback.message.edit_text(
|
||||
menu_text,
|
||||
reply_markup=get_main_menu_keyboard(
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=menu_text,
|
||||
keyboard=get_main_menu_keyboard(
|
||||
language=db_user.language,
|
||||
is_admin=settings.is_admin(db_user.telegram_id),
|
||||
has_had_paid_subscription=db_user.has_had_paid_subscription,
|
||||
@@ -124,7 +127,7 @@ async def handle_back_to_menu(
|
||||
subscription=db_user.subscription,
|
||||
show_resume_checkout=show_resume_checkout,
|
||||
),
|
||||
parse_mode="HTML"
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.config import settings
|
||||
from app.database.models import User
|
||||
from app.keyboards.inline import get_support_keyboard
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
from app.localization.texts import get_texts
|
||||
from app.utils.photo_message import edit_or_answer_photo
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,10 +18,12 @@ async def show_support_info(
|
||||
):
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
await callback.message.edit_text(
|
||||
texts.SUPPORT_INFO,
|
||||
reply_markup=get_support_keyboard(db_user.language)
|
||||
support_info = SupportSettingsService.get_support_info_text(db_user.language)
|
||||
await edit_or_answer_photo(
|
||||
callback=callback,
|
||||
caption=support_info,
|
||||
keyboard=get_support_keyboard(db_user.language),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
1039
app/handlers/tickets.py
Normal file
1039
app/handlers/tickets.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -61,6 +61,12 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
|
||||
[
|
||||
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
|
||||
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
|
||||
|
||||
@@ -157,13 +157,10 @@ def get_main_menu_keyboard(
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_PROMOCODE, callback_data="menu_promocode"),
|
||||
InlineKeyboardButton(text=texts.MENU_REFERRALS, callback_data="menu_referrals")
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"),
|
||||
InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules")
|
||||
]
|
||||
])
|
||||
|
||||
# Server status button
|
||||
server_status_mode = settings.get_server_status_mode()
|
||||
server_status_text = texts.t("MENU_SERVER_STATUS", "📊 Статус серверов")
|
||||
|
||||
@@ -178,6 +175,17 @@ def get_main_menu_keyboard(
|
||||
InlineKeyboardButton(text=server_status_text, callback_data="menu_server_status")
|
||||
])
|
||||
|
||||
# Support button is configurable (runtime via service)
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
support_enabled = SupportSettingsService.is_support_menu_enabled()
|
||||
except Exception:
|
||||
support_enabled = settings.SUPPORT_MENU_ENABLED
|
||||
support_row = []
|
||||
if support_enabled:
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_SUPPORT, callback_data="menu_support"))
|
||||
support_row.append(InlineKeyboardButton(text=texts.MENU_RULES, callback_data="menu_rules"))
|
||||
keyboard.append(support_row)
|
||||
if settings.DEBUG:
|
||||
print(f"DEBUG KEYBOARD: is_admin={is_admin}, добавляем админ кнопку: {is_admin}")
|
||||
|
||||
@@ -767,17 +775,38 @@ def get_referral_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMar
|
||||
|
||||
def get_support_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
try:
|
||||
from app.services.support_settings_service import SupportSettingsService
|
||||
tickets_enabled = SupportSettingsService.is_tickets_enabled()
|
||||
contact_enabled = SupportSettingsService.is_contact_enabled()
|
||||
except Exception:
|
||||
tickets_enabled = True
|
||||
contact_enabled = True
|
||||
rows: list[list[InlineKeyboardButton]] = []
|
||||
# Tickets
|
||||
if tickets_enabled:
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.CONTACT_SUPPORT,
|
||||
text=texts.t("CREATE_TICKET_BUTTON", "🎫 Создать тикет"),
|
||||
callback_data="create_ticket"
|
||||
)
|
||||
])
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("MY_TICKETS_BUTTON", "📋 Мои тикеты"),
|
||||
callback_data="my_tickets"
|
||||
)
|
||||
])
|
||||
# Direct contact
|
||||
if contact_enabled and settings.get_support_contact_url():
|
||||
rows.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CONTACT_SUPPORT_BUTTON", "💬 Связаться с поддержкой"),
|
||||
url=settings.get_support_contact_url() or "https://t.me/"
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")
|
||||
]
|
||||
])
|
||||
])
|
||||
rows.append([InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")])
|
||||
return InlineKeyboardMarkup(inline_keyboard=rows)
|
||||
|
||||
|
||||
def get_pagination_keyboard(
|
||||
@@ -1486,3 +1515,250 @@ def get_device_management_help_keyboard(language: str = DEFAULT_LANGUAGE) -> Inl
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
# ==================== TICKET KEYBOARDS ====================
|
||||
|
||||
def get_ticket_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_TICKET_CREATION", "❌ Отменить создание тикета"),
|
||||
callback_data="cancel_ticket_creation"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
def get_my_tickets_keyboard(
|
||||
tickets: List[dict],
|
||||
current_page: int = 1,
|
||||
total_pages: int = 1,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
for ticket in tickets:
|
||||
status_emoji = ticket.get('status_emoji', '❓')
|
||||
# Override status emoji for closed tickets in admin list
|
||||
if ticket.get('is_closed', False):
|
||||
status_emoji = '✅'
|
||||
title = ticket.get('title', 'Без названия')[:25]
|
||||
button_text = f"{status_emoji} #{ticket['id']} {title}"
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"view_ticket_{ticket['id']}"
|
||||
)
|
||||
])
|
||||
|
||||
# Пагинация
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"my_tickets_page_{current_page - 1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"my_tickets_page_{current_page + 1}"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="menu_support")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_ticket_view_keyboard(
|
||||
ticket_id: int,
|
||||
is_closed: bool = False,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if not is_closed:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("REPLY_TO_TICKET", "💬 Ответить"),
|
||||
callback_data=f"reply_ticket_{ticket_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if not is_closed:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"),
|
||||
callback_data=f"close_ticket_{ticket_id}"
|
||||
)
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="my_tickets")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
|
||||
callback_data="cancel_ticket_reply"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
|
||||
# ==================== ADMIN TICKET KEYBOARDS ====================
|
||||
|
||||
def get_admin_tickets_keyboard(
|
||||
tickets: List[dict],
|
||||
current_page: int = 1,
|
||||
total_pages: int = 1,
|
||||
language: str = DEFAULT_LANGUAGE,
|
||||
scope: str = "all"
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
# Разделяем открытые/закрытые
|
||||
open_rows = []
|
||||
closed_rows = []
|
||||
for ticket in tickets:
|
||||
status_emoji = ticket.get('status_emoji', '❓')
|
||||
if ticket.get('is_closed', False):
|
||||
status_emoji = '✅'
|
||||
user_name = ticket.get('user_name', 'Unknown')[:15]
|
||||
title = ticket.get('title', 'Без названия')[:20]
|
||||
locked_emoji = ticket.get('locked_emoji', '')
|
||||
button_text = f"{status_emoji} #{ticket['id']} {locked_emoji} {user_name}: {title}".replace(" ", " ")
|
||||
row = [InlineKeyboardButton(text=button_text, callback_data=f"admin_view_ticket_{ticket['id']}")]
|
||||
if ticket.get('is_closed', False):
|
||||
closed_rows.append(row)
|
||||
else:
|
||||
open_rows.append(row)
|
||||
|
||||
# Scope switcher
|
||||
switch_row = []
|
||||
switch_row.append(InlineKeyboardButton(text=texts.t("OPEN_TICKETS", "🔴 Открытые"), callback_data="admin_tickets_scope_open"))
|
||||
switch_row.append(InlineKeyboardButton(text=texts.t("CLOSED_TICKETS", "🟢 Закрытые"), callback_data="admin_tickets_scope_closed"))
|
||||
keyboard.append(switch_row)
|
||||
|
||||
if open_rows and scope in ("all", "open"):
|
||||
keyboard.append([InlineKeyboardButton(text=texts.t("OPEN_TICKETS_HEADER", "Открытые тикеты"), callback_data="noop")])
|
||||
keyboard.extend(open_rows)
|
||||
if closed_rows and scope in ("all", "closed"):
|
||||
keyboard.append([InlineKeyboardButton(text=texts.t("CLOSED_TICKETS_HEADER", "Закрытые тикеты"), callback_data="noop")])
|
||||
keyboard.extend(closed_rows)
|
||||
|
||||
# Пагинация
|
||||
if total_pages > 1:
|
||||
nav_row = []
|
||||
|
||||
if current_page > 1:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_PREV", "⬅️"),
|
||||
callback_data=f"admin_tickets_page_{scope}_{current_page - 1}"
|
||||
)
|
||||
)
|
||||
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=f"{current_page}/{total_pages}",
|
||||
callback_data="current_page"
|
||||
)
|
||||
)
|
||||
|
||||
if current_page < total_pages:
|
||||
nav_row.append(
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("PAGINATION_NEXT", "➡️"),
|
||||
callback_data=f"admin_tickets_page_{scope}_{current_page + 1}"
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.append(nav_row)
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_admin_ticket_view_keyboard(
|
||||
ticket_id: int,
|
||||
is_closed: bool = False,
|
||||
language: str = DEFAULT_LANGUAGE
|
||||
) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
keyboard = []
|
||||
|
||||
if not is_closed:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("REPLY_TO_TICKET", "💬 Ответить"),
|
||||
callback_data=f"admin_reply_ticket_{ticket_id}"
|
||||
)
|
||||
])
|
||||
|
||||
if not is_closed:
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CLOSE_TICKET", "🔒 Закрыть тикет"),
|
||||
callback_data=f"admin_close_ticket_{ticket_id}"
|
||||
)
|
||||
])
|
||||
|
||||
# Block controls: first row Unblock + Block forever, second row Block by time
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}"),
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Блок навсегда"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}")
|
||||
])
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.t("BLOCK_BY_TIME", "⏳ Блокировка по времени"), callback_data=f"admin_block_user_ticket_{ticket_id}")
|
||||
])
|
||||
|
||||
keyboard.append([
|
||||
InlineKeyboardButton(text=texts.BACK, callback_data="admin_tickets")
|
||||
])
|
||||
|
||||
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
||||
|
||||
|
||||
def get_admin_ticket_reply_cancel_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeyboardMarkup:
|
||||
texts = get_texts(language)
|
||||
return InlineKeyboardMarkup(inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
|
||||
callback_data="cancel_admin_ticket_reply"
|
||||
)
|
||||
]
|
||||
])
|
||||
|
||||
@@ -44,15 +44,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Техническая поддержка</b>\n\n"
|
||||
"По всем вопросам обращайтесь к нашей поддержке:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"Мы поможем с:\n"
|
||||
"• Настройкой подключения\n"
|
||||
"• Решением технических проблем \n"
|
||||
"• Вопросами по оплате\n"
|
||||
"• Другими вопросами\n\n"
|
||||
"⏰ Время ответа: обычно в течение 1-2 часов\n"
|
||||
"\n🛟 <b>Поддержка RemnaWave</b>\n\n"
|
||||
"Это центр тикетов: создавайте обращения, просматривайте ответы и историю.\n\n"
|
||||
"• 🎫 Создать тикет — опишите проблему или вопрос\n"
|
||||
"• 📋 Мои тикеты — статус и переписка\n"
|
||||
"• 💬 Связаться — написать напрямую (если нужно)\n\n"
|
||||
"Старайтесь использовать тикеты — так мы быстрее поможем и ничего не потеряется.\n"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -72,15 +69,12 @@ def _build_dynamic_values(language: str) -> Dict[str, Any]:
|
||||
"TRAFFIC_250GB": f"📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": f"📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"SUPPORT_INFO": (
|
||||
"\n🛠️ <b>Technical support</b>\n\n"
|
||||
"For any questions contact our support:\n\n"
|
||||
f"👤 {settings.SUPPORT_USERNAME}\n\n"
|
||||
"We can help with:\n"
|
||||
"• Connection setup\n"
|
||||
"• Troubleshooting issues\n"
|
||||
"• Payment questions\n"
|
||||
"• Other requests\n\n"
|
||||
"⏰ Response time: usually within 1-2 hours\n"
|
||||
"\n🛟 <b>RemnaWave Support</b>\n\n"
|
||||
"This is the ticket center: create requests, view replies and history.\n\n"
|
||||
"• 🎫 Create ticket — describe your issue or question\n"
|
||||
"• 📋 My tickets — status and conversation\n"
|
||||
"• 💬 Contact — message directly if needed\n\n"
|
||||
"Prefer tickets — it helps us respond faster and keep context.\n"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import time
|
||||
from typing import Callable, Dict, Any, Awaitable
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery, TelegramObject
|
||||
from aiogram.fsm.context import FSMContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -33,13 +34,31 @@ class ThrottlingMiddleware(BaseMiddleware):
|
||||
|
||||
if now - last_call < self.rate_limit:
|
||||
logger.warning(f"🚫 Throttling для пользователя {user_id}")
|
||||
|
||||
|
||||
# Для сообщений: молчим только если это состояние работы с тикетами; иначе показываем блок
|
||||
if isinstance(event, Message):
|
||||
try:
|
||||
fsm: FSMContext = data.get("state") # может отсутствовать
|
||||
current = await fsm.get_state() if fsm else None
|
||||
except Exception:
|
||||
current = None
|
||||
is_ticket_state = False
|
||||
if current:
|
||||
# Молчим только в состояниях работы с тикетами (user/admin): waiting_for_message / waiting_for_reply
|
||||
lowered = str(current)
|
||||
is_ticket_state = (
|
||||
(":waiting_for_message" in lowered or ":waiting_for_reply" in lowered) and
|
||||
("TicketStates" in lowered or "AdminTicketStates" in lowered)
|
||||
)
|
||||
if is_ticket_state:
|
||||
return
|
||||
# В остальных случаях — явный блок
|
||||
await event.answer("⏳ Пожалуйста, не отправляйте сообщения так часто!")
|
||||
return
|
||||
# Для callback допустим краткое уведомление
|
||||
elif isinstance(event, CallbackQuery):
|
||||
await event.answer("⏳ Слишком быстро! Подождите немного.", show_alert=True)
|
||||
|
||||
return
|
||||
return
|
||||
|
||||
self.user_buckets[user_id] = now
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
from aiogram import Bot
|
||||
from aiogram import Bot, types
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
@@ -18,6 +18,7 @@ class AdminNotificationService:
|
||||
self.bot = bot
|
||||
self.chat_id = getattr(settings, 'ADMIN_NOTIFICATIONS_CHAT_ID', None)
|
||||
self.topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TOPIC_ID', None)
|
||||
self.ticket_topic_id = getattr(settings, 'ADMIN_NOTIFICATIONS_TICKET_TOPIC_ID', None)
|
||||
self.enabled = getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False)
|
||||
|
||||
async def _get_referrer_info(self, db: AsyncSession, referred_by_id: Optional[int]) -> str:
|
||||
@@ -294,7 +295,7 @@ class AdminNotificationService:
|
||||
logger.error(f"Ошибка отправки уведомления о продлении: {e}")
|
||||
return False
|
||||
|
||||
async def _send_message(self, text: str) -> bool:
|
||||
async def _send_message(self, text: str, reply_markup: types.InlineKeyboardMarkup | None = None, *, ticket_event: bool = False) -> bool:
|
||||
if not self.chat_id:
|
||||
logger.warning("ADMIN_NOTIFICATIONS_CHAT_ID не настроен")
|
||||
return False
|
||||
@@ -307,8 +308,16 @@ class AdminNotificationService:
|
||||
'disable_web_page_preview': True
|
||||
}
|
||||
|
||||
if self.topic_id:
|
||||
message_kwargs['message_thread_id'] = self.topic_id
|
||||
# route to ticket-specific topic if provided
|
||||
thread_id = None
|
||||
if ticket_event and self.ticket_topic_id:
|
||||
thread_id = self.ticket_topic_id
|
||||
elif self.topic_id:
|
||||
thread_id = self.topic_id
|
||||
if thread_id:
|
||||
message_kwargs['message_thread_id'] = thread_id
|
||||
if reply_markup is not None:
|
||||
message_kwargs['reply_markup'] = reply_markup
|
||||
|
||||
await self.bot.send_message(**message_kwargs)
|
||||
logger.info(f"Уведомление отправлено в чат {self.chat_id}")
|
||||
@@ -801,3 +810,15 @@ class AdminNotificationService:
|
||||
return str(value)
|
||||
return str(value)
|
||||
|
||||
async def send_ticket_event_notification(
|
||||
self,
|
||||
text: str,
|
||||
keyboard: types.InlineKeyboardMarkup | None = None
|
||||
) -> bool:
|
||||
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
|
||||
Учитывает настройки включенности в settings.
|
||||
"""
|
||||
if not self._is_enabled():
|
||||
return False
|
||||
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)
|
||||
|
||||
|
||||
112
app/services/support_settings_service.py
Normal file
112
app/services/support_settings_service.py
Normal file
@@ -0,0 +1,112 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupportSettingsService:
|
||||
"""Runtime editable support settings with JSON persistence."""
|
||||
|
||||
_storage_path: Path = Path("data/support_settings.json")
|
||||
_data: Dict = {}
|
||||
_loaded: bool = False
|
||||
|
||||
@classmethod
|
||||
def _ensure_dir(cls) -> None:
|
||||
try:
|
||||
cls._storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ensure settings dir: {e}")
|
||||
|
||||
@classmethod
|
||||
def _load(cls) -> None:
|
||||
if cls._loaded:
|
||||
return
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
if cls._storage_path.exists():
|
||||
cls._data = json.loads(cls._storage_path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
cls._data = {}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load support settings: {e}")
|
||||
cls._data = {}
|
||||
cls._loaded = True
|
||||
|
||||
@classmethod
|
||||
def _save(cls) -> bool:
|
||||
cls._ensure_dir()
|
||||
try:
|
||||
cls._storage_path.write_text(json.dumps(cls._data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save support settings: {e}")
|
||||
return False
|
||||
|
||||
# Mode
|
||||
@classmethod
|
||||
def get_system_mode(cls) -> str:
|
||||
cls._load()
|
||||
mode = (cls._data.get("system_mode") or settings.get_support_system_mode()).strip().lower()
|
||||
return mode if mode in {"tickets", "contact", "both"} else "both"
|
||||
|
||||
@classmethod
|
||||
def set_system_mode(cls, mode: str) -> bool:
|
||||
mode_clean = (mode or "").strip().lower()
|
||||
if mode_clean not in {"tickets", "contact", "both"}:
|
||||
return False
|
||||
cls._load()
|
||||
cls._data["system_mode"] = mode_clean
|
||||
return cls._save()
|
||||
|
||||
# Main menu visibility
|
||||
@classmethod
|
||||
def is_support_menu_enabled(cls) -> bool:
|
||||
cls._load()
|
||||
if "menu_enabled" in cls._data:
|
||||
return bool(cls._data["menu_enabled"])
|
||||
return bool(settings.SUPPORT_MENU_ENABLED)
|
||||
|
||||
@classmethod
|
||||
def set_support_menu_enabled(cls, enabled: bool) -> bool:
|
||||
cls._load()
|
||||
cls._data["menu_enabled"] = bool(enabled)
|
||||
return cls._save()
|
||||
|
||||
# Contact vs tickets helpers
|
||||
@classmethod
|
||||
def is_tickets_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"tickets", "both"}
|
||||
|
||||
@classmethod
|
||||
def is_contact_enabled(cls) -> bool:
|
||||
return cls.get_system_mode() in {"contact", "both"}
|
||||
|
||||
# Descriptions (per language)
|
||||
@classmethod
|
||||
def get_support_info_text(cls, language: str) -> str:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
overrides = cls._data.get("support_info_texts") or {}
|
||||
text = overrides.get(lang)
|
||||
if text and isinstance(text, str) and text.strip():
|
||||
return text
|
||||
# Fallback to dynamic localization default
|
||||
from app.localization.texts import get_texts
|
||||
return get_texts(lang).SUPPORT_INFO
|
||||
|
||||
@classmethod
|
||||
def set_support_info_text(cls, language: str, text: str) -> bool:
|
||||
cls._load()
|
||||
lang = (language or settings.DEFAULT_LANGUAGE).split("-")[0].lower()
|
||||
texts_map = cls._data.get("support_info_texts") or {}
|
||||
texts_map[lang] = text or ""
|
||||
cls._data["support_info_texts"] = texts_map
|
||||
return cls._save()
|
||||
|
||||
|
||||
@@ -104,6 +104,18 @@ class AdminStates(StatesGroup):
|
||||
class SupportStates(StatesGroup):
|
||||
waiting_for_message = State()
|
||||
|
||||
class TicketStates(StatesGroup):
|
||||
waiting_for_title = State()
|
||||
waiting_for_message = State()
|
||||
waiting_for_reply = State()
|
||||
|
||||
class AdminTicketStates(StatesGroup):
|
||||
waiting_for_reply = State()
|
||||
waiting_for_block_duration = State()
|
||||
|
||||
class SupportSettingsStates(StatesGroup):
|
||||
waiting_for_desc = State()
|
||||
|
||||
class AutoPayStates(StatesGroup):
|
||||
setting_autopay_days = State()
|
||||
confirming_autopay_toggle = State()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from pathlib import Path
|
||||
from aiogram.types import Message, FSInputFile, InputMediaPhoto
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from app.config import settings
|
||||
|
||||
@@ -15,13 +16,39 @@ _original_edit_text = Message.edit_text
|
||||
|
||||
|
||||
async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
# Если caption слишком длинный для фото — отправим как текст
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
if LOGO_PATH.exists():
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
try:
|
||||
return await self.answer_photo(FSInputFile(LOGO_PATH), caption=text, **kwargs)
|
||||
except Exception:
|
||||
# Фоллбек, если Telegram ругается на caption: отправим как текст
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
|
||||
|
||||
async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
if self.photo:
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
# Всегда используем логотип если включен режим логотипа,
|
||||
# кроме специальных случаев (QR сообщения)
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not is_qr_message(self):
|
||||
@@ -32,8 +59,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
media = self.photo[-1].file_id
|
||||
media_kwargs = {"media": media, "caption": text}
|
||||
if "parse_mode" in kwargs:
|
||||
media_kwargs["parse_mode"] = kwargs.pop("parse_mode")
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
_pm = kwargs.pop("parse_mode")
|
||||
media_kwargs["parse_mode"] = _pm if _pm is not None else "HTML"
|
||||
else:
|
||||
media_kwargs["parse_mode"] = "HTML"
|
||||
try:
|
||||
return await self.edit_media(InputMediaPhoto(**media_kwargs), **kwargs)
|
||||
except TelegramBadRequest:
|
||||
# Фоллбек: удалим и отправим обычный текст без фото
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
pass
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
return await _original_edit_text(self, text, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ async def edit_or_answer_photo(
|
||||
keyboard: types.InlineKeyboardMarkup,
|
||||
parse_mode: str | None = "HTML",
|
||||
) -> None:
|
||||
# Если режим логотипа выключен — работаем текстом
|
||||
if not settings.ENABLE_LOGO_MODE:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
@@ -47,17 +48,44 @@ async def edit_or_answer_photo(
|
||||
)
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
if caption and len(caption) > 1000:
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
return
|
||||
|
||||
media = _resolve_media(callback.message)
|
||||
try:
|
||||
await callback.message.edit_media(
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=parse_mode),
|
||||
InputMediaPhoto(media=media, caption=caption, parse_mode=(parse_mode or "HTML")),
|
||||
reply_markup=keyboard,
|
||||
)
|
||||
except TelegramBadRequest:
|
||||
await callback.message.delete()
|
||||
await callback.message.answer_photo(
|
||||
FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=parse_mode,
|
||||
)
|
||||
# Фоллбек: если не удалось обновить фото — отправим текст, чтобы не упасть на лимите caption
|
||||
try:
|
||||
await callback.message.delete()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Отправим как фото с логотипом
|
||||
await callback.message.answer_photo(
|
||||
photo=media if isinstance(media, FSInputFile) else FSInputFile(LOGO_PATH),
|
||||
caption=caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
except Exception:
|
||||
# Последний фоллбек — обычный текст
|
||||
await callback.message.answer(
|
||||
caption,
|
||||
reply_markup=keyboard,
|
||||
parse_mode=(parse_mode or "HTML"),
|
||||
)
|
||||
|
||||
439
locales/en.json
Normal file
439
locales/en.json
Normal file
@@ -0,0 +1,439 @@
|
||||
{
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Add countries",
|
||||
"ADMIN_MAIN_MENU": "🏠 Main menu",
|
||||
"ADMIN_CAMPAIGNS": "📣 Promotional campaigns",
|
||||
"AUTOPAY_BUTTON": "💳 Auto payment",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Configure days",
|
||||
"BACK": "⬅️ Back",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ Back to subscription",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Balance: {balance}",
|
||||
"CANCEL": "❌ Cancel",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Change devices",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Choose another device",
|
||||
"CONFIRM": "✅ Confirm",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Confirm change",
|
||||
"CONNECT_BUTTON": "🔗 Connect",
|
||||
"CONTINUE": "➡️ Continue",
|
||||
"CONTINUE_BUTTON": "➡️ Continue",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Copy subscription link",
|
||||
"CREATE_INVITE_BUTTON": "📝 Create invite",
|
||||
"DEVICE_CONNECTION_HELP": "❓ How to reconnect a device?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Disable",
|
||||
"ENABLE_BUTTON": "✅ Enable",
|
||||
"ERROR": "❌ An error occurred",
|
||||
"ERROR_TRY_AGAIN": "❌ An error occurred. Please try again.",
|
||||
"ERROR_RULES_RETRY": "An error occurred. Please try accepting the rules again:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Go to balance top up",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Return to subscription checkout",
|
||||
"INSUFFICIENT_BALANCE": "❌ Insufficient balance.\n\nTop up {amount} and try again.",
|
||||
"LANGUAGE_SELECTED": "🌐 Interface language set: <b>English</b>",
|
||||
"LOADING": "⏳ Loading...",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n\n📱 <b>Subscription:</b> {subscription_status}\n\nChoose an option:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Choose an option:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Main menu",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Manage devices",
|
||||
"MENU_BALANCE": "💰 Balance",
|
||||
"MENU_SUBSCRIPTION": "📱 Subscription",
|
||||
"MENU_TRIAL": "🎁 Trial subscription",
|
||||
"MY_BALANCE_BUTTON": "💰 My balance",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 My subscription",
|
||||
"NO": "❌ No",
|
||||
"NO_SERVERS_AVAILABLE": "❌ No servers available",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ No packages available",
|
||||
"OTHER_APPS_BUTTON": "📋 Other apps",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Payment methods are temporarily unavailable",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Bank card (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Bank card (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Cryptocurrency (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏦 Pay via SBP (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Via support",
|
||||
"PAY_NOW_BUTTON": "💳 Pay",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Pay",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Cancel",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Activate free trial 🚀",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Analytics",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Referral code accepted!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Invalid referral code",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Invalid referral code.\n\n💡 If you have a referral code, please double-check the spelling.\n⏭️ To continue without a referral code, use the /start command.",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>Do you have a friend's referral code?</b>\n\nIf you have a promo code or referral link, enter it now to receive a bonus!\n\nSend the code or tap \"Skip\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Skip",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ You are already registered. A referral link cannot be applied.",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Referral list",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Reset all devices",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Reset this device",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Reset traffic",
|
||||
"RULES_HEADER": "📋 <b>Service Rules</b>",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Rules accepted! Completing registration...",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Service Usage Rules</b>\n\n1. Do not use the service for illegal activity\n2. Avoid sharing pirated or malicious content\n3. Spam and phishing are prohibited\n4. Using the service for DDoS attacks is forbidden\n5. One account is intended for one person\n6. Refunds are provided only in exceptional cases\n7. The administration may block accounts that violate the rules\n\n<b>By using the service you agree to follow these rules.</b>",
|
||||
"SEND_CONTACT_BUTTON": "📱 Share contact",
|
||||
"SEND_LOCATION_BUTTON": "📍 Share location",
|
||||
"SHOW_QR_BUTTON": "📱 Show QR code",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Show subscription link",
|
||||
"SKIP_BUTTON": "Skip ➡️",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Subscription settings",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Active\n⚠️ expires in {days} days",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Active\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Active\n⚠️ expires today!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Active\n⚠️ expires tomorrow!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Expired\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Not available",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Trial subscription\n📅 until {end_date} ({days} days)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Trial subscription\n⚠️ expires today!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Trial subscription\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Active",
|
||||
"SUCCESS": "✅ Success",
|
||||
"REGISTRATION_COMPLETING": "✅ Completing registration...",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Switch traffic",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Top up balance",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Traffic packages are not configured",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Activate",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Please enter a valid promo code",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Failed to credit funds. Please contact support; the payment will be verified manually.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Technical error processing the payment. Please contact support for assistance.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Payment processed successfully!</b>\n\n⭐ Stars spent: {stars_spent}\n💰 Added to balance: {amount} ₽\n🆔 Transaction ID: {transaction_id}...\n\nThank you for topping up! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Error: user not found. Please contact support.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Payment validation error. Please try again.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Technical error. Please try again later.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "User not found. Please contact support.",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Unknown action. Please try again.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ I didn't understand that command. Use the menu buttons.",
|
||||
"WELCOME": "\n🎉 <b>Welcome to VPN Service!</b>\n\nOur service provides fast and secure internet access without restrictions.\n\n🔐 <b>Advantages:</b>\n• High connection speed\n• Servers in different countries \n• Reliable data protection\n• 24/7 support\n\nTo get started, select interface language:\n",
|
||||
"WELCOME_FALLBACK": "Welcome, {user_name}!",
|
||||
"YES": "✅ Yes",
|
||||
"ACCESS_DENIED": "❌ Access denied",
|
||||
"ADMIN_MESSAGES": "📨 Broadcasts",
|
||||
"ADMIN_MONITORING": "🔍 Monitoring",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Administration panel</b>\n\nSelect a section to manage:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Promo codes",
|
||||
"ADMIN_REFERRALS": "🤝 Referral program",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Rules",
|
||||
"ADMIN_STATISTICS": "📊 Statistics",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Promo groups</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Groups total: {count}\nMembers total: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
|
||||
"MY_TICKETS_BUTTON": "📋 My tickets",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Contact support",
|
||||
"TICKET_PRIORITY_SELECT": "Select ticket priority:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Low",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Normal",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 High",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Urgent",
|
||||
"CANCEL_TICKET_CREATION": "❌ Cancel ticket creation",
|
||||
"TICKET_TITLE_INPUT": "Enter ticket title:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Title must contain at least 5 characters. Try again:",
|
||||
"TICKET_TITLE_TOO_LONG": "Title is too long. Maximum 255 characters. Try again:",
|
||||
"TICKET_MESSAGE_INPUT": "Now describe your problem or question:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Message must contain at least 10 characters. Try again:",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Ticket #{ticket_id} created successfully!\n\nTitle: {title}\n\nWe will respond to you soon.",
|
||||
"VIEW_TICKET": "👁️ View ticket",
|
||||
"BACK_TO_MENU": "🏠 Back to menu",
|
||||
"TICKET_CREATION_ERROR": "❌ An error occurred while creating the ticket. Please try again later.",
|
||||
"NO_TICKETS": "You don't have any tickets yet.",
|
||||
"MY_TICKETS_TITLE": "📋 Your tickets:",
|
||||
"TICKET_STATUS_OPEN": "Open",
|
||||
"TICKET_STATUS_ANSWERED": "Answered",
|
||||
"TICKET_STATUS_CLOSED": "Closed",
|
||||
"TICKET_STATUS_PENDING": "Pending",
|
||||
"REPLY_TO_TICKET": "💬 Reply",
|
||||
"CLOSE_TICKET": "🔒 Close ticket",
|
||||
"CANCEL_REPLY": "❌ Cancel reply",
|
||||
"TICKET_REPLY_INPUT": "Enter your reply:",
|
||||
"TICKET_REPLY_TOO_SHORT": "Reply must contain at least 5 characters. Try again:",
|
||||
"TICKET_REPLY_SENT": "✅ Your reply has been sent!",
|
||||
"TICKET_REPLY_ERROR": "❌ An error occurred while sending the reply. Please try again later.",
|
||||
"TICKET_CLOSED": "✅ Ticket closed.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Error closing ticket.",
|
||||
"TICKET_NOT_FOUND": "Ticket not found.",
|
||||
"TICKET_CREATION_CANCELLED": "Ticket creation cancelled.",
|
||||
"BACK_TO_SUPPORT": "⬅️ Back to support",
|
||||
"TICKET_REPLY_CANCELLED": "Reply cancelled.",
|
||||
"BACK_TO_TICKETS": "⬅️ Back to tickets",
|
||||
"NO_TICKETS_ADMIN": "No tickets to display.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 All support tickets:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Enter support reply:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Reply sent!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Ticket marked as answered.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Error updating ticket.",
|
||||
"MARK_AS_ANSWERED": "✅ Mark as answered",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Reply received for ticket #{ticket_id}\n\n{reply_preview}\n\nClick the button below to go to the ticket:",
|
||||
"CLOSE_NOTIFICATION": "❌ Close notification",
|
||||
"NOTIFICATION_CLOSED": "Notification closed.",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Promo group",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>User promo group</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Current group: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Current group: not assigned",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Discounts — servers: {servers}%, traffic: {traffic}%, devices: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "No discounts configured.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Select a promo group to assign:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ User promo group updated: “{name}”",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ The user is already in this promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Failed to update the user's promo group.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ Back to user",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Promo group:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Members: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "This is the default group.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Members",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Edit",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Delete",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Enter a name for the new promo group:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Name cannot be empty.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Enter traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Enter server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Enter device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Enter a number from 0 to 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Promo group “{name}” created.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ Back to promo groups",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Enter a new name (current: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Enter new traffic discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Enter new server discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Enter new device discount (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Promo group “{name}” updated.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Members of {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "This group has no members yet.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "The default promo group cannot be deleted.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Delete promo group “{name}”? All users will be moved to the default group.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Promo group “{name}” deleted.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Subscriptions",
|
||||
"ADMIN_USERS": "👥 Users",
|
||||
"AUTOPAY_DISABLED_TEXT": "Disabled — don't forget to renew manually!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Enabled — the subscription will renew automatically",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Autopay failed</b>\n\nWe couldn't charge the renewal payment.\nBalance available: {balance}\nRequired: {required}\n\nPlease top up your balance and renew manually.\n",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Autopay completed</b>\n\nYour subscription was automatically renewed for {days} days.\nCharged from balance: {amount}\n",
|
||||
"BALANCE_BUTTON": "💰 Balance: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Balance: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 Transaction history",
|
||||
"BALANCE_INFO": "\n💰 <b>Balance: {balance}</b>\n\nChoose an action:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Request via support",
|
||||
"BALANCE_TOP_UP": "💳 Top up",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ This promo link is available only to new users.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 You received {amount} for registering via the \"{name}\" campaign!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 You’ve been granted a {days}-day subscription (traffic: {traffic}, devices: {devices}) from the \"{name}\" campaign!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Subscription setup</b>\n\nLet's configure a plan that fits you.\n\nFirst, choose the subscription period:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Your promo group discounts</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Servers: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Traffic: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Extra devices: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Long-term period discounts:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n📱 <b>Confirm change</b>\n\nCurrent amount: {current_devices} devices\nNew amount: {new_devices} devices\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"CHANGE_DEVICES_INFO": "\n📱 <b>Adjust device limit</b>\n\nCurrent limit: {current_devices} devices\n\nChoose the new number of devices:\n\n💡 <b>Important:</b>\n• Increasing — extra charge proportional to the remaining time\n• Decreasing — funds are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n✅ Device limit decreased!\n\n📱 Was: {old_count} → Now: {new_count}\nℹ️ Payments are not refunded\n",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||
"CONTACT_SUPPORT": "💬 Contact support",
|
||||
"CREATE_INVITE": "📝 Create invite",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Maximum device limit exceeded ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Minimum number of devices: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Device limit was not changed",
|
||||
"INVALID_AMOUNT": "❌ Invalid amount",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable while we improve performance.\n\n⏰ Estimated completion time: unknown\n🔄 Please try again later\n\nWe apologize for the inconvenience.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Maintenance in progress!\n\nThe service is temporarily unavailable due to connection issues with the servers.\n\n⏰ We're working on it. Please try again in a few minutes.\n\n🔄 Last check: {last_check}\n",
|
||||
"MENU_ADMIN": "⚙️ Admin panel",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Buy subscription",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Extend subscription",
|
||||
"MENU_PROMOCODE": "🎫 Promo code",
|
||||
"MENU_REFERRALS": "🤝 Referral program",
|
||||
"MENU_RULES": "📋 Service rules",
|
||||
"MENU_SUPPORT": "🛠️ Support",
|
||||
"OPERATION_CANCELLED": "❌ Operation cancelled",
|
||||
"PERIOD_14_DAYS": "📅 14 days - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 days - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 days - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 days - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 days - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 days - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PROMOCODE_ENTER": "🎫 Enter promo code",
|
||||
"PROMOCODE_EXPIRED": "❌ Promo code has expired",
|
||||
"PROMOCODE_INVALID": "❌ Invalid promo code",
|
||||
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
|
||||
"PROMOCODE_USED": "ℹ️ Promo code has already been used",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Referral code applied! You will receive a bonus after the first purchase.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Referral program</b>\n\n👥 <b>Invited:</b> {referrals_count} friends\n💰 <b>Earned:</b> {earned_amount}\n\n🔗 <b>Your referral link:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Your promo code:</b>\n<code>{referral_code}</code>\n\n💰 <b>Terms:</b>\n• Per friend: {registration_bonus}\n• Top-up commission: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Invitation to the VPN service</b>\n\nHi! I invite you to an excellent VPN service!\n\n🎁 Use my link to get a bonus: {bonus}\n\n🔗 Join: {link}\n🎫 Or use promo code: {code}\n\n💪 Fast, reliable, affordable!\n",
|
||||
"RULES_ACCEPT": "✅ I accept the rules",
|
||||
"RULES_DECLINE": "❌ I do not accept",
|
||||
"RULES_REQUIRED": "❗️ You must accept the rules to use the service!",
|
||||
"SELECT_COUNTRIES": "Select countries:",
|
||||
"SELECT_DEVICES": "Number of devices:",
|
||||
"SELECT_PERIOD": "Choose period:",
|
||||
"SELECT_TRAFFIC": "Choose traffic package:",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew it to restore access.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Subscription expiring!</b>\n\nYour subscription expires in {days} days.\n\nRenew it now so you don't lose access.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Subscription expires in {days_text}!</b>\n\nYour paid subscription ends on {end_date}.\n\n💳 <b>Autopay:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Subscription details</b>\n\n📊 <b>Status:</b> {status}\n🎭 <b>Type:</b> {type}\n📅 <b>Valid until:</b> {end_date}\n⏰ <b>Days left:</b> {days_left}\n\n📈 <b>Traffic:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Servers:</b> {countries_count} countries\n📱 <b>Devices:</b> {devices_used} / {devices_limit}\n\n💳 <b>Autopay:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ No active subscription",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Technical support</b>\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Confirm traffic change</b>\n\nCurrent limit: {current_traffic}\nNew limit: {new_traffic}\n\nAction: {action}\n💰 {cost}\n\nApply this change?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Switch traffic limit</b>\n\nCurrent limit: {current_traffic}\nChoose the new traffic amount:\n\n💡 <b>Important:</b>\n• Increasing — you pay the difference proportionally to the remaining time\n• Decreasing — payments are not refunded\n• The used traffic counter is NOT reset\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Traffic limit decreased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\nℹ️ Payments are not refunded\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Traffic limit increased!\n\n📊 Was: {old_traffic} → Now: {new_traffic}\n💰 Charged: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Switch traffic limit",
|
||||
"TOP_UP_AMOUNT": "💳 Enter top-up amount (in rubles):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Select a payment method</b>\n\nAmount: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"TOP_UP_TRIBUTE": "💎 Bank card",
|
||||
"TRAFFIC_5GB": "📊 5 GB - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 GB - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 GB - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 GB - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_100GB": "📊 100 GB - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 GB - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_UNLIMITED": "📊 Unlimited - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Insufficient balance!\nRequired: {required} (for {months} mo)\nYou have: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Traffic limit was not changed",
|
||||
"TRIAL_ACTIVATED": "🎉 Trial subscription activated!",
|
||||
"TRIAL_ALREADY_USED": "❌ The trial subscription has already been used",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Trial subscription</b>\n\nYou can get a free trial plan:\n\n⏰ <b>Duration:</b> {days} days\n📈 <b>Traffic:</b> {traffic} GB\n📱 <b>Devices:</b> {devices} pcs\n🌍 <b>Server:</b> {server_name}\n\nActivate the trial subscription?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>The trial subscription is ending soon!</b>\n\nYour trial expires in a few hours.\n\n💎 <b>Don't want to lose VPN access?</b>\nSwitch to the full subscription!\n\n🔥 <b>Special offer:</b>\n• 30 days for {price}\n• Unlimited traffic\n• All servers available\n• Speeds up to 1 Gbit/s\n\n⚡️ Activate before the trial ends!\n",
|
||||
"USER_NOT_FOUND": "❌ User not found",
|
||||
"MENU_LANGUAGE": "🌐 Language",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Expired",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Active",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Unknown",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "expired",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} days",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} hr",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} min",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ expires tomorrow!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ expires today!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 expires in a few minutes!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Trial",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Paid",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (unlimited) | Used: {used} GB",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} GB",
|
||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Connected devices:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Connection link:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Copy the link and add it to your VPN app",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Your import link for the VPN app:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Tap the button below to get setup instructions for your device",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ Back to main menu",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Custom mini-app link is not configured",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nThe link is being generated, open the 'My subscription' section in a few seconds.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ You don't have an active subscription or the link is still being generated",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Connect subscription</b>\n\n🚀 Tap the button below to open the subscription in the Telegram mini app:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Connect subscription</b>\n\n📱 Tap the button below to open the app:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Connect subscription</b>\n\n🔗 Tap the button below to open the subscription link:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Connect subscription</b>\n\n🔗 <b>Subscription link:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Choose your device</b> to get detailed setup instructions:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Subscription link is unavailable",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ No apps found for this device",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Setup for {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Subscription link:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Recommended app:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Step 1 - Install:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Step 2 - Add subscription:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Step 3 - Connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>How to connect:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Install the app from the link above",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Copy the subscription link (tap on it)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Open the app and paste the link",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Connect to a server",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Apps for {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>How to use:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Tap the link above to copy it",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Open your VPN app",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Find the 'Add subscription' or 'Import' option",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Paste the copied link",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 If the link didn't copy, select it manually and copy.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Referral program</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Your statistics:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Invited users: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Made first top-up: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Active referrals: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Conversion: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Earned in total: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• Earned last month: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>How rewards work:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• New user receives: <b>{bonus}</b> on the first top-up from <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• You receive on the referral's first top-up: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Commission from each referral top-up: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Your referral link:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Your code:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Latest rewards:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 First top-up",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Top-up commission",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Purchase commission",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> from {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Earnings by type:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Bonuses for first top-ups: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Top-up commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Purchase commissions: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Invite friends and earn!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Your referral link:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 You have no referrals yet.\n\nShare your referral link to start earning!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Your referrals</b> (page {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Top-ups: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Earned from them: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Registered: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Activity: {days} days ago",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Activity: long ago",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Back",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Next ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Referral analytics</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Earnings by period:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Today: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• Week: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• Month: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• Quarter: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Top {count} referrals:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} rewards)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Keep growing your referral network!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Join the VPN service!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 On your first top-up from {minimum} you get {bonus} as a bonus!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Fast connection",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Servers worldwide",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Reliable protection",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Follow the link:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Share",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Invitation created!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Tap the “📤 Share” button to send the invite to any chat or copy the text below:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Balance top-up methods</b>\n\n⚠️ Automated payment methods are temporarily unavailable.\nContact support to top up your balance.\n\nChoose a top-up method:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Balance top-up methods</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Choose the payment method that suits you:",
|
||||
"PAYMENT_METHODS_FOOTER": "Choose a top-up method:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "fast and convenient",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "via YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Bank card</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "via Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Cryptocurrency</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Support team</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
|
||||
}
|
||||
450
locales/ru.json
Normal file
450
locales/ru.json
Normal file
@@ -0,0 +1,450 @@
|
||||
{
|
||||
"ACCESS_DENIED": "❌ Доступ запрещен",
|
||||
"ADD_COUNTRIES_BUTTON": "🌐 Добавить страны",
|
||||
"ADMIN_MAIN_MENU": "🏠 Главное меню",
|
||||
"ADMIN_CAMPAIGNS": "📣 Рекламные кампании",
|
||||
"ADMIN_MESSAGES": "📨 Рассылки",
|
||||
"ADMIN_MONITORING": "🔍 Мониторинг",
|
||||
"ADMIN_PANEL": "\n⚙️ <b>Административная панель</b>\n\nВыберите раздел для управления:\n",
|
||||
"ADMIN_PROMOCODES": "🎫 Промокоды",
|
||||
"ADMIN_REFERRALS": "🤝 Партнерка",
|
||||
"ADMIN_REMNAWAVE": "🖥️ Remnawave",
|
||||
"ADMIN_RULES": "📋 Правила",
|
||||
"ADMIN_STATISTICS": "📊 Статистика",
|
||||
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
|
||||
"ADMIN_PROMO_GROUPS_TITLE": "💳 <b>Промогруппы</b>",
|
||||
"ADMIN_PROMO_GROUPS_SUMMARY": "Всего групп: {count}\nВсего участников: {members}",
|
||||
"ADMIN_PROMO_GROUPS_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
|
||||
"ADMIN_PROMO_GROUPS_MEMBERS_COUNT": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
|
||||
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
|
||||
"MY_TICKETS_BUTTON": "📋 Мои тикеты",
|
||||
"CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой",
|
||||
"TICKET_PRIORITY_SELECT": "Выберите приоритет тикета:",
|
||||
"TICKET_PRIORITY_LOW": "🟢 Низкий",
|
||||
"TICKET_PRIORITY_NORMAL": "🟡 Обычный",
|
||||
"TICKET_PRIORITY_HIGH": "🟠 Высокий",
|
||||
"TICKET_PRIORITY_URGENT": "🔴 Срочный",
|
||||
"CANCEL_TICKET_CREATION": "❌ Отменить создание тикета",
|
||||
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
|
||||
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
|
||||
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:",
|
||||
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
|
||||
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
|
||||
"VIEW_TICKET": "👁️ Посмотреть тикет",
|
||||
"BACK_TO_MENU": "🏠 В главное меню",
|
||||
"TICKET_CREATION_ERROR": "❌ Произошла ошибка при создании тикета. Попробуйте позже.",
|
||||
"NO_TICKETS": "У вас пока нет тикетов.",
|
||||
"MY_TICKETS_TITLE": "📋 Ваши тикеты:",
|
||||
"TICKET_STATUS_OPEN": "Открыт",
|
||||
"TICKET_STATUS_ANSWERED": "Отвечен",
|
||||
"TICKET_STATUS_CLOSED": "Закрыт",
|
||||
"TICKET_STATUS_PENDING": "В ожидании",
|
||||
"REPLY_TO_TICKET": "💬 Ответить",
|
||||
"CLOSE_TICKET": "🔒 Закрыть тикет",
|
||||
"CANCEL_REPLY": "❌ Отменить ответ",
|
||||
"TICKET_REPLY_INPUT": "Введите ваш ответ:",
|
||||
"TICKET_REPLY_TOO_SHORT": "Ответ должен содержать минимум 5 символов. Попробуйте еще раз:",
|
||||
"TICKET_REPLY_SENT": "✅ Ваш ответ отправлен!",
|
||||
"TICKET_REPLY_ERROR": "❌ Произошла ошибка при отправке ответа. Попробуйте позже.",
|
||||
"TICKET_CLOSED": "✅ Тикет закрыт.",
|
||||
"TICKET_CLOSE_ERROR": "❌ Ошибка при закрытии тикета.",
|
||||
"TICKET_NOT_FOUND": "Тикет не найден.",
|
||||
"TICKET_CREATION_CANCELLED": "Создание тикета отменено.",
|
||||
"BACK_TO_SUPPORT": "⬅️ К поддержке",
|
||||
"TICKET_REPLY_CANCELLED": "Ответ отменен.",
|
||||
"BACK_TO_TICKETS": "⬅️ К тикетам",
|
||||
"NO_TICKETS_ADMIN": "Нет тикетов для отображения.",
|
||||
"ADMIN_TICKETS_TITLE": "🎫 Все тикеты поддержки:",
|
||||
"ADMIN_TICKET_REPLY_INPUT": "Введите ответ от поддержки:",
|
||||
|
||||
"ADMIN_TICKET_REPLY_SENT": "✅ Ответ отправлен!",
|
||||
"TICKET_MARKED_ANSWERED": "✅ Тикет отмечен как отвеченный.",
|
||||
"TICKET_UPDATE_ERROR": "❌ Ошибка при обновлении тикета.",
|
||||
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
|
||||
"TICKET_REPLY_NOTIFICATION": "🎫 Получен ответ по тикету #{ticket_id}\n\n{reply_preview}\n\nНажмите кнопку ниже, чтобы перейти к тикету:",
|
||||
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
|
||||
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
|
||||
"UNBLOCK": "✅ Разблокировать",
|
||||
"BLOCK_FOREVER": "🚫 Блок навсегда",
|
||||
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
|
||||
"TICKET_ATTACHMENTS": "📎 Вложения",
|
||||
"OPEN_TICKETS": "🔴 Открытые",
|
||||
"CLOSED_TICKETS": "🟢 Закрытые",
|
||||
"OPEN_TICKETS_HEADER": "🔴 Открытые тикеты",
|
||||
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
|
||||
"NO_ATTACHMENTS": "Вложений нет.",
|
||||
"ATTACHMENTS_SENT": "✅ Вложения отправлены.",
|
||||
"DELETE_MESSAGE": "🗑 Удалить",
|
||||
"ADMIN_USER_PROMO_GROUP_BUTTON": "👥 Промогруппа",
|
||||
"ADMIN_USER_PROMO_GROUP_TITLE": "👥 <b>Промогруппа пользователя</b>",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT": "Текущая группа: {name}",
|
||||
"ADMIN_USER_PROMO_GROUP_CURRENT_NONE": "Текущая группа: не назначена",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS": "Скидки — серверы: {servers}%, трафик: {traffic}%, устройства: {devices}%",
|
||||
"ADMIN_USER_PROMO_GROUP_DISCOUNTS_NONE": "Скидки не заданы.",
|
||||
"ADMIN_USER_PROMO_GROUP_SELECT": "Выберите промогруппу для назначения:",
|
||||
"ADMIN_USER_PROMO_GROUP_UPDATED": "✅ Промогруппа пользователя обновлена: «{name}»",
|
||||
"ADMIN_USER_PROMO_GROUP_ALREADY": "ℹ️ Пользователь уже состоит в этой промогруппе.",
|
||||
"ADMIN_USER_PROMO_GROUP_ERROR": "❌ Не удалось обновить промогруппу пользователя.",
|
||||
"ADMIN_USER_PROMO_GROUP_BACK": "⬅️ К пользователю",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_TITLE": "💳 <b>Промогруппа:</b> {name}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_MEMBERS": "Участников: {count}",
|
||||
"ADMIN_PROMO_GROUP_DETAILS_DEFAULT": "Это базовая группа.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_BUTTON": "👥 Участники",
|
||||
"ADMIN_PROMO_GROUP_EDIT_BUTTON": "✏️ Изменить",
|
||||
"ADMIN_PROMO_GROUP_DELETE_BUTTON": "🗑️ Удалить",
|
||||
"ADMIN_PROMO_GROUP_CREATE_NAME_PROMPT": "Введите название новой промогруппы:",
|
||||
"ADMIN_PROMO_GROUP_INVALID_NAME": "Название не может быть пустым.",
|
||||
"ADMIN_PROMO_GROUP_CREATE_TRAFFIC_PROMPT": "Введите скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_SERVERS_PROMPT": "Введите скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_CREATE_DEVICES_PROMPT": "Введите скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_INVALID_PERCENT": "Введите число от 0 до 100.",
|
||||
"ADMIN_PROMO_GROUP_CREATED": "Промогруппа «{name}» создана.",
|
||||
"ADMIN_PROMO_GROUP_CREATED_BACK_BUTTON": "↩️ К промогруппам",
|
||||
"ADMIN_PROMO_GROUP_EDIT_NAME_PROMPT": "Введите новое название промогруппы (текущее: {name}):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_TRAFFIC_PROMPT": "Введите новую скидку на трафик (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_SERVERS_PROMPT": "Введите новую скидку на серверы (0-100):",
|
||||
"ADMIN_PROMO_GROUP_EDIT_DEVICES_PROMPT": "Введите новую скидку на устройства (0-100):",
|
||||
"ADMIN_PROMO_GROUP_UPDATED": "Промогруппа «{name}» обновлена.",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_TITLE": "👥 Участники группы {name}",
|
||||
"ADMIN_PROMO_GROUP_MEMBERS_EMPTY": "В этой группе пока нет участников.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_FORBIDDEN": "Базовую промогруппу нельзя удалить.",
|
||||
"ADMIN_PROMO_GROUP_DELETE_CONFIRM": "Удалить промогруппу «{name}»? Все пользователи будут переведены в базовую группу.",
|
||||
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
|
||||
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
|
||||
"ADMIN_USERS": "👥 Пользователи",
|
||||
"AUTOPAY_BUTTON": "💳 Автоплатёж",
|
||||
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
|
||||
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
|
||||
"AUTOPAY_FAILED": "\n❌ <b>Ошибка автоплатежа</b>\n\nНе удалось списать средства для продления подписки.\nНедостаточно средств на балансе: {balance}\nТребуется: {required}\n\nПополните баланс и продлите подписку вручную.\n",
|
||||
"AUTOPAY_SET_DAYS_BUTTON": "⚙️ Настроить дни",
|
||||
"AUTOPAY_SUCCESS": "\n✅ <b>Автоплатеж выполнен</b>\n\nВаша подписка автоматически продлена на {days} дней.\nСписано с баланса: {amount}\n",
|
||||
"BACK": "⬅️ Назад",
|
||||
"BACK_TO_SUBSCRIPTION": "⬅️ К подписке",
|
||||
"BALANCE_BUTTON": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_DEFAULT": "💰 Баланс: {balance}",
|
||||
"BALANCE_BUTTON_ZERO": "💰 Баланс: 0 ₽",
|
||||
"BALANCE_HISTORY": "📊 История операций",
|
||||
"BALANCE_INFO": "\n💰 <b>Баланс: {balance}</b>\n\nВыберите действие:\n",
|
||||
"BALANCE_SUPPORT_REQUEST": "🛠️ Запрос через поддержку",
|
||||
"BALANCE_TOP_UP": "💳 Пополнить",
|
||||
"CAMPAIGN_EXISTING_USER": "ℹ️ Эта рекламная ссылка доступна только новым пользователям.",
|
||||
"CAMPAIGN_BONUS_BALANCE": "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!",
|
||||
"CAMPAIGN_BONUS_SUBSCRIPTION": "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!",
|
||||
"BUY_SUBSCRIPTION_START": "\n💎 <b>Настройка подписки</b>\n\nДавайте настроим вашу подписку под ваши потребности.\n\nСначала выберите период подписки:\n",
|
||||
"PROMO_GROUP_DISCOUNTS_HEADER": "🎁 <b>Скидки вашей промогруппы</b>",
|
||||
"PROMO_GROUP_DISCOUNT_SERVERS": "🌍 Серверы: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_TRAFFIC": "📊 Трафик: {percent}%",
|
||||
"PROMO_GROUP_DISCOUNT_DEVICES": "📱 Доп. устройства: {percent}%",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNTS_HEADER": "⏳ Скидки за длительный период:",
|
||||
"PROMO_GROUP_PERIOD_DISCOUNT_ITEM": "{period} — {percent}%",
|
||||
"CANCEL": "❌ Отмена",
|
||||
"CHANGE_DEVICES_BUTTON": "📱 Изменить устройства",
|
||||
"CHANGE_DEVICES_CONFIRM": "\n 📱 <b>Подтверждение изменения</b>\n\n Текущее количество: {current_devices} устройств\n Новое количество: {new_devices} устройств\n\n Действие: {action}\n 💰 {cost}\n\n Подтвердить изменение?\n ",
|
||||
"CHANGE_DEVICES_INFO": "\n 📱 <b>Изменение количества устройств</b>\n\n Текущий лимит: {current_devices} устройств\n\n Выберите новое количество устройств:\n\n 💡 <b>Важно:</b>\n • При увеличении - доплата пропорционально оставшемуся времени\n • При уменьшении - возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_DECREASE": "\n ✅ Количество устройств уменьшено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n ℹ️ Возврат средств не производится\n ",
|
||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Количество устройств увеличено!\n\n 📱 Было: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||
"CHOOSE_ANOTHER_DEVICE": "📱 Выбрать другое устройство",
|
||||
"CONFIRM": "✅ Подтвердить",
|
||||
"CONFIRM_CHANGE_BUTTON": "✅ Подтвердить изменение",
|
||||
"CONNECT_BUTTON": "🔗 Подключиться",
|
||||
"CONTACT_SUPPORT": "💬 Написать в поддержку",
|
||||
"CONTINUE": "➡️ Продолжить",
|
||||
"CONTINUE_BUTTON": "✅ Продолжить",
|
||||
"COPY_SUBSCRIPTION_LINK": "📋 Скопировать ссылку подписки",
|
||||
"CREATE_INVITE": "📝 Создать приглашение",
|
||||
"CREATE_INVITE_BUTTON": "📝 Создать приглашение",
|
||||
"DEVICES_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"DEVICES_LIMIT_EXCEEDED": "⚠️ Превышен максимальный лимит устройств ({limit})",
|
||||
"DEVICES_MINIMUM_LIMIT": "⚠️ Минимальное количество устройств: {limit}",
|
||||
"DEVICES_NO_CHANGE": "ℹ️ Количество устройств не изменилось",
|
||||
"DEVICE_CONNECTION_HELP": "❓ Как подключить устройство заново?",
|
||||
"DEVICE_GUIDE_ANDROID": "🤖 Android",
|
||||
"DEVICE_GUIDE_ANDROID_TV": "📺 Android TV",
|
||||
"DEVICE_GUIDE_IOS": "📱 iOS (iPhone/iPad)",
|
||||
"DEVICE_GUIDE_MAC": "🎯 macOS",
|
||||
"DEVICE_GUIDE_WINDOWS": "💻 Windows",
|
||||
"DISABLE_BUTTON": "❌ Выключить",
|
||||
"ENABLE_BUTTON": "✅ Включить",
|
||||
"ERROR": "❌ Произошла ошибка",
|
||||
"ERROR_TRY_AGAIN": "❌ Произошла ошибка. Попробуйте еще раз.",
|
||||
"ERROR_RULES_RETRY": "Произошла ошибка. Попробуйте принять правила еще раз:",
|
||||
"GO_TO_BALANCE_TOP_UP": "💳 Перейти к пополнению баланса",
|
||||
"RETURN_TO_SUBSCRIPTION_CHECKOUT": "⬅️ Вернуться к оформлению подписки",
|
||||
"INSUFFICIENT_BALANCE": "❌ Недостаточно средств на балансе. \n \n <b>Пополните баланс на {amount} и попробуйте снова.</b>\n ",
|
||||
"INVALID_AMOUNT": "❌ Неверная сумма",
|
||||
"LANGUAGE_SELECTED": "🌐 Язык интерфейса установлен: <b>Русский</b>",
|
||||
"LOADING": "⏳ Загрузка...",
|
||||
"MAINTENANCE_MODE_ACTIVE": "\n🔧 Технические работы!\n\nСервис временно недоступен. Ведутся технические работы по улучшению качества обслуживания.\n\n⏰ Ориентировочное время завершения: неизвестно\n🔄 Попробуйте позже\n\nПриносим извинения за временные неудобства.\n",
|
||||
"MAINTENANCE_MODE_API_ERROR": "\n🔧 Технические работы!\n\nСервис временно недоступен из-за проблем с подключением к серверам.\n\n⏰ Мы работаем над восстановлением. Попробуйте через несколько минут.\n\n🔄 Последняя проверка: {last_check}\n",
|
||||
"MAIN_MENU": "👤 <b>{user_name}</b>\n \n📱 <b>Подписка:</b> {subscription_status}\n\nВыберите действие:\n",
|
||||
"MAIN_MENU_ACTION_PROMPT": "Выберите действие:",
|
||||
"MAIN_MENU_BUTTON": "🏠 Главное меню",
|
||||
"MANAGE_DEVICES_BUTTON": "🔧 Управление устройствами",
|
||||
"MENU_ADMIN": "⚙️ Админ-панель",
|
||||
"MENU_BALANCE": "💰 Баланс",
|
||||
"MENU_BUY_SUBSCRIPTION": "💎 Купить подписку",
|
||||
"MENU_EXTEND_SUBSCRIPTION": "⏰ Продлить подписку",
|
||||
"MENU_LANGUAGE": "🌐 Язык",
|
||||
"MENU_PROMOCODE": "🎫 Промокод",
|
||||
"MENU_REFERRALS": "🤝 Партнерка",
|
||||
"MENU_RULES": "📋 Правила сервиса",
|
||||
"MENU_SUBSCRIPTION": "📱 Подписка",
|
||||
"MENU_SUPPORT": "🛠️ Техподдержка",
|
||||
"MENU_TRIAL": "🧪 Тестовая подписка",
|
||||
"MY_BALANCE_BUTTON": "💰 Мой баланс",
|
||||
"MY_SUBSCRIPTION_BUTTON": "📱 Моя подписка",
|
||||
"NO": "❌ Нет",
|
||||
"NO_SERVERS_AVAILABLE": "❌ Нет доступных серверов",
|
||||
"NO_TRAFFIC_PACKAGES": "❌ Нет доступных пакетов",
|
||||
"OPERATION_CANCELLED": "❌ Операция отменена",
|
||||
"OTHER_APPS_BUTTON": "📋 Другие приложения",
|
||||
"PAGINATION_NEXT": "➡️",
|
||||
"PAGINATION_PREV": "⬅️",
|
||||
"PAYMENTS_TEMPORARILY_UNAVAILABLE": "⚠️ Способы оплаты временно недоступны",
|
||||
"PAYMENT_CARD_TRIBUTE": "💳 Банковская карта (Tribute)",
|
||||
"PAYMENT_CARD_YOOKASSA": "💳 Банковская карта (YooKassa)",
|
||||
"PAYMENT_CRYPTOBOT": "🪙 Криптовалюта (CryptoBot)",
|
||||
"PAYMENT_SBP_YOOKASSA": "🏬 Оплатить по СБП (YooKassa)",
|
||||
"PAYMENT_TELEGRAM_STARS": "⭐ Telegram Stars",
|
||||
"PAYMENT_VIA_SUPPORT": "🛠️ Через поддержку",
|
||||
"PAY_NOW_BUTTON": "💳 Оплатить",
|
||||
"PAY_WITH_COINS_BUTTON": "🪙 Оплатить",
|
||||
"PENDING_CANCEL_BUTTON": "⌛ Отмена",
|
||||
"PERIOD_14_DAYS": "📅 14 дней - {settings.format_price(settings.PRICE_14_DAYS)}",
|
||||
"PERIOD_180_DAYS": "📅 180 дней - {settings.format_price(settings.PRICE_180_DAYS)}",
|
||||
"PERIOD_30_DAYS": "📅 30 дней - {settings.format_price(settings.PRICE_30_DAYS)}",
|
||||
"PERIOD_360_DAYS": "📅 360 дней - {settings.format_price(settings.PRICE_360_DAYS)}",
|
||||
"PERIOD_60_DAYS": "📅 60 дней - {settings.format_price(settings.PRICE_60_DAYS)}",
|
||||
"PERIOD_90_DAYS": "📅 90 дней - {settings.format_price(settings.PRICE_90_DAYS)}",
|
||||
"POST_REGISTRATION_TRIAL_BUTTON": "🚀 Подключиться бесплатно 🚀",
|
||||
"PROMOCODE_ENTER": "🎫 Введите промокод:",
|
||||
"PROMOCODE_EMPTY_INPUT": "❌ Введите корректный промокод",
|
||||
"PROMOCODE_EXPIRED": "❌ Промокод истек",
|
||||
"PROMOCODE_INVALID": "❌ Неверный промокод",
|
||||
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
|
||||
"PROMOCODE_USED": "❌ Промокод уже использован",
|
||||
"REFERRAL_ANALYTICS_BUTTON": "📊 Аналитика",
|
||||
"REFERRAL_CODE_APPLIED": "🎁 Реферальный код применен! Вы получите бонус после первой покупки.",
|
||||
"REFERRAL_CODE_ACCEPTED": "✅ Реферальный код принят!",
|
||||
"REFERRAL_CODE_INVALID": "❌ Неверный реферальный код",
|
||||
"REFERRAL_CODE_INVALID_HELP": "❌ Неверный реферальный код.\n\n💡 Если у вас есть реферальный код, убедитесь что он введен правильно.\n⏭️ Для продолжения регистрации без реферального кода используйте команду /start",
|
||||
"REFERRAL_CODE_QUESTION": "\n🤝 <b>У вас есть реферальный код от друга?</b>\n\nЕсли у вас есть промокод или реферальная ссылка от друга, введите её сейчас, чтобы получить бонус!\n\nВведите код или нажмите \"Пропустить\":\n",
|
||||
"REFERRAL_CODE_SKIP": "⏭️ Пропустить",
|
||||
"ALREADY_REGISTERED_REFERRAL": "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.",
|
||||
"REFERRAL_INFO": "\n🤝 <b>Реферальная программа</b>\n\n👥 <b>Приглашено:</b> {referrals_count} друзей\n💰 <b>Заработано:</b> {earned_amount}\n\n🔗 <b>Ваша реферальная ссылка:</b>\n<code>{referral_link}</code>\n\n🎫 <b>Ваш промокод:</b>\n<code>{referral_code}</code>\n\n💰 <b>Условия:</b>\n• За каждого друга: {registration_bonus}\n• Процент с пополнений: {commission_percent}%\n",
|
||||
"REFERRAL_INVITE_MESSAGE": "\n🎯 <b>Приглашение в VPN сервис</b>\n\nПривет! Приглашаю тебя в отличный VPN сервис!\n\n🎁 По моей ссылке ты получишь бонус: {bonus}\n\n🔗 Переходи: {link}\n🎫 Или используй промокод: {code}\n\n💪 Быстро, надежно, недорого!\n",
|
||||
"REFERRAL_LIST_BUTTON": "👥 Список рефералов",
|
||||
"RESET_ALL_DEVICES_BUTTON": "🔄 Сбросить все устройства",
|
||||
"RESET_DEVICE_CONFIRM_BUTTON": "✅ Да, сбросить это устройство",
|
||||
"RESET_TRAFFIC_BUTTON": "🔄 Сбросить трафик",
|
||||
"RULES_ACCEPT": "✅ Принимаю правила",
|
||||
"RULES_ACCEPTED_PROCESSING": "✅ Правила приняты! Завершаем регистрацию...",
|
||||
"RULES_DECLINE": "❌ Не принимаю",
|
||||
"RULES_HEADER": "📋 <b>Правила сервиса</b>",
|
||||
"RULES_REQUIRED": "❗️ Для использования сервиса необходимо принять правила!",
|
||||
"RULES_TEXT_DEFAULT": "📋 <b>Правила использования сервиса</b>\n\n1. Запрещено использовать сервис для противоправной деятельности\n2. Не распространяйте пиратский или вредоносный контент\n3. Запрещены спам и фишинг\n4. Нельзя использовать сервис для DDoS-атак\n5. Один аккаунт предназначен для одного пользователя\n6. Возвраты возможны только в исключительных случаях\n7. Администрация может заблокировать аккаунт при нарушении правил\n\n<b>Используя сервис, вы подтверждаете согласие с этими правилами.</b>",
|
||||
"SELECT_COUNTRIES": "Выберите страны:",
|
||||
"SELECT_DEVICES": "Количество устройств:",
|
||||
"SELECT_PERIOD": "Выберите период:",
|
||||
"SELECT_TRAFFIC": "Выберите пакет трафика:",
|
||||
"SEND_CONTACT_BUTTON": "📱 Отправить контакт",
|
||||
"SEND_LOCATION_BUTTON": "📍 Отправить геолокацию",
|
||||
"SHOW_QR_BUTTON": "📱 Показать QR код",
|
||||
"SHOW_SUBSCRIPTION_LINK": "📋 Показать ссылку подписки",
|
||||
"SKIP_BUTTON": "⏭️ Пропустить",
|
||||
"SUBSCRIPTION_ACTIVE": "✅ Активна",
|
||||
"SUBSCRIPTION_EXPIRED": "\n❌ <b>Подписка истекла</b>\n\nВаша подписка истекла. Для восстановления доступа продлите подписку.\n",
|
||||
"SUBSCRIPTION_EXPIRING": "\n⚠️ <b>Подписка истекает!</b>\n\nВаша подписка истекает через {days} дней.\n\nНе забудьте продлить подписку, чтобы не потерять доступ к серверам.\n",
|
||||
"SUBSCRIPTION_EXPIRING_PAID": "\n⚠️ <b>Подписка истекает через {days_text}!</b>\n\nВаша платная подписка истекает {end_date}.\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n\n{action_text}\n",
|
||||
"SUBSCRIPTION_INFO": "\n📱 <b>Информация о подписке</b>\n\n📊 <b>Статус:</b> {status}\n🎭 <b>Тип:</b> {type}\n📅 <b>Действует до:</b> {end_date}\n⏰ <b>Осталось дней:</b> {days_left}\n\n📈 <b>Трафик:</b> {traffic_used} / {traffic_limit}\n🌍 <b>Серверы:</b> {countries_count} стран\n📱 <b>Устройства:</b> {devices_used} / {devices_limit}\n\n💳 <b>Автоплатеж:</b> {autopay_status}\n",
|
||||
"SUBSCRIPTION_NONE": "❌ Нет активной подписки",
|
||||
"SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена",
|
||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки",
|
||||
"SUBSCRIPTION_SUMMARY": "\n📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",
|
||||
"SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка",
|
||||
"SUB_STATUS_ACTIVE_FEW_DAYS": "💎 Активна\n⚠️ истекает через {days} дн.",
|
||||
"SUB_STATUS_ACTIVE_LONG": "💎 Активна\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_ACTIVE_TODAY": "💎 Активна\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_ACTIVE_TOMORROW": "💎 Активна\n⚠️ истекает завтра!",
|
||||
"SUB_STATUS_EXPIRED": "🔴 Истекла\n📅 {end_date}",
|
||||
"SUB_STATUS_NONE": "❌ Отсутствует",
|
||||
"SUB_STATUS_TRIAL_ACTIVE": "🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)",
|
||||
"SUB_STATUS_TRIAL_TODAY": "🎁 Тестовая подписка\n⚠️ истекает сегодня!",
|
||||
"SUB_STATUS_TRIAL_TOMORROW": "🎁 Тестовая подписка\n⚠️ истекает завтра!",
|
||||
"SUCCESS": "✅ Успешно",
|
||||
"REGISTRATION_COMPLETING": "✅ Завершаем регистрацию...",
|
||||
"SUPPORT_INFO": "\n🛠️ <b>Техническая поддержка</b>\n\nПо всем вопросам обращайтесь к нашей поддержке:\n\n👤 {settings.SUPPORT_USERNAME}\n\nМы поможем с:\n• Настройкой подключения\n• Решением технических проблем \n• Вопросами по оплате\n• Другими вопросами\n\n⏰ Время ответа: обычно в течение 1-2 часов\n",
|
||||
"SWITCH_TRAFFIC_BUTTON": "🔄 Переключить трафик",
|
||||
"SWITCH_TRAFFIC_CONFIRM": "\n🔄 <b>Подтверждение переключения трафика</b>\n\nТекущий лимит: {current_traffic}\nНовый лимит: {new_traffic}\n\nДействие: {action}\n💰 {cost}\n\nПодтвердить переключение?\n",
|
||||
"SWITCH_TRAFFIC_INFO": "\n🔄 <b>Переключение лимита трафика</b>\n\nТекущий лимит: {current_traffic}\nВыберите новый лимит трафика:\n\n💡 <b>Важно:</b>\n• При увеличении - доплата за разницу пропорционально оставшемуся времени\n• При уменьшении - возврат средств не производится\n• Счетчик использованного трафика НЕ сбрасывается\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_DECREASE": "\n✅ Лимит трафика уменьшен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\nℹ️ Возврат средств не производится\n",
|
||||
"SWITCH_TRAFFIC_SUCCESS_INCREASE": "\n✅ Лимит трафика увеличен!\n\n📊 Было: {old_traffic} → Стало: {new_traffic}\n💰 Списано: {amount}\n",
|
||||
"SWITCH_TRAFFIC_TITLE": "🔄 Переключение лимита трафика",
|
||||
"TOPUP_BALANCE_BUTTON": "💳 Попол\\у043Dить баланс",
|
||||
"TOP_UP_AMOUNT": "💳 Введите сумму для пополнения (в рублях):",
|
||||
"TOP_UP_METHODS": "\n💳 <b>Выберите способ оплаты</b>\n\nСумма: {amount}\n",
|
||||
"TOP_UP_STARS": "⭐ Telegram Stars",
|
||||
"STARS_PAYMENT_ENROLLMENT_ERROR": "❌ Произошла ошибка при зачислении средств. Обратитесь в поддержку, платеж будет проверен вручную.",
|
||||
"STARS_PAYMENT_PROCESSING_ERROR": "❌ Техническая ошибка при обработке платежа. Обратитесь в поддержку для решения проблемы.",
|
||||
"STARS_PAYMENT_SUCCESS": "🎉 <b>Платеж успешно обработан!</b>\n\n⭐ Потрачено звезд: {stars_spent}\n💰 Зачислено на баланс: {amount} ₽\n🆔 ID транзакции: {transaction_id}...\n\nСпасибо за пополнение! 🚀",
|
||||
"STARS_PAYMENT_USER_NOT_FOUND": "❌ Ошибка: пользователь не найден. Обратитесь в поддержку.",
|
||||
"STARS_PRECHECK_INVALID_PAYLOAD": "Ошибка валидации платежа. Попробуйте еще раз.",
|
||||
"STARS_PRECHECK_TECHNICAL_ERROR": "Техническая ошибка. Попробуйте позже.",
|
||||
"STARS_PRECHECK_USER_NOT_FOUND": "Пользователь не найден. Обратитесь в поддержку.",
|
||||
"TOP_UP_TRIBUTE": "💎 Банковская карта",
|
||||
"TRAFFIC_100GB": "📊 100 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_100GB)}",
|
||||
"TRAFFIC_10GB": "📊 10 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_10GB)}",
|
||||
"TRAFFIC_250GB": "📊 250 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_250GB)}",
|
||||
"TRAFFIC_25GB": "📊 25 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_25GB)}",
|
||||
"TRAFFIC_50GB": "📊 50 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_50GB)}",
|
||||
"TRAFFIC_5GB": "📊 5 ГБ - {settings.format_price(settings.PRICE_TRAFFIC_5GB)}",
|
||||
"TRAFFIC_INSUFFICIENT_BALANCE": "⚠️ Недостаточно средств!\nТребуется: {required} (за {months} мес)\nУ вас: {balance}",
|
||||
"TRAFFIC_NO_CHANGE": "ℹ️ Лимит трафика не изменился",
|
||||
"TRAFFIC_PACKAGES_NOT_CONFIGURED": "⚠️ Пакеты трафика не настроены",
|
||||
"TRAFFIC_UNLIMITED": "📊 Безлимит - {settings.format_price(settings.PRICE_TRAFFIC_UNLIMITED)}",
|
||||
"TRIAL_ACTIVATED": "🎉 Тестовая подписка активирована!",
|
||||
"TRIAL_ACTIVATE_BUTTON": "🎁 Активировать",
|
||||
"TRIAL_ALREADY_USED": "❌ Тестовая подписка уже была использована",
|
||||
"TRIAL_AVAILABLE": "\n🎁 <b>Тестовая подписка</b>\n\nВы можете получить бесплатную тестовую подписку:\n\n⏰ <b>Период:</b> {days} дней\n📈 <b>Трафик:</b> {traffic} ГБ\n📱 <b>Устройства:</b> {devices} шт.\n🌍 <b>Сервер:</b> {server_name}\n\nАктивировать тестовую подписку?\n",
|
||||
"TRIAL_ENDING_SOON": "\n🎁 <b>Тестовая подписка скоро закончится!</b>\n\nВаша тестовая подписка истекает через несколько часов.\n\n💎 <b>Не хотите остаться без VPN?</b>\nПереходите на полную подписку!\n\n🔥 <b>Специальное предложение:</b>\n• 30 дней всего за {price}\n• Безлимитный трафик \n• Все серверы доступны\n• Скорость до 1ГБит/сек\n\n⚡️ Успейте оформить до окончания тестового периода!\n",
|
||||
"UNKNOWN_CALLBACK_ALERT": "❓ Неизвестная команда. Попробуйте ещё раз.",
|
||||
"UNKNOWN_COMMAND_MESSAGE": "❓ Не понимаю эту команду. Используйте кнопки меню.",
|
||||
"USER_NOT_FOUND": "❌ Пользователь не найден",
|
||||
"WELCOME": "\n🎉 <b>Добро пожаловать в VPN сервис!</b>\n\nНаш сервис предоставляет быстрый и безопасный доступ к интернету без ограничений.\n\n🔐 <b>Преимущества:</b>\n• Высокая скорость подключения\n• Серверы в разных странах\n• Надежная защита данных\n• Круглосуточная поддержка\n\nДля начала работы выберите язык интерфейса:\n",
|
||||
"WELCOME_FALLBACK": "Добро пожаловать, {user_name}!",
|
||||
"YES": "✅ Да",
|
||||
"SUBSCRIPTION_STATUS_EXPIRED": "Истекла",
|
||||
"SUBSCRIPTION_STATUS_TRIAL": "Тестовая",
|
||||
"SUBSCRIPTION_STATUS_ACTIVE": "Активна",
|
||||
"SUBSCRIPTION_STATUS_UNKNOWN": "Неизвестно",
|
||||
"SUBSCRIPTION_TIME_LEFT_EXPIRED": "истёк",
|
||||
"SUBSCRIPTION_TIME_LEFT_DAYS": "{days} дн.",
|
||||
"SUBSCRIPTION_TIME_LEFT_HOURS": "{hours} ч.",
|
||||
"SUBSCRIPTION_TIME_LEFT_MINUTES": "{minutes} мин.",
|
||||
"SUBSCRIPTION_WARNING_TOMORROW": "\n⚠️ истекает завтра!",
|
||||
"SUBSCRIPTION_WARNING_TODAY": "\n⚠️ истекает сегодня!",
|
||||
"SUBSCRIPTION_WARNING_MINUTES": "\n🔴 истекает через несколько минут!",
|
||||
"SUBSCRIPTION_TYPE_TRIAL": "Триал",
|
||||
"SUBSCRIPTION_TYPE_PAID": "Платная",
|
||||
"SUBSCRIPTION_TRAFFIC_UNLIMITED": "∞ (безлимит) | Использовано: {used} ГБ",
|
||||
"SUBSCRIPTION_TRAFFIC_LIMITED": "{used} / {limit} ГБ",
|
||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "<blockquote>📱 <b>Подключенные устройства:</b>\n",
|
||||
"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "</blockquote>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_SECTION": "🔗 <b>Ссылка для подключения:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_CONNECT_LINK_PROMPT": "📱 Скопируйте ссылку и добавьте в ваше VPN приложение",
|
||||
"SUBSCRIPTION_IMPORT_LINK_SECTION": "🔗 <b>Ваша ссылка для импорта в VPN приложение:</b>\n<code>{subscription_url}</code>",
|
||||
"SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT": "📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве",
|
||||
"BACK_TO_MAIN_MENU_BUTTON": "⬅️ В главное меню",
|
||||
"CUSTOM_MINIAPP_URL_NOT_SET": "⚠ Кастомная ссылка для мини-приложения не настроена",
|
||||
"SUBSCRIPTION_LINK_GENERATING_NOTICE": "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
|
||||
"SUBSCRIPTION_NO_ACTIVE_LINK": "⚠ У вас нет активной подписки или ссылка еще генерируется",
|
||||
"SUBSCRIPTION_CONNECT_MINIAPP_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🚀 Нажмите кнопку ниже, чтобы открыть подписку в мини-приложении Telegram:",
|
||||
"SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:",
|
||||
"SUBSCRIPTION_CONNECT_LINK_MESSAGE": "🚀 <b>Подключить подписку</b>\n\n🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:",
|
||||
"SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 <b>Подключить подписку</b>\n\n🔗 <b>Ссылка подписки:</b>\n<code>{subscription_url}</code>\n\n💡 <b>Выберите ваше устройство</b> для получения подробной инструкции по настройке:",
|
||||
"SUBSCRIPTION_LINK_UNAVAILABLE": "❌ Ссылка подписки недоступна",
|
||||
"SUBSCRIPTION_DEVICE_APPS_NOT_FOUND": "❌ Приложения для этого устройства не найдены",
|
||||
"SUBSCRIPTION_DEVICE_GUIDE_TITLE": "📱 <b>Настройка для {device_name}</b>",
|
||||
"SUBSCRIPTION_DEVICE_LINK_TITLE": "🔗 <b>Ссылка подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_FEATURED_APP": "📋 <b>Рекомендуемое приложение:</b> {app_name}",
|
||||
"SUBSCRIPTION_DEVICE_STEP_INSTALL_TITLE": "<b>Шаг 1 - Установка:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_ADD_TITLE": "<b>Шаг 2 - Добавление подписки:</b>",
|
||||
"SUBSCRIPTION_DEVICE_STEP_CONNECT_TITLE": "<b>Шаг 3 - Подключение:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_TITLE": "💡 <b>Как подключить:</b>",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP1": "1. Установите приложение по ссылке выше",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP2": "2. Скопируйте ссылку подписки (нажмите на неё)",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP3": "3. Откройте приложение и вставьте ссылку",
|
||||
"SUBSCRIPTION_DEVICE_HOW_TO_STEP4": "4. Подключитесь к серверу",
|
||||
"SUBSCRIPTION_APPS_TITLE": "📱 <b>Приложения для {device_name}</b>",
|
||||
"SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:",
|
||||
"SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено",
|
||||
"SUBSCRIPTION_SPECIFIC_APP_TITLE": "📱 <b>{app_name} - {device_name}</b>",
|
||||
"SUBSCRIPTION_ADDITIONAL_STEP_TITLE": "<b>{title}:</b>",
|
||||
"SUBSCRIPTION_LINK_USAGE_TITLE": "📱 <b>Как использовать:</b>",
|
||||
"SUBSCRIPTION_LINK_STEP1": "1. Нажмите на ссылку выше чтобы её скопировать",
|
||||
"SUBSCRIPTION_LINK_STEP2": "2. Откройте ваше VPN приложение",
|
||||
"SUBSCRIPTION_LINK_STEP3": "3. Найдите функцию \"Добавить подписку\" или \"Import\"",
|
||||
"SUBSCRIPTION_LINK_STEP4": "4. Вставьте скопированную ссылку",
|
||||
"SUBSCRIPTION_LINK_HINT": "💡 Если ссылка не скопировалась, выделите её вручную и скопируйте.",
|
||||
"REFERRAL_PROGRAM_TITLE": "👥 <b>Реферальная программа</b>",
|
||||
"REFERRAL_STATS_HEADER": "📊 <b>Ваша статистика:</b>",
|
||||
"REFERRAL_STATS_INVITED": "• Приглашено пользователей: <b>{count}</b>",
|
||||
"REFERRAL_STATS_FIRST_TOPUPS": "• Сделали первое пополнение: <b>{count}</b>",
|
||||
"REFERRAL_STATS_ACTIVE": "• Активных рефералов: <b>{count}</b>",
|
||||
"REFERRAL_STATS_CONVERSION": "• Конверсия: <b>{rate}%</b>",
|
||||
"REFERRAL_STATS_TOTAL_EARNED": "• Заработано всего: <b>{amount}</b>",
|
||||
"REFERRAL_STATS_MONTH_EARNED": "• За последний месяц: <b>{amount}</b>",
|
||||
"REFERRAL_REWARDS_HEADER": "🎁 <b>Как работают награды:</b>",
|
||||
"REFERRAL_REWARD_NEW_USER": "• Новый пользователь получает: <b>{bonus}</b> при первом пополнении от <b>{minimum}</b>",
|
||||
"REFERRAL_REWARD_INVITER": "• Вы получаете при первом пополнении реферала: <b>{bonus}</b>",
|
||||
"REFERRAL_REWARD_COMMISSION": "• Комиссия с каждого пополнения реферала: <b>{percent}%</b>",
|
||||
"REFERRAL_LINK_TITLE": "🔗 <b>Ваша реферальная ссылка:</b>",
|
||||
"REFERRAL_CODE_TITLE": "🆔 <b>Ваш код:</b> <code>{code}</code>",
|
||||
"REFERRAL_RECENT_EARNINGS_HEADER": "💰 <b>Последние начисления:</b>",
|
||||
"REFERRAL_EARNING_REASON_FIRST_TOPUP": "🎉 Первое пополнение",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_TOPUP": "💰 Комиссия с пополнения",
|
||||
"REFERRAL_EARNING_REASON_COMMISSION_PURCHASE": "💰 Комиссия с покупки",
|
||||
"REFERRAL_RECENT_EARNINGS_ITEM": "• {reason}: <b>{amount}</b> от {referral_name}",
|
||||
"REFERRAL_EARNINGS_BY_TYPE_HEADER": "📈 <b>Доходы по типам:</b>",
|
||||
"REFERRAL_EARNINGS_FIRST_TOPUPS": "• Бонусы за первые пополнения: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_TOPUPS": "• Комиссии с пополнений: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_EARNINGS_PURCHASES": "• Комиссии с покупок: <b>{count}</b> ({amount})",
|
||||
"REFERRAL_INVITE_FOOTER": "📢 Приглашайте друзей и зарабатывайте!",
|
||||
"REFERRAL_LINK_CAPTION": "🔗 Ваша реферальная ссылка:\n{link}",
|
||||
"REFERRAL_LIST_EMPTY": "📋 У вас пока нет рефералов.\n\nПоделитесь своей реферальной ссылкой, чтобы начать зарабатывать!",
|
||||
"REFERRAL_LIST_HEADER": "👥 <b>Ваши рефералы</b> (стр. {current}/{total})",
|
||||
"REFERRAL_LIST_ITEM_HEADER": "{index}. {status} <b>{name}</b>",
|
||||
"REFERRAL_LIST_ITEM_TOPUPS": " {emoji} Пополнений: {count}",
|
||||
"REFERRAL_LIST_ITEM_EARNED": " 💎 Заработано с него: {amount}",
|
||||
"REFERRAL_LIST_ITEM_REGISTERED": " 📅 Регистрация: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY": " 🕐 Активность: {days} дн. назад",
|
||||
"REFERRAL_LIST_ITEM_ACTIVITY_LONG_AGO": " 🕐 Активность: давно",
|
||||
"REFERRAL_LIST_PREV_PAGE": "⬅️ Назад",
|
||||
"REFERRAL_LIST_NEXT_PAGE": "Вперед ➡️",
|
||||
"REFERRAL_ANALYTICS_TITLE": "📊 <b>Аналитика рефералов</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_HEADER": "💰 <b>Доходы по периодам:</b>",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_TODAY": "• Сегодня: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_WEEK": "• За неделю: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_MONTH": "• За месяц: {amount}",
|
||||
"REFERRAL_ANALYTICS_EARNINGS_QUARTER": "• За квартал: {amount}",
|
||||
"REFERRAL_ANALYTICS_TOP_TITLE": "🏆 <b>Топ-{count} рефералов:</b>",
|
||||
"REFERRAL_ANALYTICS_TOP_ITEM": "{index}. {name}: {amount} ({count} начислений)",
|
||||
"REFERRAL_ANALYTICS_FOOTER": "📈 Продолжайте развивать свою реферальную сеть!",
|
||||
"REFERRAL_INVITE_TITLE": "🎉 Присоединяйся к VPN сервису!",
|
||||
"REFERRAL_INVITE_BONUS": "💎 При первом пополнении от {minimum} ты получишь {bonus} бонусом на баланс!",
|
||||
"REFERRAL_INVITE_FEATURE_FAST": "🚀 Быстрое подключение",
|
||||
"REFERRAL_INVITE_FEATURE_SERVERS": "🌍 Серверы по всему миру",
|
||||
"REFERRAL_INVITE_FEATURE_SECURE": "🔒 Надежная защита",
|
||||
"REFERRAL_INVITE_LINK_PROMPT": "👇 Переходи по ссылке:",
|
||||
"REFERRAL_SHARE_BUTTON": "📤 Поделиться",
|
||||
"REFERRAL_INVITE_CREATED_TITLE": "📝 <b>Приглашение создано!</b>",
|
||||
"REFERRAL_INVITE_CREATED_INSTRUCTION": "Нажмите кнопку «📤 Поделиться» чтобы отправить приглашение в любой чат, или скопируйте текст ниже:",
|
||||
"PAYMENT_METHODS_ONLY_SUPPORT": "💳 <b>Способы пополнения баланса</b>\n\n⚠️ В данный момент автоматические способы оплаты временно недоступны.\nОбратитесь в техподдержку для пополнения баланса.\n\nВыберите способ пополнения:",
|
||||
"PAYMENT_METHODS_TITLE": "💳 <b>Способы пополнения баланса</b>",
|
||||
"PAYMENT_METHODS_PROMPT": "Выберите удобный для вас способ оплаты:",
|
||||
"PAYMENT_METHODS_FOOTER": "Выберите способ пополнения:",
|
||||
"PAYMENT_METHOD_STARS_NAME": "⭐ <b>Telegram Stars</b>",
|
||||
"PAYMENT_METHOD_STARS_DESCRIPTION": "быстро и удобно",
|
||||
"PAYMENT_METHOD_YOOKASSA_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_YOOKASSA_DESCRIPTION": "через YooKassa",
|
||||
"PAYMENT_METHOD_TRIBUTE_NAME": "💳 <b>Банковская карта</b>",
|
||||
"PAYMENT_METHOD_TRIBUTE_DESCRIPTION": "через Tribute",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_NAME": "🪙 <b>Криптовалюта</b>",
|
||||
"PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot",
|
||||
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
|
||||
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
|
||||
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
}
|
||||
Reference in New Issue
Block a user