diff --git a/.env.bak-20260709061701 b/.env.bak-20260709061701 new file mode 100644 index 0000000..9ad4e64 --- /dev/null +++ b/.env.bak-20260709061701 @@ -0,0 +1,213 @@ +# ========================= +# Telegram bot +# ========================= + +# Токен бота от BotFather +BOT_TOKEN=8568577400:AAFZY7qnwgWMygAkiOvFR8zEFFD6PlYV-fE + +# Telegram ID администраторов через запятую +# Пример: 123456789,987654321 +BOT_ADMIN_IDS=146834537,591220249 + +# Telegram ID модераторов через запятую +# Модераторы могут отвечать на тикеты и подтверждать оплаты +BOT_MODERATOR_IDS= + +# Прокси для доступа к Telegram Bot API, если хост не видит api.telegram.org напрямую +# Подходят обычные URL: http://... или socks5://... +TELEGRAM_PROXY_URL= + +# Название бренда в пользовательской панели +BOT_BRAND_NAME=NetworkBypass + +# Username бота без @, нужен для реферальных ссылок +BOT_PUBLIC_USERNAME=Y0zhyckShield_bot + +# Опциональная ссылка на поддержку +BOT_SUPPORT_URL= https://t.me/y0zhyck + +# Опциональная внешняя ссылка на Terms of Use +BOT_TERMS_URL= + +# Ссылка на приватный чат/топик для тикетов поддержки +# Формат: https://t.me/c/// +BOT_SUPPORT_TICKET_LINK=https://t.me/c/3591520479/5788/7962 + +# Альтернативный способ указать chat id для тикетов вручную +# Если заполнен, имеет приоритет над BOT_SUPPORT_TICKET_LINK +BOT_SUPPORT_TICKET_CHAT_ID= + +# Альтернативный способ указать thread/topic id для тикетов вручную +# Если > 0, имеет приоритет над BOT_SUPPORT_TICKET_LINK +BOT_SUPPORT_TICKET_THREAD_ID=0 + +# Включить стартовую картинку в панели +BOT_START_IMAGE_ENABLED=true + +# Путь до стартовой картинки +# Можно абсолютный путь или относительный от корня проекта +BOT_START_IMAGE_PATH=assets/main.png + + +# ========================= +# Remnawave +# ========================= + +# Базовый URL Remnawave без /api или с /api — клиент нормализует сам +REMNAWAVE_BASE_URL=https://subs.grenu4.ru + +# API token Remnawave +REMNAWAVE_API_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1dWlkIjoiOWVjZDIxNzQtMzQyOC00OTg1LTkyMjAtNDZjYjJiOGNjMjhhIiwidXNlcm5hbWUiOm51bGwsInJvbGUiOiJBUEkiLCJpYXQiOjE3ODA3NDcwMjIsImV4cCI6MTA0MjA2NjA2MjJ9.tq4FyZ-gTor7ub57C3HLOW9BQSzC2X0i0IhtV9MMcHc + +# Дополнительный X-Api-Key, если API закрыт через Caddy +REMNAWAVE_CADDY_API_KEY= + +# HTTP timeout в секундах для запросов к Remnawave +REMNAWAVE_TIMEOUT_SECONDS=20 + + +# ========================= +# MariaDB +# ========================= + +# Хост MariaDB +DB_HOST=db + +# Порт MariaDB +DB_PORT=3306 + +# Имя базы данных +DB_NAME=y0zhyck_db + +# Пользователь MariaDB +DB_USER=telegabot + +# Пароль MariaDB +DB_PASSWORD=botpass123 + +# Логировать SQLAlchemy SQL-запросы в консоль +# true = логировать, false = не логировать +DB_ECHO=false + +# Попытаться создать базу при старте, если её ещё нет +CREATE_DATABASE_ON_START=true + +# Создавать таблицы ORM при старте приложения +CREATE_TABLES_ON_START=true + +# Сохранять историю subscription request log из Remnawave +SYNC_SUBSCRIPTION_HISTORY=true + +# Размер батча при /sync_all +SYNC_BATCH_SIZE=100 + +# Уровень логов Python +# Примеры: INFO, DEBUG, WARNING, ERROR +LOG_LEVEL=INFO + + +# ========================= +# Manual payment flow +# ========================= + +# Набор тарифов в формате дни:цена_в_рублях +# Пример: 30:250,180:600,365:1000 +PAYMENT_PLANS=30:250,180:600,365:1000 + +# Реквизиты или инструкция для перевода +# Этот текст бот показывает пользователю после выбора тарифа +PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN + +# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию +# Формат: https://t.me/c/// +PAYMENT_REVIEW_LINK=https://t.me/c/3591520479/5788/7962 + +# Альтернативный способ указать chat id review-чата вручную +# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_CHAT_ID= + +# Альтернативный способ указать thread/topic id review-чата вручную +# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_THREAD_ID=0 + +# Процент скидки по реферальному коду +REFERRAL_DISCOUNT_PERCENT=5 + + +# ========================= +# Manual payment flow +# ========================= + +# Набор тарифов в формате дни:цена_в_рублях +# Пример: 30:250,180:600,365:1000 +PAYMENT_PLANS=30:250,180:600,365:1000 + +# Реквизиты или инструкция для перевода +# Этот текст бот показывает пользователю после выбора тарифа +PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN + +# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию +# Формат: https://t.me/c/// +PAYMENT_REVIEW_LINK=https://t.me/c/3591520479/5910/7961 + +# Альтернативный способ указать chat id review-чата вручную +# Если заполнен, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_CHAT_ID= + +# Альтернативный способ указать thread/topic id review-чата вручную +# Если > 0, имеет приоритет над PAYMENT_REVIEW_LINK +PAYMENT_REVIEW_THREAD_ID=0 + +# Процент скидки по реферальному коду +REFERRAL_DISCOUNT_PERCENT=5 + + +# ========================= +# Payment / Remnawave provisioning +# ========================= + +# Резервный код тарифа по умолчанию +# Сейчас в основном используется PAYMENT_PLANS, но поле оставлено для совместимости +PAYMENT_PLAN_CODE=vpn_30d + +# Резервное название базового тарифа +PAYMENT_PLAN_TITLE=OREOL VPN на 30 дней + +# Резервное описание базового тарифа +PAYMENT_PLAN_DESCRIPTION=Доступ к VPN на 30 дней + +# Резервная цена базового тарифа +PAYMENT_PLAN_PRICE_STARS=250 + +# Резервная длительность базового тарифа +PAYMENT_PLAN_DURATION_DAYS=30 + +# Лимит трафика в GB +# 0 = без лимита +PAYMENT_PLAN_TRAFFIC_LIMIT_GB=0 + +# Стратегия сброса трафика для Remnawave +# Обычно NO_RESET +PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET + +# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу +# Несколько значений через запятую +PAYMENT_INTERNAL_SQUAD_UUIDS=57c259cc-ddbf-43fc-8612-e35e1ec488b8 +PAYMENT_PRODUCT_PLANS=vpn_white:30:450:180:2200:365:4000,white:30:250:180:1200:365:2000,vpn:30:200:180:1000:365:1800 +PAYMENT_VPN_SQUAD_UUIDS=57c259cc-ddbf-43fc-8612-e35e1ec488b8 +PAYMENT_WHITE_SQUAD_UUIDS=d0b30ec8-e2cd-437a-8086-66a5e3c4adbd + + + +# UUID внешней группы Remnawave, если используется +PAYMENT_EXTERNAL_SQUAD_UUID= + +# Текст для пользователя, если оплата отклонена или доступ не выдался +PAYMENT_SUPPORT_TEXT=Если оплата прошла, но доступ не выдался, напишите в поддержку. + +# Префикс логина Remnawave +# Итоговый логин будет вида Oreol-- +PAYMENT_USERNAME_PREFIX=subs + +# Tag для пользователей, создаваемых ботом в Remnawave +PAYMENT_USER_TAG=BOT diff --git a/.env.example b/.env.example index 80cfa66..07d2794 100644 --- a/.env.example +++ b/.env.example @@ -118,18 +118,14 @@ LOG_LEVEL=INFO # Пример: 30:250,180:600,365:1000 PAYMENT_PLANS=30:250,180:600,365:1000 -# Типы подписок с разными squad-группами. -# Если заполнено, заменяет PAYMENT_PLANS. -# Коды: vpn_white = VPN + белые списки, white = только белые списки, vpn = только VPN. -# Формат: код:дни:цена_в_рублях -PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 - # Реквизиты или инструкция для перевода # Этот текст бот показывает пользователю после выбора тарифа PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN # Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию # Формат: https://t.me/c/// +PAYMENT_SUCCESS_TEXT=Доступ выдан. Сохраните ссылку и используйте её для подключения. + PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57 # Альтернативный способ указать chat id review-чата вручную @@ -178,15 +174,8 @@ PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET # UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу # Несколько значений через запятую -# Используется старым режимом PAYMENT_PLANS. PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 -# UUID squad для тарифа VPN и комбинированного тарифа VPN + белые списки -PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 - -# UUID squad для тарифа "Белые списки" и комбинированного тарифа VPN + белые списки -PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 - # UUID внешней группы Remnawave, если используется PAYMENT_EXTERNAL_SQUAD_UUID= diff --git a/README.md b/README.md index 7477bf7..ac06be8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, - Хранит локальный кэш пользователей и истории подписок в MariaDB - Поддерживает привязку существующего аккаунта Remnawave по `short_uuid` - Показывает профиль, статус подписки, срок и трафик -- Работает с реферальными кодами, промокодами и скидкой перед оплатой +- Работает с реферальными кодами и скидкой перед оплатой - Поддерживает ручную оплату переводом с отправкой чека в бот - Отправляет чек в отдельный Telegram-канал/топик на проверку - Даёт админу или модератору подтвердить или отклонить оплату @@ -24,8 +24,8 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, Сейчас в проекте не используется Telegram Stars. Оплата работает вручную: 1. Пользователь нажимает `Купить подписку` -2. Бот показывает список тарифов из `PAYMENT_PRODUCT_PLANS`, если они заданы, иначе из `PAYMENT_PLANS` -3. Если у пользователя применён реферальный код или промокод, скидка учитывается до выбора тарифа +2. Бот показывает список тарифов из `PAYMENT_PLANS` +3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа 4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT` 5. Пользователь отправляет чек следующим сообщением в бота 6. Бот пересылает чек в review-чат оплаты @@ -34,7 +34,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`, ### Тарифы -Обычные тарифы по срокам задаются одной переменной: +Тарифы задаются одной переменной: ```env PAYMENT_PLANS=30:250,180:600,365:1000 @@ -51,22 +51,6 @@ PAYMENT_PLANS=30:250,180:600,365:1000 - 180 дней = 600 ₽ - 365 дней = 1000 ₽ -Для разных типов подписки можно включить продуктовые тарифы: - -```env -PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 -PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 -PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 -``` - -Если `PAYMENT_PRODUCT_PLANS` заполнен, пользователь увидит выбор: - -- `VPN + Белые списки` — получает UUID из `PAYMENT_VPN_SQUAD_UUIDS` и `PAYMENT_WHITE_SQUAD_UUIDS` -- `Белые списки` — получает только `PAYMENT_WHITE_SQUAD_UUIDS` -- `VPN` — получает только `PAYMENT_VPN_SQUAD_UUIDS` - -Формат `PAYMENT_PRODUCT_PLANS`: `код:дни:цена_в_рублях`. Доступные коды: `vpn_white`, `white`, `vpn`. Эти значения также можно менять в админ-настройках бота. - ### Логин в Remnawave Логин создаётся в формате: @@ -253,12 +237,6 @@ telegabot/ - `referral_bonuses` - начисленные и ожидающие применения бонусы реферерам -- `promo_codes` - - промокоды, скидка, срок действия и ограничение по тарифу - -- `promo_code_applications` - - активный промокод, который пользователь ввёл перед оплатой - - `payment_orders` - ручные платежи, статусы и выданные доступы @@ -283,26 +261,6 @@ telegabot/ - за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях - размер бонуса задаётся в `REFERRAL_BONUS_DAYS` -### Промокоды - -Админ может создать промокод из UI: `Админ-панель` -> `Промокоды` -> `Создать промокод`. - -Сценарий создания: - -1. Админ вводит слово промокода -2. Выбирает область действия: все тарифы или один тариф -3. Вводит процент скидки -4. Вводит срок действия - -Пользователь вводит промокод в том же разделе, где вводится реферальный код. Если промокод ограничен одним тарифом, скидка применяется только к этому тарифу. Если у пользователя есть и реферальное приглашение, и активный промокод, при оплате используется скидка промокода; реферальный бонус рефереру всё равно начислится после подтверждённой оплаты. - -Форматы срока действия: - -- `7` — 7 дней -- `24h` или `24ч` — 24 часа -- `2026-05-31` — до конца указанной даты -- `0` — без срока действия - ## Тикеты поддержки Сценарий: @@ -373,11 +331,10 @@ DB_NAME=oreolvpn DB_USER=oreolvpn DB_PASSWORD=strong_password -PAYMENT_PRODUCT_PLANS=vpn_white:30:450,white:30:250,vpn:30:200 +PAYMENT_PLANS=30:250,180:600,365:1000 PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57 -PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 -PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222 +PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 REFERRAL_DISCOUNT_PERCENT=5 PAYMENT_USERNAME_PREFIX=Oreol ``` @@ -853,7 +810,7 @@ OperationalError: (1049, "Unknown database '...'" ) 6. Проверить руками: - `/start` - открытие раздела подписки через кнопку в панели - - ввод реферального кода или промокода + - ввод реферального кода - отправку чека - подтверждение оплаты модератором - выдачу ссылки пользователю diff --git a/app/bot/handlers/main.py b/app/bot/handlers/main.py index ea71042..55bb21c 100644 --- a/app/bot/handlers/main.py +++ b/app/bot/handlers/main.py @@ -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"📣 {html.escape(brand_name)} · {title}", + "", + f"
{body_html}
", + ] + if mode == "poll": + lines.extend(["", "Выберите один из вариантов ниже."]) + 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"Получателей: {recipient_count}\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( + "У пользователей под этим сообщением будут кнопки Да и Нет.", + 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Предыдущий ответ: {broadcast_answer_label(previous_answer)}" + 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=( + "📬 Ответ на опрос рассылки\n\n" + f"Опрос: {poll_id}\n" + f"Ответ: {broadcast_answer_label(answer)}{changed_text}\n\n" + "Пользователь\n" + "
" + f"ID: {telegram_id}\n" + f"Имя: {html.escape(full_name or str(telegram_id))}\n" + f"Username: {html.escape(username_text)}" + "
\n\n" + "Вопрос\n" + f"
{question_html}
" + ), + ) + 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Статус: {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Статус: {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" "Чтобы отменить действие, отправьте /cancel." @@ -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" + "Чтобы отменить действие, отправьте /cancel." + ) + 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" - "Чтобы отменить действие, отправьте /cancel." + "📣 Выберите тип рассылки.", + 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" + "Чтобы отменить действие, отправьте /cancel." + ) + else: + prompt = ( + "📣 Отправьте текст уведомления следующим сообщением.\n\n" + "Бот красиво оформит текст, покажет предпросмотр и попросит подтвердить отправку.\n" + "Чтобы отменить действие, отправьте /cancel." + ) + + 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"Отправлено: {sent_count}\n" f"Недоступны/заблокировали бота: {blocked_count}\n" f"Ошибок отправки: {failed_count}" + + (f"\nID опроса: {poll_id}" 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 = [ + "📱 Подключённые устройства", + f"
Подключено: {len(devices)} / {limit_text}
", + ] + if not devices: + lines.extend([ + "", + "Нет подключённых устройств.", + ]) + 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}. {label}" + f"\n
Подключено: {connected}
" + ) + + lines.extend([ + "", + "Отключите устройство, чтобы освободить слот для нового подключения.", + ]) + 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:")) diff --git a/app/bot/ui/panel.py b/app/bot/ui/panel.py index ee56750..f5e35be 100644 --- a/app/bot/ui/panel.py +++ b/app/bot/ui/panel.py @@ -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) diff --git a/app/config.py b/app/config.py index daee204..0e4e88e 100644 --- a/app/config.py +++ b/app/config.py @@ -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() diff --git a/app/db/models.py b/app/db/models.py index f3b19f5..2dabd1f 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -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" diff --git a/app/schemas/remnawave.py b/app/schemas/remnawave.py index a3bc816..68b4628 100644 --- a/app/schemas/remnawave.py +++ b/app/schemas/remnawave.py @@ -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] diff --git a/app/services/bot_config_service.py b/app/services/bot_config_service.py index d60bf93..f5f2240 100644 --- a/app/services/bot_config_service.py +++ b/app/services/bot_config_service.py @@ -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///`.", 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///`.", + 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] diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 6a8e1b9..2880da6 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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" diff --git a/app/services/remnawave_client.py b/app/services/remnawave_client.py index b60be9e..9ce1050 100644 --- a/app/services/remnawave_client.py +++ b/app/services/remnawave_client.py @@ -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) diff --git a/app/services/sync_service.py b/app/services/sync_service.py index c490d96..56c0435 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -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, *, diff --git a/app/utils/formatters.py b/app/utils/formatters.py index 67f885c..2dcc743 100644 --- a/app/utils/formatters.py +++ b/app/utils/formatters.py @@ -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( "", "Если ссылка не открывается автоматически, скопируйте её в клиент вручную.", ] + normalized_success_text = success_text.strip() + if normalized_success_text: + lines[7:7] = [ + "Информация", + f"
{html.escape(normalized_success_text)}
", + "", + ] return "\n".join(lines) diff --git a/docker-compose.yml b/docker-compose.yml index b225df0..d5ea461 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +5,17 @@ services: env_file: - .env environment: - DB_HOST: db - DB_PORT: 3306 + HTTP_PROXY: http://mihomo:7890 + HTTPS_PROXY: http://mihomo:7890 + ALL_PROXY: http://mihomo:7890 + http_proxy: http://mihomo:7890 + https_proxy: http://mihomo:7890 + all_proxy: http://mihomo:7890 + NO_PROXY: localhost,127.0.0.1,::1,db,mihomo,subconverter + no_proxy: localhost,127.0.0.1,::1,db,mihomo,subconverter depends_on: - db: - condition: service_healthy + mihomo: + condition: service_started db: image: mariadb:11.7 @@ -27,5 +33,29 @@ services: timeout: 5s retries: 10 + subconverter: + image: tindy2013/subconverter:latest + container_name: subconverter + restart: unless-stopped + + mihomo: + image: metacubex/mihomo:Alpha + container_name: mihomo + depends_on: + - subconverter + volumes: + - ./mihomo:/root/.config/mihomo + ports: + - "7890:7890/tcp" + - "7890:7890/udp" + - "9090:9090/tcp" + dns: + - 1.1.1.1 + - 1.0.0.1 + - 9.9.9.9 + command: + - "-d" + - "/root/.config/mihomo" + restart: unless-stopped volumes: mariadb_data: diff --git a/docker-compose.yml.bak-20260709055609 b/docker-compose.yml.bak-20260709055609 new file mode 100644 index 0000000..d5ea461 --- /dev/null +++ b/docker-compose.yml.bak-20260709055609 @@ -0,0 +1,61 @@ +services: + bot: + build: . + restart: unless-stopped + env_file: + - .env + environment: + HTTP_PROXY: http://mihomo:7890 + HTTPS_PROXY: http://mihomo:7890 + ALL_PROXY: http://mihomo:7890 + http_proxy: http://mihomo:7890 + https_proxy: http://mihomo:7890 + all_proxy: http://mihomo:7890 + NO_PROXY: localhost,127.0.0.1,::1,db,mihomo,subconverter + no_proxy: localhost,127.0.0.1,::1,db,mihomo,subconverter + depends_on: + mihomo: + condition: service_started + + db: + image: mariadb:11.7 + restart: unless-stopped + environment: + MARIADB_DATABASE: ${DB_NAME:-telegabot} + MARIADB_USER: ${DB_USER:-telegabot} + MARIADB_PASSWORD: ${DB_PASSWORD:-telegabot_password} + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root_password} + volumes: + - mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mariadb-admin ping -h localhost -p$$MARIADB_ROOT_PASSWORD --silent"] + interval: 10s + timeout: 5s + retries: 10 + + subconverter: + image: tindy2013/subconverter:latest + container_name: subconverter + restart: unless-stopped + + mihomo: + image: metacubex/mihomo:Alpha + container_name: mihomo + depends_on: + - subconverter + volumes: + - ./mihomo:/root/.config/mihomo + ports: + - "7890:7890/tcp" + - "7890:7890/udp" + - "9090:9090/tcp" + dns: + - 1.1.1.1 + - 1.0.0.1 + - 9.9.9.9 + command: + - "-d" + - "/root/.config/mihomo" + restart: unless-stopped +volumes: + mariadb_data: diff --git a/mihomo/cache.db b/mihomo/cache.db new file mode 100644 index 0000000..c1f1a78 Binary files /dev/null and b/mihomo/cache.db differ diff --git a/mihomo/config.yaml b/mihomo/config.yaml new file mode 100644 index 0000000..b7db59d --- /dev/null +++ b/mihomo/config.yaml @@ -0,0 +1,36 @@ +mixed-port: 7890 +allow-lan: true +bind-address: "*" +mode: rule +log-level: info +external-controller: "0.0.0.0:9090" + +proxy-providers: + oreol: + type: http + url: "https://subs.oreolrp.ru/7SnWGMPz98-T88kx" + path: /root/.config/mihomo/proxy_providers/oreol.txt + interval: 3600 + proxy: DIRECT + header: + User-Agent: + - "v2rayN" + health-check: + enable: true + url: "https://www.gstatic.com/generate_204" + interval: 60 + timeout: 5000 + lazy: false + +proxy-groups: + - name: "AUTO" + type: fallback + use: + - oreol + url: "https://www.gstatic.com/generate_204" + interval: 60 + timeout: 5000 + lazy: false + +rules: + - MATCH,AUTO diff --git a/mihomo/proxy_providers/oreol.txt b/mihomo/proxy_providers/oreol.txt new file mode 100644 index 0000000..b8dfa25 --- /dev/null +++ b/mihomo/proxy_providers/oreol.txt @@ -0,0 +1 @@ +dmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZANDUuMTAuMTY1LjI0Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQTklRjAlOUYlODclQUElMjAlRDAlOTMlRDAlQjUlRDElODAlRDAlQkMlRDAlQjAlRDAlQkQlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC44NC4yMjc6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWNocm9tZSZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjglRjAlOUYlODclQUElMjAlRDAlQTglRDAlQjIlRDAlQjUlRDElODYlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC40LjIwNjo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBQSVGMCU5RiU4NyVBQSUyMCVEMCVBRCVEMSU4MSVEMSU4MiVEMCVCRSVEMCVCRCVEMCVCOCVEMSU4Rgp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA4OS4zNy4xOTIuNTQ6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWZpcmVmb3gmcGJrPUZkVHpfSjZ3bjA4U0g1dmlOUUUwXzVuYTNZU3VPejBzY09Dbjk4VnpKaVEjJUYwJTlGJTg3JUI3JUYwJTlGJTg3JUI0JTIwJUQwJUEwJUQxJTgzJUQwJUJDJUQxJThCJUQwJUJEJUQwJUI4JUQxJThGCnZsZXNzOi8vZTAzMjMxNDItOWE1NC00ODBlLTkyZWEtMmIwM2Y1YWNjNzM2QDE5My4yMDIuMTEuNTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVCQSVGMCU5RiU4NyVCOCUyMFVTQQp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA5MS45Mi4yMS41Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjUlRjAlOUYlODclQjElMjAlRDAlOUYlRDAlQkUlRDAlQkIlRDElOEMlRDElODglRDAlQjAlMjBEYXRpZ28Kdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTkxLjQ0LjExOC43OTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBOSVGMCU5RiU4NyVBQSUyMCUyMCVEMCU5MyVEMCVCNSVEMSU4MCVEMCVCQyVEMCVCMCVEMCVCRCVEMCVCOCVEMSU4RiUyMERhdGlnbw== \ No newline at end of file diff --git a/tests/test_bot_config_service.py b/tests/test_bot_config_service.py index f2403d1..58a20f4 100644 --- a/tests/test_bot_config_service.py +++ b/tests/test_bot_config_service.py @@ -19,14 +19,12 @@ def _make_settings() -> Settings: referral_discount_percent=5, referral_bonus_days=7, payment_plans_raw="30:250,180:600", - payment_product_plans_raw="", payment_transfer_text="Реквизиты", payment_support_text="Напишите в поддержку", + payment_success_text="Доступ выдан", payment_plan_traffic_limit_gb=0, payment_plan_traffic_reset_period="NO_RESET", payment_internal_squad_uuids_raw="uuid-1", - payment_vpn_squad_uuids_raw="vpn-uuid", - payment_white_squad_uuids_raw="white-uuid", payment_external_squad_uuid="", payment_username_prefix="Oreol", payment_user_tag="BOT", @@ -50,13 +48,13 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N updated_by_telegram_id=1, ) await service.update_setting( - key="payment_product_plans_raw", - raw_value="vpn_white:30:450,white:30:250,vpn:30:200", + key="referral_enabled", + raw_value="off", updated_by_telegram_id=1, ) await service.update_setting( - key="referral_enabled", - raw_value="off", + key="payment_success_text", + raw_value="Новый текст после оплаты", updated_by_telegram_id=1, ) @@ -64,10 +62,10 @@ async def test_bot_config_service_updates_runtime_snapshot(session_factory) -> N assert snapshot.bot_brand_name == "NEW BRAND" assert snapshot.referral_enabled is False - assert [(plan.code, plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ - ("vpn_white", 30, 450), - ("white", 30, 250), - ("vpn", 30, 200), + assert snapshot.payment_success_text == "Новый текст после оплаты" + assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ + (30, 300), + (90, 700), ] @@ -81,4 +79,3 @@ async def test_bot_config_service_formats_values_for_admin_ui(session_factory) - assert BotConfigService.format_value(snapshot=snapshot, key="bot_public_username") == "@oreol_vpn_bot" assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on" assert BotConfigService.format_value(snapshot=snapshot, key="payment_plan_traffic_limit_gb") == "0 GB (unlimited)" - assert BotConfigService.format_value(snapshot=snapshot, key="payment_vpn_squad_uuids_raw") == "vpn-uuid" diff --git a/tests/test_config.py b/tests/test_config.py index 8ec9987..997f806 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -79,22 +79,3 @@ def test_payment_plans_are_parsed() -> None: assert [plan.code for plan in plans] == ["30d", "180d", "365d"] assert [plan.days for plan in plans] == [30, 180, 365] assert [plan.amount_rub for plan in plans] == [250, 600, 1000] - - -def test_payment_product_plans_override_legacy_plans() -> None: - settings = Settings.model_construct( - payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200", - payment_plans_raw="30:999", - payment_plan_duration_days=30, - ) - - plans = settings.payment_plans - - assert [plan.code for plan in plans] == ["vpn_white", "white", "vpn"] - assert [plan.title for plan in plans] == ["VPN + Белые списки", "Белые списки", "VPN"] - assert [plan.amount_rub for plan in plans] == [450, 250, 200] - assert [plan.squad_groups for plan in plans] == [ - ("vpn", "white"), - ("white",), - ("vpn",), - ] diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 8065512..6940dae 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -75,6 +75,19 @@ def test_format_issued_access_contains_subscription_data() -> None: assert "/me" not in text +def test_format_issued_access_includes_custom_success_text() -> None: + text = format_issued_access( + subscription_url="https://example.com/sub", + expire_at=datetime(2026, 5, 21, 18, 0), + traffic_limit_bytes=0, + brand_name="OREOL VPN", + success_text="Откройте приложение и импортируйте ссылку <важно>.", + ) + + assert "Откройте приложение и импортируйте ссылку" in text + assert "<важно>" in text + + def test_format_issued_access_for_renewal_mentions_extension() -> None: text = format_issued_access( subscription_url="https://example.com/sub", diff --git a/tests/test_payment_service.py b/tests/test_payment_service.py index f901f92..a44982f 100644 --- a/tests/test_payment_service.py +++ b/tests/test_payment_service.py @@ -21,15 +21,9 @@ from tests.helpers import make_remote_user class _StubSyncService: - def __init__( - self, - *, - cached_users_by_telegram: dict[int, list] | None = None, - active_promo_by_telegram: dict[int, object] | None = None, - ) -> None: + def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None: self.refreshed: list[int] = [] self.cached_users_by_telegram = cached_users_by_telegram or {} - self.active_promo_by_telegram = active_promo_by_telegram or {} async def get_referral_summary(self, telegram_id: int): return type( @@ -50,9 +44,6 @@ class _StubSyncService: async def get_cached_users_for_telegram(self, telegram_id: int): return list(self.cached_users_by_telegram.get(telegram_id, [])) - async def get_active_promo_for_user(self, telegram_id: int): - return self.active_promo_by_telegram.get(telegram_id) - class _StubRemnawaveClient: def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None: @@ -62,6 +53,10 @@ class _StubRemnawaveClient: self.users_by_telegram_id = users_by_telegram_id or {} async def get_user_by_uuid(self, user_uuid: str): + for users in self.users_by_telegram_id.values(): + for user in users: + if str(user.uuid) == user_uuid: + return user raise RemnawaveApiError(status_code=404, message="not found") async def get_user_by_username(self, username: str): @@ -107,9 +102,6 @@ async def _seed_order( telegram_id: int, status: str, order_uuid: str | None = None, - plan_code: str = "30d", - plan_title: str = "OREOL VPN на 30 дней", - amount_stars: int = 250, ) -> str: resolved_order_uuid = order_uuid or str(uuid4()) @@ -128,12 +120,12 @@ async def _seed_order( order_uuid=resolved_order_uuid, telegram_user_id=user.id, telegram_id=telegram_id, - plan_code=plan_code, - plan_title=plan_title, + plan_code="30d", + plan_title="OREOL VPN на 30 дней", plan_duration_days=30, traffic_limit_bytes=0, traffic_limit_strategy="NO_RESET", - amount_stars=amount_stars, + amount_stars=250, currency="RUB", status=status, invoice_payload=f"payload-{resolved_order_uuid}", @@ -167,29 +159,7 @@ async def _seed_referral_invite( def _make_settings() -> Settings: return Settings.model_construct( bot_admin_ids_raw="", - payment_product_plans_raw="", payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", - payment_vpn_squad_uuids_raw="", - payment_white_squad_uuids_raw="", - payment_user_tag="BOT", - payment_external_squad_uuid="", - payment_username_prefix="Oreol", - payment_plan_traffic_limit_gb=0, - payment_plan_traffic_reset_period="NO_RESET", - referral_discount_percent=5, - referral_bonus_days=7, - ) - - -def _make_product_settings() -> Settings: - return Settings.model_construct( - bot_admin_ids_raw="", - payment_plans_raw="30:999", - payment_product_plans_raw="vpn_white:30:450,white:30:250,vpn:30:200", - payment_transfer_text="Реквизиты", - payment_internal_squad_uuids_raw="", - payment_vpn_squad_uuids_raw="11111111-1111-1111-1111-111111111111", - payment_white_squad_uuids_raw="22222222-2222-2222-2222-222222222222", payment_user_tag="BOT", payment_external_squad_uuid="", payment_username_prefix="Oreol", @@ -292,37 +262,6 @@ async def test_approve_order_fulfills_and_refreshes_cache(session_factory) -> No assert order.remnawave_user_uuid == str(remote_user.uuid) -async def test_approve_product_order_sends_product_squads_to_remnawave(session_factory) -> None: - remote_user = make_remote_user( - user_uuid="99999999-9999-9999-9999-999999999999", - user_id=9, - short_uuid="product999", - username="oreol-product", - telegram_id=1001, - ) - remnawave_client = _StubRemnawaveClient(created_user=remote_user) - service = PaymentService( - settings=_make_product_settings(), - session_factory=session_factory, - remnawave_client=remnawave_client, - sync_service=_StubSyncService(), - ) - order_uuid = await _seed_order( - session_factory, - telegram_id=1001, - status=REVIEW_PAYMENT_STATUS, - plan_code="white", - plan_title="OREOL VPN · Белые списки", - amount_stars=250, - ) - - await service.approve_order(order_uuid=order_uuid) - - assert remnawave_client.created_bodies[0]["activeInternalSquads"] == [ - "22222222-2222-2222-2222-222222222222", - ] - - async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None: buyer_remote_user = make_remote_user( user_uuid="44444444-4444-4444-4444-444444444444", @@ -475,16 +414,30 @@ async def test_create_order_generates_unique_provision_username(session_factory) assert second_order.extends_existing_access is False -async def test_product_plan_uses_matching_squads_and_price(session_factory) -> None: +async def test_create_order_transliterates_russian_first_name_without_username( + session_factory, +) -> None: service = PaymentService( - settings=_make_product_settings(), + settings=Settings.model_construct( + bot_admin_ids_raw="", + payment_plans_raw="30:250", + payment_transfer_text="Реквизиты", + payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ), session_factory=session_factory, remnawave_client=_StubRemnawaveClient( created_user=make_remote_user( user_uuid="99999999-9999-9999-9999-999999999999", user_id=9, short_uuid="buyer999", - username="oreol-product", + username="oreol-user", telegram_id=1001, ) ), @@ -493,75 +446,16 @@ async def test_product_plan_uses_matching_squads_and_price(session_factory) -> N order = await service.create_order( telegram_id=1001, - username="tester", - first_name="Test", + username=None, + first_name="\u0410\u043b\u0435\u043a\u0441\u0435\u0439", last_name=None, language_code="ru", - plan_code="vpn_white", + plan_code="30d", ) - assert order.plan.label == "VPN + Белые списки" - assert order.plan.amount_rub == 450 - assert order.plan.internal_squad_uuids == [ - "11111111-1111-1111-1111-111111111111", - "22222222-2222-2222-2222-222222222222", - ] - - -async def test_promo_code_discounts_only_matching_plan(session_factory) -> None: - promo = type( - "PromoStub", - (), - { - "code": "WHITE50", - "discount_percent": 50, - "plan_code": "white", - "expires_at": None, - }, - )() - service = PaymentService( - settings=_make_product_settings(), - session_factory=session_factory, - remnawave_client=_StubRemnawaveClient( - created_user=make_remote_user( - user_uuid="10101010-1010-1010-1010-101010101010", - user_id=10, - short_uuid="buyer101", - username="oreol-promo", - telegram_id=1001, - ) - ), - sync_service=_StubSyncService(active_promo_by_telegram={1001: promo}), - ) - - plans = await service.get_available_plans(telegram_id=1001) - - assert [(plan.code, plan.amount_rub, plan.discount_code) for plan in plans] == [ - ("vpn_white", 450, ""), - ("white", 125, "WHITE50"), - ("vpn", 200, ""), - ] - - order = await service.create_order( - telegram_id=1001, - username="tester", - first_name="Test", - last_name=None, - language_code="ru", - plan_code="white", - ) - - assert order.plan.amount_rub == 125 - assert order.plan.discount_source == "promo" - - async with session_factory() as session: - stored_order = await session.scalar( - select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid) - ) - - assert stored_order is not None - assert stored_order.amount_stars == 125 - assert stored_order.error_message == "WHITE50" + assert order.provision_username.startswith("Oreol-1001-") + assert order.provision_username.endswith("-Aleksey") + assert order.provision_username.isascii() async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None: @@ -605,6 +499,68 @@ async def test_create_order_marks_existing_access_as_renewal(session_factory) -> assert "продлит текущую подписку" in service.build_transfer_text(order=order) +async def test_create_order_targets_external_subscription_linked_by_telegram_id( + session_factory, +) -> None: + existing_remote_user = make_remote_user( + user_uuid="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + user_id=10, + short_uuid="external10", + username="custom-external", + telegram_id=1001, + ) + remnawave_client = _StubRemnawaveClient( + created_user=existing_remote_user, + users_by_telegram_id={1001: [existing_remote_user]}, + ) + service = PaymentService( + settings=Settings.model_construct( + bot_admin_ids_raw="", + payment_plans_raw="30:250", + payment_transfer_text="Реквизиты", + payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", + payment_user_tag="BOT", + payment_external_squad_uuid="", + payment_username_prefix="Oreol", + payment_plan_traffic_limit_gb=0, + payment_plan_traffic_reset_period="NO_RESET", + referral_discount_percent=5, + referral_bonus_days=7, + ), + session_factory=session_factory, + remnawave_client=remnawave_client, + sync_service=_StubSyncService(), + ) + + order = await service.create_order( + telegram_id=1001, + username="buyer", + first_name="Buyer", + last_name=None, + language_code="ru", + plan_code="30d", + ) + + assert order.extends_existing_access is True + assert order.provision_username == "custom-external" + + async with session_factory() as session: + stored_order = await session.scalar( + select(PaymentOrder).where(PaymentOrder.order_uuid == order.order_uuid) + ) + + assert stored_order is not None + assert stored_order.remnawave_user_uuid == str(existing_remote_user.uuid) + + await service.mark_order_under_review(order_uuid=order.order_uuid, telegram_id=1001) + access = await service.approve_order(order_uuid=order.order_uuid) + + assert access.is_renewal is True + assert access.username == "custom-external" + assert remnawave_client.created_bodies == [] + assert remnawave_client.updated_bodies[-1]["username"] == "custom-external" + + async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None: existing_remote_user = make_remote_user( user_uuid="88888888-8888-8888-8888-888888888888", diff --git a/tests/test_sync_service.py b/tests/test_sync_service.py index 1f2e6c7..fec7ada 100644 --- a/tests/test_sync_service.py +++ b/tests/test_sync_service.py @@ -1,19 +1,11 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime from sqlalchemy import select from app.config import Settings -from app.db.base import utcnow -from app.db.models import ( - PromoCode, - PromoCodeApplication, - ReferralCode, - ReferralInvite, - RemnawaveUser, - TelegramUser, -) +from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser from app.schemas.remnawave import PaginatedUsers from app.services.sync_service import SyncService from tests.helpers import make_remote_user @@ -221,56 +213,6 @@ async def test_get_admin_telegram_user_profile_returns_referral_and_access_data( assert profile.accesses[0].record.username == "oreol-profile" -async def test_promo_code_can_be_applied_through_referral_input(session_factory) -> None: - service = SyncService( - settings=Settings.model_construct( - bot_admin_ids_raw="", - sync_subscription_history=False, - ), - session_factory=session_factory, - remnawave_client=_StubRemnawaveClient([]), - ) - - promo = await service.create_promo_code( - code="white50", - plan_code="white", - discount_percent=50, - expires_at=utcnow() + timedelta(days=7), - created_by_telegram_id=1001, - ) - applied_code = await service.apply_referral_code( - telegram_id=2001, - username="buyer", - first_name="Buyer", - last_name=None, - language_code="ru", - referral_code="white50", - ) - summary = await service.get_referral_summary(2001) - active_promo = await service.get_active_promo_for_user(2001) - - assert promo.code == "WHITE50" - assert applied_code == "WHITE50" - assert summary.applied_referral_code == "" - assert summary.applied_promo_code == "WHITE50" - assert summary.applied_promo_discount_percent == 50 - assert summary.applied_promo_plan_code == "white" - assert active_promo is not None - assert active_promo.code == "WHITE50" - - async with session_factory() as session: - promo_record = await session.scalar( - select(PromoCode).where(PromoCode.code == "WHITE50") - ) - application = await session.scalar( - select(PromoCodeApplication).where(PromoCodeApplication.telegram_id == 2001) - ) - - assert promo_record is not None - assert application is not None - assert application.promo_code_id == promo_record.id - - async def test_sync_all_users_does_not_autoflush_half_built_user( session_factory, ) -> None: