Files
remnawave-minishop/backend/bot/services/support_service.py
T

466 lines
17 KiB
Python

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, i18n)
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,
}