2500 lines
99 KiB
Python
2500 lines
99 KiB
Python
from __future__ import annotations
|
||
|
||
import html
|
||
import secrets
|
||
from pathlib import Path
|
||
from urllib.parse import quote_plus
|
||
|
||
from aiogram import Bot, F, Router
|
||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||
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, 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 SupportTicketService
|
||
from app.services.sync_service import (
|
||
AdminTelegramUserProfile,
|
||
AdminTelegramUsersPage,
|
||
CachedUserView,
|
||
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()
|
||
|
||
|
||
class ReferralCodeStates(StatesGroup):
|
||
waiting_for_code = State()
|
||
|
||
|
||
class ManualPaymentStates(StatesGroup):
|
||
waiting_for_receipt = State()
|
||
|
||
|
||
class AdminLookupStates(StatesGroup):
|
||
waiting_for_identifier = State()
|
||
|
||
|
||
class AdminConfigStates(StatesGroup):
|
||
waiting_for_value = 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}"
|
||
|
||
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,
|
||
ReferralCodeStates.waiting_for_code.state,
|
||
ManualPaymentStates.waiting_for_receipt.state,
|
||
AdminLookupStates.waiting_for_identifier.state,
|
||
AdminConfigStates.waiting_for_value.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)
|
||
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 = plans[0] if plans else None
|
||
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=(
|
||
referral_summary.applied_referral_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(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=bool(primary_plan and primary_plan.is_ready),
|
||
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 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 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 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_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 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>"])
|
||
if not plans:
|
||
lines.append("<blockquote>Покупка пока не настроена.</blockquote>")
|
||
return "\n".join(lines)
|
||
|
||
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_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",
|
||
)
|
||
for plan in plans:
|
||
price_text = f"{plan.duration_days} дней · {plan.amount_rub} ₽"
|
||
builder.button(
|
||
text=price_text,
|
||
callback_data=payment_callback("select", plan.code),
|
||
)
|
||
builder.button(
|
||
text="🏠 Главное меню",
|
||
callback_data=f"panel:{PANEL_SECTION_HOME}",
|
||
)
|
||
builder.adjust(1)
|
||
return builder.as_markup()
|
||
|
||
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 = await payment_service.get_available_plans(telegram_id=telegram_id)
|
||
has_existing_access = bool(await sync_service.get_cached_users_for_telegram(telegram_id))
|
||
referral_code = (
|
||
(await sync_service.get_referral_summary(telegram_id)).applied_referral_code
|
||
if config.referral_enabled
|
||
else ""
|
||
)
|
||
await answer_branded(
|
||
message,
|
||
render_payment_menu_text(
|
||
plans=plans,
|
||
referral_code=referral_code,
|
||
referral_enabled=config.referral_enabled,
|
||
referral_discount_percent=config.referral_discount_percent,
|
||
has_existing_access=has_existing_access,
|
||
),
|
||
reply_markup=build_payment_menu_keyboard(
|
||
plans=plans,
|
||
referral_code=referral_code,
|
||
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 = await payment_service.get_available_plans(telegram_id=callback.from_user.id)
|
||
has_existing_access = bool(
|
||
await sync_service.get_cached_users_for_telegram(callback.from_user.id)
|
||
)
|
||
referral_code = (
|
||
(await sync_service.get_referral_summary(callback.from_user.id)).applied_referral_code
|
||
if config.referral_enabled
|
||
else ""
|
||
)
|
||
await edit_message_content(
|
||
callback,
|
||
text=render_payment_menu_text(
|
||
plans=plans,
|
||
referral_code=referral_code,
|
||
referral_enabled=config.referral_enabled,
|
||
referral_discount_percent=config.referral_discount_percent,
|
||
has_existing_access=has_existing_access,
|
||
),
|
||
reply_markup=build_payment_menu_keyboard(
|
||
plans=plans,
|
||
referral_code=referral_code,
|
||
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>ID</b>: <code>{ticket_id}</code>",
|
||
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"<b>Создан</b>: {format_datetime(message.date.replace(tzinfo=None))}",
|
||
"",
|
||
"<b>Сообщение</b>",
|
||
f"<blockquote>{html.escape(raw_message_text)}</blockquote>",
|
||
"",
|
||
"<i>Ответьте реплаем на это сообщение, чтобы бот доставил ответ пользователю.</i>",
|
||
]
|
||
)
|
||
|
||
support_message = await message.bot.send_message(
|
||
chat_id=support_chat_id,
|
||
message_thread_id=config.support_ticket_message_thread_id,
|
||
text=ticket_text,
|
||
)
|
||
|
||
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 state.clear()
|
||
await answer_branded(
|
||
message,
|
||
f"🎫 Тикет <b>#{ticket_id}</b> отправлен в поддержку.\n"
|
||
"Ответ придёт сюда, как только его обработают."
|
||
)
|
||
|
||
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.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: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(ReferralCodeStates.waiting_for_code, Command("cancel"))
|
||
@router.message(AdminConfigStates.waiting_for_value, 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("Чек уже отправлен на проверку.")
|
||
return
|
||
if order.status == FULFILLED_PAYMENT_STATUS:
|
||
await state.clear()
|
||
await message.answer("Этот заказ уже подтверждён.")
|
||
return
|
||
if order.status == REJECTED_PAYMENT_STATUS:
|
||
await state.clear()
|
||
await message.answer("Этот заказ уже отклонён. Создайте новый платёж.")
|
||
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"
|
||
"После подтверждения администратором доступ придёт сюда."
|
||
)
|
||
|
||
@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("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(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()
|
||
await message.answer(
|
||
f"✅ Реферальный код <code>{applied_code}</code> сохранён.\n"
|
||
f"Скидка <b>{max(config.referral_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: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: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.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(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(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 'Если это ошибка, обратитесь в поддержку.')}"
|
||
),
|
||
)
|
||
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.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
|
||
|
||
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)}"
|
||
),
|
||
)
|
||
except TelegramForbiddenError:
|
||
await message.reply(
|
||
"Не удалось доставить ответ: пользователь заблокировал бота или недоступен."
|
||
)
|
||
return
|
||
|
||
await support_ticket_service.mark_answered(ticket_id=ticket.id)
|
||
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
|