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

213
.env.bak-20260709061701 Normal file
View File

@@ -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/<chat>/<topic>/<message>
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/<chat>/<topic>/<message>
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/<chat>/<topic>/<message>
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-<telegram_id>-<username>
PAYMENT_USERNAME_PREFIX=subs
# Tag для пользователей, создаваемых ботом в Remnawave
PAYMENT_USER_TAG=BOT

View File

@@ -118,18 +118,14 @@ LOG_LEVEL=INFO
# Пример: 30:250,180:600,365:1000 # Пример: 30:250,180:600,365:1000
PAYMENT_PLANS=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 PAYMENT_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
# Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию # Ссылка на приватный чат/топик, куда бот отправляет чеки на модерацию
# Формат: https://t.me/c/<chat>/<topic>/<message> # Формат: https://t.me/c/<chat>/<topic>/<message>
PAYMENT_SUCCESS_TEXT=Доступ выдан. Сохраните ссылку и используйте её для подключения.
PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57 PAYMENT_REVIEW_LINK=https://t.me/c/1234567890/56/57
# Альтернативный способ указать chat id review-чата вручную # Альтернативный способ указать chat id review-чата вручную
@@ -178,15 +174,8 @@ PAYMENT_PLAN_TRAFFIC_RESET_PERIOD=NO_RESET
# UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу # UUID внутренних групп Remnawave, которые назначаются новому или продлеваемому доступу
# Несколько значений через запятую # Несколько значений через запятую
# Используется старым режимом PAYMENT_PLANS.
PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 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, если используется # UUID внешней группы Remnawave, если используется
PAYMENT_EXTERNAL_SQUAD_UUID= PAYMENT_EXTERNAL_SQUAD_UUID=

View File

@@ -11,7 +11,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
- Хранит локальный кэш пользователей и истории подписок в MariaDB - Хранит локальный кэш пользователей и истории подписок в MariaDB
- Поддерживает привязку существующего аккаунта Remnawave по `short_uuid` - Поддерживает привязку существующего аккаунта Remnawave по `short_uuid`
- Показывает профиль, статус подписки, срок и трафик - Показывает профиль, статус подписки, срок и трафик
- Работает с реферальными кодами, промокодами и скидкой перед оплатой - Работает с реферальными кодами и скидкой перед оплатой
- Поддерживает ручную оплату переводом с отправкой чека в бот - Поддерживает ручную оплату переводом с отправкой чека в бот
- Отправляет чек в отдельный Telegram-канал/топик на проверку - Отправляет чек в отдельный Telegram-канал/топик на проверку
- Даёт админу или модератору подтвердить или отклонить оплату - Даёт админу или модератору подтвердить или отклонить оплату
@@ -24,8 +24,8 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
Сейчас в проекте не используется Telegram Stars. Оплата работает вручную: Сейчас в проекте не используется Telegram Stars. Оплата работает вручную:
1. Пользователь нажимает `Купить подписку` 1. Пользователь нажимает `Купить подписку`
2. Бот показывает список тарифов из `PAYMENT_PRODUCT_PLANS`, если они заданы, иначе из `PAYMENT_PLANS` 2. Бот показывает список тарифов из `PAYMENT_PLANS`
3. Если у пользователя применён реферальный код или промокод, скидка учитывается до выбора тарифа 3. Если у пользователя применён реферальный код, скидка учитывается до выбора тарифа
4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT` 4. После выбора тарифа бот показывает реквизиты из `PAYMENT_TRANSFER_TEXT`
5. Пользователь отправляет чек следующим сообщением в бота 5. Пользователь отправляет чек следующим сообщением в бота
6. Бот пересылает чек в review-чат оплаты 6. Бот пересылает чек в review-чат оплаты
@@ -34,7 +34,7 @@ Telegram-бот на `aiogram 3` с интеграцией в `Remnawave API`,
### Тарифы ### Тарифы
Обычные тарифы по срокам задаются одной переменной: Тарифы задаются одной переменной:
```env ```env
PAYMENT_PLANS=30:250,180:600,365:1000 PAYMENT_PLANS=30:250,180:600,365:1000
@@ -51,22 +51,6 @@ PAYMENT_PLANS=30:250,180:600,365:1000
- 180 дней = 600 ₽ - 180 дней = 600 ₽
- 365 дней = 1000 ₽ - 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 ### Логин в Remnawave
Логин создаётся в формате: Логин создаётся в формате:
@@ -253,12 +237,6 @@ telegabot/
- `referral_bonuses` - `referral_bonuses`
- начисленные и ожидающие применения бонусы реферерам - начисленные и ожидающие применения бонусы реферерам
- `promo_codes`
- промокоды, скидка, срок действия и ограничение по тарифу
- `promo_code_applications`
- активный промокод, который пользователь ввёл перед оплатой
- `payment_orders` - `payment_orders`
- ручные платежи, статусы и выданные доступы - ручные платежи, статусы и выданные доступы
@@ -283,26 +261,6 @@ telegabot/
- за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях - за каждую подтверждённую оплату по рефкоду реферер получает бонус в днях
- размер бонуса задаётся в `REFERRAL_BONUS_DAYS` - размер бонуса задаётся в `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_USER=oreolvpn
DB_PASSWORD=strong_password 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_TRANSFER_TEXT=Карта 0000 0000 0000 0000; банк OREOL; получатель OREOL VPN
PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57 PAYMENT_REVIEW_LINK=https://t.me/c/3646494169/56/57
PAYMENT_VPN_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111 PAYMENT_INTERNAL_SQUAD_UUIDS=11111111-1111-1111-1111-111111111111
PAYMENT_WHITE_SQUAD_UUIDS=22222222-2222-2222-2222-222222222222
REFERRAL_DISCOUNT_PERCENT=5 REFERRAL_DISCOUNT_PERCENT=5
PAYMENT_USERNAME_PREFIX=Oreol PAYMENT_USERNAME_PREFIX=Oreol
``` ```
@@ -853,7 +810,7 @@ OperationalError: (1049, "Unknown database '...'" )
6. Проверить руками: 6. Проверить руками:
- `/start` - `/start`
- открытие раздела подписки через кнопку в панели - открытие раздела подписки через кнопку в панели
- ввод реферального кода или промокода - ввод реферального кода
- отправку чека - отправку чека
- подтверждение оплаты модератором - подтверждение оплаты модератором
- выдачу ссылки пользователю - выдачу ссылки пользователю

View File

@@ -528,6 +528,38 @@ def build_router(
builder.adjust(1) builder.adjust(1)
return builder.as_markup() 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]: def get_message_html_text(message: Message) -> tuple[str, str]:
raw_text = (message.text or message.caption or "").strip() raw_text = (message.text or message.caption or "").strip()
formatted_text = ( formatted_text = (
@@ -537,33 +569,90 @@ def build_router(
formatted_text = html.escape(raw_text) formatted_text = html.escape(raw_text)
return raw_text, formatted_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()) 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( await message.answer(
"📣 Предпросмотр рассылки.\n\n" "📣 Предпросмотр рассылки.\n\n"
f"Получателей: <b>{recipient_count}</b>\n" f"Получателей: <b>{recipient_count}</b>\n"
"Если всё верно, нажмите кнопку подтверждения ниже." "Если всё верно, нажмите кнопку подтверждения ниже."
) )
await message.answer( if mode == "poll":
text_html, await message.answer(text_html, reply_markup=build_broadcast_poll_keyboard(poll_id=0))
reply_markup=build_admin_broadcast_preview_keyboard(), 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() telegram_ids = await sync_service.get_broadcast_telegram_ids()
sent_count = 0 sent_count = 0
blocked_count = 0 blocked_count = 0
failed_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: for telegram_id in telegram_ids:
try: 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 sent_count += 1
except TelegramRetryAfter as exc: except TelegramRetryAfter as exc:
await asyncio.sleep(exc.retry_after + 1) await asyncio.sleep(exc.retry_after + 1)
try: 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 sent_count += 1
except TelegramForbiddenError: except TelegramForbiddenError:
blocked_count += 1 blocked_count += 1
@@ -582,6 +671,48 @@ def build_router(
return sent_count, blocked_count, failed_count 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: def admin_settings_callback(action: str, value: str | None = None) -> str:
return f"admin:{action}:{value}" if value else f"admin:{action}" return f"admin:{action}:{value}" if value else f"admin:{action}"
@@ -1887,6 +2018,7 @@ def build_router(
expire_at=expire_at, expire_at=expire_at,
traffic_limit_bytes=traffic_limit_bytes, traffic_limit_bytes=traffic_limit_bytes,
brand_name=config.bot_brand_name, brand_name=config.bot_brand_name,
success_text=config.payment_success_text,
is_renewal=is_renewal, is_renewal=is_renewal,
) )
reply_markup = build_issued_access_keyboard(subscription_url=subscription_url) reply_markup = build_issued_access_keyboard(subscription_url=subscription_url)
@@ -2208,11 +2340,30 @@ def build_router(
return return
source_text = callback.message.caption or callback.message.text or "" source_text = callback.message.caption or callback.message.text or ""
updated_text = f"{source_text}\n\n<b>Статус</b>: {status_text}" status_suffix = f"\n\n<b>Статус</b>: {status_text}"
if callback.message.photo or callback.message.document: is_caption = bool(callback.message.photo or callback.message.document)
await callback.message.edit_caption(caption=updated_text, reply_markup=None) max_length = 1024 if is_caption else 4096
else:
await callback.message.edit_text(updated_text, reply_markup=None) 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(CommandStart())
@router.message(Command("menu")) @router.message(Command("menu"))
@@ -2279,6 +2430,7 @@ def build_router(
text = (command.args or "").strip() text = (command.args or "").strip()
if not text: if not text:
await state.set_state(AdminBroadcastStates.waiting_for_text) await state.set_state(AdminBroadcastStates.waiting_for_text)
await state.update_data(broadcast_mode="notice")
await message.answer( await message.answer(
"📣 Отправьте текст рассылки следующим сообщением.\n\n" "📣 Отправьте текст рассылки следующим сообщением.\n\n"
"Чтобы отменить действие, отправьте <code>/cancel</code>." "Чтобы отменить действие, отправьте <code>/cancel</code>."
@@ -2291,7 +2443,44 @@ def build_router(
return return
await state.set_state(AdminBroadcastStates.waiting_for_text) 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") @router.callback_query(F.data == "guides:menu")
async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None: async def on_guides_menu(callback: CallbackQuery, state: FSMContext) -> None:
@@ -2851,14 +3040,113 @@ def build_router(
return return
await state.clear() await state.clear()
await state.set_state(AdminBroadcastStates.waiting_for_text)
await callback.message.answer( await callback.message.answer(
"📣 Отправьте текст рассылки следующим сообщением.\n\n" "📣 Выберите тип рассылки.",
"Бот покажет предпросмотр и попросит подтвердить отправку.\n" reply_markup=build_admin_broadcast_mode_keyboard(),
"Чтобы отменить действие, отправьте <code>/cancel</code>."
) )
await callback.answer() 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") @router.callback_query(F.data == "admin:broadcast_cancel")
async def on_admin_broadcast_cancel(callback: CallbackQuery, state: FSMContext) -> None: async def on_admin_broadcast_cancel(callback: CallbackQuery, state: FSMContext) -> None:
if callback.from_user is None: if callback.from_user is None:
@@ -2884,6 +3172,8 @@ def build_router(
state_data = await state.get_data() state_data = await state.get_data()
text_html = str(state_data.get("broadcast_text_html") or "").strip() 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: if not text_html:
await state.clear() await state.clear()
await callback.answer("Текст рассылки не найден", show_alert=True) await callback.answer("Текст рассылки не найден", show_alert=True)
@@ -2894,9 +3184,18 @@ def build_router(
if callback.message is not None: if callback.message is not None:
await callback.message.answer("📣 Рассылка запущена. Пришлю итог после отправки.") 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( sent_count, blocked_count, failed_count = await send_admin_broadcast(
callback.bot, callback.bot,
text_html=text_html, text_html=text_html,
poll_id=poll_id,
) )
if callback.message is not None: if callback.message is not None:
await callback.message.answer( await callback.message.answer(
@@ -2904,6 +3203,7 @@ def build_router(
f"Отправлено: <b>{sent_count}</b>\n" f"Отправлено: <b>{sent_count}</b>\n"
f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n" f"Недоступны/заблокировали бота: <b>{blocked_count}</b>\n"
f"Ошибок отправки: <b>{failed_count}</b>" 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") @router.callback_query(F.data == "admin:sync_all")
@@ -3281,6 +3581,7 @@ def build_router(
await callback.answer("Недостаточно прав", show_alert=True) await callback.answer("Недостаточно прав", show_alert=True)
return return
await callback.answer()
await clear_interactive_states(state) await clear_interactive_states(state)
raw_page = (callback.data or "").split(":", maxsplit=2)[2] raw_page = (callback.data or "").split(":", maxsplit=2)[2]
try: try:
@@ -3294,7 +3595,6 @@ def build_router(
text=render_admin_users_page_text(page_data=page_data), text=render_admin_users_page_text(page_data=page_data),
reply_markup=build_admin_users_page_keyboard(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:")) @router.callback_query(F.data.startswith("admin:user_view:"))
async def on_admin_user_profile(callback: CallbackQuery, state: FSMContext) -> None: async def on_admin_user_profile(callback: CallbackQuery, state: FSMContext) -> None:
@@ -3318,9 +3618,10 @@ def build_router(
await callback.answer("Некорректный пользователь", show_alert=True) await callback.answer("Некорректный пользователь", show_alert=True)
return return
await callback.answer()
profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id) profile = await sync_service.get_admin_telegram_user_profile(telegram_id=telegram_id)
if profile is None: if profile is None:
await callback.answer("Пользователь не найден", show_alert=True) await callback.message.answer("Пользователь не найден.")
return return
await show_branded_view( await show_branded_view(
@@ -3332,7 +3633,6 @@ def build_router(
page=page, page=page,
), ),
) )
await callback.answer()
# ── Admin: Block / Unblock user ────────────────────────────────── # ── Admin: Block / Unblock user ──────────────────────────────────
@@ -4080,14 +4380,21 @@ def build_router(
return return
raw_text, text_html = get_message_html_text(message) 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: if not raw_text:
await message.answer("Отправьте текст рассылки одним сообщением.") await message.answer("Отправьте текст рассылки одним сообщением.")
return return
if len(text_html) > 4000: if len(text_html) > 3800:
await message.answer("Текст слишком длинный. Максимум — около 4000 символов.") await message.answer("Текст слишком длинный. Максимум — около 3800 символов.")
return 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) @router.message(AdminConfigStates.waiting_for_value)
async def on_admin_setting_value(message: Message, state: FSMContext) -> None: 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 append_review_status(callback, status_text="❌ отклонено")
await callback.answer("Оплата отклонена") 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: if True:
@router.callback_query(F.data.startswith("ticket_admin:note:")) @router.callback_query(F.data.startswith("ticket_admin:note:"))

View File

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

View File

@@ -108,6 +108,10 @@ class Settings(BaseSettings):
default="Переведите оплату по указанным реквизитам и отправьте чек в бот.", default="Переведите оплату по указанным реквизитам и отправьте чек в бот.",
validation_alias="PAYMENT_TRANSFER_TEXT", 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_link: str = Field(default="", validation_alias="PAYMENT_REVIEW_LINK")
payment_review_chat_id_raw: str = Field( payment_review_chat_id_raw: str = Field(
default="", default="",
@@ -117,6 +121,18 @@ class Settings(BaseSettings):
default=0, default=0,
validation_alias="PAYMENT_REVIEW_THREAD_ID", 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( referral_discount_percent: int = Field(
default=5, default=5,
validation_alias="REFERRAL_DISCOUNT_PERCENT", validation_alias="REFERRAL_DISCOUNT_PERCENT",
@@ -241,6 +257,22 @@ class Settings(BaseSettings):
_, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link) _, parsed_thread_id = self._parse_private_topic_link(self.payment_review_link)
return parsed_thread_id 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 @staticmethod
def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]: def _parse_private_topic_link(link: str) -> tuple[int | None, int | None]:
cleaned = link.strip() cleaned = link.strip()

View File

@@ -266,6 +266,42 @@ class SubscriptionNotification(Base):
sent_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=utcnow) 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): class PaymentOrder(Base):
__tablename__ = "payment_orders" __tablename__ = "payment_orders"

View File

@@ -73,3 +73,19 @@ class SubscriptionRequestRecord(RemnawaveBaseModel):
class SubscriptionRequestHistory(RemnawaveBaseModel): class SubscriptionRequestHistory(RemnawaveBaseModel):
total: int total: int
records: list[SubscriptionRequestRecord] 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_terms_url: str
bot_support_ticket_link: str bot_support_ticket_link: str
payment_review_link: str payment_review_link: str
broadcast_results_link: str
bot_start_image_enabled: bool bot_start_image_enabled: bool
bot_start_image_path: str bot_start_image_path: str
referral_enabled: bool referral_enabled: bool
@@ -36,6 +37,7 @@ class BotConfigSnapshot:
payment_plans_raw: str payment_plans_raw: str
payment_product_plans_raw: str payment_product_plans_raw: str
payment_transfer_text: str payment_transfer_text: str
payment_success_text: str
payment_support_text: str payment_support_text: str
payment_plan_traffic_limit_gb: int payment_plan_traffic_limit_gb: int
payment_plan_traffic_reset_period: str payment_plan_traffic_reset_period: str
@@ -52,6 +54,8 @@ class BotConfigSnapshot:
fallback_support_ticket_thread_id: int | None = None fallback_support_ticket_thread_id: int | None = None
fallback_payment_review_chat_id: int | None = None fallback_payment_review_chat_id: int | None = None
fallback_payment_review_thread_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 @property
def bot_public_username_normalized(self) -> str: def bot_public_username_normalized(self) -> str:
@@ -245,6 +249,24 @@ class BotConfigSnapshot:
else self.fallback_payment_review_thread_id 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: class StaticBotConfigService:
def __init__(self, settings: Settings) -> None: def __init__(self, settings: Settings) -> None:
@@ -258,6 +280,7 @@ class StaticBotConfigService:
bot_terms_url=self._settings.bot_terms_url, bot_terms_url=self._settings.bot_terms_url,
bot_support_ticket_link=self._settings.bot_support_ticket_link, bot_support_ticket_link=self._settings.bot_support_ticket_link,
payment_review_link=self._settings.payment_review_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_enabled=self._settings.bot_start_image_enabled,
bot_start_image_path=self._settings.bot_start_image_path, bot_start_image_path=self._settings.bot_start_image_path,
referral_enabled=True, referral_enabled=True,
@@ -266,6 +289,11 @@ class StaticBotConfigService:
payment_plans_raw=self._settings.payment_plans_raw, payment_plans_raw=self._settings.payment_plans_raw,
payment_product_plans_raw=self._settings.payment_product_plans_raw, payment_product_plans_raw=self._settings.payment_product_plans_raw,
payment_transfer_text=self._settings.payment_transfer_text, 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_support_text=self._settings.payment_support_text,
payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb, payment_plan_traffic_limit_gb=self._settings.payment_plan_traffic_limit_gb,
payment_plan_traffic_reset_period=self._settings.payment_plan_traffic_reset_period, 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_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_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_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>`.", description="Формат: `https://t.me/c/<chat>/<topic>/<message>`.",
placeholder="https://t.me/c/1234567890/56/57", 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( "payment_transfer_text": BotConfigFieldSpec(
key="payment_transfer_text", key="payment_transfer_text",
label="Инструкция по оплате", label="Инструкция по оплате",
@@ -430,6 +468,14 @@ class BotConfigService(StaticBotConfigService):
description="Используется при отклонении оплаты и ошибках выдачи доступа.", description="Используется при отклонении оплаты и ошибках выдачи доступа.",
placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.", placeholder="Если оплата прошла, но доступ не выдался, напишите в поддержку.",
), ),
"payment_success_text": BotConfigFieldSpec(
key="payment_success_text",
label="Текст после покупки",
section="texts",
prompt="Введите текст, который бот добавит к сообщению со ссылкой после подтверждения оплаты.",
description="Показывается покупателю вместе со ссылкой на подписку после выдачи или продления доступа.",
placeholder="Доступ выдан. Сохраните ссылку и используйте её для подключения.",
),
"payment_internal_squad_uuids_raw": BotConfigFieldSpec( "payment_internal_squad_uuids_raw": BotConfigFieldSpec(
key="payment_internal_squad_uuids_raw", key="payment_internal_squad_uuids_raw",
label="UUID внутренних групп", label="UUID внутренних групп",
@@ -533,6 +579,12 @@ class BotConfigService(StaticBotConfigService):
bot_terms_url=str(overrides.get("bot_terms_url", self._settings.bot_terms_url)), 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)), 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)), 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( bot_start_image_enabled=self._parse_bool(
overrides.get("bot_start_image_enabled"), overrides.get("bot_start_image_enabled"),
default=self._settings.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_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_support_text=str(overrides.get("payment_support_text", self._settings.payment_support_text)),
payment_plan_traffic_limit_gb=self._parse_int( payment_plan_traffic_limit_gb=self._parse_int(
overrides.get("payment_plan_traffic_limit_gb"), 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_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_chat_id=self._settings.payment_review_chat_id,
fallback_payment_review_thread_id=self._settings.payment_review_message_thread_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( async def update_setting(
@@ -672,6 +736,7 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url", "bot_terms_url",
"bot_support_ticket_link", "bot_support_ticket_link",
"payment_review_link", "payment_review_link",
"broadcast_results_link",
"bot_start_image_path", "bot_start_image_path",
"payment_product_plans_raw", "payment_product_plans_raw",
"payment_vpn_squad_uuids_raw", "payment_vpn_squad_uuids_raw",
@@ -703,7 +768,7 @@ class BotConfigService(StaticBotConfigService):
if key == "notification_check_interval_hours": if key == "notification_check_interval_hours":
return f"{max(int(value or 6), 1)} ч" 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), max_length=120)
return cls._compact_text(str(value)) return cls._compact_text(str(value))
@@ -732,6 +797,7 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url", "bot_terms_url",
"bot_support_ticket_link", "bot_support_ticket_link",
"payment_review_link", "payment_review_link",
"broadcast_results_link",
"bot_start_image_path", "bot_start_image_path",
"payment_external_squad_uuid", "payment_external_squad_uuid",
"payment_user_tag", "payment_user_tag",
@@ -798,10 +864,11 @@ class BotConfigService(StaticBotConfigService):
"bot_terms_url", "bot_terms_url",
"bot_support_ticket_link", "bot_support_ticket_link",
"payment_review_link", "payment_review_link",
"broadcast_results_link",
}: }:
return value 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: if not value:
raise ValueError("Текст не может быть пустым.") raise ValueError("Текст не может быть пустым.")
return value[:4000] return value[:4000]

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import html import html
import logging import logging
import unicodedata
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from math import ceil from math import ceil
@@ -30,6 +31,77 @@ REFERRAL_BONUS_APPLIED_STATUS = "APPLIED"
logger = logging.getLogger(__name__) 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) @dataclass(slots=True)
class AppliedDiscount: class AppliedDiscount:
@@ -196,14 +268,35 @@ class PaymentService:
) )
existing_accesses = await self._sync_service.get_cached_users_for_telegram(telegram_id) 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()) order_uuid = str(uuid4())
invoice_payload = f"manual:{plan.code}:{order_uuid}" invoice_payload = f"manual:{plan.code}:{order_uuid}"
provision_username = self._build_provision_username( provision_username = (
config=config, renewal_target_username
order_uuid=order_uuid, or self._build_provision_username(
telegram_id=telegram_id, config=config,
username=username, order_uuid=order_uuid,
first_name=first_name, telegram_id=telegram_id,
username=username,
first_name=first_name,
)
) )
async with self._session_factory() as session: async with self._session_factory() as session:
@@ -230,6 +323,7 @@ class PaymentService:
status=PENDING_PAYMENT_STATUS, status=PENDING_PAYMENT_STATUS,
invoice_payload=invoice_payload, invoice_payload=invoice_payload,
provision_username=provision_username, provision_username=provision_username,
remnawave_user_uuid=renewal_target_uuid,
error_message=plan.discount_code or None, error_message=plan.discount_code or None,
) )
) )
@@ -240,7 +334,7 @@ class PaymentService:
provision_username=provision_username, provision_username=provision_username,
plan=plan, plan=plan,
transfer_text=config.payment_transfer_text.strip(), 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: async def get_order_for_user(self, *, order_uuid: str, telegram_id: int) -> StoredPaymentOrder:
@@ -350,10 +444,17 @@ class PaymentService:
raise raise
if remote_user is None: if remote_user is None:
remote_users = await self._remnawave_client.get_users_by_telegram_id( try:
order.telegram_id remote_users = await self._remnawave_client.get_users_by_telegram_id(
) order.telegram_id
remote_user = self._select_referral_bonus_target(remote_users) )
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: if remote_user is None:
remote_user = await self._create_remnawave_user( remote_user = await self._create_remnawave_user(
@@ -375,6 +476,19 @@ class PaymentService:
except RemnawaveApiError as exc: except RemnawaveApiError as exc:
await self._restore_review_after_failed_approval(order.order_uuid, exc.message) await self._restore_review_after_failed_approval(order.order_uuid, exc.message)
raise 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) await self._save_fulfilled_order(order.order_uuid, remote_user)
@@ -772,7 +886,21 @@ class PaymentService:
if order.status == PENDING_PAYMENT_STATUS: if order.status == PENDING_PAYMENT_STATUS:
raise ValueError("Чек ещё не отправлен на проверку.") raise ValueError("Чек ещё не отправлен на проверку.")
if order.status == PROCESSING_PAYMENT_STATUS: 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: if order.status == REJECTED_PAYMENT_STATUS:
raise ValueError("Заказ уже отклонён.") raise ValueError("Заказ уже отклонён.")
if order.status == FULFILLED_PAYMENT_STATUS: if order.status == FULFILLED_PAYMENT_STATUS:
@@ -806,7 +934,33 @@ class PaymentService:
body["tag"] = config.payment_user_tag_normalized body["tag"] = config.payment_user_tag_normalized
if config.payment_external_squad_uuid_normalized: if config.payment_external_squad_uuid_normalized:
body["externalSquadUuid"] = 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( async def _extend_remnawave_user(
self, self,
@@ -1068,6 +1222,28 @@ class PaymentService:
return sorted(remote_users, key=_sort_key)[0] 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( async def _upsert_telegram_user(
self, self,
session: AsyncSession, session: AsyncSession,
@@ -1107,13 +1283,19 @@ class PaymentService:
first_name: str | None, first_name: str | None,
) -> str: ) -> str:
base_name = username or first_name or "user" base_name = username or first_name or "user"
normalized_name = "".join( normalized_name = self._normalize_provision_name(base_name)
ch for ch in base_name if ch.isalnum() or ch in "_-"
).strip("-_")
if not normalized_name:
normalized_name = "user"
prefix = config.payment_username_prefix_normalized or "Oreol" prefix = config.payment_username_prefix_normalized or "Oreol"
unique_suffix = order_uuid.replace("-", "")[:6] unique_suffix = order_uuid.replace("-", "")[:6]
provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}" provision_username = f"{prefix}-{telegram_id}-{unique_suffix}-{normalized_name}"
return provision_username[:36] 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 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( UUID_RE = re.compile(
@@ -145,6 +145,26 @@ class RemnawaveApiClient:
payload = await self._request_json("POST", "users/resolve", json=body) payload = await self._request_json("POST", "users/resolve", json=body)
return ResolvedUser.model_validate(payload) 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( async def _request_json(
self, self,
method: str, method: str,
@@ -153,12 +173,28 @@ class RemnawaveApiClient:
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
json: dict[str, Any] | None = None, json: dict[str, Any] | None = None,
) -> Any: ) -> Any:
response = await self._client.request( try:
method=method, response = await self._client.request(
url=path.lstrip("/"), method=method,
params=params, url=path.lstrip("/"),
json=json, 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: if response.is_error:
raise self._build_error(response) 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.config import Settings
from app.db.base import utcnow from app.db.base import utcnow
from app.db.models import ( from app.db.models import (
BroadcastPoll,
BroadcastPollAnswer,
Guide, Guide,
GuidePhoto, GuidePhoto,
InternalSquad, InternalSquad,
@@ -124,6 +126,13 @@ class AdminTelegramUserProfile:
accesses: list[CachedUserView] = field(default_factory=list) accesses: list[CachedUserView] = field(default_factory=list)
@dataclass(slots=True)
class BroadcastPollAnswerResult:
poll: BroadcastPoll
answer: BroadcastPollAnswer
previous_answer: str | None = None
class SyncService: class SyncService:
def __init__( def __init__(
self, self,
@@ -571,6 +580,69 @@ class SyncService:
) )
return list(rows.all()) 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( async def get_admin_telegram_users_page(
self, self,
*, *,

View File

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

View File

@@ -5,11 +5,17 @@ services:
env_file: env_file:
- .env - .env
environment: environment:
DB_HOST: db HTTP_PROXY: http://mihomo:7890
DB_PORT: 3306 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: depends_on:
db: mihomo:
condition: service_healthy condition: service_started
db: db:
image: mariadb:11.7 image: mariadb:11.7
@@ -27,5 +33,29 @@ services:
timeout: 5s timeout: 5s
retries: 10 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: volumes:
mariadb_data: mariadb_data:

View File

@@ -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:

BIN
mihomo/cache.db Normal file

Binary file not shown.

36
mihomo/config.yaml Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
dmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZANDUuMTAuMTY1LjI0Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQTklRjAlOUYlODclQUElMjAlRDAlOTMlRDAlQjUlRDElODAlRDAlQkMlRDAlQjAlRDAlQkQlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC44NC4yMjc6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWNocm9tZSZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjglRjAlOUYlODclQUElMjAlRDAlQTglRDAlQjIlRDAlQjUlRDElODYlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC40LjIwNjo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBQSVGMCU5RiU4NyVBQSUyMCVEMCVBRCVEMSU4MSVEMSU4MiVEMCVCRSVEMCVCRCVEMCVCOCVEMSU4Rgp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA4OS4zNy4xOTIuNTQ6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWZpcmVmb3gmcGJrPUZkVHpfSjZ3bjA4U0g1dmlOUUUwXzVuYTNZU3VPejBzY09Dbjk4VnpKaVEjJUYwJTlGJTg3JUI3JUYwJTlGJTg3JUI0JTIwJUQwJUEwJUQxJTgzJUQwJUJDJUQxJThCJUQwJUJEJUQwJUI4JUQxJThGCnZsZXNzOi8vZTAzMjMxNDItOWE1NC00ODBlLTkyZWEtMmIwM2Y1YWNjNzM2QDE5My4yMDIuMTEuNTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVCQSVGMCU5RiU4NyVCOCUyMFVTQQp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA5MS45Mi4yMS41Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjUlRjAlOUYlODclQjElMjAlRDAlOUYlRDAlQkUlRDAlQkIlRDElOEMlRDElODglRDAlQjAlMjBEYXRpZ28Kdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTkxLjQ0LjExOC43OTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBOSVGMCU5RiU4NyVBQSUyMCUyMCVEMCU5MyVEMCVCNSVEMSU4MCVEMCVCQyVEMCVCMCVEMCVCRCVEMCVCOCVEMSU4RiUyMERhdGlnbw==

View File

@@ -19,14 +19,12 @@ def _make_settings() -> Settings:
referral_discount_percent=5, referral_discount_percent=5,
referral_bonus_days=7, referral_bonus_days=7,
payment_plans_raw="30:250,180:600", payment_plans_raw="30:250,180:600",
payment_product_plans_raw="",
payment_transfer_text="Реквизиты", payment_transfer_text="Реквизиты",
payment_support_text="Напишите в поддержку", payment_support_text="Напишите в поддержку",
payment_success_text="Доступ выдан",
payment_plan_traffic_limit_gb=0, payment_plan_traffic_limit_gb=0,
payment_plan_traffic_reset_period="NO_RESET", payment_plan_traffic_reset_period="NO_RESET",
payment_internal_squad_uuids_raw="uuid-1", 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_external_squad_uuid="",
payment_username_prefix="Oreol", payment_username_prefix="Oreol",
payment_user_tag="BOT", 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, updated_by_telegram_id=1,
) )
await service.update_setting( await service.update_setting(
key="payment_product_plans_raw", key="referral_enabled",
raw_value="vpn_white:30:450,white:30:250,vpn:30:200", raw_value="off",
updated_by_telegram_id=1, updated_by_telegram_id=1,
) )
await service.update_setting( await service.update_setting(
key="referral_enabled", key="payment_success_text",
raw_value="off", raw_value="Новый текст после оплаты",
updated_by_telegram_id=1, 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.bot_brand_name == "NEW BRAND"
assert snapshot.referral_enabled is False assert snapshot.referral_enabled is False
assert [(plan.code, plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [ assert snapshot.payment_success_text == "Новый текст после оплаты"
("vpn_white", 30, 450), assert [(plan.days, plan.amount_rub) for plan in snapshot.payment_plans] == [
("white", 30, 250), (30, 300),
("vpn", 30, 200), (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="bot_public_username") == "@oreol_vpn_bot"
assert BotConfigService.format_value(snapshot=snapshot, key="referral_enabled") == "on" 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_plan_traffic_limit_gb") == "0 GB (unlimited)"
assert BotConfigService.format_value(snapshot=snapshot, key="payment_vpn_squad_uuids_raw") == "vpn-uuid"

View File

@@ -79,22 +79,3 @@ def test_payment_plans_are_parsed() -> None:
assert [plan.code for plan in plans] == ["30d", "180d", "365d"] assert [plan.code for plan in plans] == ["30d", "180d", "365d"]
assert [plan.days for plan in plans] == [30, 180, 365] assert [plan.days for plan in plans] == [30, 180, 365]
assert [plan.amount_rub for plan in plans] == [250, 600, 1000] 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",),
]

View File

@@ -75,6 +75,19 @@ def test_format_issued_access_contains_subscription_data() -> None:
assert "/me</code>" not in text assert "/me</code>" 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 "&lt;важно&gt;" in text
def test_format_issued_access_for_renewal_mentions_extension() -> None: def test_format_issued_access_for_renewal_mentions_extension() -> None:
text = format_issued_access( text = format_issued_access(
subscription_url="https://example.com/sub", subscription_url="https://example.com/sub",

View File

@@ -21,15 +21,9 @@ from tests.helpers import make_remote_user
class _StubSyncService: class _StubSyncService:
def __init__( def __init__(self, *, cached_users_by_telegram: dict[int, list] | None = None) -> None:
self,
*,
cached_users_by_telegram: dict[int, list] | None = None,
active_promo_by_telegram: dict[int, object] | None = None,
) -> None:
self.refreshed: list[int] = [] self.refreshed: list[int] = []
self.cached_users_by_telegram = cached_users_by_telegram or {} 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): async def get_referral_summary(self, telegram_id: int):
return type( return type(
@@ -50,9 +44,6 @@ class _StubSyncService:
async def get_cached_users_for_telegram(self, telegram_id: int): async def get_cached_users_for_telegram(self, telegram_id: int):
return list(self.cached_users_by_telegram.get(telegram_id, [])) 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: class _StubRemnawaveClient:
def __init__(self, *, created_user, users_by_telegram_id: dict[int, list] | None = None) -> None: 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 {} self.users_by_telegram_id = users_by_telegram_id or {}
async def get_user_by_uuid(self, user_uuid: str): 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") raise RemnawaveApiError(status_code=404, message="not found")
async def get_user_by_username(self, username: str): async def get_user_by_username(self, username: str):
@@ -107,9 +102,6 @@ async def _seed_order(
telegram_id: int, telegram_id: int,
status: str, status: str,
order_uuid: str | None = None, order_uuid: str | None = None,
plan_code: str = "30d",
plan_title: str = "OREOL VPN на 30 дней",
amount_stars: int = 250,
) -> str: ) -> str:
resolved_order_uuid = order_uuid or str(uuid4()) resolved_order_uuid = order_uuid or str(uuid4())
@@ -128,12 +120,12 @@ async def _seed_order(
order_uuid=resolved_order_uuid, order_uuid=resolved_order_uuid,
telegram_user_id=user.id, telegram_user_id=user.id,
telegram_id=telegram_id, telegram_id=telegram_id,
plan_code=plan_code, plan_code="30d",
plan_title=plan_title, plan_title="OREOL VPN на 30 дней",
plan_duration_days=30, plan_duration_days=30,
traffic_limit_bytes=0, traffic_limit_bytes=0,
traffic_limit_strategy="NO_RESET", traffic_limit_strategy="NO_RESET",
amount_stars=amount_stars, amount_stars=250,
currency="RUB", currency="RUB",
status=status, status=status,
invoice_payload=f"payload-{resolved_order_uuid}", invoice_payload=f"payload-{resolved_order_uuid}",
@@ -167,29 +159,7 @@ async def _seed_referral_invite(
def _make_settings() -> Settings: def _make_settings() -> Settings:
return Settings.model_construct( return Settings.model_construct(
bot_admin_ids_raw="", bot_admin_ids_raw="",
payment_product_plans_raw="",
payment_internal_squad_uuids_raw="11111111-1111-1111-1111-111111111111", 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_user_tag="BOT",
payment_external_squad_uuid="", payment_external_squad_uuid="",
payment_username_prefix="Oreol", 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) 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: async def test_approve_order_grants_and_applies_referral_bonus(session_factory) -> None:
buyer_remote_user = make_remote_user( buyer_remote_user = make_remote_user(
user_uuid="44444444-4444-4444-4444-444444444444", 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 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( 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, session_factory=session_factory,
remnawave_client=_StubRemnawaveClient( remnawave_client=_StubRemnawaveClient(
created_user=make_remote_user( created_user=make_remote_user(
user_uuid="99999999-9999-9999-9999-999999999999", user_uuid="99999999-9999-9999-9999-999999999999",
user_id=9, user_id=9,
short_uuid="buyer999", short_uuid="buyer999",
username="oreol-product", username="oreol-user",
telegram_id=1001, 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( order = await service.create_order(
telegram_id=1001, telegram_id=1001,
username="tester", username=None,
first_name="Test", first_name="\u0410\u043b\u0435\u043a\u0441\u0435\u0439",
last_name=None, last_name=None,
language_code="ru", language_code="ru",
plan_code="vpn_white", plan_code="30d",
) )
assert order.plan.label == "VPN + Белые списки" assert order.provision_username.startswith("Oreol-1001-")
assert order.plan.amount_rub == 450 assert order.provision_username.endswith("-Aleksey")
assert order.plan.internal_squad_uuids == [ assert order.provision_username.isascii()
"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"
async def test_create_order_marks_existing_access_as_renewal(session_factory) -> None: 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) 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: async def test_approve_order_extends_existing_user_found_by_telegram_id(session_factory) -> None:
existing_remote_user = make_remote_user( existing_remote_user = make_remote_user(
user_uuid="88888888-8888-8888-8888-888888888888", user_uuid="88888888-8888-8888-8888-888888888888",

View File

@@ -1,19 +1,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime
from sqlalchemy import select from sqlalchemy import select
from app.config import Settings from app.config import Settings
from app.db.base import utcnow from app.db.models import ReferralCode, ReferralInvite, RemnawaveUser, TelegramUser
from app.db.models import (
PromoCode,
PromoCodeApplication,
ReferralCode,
ReferralInvite,
RemnawaveUser,
TelegramUser,
)
from app.schemas.remnawave import PaginatedUsers from app.schemas.remnawave import PaginatedUsers
from app.services.sync_service import SyncService from app.services.sync_service import SyncService
from tests.helpers import make_remote_user 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" 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( async def test_sync_all_users_does_not_autoflush_half_built_user(
session_factory, session_factory,
) -> None: ) -> None: