4613 lines
187 KiB
Python
4613 lines
187 KiB
Python
from __future__ import annotations
|
||
|
||
import html
|
||
import asyncio
|
||
import secrets
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from urllib.parse import quote_plus
|
||
|
||
from aiogram import Bot, F, Router
|
||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||
from aiogram.filters import Command, CommandObject, CommandStart
|
||
from aiogram.fsm.context import FSMContext
|
||
from aiogram.fsm.state import State, StatesGroup
|
||
from aiogram.types import CallbackQuery, FSInputFile, InlineKeyboardButton, InputMediaPhoto, Message
|
||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||
|
||
from app.bot.ui.panel import (
|
||
PANEL_SECTION_ADMIN,
|
||
PANEL_SECTION_HOME,
|
||
PANEL_SECTION_REFERRAL,
|
||
PANEL_SECTION_SUBSCRIPTION,
|
||
PANEL_SECTION_SUPPORT,
|
||
PANEL_SECTION_SUPPORT_TICKET,
|
||
PANEL_SECTION_TERMS,
|
||
PanelContext,
|
||
build_panel_keyboard,
|
||
render_panel_caption,
|
||
)
|
||
from app.config import Settings
|
||
from app.services.bot_config_service import BotConfigService, BotConfigSnapshot, StaticBotConfigService
|
||
from app.services.payment_service import (
|
||
FULFILLED_PAYMENT_STATUS,
|
||
PENDING_PAYMENT_STATUS,
|
||
REJECTED_PAYMENT_STATUS,
|
||
REVIEW_PAYMENT_STATUS,
|
||
CreatedPaymentOrder,
|
||
PaymentPlan,
|
||
PaymentService,
|
||
StoredPaymentOrder,
|
||
)
|
||
from app.services.remnawave_client import RemnawaveApiError
|
||
from app.services.support_ticket_service import (
|
||
AdminSupportTicketsPage,
|
||
ANSWERED_SUPPORT_TICKET_STATUS,
|
||
CLOSED_SUPPORT_TICKET_STATUS,
|
||
OPEN_SUPPORT_TICKET_STATUS,
|
||
SupportTicketService,
|
||
)
|
||
from app.services.sync_service import (
|
||
AdminTelegramUserProfile,
|
||
AdminTelegramUsersPage,
|
||
CachedUserView,
|
||
GuideView,
|
||
PromoCodeView,
|
||
SyncService,
|
||
)
|
||
from app.utils.formatters import (
|
||
build_help_text,
|
||
format_bytes,
|
||
format_datetime,
|
||
format_datetime_with_days_left,
|
||
format_issued_access,
|
||
format_traffic_limit,
|
||
format_user_card,
|
||
)
|
||
|
||
|
||
class SupportTicketStates(StatesGroup):
|
||
waiting_for_message = State()
|
||
waiting_for_note = State()
|
||
|
||
|
||
class SupportTicketAdminStates(StatesGroup):
|
||
waiting_for_note = State()
|
||
|
||
|
||
class ReferralCodeStates(StatesGroup):
|
||
waiting_for_code = State()
|
||
|
||
|
||
class ManualPaymentStates(StatesGroup):
|
||
waiting_for_receipt = State()
|
||
|
||
|
||
class AdminLookupStates(StatesGroup):
|
||
waiting_for_identifier = State()
|
||
|
||
|
||
class AdminBroadcastStates(StatesGroup):
|
||
waiting_for_text = State()
|
||
|
||
|
||
class AdminConfigStates(StatesGroup):
|
||
waiting_for_value = State()
|
||
|
||
|
||
class AdminPromoStates(StatesGroup):
|
||
waiting_for_code = State()
|
||
waiting_for_plan = State()
|
||
waiting_for_discount = State()
|
||
waiting_for_expires = State()
|
||
|
||
|
||
class AdminGuideStates(StatesGroup):
|
||
waiting_for_title = State()
|
||
waiting_for_description = State()
|
||
waiting_for_body = State()
|
||
waiting_for_photos = State()
|
||
|
||
|
||
def build_router(
|
||
*,
|
||
settings: Settings,
|
||
sync_service: SyncService,
|
||
support_ticket_service: SupportTicketService,
|
||
payment_service: PaymentService,
|
||
bot_config_service: StaticBotConfigService,
|
||
) -> Router:
|
||
router = Router(name="main")
|
||
project_root = Path(__file__).resolve().parents[3]
|
||
default_start_image_path = project_root / "assets" / "main.png"
|
||
|
||
async def get_runtime_config() -> BotConfigSnapshot:
|
||
return await bot_config_service.get_snapshot()
|
||
|
||
def resolve_start_image_path(raw_path: str) -> Path | None:
|
||
if not raw_path.strip():
|
||
return default_start_image_path if default_start_image_path.is_file() else None
|
||
|
||
image_path = Path(raw_path).expanduser()
|
||
if not image_path.is_absolute():
|
||
image_path = project_root / image_path
|
||
|
||
return image_path if image_path.is_file() else None
|
||
|
||
def get_start_image_path(config: BotConfigSnapshot) -> Path | None:
|
||
if not config.bot_start_image_enabled:
|
||
return None
|
||
|
||
return resolve_start_image_path(config.bot_start_image_path)
|
||
|
||
def payment_callback(action: str, value: str | None = None) -> str:
|
||
return f"payment:{action}:{value}" if value else f"payment:{action}"
|
||
|
||
def guides_callback(action: str, value: str | None = None) -> str:
|
||
return f"guides:{action}:{value}" if value else f"guides:{action}"
|
||
|
||
async def send_branded(
|
||
bot_instance: Bot,
|
||
*,
|
||
chat_id: int,
|
||
text: str,
|
||
reply_markup=None,
|
||
) -> None:
|
||
"""Send a message with brand image if enabled and caption fits."""
|
||
config = await get_runtime_config()
|
||
start_image_path = get_start_image_path(config)
|
||
if start_image_path is not None and len(text) <= 1024:
|
||
await bot_instance.send_photo(
|
||
chat_id=chat_id,
|
||
photo=FSInputFile(str(start_image_path)),
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
await bot_instance.send_message(
|
||
chat_id=chat_id,
|
||
text=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
|
||
async def answer_branded(
|
||
message: Message,
|
||
text: str,
|
||
reply_markup=None,
|
||
) -> None:
|
||
"""Answer message with brand image if enabled and caption fits."""
|
||
config = await get_runtime_config()
|
||
start_image_path = get_start_image_path(config)
|
||
if start_image_path is not None and len(text) <= 1024:
|
||
await message.answer_photo(
|
||
FSInputFile(str(start_image_path)),
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
await message.answer(text, reply_markup=reply_markup)
|
||
|
||
async def show_branded_view(
|
||
callback: CallbackQuery,
|
||
*,
|
||
text: str,
|
||
reply_markup,
|
||
) -> None:
|
||
"""Show a branded text view — new message with image if possible."""
|
||
if callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
start_image_path = get_start_image_path(config)
|
||
if start_image_path is not None and len(text) <= 1024:
|
||
await callback.message.answer_photo(
|
||
FSInputFile(str(start_image_path)),
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
try:
|
||
if callback.message.photo or callback.message.document:
|
||
await callback.message.answer(text, reply_markup=reply_markup)
|
||
else:
|
||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||
except TelegramBadRequest as exc:
|
||
if "message is not modified" not in str(exc).lower():
|
||
raise
|
||
|
||
async def ensure_sender(message: Message) -> bool:
|
||
if message.from_user is None:
|
||
return False
|
||
if await sync_service.is_user_blocked(message.from_user.id):
|
||
await message.answer(
|
||
"\u26d4 Ваш аккаунт заблокирован. Обратитесь в поддержку."
|
||
)
|
||
return False
|
||
return True
|
||
|
||
async def clear_interactive_states(state: FSMContext) -> None:
|
||
if await state.get_state() in {
|
||
SupportTicketStates.waiting_for_message.state,
|
||
SupportTicketStates.waiting_for_note.state,
|
||
SupportTicketAdminStates.waiting_for_note.state,
|
||
ReferralCodeStates.waiting_for_code.state,
|
||
ManualPaymentStates.waiting_for_receipt.state,
|
||
AdminLookupStates.waiting_for_identifier.state,
|
||
AdminBroadcastStates.waiting_for_text.state,
|
||
AdminConfigStates.waiting_for_value.state,
|
||
AdminPromoStates.waiting_for_code.state,
|
||
AdminPromoStates.waiting_for_plan.state,
|
||
AdminPromoStates.waiting_for_discount.state,
|
||
AdminPromoStates.waiting_for_expires.state,
|
||
AdminGuideStates.waiting_for_title.state,
|
||
AdminGuideStates.waiting_for_description.state,
|
||
AdminGuideStates.waiting_for_body.state,
|
||
AdminGuideStates.waiting_for_photos.state,
|
||
}:
|
||
await state.clear()
|
||
|
||
async def load_panel_context(
|
||
*,
|
||
telegram_id: int,
|
||
username: str | None,
|
||
first_name: str | None,
|
||
last_name: str | None,
|
||
language_code: str | None,
|
||
sync_remote: bool,
|
||
) -> PanelContext:
|
||
users: list[CachedUserView] = []
|
||
api_error: str | None = None
|
||
is_admin = settings.is_admin(telegram_id)
|
||
config = await get_runtime_config()
|
||
|
||
if sync_remote:
|
||
try:
|
||
await payment_service.apply_pending_referral_bonuses(
|
||
telegram_id=telegram_id,
|
||
refresh_cache=False,
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
try:
|
||
users = await sync_service.sync_users_for_telegram(
|
||
telegram_id=telegram_id,
|
||
username=username,
|
||
first_name=first_name,
|
||
last_name=last_name,
|
||
language_code=language_code,
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
api_error = exc.message
|
||
users = await sync_service.get_cached_users_for_telegram(telegram_id)
|
||
else:
|
||
users = await sync_service.get_cached_users_for_telegram(telegram_id)
|
||
|
||
display_name = " ".join(part for part in [first_name, last_name] if part).strip()
|
||
if not display_name:
|
||
display_name = username or "Пользователь"
|
||
|
||
referral_summary = await sync_service.get_referral_summary(telegram_id)
|
||
active_discount = await payment_service.get_active_discount_for_user(
|
||
telegram_id=telegram_id,
|
||
)
|
||
referral_link = ""
|
||
referral_share_url = ""
|
||
if config.referral_enabled and config.bot_public_username_normalized:
|
||
referral_link = (
|
||
f"https://t.me/{config.bot_public_username_normalized}"
|
||
f"?start=ref_{referral_summary.referral_code}"
|
||
)
|
||
share_text = (
|
||
"Вас пригласили по реферальной ссылке. "
|
||
f"Перед оплатой введите код {referral_summary.referral_code} "
|
||
f"и получите скидку {max(config.referral_discount_percent, 0)}%."
|
||
)
|
||
referral_share_url = (
|
||
"https://t.me/share/url"
|
||
f"?url={quote_plus(referral_link)}"
|
||
f"&text={quote_plus(share_text)}"
|
||
)
|
||
|
||
plans = await payment_service.get_available_plans(telegram_id=telegram_id)
|
||
primary_plan = next((plan for plan in plans if plan.is_ready), plans[0] if plans else None)
|
||
has_ready_payment_plan = any(plan.is_ready for plan in plans)
|
||
admin_total_telegram_users = 0
|
||
admin_total_cached_users = 0
|
||
admin_active_cached_users = 0
|
||
admin_pending_orders = 0
|
||
admin_review_orders = 0
|
||
admin_open_tickets = 0
|
||
admin_pending_referral_bonuses = 0
|
||
|
||
if is_admin:
|
||
admin_stats = await sync_service.get_admin_stats()
|
||
payment_admin_stats = await payment_service.get_admin_stats()
|
||
admin_total_telegram_users = admin_stats.total_telegram_users
|
||
admin_total_cached_users = admin_stats.total_cached_users
|
||
admin_active_cached_users = admin_stats.active_cached_users
|
||
admin_pending_orders = payment_admin_stats.pending_orders
|
||
admin_review_orders = payment_admin_stats.review_orders
|
||
admin_pending_referral_bonuses = payment_admin_stats.pending_referral_bonuses
|
||
admin_open_tickets = await support_ticket_service.get_open_tickets_count()
|
||
|
||
return PanelContext(
|
||
telegram_id=telegram_id,
|
||
display_name=display_name,
|
||
username=username,
|
||
language_code=language_code,
|
||
is_admin=is_admin,
|
||
users=users,
|
||
referral_enabled=config.referral_enabled,
|
||
referral_code=referral_summary.referral_code,
|
||
referral_count=referral_summary.total_invited,
|
||
applied_referral_code=(
|
||
active_discount.code if config.referral_enabled else ""
|
||
),
|
||
referral_link=referral_link,
|
||
referral_share_url=referral_share_url,
|
||
referral_recent_names=referral_summary.recent_names,
|
||
referral_discount_percent=max(
|
||
active_discount.discount_percent or config.referral_discount_percent,
|
||
0,
|
||
),
|
||
referral_bonus_days=max(config.referral_bonus_days, 0),
|
||
admin_total_telegram_users=admin_total_telegram_users,
|
||
admin_total_cached_users=admin_total_cached_users,
|
||
admin_active_cached_users=admin_active_cached_users,
|
||
admin_pending_orders=admin_pending_orders,
|
||
admin_review_orders=admin_review_orders,
|
||
admin_open_tickets=admin_open_tickets,
|
||
admin_pending_referral_bonuses=admin_pending_referral_bonuses,
|
||
payment_enabled=has_ready_payment_plan,
|
||
payment_plan_title=primary_plan.title if primary_plan else "",
|
||
payment_plan_description=primary_plan.description if primary_plan else "",
|
||
payment_plan_price_stars=primary_plan.amount_rub if primary_plan else 0,
|
||
payment_plan_duration_days=primary_plan.duration_days if primary_plan else 0,
|
||
payment_plan_traffic_limit_bytes=primary_plan.traffic_limit_bytes if primary_plan else 0,
|
||
api_error=api_error,
|
||
)
|
||
|
||
def parse_start_referral_code(message: Message) -> str | None:
|
||
text = (message.text or "").strip()
|
||
parts = text.split(maxsplit=1)
|
||
if len(parts) < 2:
|
||
return None
|
||
|
||
arg = parts[1].strip()
|
||
if not arg.startswith("ref_"):
|
||
return None
|
||
|
||
raw_value = arg.removeprefix("ref_").strip().upper()
|
||
return raw_value if raw_value else None
|
||
|
||
async def show_panel(
|
||
message: Message,
|
||
*,
|
||
context: PanelContext,
|
||
section: str,
|
||
) -> None:
|
||
config = await get_runtime_config()
|
||
caption = render_panel_caption(
|
||
section=section,
|
||
context=context,
|
||
brand_name=config.bot_brand_name,
|
||
)
|
||
keyboard = build_panel_keyboard(
|
||
section=section,
|
||
context=context,
|
||
support_url=config.bot_support_url,
|
||
terms_url=config.bot_terms_url,
|
||
payment_review_url=config.payment_review_link,
|
||
support_ticket_url=config.bot_support_ticket_link,
|
||
)
|
||
|
||
start_image_path = get_start_image_path(config)
|
||
if start_image_path is not None:
|
||
await message.answer_photo(
|
||
FSInputFile(str(start_image_path)),
|
||
caption=caption,
|
||
reply_markup=keyboard,
|
||
)
|
||
return
|
||
|
||
await message.answer(caption, reply_markup=keyboard)
|
||
|
||
async def edit_message_content(
|
||
callback: CallbackQuery,
|
||
*,
|
||
text: str,
|
||
reply_markup,
|
||
) -> None:
|
||
if callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
try:
|
||
if callback.message.photo or callback.message.document:
|
||
await callback.message.edit_caption(caption=text, reply_markup=reply_markup)
|
||
else:
|
||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||
except TelegramBadRequest as exc:
|
||
if "message is not modified" not in str(exc).lower():
|
||
raise
|
||
|
||
async def show_text_view(
|
||
callback: CallbackQuery,
|
||
*,
|
||
text: str,
|
||
reply_markup,
|
||
) -> None:
|
||
if callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
try:
|
||
if callback.message.photo or callback.message.document:
|
||
await callback.message.answer(text, reply_markup=reply_markup)
|
||
else:
|
||
await callback.message.edit_text(text, reply_markup=reply_markup)
|
||
except TelegramBadRequest as exc:
|
||
if "message is not modified" not in str(exc).lower():
|
||
raise
|
||
|
||
async def update_panel(
|
||
callback: CallbackQuery,
|
||
*,
|
||
context: PanelContext,
|
||
section: str,
|
||
) -> None:
|
||
config = await get_runtime_config()
|
||
caption = render_panel_caption(
|
||
section=section,
|
||
context=context,
|
||
brand_name=config.bot_brand_name,
|
||
)
|
||
keyboard = build_panel_keyboard(
|
||
section=section,
|
||
context=context,
|
||
support_url=config.bot_support_url,
|
||
terms_url=config.bot_terms_url,
|
||
payment_review_url=config.payment_review_link,
|
||
support_ticket_url=config.bot_support_ticket_link,
|
||
)
|
||
await edit_message_content(callback, text=caption, reply_markup=keyboard)
|
||
await callback.answer()
|
||
|
||
async def send_lookup_result(message: Message, *, identifier: str) -> bool:
|
||
try:
|
||
user = await sync_service.sync_user_by_identifier(
|
||
identifier=identifier,
|
||
include_history=True,
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
await message.answer(
|
||
"Пользователь не найден или API вернул ошибку.\n"
|
||
f"Ошибка: <code>{html.escape(exc.message)}</code>"
|
||
)
|
||
return False
|
||
|
||
await message.answer(
|
||
format_user_card(
|
||
user=user.record,
|
||
squad_names=user.internal_squads,
|
||
recent_requests=user.recent_requests,
|
||
)
|
||
)
|
||
return True
|
||
|
||
async def run_sync_all(message: Message) -> bool:
|
||
await message.answer("Запустил полную синхронизацию Remnawave -> MariaDB.")
|
||
|
||
try:
|
||
total = await sync_service.sync_all_users(settings.sync_batch_size)
|
||
except RemnawaveApiError as exc:
|
||
await message.answer(
|
||
"Полная синхронизация завершилась с ошибкой.\n"
|
||
f"Ошибка: <code>{html.escape(exc.message)}</code>"
|
||
)
|
||
return False
|
||
|
||
await message.answer(
|
||
f"Полная синхронизация завершена. Обновлено пользователей: <b>{total}</b>"
|
||
)
|
||
return True
|
||
|
||
def build_admin_broadcast_preview_keyboard():
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="✅ Отправить всем",
|
||
callback_data="admin:broadcast_confirm",
|
||
)
|
||
builder.button(
|
||
text="❌ Отменить",
|
||
callback_data="admin:broadcast_cancel",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def get_message_html_text(message: Message) -> tuple[str, str]:
|
||
raw_text = (message.text or message.caption or "").strip()
|
||
formatted_text = (
|
||
(getattr(message, "html_text", None) or getattr(message, "html_caption", None) or "").strip()
|
||
)
|
||
if not formatted_text and raw_text:
|
||
formatted_text = html.escape(raw_text)
|
||
return raw_text, formatted_text
|
||
|
||
async def show_admin_broadcast_preview(message: Message, *, text_html: str, state: FSMContext) -> None:
|
||
recipient_count = len(await sync_service.get_broadcast_telegram_ids())
|
||
await state.update_data(broadcast_text_html=text_html)
|
||
await message.answer(
|
||
"📣 Предпросмотр рассылки.\n\n"
|
||
f"Получателей: <b>{recipient_count}</b>\n"
|
||
"Если всё верно, нажмите кнопку подтверждения ниже."
|
||
)
|
||
await message.answer(
|
||
text_html,
|
||
reply_markup=build_admin_broadcast_preview_keyboard(),
|
||
)
|
||
|
||
async def send_admin_broadcast(bot_instance: Bot, *, text_html: str) -> tuple[int, int, int]:
|
||
telegram_ids = await sync_service.get_broadcast_telegram_ids()
|
||
sent_count = 0
|
||
blocked_count = 0
|
||
failed_count = 0
|
||
|
||
for telegram_id in telegram_ids:
|
||
try:
|
||
await bot_instance.send_message(chat_id=telegram_id, text=text_html)
|
||
sent_count += 1
|
||
except TelegramRetryAfter as exc:
|
||
await asyncio.sleep(exc.retry_after + 1)
|
||
try:
|
||
await bot_instance.send_message(chat_id=telegram_id, text=text_html)
|
||
sent_count += 1
|
||
except TelegramForbiddenError:
|
||
blocked_count += 1
|
||
except TelegramBadRequest:
|
||
failed_count += 1
|
||
except Exception:
|
||
failed_count += 1
|
||
except TelegramForbiddenError:
|
||
blocked_count += 1
|
||
except TelegramBadRequest:
|
||
failed_count += 1
|
||
except Exception:
|
||
failed_count += 1
|
||
|
||
await asyncio.sleep(0.035)
|
||
|
||
return sent_count, blocked_count, failed_count
|
||
|
||
def admin_settings_callback(action: str, value: str | None = None) -> str:
|
||
return f"admin:{action}:{value}" if value else f"admin:{action}"
|
||
|
||
def admin_users_page_callback(page: int) -> str:
|
||
return admin_settings_callback("user_list", str(max(page, 1)))
|
||
|
||
def admin_user_profile_callback(*, telegram_id: int, page: int) -> str:
|
||
return f"admin:user_view:{telegram_id}:{max(page, 1)}"
|
||
|
||
def admin_tickets_page_callback(page: int) -> str:
|
||
return admin_settings_callback("ticket_list", str(max(page, 1)))
|
||
|
||
def admin_ticket_view_callback(*, ticket_id: int, page: int) -> str:
|
||
return f"admin:ticket_view:{ticket_id}:{max(page, 1)}"
|
||
|
||
def admin_promo_plan_callback(plan_code: str) -> str:
|
||
return f"admin:promo_plan:{plan_code}"
|
||
|
||
def compact_inline_label(value: str, *, limit: int = 30) -> str:
|
||
normalized = " ".join(value.split())
|
||
if len(normalized) <= limit:
|
||
return normalized
|
||
return f"{normalized[: max(0, limit - 3)].rstrip()}..."
|
||
|
||
def get_payment_plan_label_map(config: BotConfigSnapshot) -> dict[str, str]:
|
||
return {
|
||
plan.code: f"{plan.title} · {plan.days} дней" if plan.title else f"{plan.days} дней"
|
||
for plan in config.payment_plans
|
||
}
|
||
|
||
def format_promo_scope(*, plan_code: str | None, config: BotConfigSnapshot) -> str:
|
||
if not plan_code:
|
||
return "все тарифы"
|
||
# "all_30" → "Все тарифы · 30 дней"
|
||
if plan_code.startswith("all_") and plan_code.removeprefix("all_").isdigit():
|
||
days = plan_code.removeprefix("all_")
|
||
return f"Все тарифы · {days} дней"
|
||
label_map = get_payment_plan_label_map(config)
|
||
if plan_code in label_map:
|
||
return label_map[plan_code]
|
||
# Try product-level lookup (e.g. plan_code="vpn_white" → title from spec)
|
||
from app.config import PAYMENT_PRODUCT_PLAN_SPECS
|
||
spec = PAYMENT_PRODUCT_PLAN_SPECS.get(plan_code)
|
||
if spec is not None:
|
||
return spec[0] # title
|
||
return plan_code
|
||
|
||
def format_promo_expires_at(expires_at) -> str:
|
||
return format_datetime(expires_at) if expires_at is not None else "без срока"
|
||
|
||
def parse_promo_expires_at(raw_value: str):
|
||
value = (raw_value or "").strip().lower()
|
||
if value in {"0", "-", "none", "no", "без срока", "бессрочно"}:
|
||
return None
|
||
|
||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||
compact_value = "".join(value.split())
|
||
suffix_map = {
|
||
"h": "hours",
|
||
"ч": "hours",
|
||
"hour": "hours",
|
||
"hours": "hours",
|
||
"d": "days",
|
||
"д": "days",
|
||
"day": "days",
|
||
"days": "days",
|
||
}
|
||
for suffix, unit in suffix_map.items():
|
||
if not compact_value.endswith(suffix):
|
||
continue
|
||
raw_number = compact_value[: -len(suffix)]
|
||
if raw_number.isdigit() and int(raw_number) > 0:
|
||
return now + timedelta(**{unit: int(raw_number)})
|
||
|
||
if compact_value.isdigit() and int(compact_value) > 0:
|
||
return now + timedelta(days=int(compact_value))
|
||
|
||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%d.%m.%Y %H:%M", "%d.%m.%Y"):
|
||
try:
|
||
parsed = datetime.strptime(raw_value.strip(), fmt)
|
||
except ValueError:
|
||
continue
|
||
if "%H" not in fmt:
|
||
parsed = parsed.replace(hour=23, minute=59, second=59)
|
||
return parsed
|
||
|
||
raise ValueError(
|
||
"Введите срок в днях, например 7, или дату 2026-05-31. Для бессрочного промокода отправьте 0."
|
||
)
|
||
|
||
def render_admin_promos_text(
|
||
*,
|
||
promos: list[PromoCodeView],
|
||
config: BotConfigSnapshot,
|
||
) -> str:
|
||
lines = [
|
||
"<b>🎟 Промокоды</b>",
|
||
"",
|
||
"<blockquote>Промокод вводится пользователем в разделе реферальной системы и даёт скидку перед оплатой.</blockquote>",
|
||
]
|
||
if not promos:
|
||
lines.extend(["", "<i>Промокоды ещё не созданы.</i>"])
|
||
return "\n".join(lines)
|
||
|
||
lines.extend(["", "<b>Последние промокоды</b>"])
|
||
for promo in promos[:10]:
|
||
status_text = "активен" if promo.is_valid else "неактивен"
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"<code>{html.escape(promo.code)}</code> · <b>-{promo.discount_percent}%</b>\n"
|
||
f"Тариф: <b>{html.escape(format_promo_scope(plan_code=promo.plan_code, config=config))}</b>\n"
|
||
f"Срок: <b>{html.escape(format_promo_expires_at(promo.expires_at))}</b>\n"
|
||
f"Статус: <b>{status_text}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_promos_keyboard():
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="➕ Создать промокод",
|
||
callback_data=admin_settings_callback("promo_create"),
|
||
)
|
||
builder.button(
|
||
text="↻ Обновить",
|
||
callback_data=admin_settings_callback("promos"),
|
||
)
|
||
builder.button(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_admin_promo_plan_keyboard(*, config: BotConfigSnapshot):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="Все тарифы",
|
||
callback_data=admin_promo_plan_callback("all"),
|
||
)
|
||
# Show unique product types, not individual durations
|
||
seen_products: set[str] = set()
|
||
for plan in config.payment_plans:
|
||
product_code = _extract_product_code(plan.code)
|
||
if product_code in seen_products:
|
||
continue
|
||
seen_products.add(product_code)
|
||
builder.button(
|
||
text=plan.title or product_code,
|
||
callback_data=admin_promo_plan_callback(product_code),
|
||
)
|
||
builder.button(
|
||
text="⬅️ К промокодам",
|
||
callback_data=admin_settings_callback("promos"),
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_admin_promo_duration_keyboard(
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
product_code: str,
|
||
):
|
||
"""Build keyboard with duration options for a specific product (or 'all')."""
|
||
builder = InlineKeyboardBuilder()
|
||
if product_code == "all":
|
||
# Show unique durations across all products
|
||
seen_days: set[int] = set()
|
||
for plan in config.payment_plans:
|
||
if plan.days not in seen_days:
|
||
seen_days.add(plan.days)
|
||
builder.button(
|
||
text=f"{plan.days} дней",
|
||
callback_data=f"admin:promo_duration:all_{plan.days}",
|
||
)
|
||
else:
|
||
for plan in config.payment_plans:
|
||
if _extract_product_code(plan.code) == product_code:
|
||
builder.button(
|
||
text=f"{plan.days} дней · {plan.amount_rub} ₽",
|
||
callback_data=f"admin:promo_duration:{plan.code}",
|
||
)
|
||
builder.button(
|
||
text="⬅️ Назад к тарифам",
|
||
callback_data="admin:promo_back_to_plans",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def render_guides_menu_text(*, guides: list[GuideView]) -> str:
|
||
lines = [
|
||
"<b>📚 Гайды</b>",
|
||
"",
|
||
]
|
||
if not guides:
|
||
lines.append("<blockquote>Гайды пока не добавлены.</blockquote>")
|
||
return "\n".join(lines)
|
||
|
||
lines.append("<blockquote>Выберите инструкцию ниже.</blockquote>")
|
||
for index, guide in enumerate(guides[:10], start=1):
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"<b>{index}. {html.escape(guide.title)}</b>",
|
||
html.escape(guide.short_description or "Без описания"),
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def build_guides_menu_keyboard(*, guides: list[GuideView]):
|
||
builder = InlineKeyboardBuilder()
|
||
for guide in guides[:10]:
|
||
builder.button(
|
||
text=guide.title,
|
||
callback_data=guides_callback("view", str(guide.id)),
|
||
)
|
||
builder.button(
|
||
text="🏠 Главное меню",
|
||
callback_data=f"panel:{PANEL_SECTION_HOME}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def render_guide_text(*, guide: GuideView) -> str:
|
||
lines = [
|
||
f"<b>{html.escape(guide.title)}</b>",
|
||
]
|
||
if guide.short_description:
|
||
lines.extend(["", html.escape(guide.short_description)])
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"<blockquote>{html.escape(guide.body_text)}</blockquote>",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def build_guide_view_keyboard():
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(text="⬅️ К гайдам", callback_data=guides_callback("menu"))
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
async def user_can_open_guides(telegram_id: int) -> bool:
|
||
if settings.is_admin(telegram_id):
|
||
return True
|
||
accesses = await sync_service.get_cached_users_for_telegram(telegram_id)
|
||
return any(access.record.status.upper() == "ACTIVE" for access in accesses)
|
||
|
||
async def send_guide_view(bot: Bot, *, chat_id: int, guide: GuideView) -> None:
|
||
text = render_guide_text(guide=guide)
|
||
reply_markup = build_guide_view_keyboard()
|
||
photo_file_ids = guide.photo_file_ids[:10]
|
||
|
||
if not photo_file_ids:
|
||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||
return
|
||
|
||
if len(photo_file_ids) == 1:
|
||
if len(text) <= 1024:
|
||
await bot.send_photo(
|
||
chat_id=chat_id,
|
||
photo=photo_file_ids[0],
|
||
caption=text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
await bot.send_photo(chat_id=chat_id, photo=photo_file_ids[0])
|
||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||
return
|
||
|
||
media = [InputMediaPhoto(media=file_id) for file_id in photo_file_ids]
|
||
if len(text) <= 1024:
|
||
media[0].caption = text
|
||
await bot.send_media_group(chat_id=chat_id, media=media)
|
||
await bot.send_message(
|
||
chat_id=chat_id,
|
||
text="Навигация",
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
await bot.send_media_group(chat_id=chat_id, media=media)
|
||
await bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
|
||
|
||
def render_admin_guides_text(*, guides: list[GuideView]) -> str:
|
||
lines = [
|
||
"<b>📚 Гайды</b>",
|
||
"",
|
||
"<blockquote>Здесь создаются инструкции, которые увидят пользователи с активной подпиской.</blockquote>",
|
||
]
|
||
if not guides:
|
||
lines.extend(["", "<i>Гайды ещё не созданы.</i>"])
|
||
return "\n".join(lines)
|
||
|
||
lines.extend(["", "<b>Последние гайды</b>"])
|
||
for guide in guides[:10]:
|
||
photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото"
|
||
status_text = "активен" if guide.is_active else "скрыт"
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"<b>{html.escape(guide.title)}</b>\n"
|
||
f"{html.escape(guide.short_description or 'Без описания')}\n"
|
||
f"Медиа: <b>{photo_text}</b>\n"
|
||
f"Статус: <b>{status_text}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def render_admin_guide_delete_confirm_text(*, guide: GuideView) -> str:
|
||
photo_text = f"{len(guide.photo_file_ids)} фото" if guide.photo_file_ids else "без фото"
|
||
return "\n".join(
|
||
[
|
||
"<b>🗑 Удалить гайд?</b>",
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"<b>{html.escape(guide.title)}</b>\n"
|
||
f"{html.escape(guide.short_description or 'Без описания')}\n"
|
||
f"Медиа: <b>{photo_text}</b>"
|
||
"</blockquote>"
|
||
),
|
||
"",
|
||
"<i>После удаления гайд пропадёт у пользователей, а его фото-ссылки будут удалены из базы.</i>",
|
||
]
|
||
)
|
||
|
||
def build_admin_guides_keyboard(*, guides: list[GuideView]):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="➕ Создать гайд",
|
||
callback_data=admin_settings_callback("guide_create"),
|
||
)
|
||
for guide in guides[:10]:
|
||
builder.button(
|
||
text=f"🗑 {compact_inline_label(guide.title, limit=24)}",
|
||
callback_data=admin_settings_callback("guide_delete", str(guide.id)),
|
||
)
|
||
builder.button(
|
||
text="↻ Обновить",
|
||
callback_data=admin_settings_callback("guides"),
|
||
)
|
||
builder.button(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_admin_guide_delete_confirm_keyboard(*, guide_id: int):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="🗑 Да, удалить",
|
||
callback_data=admin_settings_callback("guide_delete_confirm", str(guide_id)),
|
||
)
|
||
builder.button(
|
||
text="⬅️ К гайдам",
|
||
callback_data=admin_settings_callback("guides"),
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_admin_guide_photos_keyboard(*, photo_count: int):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="✅ Сохранить гайд",
|
||
callback_data=admin_settings_callback("guide_done"),
|
||
)
|
||
builder.button(
|
||
text=f"Фото добавлено: {photo_count}",
|
||
callback_data=admin_settings_callback("guide_photo_count"),
|
||
)
|
||
builder.button(
|
||
text="❌ Отмена",
|
||
callback_data=admin_settings_callback("guides"),
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_settings_home_text(*, config: BotConfigSnapshot) -> str:
|
||
lines = [
|
||
"<b>⚙️ Настройки бота</b>",
|
||
"",
|
||
"<blockquote>Здесь меняются бизнес-настройки бота без правки .env и перезапуска.</blockquote>",
|
||
"",
|
||
"<b>Разделы</b>",
|
||
]
|
||
for section, label in BotConfigService.get_sections():
|
||
spec_count = len(BotConfigService.get_specs_for_section(section))
|
||
lines.append(f"• {html.escape(label)}: <b>{spec_count}</b>")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<i>Секреты и инфраструктурные параметры по-прежнему остаются в .env.</i>",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_settings_home_keyboard():
|
||
builder = InlineKeyboardBuilder()
|
||
for section, label in BotConfigService.get_sections():
|
||
builder.button(
|
||
text=f"⚙️ {label}",
|
||
callback_data=admin_settings_callback("settings_section", section),
|
||
)
|
||
builder.button(
|
||
text="↻ Обновить",
|
||
callback_data=admin_settings_callback("settings"),
|
||
)
|
||
builder.button(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
)
|
||
builder.adjust(2, 2, 2, 1, 1)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_settings_section_text(
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
section: str,
|
||
) -> str:
|
||
section_label = BotConfigService.get_section_label(section)
|
||
specs = BotConfigService.get_specs_for_section(section)
|
||
lines = [
|
||
f"<b>⚙️ {html.escape(section_label)}</b>",
|
||
"",
|
||
]
|
||
|
||
for spec in specs:
|
||
current_value = BotConfigService.format_value(snapshot=config, key=spec.key)
|
||
lines.extend(
|
||
[
|
||
f"<b>{html.escape(spec.label)}</b>",
|
||
f"<code>{html.escape(current_value)}</code>",
|
||
html.escape(spec.description),
|
||
"",
|
||
]
|
||
)
|
||
|
||
lines.append("<i>Нажмите на параметр ниже, чтобы изменить его.</i>")
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_settings_section_keyboard(*, section: str):
|
||
builder = InlineKeyboardBuilder()
|
||
specs = BotConfigService.get_specs_for_section(section)
|
||
for spec in specs:
|
||
builder.button(
|
||
text=f"✏️ {spec.label}",
|
||
callback_data=admin_settings_callback("edit", spec.key),
|
||
)
|
||
builder.button(
|
||
text="↻ Обновить",
|
||
callback_data=admin_settings_callback("settings_section", section),
|
||
)
|
||
builder.button(
|
||
text="⬅️ Разделы",
|
||
callback_data=admin_settings_callback("settings"),
|
||
)
|
||
builder.button(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_setting_prompt(
|
||
*,
|
||
config: BotConfigSnapshot,
|
||
key: str,
|
||
) -> str:
|
||
spec = BotConfigService.get_spec(key)
|
||
if spec is None:
|
||
return "Неизвестная настройка."
|
||
|
||
current_value = BotConfigService.format_value(snapshot=config, key=key)
|
||
return "\n".join(
|
||
[
|
||
f"<b>{html.escape(spec.label)}</b>",
|
||
"",
|
||
html.escape(spec.prompt),
|
||
"",
|
||
f"Текущее значение: <code>{html.escape(current_value)}</code>",
|
||
"",
|
||
f"<i>{html.escape(spec.description)}</i>",
|
||
]
|
||
)
|
||
|
||
def render_admin_users_page_text(*, page_data: AdminTelegramUsersPage) -> str:
|
||
lines = [
|
||
"<b>👤 Пользователи</b>",
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"Всего пользователей: <b>{page_data.total_items}</b>\n"
|
||
f"Страница: <b>{page_data.page}/{page_data.total_pages}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
|
||
if not page_data.items:
|
||
lines.extend(["", "<i>Пользователи ещё не появлялись в боте.</i>"])
|
||
return "\n".join(lines)
|
||
|
||
for index, item in enumerate(
|
||
page_data.items,
|
||
start=((page_data.page - 1) * page_data.page_size) + 1,
|
||
):
|
||
username_text = f"@{item.username}" if item.username else "—"
|
||
badges: list[str] = []
|
||
if item.is_admin:
|
||
badges.append("админ")
|
||
if item.is_blocked:
|
||
badges.append("заблокирован")
|
||
if not badges:
|
||
badges.append("пользователь")
|
||
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
f"{index}. <b>{html.escape(item.display_name)}</b>\n"
|
||
"<blockquote>"
|
||
f"ID: <code>{item.telegram_id}</code>\n"
|
||
f"Username: <b>{html.escape(username_text)}</b>\n"
|
||
f"Статус: <b>{html.escape(', '.join(badges))}</b>\n"
|
||
f"Последняя активность: <b>{format_datetime(item.last_seen_at)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_users_page_keyboard(*, page_data: AdminTelegramUsersPage):
|
||
builder = InlineKeyboardBuilder()
|
||
|
||
for item in page_data.items:
|
||
button_label = item.display_name
|
||
if item.username:
|
||
button_label = f"{button_label} (@{item.username})"
|
||
else:
|
||
button_label = f"{button_label} • {item.telegram_id}"
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text=f"👤 {compact_inline_label(button_label)}",
|
||
callback_data=admin_user_profile_callback(
|
||
telegram_id=item.telegram_id,
|
||
page=page_data.page,
|
||
),
|
||
)
|
||
)
|
||
|
||
pagination_buttons: list[InlineKeyboardButton] = []
|
||
if page_data.page > 1:
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text="⬅️ Назад",
|
||
callback_data=admin_users_page_callback(page_data.page - 1),
|
||
)
|
||
)
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text=f"{page_data.page}/{page_data.total_pages}",
|
||
callback_data=admin_users_page_callback(page_data.page),
|
||
)
|
||
)
|
||
if page_data.page < page_data.total_pages:
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text="Вперёд ➡️",
|
||
callback_data=admin_users_page_callback(page_data.page + 1),
|
||
)
|
||
)
|
||
builder.row(*pagination_buttons)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="↻ Обновить",
|
||
callback_data=admin_users_page_callback(page_data.page),
|
||
),
|
||
InlineKeyboardButton(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
),
|
||
)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_tickets_page_text(*, page_data: AdminSupportTicketsPage) -> str:
|
||
lines = [
|
||
"<b>🎫 История тикетов</b>",
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"Всего тикетов: <b>{page_data.total_items}</b>\n"
|
||
f"Страница: <b>{page_data.page}/{page_data.total_pages}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
|
||
if not page_data.items:
|
||
lines.extend(["", "<i>Тикетов пока нет.</i>"])
|
||
return "\n".join(lines)
|
||
|
||
for index, item in enumerate(
|
||
page_data.items,
|
||
start=((page_data.page - 1) * page_data.page_size) + 1,
|
||
):
|
||
username_text = f"@{item.username}" if item.username else "—"
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
f"{index}. <b>#{html.escape(item.public_id)}</b> — "
|
||
f"{html.escape(item.display_name)}\n"
|
||
"<blockquote>"
|
||
f"Статус: <b>{format_support_ticket_status(item.status)}</b>\n"
|
||
f"Telegram ID: <code>{item.telegram_id}</code>\n"
|
||
f"Username: <b>{html.escape(username_text)}</b>\n"
|
||
f"Создан: <b>{format_datetime(item.created_at)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_tickets_page_keyboard(*, page_data: AdminSupportTicketsPage):
|
||
builder = InlineKeyboardBuilder()
|
||
|
||
for item in page_data.items:
|
||
username_suffix = f" (@{item.username})" if item.username else ""
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text=(
|
||
f"🎫 #{item.public_id} • "
|
||
f"{compact_inline_label(item.display_name + username_suffix, limit=20)}"
|
||
),
|
||
callback_data=admin_ticket_view_callback(
|
||
ticket_id=item.id,
|
||
page=page_data.page,
|
||
),
|
||
)
|
||
)
|
||
|
||
pagination_buttons: list[InlineKeyboardButton] = []
|
||
if page_data.page > 1:
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text="⬅️ Назад",
|
||
callback_data=admin_tickets_page_callback(page_data.page - 1),
|
||
)
|
||
)
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text=f"{page_data.page}/{page_data.total_pages}",
|
||
callback_data=admin_tickets_page_callback(page_data.page),
|
||
)
|
||
)
|
||
if page_data.page < page_data.total_pages:
|
||
pagination_buttons.append(
|
||
InlineKeyboardButton(
|
||
text="Вперёд ➡️",
|
||
callback_data=admin_tickets_page_callback(page_data.page + 1),
|
||
)
|
||
)
|
||
builder.row(*pagination_buttons)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="↻ Обновить",
|
||
callback_data=admin_tickets_page_callback(page_data.page),
|
||
),
|
||
InlineKeyboardButton(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
),
|
||
)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_ticket_history_text(*, ticket) -> str:
|
||
username_text = f"@{html.escape(ticket.username)}" if ticket.username else "не указан"
|
||
lines = [
|
||
f"<b>🎫 Тикет #{html.escape(ticket.public_id)}</b>",
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"Статус: <b>{format_support_ticket_status(ticket.status)}</b>\n"
|
||
f"Пользователь: <b>{html.escape(ticket.display_name)}</b>\n"
|
||
f"Telegram ID: <code>{ticket.telegram_id}</code>\n"
|
||
f"Username: <b>{username_text}</b>\n"
|
||
f"Создан: <b>{format_datetime(ticket.created_at)}</b>\n"
|
||
f"Обновлён: <b>{format_datetime(ticket.updated_at)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
|
||
if ticket.closed_at is not None:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
(
|
||
"<blockquote>"
|
||
f"Закрыт: <b>{format_datetime(ticket.closed_at)}</b>\n"
|
||
f"Закрыл Telegram ID: <code>{ticket.closed_by_telegram_id or '—'}</code>"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>Сообщение</b>",
|
||
f"<blockquote>{html.escape(ticket.user_message)}</blockquote>",
|
||
]
|
||
)
|
||
|
||
if ticket.support_note:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>История текста</b>",
|
||
f"<blockquote>{html.escape(ticket.support_note)}</blockquote>",
|
||
]
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_ticket_history_keyboard(*, ticket_id: int, page: int):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="↻ Обновить",
|
||
callback_data=admin_ticket_view_callback(
|
||
ticket_id=ticket_id,
|
||
page=page,
|
||
),
|
||
),
|
||
InlineKeyboardButton(
|
||
text="⬅️ К списку",
|
||
callback_data=admin_tickets_page_callback(page),
|
||
),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="🛠 Админ-панель",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
)
|
||
)
|
||
return builder.as_markup()
|
||
|
||
def render_admin_user_profile_text(*, profile: AdminTelegramUserProfile) -> str:
|
||
user = profile.user
|
||
display_name = " ".join(
|
||
part for part in [user.first_name, user.last_name] if part
|
||
).strip() or user.username or str(user.telegram_id)
|
||
username_text = f"@{user.username}" if user.username else "—"
|
||
referral_recent_names = (
|
||
", ".join(profile.recent_invited_names)
|
||
if profile.recent_invited_names
|
||
else "—"
|
||
)
|
||
|
||
lines = [
|
||
f"<b>👤 {html.escape(display_name)}</b>",
|
||
"",
|
||
"<b>Telegram-профиль</b>",
|
||
(
|
||
"<blockquote>"
|
||
f"ID: <code>{user.telegram_id}</code>\n"
|
||
f"Username: <b>{html.escape(username_text)}</b>\n"
|
||
f"Имя: <b>{html.escape(user.first_name or '—')}</b>\n"
|
||
f"Фамилия: <b>{html.escape(user.last_name or '—')}</b>\n"
|
||
f"Язык: <b>{html.escape(user.language_code or '—')}</b>\n"
|
||
f"Админ: <b>{'да' if user.is_admin else 'нет'}</b>\n"
|
||
f"Заблокирован: <b>{'да' if user.is_blocked else 'нет'}</b>\n"
|
||
f"Создан: <b>{format_datetime(user.created_at)}</b>\n"
|
||
f"Последняя активность: <b>{format_datetime(user.last_seen_at)}</b>\n"
|
||
f"Обновлён: <b>{format_datetime(user.updated_at)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
"",
|
||
"<b>Реферальная система</b>",
|
||
(
|
||
"<blockquote>"
|
||
f"Свой код: <code>{html.escape(profile.referral_code or '—')}</code>\n"
|
||
f"Применённый код: <code>{html.escape(profile.applied_referral_code or '—')}</code>\n"
|
||
f"Приглашено пользователей: <b>{profile.invited_count}</b>\n"
|
||
f"Последние приглашённые: <b>{html.escape(referral_recent_names)}</b>"
|
||
"</blockquote>"
|
||
),
|
||
"",
|
||
f"<b>Доступы Remnawave: {len(profile.accesses)}</b>",
|
||
]
|
||
|
||
if not profile.accesses:
|
||
lines.append("<blockquote>У пользователя пока нет сохранённых доступов.</blockquote>")
|
||
return "\n".join(lines)
|
||
|
||
for index, access in enumerate(profile.accesses, start=1):
|
||
squads = ", ".join(sorted(access.internal_squads)) if access.internal_squads else "—"
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"{index}. <b>{html.escape(access.record.username)}</b> <code>{html.escape(access.record.status)}</code>",
|
||
(
|
||
"<blockquote>"
|
||
f"UUID: <code>{html.escape(access.record.rw_uuid)}</code>\n"
|
||
f"Short UUID: <code>{html.escape(access.record.short_uuid)}</code>\n"
|
||
f"Истекает: <b>{format_datetime_with_days_left(access.record.expire_at)}</b>\n"
|
||
f"Трафик: <b>{format_bytes(access.record.used_traffic_bytes)}</b> / "
|
||
f"<b>{format_traffic_limit(access.record.traffic_limit_bytes)}</b>\n"
|
||
f"Группы: <b>{html.escape(squads)}</b>\n"
|
||
f"Email: <b>{html.escape(access.record.email or '—')}</b>\n"
|
||
f"Tag: <b>{html.escape(access.record.tag or '—')}</b>\n"
|
||
f"Последнее открытие: <b>{format_datetime(access.record.sub_last_opened_at)}</b>\n"
|
||
f"Синхронизирован: <b>{format_datetime(access.record.synced_at)}</b>\n"
|
||
f"Подписка: {html.escape(access.record.subscription_url)}"
|
||
"</blockquote>"
|
||
),
|
||
]
|
||
)
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_admin_user_profile_keyboard(*, profile: AdminTelegramUserProfile, telegram_id: int, page: int):
|
||
builder = InlineKeyboardBuilder()
|
||
|
||
is_blocked = profile.user.is_blocked if profile else False
|
||
if is_blocked:
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="\U0001f513 Разблокировать",
|
||
callback_data=f"admin:unblock:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
else:
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="\U0001f512 Заблокировать",
|
||
callback_data=f"admin:block:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
|
||
for access in profile.accesses:
|
||
short = access.record.short_uuid
|
||
status_upper = access.record.status.upper()
|
||
if status_upper == "DISABLED":
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text=f"\u2705 \u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}",
|
||
callback_data=f"admin:en_acc:{short}:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
else:
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text=f"\u26d4 \u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c {compact_inline_label(access.record.username, limit=16)}",
|
||
callback_data=f"admin:dis_acc:{short}:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text=f"\u23f1 \u0412\u0440\u0435\u043c\u044f {compact_inline_label(access.record.username, limit=16)}",
|
||
callback_data=f"admin:time_menu:{short}:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="\u21bb \u041e\u0431\u043d\u043e\u0432\u0438\u0442\u044c",
|
||
callback_data=admin_user_profile_callback(
|
||
telegram_id=telegram_id,
|
||
page=page,
|
||
),
|
||
),
|
||
InlineKeyboardButton(
|
||
text="\u2b05\ufe0f \u041a \u0441\u043f\u0438\u0441\u043a\u0443",
|
||
callback_data=admin_users_page_callback(page),
|
||
),
|
||
)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="\U0001f6e0 \u0410\u0434\u043c\u0438\u043d-\u043f\u0430\u043d\u0435\u043b\u044c",
|
||
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
|
||
),
|
||
)
|
||
return builder.as_markup()
|
||
|
||
def build_time_adjust_keyboard(*, short_uuid: str, telegram_id: int, page: int):
|
||
builder = InlineKeyboardBuilder()
|
||
for days in [1, 5, 10, 15, 30]:
|
||
builder.button(
|
||
text=f"+{days} \u0434\u043d.",
|
||
callback_data=f"admin:at:+{days}:{short_uuid}:{telegram_id}:{page}",
|
||
)
|
||
builder.adjust(5)
|
||
minus_row: list[InlineKeyboardButton] = []
|
||
for days in [1, 5, 10, 15, 30]:
|
||
minus_row.append(
|
||
InlineKeyboardButton(
|
||
text=f"-{days} \u0434\u043d.",
|
||
callback_data=f"admin:at:-{days}:{short_uuid}:{telegram_id}:{page}",
|
||
)
|
||
)
|
||
builder.row(*minus_row)
|
||
builder.row(
|
||
InlineKeyboardButton(
|
||
text="\u2b05\ufe0f \u041d\u0430\u0437\u0430\u0434",
|
||
callback_data=admin_user_profile_callback(
|
||
telegram_id=telegram_id,
|
||
page=page,
|
||
),
|
||
)
|
||
)
|
||
return builder.as_markup()
|
||
|
||
def _extract_product_code(plan_code: str) -> str:
|
||
"""Extract product type from plan code, e.g. 'vpn_white_30' → 'vpn_white'."""
|
||
parts = plan_code.rsplit("_", 1)
|
||
if len(parts) == 2 and parts[1].isdigit():
|
||
return parts[0]
|
||
return plan_code
|
||
|
||
def _get_product_groups(
|
||
plans: list[PaymentPlan],
|
||
) -> dict[str, list[PaymentPlan]]:
|
||
"""Group plans by product code, preserving order."""
|
||
groups: dict[str, list[PaymentPlan]] = {}
|
||
for plan in plans:
|
||
product_code = _extract_product_code(plan.code)
|
||
groups.setdefault(product_code, []).append(plan)
|
||
return groups
|
||
|
||
def render_payment_menu_text(
|
||
*,
|
||
plans: list[PaymentPlan],
|
||
referral_code: str,
|
||
referral_enabled: bool,
|
||
referral_discount_percent: int,
|
||
has_existing_access: bool,
|
||
) -> str:
|
||
lines = [
|
||
"<b>💳 Оплата подписки</b>",
|
||
"",
|
||
]
|
||
|
||
if has_existing_access:
|
||
lines.append(
|
||
"<blockquote>У вас уже есть доступ. После подтверждения оплаты бот автоматически продлит текущую подписку на выбранный срок.</blockquote>"
|
||
)
|
||
lines.append("")
|
||
|
||
if referral_enabled:
|
||
lines.append(
|
||
"<blockquote>Если у вас есть реферальный код или промокод, укажите его до оплаты.</blockquote>"
|
||
)
|
||
else:
|
||
lines.append(
|
||
"<blockquote>Выберите тип подписки.</blockquote>"
|
||
)
|
||
|
||
if referral_code:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"🎟 Активный код: <code>{html.escape(referral_code)}</code>",
|
||
f"Скидка: <b>{max(referral_discount_percent, 0)}%</b>",
|
||
]
|
||
)
|
||
|
||
lines.extend(["", "<b>Выберите тип подписки</b>"])
|
||
|
||
product_groups = _get_product_groups(plans)
|
||
for product_code, group_plans in product_groups.items():
|
||
if group_plans:
|
||
label = group_plans[0].label
|
||
# Show price range
|
||
prices = [p.amount_rub for p in group_plans]
|
||
if len(prices) == 1:
|
||
price_text = f"от <b>{min(prices)} ₽</b>"
|
||
else:
|
||
price_text = f"от <b>{min(prices)} ₽</b>"
|
||
lines.append(f"• {html.escape(label)} — {price_text}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_payment_menu_keyboard(
|
||
*,
|
||
plans: list[PaymentPlan],
|
||
referral_code: str,
|
||
referral_enabled: bool,
|
||
):
|
||
builder = InlineKeyboardBuilder()
|
||
if referral_enabled:
|
||
builder.button(
|
||
text="🎟 Код скидки" if not referral_code else "🎟 Изменить код",
|
||
callback_data="referral:apply",
|
||
)
|
||
|
||
product_groups = _get_product_groups(plans)
|
||
for product_code, group_plans in product_groups.items():
|
||
if not group_plans:
|
||
continue
|
||
label = group_plans[0].label
|
||
if len(group_plans) == 1:
|
||
# Single duration — go directly to payment:select
|
||
plan = group_plans[0]
|
||
builder.button(
|
||
text=f"{label} · {plan.duration_days} дней · {plan.amount_rub} ₽",
|
||
callback_data=payment_callback("select", plan.code),
|
||
)
|
||
else:
|
||
# Multiple durations — open product submenu
|
||
builder.button(
|
||
text=label,
|
||
callback_data=payment_callback("product", product_code),
|
||
)
|
||
|
||
builder.button(
|
||
text="🏠 Главное меню",
|
||
callback_data=f"panel:{PANEL_SECTION_HOME}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def render_product_duration_text(
|
||
*,
|
||
product_label: str,
|
||
plans: list[PaymentPlan],
|
||
referral_code: str,
|
||
referral_discount_percent: int,
|
||
) -> str:
|
||
lines = [
|
||
f"<b>💳 {html.escape(product_label)}</b>",
|
||
"",
|
||
"<blockquote>Выберите срок подписки. После выбора бот покажет реквизиты и попросит отправить чек.</blockquote>",
|
||
]
|
||
|
||
if referral_code:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"🎟 Код: <code>{html.escape(referral_code)}</code> · скидка <b>{max(referral_discount_percent, 0)}%</b>",
|
||
]
|
||
)
|
||
|
||
lines.extend(["", "<b>Выберите срок</b>"])
|
||
for plan in plans:
|
||
if plan.discount_percent > 0 and plan.original_amount_rub != plan.amount_rub:
|
||
price_text = (
|
||
f"<b>{plan.amount_rub} ₽</b> "
|
||
f"<s>{plan.original_amount_rub} ₽</s>"
|
||
)
|
||
else:
|
||
price_text = f"<b>{plan.amount_rub} ₽</b>"
|
||
lines.append(f"• {plan.duration_days} дней — {price_text}")
|
||
|
||
return "\n".join(lines)
|
||
|
||
def build_product_duration_keyboard(
|
||
*,
|
||
plans: list[PaymentPlan],
|
||
):
|
||
builder = InlineKeyboardBuilder()
|
||
for plan in plans:
|
||
builder.button(
|
||
text=f"{plan.duration_days} дней · {plan.amount_rub} ₽",
|
||
callback_data=payment_callback("select", plan.code),
|
||
)
|
||
builder.button(
|
||
text="⬅️ Назад",
|
||
callback_data=payment_callback("menu"),
|
||
)
|
||
builder.button(
|
||
text="🏠 Главное меню",
|
||
callback_data=f"panel:{PANEL_SECTION_HOME}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_home_keyboard():
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def support_ticket_callback(action: str, ticket_id: int) -> str:
|
||
return f"ticket_admin:{action}:{ticket_id}"
|
||
|
||
def support_ticket_user_callback(action: str, ticket_id: int) -> str:
|
||
return f"ticket_user:{action}:{ticket_id}"
|
||
|
||
def format_support_ticket_status(status: str) -> str:
|
||
mapping = {
|
||
OPEN_SUPPORT_TICKET_STATUS: "🟡 Открыт",
|
||
ANSWERED_SUPPORT_TICKET_STATUS: "🔵 Есть ответ",
|
||
CLOSED_SUPPORT_TICKET_STATUS: "⚫ Закрыт",
|
||
}
|
||
return mapping.get(status.upper(), f"⚪ {html.escape(status)}")
|
||
|
||
def build_support_ticket_keyboard(*, ticket):
|
||
builder = InlineKeyboardBuilder()
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
builder.button(
|
||
text="🔒 Тикет закрыт",
|
||
callback_data=support_ticket_callback("closed", ticket.id),
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
builder.button(
|
||
text="📝 Добавить текст",
|
||
callback_data=support_ticket_callback("note", ticket.id),
|
||
)
|
||
builder.button(
|
||
text="✅ Закрыть тикет",
|
||
callback_data=support_ticket_callback("close", ticket.id),
|
||
)
|
||
builder.adjust(1, 1)
|
||
return builder.as_markup()
|
||
|
||
def build_user_support_ticket_keyboard(*, ticket):
|
||
builder = InlineKeyboardBuilder()
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
builder.button(
|
||
text="🔒 Тикет закрыт",
|
||
callback_data=support_ticket_user_callback("closed", ticket.id),
|
||
)
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1, 1)
|
||
return builder.as_markup()
|
||
|
||
builder.button(
|
||
text="📝 Добавить текст",
|
||
callback_data=support_ticket_user_callback("note", ticket.id),
|
||
)
|
||
builder.button(
|
||
text="✅ Закрыть тикет",
|
||
callback_data=support_ticket_user_callback("close", ticket.id),
|
||
)
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1, 1, 1)
|
||
return builder.as_markup()
|
||
|
||
def render_support_ticket_text(*, ticket) -> str:
|
||
username_text = f"@{html.escape(ticket.username)}" if ticket.username else "не указан"
|
||
lines = [
|
||
f"<b>🎫 ТИКЕТ #{html.escape(ticket.public_id)}</b>",
|
||
"",
|
||
f"<b>Статус</b>: {format_support_ticket_status(ticket.status)}",
|
||
f"<b>ID</b>: <code>{html.escape(ticket.public_id)}</code>",
|
||
f"<b>Пользователь</b>: {html.escape(ticket.display_name)}",
|
||
f"<b>Telegram ID</b>: <code>{ticket.telegram_id}</code>",
|
||
f"<b>Username</b>: {username_text}",
|
||
f"<b>Создан</b>: {format_datetime(ticket.created_at)}",
|
||
]
|
||
if ticket.closed_at is not None:
|
||
lines.append(f"<b>Закрыт</b>: {format_datetime(ticket.closed_at)}")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>Сообщение</b>",
|
||
f"<blockquote>{html.escape(ticket.user_message)}</blockquote>",
|
||
]
|
||
)
|
||
if ticket.support_note:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>Текст к тикету</b>",
|
||
f"<blockquote>{html.escape(ticket.support_note)}</blockquote>",
|
||
]
|
||
)
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<i>Тикет закрыт. Если вопрос снова станет актуален, пользователь может создать новый.</i>",
|
||
]
|
||
)
|
||
else:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<i>Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю. Кнопками ниже можно добавить текст к тикету или закрыть его.</i>",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
async def refresh_support_ticket_message(bot: Bot, *, ticket) -> None:
|
||
if ticket.support_message_id is None:
|
||
return
|
||
|
||
try:
|
||
await bot.edit_message_text(
|
||
chat_id=ticket.support_chat_id,
|
||
message_id=ticket.support_message_id,
|
||
text=render_support_ticket_text(ticket=ticket),
|
||
reply_markup=build_support_ticket_keyboard(ticket=ticket),
|
||
)
|
||
except TelegramBadRequest as exc:
|
||
if "message is not modified" not in str(exc).lower():
|
||
raise
|
||
|
||
def render_user_ticket_created_text(*, ticket) -> str:
|
||
return "\n".join(
|
||
[
|
||
f"🎫 Тикет <b>#{html.escape(ticket.public_id)}</b> отправлен в поддержку.",
|
||
f"Статус: <b>{format_support_ticket_status(ticket.status)}</b>",
|
||
"Ответ придёт сюда, как только его обработают.",
|
||
"",
|
||
"Если хотите уточнить вопрос, добавьте текст или закройте тикет кнопками ниже.",
|
||
]
|
||
)
|
||
|
||
def render_user_ticket_note_added_text(*, ticket) -> str:
|
||
return "\n".join(
|
||
[
|
||
f"📝 Текст добавлен к тикету <b>#{html.escape(ticket.public_id)}</b>.",
|
||
f"Статус: <b>{format_support_ticket_status(ticket.status)}</b>",
|
||
"Мы передали уточнение в поддержку.",
|
||
]
|
||
)
|
||
|
||
def render_support_ticket_note_added_notice(*, ticket, author_name: str, note_text: str) -> str:
|
||
return "\n".join(
|
||
[
|
||
f"<b>📨 Обновление тикета #{html.escape(ticket.public_id)}</b>",
|
||
f"Пользователь <b>{html.escape(author_name)}</b> добавил новый текст:",
|
||
"",
|
||
f"<blockquote>{html.escape(note_text)}</blockquote>",
|
||
]
|
||
)
|
||
|
||
def render_user_ticket_support_note_text(*, ticket, note_text: str) -> str:
|
||
return "\n".join(
|
||
[
|
||
f"<b>📨 Обновление по тикету #{html.escape(ticket.public_id)}</b>",
|
||
"Поддержка добавила к тикету новую информацию:",
|
||
"",
|
||
f"<blockquote>{html.escape(note_text)}</blockquote>",
|
||
"",
|
||
"<i>Если нужно, вы можете дописать тикет кнопкой ниже или закрыть его.</i>",
|
||
]
|
||
)
|
||
|
||
def render_user_ticket_closed_text(*, ticket, closed_by_support: bool) -> str:
|
||
reason_text = (
|
||
"Если вопрос ещё актуален, создайте новый тикет через панель бота."
|
||
if closed_by_support
|
||
else "Если понадобится помощь снова, создайте новый тикет через панель бота."
|
||
)
|
||
return "\n".join(
|
||
[
|
||
f"🎫 Тикет <b>#{html.escape(ticket.public_id)}</b> закрыт.",
|
||
f"Статус: <b>{format_support_ticket_status(ticket.status)}</b>",
|
||
reason_text,
|
||
]
|
||
)
|
||
|
||
def build_payment_receipt_keyboard(*, order_uuid: str):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="📎 Отправить чек",
|
||
callback_data=payment_callback("receipt", order_uuid),
|
||
)
|
||
builder.button(text="⬅️ К тарифам", callback_data=payment_callback("menu"))
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
def build_issued_access_keyboard(*, subscription_url: str):
|
||
builder = InlineKeyboardBuilder()
|
||
if subscription_url.startswith(("http://", "https://")):
|
||
builder.button(text="🔗 Открыть подписку", url=subscription_url)
|
||
builder.button(text="🏠 Главное меню", callback_data=f"panel:{PANEL_SECTION_HOME}")
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
async def send_issued_access_message(
|
||
bot: Bot,
|
||
*,
|
||
chat_id: int,
|
||
subscription_url: str,
|
||
expire_at,
|
||
traffic_limit_bytes: int,
|
||
is_renewal: bool = False,
|
||
) -> None:
|
||
config = await get_runtime_config()
|
||
caption = format_issued_access(
|
||
subscription_url=subscription_url,
|
||
expire_at=expire_at,
|
||
traffic_limit_bytes=traffic_limit_bytes,
|
||
brand_name=config.bot_brand_name,
|
||
is_renewal=is_renewal,
|
||
)
|
||
reply_markup = build_issued_access_keyboard(subscription_url=subscription_url)
|
||
|
||
start_image_path = get_start_image_path(config)
|
||
if start_image_path is not None:
|
||
await bot.send_photo(
|
||
chat_id=chat_id,
|
||
photo=FSInputFile(str(start_image_path)),
|
||
caption=caption,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
await bot.send_message(
|
||
chat_id=chat_id,
|
||
text=caption,
|
||
reply_markup=reply_markup,
|
||
)
|
||
|
||
async def notify_referral_bonus(
|
||
bot: Bot,
|
||
*,
|
||
inviter_telegram_id: int,
|
||
bonus_days: int,
|
||
status: str,
|
||
expire_at=None,
|
||
total_applied_days: int = 0,
|
||
) -> None:
|
||
lines = [
|
||
"<b>🎁 Реферальный бонус начислен</b>",
|
||
"",
|
||
f"По вашему коду прошла подтверждённая оплата.",
|
||
f"Новый бонус: <b>+{bonus_days} дней</b>",
|
||
]
|
||
if status == "APPLIED" and expire_at is not None:
|
||
if total_applied_days > bonus_days:
|
||
lines.append(f"Всего применено накопленных бонусов: <b>+{total_applied_days} дней</b>")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
f"Ваш доступ продлён до: <b>{format_datetime(expire_at)}</b>",
|
||
]
|
||
)
|
||
else:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"Бонус сохранён и применится автоматически, когда у вас появится активный доступ.",
|
||
]
|
||
)
|
||
|
||
await send_branded(
|
||
bot,
|
||
chat_id=inviter_telegram_id,
|
||
text="\n".join(lines),
|
||
)
|
||
|
||
def render_payment_order_text(*, order: CreatedPaymentOrder) -> str:
|
||
return payment_service.build_transfer_text(order=order)
|
||
|
||
def build_review_keyboard(*, order_uuid: str):
|
||
builder = InlineKeyboardBuilder()
|
||
builder.button(
|
||
text="✅ Подтвердить",
|
||
callback_data=payment_callback("approve", order_uuid),
|
||
)
|
||
builder.button(
|
||
text="❌ Отклонить",
|
||
callback_data=payment_callback("reject", order_uuid),
|
||
)
|
||
builder.adjust(2)
|
||
return builder.as_markup()
|
||
|
||
def render_review_text(
|
||
*,
|
||
order: StoredPaymentOrder,
|
||
user_message: Message,
|
||
referral_code: str,
|
||
) -> str:
|
||
username_text = (
|
||
f"@{html.escape(user_message.from_user.username)}"
|
||
if user_message.from_user and user_message.from_user.username
|
||
else "не указан"
|
||
)
|
||
display_name = " ".join(
|
||
part
|
||
for part in [
|
||
user_message.from_user.first_name if user_message.from_user else None,
|
||
user_message.from_user.last_name if user_message.from_user else None,
|
||
]
|
||
if part
|
||
).strip() or (user_message.from_user.full_name if user_message.from_user else "Пользователь")
|
||
receipt_note = (user_message.caption or user_message.text or "").strip()
|
||
|
||
lines = [
|
||
f"<b>💸 НОВАЯ ОПЛАТА #{order.order_uuid[:8].upper()}</b>",
|
||
"",
|
||
f"<b>Тариф</b>: {html.escape(order.plan_title)}",
|
||
f"<b>Сумма</b>: <b>{order.amount_rub} ₽</b>",
|
||
f"<b>Пользователь</b>: {html.escape(display_name)}",
|
||
f"<b>Telegram ID</b>: <code>{order.telegram_id}</code>",
|
||
f"<b>Username</b>: {username_text}",
|
||
f"<b>Логин Remnawave</b>: <code>{html.escape(order.provision_username)}</code>",
|
||
]
|
||
if referral_code:
|
||
lines.append(f"<b>Код скидки</b>: <code>{html.escape(referral_code)}</code>")
|
||
if receipt_note:
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<b>Комментарий</b>",
|
||
f"<blockquote>{html.escape(receipt_note)}</blockquote>",
|
||
]
|
||
)
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"<i>Проверьте чек и выберите действие кнопками ниже.</i>",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
async def show_payment_menu_message(
|
||
message: Message,
|
||
*,
|
||
telegram_id: int,
|
||
) -> None:
|
||
config = await get_runtime_config()
|
||
plans = [
|
||
plan
|
||
for plan in await payment_service.get_available_plans(telegram_id=telegram_id)
|
||
if plan.is_ready
|
||
]
|
||
has_existing_access = bool(await sync_service.get_cached_users_for_telegram(telegram_id))
|
||
active_discount = await payment_service.get_active_discount_for_user(
|
||
telegram_id=telegram_id,
|
||
)
|
||
await answer_branded(
|
||
message,
|
||
render_payment_menu_text(
|
||
plans=plans,
|
||
referral_code=active_discount.code if config.referral_enabled else "",
|
||
referral_enabled=config.referral_enabled,
|
||
referral_discount_percent=active_discount.discount_percent
|
||
or config.referral_discount_percent,
|
||
has_existing_access=has_existing_access,
|
||
),
|
||
reply_markup=build_payment_menu_keyboard(
|
||
plans=plans,
|
||
referral_code=active_discount.code if config.referral_enabled else "",
|
||
referral_enabled=config.referral_enabled,
|
||
),
|
||
)
|
||
|
||
async def show_payment_menu_callback(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
plans = [
|
||
plan
|
||
for plan in await payment_service.get_available_plans(
|
||
telegram_id=callback.from_user.id
|
||
)
|
||
if plan.is_ready
|
||
]
|
||
has_existing_access = bool(
|
||
await sync_service.get_cached_users_for_telegram(callback.from_user.id)
|
||
)
|
||
active_discount = await payment_service.get_active_discount_for_user(
|
||
telegram_id=callback.from_user.id,
|
||
)
|
||
await edit_message_content(
|
||
callback,
|
||
text=render_payment_menu_text(
|
||
plans=plans,
|
||
referral_code=active_discount.code if config.referral_enabled else "",
|
||
referral_enabled=config.referral_enabled,
|
||
referral_discount_percent=active_discount.discount_percent
|
||
or config.referral_discount_percent,
|
||
has_existing_access=has_existing_access,
|
||
),
|
||
reply_markup=build_payment_menu_keyboard(
|
||
plans=plans,
|
||
referral_code=active_discount.code if config.referral_enabled else "",
|
||
referral_enabled=config.referral_enabled,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
async def send_ticket_to_support(message: Message, *, state: FSMContext) -> None:
|
||
if message.from_user is None:
|
||
await state.clear()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
support_chat_id = config.support_ticket_chat_id
|
||
if support_chat_id is None:
|
||
await message.answer(
|
||
"Канал для тикетов пока не настроен. Проверьте параметры "
|
||
"<code>BOT_SUPPORT_TICKET_LINK</code> или "
|
||
"<code>BOT_SUPPORT_TICKET_CHAT_ID</code>."
|
||
)
|
||
await state.clear()
|
||
return
|
||
|
||
raw_message_text = (message.text or message.caption or "").strip()
|
||
if not raw_message_text:
|
||
await message.answer(
|
||
"Для тикета нужен текст вопроса или проблемы.\n"
|
||
"Отправьте одно текстовое сообщение или подпись к файлу."
|
||
)
|
||
return
|
||
|
||
ticket_id = secrets.token_hex(4).upper()
|
||
username_text = (
|
||
f"@{html.escape(message.from_user.username)}"
|
||
if message.from_user.username
|
||
else "не указан"
|
||
)
|
||
display_name = " ".join(
|
||
part
|
||
for part in [message.from_user.first_name, message.from_user.last_name]
|
||
if part
|
||
).strip() or message.from_user.full_name
|
||
|
||
ticket_text = "\n".join(
|
||
[
|
||
f"<b>🎫 НОВЫЙ ТИКЕТ #{ticket_id}</b>",
|
||
"",
|
||
f"<b>Пользователь</b>: {html.escape(display_name)}",
|
||
f"<b>Telegram ID</b>: <code>{message.from_user.id}</code>",
|
||
f"<b>Username</b>: {username_text}",
|
||
"",
|
||
f"<blockquote>{html.escape(raw_message_text)}</blockquote>",
|
||
]
|
||
)
|
||
|
||
support_message = await message.bot.send_message(
|
||
chat_id=support_chat_id,
|
||
message_thread_id=config.support_ticket_message_thread_id,
|
||
text=ticket_text,
|
||
)
|
||
|
||
ticket = await support_ticket_service.create_ticket(
|
||
public_id=ticket_id,
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
display_name=display_name,
|
||
user_message=raw_message_text,
|
||
support_chat_id=support_chat_id,
|
||
support_thread_id=config.support_ticket_message_thread_id,
|
||
support_message_id=support_message.message_id,
|
||
)
|
||
await refresh_support_ticket_message(message.bot, ticket=ticket)
|
||
|
||
await state.clear()
|
||
await answer_branded(
|
||
message,
|
||
render_user_ticket_created_text(ticket=ticket),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=ticket),
|
||
)
|
||
|
||
async def send_receipt_for_review(
|
||
message: Message,
|
||
*,
|
||
order: StoredPaymentOrder,
|
||
referral_code: str,
|
||
) -> None:
|
||
config = await get_runtime_config()
|
||
review_chat_id = config.payment_review_chat_id
|
||
if review_chat_id is None:
|
||
raise ValueError(
|
||
"Канал проверки оплат не настроен. Проверьте PAYMENT_REVIEW_LINK или PAYMENT_REVIEW_CHAT_ID."
|
||
)
|
||
|
||
review_text = render_review_text(
|
||
order=order,
|
||
user_message=message,
|
||
referral_code=referral_code,
|
||
)
|
||
reply_markup = build_review_keyboard(order_uuid=order.order_uuid)
|
||
|
||
if message.photo:
|
||
await message.bot.send_photo(
|
||
chat_id=review_chat_id,
|
||
message_thread_id=config.payment_review_message_thread_id,
|
||
photo=message.photo[-1].file_id,
|
||
caption=review_text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
if message.document:
|
||
await message.bot.send_document(
|
||
chat_id=review_chat_id,
|
||
message_thread_id=config.payment_review_message_thread_id,
|
||
document=message.document.file_id,
|
||
caption=review_text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
return
|
||
|
||
receipt_note = (message.text or message.caption or "").strip()
|
||
if not receipt_note:
|
||
raise ValueError("Отправьте фото, документ или текст с подтверждением оплаты.")
|
||
|
||
await message.bot.send_message(
|
||
chat_id=review_chat_id,
|
||
message_thread_id=config.payment_review_message_thread_id,
|
||
text=review_text,
|
||
reply_markup=reply_markup,
|
||
)
|
||
|
||
async def append_review_status(callback: CallbackQuery, *, status_text: str) -> None:
|
||
if callback.message is None:
|
||
return
|
||
|
||
source_text = callback.message.caption or callback.message.text or ""
|
||
updated_text = f"{source_text}\n\n<b>Статус</b>: {status_text}"
|
||
if callback.message.photo or callback.message.document:
|
||
await callback.message.edit_caption(caption=updated_text, reply_markup=None)
|
||
else:
|
||
await callback.message.edit_text(updated_text, reply_markup=None)
|
||
|
||
@router.message(CommandStart())
|
||
@router.message(Command("menu"))
|
||
async def cmd_start(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
incoming_referral_code = parse_start_referral_code(message)
|
||
|
||
await sync_service.register_telegram_user(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
)
|
||
|
||
context = await load_panel_context(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
sync_remote=True,
|
||
)
|
||
config = await get_runtime_config()
|
||
if incoming_referral_code and config.referral_enabled:
|
||
await message.answer(
|
||
"👥 Вас пригласили по реферальной ссылке.\n"
|
||
f"Чтобы получить скидку <b>{max(config.referral_discount_percent, 0)}%</b>, перед оплатой введите код "
|
||
f"<code>{incoming_referral_code}</code> в разделе "
|
||
"<b>Реферальная система</b>."
|
||
)
|
||
await show_panel(message, context=context, section=PANEL_SECTION_HOME)
|
||
|
||
@router.message(Command("buy"))
|
||
async def cmd_buy(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
await show_payment_menu_message(message, telegram_id=message.from_user.id)
|
||
|
||
@router.message(Command("paysupport"))
|
||
async def cmd_paysupport(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
config = await get_runtime_config()
|
||
support_text = config.payment_support_text.strip() or "По вопросам оплаты напишите в поддержку."
|
||
await message.answer(html.escape(support_text))
|
||
|
||
@router.message(Command("broadcast"))
|
||
async def cmd_broadcast(message: Message, command: CommandObject, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await message.answer("Эта команда доступна только администраторам.")
|
||
return
|
||
|
||
await state.clear()
|
||
text = (command.args or "").strip()
|
||
if not text:
|
||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||
await message.answer(
|
||
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
|
||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||
)
|
||
return
|
||
|
||
text_html = html.escape(text)
|
||
if len(text_html) > 4000:
|
||
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
|
||
return
|
||
|
||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
|
||
|
||
@router.callback_query(F.data == "guides:menu")
|
||
async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
if not await user_can_open_guides(callback.from_user.id):
|
||
await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
|
||
return
|
||
|
||
guides = await sync_service.get_guides(active_only=True)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_guides_menu_text(guides=guides),
|
||
reply_markup=build_guides_menu_keyboard(guides=guides),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("guides:view:"))
|
||
async def on_guide_view(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
if not await user_can_open_guides(callback.from_user.id):
|
||
await callback.answer("Гайды доступны после оплаты подписки.", show_alert=True)
|
||
return
|
||
|
||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
guide_id = int(raw_guide_id)
|
||
except ValueError:
|
||
await callback.answer("Гайд не найден", show_alert=True)
|
||
return
|
||
|
||
guide = await sync_service.get_guide(guide_id, active_only=True)
|
||
if guide is None:
|
||
await callback.answer("Гайд не найден", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await send_guide_view(
|
||
callback.bot,
|
||
chat_id=callback.from_user.id,
|
||
guide=guide,
|
||
)
|
||
except TelegramBadRequest:
|
||
await callback.bot.send_message(
|
||
chat_id=callback.from_user.id,
|
||
text=render_guide_text(guide=guide),
|
||
reply_markup=build_guide_view_keyboard(),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "payment:buy")
|
||
@router.callback_query(F.data == "payment:menu")
|
||
async def on_payment_menu(callback: CallbackQuery, state: FSMContext) -> None:
|
||
await clear_interactive_states(state)
|
||
await show_payment_menu_callback(callback)
|
||
|
||
@router.callback_query(F.data.startswith("payment:product:"))
|
||
async def on_payment_product(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
product_code = (callback.data or "").split(":", maxsplit=2)[2]
|
||
|
||
config = await get_runtime_config()
|
||
all_plans = [
|
||
plan
|
||
for plan in await payment_service.get_available_plans(
|
||
telegram_id=callback.from_user.id
|
||
)
|
||
if plan.is_ready
|
||
]
|
||
|
||
product_plans = [
|
||
p for p in all_plans
|
||
if _extract_product_code(p.code) == product_code
|
||
]
|
||
|
||
if not product_plans:
|
||
await callback.answer("Тариф не найден", show_alert=True)
|
||
return
|
||
|
||
active_discount = await payment_service.get_active_discount_for_user(
|
||
telegram_id=callback.from_user.id,
|
||
)
|
||
|
||
await edit_message_content(
|
||
callback,
|
||
text=render_product_duration_text(
|
||
product_label=product_plans[0].label,
|
||
plans=product_plans,
|
||
referral_code=active_discount.code if config.referral_enabled else "",
|
||
referral_discount_percent=active_discount.discount_percent
|
||
or config.referral_discount_percent,
|
||
),
|
||
reply_markup=build_product_duration_keyboard(
|
||
plans=product_plans,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("payment:select:"))
|
||
async def on_payment_select(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
plan_code = (callback.data or "").split(":", maxsplit=2)[2]
|
||
|
||
try:
|
||
order = await payment_service.create_order(
|
||
telegram_id=callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name,
|
||
language_code=callback.from_user.language_code,
|
||
plan_code=plan_code,
|
||
)
|
||
except ValueError as exc:
|
||
await callback.answer(str(exc), show_alert=True)
|
||
return
|
||
|
||
await edit_message_content(
|
||
callback,
|
||
text=render_payment_order_text(order=order),
|
||
reply_markup=build_payment_receipt_keyboard(order_uuid=order.order_uuid),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("payment:receipt:"))
|
||
async def on_payment_receipt_start(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
order_uuid = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
order = await payment_service.get_order_for_user(
|
||
order_uuid=order_uuid,
|
||
telegram_id=callback.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await callback.answer(str(exc), show_alert=True)
|
||
return
|
||
|
||
if order.status == REVIEW_PAYMENT_STATUS:
|
||
await callback.answer("Чек уже отправлен на проверку.", show_alert=True)
|
||
return
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
await callback.answer("Этот заказ уже подтверждён.", show_alert=True)
|
||
return
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
await callback.answer("Этот заказ уже отклонён. Создайте новый.", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(ManualPaymentStates.waiting_for_receipt)
|
||
await state.update_data(order_uuid=order_uuid)
|
||
await callback.message.answer(
|
||
"📎 Отправьте фото, скриншот, документ или текст с подтверждением перевода одним следующим сообщением."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.message(ManualPaymentStates.waiting_for_receipt, Command("cancel"))
|
||
@router.message(SupportTicketStates.waiting_for_message, Command("cancel"))
|
||
@router.message(SupportTicketStates.waiting_for_note, Command("cancel"))
|
||
@router.message(SupportTicketAdminStates.waiting_for_note, Command("cancel"))
|
||
@router.message(ReferralCodeStates.waiting_for_code, Command("cancel"))
|
||
@router.message(AdminBroadcastStates.waiting_for_text, Command("cancel"))
|
||
@router.message(AdminConfigStates.waiting_for_value, Command("cancel"))
|
||
@router.message(AdminPromoStates.waiting_for_code, Command("cancel"))
|
||
@router.message(AdminPromoStates.waiting_for_plan, Command("cancel"))
|
||
@router.message(AdminPromoStates.waiting_for_discount, Command("cancel"))
|
||
@router.message(AdminPromoStates.waiting_for_expires, Command("cancel"))
|
||
@router.message(AdminGuideStates.waiting_for_title, Command("cancel"))
|
||
@router.message(AdminGuideStates.waiting_for_description, Command("cancel"))
|
||
@router.message(AdminGuideStates.waiting_for_body, Command("cancel"))
|
||
@router.message(AdminGuideStates.waiting_for_photos, Command("cancel"))
|
||
async def cancel_interactive_flow(message: Message, state: FSMContext) -> None:
|
||
await state.clear()
|
||
await message.answer("Действие отменено.")
|
||
|
||
@router.message(ManualPaymentStates.waiting_for_receipt)
|
||
async def on_payment_receipt_message(message: Message, state: FSMContext) -> None:
|
||
if message.from_user is None:
|
||
await state.clear()
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
order_uuid = str(state_data.get("order_uuid") or "").strip()
|
||
if not order_uuid:
|
||
await state.clear()
|
||
await message.answer("Заказ не найден. Начните оплату заново.")
|
||
return
|
||
|
||
try:
|
||
order = await payment_service.get_order_for_user(
|
||
order_uuid=order_uuid,
|
||
telegram_id=message.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await state.clear()
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
if order.status == REVIEW_PAYMENT_STATUS:
|
||
await state.clear()
|
||
await message.answer(
|
||
"Чек уже отправлен на проверку.",
|
||
reply_markup=build_home_keyboard(),
|
||
)
|
||
return
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
await state.clear()
|
||
await message.answer(
|
||
"Этот заказ уже подтверждён.",
|
||
reply_markup=build_home_keyboard(),
|
||
)
|
||
return
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
await state.clear()
|
||
await message.answer(
|
||
"Этот заказ уже отклонён. Создайте новый платёж.",
|
||
reply_markup=build_home_keyboard(),
|
||
)
|
||
return
|
||
|
||
try:
|
||
order = await payment_service.mark_order_under_review(
|
||
order_uuid=order_uuid,
|
||
telegram_id=message.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
try:
|
||
referral_code = await payment_service.get_referral_code_for_order(order_uuid=order_uuid)
|
||
await send_receipt_for_review(
|
||
message,
|
||
order=order,
|
||
referral_code=referral_code,
|
||
)
|
||
except (TelegramBadRequest, TelegramForbiddenError, ValueError) as exc:
|
||
await payment_service.revert_order_to_pending(
|
||
order_uuid=order_uuid,
|
||
telegram_id=message.from_user.id,
|
||
)
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
await state.clear()
|
||
await message.answer(
|
||
"✅ Чек отправлен на проверку.\n"
|
||
"После подтверждения администратором доступ придёт сюда.",
|
||
reply_markup=build_home_keyboard(),
|
||
)
|
||
|
||
@router.callback_query(F.data == "referral:apply")
|
||
async def on_referral_apply(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if not config.referral_enabled:
|
||
await callback.answer("Реферальная система сейчас отключена", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(ReferralCodeStates.waiting_for_code)
|
||
await callback.message.answer(
|
||
"🎟 Отправьте реферальный код или промокод одним следующим сообщением.\n\n"
|
||
"Код будет сохранён и применён перед оплатой, если он подходит к выбранному тарифу.\n"
|
||
"Чтобы отменить ввод, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "ticket:create")
|
||
async def on_ticket_create(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if config.support_ticket_chat_id is None:
|
||
await callback.answer("Канал для тикетов не настроен", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(SupportTicketStates.waiting_for_message)
|
||
await callback.message.answer(
|
||
"🎫 Опишите ваш вопрос или проблему одним следующим сообщением.\n\n"
|
||
"Лучше отправить одно текстовое сообщение, чтобы тикет ушёл в поддержку в чистом виде.\n"
|
||
"Чтобы отменить создание тикета, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("ticket_user:note:"))
|
||
async def on_user_ticket_note_start(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
ticket_id_text = (callback.data or "").split(":")[-1]
|
||
if not ticket_id_text.isdigit():
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
|
||
if ticket is None or ticket.telegram_id != callback.from_user.id:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
await state.set_state(SupportTicketStates.waiting_for_note)
|
||
await state.update_data(user_ticket_id=ticket.id)
|
||
await callback.message.answer(
|
||
f"📝 Отправьте текст для тикета <b>#{ticket.public_id}</b> одним следующим сообщением.\n"
|
||
"Он будет добавлен к текущему тикету и виден поддержке.\n"
|
||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("ticket_user:close:"))
|
||
async def on_user_ticket_close(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
ticket_id_text = (callback.data or "").split(":")[-1]
|
||
if not ticket_id_text.isdigit():
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
|
||
if ticket is None or ticket.telegram_id != callback.from_user.id:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
return
|
||
|
||
closed_ticket = await support_ticket_service.close_ticket(
|
||
ticket_id=ticket.id,
|
||
closed_by_telegram_id=callback.from_user.id,
|
||
)
|
||
if closed_ticket is None:
|
||
await callback.answer("Не удалось закрыть тикет", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
await refresh_support_ticket_message(callback.bot, ticket=closed_ticket)
|
||
await send_branded(
|
||
callback.bot,
|
||
chat_id=closed_ticket.telegram_id,
|
||
text=render_user_ticket_closed_text(ticket=closed_ticket, closed_by_support=False),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=closed_ticket),
|
||
)
|
||
await callback.answer("Тикет закрыт")
|
||
|
||
@router.callback_query(F.data.startswith("ticket_user:closed:"))
|
||
async def on_user_ticket_closed(callback: CallbackQuery) -> None:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
|
||
@router.callback_query(F.data.startswith("panel:"))
|
||
async def on_panel_callback(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
if await sync_service.is_user_blocked(callback.from_user.id):
|
||
await callback.answer("⛔ Ваш аккаунт заблокирован", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
|
||
parts = (callback.data or "").split(":")
|
||
action = parts[1] if len(parts) > 1 else PANEL_SECTION_HOME
|
||
refresh = len(parts) > 2 and parts[2] == "refresh"
|
||
|
||
valid_sections = {
|
||
PANEL_SECTION_HOME,
|
||
PANEL_SECTION_ADMIN,
|
||
PANEL_SECTION_SUBSCRIPTION,
|
||
PANEL_SECTION_REFERRAL,
|
||
PANEL_SECTION_SUPPORT,
|
||
PANEL_SECTION_SUPPORT_TICKET,
|
||
PANEL_SECTION_TERMS,
|
||
}
|
||
section = action if action in valid_sections else PANEL_SECTION_HOME
|
||
if section == PANEL_SECTION_ADMIN and not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if section == PANEL_SECTION_REFERRAL and not config.referral_enabled:
|
||
await callback.answer("Реферальная система сейчас отключена", show_alert=True)
|
||
section = PANEL_SECTION_HOME
|
||
|
||
context = await load_panel_context(
|
||
telegram_id=callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name,
|
||
language_code=callback.from_user.language_code,
|
||
sync_remote=refresh,
|
||
)
|
||
await update_panel(callback, context=context, section=section)
|
||
|
||
@router.message(SupportTicketStates.waiting_for_message)
|
||
async def on_ticket_message(message: Message, state: FSMContext) -> None:
|
||
await send_ticket_to_support(message, state=state)
|
||
|
||
@router.message(SupportTicketStates.waiting_for_note)
|
||
async def on_user_ticket_note_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
|
||
note_text = (message.text or message.caption or "").strip()
|
||
if not note_text:
|
||
await message.answer("Отправьте текст уточнения одним сообщением.")
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
ticket_id = int(state_data.get("user_ticket_id") or 0)
|
||
if ticket_id <= 0:
|
||
await state.clear()
|
||
await message.answer("Тикет не найден. Нажмите кнопку ещё раз.")
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
|
||
if ticket is None or ticket.telegram_id != message.from_user.id:
|
||
await state.clear()
|
||
await message.answer("Тикет не найден.")
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await state.clear()
|
||
await message.answer(
|
||
"Тикет уже закрыт.",
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=ticket),
|
||
)
|
||
return
|
||
|
||
display_name = " ".join(
|
||
part for part in [message.from_user.first_name, message.from_user.last_name] if part
|
||
).strip() or message.from_user.full_name
|
||
note_entry = "\n".join(
|
||
[
|
||
f"[{format_datetime(message.date.replace(tzinfo=None))}] {display_name}",
|
||
note_text,
|
||
]
|
||
)
|
||
updated_ticket = await support_ticket_service.append_note(
|
||
ticket_id=ticket.id,
|
||
note_text=note_entry,
|
||
status=OPEN_SUPPORT_TICKET_STATUS,
|
||
)
|
||
if updated_ticket is None:
|
||
await state.clear()
|
||
await message.answer("Не удалось добавить текст к тикету.")
|
||
return
|
||
|
||
await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
|
||
await message.bot.send_message(
|
||
chat_id=updated_ticket.support_chat_id,
|
||
text=render_support_ticket_note_added_notice(
|
||
ticket=updated_ticket,
|
||
author_name=display_name,
|
||
note_text=note_text,
|
||
),
|
||
message_thread_id=updated_ticket.support_thread_id,
|
||
)
|
||
await state.clear()
|
||
await answer_branded(
|
||
message,
|
||
render_user_ticket_note_added_text(ticket=updated_ticket),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=updated_ticket),
|
||
)
|
||
|
||
@router.message(ReferralCodeStates.waiting_for_code)
|
||
async def on_referral_code_message(message: Message, state: FSMContext) -> None:
|
||
if message.from_user is None:
|
||
await state.clear()
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if not config.referral_enabled:
|
||
await state.clear()
|
||
await message.answer("Реферальная система сейчас отключена.")
|
||
return
|
||
|
||
raw_code = (message.text or "").strip().upper()
|
||
if not raw_code:
|
||
await message.answer("Отправьте реферальный код или промокод текстом.")
|
||
return
|
||
|
||
try:
|
||
applied_code = await sync_service.apply_referral_code(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
referral_code=raw_code,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
await state.clear()
|
||
active_discount = await payment_service.get_active_discount_for_user(
|
||
telegram_id=message.from_user.id,
|
||
)
|
||
discount_percent = active_discount.discount_percent or config.referral_discount_percent
|
||
code_kind_text = "Промокод" if active_discount.source == "promo" else "Реферальный код"
|
||
await message.answer(
|
||
f"✅ {code_kind_text} <code>{applied_code}</code> сохранён.\n"
|
||
f"Скидка <b>{max(discount_percent, 0)}%</b> будет применена перед оплатой."
|
||
)
|
||
await show_payment_menu_message(message, telegram_id=message.from_user.id)
|
||
|
||
@router.callback_query(F.data == "admin:lookup")
|
||
async def on_admin_lookup(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(AdminLookupStates.waiting_for_identifier)
|
||
await callback.message.answer(
|
||
"🔍 Отправьте идентификатор пользователя следующим сообщением.\n\n"
|
||
"Поддерживаются: uuid, id, username, short UUID.\n"
|
||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:broadcast")
|
||
async def on_admin_broadcast(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||
await callback.message.answer(
|
||
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
|
||
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
|
||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:broadcast_cancel")
|
||
async def on_admin_broadcast_cancel(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await callback.answer("Рассылка отменена")
|
||
if callback.message is not None:
|
||
await callback.message.answer("Действие отменено.")
|
||
|
||
@router.callback_query(F.data == "admin:broadcast_confirm")
|
||
async def on_admin_broadcast_confirm(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
text_html = str(state_data.get("broadcast_text_html") or "").strip()
|
||
if not text_html:
|
||
await state.clear()
|
||
await callback.answer("Текст рассылки не найден", show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await callback.answer("Рассылка запущена")
|
||
if callback.message is not None:
|
||
await callback.message.answer("📣 Рассылка запущена. Пришлю итог после отправки.")
|
||
|
||
sent_count, blocked_count, failed_count = await send_admin_broadcast(
|
||
callback.bot,
|
||
text_html=text_html,
|
||
)
|
||
if callback.message is not None:
|
||
await callback.message.answer(
|
||
"✅ Рассылка завершена.\n\n"
|
||
f"Отправлено: <b>{sent_count}</b>\n"
|
||
f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n"
|
||
f"Ошибок отправки: <b>{failed_count}</b>"
|
||
)
|
||
|
||
@router.callback_query(F.data == "admin:sync_all")
|
||
async def on_admin_sync_all(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
await callback.answer("Синхронизация запущена")
|
||
await run_sync_all(callback.message)
|
||
|
||
context = await load_panel_context(
|
||
telegram_id=callback.from_user.id,
|
||
username=callback.from_user.username,
|
||
first_name=callback.from_user.first_name,
|
||
last_name=callback.from_user.last_name,
|
||
language_code=callback.from_user.language_code,
|
||
sync_remote=False,
|
||
)
|
||
await update_panel(callback, context=context, section=PANEL_SECTION_ADMIN)
|
||
|
||
@router.callback_query(F.data == "admin:guides")
|
||
async def on_admin_guides(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
guides = await sync_service.get_guides(active_only=False)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_guides_text(guides=guides),
|
||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:guide_create")
|
||
async def on_admin_guide_create(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await state.set_state(AdminGuideStates.waiting_for_title)
|
||
await callback.message.answer(
|
||
"📚 Отправьте название гайда одним сообщением.\n\n"
|
||
"Чтобы отменить создание, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:guide_photo_count")
|
||
async def on_admin_guide_photo_count(callback: CallbackQuery) -> None:
|
||
await callback.answer("Отправьте ещё фото или сохраните гайд.", show_alert=True)
|
||
|
||
@router.callback_query(F.data == "admin:guide_done")
|
||
async def on_admin_guide_done(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
try:
|
||
guide = await sync_service.create_guide(
|
||
title=str(state_data.get("guide_title") or ""),
|
||
short_description=str(state_data.get("guide_description") or ""),
|
||
body_text=str(state_data.get("guide_body") or ""),
|
||
photo_file_ids=list(state_data.get("guide_photos") or []),
|
||
created_by_telegram_id=callback.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await callback.answer(str(exc), show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
guides = await sync_service.get_guides(active_only=False)
|
||
await show_branded_view(
|
||
callback,
|
||
text=(
|
||
f"✅ Гайд <b>{html.escape(guide.title)}</b> создан.\n\n"
|
||
+ render_admin_guides_text(guides=guides)
|
||
),
|
||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||
)
|
||
await callback.answer("Гайд сохранён")
|
||
|
||
@router.callback_query(F.data.startswith("admin:guide_delete:"))
|
||
async def on_admin_guide_delete(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
guide_id = int(raw_guide_id)
|
||
except ValueError:
|
||
await callback.answer("Гайд не найден", show_alert=True)
|
||
return
|
||
|
||
guide = await sync_service.get_guide(guide_id, active_only=False)
|
||
if guide is None:
|
||
await callback.answer("Гайд не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_guide_delete_confirm_text(guide=guide),
|
||
reply_markup=build_admin_guide_delete_confirm_keyboard(guide_id=guide.id),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:guide_delete_confirm:"))
|
||
async def on_admin_guide_delete_confirm(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
raw_guide_id = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
guide_id = int(raw_guide_id)
|
||
except ValueError:
|
||
await callback.answer("Гайд не найден", show_alert=True)
|
||
return
|
||
|
||
deleted_guide = await sync_service.delete_guide(guide_id)
|
||
if deleted_guide is None:
|
||
await callback.answer("Гайд уже удалён", show_alert=True)
|
||
return
|
||
|
||
guides = await sync_service.get_guides(active_only=False)
|
||
await show_branded_view(
|
||
callback,
|
||
text=(
|
||
f"✅ Гайд <b>{html.escape(deleted_guide.title)}</b> удалён.\n\n"
|
||
+ render_admin_guides_text(guides=guides)
|
||
),
|
||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||
)
|
||
await callback.answer("Гайд удалён")
|
||
|
||
@router.callback_query(F.data == "admin:promos")
|
||
async def on_admin_promos(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
config = await get_runtime_config()
|
||
promos = await sync_service.get_admin_promo_codes()
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_promos_text(promos=promos, config=config),
|
||
reply_markup=build_admin_promos_keyboard(),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:promo_create")
|
||
async def on_admin_promo_create(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await state.clear()
|
||
await state.set_state(AdminPromoStates.waiting_for_code)
|
||
await callback.message.answer(
|
||
"🎟 Отправьте слово для промокода.\n\n"
|
||
"Можно использовать буквы, цифры, <code>_</code> и <code>-</code>. "
|
||
"Чтобы отменить создание, отправьте <code>/cancel</code>."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:promo_plan:"))
|
||
async def on_admin_promo_plan(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
promo_code = str(state_data.get("promo_code") or "").strip()
|
||
if not promo_code:
|
||
await callback.answer("Сначала введите слово промокода", show_alert=True)
|
||
return
|
||
|
||
raw_plan_code = (callback.data or "").split(":", maxsplit=2)[2].removeprefix("promo_plan:")
|
||
|
||
config = await get_runtime_config()
|
||
|
||
if raw_plan_code == "all":
|
||
# "all" → show duration submenu with unique durations
|
||
product_label = "Все тарифы"
|
||
else:
|
||
# Specific product selected
|
||
product_plans = [
|
||
p for p in config.payment_plans
|
||
if _extract_product_code(p.code) == raw_plan_code
|
||
]
|
||
if not product_plans:
|
||
await callback.answer("Тариф не найден", show_alert=True)
|
||
return
|
||
product_label = product_plans[0].title or raw_plan_code
|
||
|
||
await edit_message_content(
|
||
callback,
|
||
text=(
|
||
f"<b>Промокод:</b> <code>{html.escape(promo_code)}</code>\n\n"
|
||
f"Тип: <b>{html.escape(product_label)}</b>\n\n"
|
||
"Выберите период, на который будет действовать скидка."
|
||
),
|
||
reply_markup=build_admin_promo_duration_keyboard(
|
||
config=config,
|
||
product_code=raw_plan_code,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:promo_back_to_plans")
|
||
async def on_admin_promo_back_to_plans(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
promo_code = str(state_data.get("promo_code") or "").strip()
|
||
config = await get_runtime_config()
|
||
await edit_message_content(
|
||
callback,
|
||
text=(
|
||
f"<b>Промокод:</b> <code>{html.escape(promo_code)}</code>\n\n"
|
||
"Выберите тариф, на который будет действовать скидка."
|
||
),
|
||
reply_markup=build_admin_promo_plan_keyboard(config=config),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:promo_duration:"))
|
||
async def on_admin_promo_duration(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
promo_code = str(state_data.get("promo_code") or "").strip()
|
||
if not promo_code:
|
||
await callback.answer("Сначала введите слово промокода", show_alert=True)
|
||
return
|
||
|
||
plan_code = (callback.data or "").removeprefix("admin:promo_duration:")
|
||
config = await get_runtime_config()
|
||
|
||
# Validate the plan code
|
||
available_plan_codes = {plan.code for plan in config.payment_plans}
|
||
is_all_duration = plan_code.startswith("all_") and plan_code.removeprefix("all_").isdigit()
|
||
|
||
if not is_all_duration and plan_code not in available_plan_codes:
|
||
await callback.answer("Тариф не найден", show_alert=True)
|
||
return
|
||
|
||
# Build display label
|
||
if is_all_duration:
|
||
days = plan_code.removeprefix("all_")
|
||
plan_label = f"Все тарифы · {days} дней"
|
||
else:
|
||
plan_info = next((p for p in config.payment_plans if p.code == plan_code), None)
|
||
plan_label = f"{plan_info.title} · {plan_info.days} дней" if plan_info and plan_info.title else plan_code
|
||
|
||
await state.set_state(AdminPromoStates.waiting_for_discount)
|
||
await state.update_data(promo_plan_code=plan_code)
|
||
await callback.message.answer(
|
||
f"Тариф: <b>{html.escape(plan_label)}</b>\n\n"
|
||
"Введите скидку по промокоду в процентах.\n"
|
||
"Например: <code>15</code>. Допустимо от 1 до 100."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:settings")
|
||
async def on_admin_settings(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
config = await get_runtime_config()
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_settings_home_text(config=config),
|
||
reply_markup=build_admin_settings_home_keyboard(),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:settings_section:"))
|
||
async def on_admin_settings_section(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
section = (callback.data or "").split(":", maxsplit=2)[2]
|
||
if not BotConfigService.get_specs_for_section(section):
|
||
await callback.answer("Раздел не найден", show_alert=True)
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_settings_section_text(config=config, section=section),
|
||
reply_markup=build_admin_settings_section_keyboard(section=section),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data == "admin:users")
|
||
async def on_admin_users(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
page_data = await sync_service.get_admin_telegram_users_page(page=1)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_users_page_text(page_data=page_data),
|
||
reply_markup=build_admin_users_page_keyboard(page_data=page_data),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:user_list:"))
|
||
async def on_admin_users_page(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
raw_page = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
page = int(raw_page)
|
||
except ValueError:
|
||
page = 1
|
||
|
||
page_data = await sync_service.get_admin_telegram_users_page(page=page)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_users_page_text(page_data=page_data),
|
||
reply_markup=build_admin_users_page_keyboard(page_data=page_data),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:user_view:"))
|
||
async def on_admin_user_profile(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
parts = (callback.data or "").split(":")
|
||
if len(parts) < 4:
|
||
await callback.answer("Некорректный пользователь", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
telegram_id = int(parts[2])
|
||
page = int(parts[3])
|
||
except ValueError:
|
||
await callback.answer("Некорректный пользователь", show_alert=True)
|
||
return
|
||
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id)
|
||
if profile is None:
|
||
await callback.answer("Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile,
|
||
telegram_id=telegram_id,
|
||
page=page,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
# ── Admin: Block / Unblock user ──────────────────────────────────
|
||
|
||
@router.callback_query(F.data == "admin:tickets")
|
||
async def on_admin_tickets(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
page_data = await support_ticket_service.get_admin_tickets_page(page=1)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_tickets_page_text(page_data=page_data),
|
||
reply_markup=build_admin_tickets_page_keyboard(page_data=page_data),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:ticket_list:"))
|
||
async def on_admin_tickets_page(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
raw_page = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
page = int(raw_page)
|
||
except ValueError:
|
||
page = 1
|
||
|
||
page_data = await support_ticket_service.get_admin_tickets_page(page=page)
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_tickets_page_text(page_data=page_data),
|
||
reply_markup=build_admin_tickets_page_keyboard(page_data=page_data),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:ticket_view:"))
|
||
async def on_admin_ticket_view(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
await clear_interactive_states(state)
|
||
parts = (callback.data or "").split(":")
|
||
if len(parts) < 4:
|
||
await callback.answer("Некорректный тикет", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
ticket_id = int(parts[2])
|
||
page = int(parts[3])
|
||
except ValueError:
|
||
await callback.answer("Некорректный тикет", show_alert=True)
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
|
||
if ticket is None:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_ticket_history_text(ticket=ticket),
|
||
reply_markup=build_admin_ticket_history_keyboard(
|
||
ticket_id=ticket.id,
|
||
page=page,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:block:"))
|
||
async def on_admin_block_user(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 4:
|
||
await callback.answer()
|
||
return
|
||
|
||
try:
|
||
target_telegram_id = int(parts[2])
|
||
page = int(parts[3])
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
await sync_service.block_telegram_user(target_telegram_id)
|
||
|
||
# Auto-disable all Remnawave accesses
|
||
try:
|
||
remote_users = await sync_service._remnawave_client.get_users_by_telegram_id(
|
||
target_telegram_id
|
||
)
|
||
for ru in remote_users:
|
||
if ru.status.upper() != "DISABLED":
|
||
await sync_service._remnawave_client.update_user(
|
||
{"uuid": str(ru.uuid), "status": "DISABLED"}
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
try:
|
||
await sync_service.refresh_cached_users_for_telegram(
|
||
telegram_id=target_telegram_id,
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id)
|
||
if profile is None:
|
||
await callback.answer("Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile,
|
||
telegram_id=target_telegram_id,
|
||
page=page,
|
||
),
|
||
)
|
||
await callback.answer("🔒 Пользователь заблокирован", show_alert=True)
|
||
|
||
@router.callback_query(F.data.startswith("admin:unblock:"))
|
||
async def on_admin_unblock_user(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 4:
|
||
await callback.answer()
|
||
return
|
||
|
||
try:
|
||
target_telegram_id = int(parts[2])
|
||
page = int(parts[3])
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
await sync_service.unblock_telegram_user(target_telegram_id)
|
||
|
||
# Auto-enable all Remnawave accesses
|
||
try:
|
||
remote_users = await sync_service._remnawave_client.get_users_by_telegram_id(
|
||
target_telegram_id
|
||
)
|
||
for ru in remote_users:
|
||
if ru.status.upper() == "DISABLED":
|
||
await sync_service._remnawave_client.update_user(
|
||
{"uuid": str(ru.uuid), "status": "ACTIVE"}
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
try:
|
||
await sync_service.refresh_cached_users_for_telegram(
|
||
telegram_id=target_telegram_id,
|
||
)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id)
|
||
if profile is None:
|
||
await callback.answer("Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile,
|
||
telegram_id=target_telegram_id,
|
||
page=page,
|
||
),
|
||
)
|
||
await callback.answer("🔓 Пользователь разблокирован", show_alert=True)
|
||
|
||
# ── Admin: Disable / Enable individual Remnawave access ──────────
|
||
|
||
@router.callback_query(F.data.startswith("admin:dis_acc:"))
|
||
async def on_admin_disable_access(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 5:
|
||
await callback.answer()
|
||
return
|
||
|
||
short_uuid = parts[2]
|
||
try:
|
||
target_telegram_id = int(parts[3])
|
||
page = int(parts[4])
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||
await sync_service._remnawave_client.update_user(
|
||
{"uuid": str(rw_user.uuid), "status": "DISABLED"}
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id)
|
||
if profile is None:
|
||
await callback.answer("Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile, telegram_id=target_telegram_id, page=page,
|
||
),
|
||
)
|
||
await callback.answer("⛔ Доступ отключён", show_alert=True)
|
||
|
||
@router.callback_query(F.data.startswith("admin:en_acc:"))
|
||
async def on_admin_enable_access(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 5:
|
||
await callback.answer()
|
||
return
|
||
|
||
short_uuid = parts[2]
|
||
try:
|
||
target_telegram_id = int(parts[3])
|
||
page = int(parts[4])
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||
await sync_service._remnawave_client.update_user(
|
||
{"uuid": str(rw_user.uuid), "status": "ACTIVE"}
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id)
|
||
if profile is None:
|
||
await callback.answer("Пользователь не найден", show_alert=True)
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile, telegram_id=target_telegram_id, page=page,
|
||
),
|
||
)
|
||
await callback.answer("✅ Доступ включён", show_alert=True)
|
||
|
||
# ── Admin: Time adjustment menu & actions ────────────────────────
|
||
|
||
@router.callback_query(F.data.startswith("admin:time_menu:"))
|
||
async def on_admin_time_menu(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
if len(parts) < 5:
|
||
await callback.answer()
|
||
return
|
||
|
||
short_uuid = parts[2]
|
||
try:
|
||
target_telegram_id = int(parts[3])
|
||
page = int(parts[4])
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||
except RemnawaveApiError as exc:
|
||
await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True)
|
||
return
|
||
|
||
text = (
|
||
f"<b>⏱ Изменение срока подписки</b>\n\n"
|
||
f"Пользователь: <b>{html.escape(rw_user.username)}</b>\n"
|
||
f"Текущий срок: <b>{format_datetime_with_days_left(rw_user.expire_at)}</b>\n\n"
|
||
f"Выберите количество дней для добавления или снятия:"
|
||
)
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=text,
|
||
reply_markup=build_time_adjust_keyboard(
|
||
short_uuid=short_uuid,
|
||
telegram_id=target_telegram_id,
|
||
page=page,
|
||
),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("admin:at:"))
|
||
async def on_admin_adjust_time(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
parts = callback.data.split(":")
|
||
# admin:at:+5:shortUuid:telegramId:page
|
||
if len(parts) < 6:
|
||
await callback.answer()
|
||
return
|
||
|
||
days_str = parts[2]
|
||
short_uuid = parts[3]
|
||
try:
|
||
target_telegram_id = int(parts[4])
|
||
page = int(parts[5])
|
||
days = int(days_str)
|
||
except ValueError:
|
||
await callback.answer("Некорректные данные", show_alert=True)
|
||
return
|
||
|
||
from datetime import timedelta, timezone
|
||
|
||
try:
|
||
rw_user = await sync_service._remnawave_client.get_user_by_short_uuid(short_uuid)
|
||
except RemnawaveApiError as exc:
|
||
await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True)
|
||
return
|
||
|
||
current_expire = rw_user.expire_at
|
||
if current_expire.tzinfo is None:
|
||
from datetime import timezone as tz
|
||
current_expire = current_expire.replace(tzinfo=tz.utc)
|
||
|
||
from datetime import datetime as dt
|
||
|
||
now = dt.now(timezone.utc)
|
||
|
||
if days > 0:
|
||
new_expire = max(current_expire, now) + timedelta(days=days)
|
||
else:
|
||
new_expire = current_expire + timedelta(days=days)
|
||
if new_expire < now:
|
||
new_expire = now
|
||
|
||
try:
|
||
await sync_service._remnawave_client.update_user(
|
||
{
|
||
"uuid": str(rw_user.uuid),
|
||
"expireAt": new_expire.isoformat().replace("+00:00", "Z"),
|
||
}
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
await callback.answer(f"Ошибка: {exc.message[:100]}", show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await sync_service.refresh_cached_users_for_telegram(telegram_id=target_telegram_id)
|
||
except RemnawaveApiError:
|
||
pass
|
||
|
||
sign = "+" if days > 0 else ""
|
||
await callback.answer(f"✅ {sign}{days} дн. применено", show_alert=True)
|
||
|
||
# Re-fetch and show updated profile
|
||
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=target_telegram_id)
|
||
if profile is None:
|
||
return
|
||
|
||
await show_branded_view(
|
||
callback,
|
||
text=render_admin_user_profile_text(profile=profile),
|
||
reply_markup=build_admin_user_profile_keyboard(
|
||
profile=profile, telegram_id=target_telegram_id, page=page,
|
||
),
|
||
)
|
||
|
||
@router.callback_query(F.data.startswith("admin:edit:"))
|
||
async def on_admin_edit_setting(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
if not settings.is_admin(callback.from_user.id):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
if not isinstance(bot_config_service, BotConfigService):
|
||
await callback.answer("Редактирование сейчас недоступно", show_alert=True)
|
||
return
|
||
|
||
key = (callback.data or "").split(":", maxsplit=2)[2]
|
||
spec = BotConfigService.get_spec(key)
|
||
if spec is None:
|
||
await callback.answer("Настройка не найдена", show_alert=True)
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
await state.set_state(AdminConfigStates.waiting_for_value)
|
||
await state.update_data(config_key=key, config_section=spec.section)
|
||
await callback.message.answer(
|
||
render_admin_setting_prompt(config=config, key=key),
|
||
reply_markup=build_admin_settings_section_keyboard(section=spec.section),
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.message(AdminGuideStates.waiting_for_title)
|
||
async def on_admin_guide_title_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
title = (message.text or message.caption or "").strip()
|
||
if not title:
|
||
await message.answer("Отправьте название гайда текстом.")
|
||
return
|
||
if len(title) > 255:
|
||
await message.answer("Название должно быть не длиннее 255 символов.")
|
||
return
|
||
|
||
await state.set_state(AdminGuideStates.waiting_for_description)
|
||
await state.update_data(guide_title=title)
|
||
await message.answer(
|
||
"Теперь отправьте короткое описание гайда.\n\n"
|
||
"Оно будет видно в списке гайдов."
|
||
)
|
||
|
||
@router.message(AdminGuideStates.waiting_for_description)
|
||
async def on_admin_guide_description_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
description = (message.text or message.caption or "").strip()
|
||
if not description:
|
||
await message.answer("Отправьте короткое описание текстом.")
|
||
return
|
||
if len(description) > 512:
|
||
await message.answer("Описание должно быть не длиннее 512 символов.")
|
||
return
|
||
|
||
await state.set_state(AdminGuideStates.waiting_for_body)
|
||
await state.update_data(guide_description=description)
|
||
await message.answer(
|
||
"Теперь отправьте сам гайд одним сообщением.\n\n"
|
||
"Бот покажет этот текст пользователю как цитату."
|
||
)
|
||
|
||
@router.message(AdminGuideStates.waiting_for_body)
|
||
async def on_admin_guide_body_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
body_text = (message.text or message.caption or "").strip()
|
||
if not body_text:
|
||
await message.answer("Отправьте текст гайда.")
|
||
return
|
||
|
||
await state.set_state(AdminGuideStates.waiting_for_photos)
|
||
await state.update_data(guide_body=body_text, guide_photos=[])
|
||
await message.answer(
|
||
"Теперь отправьте фото для гайда.\n\n"
|
||
"Можно отправить несколько фото одним альбомом или по одному. "
|
||
"Когда закончите, нажмите кнопку сохранения или отправьте <code>/done</code>.",
|
||
reply_markup=build_admin_guide_photos_keyboard(photo_count=0),
|
||
)
|
||
|
||
@router.message(AdminGuideStates.waiting_for_photos, Command("done"))
|
||
async def on_admin_guide_done_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
try:
|
||
guide = await sync_service.create_guide(
|
||
title=str(state_data.get("guide_title") or ""),
|
||
short_description=str(state_data.get("guide_description") or ""),
|
||
body_text=str(state_data.get("guide_body") or ""),
|
||
photo_file_ids=list(state_data.get("guide_photos") or []),
|
||
created_by_telegram_id=message.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
await state.clear()
|
||
guides = await sync_service.get_guides(active_only=False)
|
||
await message.answer(f"✅ Гайд <b>{html.escape(guide.title)}</b> создан.")
|
||
await message.answer(
|
||
render_admin_guides_text(guides=guides),
|
||
reply_markup=build_admin_guides_keyboard(guides=guides),
|
||
)
|
||
|
||
@router.message(AdminGuideStates.waiting_for_photos)
|
||
async def on_admin_guide_photo_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
if not message.photo:
|
||
await message.answer(
|
||
"Отправьте фото или завершите создание командой <code>/done</code>.",
|
||
reply_markup=build_admin_guide_photos_keyboard(
|
||
photo_count=len((await state.get_data()).get("guide_photos") or []),
|
||
),
|
||
)
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
photo_file_ids = list(state_data.get("guide_photos") or [])
|
||
file_id = message.photo[-1].file_id
|
||
if file_id not in photo_file_ids:
|
||
photo_file_ids.append(file_id)
|
||
await state.update_data(guide_photos=photo_file_ids)
|
||
await message.answer(
|
||
f"Фото добавлено: <b>{len(photo_file_ids)}</b>.",
|
||
reply_markup=build_admin_guide_photos_keyboard(photo_count=len(photo_file_ids)),
|
||
)
|
||
|
||
@router.message(AdminPromoStates.waiting_for_code)
|
||
async def on_admin_promo_code_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
promo_code = sync_service.normalize_discount_code(message.text or message.caption or "")
|
||
if not promo_code:
|
||
await message.answer("Отправьте слово для промокода текстом.")
|
||
return
|
||
if len(promo_code) > 32:
|
||
await message.answer("Промокод должен быть не длиннее 32 символов.")
|
||
return
|
||
if not all(ch.isalnum() or ch in "_-" for ch in promo_code):
|
||
await message.answer("Промокод может содержать только буквы, цифры, _ и -.")
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
await state.set_state(AdminPromoStates.waiting_for_plan)
|
||
await state.update_data(promo_code=promo_code)
|
||
await message.answer(
|
||
f"Промокод: <code>{html.escape(promo_code)}</code>\n\n"
|
||
"Выберите тариф, на который будет действовать скидка.",
|
||
reply_markup=build_admin_promo_plan_keyboard(config=config),
|
||
)
|
||
|
||
@router.message(AdminPromoStates.waiting_for_plan)
|
||
async def on_admin_promo_plan_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
await message.answer(
|
||
"Выберите тариф кнопкой ниже.",
|
||
reply_markup=build_admin_promo_plan_keyboard(config=config),
|
||
)
|
||
|
||
@router.message(AdminPromoStates.waiting_for_discount)
|
||
async def on_admin_promo_discount_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
raw_discount = (message.text or message.caption or "").strip().removesuffix("%")
|
||
try:
|
||
discount_percent = int(raw_discount)
|
||
except ValueError:
|
||
await message.answer("Введите скидку числом, например <code>15</code>.")
|
||
return
|
||
if discount_percent <= 0 or discount_percent > 100:
|
||
await message.answer("Скидка должна быть от 1 до 100%.")
|
||
return
|
||
|
||
await state.set_state(AdminPromoStates.waiting_for_expires)
|
||
await state.update_data(promo_discount_percent=discount_percent)
|
||
await message.answer(
|
||
"Введите срок действия промокода.\n\n"
|
||
"Примеры: <code>7</code> = 7 дней, <code>24h</code> = 24 часа, "
|
||
"<code>2026-05-31</code> = до даты. Для бессрочного промокода отправьте <code>0</code>."
|
||
)
|
||
|
||
@router.message(AdminPromoStates.waiting_for_expires)
|
||
async def on_admin_promo_expires_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
promo_code = str(state_data.get("promo_code") or "").strip()
|
||
plan_code = state_data.get("promo_plan_code")
|
||
discount_percent = int(state_data.get("promo_discount_percent") or 0)
|
||
if not promo_code or discount_percent <= 0:
|
||
await state.clear()
|
||
await message.answer("Данные промокода потерялись. Начните создание заново.")
|
||
return
|
||
|
||
try:
|
||
expires_at = parse_promo_expires_at(message.text or message.caption or "")
|
||
promo = await sync_service.create_promo_code(
|
||
code=promo_code,
|
||
plan_code=str(plan_code) if plan_code else None,
|
||
discount_percent=discount_percent,
|
||
expires_at=expires_at,
|
||
created_by_telegram_id=message.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
await state.clear()
|
||
config = await get_runtime_config()
|
||
promos = await sync_service.get_admin_promo_codes()
|
||
await message.answer(
|
||
"\n".join(
|
||
[
|
||
f"✅ Промокод <code>{html.escape(promo.code)}</code> создан.",
|
||
f"Скидка: <b>{promo.discount_percent}%</b>",
|
||
f"Тариф: <b>{html.escape(format_promo_scope(plan_code=promo.plan_code, config=config))}</b>",
|
||
f"Срок: <b>{html.escape(format_promo_expires_at(promo.expires_at))}</b>",
|
||
]
|
||
)
|
||
)
|
||
await message.answer(
|
||
render_admin_promos_text(promos=promos, config=config),
|
||
reply_markup=build_admin_promos_keyboard(),
|
||
)
|
||
|
||
@router.message(AdminLookupStates.waiting_for_identifier)
|
||
async def on_admin_lookup_message(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
identifier = (message.text or "").strip()
|
||
if not identifier:
|
||
await message.answer("Отправьте идентификатор пользователя текстом.")
|
||
return
|
||
|
||
found = await send_lookup_result(message, identifier=identifier)
|
||
if found:
|
||
await state.clear()
|
||
|
||
@router.message(AdminBroadcastStates.waiting_for_text)
|
||
async def on_admin_broadcast_text(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
raw_text, text_html = get_message_html_text(message)
|
||
if not raw_text:
|
||
await message.answer("Отправьте текст рассылки одним сообщением.")
|
||
return
|
||
if len(text_html) > 4000:
|
||
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
|
||
return
|
||
|
||
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
|
||
|
||
@router.message(AdminConfigStates.waiting_for_value)
|
||
async def on_admin_setting_value(message: Message, state: FSMContext) -> None:
|
||
if not await ensure_sender(message):
|
||
await state.clear()
|
||
return
|
||
if not settings.is_admin(message.from_user.id):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
if not isinstance(bot_config_service, BotConfigService):
|
||
await state.clear()
|
||
await message.answer("Редактирование настроек сейчас недоступно.")
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
key = str(state_data.get("config_key") or "").strip()
|
||
section = str(state_data.get("config_section") or "").strip()
|
||
spec = BotConfigService.get_spec(key)
|
||
if not key or spec is None:
|
||
await state.clear()
|
||
await message.answer("Настройка не найдена. Откройте админ-панель заново.")
|
||
return
|
||
|
||
raw_value = (message.text or message.caption or "").strip()
|
||
if not raw_value:
|
||
await message.answer("Отправьте новое значение текстом.")
|
||
return
|
||
|
||
try:
|
||
await bot_config_service.update_setting(
|
||
key=key,
|
||
raw_value=raw_value,
|
||
updated_by_telegram_id=message.from_user.id,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
|
||
await state.clear()
|
||
config = await get_runtime_config()
|
||
current_value = BotConfigService.format_value(snapshot=config, key=key)
|
||
await message.answer(
|
||
"\n".join(
|
||
[
|
||
f"✅ Настройка <b>{html.escape(spec.label)}</b> сохранена.",
|
||
f"Новое значение: <code>{html.escape(current_value)}</code>",
|
||
]
|
||
),
|
||
reply_markup=build_admin_settings_section_keyboard(section=section or spec.section),
|
||
)
|
||
await message.answer(
|
||
render_admin_settings_section_text(config=config, section=section or spec.section),
|
||
reply_markup=build_admin_settings_section_keyboard(section=section or spec.section),
|
||
)
|
||
|
||
if True:
|
||
|
||
@router.callback_query(F.data.startswith("payment:approve:"))
|
||
async def on_payment_approve(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(callback.from_user.id)
|
||
or settings.is_moderator(callback.from_user.id)
|
||
):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
order_uuid = (callback.data or "").split(":", maxsplit=2)[2]
|
||
try:
|
||
access = await payment_service.approve_order(order_uuid=order_uuid)
|
||
except (ValueError, RemnawaveApiError) as exc:
|
||
await callback.answer(str(exc), show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await send_issued_access_message(
|
||
callback.bot,
|
||
chat_id=access.telegram_id,
|
||
subscription_url=access.subscription_url,
|
||
expire_at=access.expire_at,
|
||
traffic_limit_bytes=access.traffic_limit_bytes,
|
||
is_renewal=access.is_renewal,
|
||
)
|
||
except TelegramForbiddenError:
|
||
if access.referral_bonus is not None:
|
||
try:
|
||
await notify_referral_bonus(
|
||
callback.bot,
|
||
inviter_telegram_id=access.referral_bonus.inviter_telegram_id,
|
||
bonus_days=access.referral_bonus.bonus_days,
|
||
status=access.referral_bonus.status,
|
||
expire_at=access.referral_bonus.expire_at,
|
||
total_applied_days=access.referral_bonus.total_applied_days,
|
||
)
|
||
except TelegramForbiddenError:
|
||
pass
|
||
await append_review_status(callback, status_text="✅ подтверждено, бот у пользователя недоступен")
|
||
await callback.answer(
|
||
"Доступ создан, но пользователь заблокировал бота.",
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
if access.referral_bonus is not None:
|
||
try:
|
||
await notify_referral_bonus(
|
||
callback.bot,
|
||
inviter_telegram_id=access.referral_bonus.inviter_telegram_id,
|
||
bonus_days=access.referral_bonus.bonus_days,
|
||
status=access.referral_bonus.status,
|
||
expire_at=access.referral_bonus.expire_at,
|
||
total_applied_days=access.referral_bonus.total_applied_days,
|
||
)
|
||
except TelegramForbiddenError:
|
||
pass
|
||
|
||
await append_review_status(callback, status_text="✅ подтверждено")
|
||
await callback.answer("Оплата подтверждена")
|
||
|
||
@router.callback_query(F.data.startswith("payment:reject:"))
|
||
async def on_payment_reject(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(callback.from_user.id)
|
||
or settings.is_moderator(callback.from_user.id)
|
||
):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
order_uuid = (callback.data or "").split(":", maxsplit=2)[2]
|
||
config = await get_runtime_config()
|
||
try:
|
||
order = await payment_service.reject_order(
|
||
order_uuid=order_uuid,
|
||
reason=config.payment_support_text.strip() or "Платёж отклонён",
|
||
)
|
||
except ValueError as exc:
|
||
await callback.answer(str(exc), show_alert=True)
|
||
return
|
||
|
||
try:
|
||
await send_branded(
|
||
callback.bot,
|
||
chat_id=order.telegram_id,
|
||
text=(
|
||
"<b>Платёж не подтверждён</b>\n\n"
|
||
"Проверьте реквизиты и чек. "
|
||
f"{html.escape(config.payment_support_text.strip() or 'Если это ошибка, обратитесь в поддержку.')}"
|
||
),
|
||
reply_markup=build_home_keyboard(),
|
||
)
|
||
except TelegramForbiddenError:
|
||
await append_review_status(callback, status_text="❌ отклонено, бот у пользователя недоступен")
|
||
await callback.answer(
|
||
"Не удалось уведомить пользователя: бот недоступен.",
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
await append_review_status(callback, status_text="❌ отклонено")
|
||
await callback.answer("Оплата отклонена")
|
||
|
||
if True:
|
||
|
||
@router.callback_query(F.data.startswith("ticket_admin:note:"))
|
||
async def on_support_ticket_note_start(callback: CallbackQuery, state: FSMContext) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(callback.from_user.id)
|
||
or settings.is_moderator(callback.from_user.id)
|
||
):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if config.support_ticket_chat_id is None or callback.message.chat.id != config.support_ticket_chat_id:
|
||
await callback.answer()
|
||
return
|
||
|
||
ticket_id_text = (callback.data or "").split(":")[-1]
|
||
if not ticket_id_text.isdigit():
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
|
||
if ticket is None:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
return
|
||
|
||
await state.set_state(SupportTicketAdminStates.waiting_for_note)
|
||
await state.update_data(support_ticket_id=ticket.id)
|
||
await callback.message.reply(
|
||
f"📝 Отправьте текст для тикета #{ticket.public_id} одним следующим сообщением.\n"
|
||
"Он будет добавлен к карточке тикета в чате поддержки.\n"
|
||
"Чтобы отменить действие, отправьте /cancel."
|
||
)
|
||
await callback.answer()
|
||
|
||
@router.callback_query(F.data.startswith("ticket_admin:close:"))
|
||
async def on_support_ticket_close(callback: CallbackQuery) -> None:
|
||
if callback.from_user is None or callback.message is None:
|
||
await callback.answer()
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(callback.from_user.id)
|
||
or settings.is_moderator(callback.from_user.id)
|
||
):
|
||
await callback.answer("Недостаточно прав", show_alert=True)
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if config.support_ticket_chat_id is None or callback.message.chat.id != config.support_ticket_chat_id:
|
||
await callback.answer()
|
||
return
|
||
|
||
ticket_id_text = (callback.data or "").split(":")[-1]
|
||
if not ticket_id_text.isdigit():
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=int(ticket_id_text))
|
||
if ticket is None:
|
||
await callback.answer("Тикет не найден", show_alert=True)
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
return
|
||
|
||
closed_ticket = await support_ticket_service.close_ticket(
|
||
ticket_id=ticket.id,
|
||
closed_by_telegram_id=callback.from_user.id,
|
||
)
|
||
if closed_ticket is None:
|
||
await callback.answer("Не удалось закрыть тикет", show_alert=True)
|
||
return
|
||
|
||
await refresh_support_ticket_message(callback.bot, ticket=closed_ticket)
|
||
|
||
try:
|
||
await send_branded(
|
||
callback.bot,
|
||
chat_id=closed_ticket.telegram_id,
|
||
text=render_user_ticket_closed_text(
|
||
ticket=closed_ticket,
|
||
closed_by_support=True,
|
||
),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=closed_ticket),
|
||
)
|
||
except TelegramForbiddenError:
|
||
await callback.answer(
|
||
"Тикет закрыт, но пользователь недоступен для уведомления.",
|
||
show_alert=True,
|
||
)
|
||
return
|
||
|
||
await callback.answer("Тикет закрыт")
|
||
|
||
@router.callback_query(F.data.startswith("ticket_admin:closed:"))
|
||
async def on_support_ticket_closed(callback: CallbackQuery) -> None:
|
||
await callback.answer("Тикет уже закрыт", show_alert=True)
|
||
|
||
@router.message(SupportTicketAdminStates.waiting_for_note)
|
||
async def on_support_ticket_note_message(message: Message, state: FSMContext) -> None:
|
||
if message.from_user is None:
|
||
await state.clear()
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(message.from_user.id)
|
||
or settings.is_moderator(message.from_user.id)
|
||
):
|
||
await state.clear()
|
||
await message.answer("Недостаточно прав.")
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if config.support_ticket_chat_id is None or message.chat.id != config.support_ticket_chat_id:
|
||
await message.answer("Добавьте текст к тикету прямо в чате поддержки.")
|
||
return
|
||
|
||
note_text = (message.text or message.caption or "").strip()
|
||
if not note_text:
|
||
await message.answer("Отправьте текст заметки одним сообщением.")
|
||
return
|
||
|
||
state_data = await state.get_data()
|
||
ticket_id = int(state_data.get("support_ticket_id") or 0)
|
||
if ticket_id <= 0:
|
||
await state.clear()
|
||
await message.answer("Тикет не найден. Нажмите кнопку ещё раз.")
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket(ticket_id=ticket_id)
|
||
if ticket is None:
|
||
await state.clear()
|
||
await message.answer("Тикет не найден.")
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await state.clear()
|
||
await message.answer("Тикет уже закрыт.")
|
||
return
|
||
|
||
note_entry = "\n".join(
|
||
[
|
||
f"[{format_datetime(message.date.replace(tzinfo=None))}] {message.from_user.full_name}",
|
||
note_text,
|
||
]
|
||
)
|
||
updated_ticket = await support_ticket_service.append_note(
|
||
ticket_id=ticket.id,
|
||
note_text=note_entry,
|
||
)
|
||
if updated_ticket is None:
|
||
await state.clear()
|
||
await message.answer("Не удалось добавить текст к тикету.")
|
||
return
|
||
|
||
await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
|
||
await state.clear()
|
||
await message.reply(f"Текст добавлен к тикету #{updated_ticket.public_id}.")
|
||
|
||
try:
|
||
await send_branded(
|
||
message.bot,
|
||
chat_id=updated_ticket.telegram_id,
|
||
text=render_user_ticket_support_note_text(
|
||
ticket=updated_ticket,
|
||
note_text=note_text,
|
||
),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=updated_ticket),
|
||
)
|
||
except TelegramForbiddenError:
|
||
await message.reply("Текст добавлен, но пользователь недоступен для уведомления.")
|
||
return
|
||
|
||
@router.message(F.reply_to_message)
|
||
async def on_support_reply(message: Message) -> None:
|
||
if message.reply_to_message is None or message.from_user is None:
|
||
return
|
||
|
||
if not (
|
||
settings.is_admin(message.from_user.id)
|
||
or settings.is_moderator(message.from_user.id)
|
||
):
|
||
await message.reply("Недостаточно прав для ответа на тикеты.")
|
||
return
|
||
|
||
config = await get_runtime_config()
|
||
if config.support_ticket_chat_id is None or message.chat.id != config.support_ticket_chat_id:
|
||
return
|
||
|
||
reply_text = (message.text or message.caption or "").strip()
|
||
if not reply_text:
|
||
return
|
||
|
||
ticket = await support_ticket_service.get_ticket_by_support_message(
|
||
support_chat_id=message.chat.id,
|
||
support_message_id=message.reply_to_message.message_id,
|
||
)
|
||
if ticket is None:
|
||
return
|
||
if ticket.status == CLOSED_SUPPORT_TICKET_STATUS:
|
||
await message.reply(f"Тикет #{ticket.public_id} уже закрыт.")
|
||
return
|
||
|
||
try:
|
||
await send_branded(
|
||
message.bot,
|
||
chat_id=ticket.telegram_id,
|
||
text=(
|
||
f"<b>🆘 Ответ по тикету #{ticket.public_id}</b>\n\n"
|
||
f"{html.escape(reply_text)}\n\n"
|
||
"<i>Если вопрос решён, закройте тикет. Если нужно что-то уточнить, добавьте текст кнопкой ниже.</i>"
|
||
),
|
||
reply_markup=build_user_support_ticket_keyboard(ticket=ticket),
|
||
)
|
||
except TelegramForbiddenError:
|
||
await message.reply(
|
||
"Не удалось доставить ответ: пользователь заблокировал бота или недоступен."
|
||
)
|
||
return
|
||
|
||
updated_ticket = await support_ticket_service.mark_answered(ticket_id=ticket.id)
|
||
if updated_ticket is not None:
|
||
await refresh_support_ticket_message(message.bot, ticket=updated_ticket)
|
||
await message.reply(f"Ответ по тикету #{ticket.public_id} отправлен пользователю.")
|
||
|
||
@router.message(Command("help"))
|
||
async def cmd_help(message: Message) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
await answer_branded(
|
||
message,
|
||
build_help_text(is_admin=settings.is_admin(message.from_user.id))
|
||
)
|
||
|
||
@router.message(Command("sync"))
|
||
async def cmd_sync(message: Message) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
try:
|
||
users = await sync_service.sync_users_for_telegram(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
)
|
||
except RemnawaveApiError as exc:
|
||
await message.answer(
|
||
"Не удалось обновить данные из Remnawave.\n"
|
||
f"Ошибка: <code>{html.escape(exc.message)}</code>"
|
||
)
|
||
return
|
||
|
||
if not users:
|
||
await message.answer(
|
||
"Синхронизация завершена, но связанных аккаунтов не найдено."
|
||
)
|
||
return
|
||
|
||
context = await load_panel_context(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
sync_remote=False,
|
||
)
|
||
await show_panel(message, context=context, section=PANEL_SECTION_HOME)
|
||
|
||
@router.message(Command("link"))
|
||
async def cmd_link(message: Message, command: CommandObject) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
short_uuid = (command.args or "").strip()
|
||
if not short_uuid:
|
||
await message.answer(
|
||
"Укажите <code>short_uuid</code> из вашей ссылки подписки.\n"
|
||
"Пример значения: <code>AbCdEF12</code>"
|
||
)
|
||
return
|
||
|
||
try:
|
||
await sync_service.link_user_by_short_uuid(
|
||
short_uuid=short_uuid,
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
)
|
||
except ValueError as exc:
|
||
await message.answer(html.escape(str(exc)))
|
||
return
|
||
except RemnawaveApiError as exc:
|
||
await message.answer(
|
||
"Не удалось привязать аккаунт.\n"
|
||
f"Ошибка API: <code>{html.escape(exc.message)}</code>"
|
||
)
|
||
return
|
||
|
||
context = await load_panel_context(
|
||
telegram_id=message.from_user.id,
|
||
username=message.from_user.username,
|
||
first_name=message.from_user.first_name,
|
||
last_name=message.from_user.last_name,
|
||
language_code=message.from_user.language_code,
|
||
sync_remote=False,
|
||
)
|
||
await message.answer("Аккаунт успешно привязан к вашему Telegram.")
|
||
await show_panel(message, context=context, section=PANEL_SECTION_SUBSCRIPTION)
|
||
|
||
@router.message(Command("lookup"))
|
||
async def cmd_lookup(message: Message, command: CommandObject) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
if not settings.is_admin(message.from_user.id):
|
||
await message.answer("Эта команда доступна только администраторам.")
|
||
return
|
||
|
||
identifier = (command.args or "").strip()
|
||
if not identifier:
|
||
await message.answer(
|
||
"Укажите идентификатор пользователя.\n"
|
||
"Поддерживаются: uuid, id, username, short_uuid.\n"
|
||
"Пример: <code>/lookup 9b488e20-2a1f-4d69-b04d-c7b7f435f1a5</code>"
|
||
)
|
||
return
|
||
|
||
await send_lookup_result(message, identifier=identifier)
|
||
|
||
@router.message(Command("sync_all"))
|
||
async def cmd_sync_all(message: Message) -> None:
|
||
if not await ensure_sender(message):
|
||
return
|
||
|
||
if not settings.is_admin(message.from_user.id):
|
||
await message.answer("Эта команда доступна только администраторам.")
|
||
return
|
||
|
||
await run_sync_all(message)
|
||
|
||
return router
|