feat: модерация, обновленное меню тикетов, SLA и управление уведомлениями

This commit is contained in:
PEDZEO
2025-09-23 15:39:16 +03:00
parent 93668da99f
commit 15bda0560a
17 changed files with 1191 additions and 111 deletions

View File

@@ -16,6 +16,11 @@ class Settings(BaseSettings):
SUPPORT_MENU_ENABLED: bool = True
SUPPORT_SYSTEM_MODE: str = "both" # one of: tickets, contact, both
SUPPORT_MENU_ENABLED: bool = True
# SLA for support tickets
SUPPORT_TICKET_SLA_ENABLED: bool = True
SUPPORT_TICKET_SLA_MINUTES: int = 5
SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS: int = 60
SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES: int = 15
ADMIN_NOTIFICATIONS_ENABLED: bool = False
ADMIN_NOTIFICATIONS_CHAT_ID: Optional[str] = None

View File

@@ -4,7 +4,7 @@ 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
from app.database.models import Ticket, TicketMessage, TicketStatus, User, SupportAuditLog
class TicketCRUD:
@@ -87,6 +87,40 @@ class TicketCRUD:
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def count_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str]
) -> int:
"""Подсчитать количество тикетов пользователя по списку статусов"""
query = select(func.count()).select_from(Ticket).where(Ticket.user_id == user_id)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return int(result.scalar() or 0)
@staticmethod
async def get_user_tickets_by_statuses(
db: AsyncSession,
user_id: int,
statuses: List[str],
limit: int = 20,
offset: int = 0
) -> List[Ticket]:
"""Получить тикеты пользователя по списку статусов с пагинацией"""
query = (
select(Ticket)
.where(Ticket.user_id == user_id)
.order_by(desc(Ticket.updated_at))
.offset(offset)
.limit(limit)
)
if statuses:
query = query.where(Ticket.status.in_(statuses))
result = await db.execute(query)
return result.scalars().all()
@staticmethod
async def user_has_active_ticket(
db: AsyncSession,
@@ -239,6 +273,54 @@ class TicketCRUD:
return await TicketCRUD.update_ticket_status(
db, ticket_id, TicketStatus.CLOSED.value, datetime.utcnow()
)
@staticmethod
async def add_support_audit(
db: AsyncSession,
*,
actor_user_id: Optional[int],
actor_telegram_id: int,
is_moderator: bool,
action: str,
ticket_id: Optional[int] = None,
target_user_id: Optional[int] = None,
details: Optional[dict] = None,
) -> None:
try:
log = SupportAuditLog(
actor_user_id=actor_user_id,
actor_telegram_id=actor_telegram_id,
is_moderator=bool(is_moderator),
action=action,
ticket_id=ticket_id,
target_user_id=target_user_id,
details=details or {},
)
db.add(log)
await db.commit()
except Exception:
await db.rollback()
# не мешаем основной логике
pass
@staticmethod
async def list_support_audit(
db: AsyncSession,
*,
limit: int = 50,
offset: int = 0,
) -> List[SupportAuditLog]:
from sqlalchemy import select, desc
result = await db.execute(
select(SupportAuditLog).order_by(desc(SupportAuditLog.created_at)).offset(offset).limit(limit)
)
return result.scalars().all()
@staticmethod
async def count_support_audit(db: AsyncSession) -> int:
from sqlalchemy import select, func
result = await db.execute(select(func.count()).select_from(SupportAuditLog))
return int(result.scalar() or 0)
@staticmethod
async def get_open_tickets_count(db: AsyncSession) -> int:
@@ -291,6 +373,14 @@ class TicketMessageCRUD:
else:
# Пользователь ответил - тикет открыт
ticket.status = TicketStatus.OPEN.value
# Сбросить отметку последнего SLA-напоминания, чтобы снова напоминать от времени нового сообщения
try:
from sqlalchemy import inspect as sa_inspect
# если колонка существует в модели
if hasattr(ticket, 'last_sla_reminder_at'):
ticket.last_sla_reminder_at = None
except Exception:
pass
ticket.updated_at = datetime.utcnow()

View File

@@ -703,6 +703,23 @@ class SubscriptionServer(Base):
subscription = relationship("Subscription", backref="subscription_servers")
server_squad = relationship("ServerSquad", backref="subscription_servers")
class SupportAuditLog(Base):
__tablename__ = "support_audit_logs"
id = Column(Integer, primary_key=True, index=True)
actor_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
actor_telegram_id = Column(BigInteger, nullable=False)
is_moderator = Column(Boolean, default=False)
action = Column(String(50), nullable=False) # close_ticket, block_user_timed, block_user_perm, unblock_user
ticket_id = Column(Integer, ForeignKey("tickets.id", ondelete="SET NULL"), nullable=True)
target_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
actor = relationship("User", foreign_keys=[actor_user_id])
ticket = relationship("Ticket", foreign_keys=[ticket_id])
class UserMessage(Base):
__tablename__ = "user_messages"
id = Column(Integer, primary_key=True, index=True)
@@ -810,6 +827,8 @@ class Ticket(Base):
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
closed_at = Column(DateTime, nullable=True)
# SLA reminders
last_sla_reminder_at = Column(DateTime, nullable=True)
# Связи
user = relationship("User", backref="tickets")

View File

@@ -815,6 +815,30 @@ async def add_ticket_reply_block_columns():
logger.error(f"Ошибка добавления колонок блокировок в tickets: {e}")
return False
async def add_ticket_sla_columns():
try:
col_exists = await check_column_exists('tickets', 'last_sla_reminder_at')
if col_exists:
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL"
elif db_type == 'postgresql':
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at TIMESTAMP NULL"
elif db_type == 'mysql':
alter_sql = "ALTER TABLE tickets ADD COLUMN last_sla_reminder_at DATETIME NULL"
else:
logger.error(f"Неподдерживаемый тип БД для добавления last_sla_reminder_at: {db_type}")
return False
await conn.execute(text(alter_sql))
logger.info("✅ Добавлена колонка tickets.last_sla_reminder_at")
return True
except Exception as e:
logger.error(f"Ошибка добавления SLA колонки в tickets: {e}")
return False
async def fix_foreign_keys_for_user_deletion():
try:
async with engine.begin() as conn:
@@ -1107,6 +1131,79 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с добавлением полей блокировок в tickets")
logger.info("=== ДОБАВЛЕНИЕ ПОЛЕЙ SLA В TICKETS ===")
sla_cols_added = await add_ticket_sla_columns()
if sla_cols_added:
logger.info("✅ Поля SLA в tickets готовы")
else:
logger.warning("⚠️ Проблемы с добавлением полей SLA в tickets")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ АУДИТА ПОДДЕРЖКИ ===")
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if not await check_table_exists('support_audit_logs'):
if db_type == 'sqlite':
create_sql = """
CREATE TABLE support_audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER NULL,
actor_telegram_id BIGINT NOT NULL,
is_moderator BOOLEAN NOT NULL DEFAULT 0,
action VARCHAR(50) NOT NULL,
ticket_id INTEGER NULL,
target_user_id INTEGER NULL,
details JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (actor_user_id) REFERENCES users(id),
FOREIGN KEY (ticket_id) REFERENCES tickets(id),
FOREIGN KEY (target_user_id) REFERENCES users(id)
);
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE support_audit_logs (
id SERIAL PRIMARY KEY,
actor_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
actor_telegram_id BIGINT NOT NULL,
is_moderator BOOLEAN NOT NULL DEFAULT FALSE,
action VARCHAR(50) NOT NULL,
ticket_id INTEGER NULL REFERENCES tickets(id) ON DELETE SET NULL,
target_user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
details JSON NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
"""
else:
create_sql = """
CREATE TABLE support_audit_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
actor_user_id INT NULL,
actor_telegram_id BIGINT NOT NULL,
is_moderator BOOLEAN NOT NULL DEFAULT 0,
action VARCHAR(50) NOT NULL,
ticket_id INT NULL,
target_user_id INT NULL,
details JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_support_audit_logs_ticket ON support_audit_logs(ticket_id);
CREATE INDEX idx_support_audit_logs_actor ON support_audit_logs(actor_telegram_id);
CREATE INDEX idx_support_audit_logs_action ON support_audit_logs(action);
"""
await conn.execute(text(create_sql))
logger.info("✅ Таблица support_audit_logs создана")
else:
logger.info(" Таблица support_audit_logs уже существует")
except Exception as e:
logger.warning(f"⚠️ Проблемы с созданием таблицы support_audit_logs: {e}")
logger.info("=== НАСТРОЙКА ПРОМО ГРУПП ===")
promo_groups_ready = await ensure_promo_groups_setup()
if promo_groups_ready:

View File

@@ -10,14 +10,18 @@ from app.keyboards.admin import (
get_admin_users_submenu_keyboard,
get_admin_promo_submenu_keyboard,
get_admin_communications_submenu_keyboard,
get_admin_support_submenu_keyboard,
get_admin_settings_submenu_keyboard,
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.services.support_settings_service import SupportSettingsService
from app.database.crud.rules import clear_all_rules, get_rules_statistics
from app.localization.texts import clear_rules_cache
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from app.database.crud.ticket import TicketCRUD
logger = logging.getLogger(__name__)
@@ -113,6 +117,115 @@ async def show_communications_submenu(
await callback.answer()
@admin_required
@error_handler
async def show_support_submenu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
texts = get_texts(db_user.language)
# Moderators have access only to tickets and not to settings
is_moderator_only = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
from app.keyboards.admin import get_admin_support_submenu_keyboard
kb = get_admin_support_submenu_keyboard(db_user.language)
if is_moderator_only:
# Rebuild keyboard to include only tickets and back to main menu
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ Назад", callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🛟 **Поддержка**\n\n" + ("Доступ к тикетам." if is_moderator_only else "Управление тикетами и настройками поддержки:"),
reply_markup=kb,
parse_mode="Markdown"
)
await callback.answer()
# Moderator panel entry (from main menu quick button)
async def show_moderator_panel(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")]
])
await callback.message.edit_text(
"🧑‍⚖️ <b>Модерация поддержки</b>\n\nДоступ к тикетам поддержки.",
parse_mode="HTML",
reply_markup=kb
)
await callback.answer()
@admin_required
@error_handler
async def show_support_audit(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession
):
# pagination
page = 1
if callback.data.startswith("admin_support_audit_page_"):
try:
page = int(callback.data.split("_")[-1])
except Exception:
page = 1
per_page = 10
total = await TicketCRUD.count_support_audit(db)
total_pages = max(1, (total + per_page - 1) // per_page)
if page < 1:
page = 1
if page > total_pages:
page = total_pages
offset = (page - 1) * per_page
logs = await TicketCRUD.list_support_audit(db, limit=per_page, offset=offset)
lines = ["🧾 <b>Аудит модераторов</b>", ""]
if not logs:
lines.append("Пока пусто")
else:
for log in logs:
role = "Модератор" if getattr(log, 'is_moderator', False) else "Админ"
ts = log.created_at.strftime('%d.%m.%Y %H:%M') if getattr(log, 'created_at', None) else ''
action_map = {
'close_ticket': 'Закрытие тикета',
'block_user_timed': 'Блокировка (время)',
'block_user_perm': 'Блокировка (навсегда)',
'unblock_user': 'Снятие блока',
}
action_text = action_map.get(log.action, log.action)
ticket_part = f" тикет #{log.ticket_id}" if log.ticket_id else ""
details = log.details or {}
extra = ""
if log.action == 'block_user_timed' and 'minutes' in details:
extra = f" ({details['minutes']} мин)"
lines.append(f"{ts}{role} <code>{log.actor_telegram_id}</code> — {action_text}{ticket_part}{extra}")
# keyboard with pagination
nav_row = []
if total_pages > 1:
if page > 1:
nav_row.append(InlineKeyboardButton(text="⬅️", callback_data=f"admin_support_audit_page_{page-1}"))
nav_row.append(InlineKeyboardButton(text=f"{page}/{total_pages}", callback_data="current_page"))
if page < total_pages:
nav_row.append(InlineKeyboardButton(text="➡️", callback_data=f"admin_support_audit_page_{page+1}"))
kb_rows = []
if nav_row:
kb_rows.append(nav_row)
kb_rows.append([InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_support")])
kb = InlineKeyboardMarkup(inline_keyboard=kb_rows)
await callback.message.edit_text("\n".join(lines), parse_mode="HTML", reply_markup=kb)
await callback.answer()
@admin_required
@error_handler
async def show_settings_submenu(
@@ -285,6 +398,15 @@ def register_handlers(dp: Dispatcher):
F.data == "admin_submenu_communications"
)
dp.callback_query.register(
show_support_submenu,
F.data == "admin_submenu_support"
)
dp.callback_query.register(
show_support_audit,
F.data.in_(["admin_support_audit"]) | F.data.startswith("admin_support_audit_page_")
)
dp.callback_query.register(
show_settings_submenu,
F.data == "admin_submenu_settings"
@@ -294,6 +416,10 @@ def register_handlers(dp: Dispatcher):
show_system_submenu,
F.data == "admin_submenu_system"
)
dp.callback_query.register(
show_moderator_panel,
F.data == "moderator_panel"
)
# Support settings module
support_settings_handlers.register_handlers(dp)

View File

@@ -7,6 +7,7 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import User
from app.config import settings
from app.localization.texts import get_texts
from app.utils.decorators import admin_required, error_handler
from app.services.support_settings_service import SupportSettingsService
@@ -20,6 +21,10 @@ 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()
admin_notif = SupportSettingsService.get_admin_ticket_notifications_enabled()
user_notif = SupportSettingsService.get_user_ticket_notifications_enabled()
sla_enabled = SupportSettingsService.get_sla_enabled()
sla_minutes = SupportSettingsService.get_sla_minutes()
rows: list[list[types.InlineKeyboardButton]] = []
@@ -40,8 +45,53 @@ def _get_support_settings_keyboard(language: str) -> types.InlineKeyboardMarkup:
types.InlineKeyboardButton(text="📝 Изменить описание", callback_data="admin_support_edit_desc")
])
# Notifications block
rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
types.InlineKeyboardButton(
text=("🔔 Админ-уведомления: Включены" if admin_notif else "🔕 Админ-уведомления: Отключены"),
callback_data="admin_support_toggle_admin_notifications"
)
])
rows.append([
types.InlineKeyboardButton(
text=("🔔 Пользовательские уведомления: Включены" if user_notif else "🔕 Пользовательские уведомления: Отключены"),
callback_data="admin_support_toggle_user_notifications"
)
])
# SLA block
rows.append([
types.InlineKeyboardButton(
text=("⏰ SLA: Включено" if sla_enabled else "⏹️ SLA: Отключено"),
callback_data="admin_support_toggle_sla"
)
])
rows.append([
types.InlineKeyboardButton(
text=f"⏳ Время SLA: {sla_minutes} мин",
callback_data="admin_support_set_sla_minutes"
)
])
# Moderators
moderators = SupportSettingsService.get_moderators()
mod_count = len(moderators)
rows.append([
types.InlineKeyboardButton(
text=f"🧑‍⚖️ Модераторы: {mod_count}", callback_data="admin_support_list_moderators"
)
])
rows.append([
types.InlineKeyboardButton(
text=" Назначить модератора", callback_data="admin_support_add_moderator"
),
types.InlineKeyboardButton(
text=" Удалить модератора", callback_data="admin_support_remove_moderator"
)
])
rows.append([
types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_support")
])
return types.InlineKeyboardMarkup(inline_keyboard=rows)
@@ -78,6 +128,175 @@ async def toggle_support_menu(
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_admin_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_admin_ticket_notifications_enabled()
SupportSettingsService.set_admin_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_user_notifications(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_user_ticket_notifications_enabled()
SupportSettingsService.set_user_ticket_notifications_enabled(not current)
await show_support_settings(callback, db_user, db)
@admin_required
@error_handler
async def toggle_sla(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
current = SupportSettingsService.get_sla_enabled()
SupportSettingsService.set_sla_enabled(not current)
await show_support_settings(callback, db_user, db)
from app.states import SupportSettingsStates
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportSettingsStates.waiting_for_desc) # temporary reuse replaced below
# we'll manage separate state below
from aiogram.fsm.state import State, StatesGroup
class SupportAdvancedStates(StatesGroup):
waiting_for_sla_minutes = State()
waiting_for_moderator_id = State()
@admin_required
@error_handler
async def start_set_sla_minutes(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"⏳ <b>Настройка SLA</b>\n\nВведите количество минут ожидания ответа (целое число > 0):",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_sla_minutes)
await callback.answer()
@admin_required
@error_handler
async def handle_sla_minutes(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
text = (message.text or "").strip()
try:
minutes = int(text)
if minutes <= 0 or minutes > 1440:
raise ValueError()
except Exception:
await message.answer("❌ Введите корректное число минут (1-1440)")
return
SupportSettingsService.set_sla_minutes(minutes)
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
await message.answer("✅ Значение SLA сохранено", reply_markup=markup)
@admin_required
@error_handler
async def start_add_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"🧑‍⚖️ <b>Назначение модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
await callback.answer()
@admin_required
@error_handler
async def handle_add_moderator(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
return
if SupportSettingsService.add_moderator(tid):
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
await message.answer(f"✅ Пользователь {tid} назначен модератором", reply_markup=markup)
else:
await message.answer("Не удалось сохранить")
await state.clear()
@admin_required
@error_handler
async def start_remove_moderator(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
await callback.message.edit_text(
"🧑‍⚖️ <b>Удаление модератора</b>\n\nОтправьте Telegram ID пользователя (число)",
parse_mode="HTML",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
)
await state.set_state(SupportAdvancedStates.waiting_for_moderator_id)
# We'll reuse the same state; next message will decide action via flag
await state.update_data(action="remove_moderator")
await callback.answer()
@admin_required
@error_handler
async def handle_moderator_id(message: types.Message, db_user: User, db: AsyncSession, state: FSMContext):
data = await state.get_data()
action = data.get("action", "add")
text = (message.text or "").strip()
try:
tid = int(text)
except Exception:
await message.answer("❌ Введите корректный Telegram ID (число)")
return
ok = False
if action == "remove_moderator":
ok = SupportSettingsService.remove_moderator(tid)
msg = "✅ Модератор удалён" if ok else "Не удалось удалить"
else:
ok = SupportSettingsService.add_moderator(tid)
msg = "✅ Пользователь назначен модератором" if ok else "Не удалось назначить"
await state.clear()
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
await message.answer(msg, reply_markup=markup)
@admin_required
@error_handler
async def list_moderators(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
moderators = SupportSettingsService.get_moderators()
if not moderators:
await callback.answer("Список пуст", show_alert=True)
return
text = "🧑‍⚖️ <b>Модераторы</b>\n\n" + "\n".join([f"• <code>{tid}</code>" for tid in moderators])
markup = types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_support_settings")]]
)
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=markup)
await callback.answer()
@admin_required
@error_handler
async def set_mode_tickets(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
@@ -177,9 +396,17 @@ async def send_desc_copy(callback: types.CallbackQuery, db_user: User, db: Async
await callback.answer("Текст отправлен ниже")
@admin_required
@error_handler
async def delete_sent_message(callback: types.CallbackQuery, db_user: User, db: AsyncSession):
# Allow admins and moderators to delete informational notifications
try:
may_delete = (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id))
except Exception:
may_delete = False
if not may_delete:
texts = get_texts(db_user.language if db_user else 'ru')
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
try:
await callback.message.delete()
finally:
@@ -196,6 +423,15 @@ def register_handlers(dp: Dispatcher):
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.callback_query.register(toggle_admin_notifications, F.data == "admin_support_toggle_admin_notifications")
dp.callback_query.register(toggle_user_notifications, F.data == "admin_support_toggle_user_notifications")
dp.callback_query.register(toggle_sla, F.data == "admin_support_toggle_sla")
dp.callback_query.register(start_set_sla_minutes, F.data == "admin_support_set_sla_minutes")
dp.callback_query.register(start_add_moderator, F.data == "admin_support_add_moderator")
dp.callback_query.register(start_remove_moderator, F.data == "admin_support_remove_moderator")
dp.callback_query.register(list_moderators, F.data == "admin_support_list_moderators")
dp.message.register(handle_new_desc, SupportSettingsStates.waiting_for_desc)
dp.message.register(handle_sla_minutes, SupportAdvancedStates.waiting_for_sla_minutes)
dp.message.register(handle_moderator_id, SupportAdvancedStates.waiting_for_moderator_id)

View File

@@ -1,6 +1,7 @@
import logging
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from aiogram import Dispatcher, types, F, Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc, and_
@@ -18,6 +19,7 @@ from app.keyboards.inline import (
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.services.support_settings_service import SupportSettingsService
from app.config import settings
from app.utils.cache import RateLimitCache
@@ -33,6 +35,11 @@ async def show_admin_tickets(
db: AsyncSession
):
"""Показать все тикеты для админов"""
# permission gate: admin or active moderator only
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
texts = get_texts(db_user.language)
# Определяем текущую страницу и scope
@@ -59,6 +66,8 @@ async def show_admin_tickets(
# 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 < 1:
current_page = 1
if current_page > total_pages:
current_page = total_pages
offset = (current_page - 1) * page_size
@@ -81,9 +90,33 @@ async def show_admin_tickets(
})
# Итоговые страницы уже посчитаны выше
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)
header_text = (
texts.t("ADMIN_TICKETS_TITLE_OPEN", "🎫 Открытые тикеты поддержки:")
if scope == "open"
else texts.t("ADMIN_TICKETS_TITLE_CLOSED", "🎫 Закрытые тикеты поддержки:")
)
# Determine proper back target for moderators
back_cb = "admin_submenu_support"
try:
if not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id):
back_cb = "moderator_panel"
except Exception:
pass
keyboard = get_admin_tickets_keyboard(
ticket_data,
current_page=current_page,
total_pages=total_pages,
language=db_user.language,
scope=scope,
back_callback=back_cb,
)
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=header_text,
keyboard=keyboard,
parse_mode="HTML",
)
await callback.answer()
@@ -92,10 +125,29 @@ async def view_admin_ticket(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext
state: Optional[FSMContext] = None
):
"""Показать детали тикета для админа"""
ticket_id = int(callback.data.replace("admin_view_ticket_", ""))
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
data_str = callback.data or ""
ticket_id = None
try:
if data_str.startswith("admin_view_ticket_"):
ticket_id = int(data_str.replace("admin_view_ticket_", ""))
else:
ticket_id = int(data_str.split("_")[-1])
except Exception:
ticket_id = None
if ticket_id is None:
texts = get_texts(db_user.language)
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, load_user=True)
@@ -145,9 +197,10 @@ async def view_admin_ticket(
# Добавим кнопку "Вложения", если есть фото
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
ticket_id,
ticket.is_closed,
db_user.language,
is_user_blocked=ticket.is_user_reply_blocked
)
if has_photos:
try:
@@ -155,23 +208,20 @@ async def view_admin_ticket(
except Exception:
pass
# Сначала пробуем отредактировать; если не вышло — удалим и отправим новое
try:
await callback.message.edit_text(
ticket_text,
reply_markup=keyboard,
)
except Exception:
# Рендер через фото-утилиту (с логотипом), внутри есть фоллбеки на текст
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=ticket_text,
keyboard=keyboard,
parse_mode="HTML",
)
# сохраняем id для дальнейших действий (ответ/статусы)
if state is not None:
try:
await callback.message.delete()
await state.update_data(ticket_id=ticket_id)
except Exception:
pass
await callback.message.answer(
ticket_text,
reply_markup=keyboard,
)
# сохраняем id для дальнейших действий (ответ/статусы)
await state.update_data(ticket_id=ticket_id)
await callback.answer()
@@ -181,6 +231,10 @@ async def reply_to_admin_ticket(
db_user: User
):
"""Начать ответ на тикет от админа"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
ticket_id = int(callback.data.replace("admin_reply_ticket_", ""))
await state.update_data(ticket_id=ticket_id, reply_mode=True)
@@ -200,6 +254,11 @@ async def handle_admin_ticket_reply(
db_user: User,
db: AsyncSession
):
if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)):
texts = get_texts(db_user.language)
await message.answer(texts.ACCESS_DENIED)
await state.clear()
return
# Проверяем, что пользователь в правильном состоянии
current_state = await state.get_state()
if current_state != AdminTicketStates.waiting_for_reply:
@@ -373,17 +432,42 @@ async def close_admin_ticket(
db: AsyncSession
):
"""Закрыть тикет админом"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
ticket_id = int(callback.data.replace("admin_close_ticket_", ""))
try:
success = await TicketCRUD.close_ticket(db, ticket_id)
if success:
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_mod,
action="close_ticket",
ticket_id=ticket_id,
target_user_id=None,
details={}
)
except Exception:
pass
texts = get_texts(db_user.language)
await callback.answer(
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
show_alert=True
)
# Notify with deletable inline message
try:
await callback.message.answer(
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
)
except Exception:
await callback.answer(texts.t("TICKET_CLOSED", "✅ Тикет закрыт."), show_alert=True)
# Обновляем inline-клавиатуру в текущем сообщении без кнопок действий
await callback.message.edit_reply_markup(
@@ -411,6 +495,10 @@ async def cancel_admin_ticket_reply(
db_user: User
):
"""Отменить ответ админа на тикет"""
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
await state.clear()
texts = get_texts(db_user.language)
@@ -433,13 +521,22 @@ async def block_user_in_ticket(
db_user: User,
db: AsyncSession
):
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
ticket_id = int(callback.data.replace("admin_block_user_ticket_", ""))
texts = get_texts(db_user.language)
# Save original ticket message ids to update it after blocking without reopening
try:
await state.update_data(origin_chat_id=callback.message.chat.id, origin_message_id=callback.message.message_id)
except Exception:
pass
await callback.message.edit_text(
texts.t("ENTER_BLOCK_MINUTES", "Введите количество минут для блокировки пользователя (например, 15):"),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(
text=texts.t("CANCEL_REPLY", "❌ Отменить ответ"),
text=texts.t("CANCEL_REPLY", "❌ Отменить ввод"),
callback_data="cancel_admin_ticket_reply"
)]
])
@@ -455,6 +552,12 @@ async def handle_admin_block_duration_input(
db_user: User,
db: AsyncSession
):
# permission gate for message flow
if not (settings.is_admin(message.from_user.id) or SupportSettingsService.is_moderator(message.from_user.id)):
texts = get_texts(db_user.language)
await message.answer(texts.ACCESS_DENIED)
await state.clear()
return
# Проверяем состояние
current_state = await state.get_state()
if current_state != AdminTicketStates.waiting_for_block_duration:
@@ -467,6 +570,8 @@ async def handle_admin_block_duration_input(
data = await state.get_data()
ticket_id = data.get("ticket_id")
origin_chat_id = data.get("origin_chat_id")
origin_message_id = data.get("origin_message_id")
try:
minutes = int(reply_text)
minutes = max(1, min(60*24*365, minutes)) # максимум 1 год
@@ -490,15 +595,76 @@ async def handle_admin_block_duration_input(
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:
if not ok:
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}")]])
)
return
# audit
try:
is_mod = (not settings.is_admin(message.from_user.id) and SupportSettingsService.is_moderator(message.from_user.id))
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=message.from_user.id,
is_moderator=is_mod,
action="block_user_timed",
ticket_id=ticket_id,
target_user_id=ticket.user_id if ticket else None,
details={"minutes": minutes}
)
except Exception:
pass
# Refresh original ticket card (caption/text and buttons) in place
try:
updated = await TicketCRUD.get_ticket_by_id(db, ticket_id, load_messages=True, load_user=True)
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(updated.status, updated.status)
user_name = updated.user.full_name if updated.user else "Unknown"
ticket_text = f"🎫 Тикет #{updated.id}\n\n"
ticket_text += f"👤 Пользователь: {user_name}\n"
ticket_text += f"📝 Заголовок: {updated.title}\n"
ticket_text += f"📊 Статус: {updated.status_emoji} {status_text}\n"
ticket_text += f"📅 Создан: {updated.created_at.strftime('%d.%m.%Y %H:%M')}\n"
ticket_text += f"🔄 Обновлен: {updated.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n"
if updated.is_user_reply_blocked:
if updated.user_reply_block_permanent:
ticket_text += "🚫 Пользователь заблокирован навсегда для ответов в этом тикете\n"
elif updated.user_reply_block_until:
ticket_text += f"⏳ Блок до: {updated.user_reply_block_until.strftime('%d.%m.%Y %H:%M')}\n"
if updated.messages:
ticket_text += f"💬 Сообщения ({len(updated.messages)}):\n\n"
for msg in updated.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"
kb = get_admin_ticket_view_keyboard(updated.id, updated.is_closed, db_user.language, is_user_blocked=updated.is_user_reply_blocked)
has_photos = any(getattr(m, "has_media", False) and getattr(m, "media_type", None) == "photo" for m in updated.messages or [])
if has_photos:
try:
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("TICKET_ATTACHMENTS", "📎 Вложения"), callback_data=f"admin_ticket_attachments_{updated.id}")])
except Exception:
pass
if origin_chat_id and origin_message_id:
try:
await message.bot.edit_message_caption(chat_id=origin_chat_id, message_id=origin_message_id, caption=ticket_text, reply_markup=kb, parse_mode="HTML")
except Exception:
try:
await message.bot.edit_message_text(chat_id=origin_chat_id, message_id=origin_message_id, text=ticket_text, reply_markup=kb, parse_mode="HTML")
except Exception:
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
else:
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
except Exception:
await message.answer(f"✅ Пользователь заблокирован на {minutes} минут")
finally:
await state.clear()
except Exception as e:
logger.error(f"Error setting block duration: {e}")
texts = get_texts(db_user.language)
@@ -515,11 +681,39 @@ async def unblock_user_in_ticket(
db_user: User,
db: AsyncSession
):
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
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))
try:
await callback.message.answer(
"✅ Блок снят",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
)
except Exception:
await callback.answer("✅ Блок снят")
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
ticket_id = int(callback.data.replace("admin_unblock_user_ticket_", ""))
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_mod,
action="unblock_user",
ticket_id=ticket_id,
target_user_id=None,
details={}
)
except Exception:
pass
await view_admin_ticket(callback, db_user, db)
else:
await callback.answer("❌ Ошибка", show_alert=True)
@@ -529,11 +723,38 @@ async def block_user_permanently(
db_user: User,
db: AsyncSession
):
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
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))
try:
await callback.message.answer(
"✅ Пользователь заблокирован навсегда",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text="🗑 Удалить", callback_data="admin_support_delete_msg")]]
)
)
except Exception:
await callback.answer("✅ Пользователь заблокирован")
# audit
try:
is_mod = (not settings.is_admin(callback.from_user.id) and SupportSettingsService.is_moderator(callback.from_user.id))
await TicketCRUD.add_support_audit(
db,
actor_user_id=db_user.id if db_user else None,
actor_telegram_id=callback.from_user.id,
is_moderator=is_mod,
action="block_user_perm",
ticket_id=ticket_id,
target_user_id=None,
details={}
)
except Exception:
pass
await view_admin_ticket(callback, db_user, db)
else:
await callback.answer("❌ Ошибка", show_alert=True)
@@ -541,6 +762,12 @@ async def block_user_permanently(
async def notify_user_about_ticket_reply(bot: Bot, ticket: Ticket, reply_text: str, db: AsyncSession):
"""Уведомить пользователя о новом ответе в тикете"""
try:
# Respect runtime toggle for user ticket notifications
try:
if not SupportSettingsService.get_user_ticket_notifications_enabled():
return
except Exception:
pass
from app.localization.texts import get_texts
# Получаем тикет с пользователем
@@ -638,6 +865,11 @@ def register_handlers(dp: Dispatcher):
db_user: User,
db: AsyncSession
):
# permission gate for attachments view
if not (settings.is_admin(callback.from_user.id) or SupportSettingsService.is_moderator(callback.from_user.id)):
texts = get_texts(db_user.language)
await callback.answer(texts.ACCESS_DENIED, show_alert=True)
return
texts = get_texts(db_user.language)
try:
ticket_id = int(callback.data.replace("admin_ticket_attachments_", ""))

View File

@@ -16,6 +16,7 @@ from app.services.subscription_checkout_service import (
should_offer_checkout_resume,
)
from app.utils.photo_message import edit_or_answer_photo
from app.services.support_settings_service import SupportSettingsService
logger = logging.getLogger(__name__)
@@ -48,6 +49,7 @@ async def show_main_menu(
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
@@ -120,6 +122,7 @@ async def handle_back_to_menu(
keyboard=get_main_menu_keyboard(
language=db_user.language,
is_admin=settings.is_admin(db_user.telegram_id),
is_moderator=(not settings.is_admin(db_user.telegram_id) and SupportSettingsService.is_moderator(db_user.telegram_id)),
has_had_paid_subscription=db_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,

View File

@@ -374,12 +374,17 @@ async def show_my_tickets(
except ValueError:
current_page = 1
# Получаем тикеты пользователя (открытые/закрытые отдельно)
all_tickets = await TicketCRUD.get_user_tickets(db, db_user.id, limit=100)
open_tickets = [t for t in all_tickets if t.status != TicketStatus.CLOSED.value]
closed_tickets = [t for t in all_tickets if t.status == TicketStatus.CLOSED.value]
if not open_tickets and not closed_tickets:
# Пагинация открытых тикетов из БД
per_page = 10
total_open = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value])
total_pages = max(1, (total_open + per_page - 1) // per_page)
current_page = max(1, min(current_page, total_pages))
offset = (current_page - 1) * per_page
open_tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.OPEN.value, TicketStatus.ANSWERED.value, TicketStatus.PENDING.value], limit=per_page, offset=offset)
# Проверка на отсутствие тикетов совсем (ни открытых, ни закрытых)
has_closed_any = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value]) > 0
if not open_tickets and not has_closed_any:
await callback.message.edit_text(
texts.t("NO_TICKETS", "У вас пока нет тикетов."),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -400,32 +405,19 @@ async def show_my_tickets(
await callback.answer()
return
# Открытые с пагинацией
open_data = []
for t in open_tickets:
if t.status != TicketStatus.CLOSED.value:
open_data.append({'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji})
per_page = 10
pag = get_pagination_info(total_count=len(open_data), page=current_page, per_page=per_page)
# Корректируем текущую страницу в допустимые границы
current_page = max(1, min(current_page, pag["total_pages"]))
start_index = (current_page - 1) * per_page
end_index = start_index + per_page
page_items = open_data[start_index:end_index]
keyboard = get_my_tickets_keyboard(page_items, current_page=current_page, total_pages=pag["total_pages"], language=db_user.language)
# Открытые с пагинацией (DB)
open_data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in open_tickets]
keyboard = get_my_tickets_keyboard(open_data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_page_")
# Добавим кнопку перехода к закрытым
keyboard.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("VIEW_CLOSED_TICKETS", "🟢 Закрытые тикеты"), callback_data="my_tickets_closed")])
# Покажем список тикетов c логотипом, если режим включен
if settings.ENABLE_LOGO_MODE and callback.message.photo:
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
keyboard=keyboard,
parse_mode="HTML",
)
else:
await callback.message.edit_text(texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"), reply_markup=keyboard)
# Всегда используем фото-рендер с логотипом (утилита сама сделает фоллбек при необходимости)
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("MY_TICKETS_TITLE", "📋 Ваши тикеты:"),
keyboard=keyboard,
parse_mode="HTML",
)
await callback.answer()
@@ -435,9 +427,18 @@ async def show_my_tickets_closed(
db: AsyncSession
):
texts = get_texts(db_user.language)
# Пагинация (при необходимости можно добавить аналогично open)
tickets = await TicketCRUD.get_user_tickets(db, db_user.id, status=TicketStatus.CLOSED.value, limit=10)
if not tickets:
# Пагинация закрытых
current_page = 1
data_str = callback.data
if data_str.startswith("my_tickets_closed_page_"):
try:
current_page = int(data_str.replace("my_tickets_closed_page_", ""))
except ValueError:
current_page = 1
per_page = 10
total_closed = await TicketCRUD.count_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value])
if total_closed == 0:
await callback.message.edit_text(
texts.t("NO_CLOSED_TICKETS", "Закрытых тикетов пока нет."),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
@@ -447,19 +448,20 @@ async def show_my_tickets_closed(
)
await callback.answer()
return
total_pages = max(1, (total_closed + per_page - 1) // per_page)
current_page = max(1, min(current_page, total_pages))
offset = (current_page - 1) * per_page
tickets = await TicketCRUD.get_user_tickets_by_statuses(db, db_user.id, [TicketStatus.CLOSED.value], limit=per_page, offset=offset)
data = [{'id': t.id, 'title': t.title, 'status_emoji': t.status_emoji} for t in tickets]
kb = get_my_tickets_keyboard(data, current_page=1, language=db_user.language)
kb = get_my_tickets_keyboard(data, current_page=current_page, total_pages=total_pages, language=db_user.language, page_prefix="my_tickets_closed_page_")
kb.inline_keyboard.insert(0, [types.InlineKeyboardButton(text=texts.t("BACK_TO_OPEN_TICKETS", "🔴 Открытые тикеты"), callback_data="my_tickets")])
if settings.ENABLE_LOGO_MODE and callback.message.photo:
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
keyboard=kb,
parse_mode="HTML",
)
else:
await callback.message.edit_text(texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"), reply_markup=kb)
from app.utils.photo_message import edit_or_answer_photo
await edit_or_answer_photo(
callback=callback,
caption=texts.t("CLOSED_TICKETS_TITLE", "🟢 Закрытые тикеты:"),
keyboard=kb,
parse_mode="HTML",
)
await callback.answer()
@@ -758,7 +760,10 @@ async def handle_ticket_reply(
if ticket.status == TicketStatus.CLOSED.value:
texts = get_texts(db_user.language)
await message.answer(
texts.t("TICKET_CLOSED", "✅ Тикет закрыт.")
texts.t("TICKET_CLOSED", "✅ Тикет закрыт."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]]
)
)
await state.clear()
return
@@ -767,7 +772,10 @@ async def handle_ticket_reply(
if ticket.status == TicketStatus.CLOSED.value or ticket.is_user_reply_blocked:
texts = get_texts(db_user.language)
await message.answer(
texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно.")
texts.t("TICKET_CLOSED_NO_REPLY", "❌ Тикет закрыт, ответить невозможно."),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[[types.InlineKeyboardButton(text=texts.t("CLOSE_NOTIFICATION", "❌ Закрыть уведомление"), callback_data=f"close_ticket_notification_{ticket.id}")]]
)
)
await state.clear()
return
@@ -981,6 +989,10 @@ def register_handlers(dp: Dispatcher):
show_my_tickets_closed,
F.data == "my_tickets_closed"
)
dp.callback_query.register(
show_my_tickets_closed,
F.data.startswith("my_tickets_closed_page_")
)
dp.callback_query.register(
view_ticket,

View File

@@ -10,6 +10,7 @@ def get_admin_main_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👥 Юзеры/Подписки", callback_data="admin_submenu_users")],
[InlineKeyboardButton(text="💰 Промокоды/Статистика", callback_data="admin_submenu_promo")],
[InlineKeyboardButton(text="🛟 Поддержка", callback_data="admin_submenu_support")],
[InlineKeyboardButton(text="📨 Сообщения", callback_data="admin_submenu_communications")],
[InlineKeyboardButton(text="⚙️ Настройки", callback_data="admin_submenu_settings")],
[InlineKeyboardButton(text="🛠️ Система", callback_data="admin_submenu_system")],
@@ -61,15 +62,28 @@ def get_admin_communications_submenu_keyboard(language: str = "ru") -> InlineKey
[
InlineKeyboardButton(text=texts.ADMIN_MESSAGES, callback_data="admin_messages")
],
[
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
])
def get_admin_support_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text="🎫 Тикеты поддержки", callback_data="admin_tickets")
],
[
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
InlineKeyboardButton(text="🧾 Аудит модераторов", callback_data="admin_support_audit")
],
[
InlineKeyboardButton(text="👋 Приветственный текст", callback_data="welcome_text_panel"),
InlineKeyboardButton(text="📢 Сообщения в меню", callback_data="user_messages_panel")
InlineKeyboardButton(text="🛟 Настройки поддержки", callback_data="admin_support_settings")
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")

View File

@@ -67,6 +67,8 @@ def get_main_menu_keyboard(
balance_kopeks: int = 0,
subscription=None,
show_resume_checkout: bool = False,
*,
is_moderator: bool = False,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
@@ -198,6 +200,11 @@ def get_main_menu_keyboard(
else:
if settings.DEBUG:
print("DEBUG KEYBOARD: Админ кнопка НЕ добавлена")
# Moderator access (limited support panel)
if (not is_admin) and is_moderator:
keyboard.append([
InlineKeyboardButton(text="🧑‍⚖️ Модерация", callback_data="moderator_panel")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1535,7 +1542,8 @@ def get_my_tickets_keyboard(
tickets: List[dict],
current_page: int = 1,
total_pages: int = 1,
language: str = DEFAULT_LANGUAGE
language: str = DEFAULT_LANGUAGE,
page_prefix: str = "my_tickets_page_"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
@@ -1563,7 +1571,7 @@ def get_my_tickets_keyboard(
nav_row.append(
InlineKeyboardButton(
text=texts.t("PAGINATION_PREV", "⬅️"),
callback_data=f"my_tickets_page_{current_page - 1}"
callback_data=f"{page_prefix}{current_page - 1}"
)
)
@@ -1578,7 +1586,7 @@ def get_my_tickets_keyboard(
nav_row.append(
InlineKeyboardButton(
text=texts.t("PAGINATION_NEXT", "➡️"),
callback_data=f"my_tickets_page_{current_page + 1}"
callback_data=f"{page_prefix}{current_page + 1}"
)
)
@@ -1641,7 +1649,9 @@ def get_admin_tickets_keyboard(
current_page: int = 1,
total_pages: int = 1,
language: str = DEFAULT_LANGUAGE,
scope: str = "all"
scope: str = "all",
*,
back_callback: str = "admin_submenu_support"
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
@@ -1706,7 +1716,7 @@ def get_admin_tickets_keyboard(
keyboard.append(nav_row)
keyboard.append([
InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_communications")
InlineKeyboardButton(text=texts.BACK, callback_data=back_callback)
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
@@ -1715,7 +1725,9 @@ def get_admin_tickets_keyboard(
def get_admin_ticket_view_keyboard(
ticket_id: int,
is_closed: bool = False,
language: str = DEFAULT_LANGUAGE
language: str = DEFAULT_LANGUAGE,
*,
is_user_blocked: bool = False
) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
@@ -1736,14 +1748,16 @@ def get_admin_ticket_view_keyboard(
)
])
# 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}")
])
# Блок-контролы: когда не заблокирован — показать два варианта, когда заблокирован — только "Разблокировать"
if is_user_blocked:
keyboard.append([
InlineKeyboardButton(text=texts.t("UNBLOCK", "✅ Разблокировать"), callback_data=f"admin_unblock_user_ticket_{ticket_id}")
])
else:
keyboard.append([
InlineKeyboardButton(text=texts.t("BLOCK_FOREVER", "🚫 Заблокировать"), callback_data=f"admin_block_user_perm_ticket_{ticket_id}"),
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")

View File

@@ -401,4 +401,5 @@
"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."
}

View File

@@ -55,6 +55,8 @@
"ADMIN_PROMO_GROUP_DELETED": "Промогруппа «{name}» удалена.",
"ADMIN_SUBSCRIPTIONS": "📱 Подписки",
"ADMIN_USERS": "👥 Пользователи",
"ADMIN_TICKETS_TITLE_OPEN": "🎫 Открытые тикеты поддержки:",
"ADMIN_TICKETS_TITLE_CLOSED": "🎫 Закрытые тикеты поддержки:",
"AUTOPAY_BUTTON": "💳 Автоплатёж",
"AUTOPAY_DISABLED_TEXT": "Отключен - не забудьте продлить вручную!",
"AUTOPAY_ENABLED_TEXT": "Включен - подписка продлится автоматически",
@@ -401,4 +403,5 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ <b>Через поддержку</b>",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
}

View File

@@ -818,7 +818,13 @@ class AdminNotificationService:
"""Публичный метод для отправки уведомлений по тикетам в админ-топик.
Учитывает настройки включенности в settings.
"""
if not self._is_enabled():
# Respect runtime toggle for admin ticket notifications
try:
from app.services.support_settings_service import SupportSettingsService
runtime_enabled = SupportSettingsService.get_admin_ticket_notifications_enabled()
except Exception:
runtime_enabled = True
if not (self._is_enabled() and runtime_enabled):
return False
return await self._send_message(text, reply_markup=keyboard, ticket_event=True)

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional, Set
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from sqlalchemy import select, and_, or_
from sqlalchemy.orm import selectinload
from app.config import settings
@@ -21,7 +21,7 @@ from app.database.crud.notification import (
notification_sent,
record_notification,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
from app.localization.texts import get_texts
@@ -42,6 +42,7 @@ class MonitoringService:
self.bot = bot
self._notified_users: Set[str] = set()
self._last_cleanup = datetime.utcnow()
self._sla_task = None
async def start_monitoring(self):
if self.is_running:
@@ -50,6 +51,12 @@ class MonitoringService:
self.is_running = True
logger.info("🔄 Запуск службы мониторинга")
# Start dedicated SLA loop with its own interval for timely 5-min checks
try:
if not self._sla_task or self._sla_task.done():
self._sla_task = asyncio.create_task(self._sla_loop())
except Exception as e:
logger.error(f"Не удалось запустить SLA-мониторинг: {e}")
while self.is_running:
try:
@@ -63,6 +70,11 @@ class MonitoringService:
def stop_monitoring(self):
self.is_running = False
logger.info(" Мониторинг остановлен")
try:
if self._sla_task and not self._sla_task.done():
self._sla_task.cancel()
except Exception:
pass
async def _monitoring_cycle(self):
async for db in get_db():
@@ -576,6 +588,107 @@ class MonitoringService:
is_success=False
)
async def _check_ticket_sla(self, db: AsyncSession):
try:
# Quick guards
# Allow runtime toggle from SupportSettingsService
try:
from app.services.support_settings_service import SupportSettingsService
sla_enabled_runtime = SupportSettingsService.get_sla_enabled()
except Exception:
sla_enabled_runtime = getattr(settings, 'SUPPORT_TICKET_SLA_ENABLED', True)
if not sla_enabled_runtime:
return
if not self.bot:
return
if not settings.is_admin_notifications_enabled():
return
from datetime import datetime, timedelta
try:
from app.services.support_settings_service import SupportSettingsService
sla_minutes = max(1, int(SupportSettingsService.get_sla_minutes()))
except Exception:
sla_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_MINUTES', 5)))
cooldown_minutes = max(1, int(getattr(settings, 'SUPPORT_TICKET_SLA_REMINDER_COOLDOWN_MINUTES', 15)))
now = datetime.utcnow()
stale_before = now - timedelta(minutes=sla_minutes)
cooldown_before = now - timedelta(minutes=cooldown_minutes)
# Tickets to remind: open, no admin reply yet after user's last message (status OPEN), stale by SLA,
# and either never reminded or cooldown passed
result = await db.execute(
select(Ticket)
.options(selectinload(Ticket.user))
.where(
and_(
Ticket.status == TicketStatus.OPEN.value,
Ticket.updated_at <= stale_before,
or_(Ticket.last_sla_reminder_at.is_(None), Ticket.last_sla_reminder_at <= cooldown_before),
)
)
)
tickets = result.scalars().all()
if not tickets:
return
from app.services.admin_notification_service import AdminNotificationService
reminders_sent = 0
service = AdminNotificationService(self.bot)
for ticket in tickets:
try:
waited_minutes = max(0, int((now - ticket.updated_at).total_seconds() // 60))
title = (ticket.title or '').strip()
if len(title) > 60:
title = title[:57] + '...'
text = (
f"⏰ <b>Ожидание ответа на тикет превышено</b>\n\n"
f"🆔 <b>ID:</b> <code>{ticket.id}</code>\n"
f"👤 <b>User ID:</b> <code>{ticket.user_id}</code>\n"
f"📝 <b>Заголовок:</b> {title or ''}\n"
f"⏱️ <b>Ожидает ответа:</b> {waited_minutes} мин\n"
)
sent = await service.send_ticket_event_notification(text)
if sent:
ticket.last_sla_reminder_at = now
reminders_sent += 1
# commit after each to persist timestamp and avoid duplicate reminders on crash
await db.commit()
except Exception as notify_error:
logger.error(f"Ошибка отправки SLA-уведомления по тикету {ticket.id}: {notify_error}")
if reminders_sent > 0:
await self._log_monitoring_event(
db,
"ticket_sla_reminders_sent",
f"Отправлено {reminders_sent} SLA-напоминаний по тикетам",
{"count": reminders_sent},
)
except Exception as e:
logger.error(f"Ошибка проверки SLA тикетов: {e}")
async def _sla_loop(self):
try:
interval_seconds = max(10, int(getattr(settings, 'SUPPORT_TICKET_SLA_CHECK_INTERVAL_SECONDS', 60)))
except Exception:
interval_seconds = 60
while self.is_running:
try:
async for db in get_db():
try:
await self._check_ticket_sla(db)
finally:
break
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Ошибка в SLA-цикле: {e}")
await asyncio.sleep(interval_seconds)
async def _log_monitoring_event(
self,
db: AsyncSession,

View File

@@ -110,3 +110,112 @@ class SupportSettingsService:
return cls._save()
# Notifications & SLA
@classmethod
def get_admin_ticket_notifications_enabled(cls) -> bool:
cls._load()
if "admin_ticket_notifications_enabled" in cls._data:
return bool(cls._data["admin_ticket_notifications_enabled"])
# fallback to global admin notifications setting
return bool(settings.is_admin_notifications_enabled())
@classmethod
def set_admin_ticket_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["admin_ticket_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_user_ticket_notifications_enabled(cls) -> bool:
cls._load()
if "user_ticket_notifications_enabled" in cls._data:
return bool(cls._data["user_ticket_notifications_enabled"])
# fallback to global enable notifications
return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True))
@classmethod
def set_user_ticket_notifications_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["user_ticket_notifications_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_sla_enabled(cls) -> bool:
cls._load()
if "ticket_sla_enabled" in cls._data:
return bool(cls._data["ticket_sla_enabled"])
return bool(getattr(settings, "SUPPORT_TICKET_SLA_ENABLED", True))
@classmethod
def set_sla_enabled(cls, enabled: bool) -> bool:
cls._load()
cls._data["ticket_sla_enabled"] = bool(enabled)
return cls._save()
@classmethod
def get_sla_minutes(cls) -> int:
cls._load()
minutes = cls._data.get("ticket_sla_minutes")
if isinstance(minutes, int) and minutes > 0:
return minutes
return int(getattr(settings, "SUPPORT_TICKET_SLA_MINUTES", 5))
@classmethod
def set_sla_minutes(cls, minutes: int) -> bool:
try:
minutes_int = int(minutes)
except Exception:
return False
if minutes_int <= 0:
return False
cls._load()
cls._data["ticket_sla_minutes"] = minutes_int
return cls._save()
# Moderators management
@classmethod
def get_moderators(cls) -> list[int]:
cls._load()
raw = cls._data.get("moderators") or []
moderators: list[int] = []
for item in raw:
try:
moderators.append(int(item))
except Exception:
continue
return moderators
@classmethod
def is_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
return tid in cls.get_moderators()
@classmethod
def add_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
cls._load()
moderators = set(cls.get_moderators())
moderators.add(tid)
cls._data["moderators"] = sorted(moderators)
return cls._save()
@classmethod
def remove_moderator(cls, telegram_id: int) -> bool:
try:
tid = int(telegram_id)
except Exception:
return False
cls._load()
moderators = set(cls.get_moderators())
if tid in moderators:
moderators.remove(tid)
cls._data["moderators"] = sorted(moderators)
return cls._save()
return True

View File

@@ -30,7 +30,7 @@
"TICKET_TITLE_INPUT": "Введите заголовок тикета:",
"TICKET_TITLE_TOO_SHORT": "Заголовок должен содержать минимум 5 символов. Попробуйте еще раз:",
"TICKET_TITLE_TOO_LONG": "Заголовок слишком длинный. Максимум 255 символов. Попробуйте еще раз:",
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото без текста:",
"TICKET_MESSAGE_INPUT": "Опишите проблему (до 500 символов) или отправьте фото c подписью:",
"TICKET_MESSAGE_TOO_SHORT": "Сообщение должно содержать минимум 10 символов. Попробуйте еще раз:",
"TICKET_CREATED_SUCCESS": "✅ Тикет #{ticket_id} успешно создан!\n\nЗаголовок: {title}\n\nМы ответим вам в ближайшее время.",
"VIEW_TICKET": "👁️ Посмотреть тикет",
@@ -68,7 +68,7 @@
"CLOSE_NOTIFICATION": "❌ Закрыть уведомление",
"NOTIFICATION_CLOSED": "Уведомление закрыто.",
"UNBLOCK": "✅ Разблокировать",
"BLOCK_FOREVER": "🚫 Блок навсегда",
"BLOCK_FOREVER": "🚫 Заблокировать",
"BLOCK_BY_TIME": "⏳ Блокировка по времени",
"TICKET_ATTACHMENTS": "📎 Вложения",
"OPEN_TICKETS": "🔴 Открытые",