feat: add support tickets and imrpove web app loading

This commit is contained in:
3252a8
2026-05-20 16:38:24 +03:00
parent 3b846f0d44
commit d8a1da1f13
78 changed files with 8990 additions and 168 deletions
+152
View File
@@ -492,3 +492,155 @@ def render_subscription_expiring(
footer_html=footer,
)
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def _support_email(
settings: Settings,
i18n: Optional[JsonI18n],
language: Optional[str],
*,
subject: str,
heading: str,
intro: str,
rows: Sequence[Tuple[str, str]],
body_preview: str,
ticket_url: Optional[str],
cta_label: str,
) -> EmailContent:
lang = _normalize_lang(language, settings)
brand = _brand_title(settings)
accent = _safe_color(settings.WEBAPP_PRIMARY_COLOR)
safe_url = (ticket_url or "").strip()
footer = _t_html(_resolve_i18n(i18n), lang, "email_footer_auto", brand=brand)
preview_block = (
f'<div style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};'
f'border-radius:14px;padding:14px 16px;font-size:14px;line-height:1.55;color:{_TEXT};'
f'white-space:pre-wrap;">{html.escape(body_preview or "")}</div>'
)
body_parts = [_info_rows_html(rows), preview_block]
if safe_url:
body_parts.append(_cta_button_html(label=cta_label, url=safe_url, accent=accent))
rendered = _layout(
settings=settings,
preheader=intro,
heading=heading,
intro_html=html.escape(intro),
body_html="".join(body_parts),
footer_html=footer,
)
text_lines = [intro, "", *[f"{label}: {value}" for label, value in rows], "", body_preview]
if safe_url:
text_lines.extend(["", safe_url])
return EmailContent(subject=subject, text="\n".join(text_lines), html=rendered)
def render_support_new_ticket_admin(
settings: Settings,
i18n: Optional[JsonI18n],
language: Optional[str],
*,
ticket_id: int,
user_display: str,
subject: str,
body_preview: str,
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New support ticket #{ticket_id}",
heading=f"New support ticket #{ticket_id}",
intro="A user opened a new support ticket.",
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
)
def render_support_user_reply_admin(
settings: Settings,
i18n: Optional[JsonI18n],
language: Optional[str],
*,
ticket_id: int,
user_display: str,
subject: str,
body_preview: str,
snapshot_rows: Sequence[Tuple[str, str]],
ticket_url: Optional[str],
) -> EmailContent:
rows = [
("Ticket", f"#{ticket_id}"),
("User", user_display),
("Subject", subject),
*snapshot_rows,
]
return _support_email(
settings,
i18n,
language,
subject=f"New user reply in ticket #{ticket_id}",
heading=f"User replied in ticket #{ticket_id}",
intro="A user sent a new support message.",
rows=rows,
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open ticket",
)
def render_support_admin_reply_user(
settings: Settings,
i18n: Optional[JsonI18n],
language: Optional[str],
*,
ticket_id: int,
subject: str,
body_preview: str,
ticket_url: Optional[str],
) -> EmailContent:
return _support_email(
settings,
i18n,
language,
subject=f"New reply for ticket #{ticket_id}",
heading=f"New reply for ticket #{ticket_id}",
intro="Support has replied to your ticket.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
body_preview=body_preview,
ticket_url=ticket_url,
cta_label="Open in Mini App",
)
def render_support_ticket_closed_user(
settings: Settings,
i18n: Optional[JsonI18n],
language: Optional[str],
*,
ticket_id: int,
subject: str,
body_preview: str = "",
ticket_url: Optional[str],
) -> EmailContent:
return _support_email(
settings,
i18n,
language,
subject=f"Ticket #{ticket_id} was closed",
heading=f"Ticket #{ticket_id} was closed",
intro="Your support ticket has been closed.",
rows=[("Ticket", f"#{ticket_id}"), ("Subject", subject)],
body_preview=body_preview or "The ticket is closed.",
ticket_url=ticket_url,
cta_label="Open in Mini App",
)
+364 -4
View File
@@ -4,10 +4,18 @@ from typing import Any, Callable, Dict, Optional
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from aiogram.utils.text_decorations import html_decoration as hd
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.email_templates import (
render_support_admin_reply_user,
render_support_new_ticket_admin,
render_support_ticket_closed_user,
render_support_user_reply_admin,
)
from bot.utils import MessageContent, send_message_via_queue
from bot.utils.message_queue import get_queue_manager
from bot.utils.telegram_markup import (
is_profile_link_error,
@@ -18,15 +26,30 @@ from bot.utils.text_sanitizer import (
username_for_display,
)
from config.settings import Settings
from db.dal import app_settings_dal, user_dal
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY = "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
class NotificationService:
"""Enhanced notification service for sending messages to admins and log channels"""
def __init__(self, bot: Bot, settings: Settings, i18n: Optional[JsonI18n] = None):
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: Optional[JsonI18n] = None,
*,
session_factory=None,
email_auth_service: Optional[EmailAuthService] = None,
bot_username: Optional[str] = None,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.session_factory = session_factory
self.email_auth_service = email_auth_service
self.bot_username = bot_username or ""
@staticmethod
def _format_user_display(
@@ -149,7 +172,11 @@ class NotificationService:
"Failed to queue notification to log channel %s.", self.settings.LOG_CHAT_ID
)
async def _send_to_admins(self, message: str):
async def _send_to_admins(
self,
message: str,
reply_markup: Optional[InlineKeyboardMarkup] = None,
):
"""Send message to all admin users using message queue"""
if not self.settings.ADMIN_IDS:
return
@@ -164,6 +191,7 @@ class NotificationService:
text=message,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=reply_markup,
)
except Exception:
logging.exception("Failed to send notification to admin %s.", admin_id)
@@ -172,11 +200,343 @@ class NotificationService:
for admin_id in self.settings.ADMIN_IDS:
try:
await queue_manager.send_message(
chat_id=admin_id, text=message, parse_mode="HTML", disable_web_page_preview=True
chat_id=admin_id,
text=message,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=reply_markup,
)
except Exception:
logging.exception("Failed to queue notification to admin %s.", admin_id)
def _support_webapp_url(self, path: str) -> Optional[str]:
base_url = str(getattr(self.settings, "SUBSCRIPTION_MINI_APP_URL", "") or "").strip()
if not base_url:
return None
normalized_path = f"/{str(path or '').lstrip('/')}"
return f"{base_url.rstrip('/')}{normalized_path}"
def _support_ticket_url(self, ticket_id: int, *, admin: bool = True) -> str:
path = f"/admin/support/{ticket_id}" if admin else f"/support/{ticket_id}"
webapp_url = self._support_webapp_url(path)
if webapp_url:
return webapp_url
bot_username = self.bot_username.strip().lstrip("@")
if bot_username:
return f"https://t.me/{bot_username}?startapp=ticket_{ticket_id}"
return "https://t.me/"
def _support_mini_app_button(
self,
*,
text: str,
path: str,
fallback_url: str,
) -> InlineKeyboardButton:
webapp_url = self._support_webapp_url(path)
if webapp_url:
return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=webapp_url))
return InlineKeyboardButton(text=text, url=fallback_url)
def _support_text(self, language: Optional[str], key: str, fallback: str) -> str:
if not self.i18n:
return fallback
return self.i18n.gettext(language or self.settings.DEFAULT_LANGUAGE, key) or fallback
@staticmethod
def _support_preview(body: str, limit: int = 700) -> str:
text = (body or "").strip()
return text if len(text) <= limit else f"{text[: limit - 1]}"
@staticmethod
def _support_user_display(user) -> str:
name = " ".join(
part for part in [user.first_name, getattr(user, "last_name", None)] if part
)
if user.username:
return f"{name or user.username} (@{user.username})"
return name or getattr(user, "email", None) or f"ID {user.user_id}"
@staticmethod
def _support_snapshot_rows(snapshot: Optional[dict]) -> list[tuple[str, str]]:
if not snapshot:
return []
rows = []
for key, label in (
("tariff", "Tariff"),
("end_date", "End date"),
("remaining", "Remaining"),
("panel_status", "Panel status"),
):
value = snapshot.get(key)
if value:
rows.append((label, str(value)))
return rows
@staticmethod
def _coerce_bool_setting(value: Any, default: bool) -> bool:
if value is None:
return default
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
return bool(value)
async def support_admin_email_notifications_enabled(self) -> bool:
enabled = bool(
getattr(self.settings, SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY, False)
)
if not self.session_factory:
return enabled
try:
async with self.session_factory() as session:
found, raw_value = await app_settings_dal.get_override_value(
session,
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_KEY,
)
except Exception:
logging.exception("Failed to read support admin email notification override.")
return enabled
if not found:
return enabled
return self._coerce_bool_setting(raw_value, enabled)
def _support_keyboard(self, ticket, user, *, admin: bool = True) -> InlineKeyboardMarkup:
ticket_path = (
f"/admin/support/{ticket.ticket_id}" if admin else f"/support/{ticket.ticket_id}"
)
rows = [
[
self._support_mini_app_button(
text="Открыть тикет",
path=ticket_path,
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=admin),
)
]
]
if admin:
profile_row = []
if getattr(user, "user_id", 0) and int(user.user_id) > 0:
profile_row.append(
InlineKeyboardButton(text="Профиль", url=f"tg://user?id={user.user_id}")
)
user_card_path = f"/admin/users/{user.user_id}"
if self._support_webapp_url(user_card_path):
profile_row.append(
self._support_mini_app_button(
text="Карточка пользователя",
path=user_card_path,
fallback_url=self._support_ticket_url(ticket.ticket_id, admin=True),
)
)
if profile_row:
rows.append(profile_row)
return InlineKeyboardMarkup(inline_keyboard=rows)
def _support_user_keyboard(self, ticket, user) -> InlineKeyboardMarkup:
button_text = self._support_text(
getattr(user, "language_code", None),
"wa_support_open_ticket",
"Открыть тикет",
)
webapp_url = self._support_webapp_url(f"/support/{ticket.ticket_id}")
if webapp_url:
button = InlineKeyboardButton(text=button_text, web_app=WebAppInfo(url=webapp_url))
else:
button = InlineKeyboardButton(
text=button_text,
url=self._support_ticket_url(ticket.ticket_id, admin=False),
)
return InlineKeyboardMarkup(inline_keyboard=[[button]])
async def _admin_email_users(self):
if not self.session_factory:
return []
async with self.session_factory() as session:
users = []
for admin_id in self.settings.ADMIN_IDS:
user = await user_dal.get_user_by_id(session, int(admin_id))
if user and user.email:
users.append(user)
return users
async def _send_admin_support_email(self, renderer, **kwargs) -> None:
if not await self.support_admin_email_notifications_enabled():
return
if not self.email_auth_service:
return
for admin in await self._admin_email_users():
try:
content = renderer(
self.settings,
self.i18n,
getattr(admin, "language_code", None) or self.settings.DEFAULT_LANGUAGE,
**kwargs,
)
await self.email_auth_service.send_rendered_email(
email=admin.email,
content=content,
)
except Exception:
logging.exception("Failed to send support email to admin %s.", admin.user_id)
async def notify_new_support_ticket(self, ticket, user, first_message: str, snapshot: dict):
if not getattr(self.settings, "LOG_SUPPORT", True):
return
priority_emoji = {"low": "🟢", "normal": "🟡", "high": "🟠", "urgent": "🔴"}.get(
ticket.priority,
"🟡",
)
preview = self._support_preview(first_message)
user_display = self._support_user_display(user)
message = (
f"🆘 <b>Новый тикет #{ticket.ticket_id}</b>\n"
f"{priority_emoji} <b>{hd.quote(ticket.priority)}</b> · {hd.quote(ticket.category)}\n\n"
f"<b>Пользователь</b>\n{hd.quote(user_display)}\nID: <code>{user.user_id}</code>\n\n"
f"<b>Подписка</b>\n"
f"{hd.quote(str(snapshot.get('tariff') or ''))}, "
f"до {hd.quote(str(snapshot.get('end_date') or ''))}, "
f"осталось {hd.quote(str(snapshot.get('remaining') or ''))}\n"
f"статус: {hd.quote(str(snapshot.get('panel_status') or ''))}\n\n"
f"<b>Текст обращения</b>\n{hd.quote(preview)}"
)
keyboard = self._support_keyboard(ticket, user, admin=True)
await self._send_to_admins(message, reply_markup=keyboard)
await self._send_to_log_channel(
message,
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
reply_markup=keyboard,
)
await self._send_admin_support_email(
render_support_new_ticket_admin,
ticket_id=ticket.ticket_id,
user_display=user_display,
subject=ticket.subject,
body_preview=preview,
snapshot_rows=self._support_snapshot_rows(snapshot),
ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
)
async def notify_support_user_reply(
self,
ticket,
message,
user,
snapshot: dict,
*,
unread_count: Optional[int] = None,
send_telegram: bool = True,
send_email: bool = True,
):
if not getattr(self.settings, "LOG_SUPPORT", True):
return
preview = self._support_preview(message.body)
user_display = self._support_user_display(user)
unread_line = (
f"\n<b>Unread:</b> {int(unread_count)}"
if unread_count is not None and int(unread_count or 0) > 1
else ""
)
text = (
f"💬 <b>Ответ пользователя в тикете #{ticket.ticket_id}</b>\n"
f"{hd.quote(user_display)}{unread_line}\n\n{hd.quote(preview)}"
)
keyboard = self._support_keyboard(ticket, user, admin=True)
if send_telegram and getattr(self.settings, "LOG_SUPPORT", True):
await self._send_to_admins(text, reply_markup=keyboard)
await self._send_to_log_channel(
text,
thread_id=getattr(self.settings, "LOG_SUPPORT_THREAD_ID", None),
reply_markup=keyboard,
)
if send_email:
await self._send_admin_support_email(
render_support_user_reply_admin,
ticket_id=ticket.ticket_id,
user_display=user_display,
subject=ticket.subject,
body_preview=preview,
snapshot_rows=self._support_snapshot_rows(snapshot),
ticket_url=self._support_ticket_url(ticket.ticket_id, admin=True),
)
async def notify_support_admin_reply(self, ticket, message, user):
preview = self._support_preview(message.body, limit=500)
url = self._support_ticket_url(ticket.ticket_id, admin=False)
text = f"💬 <b>Новый ответ по тикету #{ticket.ticket_id}</b>\n\n{hd.quote(preview)}"
keyboard = self._support_user_keyboard(ticket, user)
if int(user.user_id) > 0:
queue_manager = get_queue_manager()
if queue_manager:
await send_message_via_queue(
queue_manager,
int(user.user_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
else:
await self.bot.send_message(
chat_id=int(user.user_id),
text=text,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
if self.email_auth_service and getattr(user, "email", None):
content = render_support_admin_reply_user(
self.settings,
self.i18n,
getattr(user, "language_code", None),
ticket_id=ticket.ticket_id,
subject=ticket.subject,
body_preview=preview,
ticket_url=url,
)
await self.email_auth_service.send_rendered_email(email=user.email, content=content)
async def notify_support_ticket_closed(self, ticket, user, closing_admin):
url = self._support_ticket_url(ticket.ticket_id, admin=False)
text = f"✅ <b>Тикет #{ticket.ticket_id} закрыт</b>\n\n{hd.quote(ticket.subject)}"
keyboard = self._support_user_keyboard(ticket, user)
if int(user.user_id) > 0:
queue_manager = get_queue_manager()
if queue_manager:
await send_message_via_queue(
queue_manager,
int(user.user_id),
MessageContent(content_type="text", text=text),
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
else:
await self.bot.send_message(
chat_id=int(user.user_id),
text=text,
parse_mode="HTML",
disable_web_page_preview=True,
reply_markup=keyboard,
)
if self.email_auth_service and getattr(user, "email", None):
content = render_support_ticket_closed_user(
self.settings,
self.i18n,
getattr(user, "language_code", None),
ticket_id=ticket.ticket_id,
subject=ticket.subject,
ticket_url=url,
)
await self.email_auth_service.send_rendered_email(email=user.email, content=content)
async def notify_new_user_registration(
self,
user_id: int,
+465
View File
@@ -0,0 +1,465 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.email_auth_service import EmailAuthService
from bot.services.notification_service import NotificationService
from config.settings import Settings
from db.dal import message_log_dal, subscription_dal, support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage, User
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class AdminNotificationDecision:
send_telegram: bool
send_email: bool
def _as_utc(value: Optional[datetime]) -> Optional[datetime]:
if value is None:
return None
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _notification_due(
last_sent_at: Optional[datetime],
*,
now: datetime,
cooldown_seconds: int,
) -> bool:
last_sent_at = _as_utc(last_sent_at)
if last_sent_at is None:
return True
cooldown = max(0, int(cooldown_seconds or 0))
if cooldown <= 0:
return True
return (now - last_sent_at).total_seconds() >= cooldown
def _support_admin_notification_decision(
ticket: SupportTicket,
settings: Settings,
*,
now: Optional[datetime] = None,
admin_email_notifications_enabled: Optional[bool] = None,
) -> AdminNotificationDecision:
now = _as_utc(now) or datetime.now(timezone.utc)
unread_count = max(0, int(getattr(ticket, "unread_admin_count", 0) or 0))
if unread_count <= 0:
return AdminNotificationDecision(send_telegram=False, send_email=False)
first_unread = unread_count <= 1
send_telegram = first_unread or _notification_due(
getattr(ticket, "admin_last_notified_at", None),
now=now,
cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS", 300),
)
if admin_email_notifications_enabled is None:
admin_email_notifications_enabled = bool(
getattr(settings, "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED", False)
)
send_email = bool(admin_email_notifications_enabled) and (
first_unread
or _notification_due(
getattr(ticket, "admin_last_emailed_at", None),
now=now,
cooldown_seconds=getattr(settings, "SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS", 1800),
)
)
return AdminNotificationDecision(send_telegram=send_telegram, send_email=send_email)
def _format_support_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
return "Subscription inactive" if lang == "en" else "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
class TicketForbidden(PermissionError):
pass
class TicketRateLimited(RuntimeError):
pass
class TicketNotFound(LookupError):
pass
class SupportService:
def __init__(
self,
session_factory: sessionmaker,
settings: Settings,
bot: Bot,
i18n: Optional[JsonI18n],
notification_service: Optional[NotificationService] = None,
email_auth_service: Optional[EmailAuthService] = None,
):
self.session_factory = session_factory
self.settings = settings
self.bot = bot
self.i18n = i18n
self.email_auth_service = email_auth_service or EmailAuthService(settings)
self.notification_service = notification_service or NotificationService(
bot,
settings,
i18n,
session_factory=session_factory,
email_auth_service=self.email_auth_service,
)
async def _ensure_user_allowed(self, session, user_id: int) -> User:
user = await user_dal.get_user_by_id(session, user_id)
if not user or user.is_banned or not self.settings.SUPPORT_TICKETS_ENABLED:
raise TicketForbidden("ticket_forbidden")
return user
async def create_ticket(
self,
user_id: int,
subject: str,
category: str,
priority: str,
first_message_body: str,
) -> SupportTicket:
async with self.session_factory() as session:
user = await self._ensure_user_allowed(session, user_id)
limit = max(0, int(self.settings.SUPPORT_TICKET_RATE_LIMIT_PER_HOUR or 0))
if limit:
recent = await support_dal.count_recent_tickets_for_user(session, user_id, 3600)
if recent >= limit:
raise TicketRateLimited("ticket_rate_limited")
ticket = await support_dal.create_ticket(
session,
user_id,
subject[: self.settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH],
category,
priority,
first_message_body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
)
snapshot = await self.build_user_snapshot(user, session=session)
await session.commit()
try:
await self.notification_service.notify_new_support_ticket(
ticket,
user,
first_message_body,
snapshot,
)
except Exception:
logger.exception("Failed to notify about support ticket %s", ticket.ticket_id)
return ticket
async def reply_as_user(
self,
user_id: int,
ticket_id: int,
body: str,
) -> tuple[SupportTicket, SupportTicketMessage]:
async with self.session_factory() as session:
user = await self._ensure_user_allowed(session, user_id)
ticket, _messages = await support_dal.get_ticket(session, ticket_id)
if not ticket or ticket.user_id != user_id:
raise TicketNotFound("not_found")
message = await support_dal.add_message(
session,
ticket_id,
"user",
user_id,
body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
)
if message is None:
raise TicketNotFound("not_found")
await session.refresh(ticket)
notification_at = datetime.now(timezone.utc)
admin_email_notifications_enabled = (
await self.notification_service.support_admin_email_notifications_enabled()
)
notification_decision = _support_admin_notification_decision(
ticket,
self.settings,
now=notification_at,
admin_email_notifications_enabled=admin_email_notifications_enabled,
)
await support_dal.record_admin_notification(
session,
ticket_id,
notified_at=notification_at if notification_decision.send_telegram else None,
emailed_at=notification_at if notification_decision.send_email else None,
)
snapshot = await self.build_user_snapshot(user, session=session)
await session.commit()
if notification_decision.send_telegram or notification_decision.send_email:
try:
await self.notification_service.notify_support_user_reply(
ticket,
message,
user,
snapshot,
unread_count=int(ticket.unread_admin_count or 0),
send_telegram=notification_decision.send_telegram,
send_email=notification_decision.send_email,
)
except Exception:
logger.exception("Failed to notify about support user reply %s", ticket_id)
return ticket, message
async def reply_as_admin(
self,
admin_id: int,
ticket_id: int,
body: str,
*,
is_internal_note: bool = False,
) -> tuple[SupportTicket, SupportTicketMessage]:
async with self.session_factory() as session:
ticket, _messages = await support_dal.get_ticket(
session,
ticket_id,
include_internal=True,
)
if not ticket:
raise TicketNotFound("not_found")
user = await user_dal.get_user_by_id(session, ticket.user_id)
message = await support_dal.add_message(
session,
ticket_id,
"admin",
admin_id,
body[: self.settings.SUPPORT_TICKET_MAX_BODY_LENGTH],
is_internal_note=is_internal_note,
)
if message is None:
raise TicketNotFound("not_found")
await support_dal.mark_read(session, ticket_id, "admin")
if not is_internal_note:
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": admin_id,
"event_type": "support_admin_reply",
"content": f"Support ticket #{ticket_id} reply",
"is_admin_event": True,
"target_user_id": ticket.user_id,
},
)
await session.refresh(ticket)
await session.commit()
if user and not is_internal_note:
try:
await self.notification_service.notify_support_admin_reply(ticket, message, user)
except Exception:
logger.exception("Failed to notify user about support admin reply %s", ticket_id)
return ticket, message
async def change_status(self, admin_id: int, ticket_id: int, status: str) -> SupportTicket:
return await self._update_and_audit(admin_id, ticket_id, status=status)
async def change_priority(self, admin_id: int, ticket_id: int, priority: str) -> SupportTicket:
return await self._update_and_audit(admin_id, ticket_id, priority=priority)
async def change_category(self, admin_id: int, ticket_id: int, category: str) -> SupportTicket:
return await self._update_and_audit(admin_id, ticket_id, category=category)
async def assign_admin(
self,
admin_id: int,
ticket_id: int,
assigned_admin_id: Optional[int],
) -> SupportTicket:
return await self._update_and_audit(
admin_id,
ticket_id,
assigned_admin_id=assigned_admin_id,
)
async def close_ticket(self, admin_id: int, ticket_id: int) -> SupportTicket:
ticket = await self._update_and_audit(
admin_id,
ticket_id,
status="closed",
closed_by_admin_id=admin_id,
)
async with self.session_factory() as session:
user = await user_dal.get_user_by_id(session, ticket.user_id)
if user:
try:
await self.notification_service.notify_support_ticket_closed(ticket, user, admin_id)
except Exception:
logger.exception("Failed to notify user about support close %s", ticket_id)
return ticket
async def _update_and_audit(
self,
admin_id: int,
ticket_id: int,
**updates: Any,
) -> SupportTicket:
async with self.session_factory() as session:
ticket = await support_dal.update_ticket(session, ticket_id, **updates)
if not ticket:
raise TicketNotFound("not_found")
await message_log_dal.create_message_log_no_commit(
session,
{
"user_id": admin_id,
"event_type": "support_ticket_update",
"content": f"Support ticket #{ticket_id}: {updates}",
"is_admin_event": True,
"target_user_id": ticket.user_id,
},
)
await session.commit()
return ticket
async def mark_read_as_user(self, user_id: int, ticket_id: int) -> None:
async with self.session_factory() as session:
ticket, _messages = await support_dal.get_ticket(session, ticket_id)
if not ticket or ticket.user_id != user_id:
raise TicketNotFound("not_found")
await support_dal.mark_read(session, ticket_id, "user")
await session.commit()
async def mark_read_as_admin(self, ticket_id: int) -> None:
async with self.session_factory() as session:
await support_dal.mark_read(session, ticket_id, "admin")
await session.commit()
async def build_user_snapshot(self, user: User, *, session=None) -> dict:
owns_session = session is None
if owns_session:
session = self.session_factory()
await session.__aenter__()
try:
sub = None
if getattr(user, "panel_user_uuid", None):
sub = await subscription_dal.get_active_subscription_by_user_id(
session,
int(user.user_id),
user.panel_user_uuid,
)
lang = (
getattr(user, "language_code", None) or self.settings.DEFAULT_LANGUAGE or "ru"
).split("-")[0]
tariff_name = ""
if sub and getattr(sub, "tariff_key", None) and self.settings.tariffs_config:
try:
tariff = self.settings.tariffs_config.require(str(sub.tariff_key))
tariff_name = tariff.name(lang)
except Exception:
tariff_name = str(sub.tariff_key or "")
end_date = getattr(sub, "end_date", None)
seconds_left = (
max(0, int((end_date - datetime.now(timezone.utc)).total_seconds()))
if end_date
else 0
)
return {
"user_id": int(user.user_id),
"name": " ".join(
part for part in [user.first_name, getattr(user, "last_name", None)] if part
)
or user.username
or str(user.user_id),
"username": user.username,
"email": user.email,
"telegram_id": user.telegram_id,
"language": lang,
"registration_date": (
user.registration_date.isoformat() if user.registration_date else None
),
"email_login": bool(user.email and user.email_verified_at),
"subscription_active": bool(sub),
"panel_status": getattr(sub, "status_from_panel", None) if sub else None,
"end_date": end_date.isoformat() if end_date else None,
"remaining": _format_support_remaining(seconds_left, lang),
"tariff": tariff_name or (getattr(sub, "tariff_key", None) if sub else ""),
"traffic_regular": self._traffic_snapshot(
getattr(sub, "traffic_used_bytes", 0) if sub else 0,
self._regular_limit(sub),
),
"traffic_premium": self._traffic_snapshot(
getattr(sub, "premium_used_bytes", 0) if sub else 0,
self._premium_limit(sub),
),
"is_throttled": bool(getattr(sub, "is_throttled", False)) if sub else False,
"topup_balance_bytes": int(getattr(sub, "topup_balance_bytes", 0) or 0)
if sub
else 0,
"premium_topup_balance_bytes": int(
getattr(sub, "premium_topup_balance_bytes", 0) or 0
)
if sub
else 0,
"lifetime_used_traffic_bytes": int(user.lifetime_used_traffic_bytes or 0),
}
finally:
if owns_session:
await session.__aexit__(None, None, None)
@staticmethod
def _regular_limit(sub) -> int:
if not sub:
return 0
if getattr(sub, "regular_unlimited_override", False):
return 0
return int(
(sub.traffic_limit_bytes or 0)
+ (sub.topup_balance_bytes or 0)
+ (getattr(sub, "regular_bonus_bytes", 0) or 0)
)
@staticmethod
def _premium_limit(sub) -> int:
if not sub:
return 0
if getattr(sub, "premium_unlimited_override", False):
return 0
return int(
(sub.premium_baseline_bytes or 0)
+ (sub.premium_topup_balance_bytes or 0)
+ (getattr(sub, "premium_topup_used_bytes", 0) or 0)
+ (getattr(sub, "premium_bonus_bytes", 0) or 0)
)
@staticmethod
def _traffic_snapshot(used: Any, limit: Any) -> dict:
used_value = max(0, int(used or 0))
limit_value = max(0, int(limit or 0))
percent = round((used_value / limit_value) * 100, 2) if limit_value else 0
left = max(0, limit_value - used_value) if limit_value else 0
return {
"used_bytes": used_value,
"limit_bytes": limit_value,
"percent": percent,
"left_bytes": left,
}