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
+1
View File
@@ -33,6 +33,7 @@ tmp/
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
# Byte-compiled / optimized / DLL files
**/__pycache__/
+11
View File
@@ -304,13 +304,24 @@ LOG_LEVEL=INFO #
# Admin Logging Configuration
LOG_CHAT_ID=-1001234567890 # Telegram chat/group ID for admin notifications
LOG_THREAD_ID= # Optional: Thread ID for supergroup messages
LOG_SUPPORT_THREAD_ID= # Optional: Thread ID for support ticket messages
LOG_NEW_USERS=True # Log new user registrations
LOG_PAYMENTS=True # Log payments
LOG_SUPPORT=True # Log support tickets and replies
LOG_PROMO_ACTIVATIONS=True # Log promo code activations
LOG_TRIAL_ACTIVATIONS=True # Log trial activations
LOG_SUSPICIOUS_ACTIVITY=True # Log suspicious activity
LOG_ADMIN_ACTIONS=True # Log actions from users listed in ADMIN_IDS
# Support tickets
SUPPORT_TICKETS_ENABLED=True # Enable support tickets in the Mini App
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False # Send support ticket emails to admins with email addresses
SUPPORT_TICKET_MAX_BODY_LENGTH=4000 # Max message length
SUPPORT_TICKET_MAX_SUBJECT_LENGTH=160 # Max subject length
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR=5 # New tickets per user per hour
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300 # Min seconds between admin Telegram/log notifications per unread ticket
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800 # Min seconds between admin email notifications per unread ticket
# Embedded mode thumbnails. Please don't touch this if you don't know what it is.
INLINE_REFERRAL_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/1077/1077114.png
INLINE_USER_STATS_THUMBNAIL_URL=https://cdn-icons-png.flaticon.com/512/681/681494.png
+3
View File
@@ -7,6 +7,7 @@ bot_database.sqlite3
!.env.example
docker-compose-dev.yml
scratch_*.py
scratch/
*.local.*
node_modules/
.git/
@@ -15,9 +16,11 @@ node_modules/
bot/app/web/templates/subscription_webapp.css
bot/app/web/templates/subscription_webapp.js
bot/app/web/templates/subscription_webapp.min.*.js
bot/app/web/templates/subscription_webapp.*.css
backend/bot/app/web/templates/subscription_webapp.css
backend/bot/app/web/templates/subscription_webapp.js
backend/bot/app/web/templates/subscription_webapp.min.*.js
backend/bot/app/web/templates/subscription_webapp.*.css
tmp
.claude
@@ -7,12 +7,15 @@ from bot.payment_providers import (
build_provider_configs,
build_provider_services,
)
from bot.services.email_auth_service import EmailAuthService
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.support_service import SupportService
from config.settings import Settings
@@ -27,6 +30,23 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
email_auth_service = EmailAuthService(settings)
notification_service = NotificationService(
bot,
settings,
i18n,
session_factory=async_session_factory,
email_auth_service=email_auth_service,
bot_username=bot_username_for_default_return,
)
support_service = SupportService(
async_session_factory,
settings,
bot,
i18n,
notification_service,
email_auth_service,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
@@ -58,6 +78,9 @@ def build_core_services(
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"notification_service": notification_service,
"email_auth_service": email_auth_service,
"support_service": support_service,
"panel_webhook_service": panel_webhook_service,
"lknpd_service": lknpd_service,
}
+2
View File
@@ -15,6 +15,7 @@ from bot.app.web.admin_api_impl import (
routes as _routes,
settings as _settings,
stats as _stats,
support as _support,
sync as _sync,
tariffs as _tariffs,
themes as _themes,
@@ -30,6 +31,7 @@ _MODULES = (
_payments,
_promos,
_logs,
_support,
_broadcast,
_sync,
_ads,
@@ -45,6 +45,16 @@ def setup_admin_routes(app: web.Application) -> None:
router.add_get("/api/admin/logs", admin_logs_route)
router.add_get("/api/admin/support/tickets", admin_support_tickets_route)
router.add_get("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_detail_route)
router.add_post(
"/api/admin/support/tickets/{id:\\d+}/messages",
admin_support_ticket_reply_route,
)
router.add_patch("/api/admin/support/tickets/{id:\\d+}", admin_support_ticket_patch_route)
router.add_post("/api/admin/support/tickets/{id:\\d+}/read", admin_support_ticket_read_route)
router.add_get("/api/admin/support/stats", admin_support_stats_route)
router.add_post("/api/admin/broadcast", admin_broadcast_route)
router.add_post("/api/admin/sync", admin_sync_route)
@@ -0,0 +1,251 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, constr, field_validator
from bot.services.support_service import TicketNotFound
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
class AdminTicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
is_internal_note: bool = False
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]] = (
None
)
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
category: Optional[Literal["billing", "technical", "account", "other"]] = None
assigned_admin_id: Optional[int] = None
def _validate_model_payload(model_cls, payload: Dict[str, Any]):
try:
return model_cls.model_validate(payload), None
except ValidationError:
return None, _error(400, "invalid_request", "Invalid request")
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _user_display_name(user) -> Optional[str]:
if not user:
return None
name = " ".join(
part.strip() for part in [user.first_name, user.last_name] if part and part.strip()
).strip()
return name or user.username or user.email or str(user.user_id)
def _support_message_payload(
message: SupportTicketMessage,
*,
authors: Optional[Dict[int, Any]] = None,
) -> Dict[str, Any]:
author = authors.get(message.author_user_id) if authors and message.author_user_id else None
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"author_name": _user_display_name(author),
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _admin_support_user_payload(user) -> Dict[str, Any]:
if not user:
return {}
return {
"user_id": user.user_id,
"telegram_id": user.telegram_id,
"username": user.username,
"first_name": user.first_name,
"last_name": user.last_name,
"email": user.email,
"telegram_photo_url": user.telegram_photo_url,
"is_banned": bool(user.is_banned),
"registration_date": user.registration_date.isoformat() if user.registration_date else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def admin_support_tickets_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
limit, offset = _support_limit_offset(request)
assigned_raw = request.query.get("assigned")
assigned_admin_id = None
if assigned_raw and assigned_raw not in {"all", "any"}:
assigned_admin_id = int(assigned_raw)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_admin_tickets(
session,
status=request.query.get("status") or None,
priority=request.query.get("priority") or None,
category=request.query.get("category") or None,
assigned_admin_id=assigned_admin_id,
search=request.query.get("search") or None,
sort=request.query.get("sort") or "updated_desc",
limit=limit,
offset=offset,
)
return web.json_response(
{
"ok": True,
"tickets": [
{
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(getattr(ticket, "user", None)),
}
for ticket in tickets
],
}
)
async def admin_support_ticket_detail_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
service = request.app["support_service"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=True)
if not ticket:
return _error(404, "not_found", "Ticket not found")
user = await user_dal.get_user_by_id(session, ticket.user_id)
snapshot = await service.build_user_snapshot(user, session=session) if user else {}
author_ids = {m.author_user_id for m in messages if m.author_user_id is not None}
authors = {}
for author_id in author_ids:
author = await user_dal.get_user_by_id(session, author_id)
if author:
authors[author_id] = author
return web.json_response(
{
"ok": True,
"ticket": {
**_support_ticket_payload(ticket),
"user": _admin_support_user_payload(user),
},
"messages": [_support_message_payload(m, authors=authors) for m in messages],
"user_snapshot": snapshot,
}
)
async def admin_support_ticket_reply_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketReplyPayload, await _read_json(request))
if error:
return error
try:
ticket, message = await request.app["support_service"].reply_as_admin(
admin_id,
ticket_id,
payload.body,
is_internal_note=payload.is_internal_note,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
admin = await user_dal.get_user_by_id(session, admin_id)
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(
message, authors={admin_id: admin} if admin else {}
),
}
)
async def admin_support_ticket_patch_route(request: web.Request) -> web.Response:
admin_id = _require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(AdminTicketPatchPayload, await _read_json(request))
if error:
return error
updates = payload.model_dump(exclude_unset=True)
try:
if updates.get("status") == "closed":
ticket = await request.app["support_service"].close_ticket(admin_id, ticket_id)
updates.pop("status", None)
if updates:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
else:
ticket = await request.app["support_service"]._update_and_audit(
admin_id,
ticket_id,
**updates,
)
except TicketNotFound:
return _error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def admin_support_ticket_read_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
ticket_id = int(request.match_info["id"])
await request.app["support_service"].mark_read_as_admin(ticket_id)
return web.json_response({"ok": True})
async def admin_support_stats_route(request: web.Request) -> web.Response:
_require_admin_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
stats = await support_dal.admin_stats(session)
return web.json_response({"ok": True, "stats": stats})
+76 -5
View File
@@ -235,6 +235,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"),
SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"),
SettingField("LOG_SUPPORT", "bool", "notifications", "Логировать тикеты поддержки"),
SettingField(
"LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"
),
@@ -248,8 +249,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
"notifications",
"Логировать действия администраторов",
"Если выключено, события от пользователей из ADMIN_IDS не записываются в message logs.",
i18n_label_key="settings_field_log_admin_actions_label",
i18n_description_key="settings_field_log_admin_actions_description",
i18n_label_key="admin_settings_field_log_admin_actions_label",
i18n_description_key="admin_settings_field_log_admin_actions_description",
),
SettingField(
"LOG_LEVEL",
@@ -260,6 +261,73 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"),
SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"),
SettingField(
"LOG_SUPPORT_THREAD_ID",
"int",
"notifications",
"ID треда поддержки",
"Тред лог-чата для уведомлений о тикетах поддержки.",
),
SettingField(
"SUPPORT_TICKETS_ENABLED",
"bool",
"support",
"Тикеты поддержки включены",
"Показывает раздел поддержки в ЛК и включает создание тикетов.",
),
SettingField(
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
"bool",
"support",
"Email-уведомления админам",
(
"Если выключено, новые тикеты и ответы пользователей останутся "
"только в Telegram и лог-чате."
),
),
SettingField(
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
"int",
"support",
"Пауза Telegram-уведомлений",
(
"Минимум секунд между повторными Telegram/log уведомлениями "
"по одному непрочитанному тикету."
),
min=0,
),
SettingField(
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
"int",
"support",
"Пауза email-уведомлений",
"Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
min=0,
),
SettingField(
"SUPPORT_TICKET_MAX_BODY_LENGTH",
"int",
"support",
"Макс. длина сообщения",
"Максимальное количество символов в сообщении тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
"int",
"support",
"Макс. длина темы",
"Максимальное количество символов в теме тикета.",
min=1,
),
SettingField(
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
"int",
"support",
"Лимит тикетов в час",
"Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
min=0,
),
# ─── Devices ───────────────────────────────────────────────────
SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"),
SettingField(
@@ -371,12 +439,15 @@ def manifest_payload() -> List[dict]:
"trial": 5,
"referral": 6,
"notifications": 7,
"devices": 8,
"support": 8,
"devices": 9,
}
items: List[dict] = []
for field in aggregated_manifest():
auto_label_i18n_key = f"settings_field_{field.key.lower()}_label"
auto_description_i18n_key = f"settings_field_{field.key.lower()}_description"
auto_label_i18n_key = f"admin_settings_field_{field.key.lower()}_label"
auto_description_i18n_key = (
f"admin_settings_field_{field.key.lower()}_description"
)
default_value: Optional[str] = None
owner = find_manifest_owner(field.key)
@@ -14,6 +14,7 @@ from bot.app.web.webapp import (
payloads as _payloads,
routes as _routes,
serializers as _serializers,
support as _support,
)
_MODULES = (
@@ -26,6 +27,7 @@ _MODULES = (
_serializers,
_billing,
_devices,
_support,
_routes,
_application,
)
@@ -1,25 +1,60 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#03070b" />
<link id="app-favicon" rel="icon" href="data:," sizes="any" />
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css" />
<style>
.app-boot-fallback {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 24px;
background: #03070b;
}
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#03070b">
<link id="app-favicon" rel="icon" href="data:," sizes="any">
<title>/minishop</title>
<link rel="stylesheet" href="/subscription_webapp.css">
</head>
.app-boot-fallback__spinner {
width: 28px;
height: 28px;
border: 2px solid rgba(242, 247, 244, 0.18);
border-top-color: #00fe7a;
border-radius: 999px;
animation: appBootSpin 0.8s linear infinite;
}
<body>
<main id="app"></main>
@media (prefers-reduced-motion: reduce) {
.app-boot-fallback__spinner {
animation: none;
}
}
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
@keyframes appBootSpin {
to {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<main id="app">
<div class="app-boot-fallback" role="status" aria-label="Загрузка">
<div class="app-boot-fallback__spinner" aria-hidden="true"></div>
</div>
</main>
<!-- WEBAPP_I18N_SCRIPT -->
<!-- WEBAPP_CONFIG_SCRIPT -->
<!-- WEBAPP_JS_SCRIPT -->
<!-- WEBAPP_DEV_MOCK_START -->
<script src="/subscription_webapp.js" defer></script>
<!-- WEBAPP_DEV_MOCK_END -->
</body>
</html>
+1 -1
View File
@@ -51,7 +51,7 @@ from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, security_dal, subscription_dal, user_dal
from db.dal import payment_dal, security_dal, subscription_dal, support_dal, user_dal
from db.dal.user_dal import UserMergeConflictError
from db.models import Payment, User, UserTelegramAvatar
@@ -44,6 +44,9 @@ def create_subscription_webapp_application(
"subscription_service",
"promo_code_service",
"referral_service",
"support_service",
"notification_service",
"email_auth_service",
"panel_service",
*iter_service_keys(),
):
+87 -7
View File
@@ -15,7 +15,13 @@ async def health_route(request: web.Request) -> web.Response:
async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
asset_hash = request.match_info.get("asset_hash")
filename = f"subscription_webapp.{asset_hash}.css" if asset_hash else "subscription_webapp.css"
response = await _serve_template_asset(request, filename, "text/css")
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable" if asset_hash else "no-cache"
)
return response
def _safe_theme_css_relative_path(raw_path: str) -> Optional[Path]:
@@ -121,9 +127,7 @@ async def theme_asset_route(request: web.Request) -> web.Response:
query = getattr(request, "query", {})
response = web.Response(body=body, content_type=content_type)
response.headers["Cache-Control"] = (
"public, max-age=31536000, immutable"
if query.get("v")
else "public, max-age=3600"
"public, max-age=31536000, immutable" if query.get("v") else "public, max-age=3600"
)
return response
@@ -887,6 +891,41 @@ async def js_asset_route(request: web.Request) -> web.Response:
return response
WEBAPP_BOOTSTRAP_I18N_PREFIXES = ("wa_",)
WEBAPP_BOOTSTRAP_I18N_KEYS = {"menu_support_button"}
WEBAPP_I18N_SCOPES = {"webapp", "admin"}
def _is_webapp_bootstrap_i18n_key(key: str) -> bool:
return key in WEBAPP_BOOTSTRAP_I18N_KEYS or key.startswith(WEBAPP_BOOTSTRAP_I18N_PREFIXES)
def _normalize_i18n_scope(raw_scope: object) -> str:
scope = str(raw_scope or "webapp").strip().lower()
return scope if scope in WEBAPP_I18N_SCOPES else "webapp"
def _filter_webapp_i18n_payload(locales_data: object, scope: str = "webapp") -> Dict[str, Any]:
if not isinstance(locales_data, dict):
return {}
normalized_scope = _normalize_i18n_scope(scope)
payload: Dict[str, Any] = {}
for lang, messages in locales_data.items():
if not isinstance(messages, dict):
continue
filtered: Dict[str, Any] = {}
for key, value in messages.items():
key_text = str(key)
is_bootstrap_key = _is_webapp_bootstrap_i18n_key(key_text)
if (normalized_scope == "webapp" and is_bootstrap_key) or (
normalized_scope == "admin" and not is_bootstrap_key
):
filtered[key_text] = value
payload[str(lang)] = filtered
return payload
def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
cached = _get_cached_webapp_settings(request)
@@ -897,6 +936,8 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
if preview_theme is None or not preview_theme.enabled:
preview_key = ""
i18n_instance: Optional[object] = request.app.get("i18n")
i18n_scope = _normalize_i18n_scope(request.query.get("i18n_scope") or "webapp")
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
return {
"config": {
"title": settings.WEBAPP_TITLE,
@@ -929,12 +970,29 @@ def _build_webapp_bootstrap_payload(request: web.Request) -> Dict[str, Any]:
"appVersion": _resolve_app_version(),
"appRepositoryUrl": APP_REPOSITORY_URL,
},
"i18n": getattr(i18n_instance, "locales_data", {}) if i18n_instance else {},
"i18n": _filter_webapp_i18n_payload(locales_data, i18n_scope),
}
async def bootstrap_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
response = web.json_response({"ok": True, **_build_webapp_bootstrap_payload(request)})
response.headers["Cache-Control"] = "no-cache"
return response
async def i18n_route(request: web.Request) -> web.Response:
i18n_instance: Optional[object] = request.app.get("i18n")
scope = _normalize_i18n_scope(request.query.get("scope") or "webapp")
locales_data = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {}
response = web.json_response(
{
"ok": True,
"scope": scope,
"i18n": _filter_webapp_i18n_payload(locales_data, scope),
}
)
response.headers["Cache-Control"] = "no-cache"
return response
async def index_route(request: web.Request) -> web.Response:
@@ -950,6 +1008,11 @@ async def index_route(request: web.Request) -> web.Response:
bootstrap = _build_webapp_bootstrap_payload(request)
config = bootstrap["config"]
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace(
'href="/subscription_webapp.css"',
f'href="/{_resolve_webapp_css_asset_name()}"',
1,
)
initial_theme_markup = _initial_theme_head_markup(request, initial_theme, primary_color)
if initial_theme_markup:
html = html.replace("</head>", f"{initial_theme_markup}\n</head>", 1)
@@ -997,7 +1060,9 @@ async def index_route(request: web.Request) -> web.Response:
),
1,
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
response = web.Response(text=html, content_type="text/html", charset="utf-8")
response.headers["Cache-Control"] = "no-cache"
return response
async def _serve_template_asset(
@@ -1035,6 +1100,21 @@ def _resolve_webapp_js_asset_name() -> str:
return "subscription_webapp.js"
def _resolve_webapp_css_asset_name() -> str:
hashed_assets = []
for path in ASSET_DIR.glob("subscription_webapp.*.css"):
if not re.fullmatch(r"subscription_webapp\.[0-9a-f]{8}\.css", path.name):
continue
try:
hashed_assets.append((path.stat().st_mtime, path.name))
except OSError:
continue
if hashed_assets:
hashed_assets.sort(reverse=True)
return hashed_assets[0][1]
return "subscription_webapp.css"
_INITIAL_THEME_TOKEN_CSS_MAP = {
"accent": "--accent",
"bg": "--bg",
+51
View File
@@ -1,6 +1,8 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from typing import Literal
class WebAppEmailPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
@@ -69,3 +71,52 @@ class WebAppDeviceDisconnectPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
token: constr(min_length=8, max_length=128)
SupportCategory = Literal["billing", "technical", "account", "other"]
SupportPriority = Literal["low", "normal", "high", "urgent"]
SupportStatus = Literal["open", "awaiting_user", "awaiting_admin", "resolved", "closed"]
class CreateTicketPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
subject: constr(min_length=1, max_length=160)
category: SupportCategory = "other"
priority: Literal["normal", "high"] = "normal"
body: constr(min_length=1, max_length=4000)
@field_validator("subject", "body")
@classmethod
def _strip_required_text(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class TicketReplyPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
body: constr(min_length=1, max_length=4000)
@field_validator("body")
@classmethod
def _strip_body(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("empty_text")
return stripped
class AdminTicketReplyPayload(TicketReplyPayload):
is_internal_note: bool = False
class AdminTicketPatchPayload(BaseModel):
model_config = ConfigDict(extra="ignore")
status: Optional[SupportStatus] = None
priority: Optional[SupportPriority] = None
category: Optional[SupportCategory] = None
assigned_admin_id: Optional[int] = None
+12 -1
View File
@@ -9,15 +9,18 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/invite", index_route)
app.router.add_get("/devices", index_route)
app.router.add_get("/settings", index_route)
app.router.add_get("/support", index_route)
app.router.add_get("/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/admin", index_route)
app.router.add_get(
(
"/admin/{section:stats|users|payments|promos|ads|broadcast|logs|tariffs|"
"appearance|settings}"
"appearance|settings|support}"
),
index_route,
)
app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route)
app.router.add_get("/admin/support/{ticket_id:\\d+}", index_route)
app.router.add_get("/auth/telegram/start", telegram_oauth_start_route)
app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route)
app.router.add_get("/health", health_route)
@@ -34,6 +37,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}",
webapp_animated_emoji_route,
)
app.router.add_get("/subscription_webapp.{asset_hash:[0-9a-f]{8}}.css", css_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get(r"/webapp-theme-css/{path:.+}", theme_css_asset_route)
app.router.add_get(r"/webapp-theme-assets/{path:.+}", theme_asset_route)
@@ -47,6 +51,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_post("/api/auth/email/password", email_password_auth_route)
app.router.add_post("/api/auth/logout", logout_route)
app.router.add_get("/api/bootstrap", bootstrap_route)
app.router.add_get("/api/i18n", i18n_route)
app.router.add_get("/api/me", me_route)
app.router.add_get("/api/account/avatar", account_avatar_route)
app.router.add_post("/api/account/language", account_language_route)
@@ -60,6 +65,12 @@ def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/api/devices", devices_route)
app.router.add_post("/api/devices/disconnect", disconnect_device_route)
app.router.add_get("/api/devices/topup-options", device_topup_options_route)
app.router.add_get("/api/support/tickets", support_tickets_route)
app.router.add_post("/api/support/tickets", support_create_ticket_route)
app.router.add_get("/api/support/tickets/{id:\\d+}", support_ticket_detail_route)
app.router.add_post("/api/support/tickets/{id:\\d+}/messages", support_ticket_reply_route)
app.router.add_post("/api/support/tickets/{id:\\d+}/read", support_ticket_read_route)
app.router.add_get("/api/support/unread", support_unread_route)
app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route)
app.router.add_get("/api/tariffs/change-options", tariff_change_options_route)
app.router.add_post("/api/tariffs/change", tariff_change_route)
+11
View File
@@ -38,6 +38,11 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
if referral_service
else {"invited_count": 0, "purchased_count": 0}
)
support_unread_count = (
await support_dal.count_user_unread(session, user_id)
if settings.SUPPORT_TICKETS_ENABLED
else 0
)
local_sub = (
await subscription_dal.get_active_subscription_by_user_id(
session,
@@ -106,8 +111,14 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
enabled_only=True,
),
"support_unread_count": int(support_unread_count or 0),
"settings": {
"support_url": settings.SUPPORT_LINK,
"support_tickets_enabled": bool(settings.SUPPORT_TICKETS_ENABLED),
"support_ticket_max_body_length": int(settings.SUPPORT_TICKET_MAX_BODY_LENGTH or 4000),
"support_ticket_max_subject_length": int(
settings.SUPPORT_TICKET_MAX_SUBJECT_LENGTH or 160
),
"traffic_mode": bool(settings.traffic_sale_mode),
"my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED),
"user_hwid_device_limit": (
+152
View File
@@ -0,0 +1,152 @@
# ruff: noqa: F401,F403,F405,I001
from ._runtime import * # noqa: F403,F405
from bot.services.support_service import TicketForbidden, TicketNotFound, TicketRateLimited
from db.dal import support_dal, user_dal
from db.models import SupportTicket, SupportTicketMessage
def _support_ticket_payload(ticket: SupportTicket) -> Dict[str, Any]:
return {
"ticket_id": ticket.ticket_id,
"user_id": ticket.user_id,
"subject": ticket.subject,
"category": ticket.category,
"priority": ticket.priority,
"status": ticket.status,
"assigned_admin_id": ticket.assigned_admin_id,
"last_message_at": ticket.last_message_at.isoformat() if ticket.last_message_at else None,
"last_message_role": ticket.last_message_role,
"unread_user_count": int(ticket.unread_user_count or 0),
"unread_admin_count": int(ticket.unread_admin_count or 0),
"created_at": ticket.created_at.isoformat() if ticket.created_at else None,
"updated_at": ticket.updated_at.isoformat() if ticket.updated_at else None,
"closed_at": ticket.closed_at.isoformat() if ticket.closed_at else None,
}
def _support_message_payload(message: SupportTicketMessage) -> Dict[str, Any]:
return {
"message_id": message.message_id,
"ticket_id": message.ticket_id,
"author_role": message.author_role,
"author_user_id": message.author_user_id,
"body": message.body,
"is_internal_note": bool(message.is_internal_note),
"created_at": message.created_at.isoformat() if message.created_at else None,
"read_by_user_at": message.read_by_user_at.isoformat() if message.read_by_user_at else None,
"read_by_admin_at": message.read_by_admin_at.isoformat()
if message.read_by_admin_at
else None,
}
def _support_limit_offset(request: web.Request) -> tuple[int, int]:
limit = max(1, min(100, int(request.query.get("limit", 25) or 25)))
offset = max(0, int(request.query.get("offset", 0) or 0))
return limit, offset
async def support_tickets_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
limit, offset = _support_limit_offset(request)
status_filter = request.query.get("status") or None
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
tickets = await support_dal.list_user_tickets(
session,
user_id,
limit=limit,
offset=offset,
status_filter=status_filter,
)
counts = await support_dal.user_ticket_counts(session, user_id)
return web.json_response(
{
"ok": True,
"tickets": [_support_ticket_payload(t) for t in tickets],
"counts": counts,
}
)
async def support_create_ticket_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload, error = _validate_model_payload(CreateTicketPayload, await _read_json(request))
if error:
return error
service = request.app["support_service"]
try:
ticket = await service.create_ticket(
user_id,
payload.subject,
payload.category,
payload.priority,
payload.body,
)
except TicketForbidden:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
except TicketRateLimited:
return _json_error(429, "ticket_rate_limited", "Too many support tickets")
return web.json_response({"ok": True, "ticket": _support_ticket_payload(ticket)})
async def support_ticket_detail_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
ticket, messages = await support_dal.get_ticket(session, ticket_id, include_internal=False)
if not ticket or ticket.user_id != user_id:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"messages": [_support_message_payload(m) for m in messages],
}
)
async def support_ticket_reply_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
payload, error = _validate_model_payload(TicketReplyPayload, await _read_json(request))
if error:
return error
service = request.app["support_service"]
try:
ticket, message = await service.reply_as_user(user_id, ticket_id, payload.body)
except TicketForbidden:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
except TicketNotFound:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response(
{
"ok": True,
"ticket": _support_ticket_payload(ticket),
"message": _support_message_payload(message),
}
)
async def support_ticket_read_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
ticket_id = int(request.match_info["id"])
service = request.app["support_service"]
try:
await service.mark_read_as_user(user_id, ticket_id)
except TicketNotFound:
return _json_error(404, "not_found", "Ticket not found")
return web.json_response({"ok": True})
async def support_unread_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
user = await user_dal.get_user_by_id(session, user_id)
if user and user.is_banned:
return _json_error(403, "ticket_forbidden", "Support ticket action is forbidden")
unread = await support_dal.count_user_unread(session, user_id)
return web.json_response({"ok": True, "unread": unread})
+28 -1
View File
@@ -387,11 +387,12 @@ async def ensure_required_channel_subscription(
)
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^ticket_(\d+)$").as_("ticket_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(
CommandStart(
magic=F.args.regexp(
r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
r"^(?!ref_|promo_|admin_user_|ticket_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$"
).as_("ad_param_match")
)
)
@@ -408,6 +409,7 @@ async def start_command_handler(
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None,
ticket_match: Optional[re.Match] = None,
):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
@@ -465,6 +467,31 @@ async def start_command_handler(
await message.answer(_("admin_user_card_error"))
return
if ticket_match:
ticket_id = int(ticket_match.group(1))
base_url = (settings.SUBSCRIPTION_MINI_APP_URL or "").strip()
if base_url:
ticket_url = f"{base_url.rstrip('/')}/support/{ticket_id}"
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=i18n.gettext(current_lang, "wa_support_open_ticket")
if i18n
else "Открыть тикет",
web_app=types.WebAppInfo(url=ticket_url),
)
]
]
)
await message.answer(
i18n.gettext(current_lang, "wa_support_open_ticket_hint")
if i18n
else "Откройте тикет в Mini App.",
reply_markup=keyboard,
)
return
referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
+3
View File
@@ -196,6 +196,9 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
"promo_code_service",
"subscription_service",
"referral_service",
"support_service",
"notification_service",
"email_auth_service",
*iter_service_keys(),
):
await close_service(service_key)
+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,
}
+12 -1
View File
@@ -336,6 +336,13 @@ class Settings(BaseSettings):
description="Log updates/events triggered by users from ADMIN_IDS.",
)
SUPPORT_TICKETS_ENABLED: bool = Field(default=True)
SUPPORT_TICKET_MAX_BODY_LENGTH: int = Field(default=4000)
SUPPORT_TICKET_MAX_SUBJECT_LENGTH: int = Field(default=160)
SUPPORT_TICKET_RATE_LIMIT_PER_HOUR: int = Field(default=5)
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED: bool = Field(default=False)
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS: int = Field(default=5 * 60)
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS: int = Field(default=30 * 60)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
START_COMMAND_DESCRIPTION: Optional[str] = Field(default=None)
@@ -805,6 +812,9 @@ class Settings(BaseSettings):
LOG_THREAD_ID: Optional[int] = Field(
default=None, description="Thread ID for supergroup messages (optional)"
)
LOG_SUPPORT_THREAD_ID: Optional[int] = Field(
default=None, description="Thread ID for support ticket log messages"
)
@field_validator("LOG_LEVEL", mode="before")
@classmethod
@@ -835,7 +845,7 @@ class Settings(BaseSettings):
return v
return secrets.token_urlsafe(32)
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", mode="before")
@field_validator("LOG_CHAT_ID", "LOG_THREAD_ID", "LOG_SUPPORT_THREAD_ID", mode="before")
@classmethod
def validate_optional_int_fields(cls, v):
"""Convert empty strings to None for optional integer fields"""
@@ -890,6 +900,7 @@ class Settings(BaseSettings):
LOG_SUSPICIOUS_ACTIVITY: bool = Field(
default=True, description="Send notifications for suspicious promo attempts"
)
LOG_SUPPORT: bool = Field(default=True, description="Send support ticket notifications")
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore", populate_by_name=True
+2
View File
@@ -7,6 +7,7 @@ from . import (
promo_code_dal,
security_dal,
subscription_dal,
support_dal,
user_billing_dal,
user_dal,
)
@@ -22,4 +23,5 @@ __all__ = (
"ad_dal",
"security_dal",
"app_settings_dal",
"support_dal",
)
+11
View File
@@ -37,6 +37,17 @@ async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]:
return {row.key: _decode(row.value) for row in rows}
async def get_override_value(session: AsyncSession, key: str) -> Tuple[bool, Any]:
row = (
await session.execute(
select(AppSettingOverride).where(AppSettingOverride.key == key).limit(1)
)
).scalar_one_or_none()
if row is None:
return False, None
return True, _decode(row.value)
async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]:
rows = (await session.execute(select(AppSettingOverride))).scalars().all()
items: List[Dict[str, Any]] = []
+375
View File
@@ -0,0 +1,375 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import and_, case, desc, func, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
from ..models import SupportTicket, SupportTicketMessage, User
ACTIVE_STATUSES = {"open", "awaiting_user", "awaiting_admin"}
CLOSED_STATUSES = {"resolved", "closed"}
_UNSET = object()
def _status_condition(status: Optional[str]):
normalized = (status or "").strip().lower()
if not normalized or normalized in {"all", "any"}:
return None
if normalized == "active":
return SupportTicket.status.in_(ACTIVE_STATUSES)
if normalized == "closed":
return SupportTicket.status.in_(CLOSED_STATUSES)
return SupportTicket.status == normalized
async def create_ticket(
session: AsyncSession,
user_id: int,
subject: str,
category: str,
priority: str,
first_message_body: str,
) -> SupportTicket:
now = datetime.now(timezone.utc)
ticket = SupportTicket(
user_id=user_id,
subject=subject,
category=category,
priority=priority,
status="awaiting_admin",
last_message_at=now,
last_message_role="user",
unread_admin_count=1,
unread_user_count=0,
admin_last_notified_at=now,
admin_last_emailed_at=now,
)
session.add(ticket)
await session.flush()
message = SupportTicketMessage(
ticket_id=ticket.ticket_id,
author_role="user",
author_user_id=user_id,
body=first_message_body,
is_internal_note=False,
created_at=now,
)
session.add(message)
await session.flush()
await session.refresh(ticket)
return ticket
async def add_message(
session: AsyncSession,
ticket_id: int,
author_role: str,
author_user_id: Optional[int],
body: str,
is_internal_note: bool = False,
) -> Optional[SupportTicketMessage]:
stmt = select(SupportTicket).where(SupportTicket.ticket_id == ticket_id).with_for_update()
result = await session.execute(stmt)
ticket = result.scalar_one_or_none()
if not ticket:
return None
now = datetime.now(timezone.utc)
message = SupportTicketMessage(
ticket_id=ticket_id,
author_role=author_role,
author_user_id=author_user_id,
body=body,
is_internal_note=bool(is_internal_note),
created_at=now,
)
session.add(message)
ticket.last_message_at = now
ticket.last_message_role = author_role
ticket.updated_at = now
if author_role == "user":
ticket.unread_admin_count = int(ticket.unread_admin_count or 0) + 1
if ticket.status not in CLOSED_STATUSES:
ticket.status = "awaiting_admin"
elif author_role == "admin" and not is_internal_note:
ticket.unread_user_count = int(ticket.unread_user_count or 0) + 1
if ticket.status not in CLOSED_STATUSES:
ticket.status = "awaiting_user"
await session.flush()
await session.refresh(message)
return message
async def record_admin_notification(
session: AsyncSession,
ticket_id: int,
*,
notified_at: Optional[datetime] = None,
emailed_at: Optional[datetime] = None,
) -> None:
values = {}
if notified_at is not None:
values["admin_last_notified_at"] = notified_at
if emailed_at is not None:
values["admin_last_emailed_at"] = emailed_at
if not values:
return
await session.execute(
update(SupportTicket).where(SupportTicket.ticket_id == ticket_id).values(**values)
)
await session.flush()
async def get_ticket(
session: AsyncSession,
ticket_id: int,
*,
include_internal: bool = False,
) -> tuple[Optional[SupportTicket], list[SupportTicketMessage]]:
stmt = (
select(SupportTicket)
.where(SupportTicket.ticket_id == ticket_id)
.options(selectinload(SupportTicket.user))
)
result = await session.execute(stmt)
ticket = result.scalar_one_or_none()
if not ticket:
return None, []
msg_stmt = select(SupportTicketMessage).where(SupportTicketMessage.ticket_id == ticket_id)
if not include_internal:
msg_stmt = msg_stmt.where(SupportTicketMessage.is_internal_note.is_(False))
msg_stmt = msg_stmt.order_by(
SupportTicketMessage.created_at.asc(),
SupportTicketMessage.message_id.asc(),
)
msg_result = await session.execute(msg_stmt)
return ticket, list(msg_result.scalars().all())
async def list_user_tickets(
session: AsyncSession,
user_id: int,
*,
limit: int,
offset: int,
status_filter: Optional[str] = None,
) -> list[SupportTicket]:
stmt = select(SupportTicket).where(SupportTicket.user_id == user_id)
status_cond = _status_condition(status_filter)
if status_cond is not None:
stmt = stmt.where(status_cond)
stmt = (
stmt.order_by(desc(SupportTicket.last_message_at), desc(SupportTicket.ticket_id))
.limit(limit)
.offset(offset)
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def list_admin_tickets(
session: AsyncSession,
*,
status: Optional[str] = None,
priority: Optional[str] = None,
category: Optional[str] = None,
assigned_admin_id: Optional[int] = None,
search: Optional[str] = None,
sort: str = "updated_desc",
limit: int,
offset: int,
) -> list[SupportTicket]:
stmt = select(SupportTicket).join(User, User.user_id == SupportTicket.user_id)
status_cond = _status_condition(status)
if status_cond is not None:
stmt = stmt.where(status_cond)
if priority:
stmt = stmt.where(SupportTicket.priority == priority)
if category:
stmt = stmt.where(SupportTicket.category == category)
if assigned_admin_id is not None:
stmt = stmt.where(SupportTicket.assigned_admin_id == assigned_admin_id)
if search:
pattern = f"%{search.strip().lower()}%"
stmt = stmt.where(
or_(
func.lower(SupportTicket.subject).like(pattern),
func.lower(User.username).like(pattern),
func.lower(User.first_name).like(pattern),
func.lower(User.email).like(pattern),
)
)
priority_rank = case(
(SupportTicket.priority == "urgent", 4),
(SupportTicket.priority == "high", 3),
(SupportTicket.priority == "normal", 2),
(SupportTicket.priority == "low", 1),
else_=0,
)
sort_key = (sort or "updated_desc").strip().lower()
sort_map = {
"updated_desc": (SupportTicket.last_message_at.desc().nullslast(),),
"updated_asc": (SupportTicket.last_message_at.asc().nullslast(),),
"created_desc": (SupportTicket.created_at.desc().nullslast(),),
"created_asc": (SupportTicket.created_at.asc().nullslast(),),
"importance_desc": (
priority_rank.desc(),
SupportTicket.last_message_at.desc().nullslast(),
),
"importance_asc": (
priority_rank.asc(),
SupportTicket.last_message_at.desc().nullslast(),
),
}
order_by = sort_map.get(sort_key, sort_map["updated_desc"])
stmt = stmt.options(selectinload(SupportTicket.user)).order_by(
*order_by,
desc(SupportTicket.ticket_id),
)
stmt = stmt.limit(limit).offset(offset)
result = await session.execute(stmt)
return list(result.scalars().unique().all())
async def user_ticket_counts(session: AsyncSession, user_id: int) -> dict:
stmt = (
select(SupportTicket.status, func.count())
.where(SupportTicket.user_id == user_id)
.group_by(SupportTicket.status)
)
result = await session.execute(stmt)
by_status = {str(status): int(count or 0) for status, count in result.all()}
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
return {
**by_status,
"active": active,
"closed": closed,
"total": active + closed,
}
async def mark_read(session: AsyncSession, ticket_id: int, role: str) -> None:
now = datetime.now(timezone.utc)
if role == "user":
await session.execute(
update(SupportTicket)
.where(SupportTicket.ticket_id == ticket_id)
.values(unread_user_count=0, updated_at=now)
)
await session.execute(
update(SupportTicketMessage)
.where(
and_(
SupportTicketMessage.ticket_id == ticket_id,
SupportTicketMessage.author_role == "admin",
SupportTicketMessage.is_internal_note.is_(False),
SupportTicketMessage.read_by_user_at.is_(None),
)
)
.values(read_by_user_at=now)
)
elif role == "admin":
await session.execute(
update(SupportTicket)
.where(SupportTicket.ticket_id == ticket_id)
.values(unread_admin_count=0, updated_at=now)
)
await session.execute(
update(SupportTicketMessage)
.where(
and_(
SupportTicketMessage.ticket_id == ticket_id,
SupportTicketMessage.author_role == "user",
SupportTicketMessage.read_by_admin_at.is_(None),
)
)
.values(read_by_admin_at=now)
)
await session.flush()
async def update_ticket(
session: AsyncSession,
ticket_id: int,
*,
status: Optional[str] = None,
priority: Optional[str] = None,
category: Optional[str] = None,
assigned_admin_id: object = _UNSET,
closed_by_admin_id: Optional[int] = None,
) -> Optional[SupportTicket]:
ticket = await session.get(SupportTicket, ticket_id)
if not ticket:
return None
now = datetime.now(timezone.utc)
if status is not None:
ticket.status = status
if status == "closed":
ticket.closed_at = now
ticket.closed_by_admin_id = closed_by_admin_id
elif status != "closed":
ticket.closed_at = None
ticket.closed_by_admin_id = None
if priority is not None:
ticket.priority = priority
if category is not None:
ticket.category = category
if assigned_admin_id is not _UNSET:
ticket.assigned_admin_id = assigned_admin_id
ticket.updated_at = now
await session.flush()
await session.refresh(ticket)
return ticket
async def count_user_unread(session: AsyncSession, user_id: int) -> int:
stmt = select(func.coalesce(func.sum(SupportTicket.unread_user_count), 0)).where(
SupportTicket.user_id == user_id
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
async def admin_stats(session: AsyncSession) -> dict:
status_result = await session.execute(
select(SupportTicket.status, func.count()).group_by(SupportTicket.status)
)
by_status = {str(status): int(count or 0) for status, count in status_result.all()}
unread_result = await session.execute(
select(func.coalesce(func.sum(SupportTicket.unread_admin_count), 0))
)
active = sum(by_status.get(status, 0) for status in ACTIVE_STATUSES)
closed = sum(by_status.get(status, 0) for status in CLOSED_STATUSES)
return {
**by_status,
"active": active,
"closed": closed,
"total": active + closed,
"open": by_status.get("open", 0),
"awaiting_admin": by_status.get("awaiting_admin", 0),
"awaiting_user": by_status.get("awaiting_user", 0),
"total_unread_admin": int(unread_result.scalar_one() or 0),
}
async def count_recent_tickets_for_user(
session: AsyncSession,
user_id: int,
window_seconds: int,
) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(seconds=max(1, int(window_seconds)))
stmt = (
select(func.count())
.select_from(SupportTicket)
.where(SupportTicket.user_id == user_id, SupportTicket.created_at >= cutoff)
)
result = await session.execute(stmt)
return int(result.scalar_one() or 0)
+130
View File
@@ -754,6 +754,126 @@ def _migration_0023_add_email_password_auth_fields(connection: Connection) -> No
connection.execute(text("ALTER TABLE users ADD COLUMN password_set_at TIMESTAMPTZ"))
def _migration_0024_add_support_tickets(connection: Connection) -> None:
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS support_tickets (
ticket_id SERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(user_id),
subject VARCHAR(160) NOT NULL,
category VARCHAR(32) NOT NULL DEFAULT 'other',
priority VARCHAR(16) NOT NULL DEFAULT 'normal',
status VARCHAR(24) NOT NULL DEFAULT 'open',
assigned_admin_id BIGINT NULL,
last_message_at TIMESTAMPTZ DEFAULT NOW(),
last_message_role VARCHAR(16) NULL,
unread_user_count INTEGER NOT NULL DEFAULT 0,
unread_admin_count INTEGER NOT NULL DEFAULT 0,
admin_last_notified_at TIMESTAMPTZ NULL,
admin_last_emailed_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ NULL,
closed_at TIMESTAMPTZ NULL,
closed_by_admin_id BIGINT NULL
)
"""
)
)
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS support_ticket_messages (
message_id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE,
author_role VARCHAR(16) NOT NULL,
author_user_id BIGINT NULL REFERENCES users(user_id),
body TEXT NOT NULL,
is_internal_note BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
read_by_user_at TIMESTAMPTZ NULL,
read_by_admin_at TIMESTAMPTZ NULL
)
"""
)
)
inspector = inspect(connection)
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
ticket_column_sql = {
"user_id": "BIGINT NOT NULL REFERENCES users(user_id)",
"subject": "VARCHAR(160) NOT NULL DEFAULT ''",
"category": "VARCHAR(32) NOT NULL DEFAULT 'other'",
"priority": "VARCHAR(16) NOT NULL DEFAULT 'normal'",
"status": "VARCHAR(24) NOT NULL DEFAULT 'open'",
"assigned_admin_id": "BIGINT NULL",
"last_message_at": "TIMESTAMPTZ DEFAULT NOW()",
"last_message_role": "VARCHAR(16) NULL",
"unread_user_count": "INTEGER NOT NULL DEFAULT 0",
"unread_admin_count": "INTEGER NOT NULL DEFAULT 0",
"admin_last_notified_at": "TIMESTAMPTZ NULL",
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
"updated_at": "TIMESTAMPTZ NULL",
"closed_at": "TIMESTAMPTZ NULL",
"closed_by_admin_id": "BIGINT NULL",
}
for column, definition in ticket_column_sql.items():
if column not in ticket_columns:
connection.execute(
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
)
message_columns: Set[str] = {
col["name"] for col in inspector.get_columns("support_ticket_messages")
}
message_column_sql = {
"ticket_id": "INTEGER NOT NULL REFERENCES support_tickets(ticket_id) ON DELETE CASCADE",
"author_role": "VARCHAR(16) NOT NULL DEFAULT 'user'",
"author_user_id": "BIGINT NULL REFERENCES users(user_id)",
"body": "TEXT NOT NULL DEFAULT ''",
"is_internal_note": "BOOLEAN NOT NULL DEFAULT FALSE",
"created_at": "TIMESTAMPTZ DEFAULT NOW()",
"read_by_user_at": "TIMESTAMPTZ NULL",
"read_by_admin_at": "TIMESTAMPTZ NULL",
}
for column, definition in message_column_sql.items():
if column not in message_columns:
connection.execute(
text(f"ALTER TABLE support_ticket_messages ADD COLUMN {column} {definition}")
)
index_statements = [
"CREATE INDEX IF NOT EXISTS ix_support_tickets_user_id ON support_tickets (user_id)",
"CREATE INDEX IF NOT EXISTS ix_support_tickets_category ON support_tickets (category)",
"CREATE INDEX IF NOT EXISTS ix_support_tickets_priority ON support_tickets (priority)",
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status ON support_tickets (status)",
"CREATE INDEX IF NOT EXISTS ix_support_tickets_assigned_admin_id ON support_tickets (assigned_admin_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_tickets_last_message_at ON support_tickets (last_message_at)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_tickets_status_last_msg ON support_tickets (status, last_message_at)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_ticket_id ON support_ticket_messages (ticket_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_author_user_id ON support_ticket_messages (author_user_id)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_is_internal_note ON support_ticket_messages (is_internal_note)", # noqa: E501
"CREATE INDEX IF NOT EXISTS ix_support_ticket_messages_created_at ON support_ticket_messages (created_at)", # noqa: E501
]
for stmt in index_statements:
connection.execute(text(stmt))
def _migration_0025_add_support_notification_timestamps(connection: Connection) -> None:
inspector = inspect(connection)
ticket_columns: Set[str] = {col["name"] for col in inspector.get_columns("support_tickets")}
column_sql = {
"admin_last_notified_at": "TIMESTAMPTZ NULL",
"admin_last_emailed_at": "TIMESTAMPTZ NULL",
}
for column, definition in column_sql.items():
if column not in ticket_columns:
connection.execute(
text(f"ALTER TABLE support_tickets ADD COLUMN {column} {definition}")
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -881,6 +1001,16 @@ MIGRATIONS: List[Migration] = [
description="Store hashed passwords for optional email password login",
upgrade=_migration_0023_add_email_password_auth_fields,
),
Migration(
id="0024_add_support_tickets",
description="Add support ticket inbox and messages",
upgrade=_migration_0024_add_support_tickets,
),
Migration(
id="0025_add_support_notification_timestamps",
description="Track support ticket admin notification cooldown timestamps",
upgrade=_migration_0025_add_support_notification_timestamps,
),
]
+56
View File
@@ -374,6 +374,62 @@ class MessageLog(Base):
)
class SupportTicket(Base):
__tablename__ = "support_tickets"
ticket_id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=False, index=True)
subject = Column(String(160), nullable=False)
category = Column(String(32), nullable=False, default="other", index=True)
priority = Column(String(16), nullable=False, default="normal", index=True)
status = Column(String(24), nullable=False, default="open", index=True)
assigned_admin_id = Column(BigInteger, nullable=True, index=True)
last_message_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
last_message_role = Column(String(16), nullable=True)
unread_user_count = Column(Integer, nullable=False, default=0)
unread_admin_count = Column(Integer, nullable=False, default=0)
admin_last_notified_at = Column(DateTime(timezone=True), nullable=True)
admin_last_emailed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
closed_at = Column(DateTime(timezone=True), nullable=True)
closed_by_admin_id = Column(BigInteger, nullable=True)
user = relationship("User")
messages = relationship(
"SupportTicketMessage",
back_populates="ticket",
cascade="all, delete-orphan",
passive_deletes=True,
)
__table_args__ = (
Index("ix_support_tickets_status_last_msg", "status", "last_message_at"),
)
class SupportTicketMessage(Base):
__tablename__ = "support_ticket_messages"
message_id = Column(Integer, primary_key=True, autoincrement=True)
ticket_id = Column(
Integer,
ForeignKey("support_tickets.ticket_id", ondelete="CASCADE"),
nullable=False,
index=True,
)
author_role = Column(String(16), nullable=False)
author_user_id = Column(BigInteger, ForeignKey("users.user_id"), nullable=True, index=True)
body = Column(Text, nullable=False)
is_internal_note = Column(Boolean, nullable=False, default=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
read_by_user_at = Column(DateTime(timezone=True), nullable=True)
read_by_admin_at = Column(DateTime(timezone=True), nullable=True)
ticket = relationship("SupportTicket", back_populates="messages")
author_user = relationship("User")
class PanelSyncStatus(Base):
__tablename__ = "panel_sync_status"
+9
View File
@@ -105,6 +105,7 @@ COPY --from=version-builder /build-tag /build-tag
COPY --from=version-builder /build-commit /build-commit
COPY backend/bot/app/web/templates/subscription_webapp.html /usr/share/nginx/html/index.html
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.css /usr/share/nginx/html/subscription_webapp.css
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.*.css /usr/share/nginx/html/
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.js /usr/share/nginx/html/subscription_webapp.js
COPY --from=frontend-builder /app/backend/bot/app/web/templates/subscription_webapp.min.*.js /usr/share/nginx/html/
@@ -112,9 +113,17 @@ RUN set -eu; \
find /docker-entrypoint.d -type f -name '*.sh' -exec sed -i 's/\r$//' {} +; \
HASHED=$(ls /usr/share/nginx/html/subscription_webapp.min.*.js 2>/dev/null | sort | tail -n1 | xargs -n1 basename || true); \
JS_NAME="${HASHED:-subscription_webapp.js}"; \
CSS_NAME="subscription_webapp.css"; \
for candidate in /usr/share/nginx/html/subscription_webapp.*.css; do \
name="$(basename "$candidate")"; \
case "$name" in \
subscription_webapp.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].css) CSS_NAME="$name" ;; \
esac; \
done; \
sed -i \
-e '/WEBAPP_I18N_SCRIPT/d' \
-e '/WEBAPP_CONFIG_SCRIPT/d' \
-e "s|href=\"/subscription_webapp.css\"|href=\"/${CSS_NAME}\"|" \
-e "/WEBAPP_JS_SCRIPT/c\\ <script src=\"/${JS_NAME}\" type=\"module\"></script>" \
-e '/WEBAPP_DEV_MOCK_START/d' \
-e '/WEBAPP_DEV_MOCK_END/d' \
+26
View File
@@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types
application/javascript
application/json
image/svg+xml
text/css
text/javascript
text/plain;
location = /health {
access_log off;
return 200 "ok\n";
@@ -45,6 +58,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~* ^/subscription_webapp\.(min\.)?[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]\.(css|js)$ {
expires off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
location ~* ^/subscription_webapp\.(css|js)$ {
expires off;
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|webp)$ {
expires 30d;
add_header Cache-Control "public";
@@ -52,6 +77,7 @@ server {
}
location / {
add_header Cache-Control "no-cache";
try_files $uri /index.html;
}
}
@@ -17,6 +17,15 @@ const sourcePath = path.join(
"templates",
"subscription_webapp.js"
);
const sourceCssPath = path.join(
repoRoot,
"backend",
"bot",
"app",
"web",
"templates",
"subscription_webapp.css"
);
function normalizeLineEndings(value) {
return value.replace(/\r\n/g, "\n");
@@ -50,16 +59,11 @@ function stripFallbackI18n(source) {
);
}
async function removeOldMinifiedAssets(assetDir, keepName) {
async function removeOldHashedAssets(assetDir, pattern, keepName) {
const entries = await readdir(assetDir, { withFileTypes: true });
await Promise.all(
entries
.filter(
(entry) =>
entry.isFile() &&
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/.test(entry.name) &&
entry.name !== keepName
)
.filter((entry) => entry.isFile() && pattern.test(entry.name) && entry.name !== keepName)
.map((entry) => unlink(path.join(assetDir, entry.name)))
);
}
@@ -84,11 +88,31 @@ async function main() {
const hash = createHash("sha256").update(code, "utf8").digest("hex").slice(0, 8);
const outputPath = path.join(path.dirname(sourcePath), `subscription_webapp.min.${hash}.js`);
await removeOldMinifiedAssets(path.dirname(sourcePath), path.basename(outputPath));
await removeOldHashedAssets(
path.dirname(sourcePath),
/^subscription_webapp\.min\.[0-9a-f]{8}\.js$/,
path.basename(outputPath)
);
await writeFile(outputPath, code, "utf8");
console.log(
`Wrote ${path.relative(repoRoot, outputPath)} (${Buffer.byteLength(code, "utf8")} bytes)`
);
const css = await readFile(sourceCssPath, "utf8");
const cssHash = createHash("sha256").update(css, "utf8").digest("hex").slice(0, 8);
const cssOutputPath = path.join(
path.dirname(sourceCssPath),
`subscription_webapp.${cssHash}.css`
);
await removeOldHashedAssets(
path.dirname(sourceCssPath),
/^subscription_webapp\.[0-9a-f]{8}\.css$/,
path.basename(cssOutputPath)
);
await writeFile(cssOutputPath, css, "utf8");
console.log(
`Wrote ${path.relative(repoRoot, cssOutputPath)} (${Buffer.byteLength(css, "utf8")} bytes)`
);
}
await main();
+125 -8
View File
@@ -3,6 +3,7 @@
import { createAuthStore } from "./lib/webapp/stores/authStore.js";
import { createBillingStore } from "./lib/webapp/stores/billingStore.js";
import { createDevicesStore } from "./lib/webapp/stores/devicesStore.js";
import { createSupportStore } from "./lib/webapp/stores/supportStore.js";
import { createAccountStore } from "./lib/webapp/stores/accountStore.js";
import { Tooltip } from "$components/ui/primitives.js";
@@ -17,6 +18,8 @@
import HomeScreen from "./webapp/screens/HomeScreen.svelte";
import InviteScreen from "./webapp/screens/InviteScreen.svelte";
import SettingsScreen from "./webapp/screens/SettingsScreen.svelte";
import SupportScreen from "./webapp/screens/SupportScreen.svelte";
import SupportTicketScreen from "./webapp/screens/SupportTicketScreen.svelte";
import {
LANGUAGE_FLAGS,
@@ -70,6 +73,7 @@
adminUserIdFromPath,
normalizeSection,
sectionFromPath,
supportTicketIdFromPath,
syncSectionPath,
} from "./lib/webapp/routes.js";
@@ -114,6 +118,8 @@
let token = MOCK ? "local-preview" : "";
let csrfToken = MOCK ? "" : readCookie(CSRF_COOKIE_NAME) || "";
let scrollLockApplied = false;
let adminI18nLoaded = false;
let adminI18nPromise = null;
let tg = null;
const telegramSdk = createTelegramSdk({
scriptUrl: TELEGRAM_WEBAPP_SCRIPT_URL,
@@ -170,6 +176,7 @@
tg,
});
const devicesStore = createDevicesStore({ api, t, showToast });
const supportStore = createSupportStore({ api, t, showToast });
const accountStore = createAccountStore({
api,
publicApi,
@@ -194,6 +201,7 @@
setContext("authStore", authStore);
setContext("billingStore", billingStore);
setContext("devicesStore", devicesStore);
setContext("supportStore", supportStore);
setContext("accountStore", accountStore);
$: ({
@@ -233,6 +241,11 @@
deviceToDisconnect,
deviceDisconnectBusy,
} = $devicesStore);
$: ({
unreadCount: supportUnreadCount,
unreadLoading: supportUnreadLoading,
unreadLoaded: supportUnreadLoaded,
} = $supportStore);
$: ({
linkEmailOpen,
linkEmailBusy,
@@ -278,6 +291,7 @@
: []
: plans;
$: devicesEnabled = Boolean(appSettings?.my_devices_enabled);
$: supportEnabled = Boolean(appSettings?.support_tickets_enabled ?? true);
$: subscription = data?.subscription || DEV_MOCK.data.subscription;
$: hasActiveTariffSubscription = Boolean(
tariffMode && subscription?.active && subscription?.tariff_key
@@ -467,13 +481,28 @@
}
if (mode === "app") {
if (section === "admin" && isAdmin) {
screen = "admin";
const pathAtStart = window.location.pathname;
void ensureI18nScope("admin").finally(() => {
if (sectionFromPath(window.location.pathname) !== "admin") return;
if (window.location.pathname !== pathAtStart) return;
activeTab = "settings";
screen = "admin";
});
return;
}
const nextSection = section === "devices" && !devicesEnabled ? "home" : section;
const nextSection =
section === "devices" && !devicesEnabled
? "home"
: section === "support" && !supportEnabled
? "home"
: section;
activeTab = nextSection;
screen = nextSection;
if (nextSection === "devices") devicesStore.loadDevices(devicesEnabled);
if (nextSection === "support") {
supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
}
};
window.addEventListener("popstate", onPopState);
@@ -486,6 +515,7 @@
authStore.clearCooldownTimer();
accountStore.clearLinkEmailResendTimer();
accountStore.clearSetPasswordResendTimer();
supportStore.closePolling();
clearLanguageClickGuard();
syncBodyScrollLock(false);
};
@@ -552,6 +582,29 @@
});
}
async function ensureI18nScope(scope) {
if (MOCK || scope !== "admin" || adminI18nLoaded) return;
if (adminI18nPromise) return adminI18nPromise;
const apiBase = String(CFG.apiBase || "/api").replace(/\/+$/, "");
adminI18nPromise = fetch(`${apiBase}/i18n?scope=admin`, {
credentials: "same-origin",
headers: { Accept: "application/json" },
})
.then((response) => (response.ok ? response.json() : null))
.then((payload) => {
if (!payload?.ok || !payload.i18n) return;
i18n.mergeMessages(payload.i18n);
adminI18nLoaded = true;
})
.catch((_error) => {
void _error;
})
.finally(() => {
adminI18nPromise = null;
});
return adminI18nPromise;
}
async function boot() {
await runWebappBoot({
MOCK,
@@ -640,17 +693,47 @@
: sectionFromPath(window.location.pathname);
if (section === "admin" && !payload.user?.is_admin) section = "settings";
if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home";
if (section === "support" && payload.settings?.support_tickets_enabled === false) {
section = "home";
}
const initialAdminSection =
section === "admin" ? adminSectionFromPath(window.location.pathname) : null;
if (section === "admin" && payload.user?.is_admin) {
await ensureI18nScope("admin");
}
const initialSupportTicketId =
section === "support" ? supportTicketIdFromPath(window.location.pathname) : null;
activeTab = section === "admin" ? "settings" : section;
screen = section;
mode = "app";
syncSectionPath(
section,
true,
section === "admin" ? adminSectionFromPath(window.location.pathname) : null
);
if (payload.settings?.support_tickets_enabled !== false) {
if (typeof payload.support_unread_count !== "undefined") {
supportStore.hydrateUnread(payload.support_unread_count);
}
void supportStore.refreshUnread();
supportStore.startPolling({ includeList: false });
}
if (section === "support" && initialSupportTicketId) {
const targetPath = `/support/${initialSupportTicketId}`;
if (window.location.protocol !== "file:" && window.location.pathname !== targetPath) {
window.history.replaceState(
null,
"",
`${targetPath}${window.location.search}${window.location.hash}`
);
}
} else {
syncSectionPath(section, true, initialAdminSection);
}
if (section === "devices" && payload.settings?.my_devices_enabled) {
await devicesStore.loadDevices(true);
}
if (section === "support") {
if (initialSupportTicketId)
await supportStore.openTicket(initialSupportTicketId, { skipPush: true });
else await supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
if (topupModalOpen) await billingStore.loadTopupOptions(topupKind);
if (deviceTopupModalOpen) await billingStore.loadDeviceTopupOptions();
if (changeModalOpen) await billingStore.loadTariffChangeOptions();
@@ -846,6 +929,16 @@
devicesStore.loadDevices(devicesEnabled);
}
function goSupport() {
if (!supportEnabled) return;
billingStore.closePaymentModal();
activeTab = "support";
screen = "support";
syncSectionPath("support");
supportStore.loadList();
supportStore.startPolling({ includeList: true });
}
function defaultPaymentMethod() {
return methods[0]?.id || "";
}
@@ -900,10 +993,11 @@
syncSectionPath("settings");
}
function openAdminPanel() {
async function openAdminPanel() {
if (!isAdmin) return;
clearLanguageClickGuard();
billingStore.closePaymentModal();
await ensureI18nScope("admin");
activeTab = "settings";
screen = "admin";
syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname));
@@ -1067,12 +1161,17 @@
{brandTitle}
{brand}
{devicesEnabled}
{supportEnabled}
{supportUnreadCount}
{supportUnreadLoading}
{supportUnreadLoaded}
{hasUnlinkedIdentity}
{isAdmin}
{openAdminPanel}
{goDevices}
{goHome}
{goInvite}
{goSupport}
{goSettings}
{t}
>
@@ -1131,6 +1230,24 @@
{openDeviceTopupModal}
{t}
/>
{:else if screen === "support"}
{#if $supportStore.openedTicketId}
<SupportTicketScreen
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
{brand}
userAvatarUrl={profileAvatarUrl}
userInitials={telegramProfileName
? telegramProfileName.slice(0, 2).toUpperCase()
: "U"}
{t}
/>
{:else}
<SupportScreen
maxSubjectLength={appSettings?.support_ticket_max_subject_length || 160}
maxBodyLength={appSettings?.support_ticket_max_body_length || 4000}
{t}
/>
{/if}
{:else if screen === "settings"}
<SettingsScreen
{currentLang}
+43 -5
View File
@@ -9,6 +9,7 @@
FileText,
Globe2,
LayoutDashboard,
LifeBuoy,
Megaphone,
Menu,
Paintbrush,
@@ -33,6 +34,7 @@
import PromosSection from "./sections/PromosSection.svelte";
import SettingsSection from "./sections/SettingsSection.svelte";
import StatsSection from "./sections/StatsSection.svelte";
import SupportSection from "./sections/SupportSection.svelte";
import TariffEditorModal from "./sections/TariffEditorModal.svelte";
import TariffsSection from "./sections/TariffsSection.svelte";
import AppearanceSection from "./sections/AppearanceSection.svelte";
@@ -45,6 +47,7 @@
import { createPromosStore } from "../lib/admin/stores/promosStore.js";
import { createSettingsStore } from "../lib/admin/stores/settingsStore.js";
import { createStatsStore } from "../lib/admin/stores/statsStore.js";
import { createAdminSupportStore } from "../lib/admin/stores/supportStore.js";
import { createTariffsStore } from "../lib/admin/stores/tariffsStore.js";
import { createThemesStore } from "../lib/admin/stores/themesStore.js";
import { createUsersStore } from "../lib/admin/stores/usersStore.js";
@@ -113,6 +116,7 @@
items: [
{ id: "broadcast", label: at("nav_broadcast", {}, "Рассылка"), icon: Megaphone },
{ id: "logs", label: at("nav_logs", {}, "Логи"), icon: FileText },
{ id: "support", label: at("nav_support", {}, "Поддержка"), icon: LifeBuoy },
],
},
{
@@ -159,6 +163,10 @@
title: at("section_logs_title", {}, "Логи активности"),
subtitle: at("section_logs_subtitle", {}, "События пользователей и админ-действия"),
},
support: {
title: at("section_support_title", {}, "Поддержка"),
subtitle: at("section_support_subtitle", {}, "Инбокс тикетов и ответы пользователям"),
},
tariffs: {
title: at("section_tariffs_title", {}, "Тарифы"),
subtitle: at("section_tariffs_subtitle", {}, "Каталог продаж, периоды, пакеты и лимиты"),
@@ -209,6 +217,7 @@
const promosStore = createPromosStore({ api, onToast: flash, at });
const settingsStore = createSettingsStore({ api, onToast: flash, at });
const statsStore = createStatsStore({ api, onToast: flash, at });
const supportStore = createAdminSupportStore({ api, onToast: flash, at });
const tariffsStore = createTariffsStore({ api, onToast: flash, onTariffsSaved, flash, at });
const themesStore = createThemesStore({ api, onThemesSaved, flash, at });
const usersStore = createUsersStore({ api, onToast: flash, at });
@@ -219,12 +228,14 @@
setContext("logsStore", logsStore);
setContext("paymentsStore", paymentsStore);
setContext("statsStore", statsStore);
setContext("adminSupportStore", supportStore);
setContext("settingsStore", settingsStore);
setContext("usersStore", usersStore);
setContext("tariffsStore", tariffsStore);
setContext("themesStore", themesStore);
$: usersStore.setActive(active);
$: supportStore.setActive(active);
$: dirtyCount = Object.keys($settingsStore.settingsDirty || {}).length;
$: syncBusy = $statsStore.syncBusy;
$: settingsSaving = $settingsStore.settingsSaving;
@@ -240,6 +251,7 @@
if (active === next) return;
active = next;
usersStore.closeUser();
supportStore.closeTicketView();
onSectionChange(next);
}
@@ -255,6 +267,12 @@
return match ? Number(match[1]) : null;
}
function readSupportTicketIdFromPath() {
if (typeof window === "undefined") return null;
const match = window.location.pathname.match(/^\/admin\/support\/(\d+)$/);
return match ? Number(match[1]) : null;
}
function onPopState() {
active = readSectionFromPath();
sidebarOpen = false;
@@ -266,6 +284,14 @@
} else if ($usersStore.openedUser) {
usersStore.closeUser({ skipPush: true });
}
const ticketId = readSupportTicketIdFromPath();
if (active === "support" && ticketId) {
if (!$supportStore.openedTicketId || $supportStore.openedTicketId !== ticketId) {
supportStore.openTicket(ticketId, { skipPush: true });
}
} else if (active === "support" && $supportStore.openedTicketId) {
supportStore.closeTicketView({ skipPush: true });
}
}
function exportPayments() {
@@ -288,10 +314,7 @@
}
function resolvedAvatarUrl(user) {
return (
userAvatarUrl(user) ||
(!user?.telegram_id && user?.email ? gravatarCache.gravatarUrl(user.email) : "")
);
return userAvatarUrl(user) || (user?.email ? gravatarCache.gravatarUrl(user.email) : "");
}
function panelStatusBadge(user) {
@@ -463,7 +486,13 @@
>
<svelte:component this={item.icon} size={16} />
<span>{item.label}</span>
<span></span>
<span>
{#if item.id === "support" && $supportStore.stats?.total_unread_admin}
<AdminBadge variant="danger">
<span class="numeric-badge-value">{$supportStore.stats.total_unread_admin}</span>
</AdminBadge>
{/if}
</span>
</button>
{/each}
</nav>
@@ -647,6 +676,15 @@
<LogsSection {at} {fmtDate} />
{/if}
{#if active === "support"}
<SupportSection
{at}
{brand}
{resolvedAvatarUrl}
initialTicketId={readSupportTicketIdFromPath()}
/>
{/if}
{#if active === "tariffs"}
<TariffsSection {at} {fmtMoney} />
{/if}
@@ -532,7 +532,9 @@
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<span>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span>
<span
>{at("appearance_use_custom_favicon", {}, "Использовать отдельную favicon")}</span
>
</label>
<input
bind:this={faviconFileInput}
@@ -697,8 +699,7 @@
max="300"
step="5"
value={homeLogoScale(theme)}
oninput={(event) =>
setThemeHomeLogoScale(theme, event.currentTarget.value)}
oninput={(event) => setThemeHomeLogoScale(theme, event.currentTarget.value)}
/>
%
</span>
@@ -139,14 +139,15 @@
function sectionTitle(id) {
const map = {
general: at("settings_section_general", {}, "Общие"),
appearance: at("settings_section_appearance", {}, "Внешний вид"),
pricing: at("settings_section_pricing", {}, "Тарифы и цены"),
payments: at("settings_section_payments", {}, "Платёжные системы"),
trial: at("settings_section_trial", {}, "Триал"),
referral: at("settings_section_referral", {}, "Реферальная программа"),
notifications: at("settings_section_notifications", {}, "Уведомления"),
devices: at("settings_section_devices", {}, "Устройства"),
general: at("admin_settings_section_general", {}, "Общие"),
appearance: at("admin_settings_section_appearance", {}, "Внешний вид"),
pricing: at("admin_settings_section_pricing", {}, "Тарифы и цены"),
payments: at("admin_settings_section_payments", {}, "Платёжные системы"),
trial: at("admin_settings_section_trial", {}, "Триал"),
referral: at("admin_settings_section_referral", {}, "Реферальная программа"),
notifications: at("admin_settings_section_notifications", {}, "Уведомления"),
support: at("admin_settings_section_support", {}, "Поддержка"),
devices: at("admin_settings_section_devices", {}, "Устройства"),
};
return map[id] || id;
}
@@ -0,0 +1,347 @@
<script>
import { afterUpdate, getContext, onMount, tick } from "svelte";
import {
AdminButton,
AdminSelect,
SupportComposer,
SupportInboxRow,
SupportTicketHeader,
SupportUserContextPanel,
} from "$components/patterns/admin/index.js";
import { TicketMessageBubble } from "$components/patterns/webapp/index.js";
import Dialog from "$components/ui/dialog.svelte";
import { Search } from "$components/ui/icons.js";
import { ScrollArea, Skeleton } from "$components/ui/index.js";
export let at = (key) => key;
export let initialTicketId = null;
export let brand = {};
export let resolvedAvatarUrl = () => "";
const supportStore = getContext("adminSupportStore");
let reply = "";
let messagesScrollEl;
let lastMessageScrollKey = "";
$: ({
tickets,
stats,
loading,
filters,
openedTicketId,
openedTicket,
messages,
userSnapshot,
sending,
composerInternalNote,
} = $supportStore);
$: statusTabs = [
{
value: "active",
label: at("support_filter_active", {}, "Активные"),
count: stats?.active || 0,
},
{
value: "closed",
label: at("support_filter_closed", {}, "Закрытые"),
count: stats?.closed || 0,
},
];
$: priorityFilterOptions = [
{ value: "all", label: at("support_filter_all_priorities", {}, "Любой приоритет") },
{ value: "low", label: at("support_priority_low", {}, "Низкий") },
{ value: "normal", label: at("support_priority_normal", {}, "Обычный") },
{ value: "high", label: at("support_priority_high", {}, "Высокий") },
{ value: "urgent", label: at("support_priority_urgent", {}, "Срочный") },
];
$: categoryFilterOptions = [
{ value: "all", label: at("support_filter_all_categories", {}, "Все категории") },
{ value: "billing", label: at("support_category_billing", {}, "Оплата") },
{ value: "technical", label: at("support_category_technical", {}, "Техническое") },
{ value: "account", label: at("support_category_account", {}, "Аккаунт") },
{ value: "other", label: at("support_category_other", {}, "Другое") },
];
$: sortOptions = [
{ value: "importance_desc", label: at("support_sort_importance_desc", {}, "Важные сверху") },
{ value: "updated_desc", label: at("sort_updated_desc", {}, "Сначала новые") },
{ value: "updated_asc", label: at("sort_updated_asc", {}, "Сначала старые") },
{ value: "created_desc", label: at("sort_created_desc", {}, "Созданы недавно") },
{ value: "created_asc", label: at("sort_created_asc", {}, "Созданы давно") },
];
$: ticketReady = Boolean(openedTicket && openedTicket.ticket_id === openedTicketId);
$: modalTitle = ticketReady
? openedTicket.subject
: openedTicketId
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
: at("support_ticket_dialog", {}, "Диалог поддержки");
$: modalDescription = ticketReady
? at("support_ticket_number", { id: openedTicketId }, `Тикет #${openedTicketId}`)
: at("loading", {}, "Загрузка");
$: openedTicketUser = openedTicket?.user || {};
$: openedTicketUserAvatarUrl = resolvedAvatarUrl(openedTicketUser);
$: openedTicketUserInitials = userInitials(openedTicketUser);
$: if (!openedTicketId) {
reply = "";
lastMessageScrollKey = "";
}
onMount(() => {
supportStore.loadList();
supportStore.loadStats();
supportStore.startStatsPolling();
if (initialTicketId) supportStore.openTicket(initialTicketId, { skipPush: true });
});
async function send(body) {
await supportStore.sendReply(body);
reply = "";
}
function scrollMessagesToBottom() {
if (!messagesScrollEl) return;
const scroll = () => {
messagesScrollEl.scrollTop = messagesScrollEl.scrollHeight;
};
scroll();
requestAnimationFrame(scroll);
window.setTimeout(scroll, 80);
window.setTimeout(scroll, 180);
}
function closeTicketModal() {
reply = "";
supportStore.closeTicketView();
}
function setFilter(key, value) {
supportStore.setFilter(key, value === "all" ? "" : value);
}
function setFilterAndLoad(key, value) {
setFilter(key, value);
supportStore.loadList();
}
function messageT(key, params = {}, fallback = "") {
if (key.startsWith("wa_support_")) {
return at(key.replace("wa_support_", "support_"), params, fallback || key);
}
return at(key, params, fallback || key);
}
function userInitials(user) {
const source =
[user?.first_name, user?.last_name].filter(Boolean).join(" ").trim() ||
user?.username ||
user?.email ||
String(user?.user_id || "");
const clean = String(source).replace(/^@/, "").trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (clean.slice(0, 2) || "U").toUpperCase();
}
function ticketUserDisplayName() {
const user = openedTicketUser || {};
const fullName = [user.first_name, user.last_name].filter(Boolean).join(" ").trim();
return (
snapshotName(userSnapshot) ||
fullName ||
user.username ||
user.email ||
String(user.user_id || "")
);
}
function snapshotName(snapshot) {
return String(snapshot?.name || "").trim();
}
function messageAuthorName(message) {
if (message?.author_name) return message.author_name;
if (message?.author_role === "user") return ticketUserDisplayName();
if (message?.author_role === "admin" && message?.author_user_id) {
return `${at("support_role_admin", {}, "Админ")} #${message.author_user_id}`;
}
return "";
}
afterUpdate(async () => {
const lastMessage = messages.at(-1);
const nextKey = `${openedTicketId || ""}:${ticketReady}:${messages.length}:${
lastMessage?.message_id || lastMessage?.created_at || ""
}`;
if (!openedTicketId || !ticketReady || !messagesScrollEl || nextKey === lastMessageScrollKey) {
return;
}
lastMessageScrollKey = nextKey;
await tick();
scrollMessagesToBottom();
});
</script>
<div class="support-admin-layout">
<div class="support-admin-summary" aria-label={at("support_summary", {}, "Сводка поддержки")}>
<span>
<strong>{stats?.open || 0}</strong>
<small>{at("support_status_open", {}, "Открыт")}</small>
</span>
<span>
<strong>{stats?.awaiting_admin || 0}</strong>
<small>{at("support_status_awaiting_admin", {}, "Ждет админа")}</small>
</span>
<span>
<strong>{stats?.total_unread_admin || 0}</strong>
<small>{at("support_unread", {}, "Непрочитано")}</small>
</span>
</div>
<section class="support-admin-list-panel">
<div class="support-admin-ticket-tabs" aria-label={at("support_status", {}, "Статус")}>
{#each statusTabs as tab (tab.value)}
<button
type="button"
class:active={filters.status === tab.value}
on:click={() => supportStore.setStatusView(tab.value)}
>
<span>{tab.label}</span>
<b>{tab.count}</b>
</button>
{/each}
</div>
<div class="support-admin-toolbar admin-toolbar-card">
<label class="support-admin-search">
<Search size={16} />
<input
class="input"
type="search"
placeholder={at("support_search", {}, "Поиск")}
value={filters.search}
on:input={(e) => supportStore.setFilter("search", e.target.value)}
on:keydown={(e) => e.key === "Enter" && supportStore.loadList()}
/>
</label>
<div class="support-admin-filter-row">
<AdminSelect
value={filters.priority || "all"}
items={priorityFilterOptions}
ariaLabel={at("support_priority", {}, "Приоритет")}
onValueChange={(value) => setFilterAndLoad("priority", value)}
/>
<AdminSelect
value={filters.category || "all"}
items={categoryFilterOptions}
ariaLabel={at("support_category", {}, "Категория")}
onValueChange={(value) => setFilterAndLoad("category", value)}
/>
<AdminSelect
value={filters.sort || "importance_desc"}
items={sortOptions}
ariaLabel={at("sort", {}, "Сортировка")}
onValueChange={(value) => setFilterAndLoad("sort", value)}
/>
<AdminButton variant="primary" onclick={() => supportStore.loadList()}>
{at("apply", {}, "Применить")}
</AdminButton>
</div>
</div>
{#if loading}
<div class="support-ticket-list-skeleton" aria-label={at("loading", {}, "Загрузка")}>
{#each Array(6) as _, index (index)}
<article class="support-ticket-row-skeleton">
<Skeleton variant="dot" width="38px" height="38px" />
<span class="support-ticket-row-skeleton-main">
<Skeleton variant="title" width="min(380px, 74%)" />
<Skeleton variant="short" width="min(280px, 58%)" />
</span>
<span class="support-ticket-row-skeleton-side">
<Skeleton variant="badge" width="92px" />
<Skeleton variant="tiny" width="64px" />
</span>
</article>
{/each}
</div>
{:else if !tickets.length}
<div class="admin-empty-state">{at("support_empty", {}, "Тикетов пока нет")}</div>
{:else}
<div class="support-inbox-list">
{#each tickets as ticket}
<SupportInboxRow
{ticket}
active={openedTicketId === ticket.ticket_id}
{at}
onOpen={(item) => supportStore.openTicket(item.ticket_id)}
/>
{/each}
</div>
{/if}
</section>
</div>
<Dialog
open={Boolean(openedTicketId)}
title={modalTitle}
description={modalDescription}
closeLabel={at("close", {}, "Закрыть")}
onclose={closeTicketModal}
class="admin-dialog support-ticket-dialog"
>
{#if !ticketReady}
<div class="support-ticket-dialog-skeleton">
<Skeleton variant="title" width="70%" />
<Skeleton variant="short" width="44%" />
<Skeleton variant="block" height="94px" />
<Skeleton variant="block" height="220px" />
<Skeleton variant="block" height="132px" />
</div>
{:else}
<div class="support-ticket-dialog-body">
<SupportTicketHeader
ticket={openedTicket}
{at}
onPatch={(updates) => supportStore.patchTicket(updates)}
onClose={() => supportStore.closeTicket()}
/>
<SupportUserContextPanel ticket={openedTicket} snapshot={userSnapshot} {at} />
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-admin-message-scroll scroll-area--mono"
>
<div class="support-admin-messages">
{#if messages.length}
{#each messages as message}
<TicketMessageBubble
role={message.author_role}
body={message.body}
createdAt={message.created_at}
isInternalNote={message.is_internal_note}
perspective="admin"
supportBrand={brand}
userAvatarUrl={openedTicketUserAvatarUrl}
userInitials={openedTicketUserInitials}
authorName={messageAuthorName(message)}
t={messageT}
/>
{/each}
{:else}
<div class="admin-empty-state">
{at("support_no_messages", {}, "Сообщений пока нет")}
</div>
{/if}
</div>
</ScrollArea>
<SupportComposer
bind:value={reply}
internal={composerInternalNote}
{sending}
{at}
onToggleInternal={supportStore.toggleInternalNote}
onSend={send}
/>
</div>
{/if}
</Dialog>
@@ -0,0 +1,216 @@
import { writable } from "svelte/store";
export function createAdminSupportStore({ api, onToast, at }) {
const state = writable({
tickets: [],
stats: { active: 0, closed: 0, open: 0, awaiting_admin: 0, total_unread_admin: 0 },
filters: {
status: "active",
priority: "",
category: "",
search: "",
sort: "importance_desc",
},
loading: false,
openedTicketId: null,
openedTicket: null,
messages: [],
userSnapshot: null,
detailLoading: false,
sending: false,
composerInternalNote: false,
});
let pollTimer = null;
let active = "stats";
function setActive(section) {
active = section;
}
function pushTicketPath(ticketId) {
if (typeof window === "undefined" || window.location.protocol === "file:") return;
if (active !== "support") return;
const target = ticketId ? `/admin/support/${ticketId}` : "/admin/support";
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
async function loadStats() {
const res = await api("/admin/support/stats");
if (res?.ok) state.update((s) => ({ ...s, stats: res.stats || s.stats }));
}
async function loadList() {
state.update((s) => ({ ...s, loading: true }));
let filters;
state.update((s) => {
filters = s.filters;
return s;
});
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
for (const [key, value] of Object.entries(filters || {})) {
if (value) params.set(key, value);
}
const res = await api(`/admin/support/tickets?${params.toString()}`);
if (res?.ok) state.update((s) => ({ ...s, tickets: res.tickets || [] }));
else if (res?.error) onToast(res.message || res.error);
} finally {
state.update((s) => ({ ...s, loading: false }));
}
}
async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId);
if (!id) return;
state.update((s) => ({
...s,
openedTicketId: id,
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
userSnapshot: s.openedTicket?.ticket_id === id ? s.userSnapshot : null,
detailLoading: true,
}));
if (!opts.skipPush) pushTicketPath(id);
try {
const res = await api(`/admin/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
userSnapshot: res.user_snapshot || null,
}));
await api(`/admin/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await loadStats();
} else onToast(res?.message || res?.error || "not_found");
} finally {
state.update((s) => ({ ...s, detailLoading: false }));
}
}
function closeTicketView(opts = {}) {
state.update((s) => ({
...s,
openedTicketId: null,
openedTicket: null,
messages: [],
userSnapshot: null,
}));
if (!opts.skipPush) pushTicketPath(null);
}
async function sendReply(body) {
let current;
let internal;
state.update((s) => {
current = s.openedTicketId;
internal = s.composerInternalNote;
return { ...s, sending: true };
});
if (!current) return;
try {
const res = await api(`/admin/support/tickets/${current}/messages`, {
method: "POST",
body: JSON.stringify({ body, is_internal_note: internal }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
messages: [...s.messages, res.message],
}));
await loadList();
await loadStats();
} catch (error) {
onToast(error?.message || at("support_send_failed", {}, "Send failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function patchTicket(updates) {
let current;
state.update((s) => {
current = s.openedTicketId;
return s;
});
if (!current) return;
const res = await api(`/admin/support/tickets/${current}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket
? { ...s.openedTicket, ...res.ticket, user: res.ticket.user || s.openedTicket?.user }
: s.openedTicket,
}));
await loadList();
await loadStats();
} else onToast(res?.message || res?.error || "update_failed");
}
function closeTicket() {
patchTicket({ status: "closed" });
}
function toggleInternalNote() {
state.update((s) => ({ ...s, composerInternalNote: !s.composerInternalNote }));
}
function setFilter(key, value) {
state.update((s) => ({ ...s, filters: { ...s.filters, [key]: value } }));
}
function setStatusView(status) {
state.update((s) => ({
...s,
filters: {
...s.filters,
status: status === "closed" ? "closed" : "active",
},
}));
loadList();
}
function startStatsPolling() {
if (pollTimer || typeof window === "undefined") return;
loadStats();
pollTimer = window.setInterval(() => {
if (document.visibilityState === "visible") loadStats();
}, 30000);
}
function stopStatsPolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
}
return {
subscribe: state.subscribe,
update: state.update,
setActive,
loadStats,
loadList,
openTicket,
closeTicketView,
sendReply,
patchTicket,
closeTicket,
toggleInternalNote,
setFilter,
setStatusView,
startStatsPolling,
stopStatsPolling,
};
}
+4 -28
View File
@@ -69,13 +69,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -99,13 +93,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_logo_uploaded_pending",
{},
"Логотип загружен и применен."
)
);
flash(at("appearance_logo_uploaded_pending", {}, "Логотип загружен и применен."));
return { logoUrl: data.logo_url || "", faviconUrl: data.favicon_url || "" };
}
flash(
@@ -130,13 +118,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body,
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -160,13 +142,7 @@ export function createThemesStore({ api, onThemesSaved, flash, at }) {
body: JSON.stringify({ url: sourceUrl }),
});
if (data?.ok) {
flash(
at(
"appearance_favicon_uploaded_pending",
{},
"Favicon загружена и применена."
)
);
flash(at("appearance_favicon_uploaded_pending", {}, "Favicon загружена и применена."));
return { faviconUrl: data.favicon_url || "", variants: data.variants || {} };
}
flash(
@@ -68,7 +68,7 @@
const accent = readCssColor("--accent", "#00fe7a");
const lineStroke = readCssColor(
"--admin-chart-stroke",
readCssColor("--admin-text", "#e8f0ec"),
readCssColor("--admin-text", "#e8f0ec")
);
const lineFill = readCssColor("--admin-chart-fill", "rgba(120, 140, 132, 0.14)");
@@ -0,0 +1,50 @@
<script>
import { Lock, Send } from "$components/ui/icons.js";
import { Spinner, Textarea } from "$components/ui/index.js";
import { Switch } from "$components/ui/primitives.js";
import { AdminButton } from "$components/patterns/admin/index.js";
export let value = "";
export let internal = false;
export let sending = false;
export let at = (key) => key;
export let onToggleInternal = () => {};
export let onSend = () => {};
function submit() {
if (sending || !value.trim()) return;
onSend(value.trim());
}
</script>
<div class="support-admin-composer">
<Textarea
bind:value
rows={4}
placeholder={at("support_reply_placeholder", {}, "Ответ")}
ariaLabel={at("support_reply_placeholder", {}, "Ответ")}
class="support-admin-composer-textarea"
/>
<div class="support-admin-composer-row">
<div class="support-admin-note-toggle">
<Switch.Root
id="support-internal-note"
checked={internal}
onCheckedChange={onToggleInternal}
class="admin-switch-root"
>
<Switch.Thumb class="admin-switch-thumb" />
</Switch.Root>
<label for="support-internal-note">
<Lock size={14} />
<span>{at("support_internal_note", {}, "Внутренняя заметка")}</span>
</label>
</div>
<AdminButton variant="primary" disabled={sending || !value.trim()} onclick={submit}>
{#if sending}<Spinner size="sm" />{:else}<Send size={14} />{/if}
{at("send", {}, "Отправить")}
</AdminButton>
</div>
</div>
@@ -0,0 +1,84 @@
<script>
import { AdminBadge } from "$components/patterns/admin/index.js";
import { MessageSquare } from "$components/ui/icons.js";
export let ticket;
export let active = false;
export let at = (key) => key;
export let onOpen = () => {};
$: user = ticket?.user || {};
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
$: userLabel = user.username ? `@${user.username}` : user.email || user.user_id || "-";
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
$: avatarInitials = computeInitials(user);
$: categoryLabel = at(`support_category_${ticket?.category}`, {}, ticket?.category || "-");
$: statusVariant =
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
$: priorityVariant =
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
function computeInitials(u) {
const source =
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
u?.username ||
u?.email ||
String(u?.user_id || "");
const clean = String(source).replace(/^@/, "").trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (clean.slice(0, 2) || "U").toUpperCase();
}
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<button
class:active
class="support-inbox-row"
type="button"
data-status={ticket?.status}
data-priority={ticket?.priority}
on:click={() => onOpen(ticket)}
>
<span class="support-inbox-row-avatar" aria-hidden="true">
{#if avatarUrl}
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
{avatarInitials}
{/if}
</span>
<span class="support-inbox-row-main">
<span class="support-inbox-row-title">
<MessageSquare size={15} />
<strong>{ticket.subject}</strong>
</span>
<small>#{ticket.ticket_id} / {userLabel} / {categoryLabel}</small>
</span>
<span class="support-row-badges">
<AdminBadge variant={statusVariant}
>{at(`support_status_${ticket.status}`, {}, ticket.status)}</AdminBadge
>
<AdminBadge variant={priorityVariant}>
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
</AdminBadge>
{#if ticket.unread_admin_count}
<b>
<span class="numeric-badge-value">{ticket.unread_admin_count}</span>
</b>
{/if}
{#if timeLabel}<small>{timeLabel}</small>{/if}
</span>
</button>
@@ -0,0 +1,79 @@
<script>
import { AdminBadge, AdminButton, AdminSelect } from "$components/patterns/admin/index.js";
import { CheckCheck } from "$components/ui/icons.js";
export let ticket;
export let at = (key) => key;
export let onPatch = () => {};
export let onClose = () => {};
$: statusOptions = ["open", "awaiting_user", "awaiting_admin", "resolved", "closed"].map(
(item) => ({
value: item,
label: at(`support_status_${item}`, {}, item),
})
);
$: priorityOptions = ["low", "normal", "high", "urgent"].map((item) => ({
value: item,
label: at(`support_priority_${item}`, {}, item),
}));
$: categoryOptions = ["billing", "technical", "account", "other"].map((item) => ({
value: item,
label: at(`support_category_${item}`, {}, item),
}));
$: statusVariant =
ticket?.status === "closed" || ticket?.status === "resolved" ? "muted" : "success";
$: priorityVariant =
ticket?.priority === "urgent" ? "danger" : ticket?.priority === "high" ? "warning" : "muted";
function patch(key, value) {
if (!ticket || ticket[key] === value) return;
onPatch({ [key]: value });
}
</script>
{#if ticket}
<div class="support-ticket-header">
<div class="support-ticket-statusbar">
<AdminBadge variant={statusVariant}>
{at(`support_status_${ticket.status}`, {}, ticket.status)}
</AdminBadge>
<AdminBadge variant={priorityVariant}>
{at(`support_priority_${ticket.priority}`, {}, ticket.priority)}
</AdminBadge>
</div>
<div class="support-ticket-actions">
<AdminButton
class="support-ticket-close"
variant="dangerSoft"
onclick={onClose}
disabled={ticket.status === "closed"}
>
<CheckCheck size={14} />
{at("support_close_ticket", {}, "Закрыть тикет")}
</AdminButton>
</div>
<div class="support-ticket-controls">
<AdminSelect
value={ticket.status}
items={statusOptions}
ariaLabel={at("support_status", {}, "Статус")}
onValueChange={(value) => patch("status", value)}
/>
<AdminSelect
value={ticket.priority}
items={priorityOptions}
ariaLabel={at("support_priority", {}, "Приоритет")}
onValueChange={(value) => patch("priority", value)}
/>
<AdminSelect
value={ticket.category}
items={categoryOptions}
ariaLabel={at("support_category", {}, "Категория")}
onValueChange={(value) => patch("category", value)}
/>
</div>
</div>
{/if}
@@ -0,0 +1,75 @@
<script>
import { AdminBadge, AdminButton } from "$components/patterns/admin/index.js";
import { User } from "$components/ui/icons.js";
export let ticket;
export let snapshot = {};
export let at = (key) => key;
$: user = ticket?.user || {};
$: displayName = snapshot?.name || user.username || user.email || user.user_id || "-";
$: avatarUrl = user?.avatar_url || user?.photo_url || "";
$: avatarInitials = computeInitials(user, displayName);
$: canOpenUser = user.user_id !== undefined && user.user_id !== null && user.user_id !== "";
$: identityMeta =
[user.email, canOpenUser ? `ID ${user.user_id}` : ""].filter(Boolean).join(" / ") || "-";
$: contextItems = [
{ label: at("support_tariff", {}, "Тариф"), value: snapshot?.tariff || "-" },
{ label: at("support_status", {}, "Статус"), value: snapshot?.panel_status || "-" },
{ label: at("support_remaining", {}, "Осталось"), value: snapshot?.remaining || "-" },
];
function computeInitials(u, fallback) {
const source =
[u?.first_name, u?.last_name].filter(Boolean).join(" ").trim() ||
u?.username ||
u?.email ||
String(fallback || "");
const clean = String(source).replace(/^@/, "").trim();
const parts = clean.split(/\s+/).filter(Boolean);
if (parts.length >= 2) return `${parts[0][0]}${parts[1][0]}`.toUpperCase();
return (clean.slice(0, 2) || "U").toUpperCase();
}
</script>
<section class="support-user-context" aria-label={at("support_user_context", {}, "User")}>
<div class="support-user-context-head">
<span class="support-user-context-avatar" aria-hidden="true">
{#if avatarUrl}
<img src={avatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else}
{avatarInitials}
{/if}
</span>
<div class="support-user-context-identity">
<strong>{displayName}</strong>
<small>{identityMeta}</small>
{#if user.is_banned}
<AdminBadge variant="danger">{at("status_banned", {}, "Бан")}</AdminBadge>
{/if}
</div>
</div>
<div class="support-user-context-metrics">
{#each contextItems as item (item.label)}
<span>
<small>{item.label}</small>
<strong>{item.value}</strong>
</span>
{/each}
</div>
<div class="support-user-context-actions">
<AdminButton
class="support-user-card-btn"
variant="ghost"
size="icon"
disabled={!canOpenUser}
onclick={() => (window.location.href = `/admin/users/${user.user_id}`)}
aria-label={at("support_open_user", {}, "Карточка")}
title={at("support_open_user", {}, "Карточка")}
>
<User size={14} />
</AdminButton>
</div>
</section>
@@ -12,3 +12,7 @@ export { default as AdminSectionHeader } from "./AdminSectionHeader.svelte";
export { default as AdminTable } from "./AdminTable.svelte";
export { default as AdminTableSkeleton } from "./AdminTableSkeleton.svelte";
export { default as AdminTrafficCard } from "./AdminTrafficCard.svelte";
export { default as SupportComposer } from "./SupportComposer.svelte";
export { default as SupportInboxRow } from "./SupportInboxRow.svelte";
export { default as SupportTicketHeader } from "./SupportTicketHeader.svelte";
export { default as SupportUserContextPanel } from "./SupportUserContextPanel.svelte";
@@ -0,0 +1,59 @@
<script>
import { AttentionDot, Badge } from "$components/ui/index.js";
import { MessageSquare } from "$components/ui/icons.js";
export let ticket;
export let t = (key) => key;
export let onOpen = () => {};
$: unread = Number(ticket?.unread_user_count || 0);
$: timeLabel = formatTime(ticket?.last_message_at || ticket?.updated_at || ticket?.created_at);
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<button
class="ticket-card"
type="button"
data-status={ticket?.status}
data-priority={ticket?.priority}
on:click={() => onOpen(ticket)}
>
<span class="ticket-card-main">
<span class="ticket-card-title">
<MessageSquare size={16} />
<strong>{ticket.subject}</strong>
</span>
<span class="ticket-card-meta">
<span>{t("wa_support_ticket_number", { id: ticket.ticket_id })}</span>
{#if timeLabel}<span>{timeLabel}</span>{/if}
</span>
</span>
<span class="ticket-card-side">
<span class="ticket-card-badges">
<Badge variant="outline" class={`ticket-status-badge ticket-status-badge--${ticket.status}`}>
{t(`wa_support_status_${ticket.status}`)}
</Badge>
<Badge
variant="muted"
class={`ticket-priority-badge ticket-priority-badge--${ticket.priority}`}
>
{t(`wa_support_priority_${ticket.priority}`)}
</Badge>
</span>
{#if unread}
<AttentionDot position="inline" class="ticket-card-unread-dot" />
{/if}
</span>
</button>
@@ -0,0 +1,41 @@
<script>
import { Send } from "$components/ui/icons.js";
import { Button, Spinner, Textarea } from "$components/ui/index.js";
export let value = "";
export let maxLength = 4000;
export let disabled = false;
export let sending = false;
export let placeholder = "";
export let sendLabel = "";
export let onSend = () => {};
function submit() {
if (disabled || sending || !value.trim()) return;
onSend(value.trim());
}
</script>
<div class="ticket-composer">
<Textarea
bind:value
rows={3}
maxlength={maxLength}
{disabled}
{placeholder}
ariaLabel={placeholder}
class="ticket-composer-textarea"
/>
<div class="ticket-composer-row">
<small>{value.length}/{maxLength}</small>
<Button
type="button"
class="ticket-composer-send"
disabled={disabled || sending || !value.trim()}
onclick={submit}
>
{#if sending}<Spinner size="sm" />{:else}<Send size={16} />{/if}
<span>{sendLabel}</span>
</Button>
</div>
</div>
@@ -0,0 +1,79 @@
<script>
import BrandMark from "$lib/webapp/BrandMark.svelte";
import { LifeBuoy, Lock, MessageSquare, UserRound } from "$components/ui/icons.js";
export let role = "user";
export let body = "";
export let createdAt = "";
export let isInternalNote = false;
export let perspective = "user";
export let userAvatarUrl = "";
export let userInitials = "";
export let authorName = "";
export let supportBrand = {};
export let t = (key, _params = {}, fallback = "") => fallback || key;
$: messageRole = role || "system";
$: serviceMessage = isInternalNote || messageRole === "system";
$: outgoing =
(perspective === "admin" && (messageRole === "admin" || serviceMessage)) ||
(!serviceMessage && perspective !== "admin" && messageRole === "user");
$: roleLabel = isInternalNote
? [authorName, t("wa_support_internal_note", {}, "Внутренняя заметка")]
.filter(Boolean)
.join(" / ")
: authorName || t(`wa_support_role_${messageRole}`, {}, messageRole);
$: timeLabel = formatTime(createdAt);
$: showSupportAvatar = !isInternalNote && messageRole === "admin";
$: showUserAvatar = !isInternalNote && messageRole === "user";
function formatTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleString(undefined, {
day: "2-digit",
month: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
</script>
<article
class={`ticket-message-row ticket-message-row--${messageRole}`.trim()}
class:ticket-message-row--outgoing={outgoing}
class:ticket-message-row--incoming={!outgoing}
class:ticket-message-row--internal={isInternalNote}
>
<span class="ticket-message-avatar" aria-hidden="true">
{#if isInternalNote}
<Lock size={15} />
{:else if showSupportAvatar}
<BrandMark brand={supportBrand} size="sm" fallbackEmoji={true} />
{:else if showUserAvatar && userAvatarUrl}
<img src={userAvatarUrl} alt="" loading="lazy" referrerpolicy="no-referrer" />
{:else if showUserAvatar && userInitials}
<strong>{userInitials}</strong>
{:else if messageRole === "admin"}
<LifeBuoy size={15} />
{:else if messageRole === "user"}
<UserRound size={15} />
{:else}
<MessageSquare size={15} />
{/if}
</span>
<div class="ticket-message-content">
<div class="ticket-message-meta">
<span class="ticket-message-author">{roleLabel}</span>
{#if timeLabel}
<time datetime={createdAt}>{timeLabel}</time>
{/if}
</div>
<div class="ticket-message-bubble">
<p>{body}</p>
</div>
</div>
</article>
@@ -4,3 +4,6 @@ export { default as LinearProgress } from "./LinearProgress.svelte";
export { default as LanguageSelect } from "./LanguageSelect.svelte";
export { default as PaymentMethodGrid } from "./PaymentMethodGrid.svelte";
export { default as StatusMessage } from "./StatusMessage.svelte";
export { default as TicketCard } from "./TicketCard.svelte";
export { default as TicketComposer } from "./TicketComposer.svelte";
export { default as TicketMessageBubble } from "./TicketMessageBubble.svelte";
@@ -0,0 +1,14 @@
<script>
import { cn } from "$lib/utils.js";
export let position = "absolute";
let className = "";
export { className as class };
</script>
<span
data-slot="attention-dot"
aria-hidden="true"
class={cn("attention-dot", position === "inline" && "attention-dot-inline", className)}
{...$$restProps}
></span>
+4
View File
@@ -5,6 +5,7 @@ export {
Bitcoin,
CalendarDays,
Check,
CheckCheck,
CheckCircle2,
ChevronDown,
ChevronLeft,
@@ -28,12 +29,15 @@ export {
Info,
Key,
LayoutDashboard,
LifeBuoy,
Lock,
LockKeyhole,
Mail,
Map,
Megaphone,
Menu,
MessageSquare,
MessageSquarePlus,
MousePointerClick,
Paintbrush,
Plus,
+3
View File
@@ -1,3 +1,4 @@
export { default as AttentionDot } from "./attention-dot.svelte";
export { default as Badge } from "./badge.svelte";
export { default as Button } from "./button.svelte";
export { default as Dialog } from "./dialog.svelte";
@@ -5,6 +6,8 @@ export { default as Input } from "./input.svelte";
export { default as LegacyCard } from "./card.svelte";
export { default as Skeleton } from "./skeleton.svelte";
export { default as Spinner } from "./spinner.svelte";
export { default as ScrollArea } from "./scroll-area.svelte";
export { default as Textarea } from "./textarea.svelte";
export * as Icons from "./icons.js";
export * as Card from "./card/index.js";
export { Accordion, Label, Select, Separator, Switch, Tabs, Tooltip } from "./primitives.js";
@@ -0,0 +1,15 @@
<script>
export let maxHeight = "100%";
export let element = null;
let className = "";
export { className as class };
</script>
<div
bind:this={element}
class={`scroll-area ${className}`.trim()}
style={`max-height:${maxHeight};`}
{...$$restProps}
>
<slot />
</div>
@@ -0,0 +1,23 @@
<script>
export let value = "";
export let rows = 3;
export let disabled = false;
export let placeholder = "";
export let maxlength = undefined;
export let ariaLabel = "";
let className = "";
export { className as class };
</script>
<textarea
class={`textarea ${className}`.trim()}
bind:value
{rows}
{disabled}
{placeholder}
{maxlength}
aria-label={ariaLabel || placeholder}
on:input
on:keydown
{...$$restProps}
></textarea>
+2
View File
@@ -22,6 +22,7 @@ export const APP_SECTION_PATHS = {
home: "/home",
invite: "/invite",
devices: "/devices",
support: "/support",
settings: "/settings",
admin: "/admin",
};
@@ -33,6 +34,7 @@ export const ADMIN_SECTIONS = new Set([
"ads",
"broadcast",
"logs",
"support",
"tariffs",
"appearance",
"settings",
+19 -2
View File
@@ -2,7 +2,24 @@ import { LANGUAGE_LABELS } from "./constants.js";
import { formatTemplate, formatFraction, roundToHalf } from "./formatters.js";
import { unitPluralBucket } from "./plurals.js";
export function createI18n({ messages = {}, defaultLang = "ru", getLang = null } = {}) {
export function createI18n({
messages: initialMessages = {},
defaultLang = "ru",
getLang = null,
} = {}) {
const messages = {};
function mergeMessages(nextMessages = {}) {
if (!nextMessages || typeof nextMessages !== "object") return messages;
for (const [lang, bucket] of Object.entries(nextMessages)) {
if (!bucket || typeof bucket !== "object") continue;
messages[lang] = { ...(messages[lang] || {}), ...bucket };
}
return messages;
}
mergeMessages(initialMessages);
function normalizeLangCode(lang) {
const key = String(lang || "")
.trim()
@@ -45,7 +62,7 @@ export function createI18n({ messages = {}, defaultLang = "ru", getLang = null }
return t(`wa_sub_term_${unit}_${bucket}`);
}
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel };
return { normalizeLangCode, t, currentLang, languageName, termUnitLabel, mergeMessages };
}
export { formatFraction, roundToHalf };
+270
View File
@@ -66,6 +66,150 @@ export async function mockApi(path, options = {}, context = {}) {
premium_traffic: { state: "none" },
},
];
const supportTickets = [
{
ticket_id: 42,
user_id: 100200300,
subject: "Не подключается профиль на телефоне",
category: "technical",
priority: "high",
status: "awaiting_admin",
unread_user_count: 0,
unread_admin_count: 2,
last_message_at: new Date(Date.now() - 18 * 60000).toISOString(),
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
user: adminUsers[0],
},
{
ticket_id: 43,
user_id: 100200300,
subject: "Вопрос по оплате подписки",
category: "billing",
priority: "normal",
status: "awaiting_user",
unread_user_count: 1,
unread_admin_count: 0,
last_message_at: new Date(Date.now() - 4 * 3600000).toISOString(),
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
user: adminUsers[0],
},
{
ticket_id: 41,
user_id: 100200300,
subject: "Закрытый вопрос по старому профилю",
category: "technical",
priority: "low",
status: "closed",
unread_user_count: 0,
unread_admin_count: 0,
last_message_at: new Date(Date.now() - 4 * 86400000).toISOString(),
created_at: new Date(Date.now() - 6 * 86400000).toISOString(),
closed_at: new Date(Date.now() - 4 * 86400000).toISOString(),
user: adminUsers[0],
},
];
function supportCounts(items = supportTickets) {
const byStatus = { open: 0, awaiting_admin: 0, awaiting_user: 0, resolved: 0 };
for (const item of items) {
byStatus[item.status] = (byStatus[item.status] || 0) + 1;
}
const closed = (byStatus.closed || 0) + (byStatus.resolved || 0);
const active = items.length - closed;
return { ...byStatus, active, closed, total: items.length };
}
function filterSupportTickets(items, params) {
let out = [...items];
const status = params.get("status");
if (status === "active")
out = out.filter((item) => !["closed", "resolved"].includes(item.status));
else if (status === "closed")
out = out.filter((item) => ["closed", "resolved"].includes(item.status));
else if (status) out = out.filter((item) => item.status === status);
const priority = params.get("priority");
if (priority) out = out.filter((item) => item.priority === priority);
const category = params.get("category");
if (category) out = out.filter((item) => item.category === category);
const search = (params.get("search") || "").trim().toLowerCase();
if (search) {
out = out.filter((item) =>
[item.subject, item.user?.username, item.user?.email, String(item.ticket_id)]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
const sort = params.get("sort") || "updated_desc";
const priorityRank = { urgent: 4, high: 3, normal: 2, low: 1 };
out.sort((a, b) => {
if (sort === "importance_desc") {
return (
(priorityRank[b.priority] || 0) - (priorityRank[a.priority] || 0) ||
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
);
}
if (sort === "updated_asc") {
return (
new Date(a.last_message_at || a.created_at) - new Date(b.last_message_at || b.created_at)
);
}
if (sort === "created_desc") return new Date(b.created_at) - new Date(a.created_at);
if (sort === "created_asc") return new Date(a.created_at) - new Date(b.created_at);
return (
new Date(b.last_message_at || b.created_at) - new Date(a.last_message_at || a.created_at)
);
});
return out;
}
const supportMessages = {
42: [
{
message_id: 1,
ticket_id: 42,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "После обновления приложения профиль перестал подключаться. Ошибка появляется сразу после импорта ссылки.",
created_at: new Date(Date.now() - 2 * 3600000).toISOString(),
},
{
message_id: 2,
ticket_id: 42,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: "Проверили подписку, она активна. Попробуйте удалить старый профиль и импортировать ссылку ещё раз.",
created_at: new Date(Date.now() - 90 * 60000).toISOString(),
},
{
message_id: 3,
ticket_id: 42,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "Сделал так, но теперь вижу timeout. Телефон iPhone, сеть домашний Wi‑Fi.",
created_at: new Date(Date.now() - 18 * 60000).toISOString(),
},
],
43: [
{
message_id: 4,
ticket_id: 43,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: "Оплата прошла, но срок подписки не изменился.",
created_at: new Date(Date.now() - 6 * 3600000).toISOString(),
},
{
message_id: 5,
ticket_id: 43,
author_role: "admin",
author_user_id: 2,
author_name: "Иван, поддержка",
body: "Платёж нашли и применили вручную. Проверьте, пожалуйста, дату окончания подписки.",
created_at: new Date(Date.now() - 4 * 3600000).toISOString(),
},
],
};
const mockAdminDailySeries = (() => {
const days = 730;
const out = [];
@@ -331,8 +475,134 @@ export async function mockApi(path, options = {}, context = {}) {
},
],
};
if (cleanPath === "/admin/support/stats") {
return {
ok: true,
stats: { ...supportCounts(), total_unread_admin: 2 },
};
}
if (cleanPath === "/admin/support/tickets") {
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
const tickets = filterSupportTickets(supportTickets, params);
return { ok: true, tickets: clone(tickets), total: tickets.length };
}
if (cleanPath.startsWith("/admin/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[4]);
const ticket = clone(
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
);
if (parts[5] === "messages") {
return {
ok: true,
ticket,
message: {
message_id: Date.now(),
ticket_id: ticket.ticket_id,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: JSON.parse(options?.body || "{}")?.body || "",
is_internal_note: Boolean(JSON.parse(options?.body || "{}")?.is_internal_note),
created_at: new Date().toISOString(),
},
};
}
if (String(options.method || "GET").toUpperCase() === "PATCH") {
return { ok: true, ticket: { ...ticket, ...(JSON.parse(options?.body || "{}") || {}) } };
}
return {
ok: true,
ticket,
messages: clone([
...(supportMessages[ticket.ticket_id] || []),
{
message_id: 99,
ticket_id: ticket.ticket_id,
author_role: "admin",
author_user_id: 1,
author_name: "Мария, поддержка",
body: "Внутренняя заметка для команды: проверить последние логи панели перед ответом.",
is_internal_note: true,
created_at: new Date(Date.now() - 12 * 60000).toISOString(),
},
]),
user_snapshot: {
user_id: ticket.user_id,
name: "Анна Смирнова",
username: "anna_ops",
email: "anna@example.com",
tariff: "Standard",
panel_status: "ACTIVE",
remaining: "20 д. 4 ч.",
regular_traffic: "12 GB / 500 GB",
premium_traffic: "4 GB / 25 GB",
},
};
}
if (cleanPath.startsWith("/admin/"))
return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 };
if (
cleanPath === "/support/tickets" &&
String(options.method || "GET").toUpperCase() === "POST"
) {
let payload = {};
try {
payload = JSON.parse(options?.body || "{}");
} catch (_error) {
void _error;
}
return {
ok: true,
ticket: {
ticket_id: 44,
user_id: 100200300,
subject: payload.subject || "Новое обращение",
category: payload.category || "other",
priority: payload.priority || "normal",
status: "awaiting_admin",
unread_user_count: 0,
unread_admin_count: 1,
last_message_at: new Date().toISOString(),
created_at: new Date().toISOString(),
},
};
}
if (cleanPath === "/support/tickets") {
const params = new URLSearchParams(String(path || "").split("?")[1] || "");
const tickets = filterSupportTickets(supportTickets, params);
return {
ok: true,
tickets: clone(tickets),
total: tickets.length,
counts: supportCounts(),
};
}
if (cleanPath.startsWith("/support/tickets/")) {
const parts = cleanPath.split("/");
const ticketId = Number(parts[3]);
const ticket = clone(
supportTickets.find((item) => item.ticket_id === ticketId) || supportTickets[0]
);
if (parts[4] === "read") return { ok: true };
if (parts[4] === "messages") {
return {
ok: true,
ticket,
message: {
message_id: Date.now(),
ticket_id: ticket.ticket_id,
author_role: "user",
author_user_id: 100200300,
author_name: "Анна Смирнова",
body: JSON.parse(options?.body || "{}")?.body || "",
created_at: new Date().toISOString(),
},
};
}
return { ok: true, ticket, messages: clone(supportMessages[ticket.ticket_id] || []) };
}
if (cleanPath === "/support/unread") return { ok: true, unread: 1 };
if (path === "/me") return clone(DEV_MOCK.data);
if (path === "/auth/email/request") return { ok: true };
if (path === "/auth/email/verify" || path === "/auth/email/magic") {
+23 -1
View File
@@ -7,6 +7,7 @@ export function normalizeSection(value) {
if (
section === "invite" ||
section === "devices" ||
section === "support" ||
section === "settings" ||
section === "admin"
) {
@@ -22,6 +23,7 @@ export function sectionFromPath(pathname) {
.replace(/\/+$/, "");
if (!normalizedPath || normalizedPath === "/") return "home";
if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin";
if (normalizedPath === "/support" || normalizedPath.startsWith("/support/")) return "support";
const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath;
return normalizeSection(section);
}
@@ -43,6 +45,22 @@ export function adminUserIdFromPath(pathname) {
return m ? Number(m[1]) : null;
}
export function supportTicketIdFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/support\/(\d+)$/);
return m ? Number(m[1]) : null;
}
export function adminSupportTicketIdFromPath(pathname) {
const normalized = String(pathname || "")
.toLowerCase()
.replace(/\/+$/, "");
const m = normalized.match(/^\/admin\/support\/(\d+)$/);
return m ? Number(m[1]) : null;
}
export function syncSectionPath(section, replace = false, adminSection = null, adminUserId = null) {
if (window.location.protocol === "file:") return;
const normalized = normalizeSection(section);
@@ -51,7 +69,11 @@ export function syncSectionPath(section, replace = false, adminSection = null, a
const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats";
const uid =
adminUserId ?? (adm === "users" ? adminUserIdFromPath(window.location.pathname) : null);
targetPath = adm === "users" && uid ? `/admin/users/${uid}` : `/admin/${adm}`;
const supportTicketId =
adm === "support" ? adminSupportTicketIdFromPath(window.location.pathname) : null;
if (adm === "users" && uid) targetPath = `/admin/users/${uid}`;
else if (adm === "support" && supportTicketId) targetPath = `/admin/support/${supportTicketId}`;
else targetPath = `/admin/${adm}`;
}
if (window.location.pathname === targetPath) return;
const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`;
@@ -0,0 +1,287 @@
import { writable } from "svelte/store";
export function createSupportStore({ api, t, showToast }) {
const state = writable({
tickets: [],
openedTicketId: null,
openedTicket: null,
messages: [],
unreadCount: 0,
unreadLoaded: false,
unreadLoading: false,
counts: { active: 0, closed: 0, awaiting_admin: 0, awaiting_user: 0, open: 0, total: 0 },
loading: false,
detailLoading: false,
sending: false,
creating: false,
statusFilter: "active",
polling: false,
});
let pollTimer = null;
let pollIncludeList = false;
let listRequestSeq = 0;
let listPromise = null;
let listPromiseKey = "";
let unreadPromise = null;
function hydrateUnread(value) {
const next = Math.max(0, Number(value || 0));
state.update((s) => ({
...s,
unreadCount: next,
unreadLoaded: true,
unreadLoading: false,
}));
}
async function loadList(options = {}) {
let filter = "all";
let hasTickets = false;
state.update((s) => {
filter = s.statusFilter;
hasTickets = Boolean(s.tickets?.length);
return s;
});
const requestKey = filter || "all";
if (!options.force && listPromise && listPromiseKey === requestKey) return listPromise;
const requestId = ++listRequestSeq;
const showLoading = !options.silent && (options.showLoading || !hasTickets);
if (showLoading) state.update((s) => ({ ...s, loading: true }));
let promise;
promise = (async () => {
try {
const params = new URLSearchParams({ limit: "50", offset: "0" });
if (filter && filter !== "all") params.set("status", filter);
const res = await api(`/support/tickets?${params.toString()}`);
if (requestId !== listRequestSeq) return res;
if (res?.ok)
state.update((s) => ({
...s,
tickets: res.tickets || [],
counts: res.counts || s.counts,
}));
else if (res?.error) showToast(res.message || res.error);
return res;
} finally {
if (requestId === listRequestSeq) {
state.update((s) => (s.loading ? { ...s, loading: false } : s));
}
if (listPromise === promise) {
listPromise = null;
listPromiseKey = "";
}
}
})();
listPromise = promise;
listPromiseKey = requestKey;
return promise;
}
async function refreshCurrentTicket(ticketId) {
const id = Number(ticketId);
if (!id) return;
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
}
return res;
} catch {
return null;
}
}
async function createTicket(payload) {
state.update((s) => ({ ...s, creating: true }));
try {
const res = await api("/support/tickets", {
method: "POST",
body: JSON.stringify(payload),
});
if (!res?.ok) throw res;
state.update((s) => ({ ...s, statusFilter: "active" }));
await loadList({ silent: true, force: true });
await openTicket(res.ticket.ticket_id);
return res.ticket;
} catch (error) {
showToast(error?.message || t("wa_support_create_failed"));
return null;
} finally {
state.update((s) => ({ ...s, creating: false }));
}
}
async function openTicket(ticketId, opts = {}) {
const id = Number(ticketId);
if (!id) return;
state.update((s) => ({
...s,
openedTicketId: id,
openedTicket: s.openedTicket?.ticket_id === id ? s.openedTicket : null,
messages: s.openedTicket?.ticket_id === id ? s.messages : [],
detailLoading: true,
}));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
const target = `/support/${id}`;
if (window.location.pathname !== target) {
window.history.pushState(
null,
"",
`${target}${window.location.search}${window.location.hash}`
);
}
}
try {
const res = await api(`/support/tickets/${id}`);
if (res?.ok) {
state.update((s) => ({
...s,
openedTicket: res.ticket,
messages: res.messages || [],
}));
await markRead(id);
} else {
showToast(res?.message || res?.error || "not_found");
}
} finally {
state.update((s) => ({ ...s, detailLoading: false }));
}
}
function closeTicketView(opts = {}) {
state.update((s) => ({ ...s, openedTicketId: null, openedTicket: null, messages: [] }));
if (!opts.skipPush && typeof window !== "undefined" && window.location.protocol !== "file:") {
if (window.location.pathname.startsWith("/support/")) {
window.history.pushState(
null,
"",
`/support${window.location.search}${window.location.hash}`
);
}
}
}
async function sendReply(body) {
let ticketId = null;
state.update((s) => {
ticketId = s.openedTicketId;
return { ...s, sending: true };
});
if (!ticketId) return;
try {
const res = await api(`/support/tickets/${ticketId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
if (!res?.ok) throw res;
state.update((s) => ({
...s,
openedTicket: res.ticket || s.openedTicket,
messages: [...s.messages, res.message],
}));
await refreshUnread();
await loadList({ silent: true, force: true });
} catch (error) {
showToast(error?.message || t("wa_support_send_failed"));
} finally {
state.update((s) => ({ ...s, sending: false }));
}
}
async function markRead(ticketId = null) {
const id =
ticketId ||
(() => {
let current = null;
state.update((s) => {
current = s.openedTicketId;
return s;
});
return current;
})();
if (!id) return;
await api(`/support/tickets/${id}/read`, { method: "POST", body: "{}" });
await refreshUnread();
}
async function refreshUnread() {
if (unreadPromise) return unreadPromise;
state.update((s) => ({ ...s, unreadLoading: true }));
unreadPromise = (async () => {
try {
const res = await api("/support/unread");
if (res?.ok) {
state.update((s) => ({
...s,
unreadCount: Math.max(0, Number(res.unread || 0)),
unreadLoaded: true,
}));
}
return res;
} finally {
state.update((s) => ({ ...s, unreadLoading: false }));
unreadPromise = null;
}
})();
return unreadPromise;
}
function setStatusFilter(status) {
state.update((s) => ({ ...s, statusFilter: status || "all" }));
loadList({ force: true, showLoading: true });
}
function startPolling(options = {}) {
const includeList = options.includeList !== false;
pollIncludeList = pollIncludeList || includeList;
if (pollTimer || typeof window === "undefined") return;
state.update((s) => ({ ...s, polling: true }));
const tick = async () => {
if (document.visibilityState === "visible") {
await refreshUnread();
if (!pollIncludeList) return;
let opened = null;
state.update((s) => {
opened = s.openedTicketId;
return s;
});
if (opened) await refreshCurrentTicket(opened);
else await loadList({ silent: true });
}
};
pollTimer = window.setInterval(tick, 15000);
document.addEventListener("visibilitychange", tick);
}
function closePolling() {
if (pollTimer) window.clearInterval(pollTimer);
pollTimer = null;
pollIncludeList = false;
state.update((s) => ({ ...s, polling: false }));
}
return {
subscribe: state.subscribe,
update: state.update,
loadList,
hydrateUnread,
createTicket,
openTicket,
closeTicketView,
sendReply,
markRead,
refreshUnread,
setStatusFilter,
startPolling,
closePolling,
};
}
+2 -1
View File
@@ -6,7 +6,7 @@ import "./styles.css";
async function loadBootstrap() {
if (document.getElementById("webapp-config")) return;
try {
const response = await fetch("/api/bootstrap", {
const response = await fetch("/api/bootstrap?i18n_scope=webapp", {
credentials: "include",
headers: { Accept: "application/json" },
});
@@ -31,6 +31,7 @@ const target = document.getElementById("app");
if (target) {
loadBootstrap().finally(() => {
target.replaceChildren();
mount(App, { target });
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+82 -34
View File
@@ -1466,20 +1466,6 @@ a {
position: relative;
}
.attention-dot {
position: absolute;
top: 0;
right: 0;
width: 13px;
height: 13px;
transform: translate(50%, -50%);
border-radius: 999px;
background: #ff4b4b;
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
animation: attention-pulse 1.6s ease-out infinite;
pointer-events: none;
}
.nav-attention-dot {
top: 6px;
right: 10px;
@@ -1490,20 +1476,6 @@ a {
padding-right: 6px;
}
@keyframes attention-pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0.75);
}
70% {
box-shadow: 0 0 0 8px rgba(255, 75, 75, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 75, 75, 0);
}
}
.bottom-nav {
position: fixed !important;
left: var(--bottom-nav-left);
@@ -1514,9 +1486,11 @@ a {
bottom: max(var(--bottom-nav-offset), env(safe-area-inset-bottom));
z-index: 80;
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(var(--bottom-nav-visible-items, 3), minmax(0, 1fr));
grid-auto-flow: column;
gap: 2px;
min-height: 64px;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--nav-bg);
@@ -1525,7 +1499,7 @@ a {
}
.bottom-nav-devices {
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(var(--bottom-nav-visible-items, 4), minmax(0, 1fr));
}
.bottom-nav.static {
@@ -1534,6 +1508,7 @@ a {
.bottom-nav button {
display: grid;
min-width: 0;
place-items: center;
align-content: center;
gap: 4px;
@@ -1542,16 +1517,77 @@ a {
color: var(--muted);
font-size: 10px;
font-weight: 800;
overflow: hidden;
padding: 6px 2px;
}
.bottom-nav button.active {
color: var(--accent);
}
.bottom-nav .bottom-nav-label {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.1;
}
.rail-admin-entry {
display: none !important;
}
@media (max-width: 380px) {
.bottom-nav.bottom-nav-many {
min-height: 58px;
}
.bottom-nav.bottom-nav-many button {
gap: 0;
padding: 0 2px;
}
.bottom-nav.bottom-nav-many .bottom-nav-label {
display: none;
}
.bottom-nav.bottom-nav-many .nav-attention-dot {
top: 8px;
right: calc(50% - 18px);
}
.bottom-nav.bottom-nav-many .nav-badge-floating {
top: 6px;
right: calc(50% - 24px);
}
}
@media (max-width: 360px) {
.bottom-nav {
min-height: 56px;
}
.bottom-nav button {
gap: 0;
padding: 0 2px;
}
.bottom-nav .bottom-nav-label {
display: none;
}
.bottom-nav .nav-attention-dot {
top: 8px;
right: calc(50% - 18px);
}
.bottom-nav .nav-badge-floating {
top: 6px;
right: calc(50% - 24px);
}
}
.home-layout {
display: grid;
min-height: calc(100dvh - 34px);
@@ -1975,6 +2011,10 @@ a {
font-size: 9px;
}
.preview-phone .bottom-nav .bottom-nav-label {
font-size: 9px;
}
.preview-phone .card {
padding: 12px;
}
@@ -2247,10 +2287,14 @@ a {
display: grid !important;
grid-template-columns: 1fr !important;
grid-template-rows: none !important;
grid-auto-flow: row !important;
grid-auto-rows: auto !important;
grid-auto-columns: 1fr !important;
align-content: start !important;
gap: 2px !important;
padding: 28px 14px !important;
overflow-x: hidden !important;
overflow-y: auto !important;
border: 0 !important;
border-right: 1px solid var(--border) !important;
border-radius: 0 !important;
@@ -2271,7 +2315,7 @@ a {
align-items: center !important;
justify-items: start !important;
gap: 12px !important;
padding: 12px 14px !important;
padding: 12px 42px 12px 14px !important;
border-radius: 10px !important;
text-align: left !important;
font-size: 13px !important;
@@ -2281,6 +2325,7 @@ a {
background 0.12s ease,
color 0.12s ease,
border-color 0.12s ease;
width: 100%;
}
.bottom-nav button > svg {
@@ -2288,7 +2333,7 @@ a {
height: 20px;
}
.bottom-nav button > span {
.bottom-nav .bottom-nav-label {
text-align: left !important;
font-size: 13px !important;
font-weight: 600;
@@ -2305,9 +2350,11 @@ a {
color: var(--text);
}
.bottom-nav .nav-attention-dot {
top: 14px;
.bottom-nav .nav-attention-dot,
.bottom-nav .nav-badge-floating {
top: 50%;
right: 14px;
transform: translateY(-50%);
}
.bottom-nav .rail-admin-entry {
@@ -2393,6 +2440,7 @@ a {
@media (min-width: 1024px) {
.bottom-nav .rail-brand {
display: flex;
width: 100%;
align-items: center;
gap: 12px;
padding: 4px 12px 18px;
+69 -11
View File
@@ -2,10 +2,12 @@
import {
Gift,
Home,
LifeBuoy,
Settings as SettingsIcon,
Shield,
Smartphone,
} from "$components/ui/icons.js";
import { AttentionDot } from "$components/ui/index.js";
import BrandMark from "$lib/webapp/BrandMark.svelte";
@@ -13,51 +15,107 @@
export let brand = {};
export let brandTitle = "";
export let devicesEnabled = false;
export let supportEnabled = true;
export let supportUnreadCount = 0;
export let supportUnreadLoading = false;
export let supportUnreadLoaded = false;
export let hasUnlinkedIdentity = false;
export let isAdmin = false;
export let onAdmin = () => {};
export let onDevices = () => {};
export let onHome = () => {};
export let onInvite = () => {};
export let onSupport = () => {};
export let onSettings = () => {};
export let t = (key) => key;
$: visibleNavItems = 3 + (devicesEnabled ? 1 : 0) + (supportEnabled ? 1 : 0);
$: adminLabel = t("admin_nav_title", {}, "Админ-панель");
</script>
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<nav
class:bottom-nav-devices={devicesEnabled}
class:bottom-nav-many={visibleNavItems >= 5}
class="bottom-nav"
style={`--bottom-nav-visible-items: ${visibleNavItems}`}
aria-label={t("wa_navigation")}
>
<div class="rail-brand" aria-hidden="true">
<BrandMark {brand} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" onclick={onHome}>
<button
class:active={activeTab === "home"}
type="button"
aria-label={t("wa_nav_home")}
title={t("wa_nav_home")}
onclick={onHome}
>
<Home size={21} />
<span>{t("wa_nav_home")}</span>
<span class="bottom-nav-label">{t("wa_nav_home")}</span>
</button>
<button class:active={activeTab === "invite"} type="button" onclick={onInvite}>
<button
class:active={activeTab === "invite"}
type="button"
aria-label={t("wa_nav_bonuses")}
title={t("wa_nav_bonuses")}
onclick={onInvite}
>
<Gift size={21} />
<span>{t("wa_nav_bonuses")}</span>
<span class="bottom-nav-label">{t("wa_nav_bonuses")}</span>
</button>
{#if devicesEnabled}
<button class:active={activeTab === "devices"} type="button" onclick={onDevices}>
<button
class:active={activeTab === "devices"}
type="button"
aria-label={t("wa_nav_devices")}
title={t("wa_nav_devices")}
onclick={onDevices}
>
<Smartphone size={21} />
<span>{t("wa_nav_devices")}</span>
<span class="bottom-nav-label">{t("wa_nav_devices")}</span>
</button>
{/if}
{#if supportEnabled}
<button
class:active={activeTab === "support"}
class="attention-wrap"
type="button"
aria-label={t("wa_nav_support")}
title={t("wa_nav_support")}
onclick={onSupport}
>
{#if supportUnreadCount || (supportUnreadLoading && !supportUnreadLoaded)}
<AttentionDot class="nav-attention-dot" />
{/if}
<LifeBuoy size={21} />
<span class="bottom-nav-label">{t("wa_nav_support")}</span>
</button>
{/if}
<button
class:active={activeTab === "settings"}
class="attention-wrap"
type="button"
aria-label={t("wa_nav_settings")}
title={t("wa_nav_settings")}
onclick={onSettings}
>
{#if hasUnlinkedIdentity}
<span class="attention-dot nav-attention-dot" aria-hidden="true"></span>
<AttentionDot class="nav-attention-dot" />
{/if}
<SettingsIcon size={21} />
<span>{t("wa_nav_settings")}</span>
<span class="bottom-nav-label">{t("wa_nav_settings")}</span>
</button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" onclick={onAdmin}>
<button
class="rail-admin-entry"
type="button"
aria-label={adminLabel}
title={adminLabel}
onclick={onAdmin}
>
<Shield size={21} />
<span>{t("admin_nav_title", {}, "Админ-панель")}</span>
<span class="bottom-nav-label">{adminLabel}</span>
</button>
{/if}
</nav>
+11 -1
View File
@@ -7,18 +7,23 @@
export let brand = {};
export let brandTitle;
export let devicesEnabled;
export let supportEnabled = true;
export let supportUnreadCount = 0;
export let supportUnreadLoading = false;
export let supportUnreadLoaded = false;
export let hasUnlinkedIdentity;
export let isAdmin;
export let openAdminPanel;
export let goDevices;
export let goHome;
export let goInvite;
export let goSupport;
export let goSettings;
export let t;
</script>
<div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "settings"}
{#if screen === "invite" || screen === "devices" || screen === "support" || screen === "settings"}
<header class="app-header accent-title">
<div class="brand-row">
<BrandMark {brand} />
@@ -34,12 +39,17 @@
{brand}
{brandTitle}
{devicesEnabled}
{supportEnabled}
{supportUnreadCount}
{supportUnreadLoading}
{supportUnreadLoaded}
{hasUnlinkedIdentity}
{isAdmin}
onAdmin={openAdminPanel}
onDevices={goDevices}
onHome={goHome}
onInvite={goInvite}
onSupport={goSupport}
onSettings={goSettings}
{t}
/>
@@ -11,6 +11,7 @@
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { AttentionDot } from "$components/ui/index.js";
import { LanguageSelect } from "$components/patterns/webapp/index.js";
export let currentLang = "ru";
@@ -95,7 +96,7 @@
onclick={linkTelegramAccount}
disabled={linkTelegramBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<AttentionDot />
<Send size={18} />
{t("wa_settings_link_telegram_action")}
</Button>
@@ -127,7 +128,7 @@
onclick={openLinkEmailDialog}
disabled={linkEmailBusy}
>
<span class="attention-dot" aria-hidden="true"></span>
<AttentionDot />
<Mail size={21} />
<span>
<strong>{t("wa_settings_link_email_action")}</strong>
@@ -0,0 +1,265 @@
<script>
import { getContext, onMount } from "svelte";
import { fade, slide } from "svelte/transition";
import { Check, ChevronsUpDown, LifeBuoy, MessageSquarePlus } from "$components/ui/icons.js";
import Button from "$components/ui/button.svelte";
import Card from "$components/ui/card.svelte";
import { Skeleton } from "$components/ui/index.js";
import { TicketCard } from "$components/patterns/webapp/index.js";
import { Select, Tabs } from "$components/ui/primitives.js";
export let t = (key) => key;
export let maxSubjectLength = 160;
export let maxBodyLength = 4000;
const supportStore = getContext("supportStore");
let subject = "";
let body = "";
let category = "other";
let priority = "normal";
let createOpen = false;
$: ({ tickets, loading, creating, statusFilter, counts } = $supportStore);
$: categoryOptions = [
{ value: "billing", label: t("wa_support_category_billing") },
{ value: "technical", label: t("wa_support_category_technical") },
{ value: "account", label: t("wa_support_category_account") },
{ value: "other", label: t("wa_support_category_other") },
];
$: priorityOptions = [
{ value: "normal", label: t("wa_support_priority_normal") },
{ value: "high", label: t("wa_support_priority_high") },
];
$: statusTabs = [
{
value: "active",
label: t("wa_support_filter_active", {}, "Активные"),
count: counts?.active || 0,
},
{
value: "awaiting_admin",
label: t("wa_support_status_awaiting_admin", {}, "Ждет админа"),
count: counts?.awaiting_admin || 0,
},
{
value: "awaiting_user",
label: t("wa_support_status_awaiting_user", {}, "Ждет пользователя"),
count: counts?.awaiting_user || 0,
},
{
value: "closed",
label: t("wa_support_status_closed", {}, "Закрытые"),
count: counts?.closed || 0,
},
];
$: selectedCategory =
categoryOptions.find((option) => option.value === category) || categoryOptions[0];
$: selectedPriority =
priorityOptions.find((option) => option.value === priority) || priorityOptions[0];
onMount(() => {
supportStore.loadList();
});
async function createTicket() {
const ticket = await supportStore.createTicket({ subject, body, category, priority });
if (ticket) {
subject = "";
body = "";
category = "other";
priority = "normal";
createOpen = false;
}
}
</script>
<main class="content with-nav support-screen">
<Card class="support-overview-card">
<div class="support-heading-row">
<span class="support-heading-icon" aria-hidden="true">
<LifeBuoy size={42} />
</span>
<div class="support-heading-copy">
<h1>{t("wa_support_title")}</h1>
<p>{t("wa_support_subtitle")}</p>
</div>
</div>
<button
class:active={createOpen}
class="support-new-ticket-button"
type="button"
aria-expanded={createOpen}
on:click={() => (createOpen = !createOpen)}
>
<span class="support-new-ticket-icon">
<MessageSquarePlus size={20} />
</span>
<span>
<strong>{t("wa_support_new_ticket")}</strong>
<small>{t("wa_support_contact_support")}</small>
</span>
<ChevronsUpDown size={18} />
</button>
{#if createOpen}
<div class="support-create-panel" in:slide={{ duration: 180 }} out:slide={{ duration: 140 }}>
<div class="support-create-panel-inner" in:fade={{ duration: 140 }}>
<label class="support-field">
<span>{t("wa_support_subject")}</span>
<input
class="input"
bind:value={subject}
maxlength={maxSubjectLength}
placeholder={t("wa_support_subject_placeholder")}
/>
</label>
<div class="support-create-grid">
<label class="support-field">
<span>{t("wa_support_category")}</span>
<Select.Root
type="single"
value={category}
items={categoryOptions}
onValueChange={(value) => (category = value)}
>
<Select.Trigger
class="support-select-trigger"
aria-label={t("wa_support_category")}
>
<span>{selectedCategory.label}</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content
class="support-select-content"
side="bottom"
align="start"
sideOffset={6}
>
<Select.Viewport class="support-select-viewport">
{#each categoryOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="support-select-item"
>
<span>{option.label}</span>
<Check size={15} class="support-select-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</label>
<label class="support-field">
<span>{t("wa_support_priority")}</span>
<Select.Root
type="single"
value={priority}
items={priorityOptions}
onValueChange={(value) => (priority = value)}
>
<Select.Trigger
class="support-select-trigger"
aria-label={t("wa_support_priority")}
>
<span>{selectedPriority.label}</span>
<ChevronsUpDown size={16} />
</Select.Trigger>
<Select.Content
class="support-select-content"
side="bottom"
align="start"
sideOffset={6}
>
<Select.Viewport class="support-select-viewport">
{#each priorityOptions as option (option.value)}
<Select.Item
value={option.value}
label={option.label}
class="support-select-item"
>
<span>{option.label}</span>
<Check size={15} class="support-select-check" />
</Select.Item>
{/each}
</Select.Viewport>
</Select.Content>
</Select.Root>
</label>
</div>
<label class="support-field">
<span>{t("wa_support_message")}</span>
<textarea
class="textarea support-message-input"
bind:value={body}
maxlength={maxBodyLength}
rows="5"
placeholder={t("wa_support_message_placeholder")}
></textarea>
<small>{body.length}/{maxBodyLength}</small>
</label>
<Button
class="wide support-submit-button"
size="lg"
disabled={creating || !subject.trim() || !body.trim()}
onclick={createTicket}
>
<MessageSquarePlus size={18} />
{creating ? t("wa_support_creating") : t("wa_support_create")}
</Button>
</div>
</div>
{/if}
</Card>
<Card class="support-list-card">
<Tabs.Root
value={statusFilter}
onValueChange={(value) => supportStore.setStatusFilter(value || "all")}
class="support-status-tabs"
>
<Tabs.List class="support-status-tabs-list" aria-label={t("wa_support_filter_label")}>
{#each statusTabs as tab (tab.value)}
<Tabs.Trigger value={tab.value} class="support-status-tabs-trigger">
<span>{tab.label}</span>
<b>{tab.count}</b>
</Tabs.Trigger>
{/each}
</Tabs.List>
</Tabs.Root>
{#if loading}
<div class="support-user-list-skeleton" aria-label={t("wa_loading")}>
{#each Array(5) as _, index (index)}
<article class="support-user-ticket-skeleton">
<span class="support-user-ticket-skeleton-main">
<Skeleton variant="title" width="min(420px, 76%)" />
<Skeleton variant="short" width="min(260px, 58%)" />
</span>
<span class="support-user-ticket-skeleton-side">
<Skeleton variant="badge" width="92px" />
<Skeleton variant="tiny" width="64px" />
</span>
</article>
{/each}
</div>
{:else if !tickets.length}
<div class="support-empty-state" in:fade={{ duration: 180 }}>
<MessageSquarePlus size={34} />
<strong>{t("wa_support_no_open_tickets")}</strong>
<small>{t("wa_support_empty_hint")}</small>
</div>
{:else}
<div class="ticket-list">
{#each tickets as ticket}
<TicketCard {ticket} {t} onOpen={(item) => supportStore.openTicket(item.ticket_id)} />
{/each}
</div>
{/if}
</Card>
</main>
@@ -0,0 +1,172 @@
<script>
import { afterUpdate, getContext, tick } from "svelte";
import { Badge, Button, ScrollArea, Skeleton } from "$components/ui/index.js";
import Card from "$components/ui/card.svelte";
import { ArrowLeft } from "$components/ui/icons.js";
import { TicketComposer, TicketMessageBubble } from "$components/patterns/webapp/index.js";
export let t = (key) => key;
export let maxBodyLength = 4000;
export let brand = {};
export let userAvatarUrl = "";
export let userInitials = "";
const supportStore = getContext("supportStore");
let reply = "";
let messagesScrollEl;
let lastMessageKey = "";
$: ({ openedTicket, messages, detailLoading, sending } = $supportStore);
$: closed = ["resolved", "closed"].includes(openedTicket?.status);
async function send(body) {
await supportStore.sendReply(body);
reply = "";
}
function scrollMessagesToBottom() {
if (!messagesScrollEl) return;
const scroll = () => {
messagesScrollEl.scrollTop = messagesScrollEl.scrollHeight;
};
scroll();
requestAnimationFrame(scroll);
window.setTimeout(scroll, 80);
window.setTimeout(scroll, 180);
}
function messageAuthorName(message) {
if (message?.author_name) return message.author_name;
return message?.author_role === "user" ? t("wa_support_role_user") : "";
}
afterUpdate(async () => {
const nextKey = `${openedTicket?.ticket_id || ""}:${messages.length}:${messages.at(-1)?.message_id || ""}`;
if (!messagesScrollEl || nextKey === lastMessageKey) return;
lastMessageKey = nextKey;
await tick();
scrollMessagesToBottom();
});
</script>
<main class="content with-nav support-ticket-screen">
{#if detailLoading && !openedTicket}
<Card class="support-ticket-card">
<header class="ticket-detail-header support-ticket-detail-header">
<Button
variant="ghost"
size="sm"
class="support-back-button"
onclick={() => supportStore.closeTicketView()}
>
<ArrowLeft size={16} />
<span>{t("wa_back")}</span>
</Button>
<div class="ticket-detail-title">
<small>{t("wa_loading")}</small>
<h1>{t("wa_support_title")}</h1>
</div>
</header>
</Card>
<Card class="support-conversation-card support-conversation-card--loading">
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-message-scroll scroll-area--mono"
>
<div class="ticket-message-list ticket-message-list--loading" aria-label={t("wa_loading")}>
{#each Array(4) as _, index (index)}
<div
class:ticket-message-skeleton-row--outgoing={index % 2 === 1}
class="ticket-message-skeleton-row"
>
<Skeleton variant="dot" width="32px" height="32px" />
<span class="ticket-message-skeleton-content">
<Skeleton variant="tiny" width={index % 2 === 1 ? "72px" : "96px"} />
<Skeleton variant="block" height={index === 1 ? "72px" : "54px"} />
</span>
</div>
{/each}
</div>
</ScrollArea>
</Card>
{:else if !openedTicket}
<Card class="support-ticket-card support-ticket-state-card">
<div class="empty-card">{t("wa_support_not_found")}</div>
</Card>
{:else}
<Card class="support-ticket-card">
<header class="ticket-detail-header support-ticket-detail-header">
<Button
variant="ghost"
size="sm"
class="support-back-button"
onclick={() => supportStore.closeTicketView()}
>
<ArrowLeft size={16} />
<span>{t("wa_back")}</span>
</Button>
<div class="ticket-detail-title">
<small>{t("wa_support_ticket_number", { id: openedTicket.ticket_id })}</small>
<h1>{openedTicket.subject}</h1>
</div>
<div class="ticket-badges">
<Badge
variant="outline"
class={`ticket-status-badge ticket-status-badge--${openedTicket.status}`}
>
{t(`wa_support_status_${openedTicket.status}`)}
</Badge>
<Badge
variant="muted"
class={`ticket-priority-badge ticket-priority-badge--${openedTicket.priority}`}
>
{t(`wa_support_priority_${openedTicket.priority}`)}
</Badge>
</div>
</header>
</Card>
<Card class="support-conversation-card">
<ScrollArea
bind:element={messagesScrollEl}
maxHeight="none"
class="support-message-scroll scroll-area--mono"
>
<div class="ticket-message-list">
{#if messages.length}
{#each messages as message}
<TicketMessageBubble
role={message.author_role}
body={message.body}
createdAt={message.created_at}
isInternalNote={message.is_internal_note}
supportBrand={brand}
{userAvatarUrl}
{userInitials}
authorName={messageAuthorName(message)}
{t}
/>
{/each}
{:else}
<div class="support-messages-empty">{t("wa_support_no_messages")}</div>
{/if}
</div>
</ScrollArea>
<TicketComposer
bind:value={reply}
maxLength={maxBodyLength}
disabled={closed}
{sending}
placeholder={closed ? t("wa_support_closed_hint") : t("wa_support_reply_placeholder")}
sendLabel={t("wa_support_send")}
onSend={send}
/>
</Card>
{/if}
</main>
+111 -1
View File
@@ -946,6 +946,7 @@
"admin_settings_section_referral": "Referral program",
"admin_settings_section_notifications": "Notifications",
"admin_settings_section_devices": "Devices",
"admin_settings_section_support": "Support",
"admin_sync_started": "Synchronization started",
"admin_sync_error": "Synchronization error",
"admin_error": "Error",
@@ -1385,6 +1386,22 @@
"admin_settings_field_log_level_description": "Controls the 'Log Level' setting in admin overrides.",
"admin_settings_field_log_chat_id_label": "Log Chat ID",
"admin_settings_field_log_thread_id_label": "Log Thread ID",
"admin_settings_field_log_support_thread_id_label": "Support thread ID",
"admin_settings_field_log_support_thread_id_description": "Log chat thread for support ticket notifications.",
"admin_settings_field_support_tickets_enabled_label": "Support tickets enabled",
"admin_settings_field_support_tickets_enabled_description": "Enable the support tickets section in the user account and allow users to create tickets.",
"admin_settings_field_support_admin_email_notifications_enabled_label": "Admin email notifications",
"admin_settings_field_support_admin_email_notifications_enabled_description": "When disabled, new tickets and user replies are sent only to Telegram and the log chat.",
"admin_settings_field_support_admin_notification_cooldown_seconds_label": "Telegram notification cooldown",
"admin_settings_field_support_admin_notification_cooldown_seconds_description": "Minimum seconds between repeated Telegram/log notifications for the same unread ticket.",
"admin_settings_field_support_admin_email_cooldown_seconds_label": "Email notification cooldown",
"admin_settings_field_support_admin_email_cooldown_seconds_description": "Minimum seconds between repeated email notifications for the same unread ticket.",
"admin_settings_field_support_ticket_max_body_length_label": "Max message length",
"admin_settings_field_support_ticket_max_body_length_description": "Maximum number of characters in a ticket message.",
"admin_settings_field_support_ticket_max_subject_length_label": "Max subject length",
"admin_settings_field_support_ticket_max_subject_length_description": "Maximum number of characters in a ticket subject.",
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Ticket limit per hour",
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "How many new tickets a user can create per hour. 0 means unlimited.",
"admin_settings_field_my_devices_section_enabled_label": "My Devices Section Enabled",
"admin_settings_field_user_hwid_device_limit_label": "User HWID Device Limit",
"admin_settings_field_user_traffic_limit_gb_label": "User Traffic Limit Gb",
@@ -1410,5 +1427,98 @@
"wa_password_mismatch": "Passwords do not match",
"wa_password_code_send_failed": "Could not send the code",
"wa_password_set_failed": "Could not save password",
"wa_password_set_success": "Password saved"
"wa_password_set_success": "Password saved",
"wa_nav_support": "Support",
"wa_support_title": "Support",
"wa_support_subtitle": "Tickets and replies from the team",
"wa_support_new_ticket": "New request",
"wa_support_contact_support": "Contact support",
"wa_support_subject": "Subject",
"wa_support_subject_placeholder": "Briefly describe the issue",
"wa_support_category": "Category",
"wa_support_priority": "Priority",
"wa_support_message": "Message",
"wa_support_message_placeholder": "Tell us what happened. More details help us respond faster.",
"wa_support_create": "Create ticket",
"wa_support_creating": "Creating...",
"wa_support_empty": "No tickets yet",
"wa_support_no_open_tickets": "No open requests",
"wa_support_empty_hint": "Create a new request if you need help with billing, connection, or your account.",
"wa_support_not_found": "Ticket not found",
"wa_support_send": "Send",
"wa_support_reply_placeholder": "Write a reply",
"wa_support_closed_hint": "This ticket is closed",
"wa_support_create_failed": "Could not create ticket",
"wa_support_send_failed": "Could not send reply",
"wa_support_filter_all": "All",
"wa_support_filter_active": "Active",
"wa_support_filter_label": "Ticket filter",
"wa_support_ticket_number": "Ticket #{id}",
"wa_support_no_messages": "No messages yet",
"wa_support_role_user": "You",
"wa_support_role_admin": "Support",
"wa_support_role_system": "System",
"wa_support_internal_note": "Internal note",
"wa_support_open_ticket": "Open ticket",
"wa_support_open_ticket_hint": "Open the ticket in Mini App.",
"wa_support_category_billing": "Billing",
"wa_support_category_technical": "Technical",
"wa_support_category_account": "Account",
"wa_support_category_other": "Other",
"wa_support_priority_low": "Low",
"wa_support_priority_normal": "Normal",
"wa_support_priority_high": "High",
"wa_support_priority_urgent": "Urgent",
"wa_support_status_open": "Open",
"wa_support_status_awaiting_user": "Awaiting user",
"wa_support_status_awaiting_admin": "Awaiting admin",
"wa_support_status_resolved": "Resolved",
"wa_support_status_closed": "Closed",
"admin_nav_support": "Support",
"admin_section_support_title": "Support",
"admin_section_support_subtitle": "Ticket inbox and user replies",
"admin_support_search": "Search",
"admin_support_empty": "No tickets yet",
"admin_support_select_ticket": "Select a ticket",
"admin_support_close_ticket": "Close",
"admin_support_internal_note": "Internal note",
"admin_support_reply_placeholder": "Reply",
"admin_support_no_messages": "No messages yet",
"admin_support_filter_all": "All",
"admin_support_filter_active": "Active",
"admin_support_filter_closed": "Closed",
"admin_support_filter_all_priorities": "Any priority",
"admin_support_filter_all_categories": "All categories",
"admin_support_ticket_number": "Ticket #{id}",
"admin_support_priority": "Priority",
"admin_support_category": "Category",
"admin_support_role_user": "User",
"admin_support_role_admin": "Admin",
"admin_support_role_system": "System",
"admin_support_user_context": "User",
"admin_support_open_user": "User card",
"admin_support_tariff": "Tariff",
"admin_support_status": "Status",
"admin_support_remaining": "Remaining",
"admin_support_unread": "Unread",
"admin_support_summary": "Support summary",
"admin_support_ticket_dialog": "Support conversation",
"admin_support_status_open": "Open",
"admin_support_status_awaiting_user": "Awaiting user",
"admin_support_status_awaiting_admin": "Awaiting admin",
"admin_support_status_resolved": "Resolved",
"admin_support_status_closed": "Closed",
"admin_support_priority_low": "Low",
"admin_support_priority_normal": "Normal",
"admin_support_priority_high": "High",
"admin_support_priority_urgent": "Urgent",
"admin_support_category_billing": "Billing",
"admin_support_category_technical": "Technical",
"admin_support_category_account": "Account",
"admin_support_category_other": "Other",
"admin_support_sort_importance_desc": "Most important",
"admin_sort_updated_desc": "Newest activity",
"admin_sort_updated_asc": "Oldest activity",
"admin_sort_created_desc": "Newest created",
"admin_sort_created_asc": "Oldest created"
}
+111 -1
View File
@@ -946,6 +946,7 @@
"admin_settings_section_referral": "Реферальная программа",
"admin_settings_section_notifications": "Уведомления",
"admin_settings_section_devices": "Устройства",
"admin_settings_section_support": "Поддержка",
"admin_sync_started": "Синхронизация запущена",
"admin_sync_error": "Ошибка синхронизации",
"admin_error": "Ошибка",
@@ -1385,6 +1386,22 @@
"admin_settings_field_log_level_description": "DEBUG / INFO / WARNING / ERROR",
"admin_settings_field_log_chat_id_label": "ID чата для логов",
"admin_settings_field_log_thread_id_label": "ID треда (для супергрупп)",
"admin_settings_field_log_support_thread_id_label": "ID треда поддержки",
"admin_settings_field_log_support_thread_id_description": "Тред лог-чата для уведомлений о тикетах поддержки.",
"admin_settings_field_support_tickets_enabled_label": "Тикеты поддержки включены",
"admin_settings_field_support_tickets_enabled_description": "Показывает раздел поддержки в ЛК и включает создание тикетов.",
"admin_settings_field_support_admin_email_notifications_enabled_label": "Email-уведомления админам",
"admin_settings_field_support_admin_email_notifications_enabled_description": "Если выключено, новые тикеты и ответы пользователей останутся только в Telegram и лог-чате.",
"admin_settings_field_support_admin_notification_cooldown_seconds_label": "Пауза Telegram-уведомлений",
"admin_settings_field_support_admin_notification_cooldown_seconds_description": "Минимум секунд между повторными Telegram/log уведомлениями по одному непрочитанному тикету.",
"admin_settings_field_support_admin_email_cooldown_seconds_label": "Пауза email-уведомлений",
"admin_settings_field_support_admin_email_cooldown_seconds_description": "Минимум секунд между повторными email-уведомлениями по одному непрочитанному тикету.",
"admin_settings_field_support_ticket_max_body_length_label": "Макс. длина сообщения",
"admin_settings_field_support_ticket_max_body_length_description": "Максимальное количество символов в сообщении тикета.",
"admin_settings_field_support_ticket_max_subject_length_label": "Макс. длина темы",
"admin_settings_field_support_ticket_max_subject_length_description": "Максимальное количество символов в теме тикета.",
"admin_settings_field_support_ticket_rate_limit_per_hour_label": "Лимит тикетов в час",
"admin_settings_field_support_ticket_rate_limit_per_hour_description": "Сколько новых тикетов пользователь может создать за час. 0 — без лимита.",
"admin_settings_field_my_devices_section_enabled_label": "Раздел «Мои устройства»",
"admin_settings_field_user_hwid_device_limit_label": "Лимит устройств по умолчанию (0 = ∞)",
"admin_settings_field_user_traffic_limit_gb_label": "Лимит трафика пользователя (ГБ)",
@@ -1410,5 +1427,98 @@
"wa_password_mismatch": "Пароли не совпадают",
"wa_password_code_send_failed": "Не удалось отправить код",
"wa_password_set_failed": "Не удалось сохранить пароль",
"wa_password_set_success": "Пароль сохранён"
"wa_password_set_success": "Пароль сохранён",
"wa_nav_support": "Поддержка",
"wa_support_title": "Поддержка",
"wa_support_subtitle": "Тикеты и ответы команды",
"wa_support_new_ticket": "Новое обращение",
"wa_support_contact_support": "Связаться с поддержкой",
"wa_support_subject": "Тема",
"wa_support_subject_placeholder": "Кратко опишите вопрос",
"wa_support_category": "Категория",
"wa_support_priority": "Приоритет",
"wa_support_message": "Сообщение",
"wa_support_message_placeholder": "Расскажите, что произошло. Чем больше деталей, тем быстрее поможем.",
"wa_support_create": "Создать тикет",
"wa_support_creating": "Создаём...",
"wa_support_empty": "Тикетов пока нет",
"wa_support_no_open_tickets": "Нет открытых обращений",
"wa_support_empty_hint": "Создайте новое обращение, если нужна помощь с оплатой, подключением или аккаунтом.",
"wa_support_not_found": "Тикет не найден",
"wa_support_send": "Отправить",
"wa_support_reply_placeholder": "Напишите ответ",
"wa_support_closed_hint": "Тикет закрыт",
"wa_support_create_failed": "Не удалось создать тикет",
"wa_support_send_failed": "Не удалось отправить ответ",
"wa_support_filter_all": "Все",
"wa_support_filter_active": "Активные",
"wa_support_filter_label": "Фильтр обращений",
"wa_support_ticket_number": "Тикет #{id}",
"wa_support_no_messages": "Сообщений пока нет",
"wa_support_role_user": "Вы",
"wa_support_role_admin": "Поддержка",
"wa_support_role_system": "Система",
"wa_support_internal_note": "Внутренняя заметка",
"wa_support_open_ticket": "Открыть тикет",
"wa_support_open_ticket_hint": "Откройте тикет в Mini App.",
"wa_support_category_billing": "Оплата",
"wa_support_category_technical": "Техническое",
"wa_support_category_account": "Аккаунт",
"wa_support_category_other": "Другое",
"wa_support_priority_low": "Низкий",
"wa_support_priority_normal": "Обычный",
"wa_support_priority_high": "Высокий",
"wa_support_priority_urgent": "Срочный",
"wa_support_status_open": "Открыт",
"wa_support_status_awaiting_user": "Ждёт пользователя",
"wa_support_status_awaiting_admin": "Ждёт админа",
"wa_support_status_resolved": "Решён",
"wa_support_status_closed": "Закрыт",
"admin_nav_support": "Поддержка",
"admin_section_support_title": "Поддержка",
"admin_section_support_subtitle": "Инбокс тикетов и ответы пользователям",
"admin_support_search": "Поиск",
"admin_support_empty": "Тикетов пока нет",
"admin_support_select_ticket": "Выберите тикет",
"admin_support_close_ticket": "Закрыть",
"admin_support_internal_note": "Внутренняя заметка",
"admin_support_reply_placeholder": "Ответ",
"admin_support_no_messages": "Сообщений пока нет",
"admin_support_filter_all": "Все",
"admin_support_filter_active": "Активные",
"admin_support_filter_closed": "Закрытые",
"admin_support_filter_all_priorities": "Любой приоритет",
"admin_support_filter_all_categories": "Все категории",
"admin_support_ticket_number": "Тикет #{id}",
"admin_support_priority": "Приоритет",
"admin_support_category": "Категория",
"admin_support_role_user": "Пользователь",
"admin_support_role_admin": "Админ",
"admin_support_role_system": "Система",
"admin_support_user_context": "Пользователь",
"admin_support_open_user": "Карточка",
"admin_support_tariff": "Тариф",
"admin_support_status": "Статус",
"admin_support_remaining": "Осталось",
"admin_support_unread": "Непрочитано",
"admin_support_summary": "Сводка поддержки",
"admin_support_ticket_dialog": "Диалог поддержки",
"admin_support_status_open": "Открыт",
"admin_support_status_awaiting_user": "Ждёт пользователя",
"admin_support_status_awaiting_admin": "Ждёт админа",
"admin_support_status_resolved": "Решён",
"admin_support_status_closed": "Закрыт",
"admin_support_priority_low": "Низкий",
"admin_support_priority_normal": "Обычный",
"admin_support_priority_high": "Высокий",
"admin_support_priority_urgent": "Срочный",
"admin_support_category_billing": "Оплата",
"admin_support_category_technical": "Техническое",
"admin_support_category_account": "Аккаунт",
"admin_support_category_other": "Другое",
"admin_support_sort_importance_desc": "Важные сверху",
"admin_sort_updated_desc": "Сначала новые",
"admin_sort_updated_asc": "Сначала старые",
"admin_sort_created_desc": "Созданы недавно",
"admin_sort_created_asc": "Созданы давно"
}
@@ -0,0 +1,52 @@
import json
from pathlib import Path
from bot.app.web.admin_settings_manifest import manifest_payload
REPO_ROOT = Path(__file__).resolve().parents[1]
SUPPORT_RELATED_SETTINGS = (
"LOG_SUPPORT_THREAD_ID",
"SUPPORT_TICKETS_ENABLED",
"SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED",
"SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS",
"SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS",
"SUPPORT_TICKET_MAX_BODY_LENGTH",
"SUPPORT_TICKET_MAX_SUBJECT_LENGTH",
"SUPPORT_TICKET_RATE_LIMIT_PER_HOUR",
)
def _manifest_by_key() -> dict[str, dict]:
return {item["key"]: item for item in manifest_payload()}
def _locale(language: str) -> dict[str, str]:
return json.loads((REPO_ROOT / "locales" / f"{language}.json").read_text(encoding="utf-8"))
def test_support_settings_manifest_uses_admin_i18n_keys():
manifest = _manifest_by_key()
assert manifest["SUPPORT_TICKETS_ENABLED"]["section"] == "support"
assert manifest["SUPPORT_TICKETS_ENABLED"]["section_order"] == 8
for setting_key in SUPPORT_RELATED_SETTINGS:
field = manifest[setting_key]
prefix = f"admin_settings_field_{setting_key.lower()}"
assert field["i18n_label_key"] == f"{prefix}_label"
assert field["i18n_description_key"] == f"{prefix}_description"
def test_support_settings_i18n_keys_exist_in_admin_locales():
manifest = _manifest_by_key()
for language in ("ru", "en"):
messages = _locale(language)
assert "admin_settings_section_support" in messages
for setting_key in SUPPORT_RELATED_SETTINGS:
field = manifest[setting_key]
assert field["i18n_label_key"] in messages
assert field["i18n_description_key"] in messages
+4 -2
View File
@@ -18,7 +18,7 @@ import tempfile
import unittest
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock
from bot.app.factories.build_services import build_core_services
from bot.payment_providers.yookassa import YooKassaService
@@ -26,7 +26,6 @@ from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
# Strip all provider env so per-provider BaseSettings models don't pick up
# real credentials from the local .env file during tests.
_PROVIDER_ENV_PREFIXES = (
@@ -140,6 +139,9 @@ class BuildServicesWiringTests(unittest.TestCase):
"subscription_service",
"referral_service",
"promo_code_service",
"notification_service",
"email_auth_service",
"support_service",
"stars_service",
"cryptopay_service",
"freekassa_service",
+11
View File
@@ -152,11 +152,22 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
def test_support_admin_email_notifications_default_to_disabled(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
)
self.assertFalse(settings.SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED)
def test_payment_button_presentation_env_values_are_available(self):
"""Presentation overrides now live on each provider's BaseSettings
model instead of the central Settings verify they're loaded from
env and exposed via the provider bundle."""
import os
from bot.payment_providers import build_provider_configs, get_spec_presentation
os.environ["PAYMENT_YOOKASSA_WEBAPP_LABEL_RU"] = "Карта"
+17
View File
@@ -0,0 +1,17 @@
from bot.app.web.admin_api_impl.support import AdminTicketPatchPayload, AdminTicketReplyPayload
def test_admin_patch_payload_accepts_closed_status_and_urgent_priority():
payload = AdminTicketPatchPayload.model_validate(
{"status": "closed", "priority": "urgent", "category": "billing"}
)
assert payload.status == "closed"
assert payload.priority == "urgent"
def test_admin_reply_payload_supports_internal_note():
payload = AdminTicketReplyPayload.model_validate({"body": " note ", "is_internal_note": True})
assert payload.body == "note"
assert payload.is_internal_note is True
+25
View File
@@ -0,0 +1,25 @@
import pytest
from pydantic import ValidationError
from bot.app.web.webapp.payloads import CreateTicketPayload, TicketReplyPayload
def test_user_ticket_payload_accepts_only_public_priorities():
payload = CreateTicketPayload.model_validate(
{"subject": "Help", "category": "technical", "priority": "high", "body": "Text"}
)
assert payload.priority == "high"
def test_user_ticket_payload_rejects_admin_only_priority():
with pytest.raises(ValidationError):
CreateTicketPayload.model_validate(
{"subject": "Help", "category": "technical", "priority": "urgent", "body": "Text"}
)
def test_ticket_reply_trims_body():
payload = TicketReplyPayload.model_validate({"body": " hello "})
assert payload.body == "hello"
+12
View File
@@ -0,0 +1,12 @@
from db.dal import support_dal
def test_support_dal_status_groups_are_future_close_ready():
assert "closed" in support_dal.CLOSED_STATUSES
assert "resolved" in support_dal.CLOSED_STATUSES
assert "awaiting_admin" in support_dal.ACTIVE_STATUSES
def test_support_dal_all_status_filter_means_no_filter():
assert support_dal._status_condition("all") is None
assert support_dal._status_condition("any") is None
+23
View File
@@ -0,0 +1,23 @@
from db.migrator import MIGRATIONS
from db.models import SupportTicket, SupportTicketMessage
def test_support_migration_is_registered_after_existing_revisions():
ids = [migration.id for migration in MIGRATIONS]
assert "0024_add_support_tickets" in ids
assert ids.index("0024_add_support_tickets") > ids.index("0023_add_email_password_auth_fields")
assert "0025_add_support_notification_timestamps" in ids
assert ids.index("0025_add_support_notification_timestamps") > ids.index(
"0024_add_support_tickets"
)
def test_support_models_expose_expected_tables():
assert SupportTicket.__tablename__ == "support_tickets"
assert SupportTicketMessage.__tablename__ == "support_ticket_messages"
assert "admin_last_notified_at" in SupportTicket.__table__.columns
assert "admin_last_emailed_at" in SupportTicket.__table__.columns
assert "ix_support_tickets_status_last_msg" in {
index.name for index in SupportTicket.__table__.indexes
}
+366
View File
@@ -0,0 +1,366 @@
import asyncio
from types import SimpleNamespace
from bot.services.notification_service import NotificationService
from config.settings import Settings
def _settings(**overrides):
data = {
"BOT_TOKEN": "123456:test",
"POSTGRES_USER": "app_user",
"POSTGRES_PASSWORD": "app_password",
}
data.update(overrides)
return Settings(_env_file=None, **data)
def test_support_ticket_url_uses_subscription_mini_app_url():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"),
)
assert service._support_ticket_url(42, admin=True) == "https://app.example.com/admin/support/42"
assert service._support_ticket_url(42, admin=False) == "https://app.example.com/support/42"
def test_support_ticket_url_falls_back_to_startapp_deeplink():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(),
bot_username="demo_bot",
)
assert service._support_ticket_url(42) == "https://t.me/demo_bot?startapp=ticket_42"
def test_admin_support_keyboard_uses_consistent_admin_links():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com/app"),
)
ticket = SimpleNamespace(ticket_id=42)
user = SimpleNamespace(user_id=100200300)
keyboard = service._support_keyboard(ticket, user, admin=True)
ticket_button = keyboard.inline_keyboard[0][0]
user_card_button = keyboard.inline_keyboard[1][1]
assert keyboard.inline_keyboard[0][0].text == "Открыть тикет"
assert ticket_button.url is None
assert ticket_button.web_app.url == "https://app.example.com/app/admin/support/42"
assert keyboard.inline_keyboard[1][0].url == "tg://user?id=100200300"
assert user_card_button.url is None
assert user_card_button.web_app.url == "https://app.example.com/app/admin/users/100200300"
def test_admin_support_keyboard_falls_back_to_startapp_url():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(),
bot_username="demo_bot",
)
ticket = SimpleNamespace(ticket_id=42)
user = SimpleNamespace(user_id=100200300)
keyboard = service._support_keyboard(ticket, user, admin=True)
button = keyboard.inline_keyboard[0][0]
assert button.web_app is None
assert button.url == "https://t.me/demo_bot?startapp=ticket_42"
def test_user_support_keyboard_uses_web_app_button_when_configured():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com/app"),
)
ticket = SimpleNamespace(ticket_id=42)
user = SimpleNamespace(language_code="ru")
keyboard = service._support_user_keyboard(ticket, user)
button = keyboard.inline_keyboard[0][0]
assert button.text == "Открыть тикет"
assert button.url is None
assert button.web_app.url == "https://app.example.com/app/support/42"
def test_user_support_keyboard_falls_back_to_startapp_url():
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(),
bot_username="demo_bot",
)
ticket = SimpleNamespace(ticket_id=42)
user = SimpleNamespace(language_code="ru")
keyboard = service._support_user_keyboard(ticket, user)
button = keyboard.inline_keyboard[0][0]
assert button.text == "Открыть тикет"
assert button.web_app is None
assert button.url == "https://t.me/demo_bot?startapp=ticket_42"
def test_admin_support_email_notifications_can_be_disabled():
sent = []
class EmailService:
async def send_rendered_email(self, *, email, content):
sent.append((email, content))
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False),
email_auth_service=EmailService(),
)
async def admin_email_users():
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
service._admin_email_users = admin_email_users
async def run():
await service._send_admin_support_email(
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
ticket_id=1,
)
asyncio.run(run())
assert sent == []
def test_admin_support_email_notifications_default_to_disabled():
sent = []
class EmailService:
async def send_rendered_email(self, *, email, content):
sent.append((email, content))
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(),
email_auth_service=EmailService(),
)
async def admin_email_users():
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
service._admin_email_users = admin_email_users
async def run():
await service._send_admin_support_email(
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
ticket_id=1,
)
asyncio.run(run())
assert sent == []
def test_persisted_support_email_override_disables_env_enabled(monkeypatch):
sent = []
class EmailService:
async def send_rendered_email(self, *, email, content):
sent.append((email, content))
class SessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return SimpleNamespace()
async def __aexit__(self, exc_type, exc, tb):
return None
async def get_override_value(_session, key):
assert key == "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
return True, False
monkeypatch.setattr(
"bot.services.notification_service.app_settings_dal.get_override_value",
get_override_value,
)
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True),
session_factory=SessionFactory(),
email_auth_service=EmailService(),
)
async def admin_email_users():
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
service._admin_email_users = admin_email_users
async def run():
await service._send_admin_support_email(
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
ticket_id=1,
)
asyncio.run(run())
assert sent == []
def test_persisted_support_email_override_enables_default_disabled(monkeypatch):
sent = []
class EmailService:
async def send_rendered_email(self, *, email, content):
sent.append((email, content))
class SessionFactory:
def __call__(self):
return self
async def __aenter__(self):
return SimpleNamespace()
async def __aexit__(self, exc_type, exc, tb):
return None
async def get_override_value(_session, key):
assert key == "SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED"
return True, True
monkeypatch.setattr(
"bot.services.notification_service.app_settings_dal.get_override_value",
get_override_value,
)
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(),
session_factory=SessionFactory(),
email_auth_service=EmailService(),
)
async def admin_email_users():
return [SimpleNamespace(user_id=1, email="admin@example.com", language_code="en")]
service._admin_email_users = admin_email_users
async def run():
await service._send_admin_support_email(
lambda *_args, **_kwargs: SimpleNamespace(subject="Ticket", html="Body", text="Body"),
ticket_id=1,
)
asyncio.run(run())
assert len(sent) == 1
def test_disabled_admin_support_email_keeps_telegram_and_log_notifications():
emails = []
channels = []
class EmailService:
async def send_rendered_email(self, *, email, content):
emails.append((email, content))
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=False,
SUBSCRIPTION_MINI_APP_URL="https://app.example.com",
),
email_auth_service=EmailService(),
)
async def send_to_admins(message, reply_markup=None):
channels.append(("admins", bool(message), bool(reply_markup)))
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
channels.append(("log", bool(message), bool(reply_markup)))
service._send_to_admins = send_to_admins
service._send_to_log_channel = send_to_log_channel
ticket = SimpleNamespace(
ticket_id=7,
priority="normal",
category="technical",
subject="Connection issue",
)
user = SimpleNamespace(
user_id=100200300,
username="user",
first_name="User",
last_name=None,
email="user@example.com",
)
asyncio.run(
service.notify_new_support_ticket(
ticket,
user,
"Cannot connect",
{"tariff": "Standard", "end_date": "2026-06-01"},
)
)
assert [item[0] for item in channels] == ["admins", "log"]
assert emails == []
def test_support_user_reply_can_send_email_without_telegram_channels():
emails = []
channels = []
service = NotificationService(
bot=SimpleNamespace(),
settings=_settings(SUBSCRIPTION_MINI_APP_URL="https://app.example.com"),
)
async def send_to_admins(message, reply_markup=None):
channels.append(("admins", bool(message), bool(reply_markup)))
async def send_to_log_channel(message, thread_id=None, reply_markup=None):
channels.append(("log", bool(message), bool(reply_markup)))
async def send_admin_support_email(renderer, **kwargs):
emails.append(kwargs)
service._send_to_admins = send_to_admins
service._send_to_log_channel = send_to_log_channel
service._send_admin_support_email = send_admin_support_email
ticket = SimpleNamespace(
ticket_id=7,
priority="normal",
category="technical",
subject="Connection issue",
)
message = SimpleNamespace(body="Still cannot connect")
user = SimpleNamespace(
user_id=100200300,
username="user",
first_name="User",
last_name=None,
email="user@example.com",
)
asyncio.run(
service.notify_support_user_reply(
ticket,
message,
user,
{},
unread_count=3,
send_telegram=False,
send_email=True,
)
)
assert channels == []
assert emails[0]["ticket_id"] == 7
+102
View File
@@ -0,0 +1,102 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from bot.services.support_service import (
SupportService,
TicketForbidden,
_support_admin_notification_decision,
)
def test_support_traffic_snapshot_calculates_percent_and_left_bytes():
snapshot = SupportService._traffic_snapshot(25, 100)
assert snapshot["percent"] == 25
assert snapshot["left_bytes"] == 75
def test_ticket_forbidden_error_code_is_stable():
exc = TicketForbidden("ticket_forbidden")
assert str(exc) == "ticket_forbidden"
def test_regular_limit_treats_unlimited_override_as_zero_limit():
sub = SimpleNamespace(regular_unlimited_override=True, traffic_limit_bytes=100)
assert SupportService._regular_limit(sub) == 0
def test_support_admin_notification_decision_sends_first_unread():
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
ticket = SimpleNamespace(
unread_admin_count=1,
admin_last_notified_at=now,
admin_last_emailed_at=now,
)
settings = SimpleNamespace(
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
)
decision = _support_admin_notification_decision(ticket, settings, now=now)
assert decision.send_telegram is True
assert decision.send_email is True
def test_support_admin_notification_decision_defaults_email_disabled():
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
ticket = SimpleNamespace(
unread_admin_count=1,
admin_last_notified_at=None,
admin_last_emailed_at=None,
)
settings = SimpleNamespace(
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
)
decision = _support_admin_notification_decision(ticket, settings, now=now)
assert decision.send_telegram is True
assert decision.send_email is False
def test_support_admin_notification_decision_suppresses_fast_followups():
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
ticket = SimpleNamespace(
unread_admin_count=4,
admin_last_notified_at=now - timedelta(seconds=60),
admin_last_emailed_at=now - timedelta(seconds=60),
)
settings = SimpleNamespace(
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
)
decision = _support_admin_notification_decision(ticket, settings, now=now)
assert decision.send_telegram is False
assert decision.send_email is False
def test_support_admin_notification_decision_uses_separate_email_cooldown():
now = datetime(2026, 5, 20, tzinfo=timezone.utc)
ticket = SimpleNamespace(
unread_admin_count=4,
admin_last_notified_at=now - timedelta(seconds=301),
admin_last_emailed_at=now - timedelta(seconds=301),
)
settings = SimpleNamespace(
SUPPORT_ADMIN_NOTIFICATION_COOLDOWN_SECONDS=300,
SUPPORT_ADMIN_EMAIL_COOLDOWN_SECONDS=1800,
SUPPORT_ADMIN_EMAIL_NOTIFICATIONS_ENABLED=True,
)
decision = _support_admin_notification_decision(ticket, settings, now=now)
assert decision.send_telegram is True
assert decision.send_email is False