466 lines
17 KiB
Python
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,
|
|
}
|