mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-01-20 03:40:26 +00:00
feat: модерация, обновленное меню тикетов, SLA и управление уведомлениями
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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_", ""))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
}
|
||||
|
||||
@@ -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": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку."
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "🔴 Открытые",
|
||||
|
||||
Reference in New Issue
Block a user