This commit is contained in:
2026-07-09 16:45:17 +03:00
parent 5c7a9710f3
commit da24ed8ba7
23 changed files with 1542 additions and 358 deletions

View File

@@ -528,6 +528,38 @@ def build_router(
builder.adjust(1)
return builder.as_markup()
def build_admin_broadcast_mode_keyboard():
builder = InlineKeyboardBuilder()
builder.button(
text="📣 Уведомление",
callback_data="admin:broadcast_mode:notice",
)
builder.button(
text="✅ Опрос Да/Нет",
callback_data="admin:broadcast_mode:poll",
)
builder.button(
text="🛠 Админ-панель",
callback_data=f"panel:{PANEL_SECTION_ADMIN}",
)
builder.adjust(1)
return builder.as_markup()
def build_broadcast_poll_keyboard(*, poll_id: int, selected_answer: str | None = None):
yes_text = "✅ Да" if selected_answer == "yes" else "Да"
no_text = "✅ Нет" if selected_answer == "no" else "Нет"
yes_callback = (
f"broadcast_poll:yes:{poll_id}" if poll_id > 0 else "admin:broadcast_preview_answer"
)
no_callback = (
f"broadcast_poll:no:{poll_id}" if poll_id > 0 else "admin:broadcast_preview_answer"
)
builder = InlineKeyboardBuilder()
builder.button(text=yes_text, callback_data=yes_callback)
builder.button(text=no_text, callback_data=no_callback)
builder.adjust(2)
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 = (
@@ -537,33 +569,90 @@ def build_router(
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:
def render_broadcast_text(
*,
brand_name: str,
body_html: str,
mode: str,
) -> str:
title = "Опрос" if mode == "poll" else "Уведомление"
lines = [
f"<b>📣 {html.escape(brand_name)} · {title}</b>",
"",
f"<blockquote>{body_html}</blockquote>",
]
if mode == "poll":
lines.extend(["", "<i>Выберите один из вариантов ниже.</i>"])
return "\n".join(lines)
async def show_admin_broadcast_preview(
message: Message,
*,
body_html: str,
mode: str,
state: FSMContext,
) -> None:
config = await get_runtime_config()
text_html = render_broadcast_text(
brand_name=config.bot_brand_name,
body_html=body_html,
mode=mode,
)
recipient_count = len(await sync_service.get_broadcast_telegram_ids())
await state.update_data(broadcast_text_html=text_html)
await state.update_data(
broadcast_body_html=body_html,
broadcast_text_html=text_html,
broadcast_mode=mode,
)
await message.answer(
"📣 Предпросмотр рассылки.\n\n"
f"Получателей: <b>{recipient_count}</b>\n"
"Если всё верно, нажмите кнопку подтверждения ниже."
)
await message.answer(
text_html,
reply_markup=build_admin_broadcast_preview_keyboard(),
)
if mode == "poll":
await message.answer(text_html, reply_markup=build_broadcast_poll_keyboard(poll_id=0))
await message.answer(
"У пользователей под этим сообщением будут кнопки <b>Да</b> и <b>Нет</b>.",
reply_markup=build_admin_broadcast_preview_keyboard(),
)
return
async def send_admin_broadcast(bot_instance: Bot, *, text_html: str) -> tuple[int, int, int]:
await message.answer(text_html, reply_markup=build_admin_broadcast_preview_keyboard())
async def send_admin_broadcast(
bot_instance: Bot,
*,
text_html: str,
poll_id: int | None = None,
) -> tuple[int, int, int]:
telegram_ids = await sync_service.get_broadcast_telegram_ids()
sent_count = 0
blocked_count = 0
failed_count = 0
reply_markup = (
build_broadcast_poll_keyboard(poll_id=poll_id)
if poll_id is not None
else None
)
for telegram_id in telegram_ids:
try:
await bot_instance.send_message(chat_id=telegram_id, text=text_html)
await send_branded(
bot_instance,
chat_id=telegram_id,
text=text_html,
reply_markup=reply_markup,
)
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)
await send_branded(
bot_instance,
chat_id=telegram_id,
text=text_html,
reply_markup=reply_markup,
)
sent_count += 1
except TelegramForbiddenError:
blocked_count += 1
@@ -582,6 +671,48 @@ def build_router(
return sent_count, blocked_count, failed_count
def broadcast_answer_label(answer: str) -> str:
return "Да" if answer == "yes" else "Нет"
async def notify_broadcast_poll_answer(
bot_instance: Bot,
*,
poll_id: int,
question_html: str,
answer: str,
previous_answer: str | None,
telegram_id: int,
username: str | None,
full_name: str,
) -> None:
config = await get_runtime_config()
if config.broadcast_results_chat_id is None:
return
username_text = f"@{username}" if username else ""
changed_text = (
f"\nПредыдущий ответ: <b>{broadcast_answer_label(previous_answer)}</b>"
if previous_answer and previous_answer != answer
else ""
)
await bot_instance.send_message(
chat_id=config.broadcast_results_chat_id,
message_thread_id=config.broadcast_results_message_thread_id,
text=(
"<b>📬 Ответ на опрос рассылки</b>\n\n"
f"Опрос: <code>{poll_id}</code>\n"
f"Ответ: <b>{broadcast_answer_label(answer)}</b>{changed_text}\n\n"
"<b>Пользователь</b>\n"
"<blockquote>"
f"ID: <code>{telegram_id}</code>\n"
f"Имя: <b>{html.escape(full_name or str(telegram_id))}</b>\n"
f"Username: <b>{html.escape(username_text)}</b>"
"</blockquote>\n\n"
"<b>Вопрос</b>\n"
f"<blockquote>{question_html}</blockquote>"
),
)
def admin_settings_callback(action: str, value: str | None = None) -> str:
return f"admin:{action}:{value}" if value else f"admin:{action}"
@@ -1887,6 +2018,7 @@ def build_router(
expire_at=expire_at,
traffic_limit_bytes=traffic_limit_bytes,
brand_name=config.bot_brand_name,
success_text=config.payment_success_text,
is_renewal=is_renewal,
)
reply_markup = build_issued_access_keyboard(subscription_url=subscription_url)
@@ -2208,11 +2340,30 @@ def build_router(
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)
status_suffix = f"\n\n<b>Статус</b>: {status_text}"
is_caption = bool(callback.message.photo or callback.message.document)
max_length = 1024 if is_caption else 4096
updated_text = f"{source_text}{status_suffix}"
if len(updated_text) > max_length:
# Truncate the original text so the status fits within the limit
available = max_length - len(status_suffix) - 3 # 3 for "..."
updated_text = f"{source_text[:max(0, available)]}...{status_suffix}"
try:
if is_caption:
await callback.message.edit_caption(caption=updated_text, reply_markup=None)
else:
await callback.message.edit_text(updated_text, reply_markup=None)
except TelegramBadRequest:
# Fallback: just remove the keyboard without appending status
try:
if is_caption:
await callback.message.edit_reply_markup(reply_markup=None)
else:
await callback.message.edit_reply_markup(reply_markup=None)
except TelegramBadRequest:
pass
@router.message(CommandStart())
@router.message(Command("menu"))
@@ -2279,6 +2430,7 @@ def build_router(
text = (command.args or "").strip()
if not text:
await state.set_state(AdminBroadcastStates.waiting_for_text)
await state.update_data(broadcast_mode="notice")
await message.answer(
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
@@ -2291,7 +2443,44 @@ def build_router(
return
await state.set_state(AdminBroadcastStates.waiting_for_text)
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
await show_admin_broadcast_preview(
message,
body_html=text_html,
mode="notice",
state=state,
)
@router.message(Command("poll"))
async def cmd_poll(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 state.update_data(broadcast_mode="poll")
await message.answer(
"✅ Отправьте текст вопроса для опроса Да/Нет следующим сообщением.\n\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
)
return
text_html = html.escape(text)
if len(text_html) > 3800:
await message.answer("Текст слишком длинный. Максимум — около 3800 символов.")
return
await state.set_state(AdminBroadcastStates.waiting_for_text)
await show_admin_broadcast_preview(
message,
body_html=text_html,
mode="poll",
state=state,
)
@router.callback_query(F.data == "guides:menu")
async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None:
@@ -2851,14 +3040,113 @@ def build_router(
return
await state.clear()
await state.set_state(AdminBroadcastStates.waiting_for_text)
await callback.message.answer(
"📣 Отправьте текст рассылки следующим сообщением.\n\n"
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
"📣 Выберите тип рассылки.",
reply_markup=build_admin_broadcast_mode_keyboard(),
)
await callback.answer()
@router.callback_query(F.data.startswith("admin:broadcast_mode:"))
async def on_admin_broadcast_mode(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
mode = (callback.data or "").split(":")[-1]
if mode not in {"notice", "poll"}:
await callback.answer("Тип рассылки не найден", show_alert=True)
return
await state.clear()
await state.set_state(AdminBroadcastStates.waiting_for_text)
await state.update_data(broadcast_mode=mode)
if mode == "poll":
prompt = (
"✅ Отправьте текст вопроса для опроса Да/Нет следующим сообщением.\n\n"
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
)
else:
prompt = (
"📣 Отправьте текст уведомления следующим сообщением.\n\n"
"Бот красиво оформит текст, покажет предпросмотр и попросит подтвердить отправку.\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>."
)
await callback.message.answer(prompt)
await callback.answer()
@router.callback_query(F.data == "admin:broadcast_preview_answer")
async def on_admin_broadcast_preview_answer(callback: CallbackQuery) -> None:
await callback.answer("Это предпросмотр. Пользователи смогут выбрать Да или Нет.", show_alert=True)
@router.callback_query(F.data.startswith("broadcast_poll:"))
async def on_broadcast_poll_answer(callback: CallbackQuery) -> None:
if callback.from_user is None:
await callback.answer()
return
parts = (callback.data or "").split(":")
if len(parts) != 3:
await callback.answer("Опрос не найден", show_alert=True)
return
answer = parts[1]
try:
poll_id = int(parts[2])
except ValueError:
await callback.answer("Опрос не найден", show_alert=True)
return
result = await sync_service.record_broadcast_poll_answer(
poll_id=poll_id,
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,
answer=answer,
)
if result is None:
await callback.answer("Опрос уже недоступен", show_alert=True)
return
if callback.message is not None:
try:
await callback.message.edit_reply_markup(
reply_markup=build_broadcast_poll_keyboard(
poll_id=poll_id,
selected_answer=result.answer.answer,
)
)
except TelegramBadRequest as exc:
if "message is not modified" not in str(exc).lower():
raise
full_name = " ".join(
part for part in [callback.from_user.first_name, callback.from_user.last_name] if part
).strip()
try:
await notify_broadcast_poll_answer(
callback.bot,
poll_id=poll_id,
question_html=result.poll.question_text,
answer=result.answer.answer,
previous_answer=result.previous_answer,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
full_name=full_name,
)
except Exception:
pass
await callback.answer(
f"Спасибо, ответ «{broadcast_answer_label(result.answer.answer)}» принят.",
show_alert=True,
)
@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:
@@ -2884,6 +3172,8 @@ def build_router(
state_data = await state.get_data()
text_html = str(state_data.get("broadcast_text_html") or "").strip()
body_html = str(state_data.get("broadcast_body_html") or "").strip()
mode = str(state_data.get("broadcast_mode") or "notice").strip()
if not text_html:
await state.clear()
await callback.answer("Текст рассылки не найден", show_alert=True)
@@ -2894,9 +3184,18 @@ def build_router(
if callback.message is not None:
await callback.message.answer("📣 Рассылка запущена. Пришлю итог после отправки.")
poll_id: int | None = None
if mode == "poll":
poll = await sync_service.create_broadcast_poll(
question_text=body_html or text_html,
created_by_telegram_id=callback.from_user.id,
)
poll_id = poll.id
sent_count, blocked_count, failed_count = await send_admin_broadcast(
callback.bot,
text_html=text_html,
poll_id=poll_id,
)
if callback.message is not None:
await callback.message.answer(
@@ -2904,6 +3203,7 @@ def build_router(
f"Отправлено: <b>{sent_count}</b>\n"
f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n"
f"Ошибок отправки: <b>{failed_count}</b>"
+ (f"\nID опроса: <code>{poll_id}</code>" if poll_id is not None else "")
)
@router.callback_query(F.data == "admin:sync_all")
@@ -3281,6 +3581,7 @@ def build_router(
await callback.answer("Недостаточно прав", show_alert=True)
return
await callback.answer()
await clear_interactive_states(state)
raw_page = (callback.data or "").split(":", maxsplit=2)[2]
try:
@@ -3294,7 +3595,6 @@ def build_router(
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:
@@ -3318,9 +3618,10 @@ def build_router(
await callback.answer("Некорректный пользователь", show_alert=True)
return
await callback.answer()
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id)
if profile is None:
await callback.answer("Пользователь не найден", show_alert=True)
await callback.message.answer("Пользователь не найден.")
return
await show_branded_view(
@@ -3332,7 +3633,6 @@ def build_router(
page=page,
),
)
await callback.answer()
# ── Admin: Block / Unblock user ──────────────────────────────────
@@ -4080,14 +4380,21 @@ def build_router(
return
raw_text, text_html = get_message_html_text(message)
state_data = await state.get_data()
mode = str(state_data.get("broadcast_mode") or "notice")
if not raw_text:
await message.answer("Отправьте текст рассылки одним сообщением.")
return
if len(text_html) > 4000:
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.")
if len(text_html) > 3800:
await message.answer("Текст слишком длинный. Максимум — около 3800 символов.")
return
await show_admin_broadcast_preview(message, text_html=text_html, state=state)
await show_admin_broadcast_preview(
message,
body_html=text_html,
mode=mode if mode in {"notice", "poll"} else "notice",
state=state,
)
@router.message(AdminConfigStates.waiting_for_value)
async def on_admin_setting_value(message: Message, state: FSMContext) -> None:
@@ -4257,6 +4564,235 @@ def build_router(
await append_review_status(callback, status_text="❌ отклонено")
await callback.answer("Оплата отклонена")
if True:
def _format_device_label(device) -> str:
parts = []
if device.device_model:
parts.append(device.device_model)
if device.platform:
parts.append(device.platform)
if device.os_version:
parts.append(device.os_version)
if parts:
return " · ".join(parts)
if device.user_agent:
ua = device.user_agent
return ua[:40] + "..." if len(ua) > 40 else ua
return device.hwid[:16] + "..."
def _build_devices_list_text(
devices,
*,
user_uuid_short: str,
hwid_device_limit: int | None,
) -> str:
limit_text = str(hwid_device_limit) if hwid_device_limit else ""
lines = [
"<b>📱 Подключённые устройства</b>",
f"<blockquote>Подключено: <b>{len(devices)}</b> / <b>{limit_text}</b></blockquote>",
]
if not devices:
lines.extend([
"",
"<i>Нет подключённых устройств.</i>",
])
else:
for idx, device in enumerate(devices, start=1):
label = html.escape(_format_device_label(device))
connected = format_datetime(device.created_at)
lines.append(
f"\n{idx}. <b>{label}</b>"
f"\n<blockquote>Подключено: {connected}</blockquote>"
)
lines.extend([
"",
"<i>Отключите устройство, чтобы освободить слот для нового подключения.</i>",
])
return "\n".join(lines)
def _build_devices_keyboard(devices, *, user_uuid: str):
builder = InlineKeyboardBuilder()
for device in devices:
label = _format_device_label(device)
short_hwid = device.hwid[:12]
builder.button(
text=f"{label[:30]}",
callback_data=f"devices:remove:{user_uuid}:{short_hwid}",
)
if len(devices) > 1:
builder.button(
text="🗑 Отключить все устройства",
callback_data=f"devices:remove_all:{user_uuid}",
)
builder.button(
text="↻ Обновить",
callback_data="devices:list",
)
builder.button(
text="🏠 Главное меню",
callback_data=f"panel:{PANEL_SECTION_HOME}",
)
builder.adjust(1)
return builder.as_markup()
@router.callback_query(F.data == "devices:list")
async def on_devices_list(callback: CallbackQuery) -> None:
if callback.from_user is None:
await callback.answer()
return
telegram_id = callback.from_user.id
cached_users = await sync_service.get_cached_users_for_telegram(telegram_id)
if not cached_users:
await callback.answer("У вас нет активной подписки.", show_alert=True)
return
primary = None
for user in cached_users:
if user.record.status.upper() == "ACTIVE":
primary = user
break
if primary is None:
primary = cached_users[0]
user_uuid = str(primary.record.rw_uuid)
try:
hwid_response = await payment_service._remnawave_client.get_user_hwid_devices(user_uuid)
except RemnawaveApiError as exc:
await callback.answer(f"Ошибка API: {exc.message}", show_alert=True)
return
text = _build_devices_list_text(
hwid_response.devices,
user_uuid_short=primary.record.short_uuid,
hwid_device_limit=primary.record.hwid_device_limit,
)
keyboard = _build_devices_keyboard(
hwid_response.devices,
user_uuid=user_uuid,
)
await show_text_view(callback, text=text, reply_markup=keyboard)
await callback.answer()
@router.callback_query(F.data.startswith("devices:remove_all:"))
async def on_devices_remove_all(callback: CallbackQuery) -> None:
if callback.from_user is None:
await callback.answer()
return
telegram_id = callback.from_user.id
parts = (callback.data or "").split(":", maxsplit=2)
if len(parts) < 3:
await callback.answer("Некорректные данные.", show_alert=True)
return
user_uuid = parts[2]
# Verify the user_uuid belongs to the caller
cached_users = await sync_service.get_cached_users_for_telegram(telegram_id)
matching = any(str(u.record.rw_uuid) == user_uuid for u in cached_users)
if not matching:
await callback.answer("Доступ запрещён.", show_alert=True)
return
try:
await payment_service._remnawave_client.delete_all_user_hwid_devices(user_uuid)
except RemnawaveApiError as exc:
await callback.answer(f"Ошибка: {exc.message}", show_alert=True)
return
primary = next((u for u in cached_users if str(u.record.rw_uuid) == user_uuid), cached_users[0])
try:
hwid_response = await payment_service._remnawave_client.get_user_hwid_devices(user_uuid)
except RemnawaveApiError:
hwid_response = type("R", (), {"devices": [], "total": 0})()
text = _build_devices_list_text(
hwid_response.devices,
user_uuid_short=primary.record.short_uuid,
hwid_device_limit=primary.record.hwid_device_limit,
)
keyboard = _build_devices_keyboard(
hwid_response.devices,
user_uuid=user_uuid,
)
await show_text_view(callback, text=text, reply_markup=keyboard)
await callback.answer("Все устройства отключены")
@router.callback_query(F.data.startswith("devices:remove:"))
async def on_devices_remove(callback: CallbackQuery) -> None:
if callback.from_user is None:
await callback.answer()
return
telegram_id = callback.from_user.id
parts = (callback.data or "").split(":", maxsplit=3)
if len(parts) < 4:
await callback.answer("Некорректные данные.", show_alert=True)
return
user_uuid = parts[2]
short_hwid = parts[3]
# Verify the user_uuid belongs to the caller
cached_users = await sync_service.get_cached_users_for_telegram(telegram_id)
matching = any(str(u.record.rw_uuid) == user_uuid for u in cached_users)
if not matching:
await callback.answer("Доступ запрещён.", show_alert=True)
return
# Find the full HWID matching the short prefix
try:
current_devices = await payment_service._remnawave_client.get_user_hwid_devices(user_uuid)
except RemnawaveApiError as exc:
await callback.answer(f"Ошибка: {exc.message}", show_alert=True)
return
target_device = None
for device in current_devices.devices:
if device.hwid.startswith(short_hwid):
target_device = device
break
if target_device is None:
await callback.answer("Устройство не найдено или уже отключено.", show_alert=True)
return
try:
await payment_service._remnawave_client.delete_user_hwid_device(
user_uuid=user_uuid,
hwid=target_device.hwid,
)
except RemnawaveApiError as exc:
await callback.answer(f"Ошибка: {exc.message}", show_alert=True)
return
primary = next((u for u in cached_users if str(u.record.rw_uuid) == user_uuid), cached_users[0])
try:
hwid_response = await payment_service._remnawave_client.get_user_hwid_devices(user_uuid)
except RemnawaveApiError:
hwid_response = type("R", (), {"devices": [], "total": 0})()
text = _build_devices_list_text(
hwid_response.devices,
user_uuid_short=primary.record.short_uuid,
hwid_device_limit=primary.record.hwid_device_limit,
)
keyboard = _build_devices_keyboard(
hwid_response.devices,
user_uuid=user_uuid,
)
device_label = _format_device_label(target_device)
await show_text_view(callback, text=text, reply_markup=keyboard)
await callback.answer(f"Устройство «{device_label[:30]}» отключено")
if True:
@router.callback_query(F.data.startswith("ticket_admin:note:"))

View File

@@ -145,11 +145,16 @@ def build_panel_keyboard(
text="💳 Подписка",
callback_data=_payment_callback("menu"),
)
if _active_users_count(context.users) > 0:
has_active = _active_users_count(context.users) > 0
if has_active:
builder.button(
text="📚 Гайды",
callback_data="guides:menu",
)
builder.button(
text="📱 Устройства",
callback_data="devices:list",
)
if context.is_admin:
builder.button(
text="🛠 Админ-панель",
@@ -172,22 +177,40 @@ def build_panel_keyboard(
callback_data=_callback(PANEL_SECTION_TERMS),
)
if has_sub_link:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1, 1)
if has_active:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 2, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 2, 1, 1, 1)
elif context.referral_enabled:
builder.adjust(1, 1, 2, 2, 1)
else:
builder.adjust(1, 1, 2, 1, 1)
else:
if context.referral_enabled:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1, 1)
elif context.referral_enabled:
builder.adjust(1, 1, 2, 1)
else:
builder.adjust(1, 1, 1, 1)
else:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1)
if has_active:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 2, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 2, 1, 1, 1)
elif context.referral_enabled:
builder.adjust(1, 2, 2, 1)
else:
builder.adjust(1, 2, 1, 1)
else:
if context.referral_enabled:
if context.is_admin and context.referral_enabled:
builder.adjust(1, 1, 2, 1)
elif context.is_admin:
builder.adjust(1, 1, 1, 1)
elif context.referral_enabled:
builder.adjust(1, 2, 1)
else:
builder.adjust(1, 1, 1)

View File

@@ -108,6 +108,10 @@ class Settings(BaseSettings):
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
validation_alias="PAYMENT_TRANSFER_TEXT",
)
payment_success_text: str = Field(
default="Доступ выдан. Сохраните ссылку и используйте её для подключения.",
validation_alias="PAYMENT_SUCCESS_TEXT",
)
payment_review_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
payment_review_chat_id_raw: str = Field(
default="",
@@ -117,6 +121,18 @@ class Settings(BaseSettings):
default=0,
validation_alias="PAYMENT_REVIEW_THREAD_ID",
)
broadcast_results_link: str = Field(
default="https://t.me/c/3646494169/67/68",
validation_alias="BROADCAST_RESULTS_LINK",
)
broadcast_results_chat_id_raw: str = Field(
default="",
validation_alias="BROADCAST_RESULTS_CHAT_ID",
)
broadcast_results_thread_id: int = Field(
default=0,
validation_alias="BROADCAST_RESULTS_THREAD_ID",
)
referral_discount_percent: int = Field(
default=5,
validation_alias="REFERRAL_DISCOUNT_PERCENT",
@@ -241,6 +257,22 @@ class Settings(BaseSettings):
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
return parsed_thread_id
@cached_property
def broadcast_results_chat_id(self) -> int | None:
if self.broadcast_results_chat_id_raw.strip():
return int(self.broadcast_results_chat_id_raw.strip())
parsed_chat_id, _ = self._parse_private_topic_link(self.broadcast_results_link)
return parsed_chat_id
@cached_property
def broadcast_results_message_thread_id(self) -> int | None:
if self.broadcast_results_thread_id > 0:
return self.broadcast_results_thread_id
_, parsed_thread_id = self._parse_private_topic_link(self.broadcast_results_link)
return parsed_thread_id
@staticmethod
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
cleaned = link.strip()

View File

@@ -266,6 +266,42 @@ class SubscriptionNotification(Base):
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
class BroadcastPoll(Base):
__tablename__ = "broadcast_polls"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
question_text: Mapped[str] = mapped_column(Text, nullable=False)
created_by_telegram_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class BroadcastPollAnswer(Base):
__tablename__ = "broadcast_poll_answers"
__table_args__ = (
Index(
"ix_broadcast_poll_answers_unique_user",
"poll_id",
"telegram_id",
unique=True,
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
poll_id: Mapped[int] = mapped_column(
ForeignKey("broadcast_polls.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
telegram_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True)
username: Mapped[str | None] = mapped_column(String(64), nullable=True)
first_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
answer: Mapped[str] = mapped_column(String(8), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow, onupdate=utcnow)
class PaymentOrder(Base):
__tablename__ = "payment_orders"

View File

@@ -73,3 +73,19 @@ class SubscriptionRequestRecord(RemnawaveBaseModel):
class SubscriptionRequestHistory(RemnawaveBaseModel):
total: int
records: list[SubscriptionRequestRecord]
class HwidDevice(RemnawaveBaseModel):
hwid: str
user_uuid: UUID = Field(alias="userUuid")
platform: str | None = None
os_version: str | None = Field(default=None, alias="osVersion")
device_model: str | None = Field(default=None, alias="deviceModel")
user_agent: str | None = Field(default=None, alias="userAgent")
created_at: datetime = Field(alias="createdAt")
updated_at: datetime = Field(alias="updatedAt")
class HwidDevicesResponse(RemnawaveBaseModel):
total: int
devices: list[HwidDevice]

View File

@@ -28,6 +28,7 @@ class BotConfigSnapshot:
bot_terms_url: str
bot_support_ticket_link: str
payment_review_link: str
broadcast_results_link: str
bot_start_image_enabled: bool
bot_start_image_path: str
referral_enabled: bool
@@ -36,6 +37,7 @@ class BotConfigSnapshot:
payment_plans_raw: str
payment_product_plans_raw: str
payment_transfer_text: str
payment_success_text: str
payment_support_text: str
payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str
@@ -52,6 +54,8 @@ class BotConfigSnapshot:
fallback_support_ticket_thread_id: int | None = None
fallback_payment_review_chat_id: int | None = None
fallback_payment_review_thread_id: int | None = None
fallback_broadcast_results_chat_id: int | None = None
fallback_broadcast_results_thread_id: int | None = None
@property
def bot_public_username_normalized(self) -> str:
@@ -245,6 +249,24 @@ class BotConfigSnapshot:
else self.fallback_payment_review_thread_id
)
@property
def broadcast_results_chat_id(self) -> int | None:
parsed_chat_id, _ = Settings._parse_private_topic_link(self.broadcast_results_link)
return (
parsed_chat_id
if parsed_chat_id is not None
else self.fallback_broadcast_results_chat_id
)
@property
def broadcast_results_message_thread_id(self) -> int | None:
_, parsed_thread_id = Settings._parse_private_topic_link(self.broadcast_results_link)
return (
parsed_thread_id
if parsed_thread_id is not None
else self.fallback_broadcast_results_thread_id
)
class StaticBotConfigService:
def __init__(self, settings: Settings) -> None:
@@ -258,6 +280,7 @@ class StaticBotConfigService:
bot_terms_url=self._settings.bot_terms_url,
bot_support_ticket_link=self._settings.bot_support_ticket_link,
payment_review_link=self._settings.payment_review_link,
broadcast_results_link=self._settings.broadcast_results_link,
bot_start_image_enabled=self._settings.bot_start_image_enabled,
bot_start_image_path=self._settings.bot_start_image_path,
referral_enabled=True,
@@ -266,6 +289,11 @@ class StaticBotConfigService:
payment_plans_raw=self._settings.payment_plans_raw,
payment_product_plans_raw=self._settings.payment_product_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text,
payment_success_text=getattr(
self._settings,
"payment_success_text",
Settings.model_fields["payment_success_text"].default,
),
payment_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period,
@@ -282,6 +310,8 @@ class StaticBotConfigService:
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
fallback_broadcast_results_chat_id=self._settings.broadcast_results_chat_id,
fallback_broadcast_results_thread_id=self._settings.broadcast_results_message_thread_id,
)
@@ -414,6 +444,14 @@ class BotConfigService(StaticBotConfigService):
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/56/57",
),
"broadcast_results_link": BotConfigFieldSpec(
key="broadcast_results_link",
label="Ссылка на ответы рассылок",
section="links",
prompt="Введите приватную ссылку на чат/топик, куда будут приходить ответы опросов. Для очистки отправьте `-`.",
description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/3646494169/67/68",
),
"payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text",
label="Инструкция по оплате",
@@ -430,6 +468,14 @@ class BotConfigService(StaticBotConfigService):
description="Используется при отклонении оплаты и ошибках выдачи доступа.",
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
),
"payment_success_text": BotConfigFieldSpec(
key="payment_success_text",
label="Текст после покупки",
section="texts",
prompt="Введите текст, который бот добавит к сообщению со ссылкой после подтверждения оплаты.",
description="Показывается покупателю вместе со ссылкой на подписку после выдачи или продления доступа.",
placeholder="Доступ выдан. Сохраните ссылку и используйте её для подключения.",
),
"payment_internal_squad_uuids_raw": BotConfigFieldSpec(
key="payment_internal_squad_uuids_raw",
label="UUID внутренних групп",
@@ -533,6 +579,12 @@ class BotConfigService(StaticBotConfigService):
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)),
bot_support_ticket_link=str(overrides.get("bot_support_ticket_link", self._settings.bot_support_ticket_link)),
payment_review_link=str(overrides.get("payment_review_link", self._settings.payment_review_link)),
broadcast_results_link=str(
overrides.get(
"broadcast_results_link",
self._settings.broadcast_results_link,
)
),
bot_start_image_enabled=self._parse_bool(
overrides.get("bot_start_image_enabled"),
default=self._settings.bot_start_image_enabled,
@@ -558,6 +610,16 @@ class BotConfigService(StaticBotConfigService):
)
),
payment_transfer_text=str(overrides.get("payment_transfer_text", self._settings.payment_transfer_text)),
payment_success_text=str(
overrides.get(
"payment_success_text",
getattr(
self._settings,
"payment_success_text",
Settings.model_fields["payment_success_text"].default,
),
)
),
payment_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int(
overrides.get("payment_plan_traffic_limit_gb"),
@@ -610,6 +672,8 @@ class BotConfigService(StaticBotConfigService):
fallback_support_ticket_thread_id=self._settings.support_ticket_message_thread_id,
fallback_payment_review_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_id,
fallback_broadcast_results_chat_id=self._settings.broadcast_results_chat_id,
fallback_broadcast_results_thread_id=self._settings.broadcast_results_message_thread_id,
)
async def update_setting(
@@ -672,6 +736,7 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"broadcast_results_link",
"bot_start_image_path",
"payment_product_plans_raw",
"payment_vpn_squad_uuids_raw",
@@ -703,7 +768,7 @@ class BotConfigService(StaticBotConfigService):
if key == "notification_check_interval_hours":
return f"{max(int(value or 6), 1)} ч"
if key in {"payment_transfer_text", "payment_support_text"}:
if key in {"payment_transfer_text", "payment_success_text", "payment_support_text"}:
return cls._compact_text(str(value), max_length=120)
return cls._compact_text(str(value))
@@ -732,6 +797,7 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"broadcast_results_link",
"bot_start_image_path",
"payment_external_squad_uuid",
"payment_user_tag",
@@ -798,10 +864,11 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url",
"bot_support_ticket_link",
"payment_review_link",
"broadcast_results_link",
}:
return value
if key in {"payment_transfer_text", "payment_support_text"}:
if key in {"payment_transfer_text", "payment_success_text", "payment_support_text"}:
if not value:
raise ValueError("Текст не может быть пустым.")
return value[:4000]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import html
import logging
import unicodedata
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from math import ceil
@@ -30,6 +31,77 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__)
_CYRILLIC_TRANSLITERATION = str.maketrans(
{
"\u0410": "A",
"\u0411": "B",
"\u0412": "V",
"\u0413": "G",
"\u0414": "D",
"\u0415": "E",
"\u0401": "E",
"\u0416": "Zh",
"\u0417": "Z",
"\u0418": "I",
"\u0419": "Y",
"\u041a": "K",
"\u041b": "L",
"\u041c": "M",
"\u041d": "N",
"\u041e": "O",
"\u041f": "P",
"\u0420": "R",
"\u0421": "S",
"\u0422": "T",
"\u0423": "U",
"\u0424": "F",
"\u0425": "Kh",
"\u0426": "Ts",
"\u0427": "Ch",
"\u0428": "Sh",
"\u0429": "Sch",
"\u042a": "",
"\u042b": "Y",
"\u042c": "",
"\u042d": "E",
"\u042e": "Yu",
"\u042f": "Ya",
"\u0430": "a",
"\u0431": "b",
"\u0432": "v",
"\u0433": "g",
"\u0434": "d",
"\u0435": "e",
"\u0451": "e",
"\u0436": "zh",
"\u0437": "z",
"\u0438": "i",
"\u0439": "y",
"\u043a": "k",
"\u043b": "l",
"\u043c": "m",
"\u043d": "n",
"\u043e": "o",
"\u043f": "p",
"\u0440": "r",
"\u0441": "s",
"\u0442": "t",
"\u0443": "u",
"\u0444": "f",
"\u0445": "kh",
"\u0446": "ts",
"\u0447": "ch",
"\u0448": "sh",
"\u0449": "sch",
"\u044a": "",
"\u044b": "y",
"\u044c": "",
"\u044d": "e",
"\u044e": "yu",
"\u044f": "ya",
}
)
@dataclass(slots=True)
class AppliedDiscount:
@@ -196,14 +268,35 @@ class PaymentService:
)
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id)
renewal_target_record = self._select_cached_renewal_target(existing_accesses)
renewal_target_uuid: str | None = None
renewal_target_username: str | None = None
if renewal_target_record is not None:
renewal_target_uuid = str(renewal_target_record.rw_uuid)
renewal_target_username = renewal_target_record.username
else:
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
telegram_id
)
except RemnawaveApiError:
remote_users = []
remote_user = self._select_referral_bonus_target(remote_users)
if remote_user is not None:
renewal_target_uuid = str(remote_user.uuid)
renewal_target_username = remote_user.username
order_uuid = str(uuid4())
invoice_payload = f"manual:{plan.code}:{order_uuid}"
provision_username = self._build_provision_username(
config=config,
order_uuid=order_uuid,
telegram_id=telegram_id,
username=username,
first_name=first_name,
provision_username = (
renewal_target_username
or self._build_provision_username(
config=config,
order_uuid=order_uuid,
telegram_id=telegram_id,
username=username,
first_name=first_name,
)
)
async with self._session_factory() as session:
@@ -230,6 +323,7 @@ class PaymentService:
status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload,
provision_username=provision_username,
remnawave_user_uuid=renewal_target_uuid,
error_message=plan.discount_code or None,
)
)
@@ -240,7 +334,7 @@ class PaymentService:
provision_username=provision_username,
plan=plan,
transfer_text=config.payment_transfer_text.strip(),
extends_existing_access=bool(existing_accesses),
extends_existing_access=bool(existing_accesses) or renewal_target_uuid is not None,
)
async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
@@ -350,10 +444,17 @@ class PaymentService:
raise
if remote_user is None:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
try:
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
remote_user = self._select_referral_bonus_target(remote_users)
except Exception:
logger.warning(
"Failed to look up user by telegram_id=%s, will try to create",
order.telegram_id,
exc_info=True,
)
if remote_user is None:
remote_user = await self._create_remnawave_user(
@@ -375,6 +476,19 @@ class PaymentService:
except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise
except Exception as exc:
error_msg = f"Unexpected error: {exc.__class__.__name__}: {exc}"
logger.error(
"Unexpected error during order approval %s: %s",
order.order_uuid,
error_msg,
exc_info=True,
)
await self._restore_review_after_failed_approval(order.order_uuid, error_msg)
raise RemnawaveApiError(
status_code=0,
message=f"Ошибка подключения к панели. Попробуйте ещё раз. ({exc.__class__.__name__})",
)
await self._save_fulfilled_order(order.order_uuid, remote_user)
@@ -772,7 +886,21 @@ class PaymentService:
if order.status == PENDING_PAYMENT_STATUS:
raise ValueError("Чек ещё не отправлен на проверку.")
if order.status == PROCESSING_PAYMENT_STATUS:
raise ValueError("Заказ уже обрабатывается другим модератором.")
# If the order has been stuck in PROCESSING for more than 60s,
# it means the previous approval attempt crashed (e.g. ConnectError).
# Allow re-claiming it instead of blocking forever.
stale_threshold = utcnow() - timedelta(seconds=60)
if order.updated_at and order.updated_at > stale_threshold:
raise ValueError("Заказ уже обрабатывается другим модератором.")
logger.warning(
"Order %s was stuck in PROCESSING since %s, reclaiming",
order_uuid,
order.updated_at,
)
# Already in PROCESSING — just refresh the timestamp and proceed
order.updated_at = utcnow()
await session.commit()
return order
if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Заказ уже отклонён.")
if order.status == FULFILLED_PAYMENT_STATUS:
@@ -806,7 +934,33 @@ class PaymentService:
body["tag"] = config.payment_user_tag_normalized
if config.payment_external_squad_uuid_normalized:
body["externalSquadUuid"] = config.payment_external_squad_uuid_normalized
return await self._remnawave_client.create_user(body)
try:
return await self._remnawave_client.create_user(body)
except RemnawaveApiError as exc:
if exc.status_code != 400:
raise
# Creation failed (likely duplicate telegramId) — fall back to
# finding the existing user and extending their subscription.
logger.warning(
"POST /api/users returned 400 for order=%s telegram_id=%s: %s. "
"Falling back to extend existing user.",
order.order_uuid,
order.telegram_id,
exc.message,
)
remote_users = await self._remnawave_client.get_users_by_telegram_id(
order.telegram_id
)
existing_user = self._select_referral_bonus_target(remote_users)
if existing_user is None:
raise # re-raise original 400 if we truly can't find anyone
return await self._extend_remnawave_user(
order,
existing_user,
plan=plan,
config=config,
)
async def _extend_remnawave_user(
self,
@@ -1068,6 +1222,28 @@ class PaymentService:
return sorted(remote_users, key=_sort_key)[0]
@staticmethod
def _select_cached_renewal_target(cached_users: list[object]) -> object | None:
records = [
record
for access in cached_users
if (record := getattr(access, "record", None)) is not None
and getattr(record, "rw_uuid", None)
and getattr(record, "username", None)
]
if not records:
return None
def _sort_key(record: object) -> tuple[int, datetime, str]:
expire_at = getattr(record, "expire_at", None) or datetime.min
if expire_at.tzinfo is None:
expire_at = expire_at.replace(tzinfo=timezone.utc)
status = str(getattr(record, "status", "")).upper()
username = str(getattr(record, "username", ""))
return (0 if status == "ACTIVE" else 1, expire_at, username)
return sorted(records, key=_sort_key)[0]
async def _upsert_telegram_user(
self,
session: AsyncSession,
@@ -1107,13 +1283,19 @@ class PaymentService:
first_name: str | None,
) -> str:
base_name = username or first_name or "user"
normalized_name = "".join(
ch for ch in base_name if ch.isalnum() or ch in "_-"
).strip("-_")
if not normalized_name:
normalized_name = "user"
normalized_name = self._normalize_provision_name(base_name)
prefix = config.payment_username_prefix_normalized or "Oreol"
unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36]
@staticmethod
def _normalize_provision_name(raw_name: str) -> str:
transliterated = raw_name.translate(_CYRILLIC_TRANSLITERATION)
decomposed = unicodedata.normalize("NFKD", transliterated)
ascii_name = decomposed.encode("ascii", "ignore").decode("ascii")
normalized_name = "".join(
ch for ch in ascii_name if ch.isalnum() or ch in "_-"
).strip("-_")
return normalized_name or "user"

View File

@@ -6,7 +6,7 @@ from typing import Any
import httpx
from app.schemas.remnawave import PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
from app.schemas.remnawave import HwidDevicesResponse, PaginatedUsers, RemnawaveUser, ResolvedUser, SubscriptionRequestHistory
UUID_RE = re.compile(
@@ -145,6 +145,26 @@ class RemnawaveApiClient:
payload = await self._request_json("POST", "users/resolve", json=body)
return ResolvedUser.model_validate(payload)
async def get_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
payload = await self._request_json("GET", f"hwid/devices/{user_uuid}")
return HwidDevicesResponse.model_validate(payload)
async def delete_user_hwid_device(self, *, user_uuid: str, hwid: str) -> HwidDevicesResponse:
payload = await self._request_json(
"POST",
"hwid/devices/delete",
json={"userUuid": user_uuid, "hwid": hwid},
)
return HwidDevicesResponse.model_validate(payload)
async def delete_all_user_hwid_devices(self, user_uuid: str) -> HwidDevicesResponse:
payload = await self._request_json(
"POST",
"hwid/devices/delete-all",
json={"userUuid": user_uuid},
)
return HwidDevicesResponse.model_validate(payload)
async def _request_json(
self,
method: str,
@@ -153,12 +173,28 @@ class RemnawaveApiClient:
params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
) -> Any:
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
try:
response = await self._client.request(
method=method,
url=path.lstrip("/"),
params=params,
json=json,
)
except httpx.TimeoutException as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API timeout: {exc}",
) from exc
except httpx.NetworkError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API network error: {exc}",
) from exc
except httpx.HTTPError as exc:
raise RemnawaveApiError(
status_code=0,
message=f"Remnawave API HTTP error: {exc}",
) from exc
if response.is_error:
raise self._build_error(response)

View File

@@ -11,6 +11,8 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.config import Settings
from app.db.base import utcnow
from app.db.models import (
BroadcastPoll,
BroadcastPollAnswer,
Guide,
GuidePhoto,
InternalSquad,
@@ -124,6 +126,13 @@ class AdminTelegramUserProfile:
accesses: list[CachedUserView] = field(default_factory=list)
@dataclass(slots=True)
class BroadcastPollAnswerResult:
poll: BroadcastPoll
answer: BroadcastPollAnswer
previous_answer: str | None = None
class SyncService:
def __init__(
self,
@@ -571,6 +580,69 @@ class SyncService:
)
return list(rows.all())
async def create_broadcast_poll(
self,
*,
question_text: str,
created_by_telegram_id: int,
) -> BroadcastPoll:
async with self._session_factory() as session:
poll = BroadcastPoll(
question_text=question_text,
created_by_telegram_id=created_by_telegram_id,
)
session.add(poll)
await session.commit()
return poll
async def record_broadcast_poll_answer(
self,
*,
poll_id: int,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
answer: str,
) -> BroadcastPollAnswerResult | None:
normalized_answer = answer.strip().lower()
if normalized_answer not in {"yes", "no"}:
raise ValueError("Unsupported answer")
async with self._session_factory() as session:
poll = await session.scalar(
select(BroadcastPoll).where(BroadcastPoll.id == poll_id)
)
if poll is None:
return None
record = await session.scalar(
select(BroadcastPollAnswer).where(
BroadcastPollAnswer.poll_id == poll_id,
BroadcastPollAnswer.telegram_id == telegram_id,
)
)
previous_answer = record.answer if record is not None else None
if record is None:
record = BroadcastPollAnswer(
poll_id=poll_id,
telegram_id=telegram_id,
)
session.add(record)
record.username = username
record.first_name = first_name
record.last_name = last_name
record.answer = normalized_answer
record.updated_at = utcnow()
await session.commit()
return BroadcastPollAnswerResult(
poll=poll,
answer=record,
previous_answer=previous_answer,
)
async def get_admin_telegram_users_page(
self,
*,

View File

@@ -97,6 +97,7 @@ def format_issued_access(
expire_at: datetime,
traffic_limit_bytes: int,
brand_name: str = "",
success_text: str = "",
is_renewal: bool = False,
) -> str:
normalized_brand_name = html.escape(brand_name.strip()) if brand_name.strip() else "VPN"
@@ -142,6 +143,13 @@ def format_issued_access(
"",
"<i>Если ссылка не открывается автоматически, скопируйте её в клиент вручную.</i>",
]
normalized_success_text = success_text.strip()
if normalized_success_text:
lines[7:7] = [
"<b>Информация</b>",
f"<blockquote>{html.escape(normalized_success_text)}</blockquote>",
"",
]
return "\n".join(lines)