diff --git a/app/config.py b/app/config.py
index e157f84c..e0075783 100644
--- a/app/config.py
+++ b/app/config.py
@@ -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
diff --git a/app/database/crud/ticket.py b/app/database/crud/ticket.py
index add26178..6f566cdc 100644
--- a/app/database/crud/ticket.py
+++ b/app/database/crud/ticket.py
@@ -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()
diff --git a/app/database/models.py b/app/database/models.py
index 2491eef2..b1e3014d 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -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")
diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py
index 2b1136ca..36c88c45 100644
--- a/app/database/universal_migration.py
+++ b/app/database/universal_migration.py
@@ -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:
diff --git a/app/handlers/admin/main.py b/app/handlers/admin/main.py
index dfce24d7..a26fd7b3 100644
--- a/app/handlers/admin/main.py
+++ b/app/handlers/admin/main.py
@@ -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(
+ "🧑⚖️ Модерация поддержки\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 = ["🧾 Аудит модераторов", ""]
+ 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} {log.actor_telegram_id} — {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)
diff --git a/app/handlers/admin/support_settings.py b/app/handlers/admin/support_settings.py
index eb93e841..adbf3ea7 100644
--- a/app/handlers/admin/support_settings.py
+++ b/app/handlers/admin/support_settings.py
@@ -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(
+ "⏳ Настройка SLA\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(
+ "⏳ Настройка SLA\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(
+ "🧑⚖️ Назначение модератора\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(
+ "🧑⚖️ Удаление модератора\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 = "🧑⚖️ Модераторы\n\n" + "\n".join([f"• {tid}" 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)
diff --git a/app/handlers/admin/tickets.py b/app/handlers/admin/tickets.py
index c943a5db..dcaab6d2 100644
--- a/app/handlers/admin/tickets.py
+++ b/app/handlers/admin/tickets.py
@@ -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_", ""))
diff --git a/app/handlers/menu.py b/app/handlers/menu.py
index 9822a144..96f9a8db 100644
--- a/app/handlers/menu.py
+++ b/app/handlers/menu.py
@@ -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,
diff --git a/app/handlers/tickets.py b/app/handlers/tickets.py
index 2afc72e0..b421f426 100644
--- a/app/handlers/tickets.py
+++ b/app/handlers/tickets.py
@@ -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,
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index c7a3a480..4137a918 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -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")
diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py
index 033884f4..27400acb 100644
--- a/app/keyboards/inline.py
+++ b/app/keyboards/inline.py
@@ -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")
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 6995e761..bd81c8fa 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -401,4 +401,5 @@
"PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance."
+
}
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index 0fbb106e..ac0e159f 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -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": "🛠️ Через поддержку",
"PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы",
"PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
+
}
diff --git a/app/services/admin_notification_service.py b/app/services/admin_notification_service.py
index 24deb059..040a2d27 100644
--- a/app/services/admin_notification_service.py
+++ b/app/services/admin_notification_service.py
@@ -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)
diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py
index 2d0b96c7..a190aec4 100644
--- a/app/services/monitoring_service.py
+++ b/app/services/monitoring_service.py
@@ -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"⏰ Ожидание ответа на тикет превышено\n\n"
+ f"🆔 ID: {ticket.id}\n"
+ f"👤 User ID: {ticket.user_id}\n"
+ f"📝 Заголовок: {title or '—'}\n"
+ f"⏱️ Ожидает ответа: {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,
diff --git a/app/services/support_settings_service.py b/app/services/support_settings_service.py
index 19be0b10..7943b84a 100644
--- a/app/services/support_settings_service.py
+++ b/app/services/support_settings_service.py
@@ -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
+
diff --git a/locales/ru.json b/locales/ru.json
index 328c0d33..54cbba08 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -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": "🔴 Открытые",