add
This commit is contained in:
213
.env.bak-20260709061701
Normal file
213
.env.bak-20260709061701
Normal 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
|
||||||
15
.env.example
15
.env.example
@@ -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=
|
||||||
|
|
||||||
|
|||||||
57
README.md
57
README.md
@@ -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`
|
||||||
- открытие раздела подписки через кнопку в панели
|
- открытие раздела подписки через кнопку в панели
|
||||||
- ввод реферального кода или промокода
|
- ввод реферального кода
|
||||||
- отправку чека
|
- отправку чека
|
||||||
- подтверждение оплаты модератором
|
- подтверждение оплаты модератором
|
||||||
- выдачу ссылки пользователю
|
- выдачу ссылки пользователю
|
||||||
|
|||||||
@@ -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"
|
||||||
"Если всё верно, нажмите кнопку подтверждения ниже."
|
"Если всё верно, нажмите кнопку подтверждения ниже."
|
||||||
)
|
)
|
||||||
|
if mode == "poll":
|
||||||
|
await message.answer(text_html, reply_markup=build_broadcast_poll_keyboard(poll_id=0))
|
||||||
await message.answer(
|
await message.answer(
|
||||||
text_html,
|
"У пользователей под этим сообщением будут кнопки <b>Да</b> и <b>Нет</b>.",
|
||||||
reply_markup=build_admin_broadcast_preview_keyboard(),
|
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)
|
||||||
|
max_length = 1024 if is_caption else 4096
|
||||||
|
|
||||||
|
updated_text = f"{source_text}{status_suffix}"
|
||||||
|
if len(updated_text) > max_length:
|
||||||
|
# Truncate the original text so the status fits within the limit
|
||||||
|
available = max_length - len(status_suffix) - 3 # 3 for "..."
|
||||||
|
updated_text = f"{source_text[:max(0, available)]}...{status_suffix}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_caption:
|
||||||
await callback.message.edit_caption(caption=updated_text, reply_markup=None)
|
await callback.message.edit_caption(caption=updated_text, reply_markup=None)
|
||||||
else:
|
else:
|
||||||
await callback.message.edit_text(updated_text, reply_markup=None)
|
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"
|
"📣 Выберите тип рассылки.",
|
||||||
|
reply_markup=build_admin_broadcast_mode_keyboard(),
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("admin:broadcast_mode:"))
|
||||||
|
async def on_admin_broadcast_mode(callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
if callback.from_user is None or callback.message is None:
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
if not settings.is_admin(callback.from_user.id):
|
||||||
|
await callback.answer("Недостаточно прав", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
mode = (callback.data or "").split(":")[-1]
|
||||||
|
if mode not in {"notice", "poll"}:
|
||||||
|
await callback.answer("Тип рассылки не найден", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.clear()
|
||||||
|
await state.set_state(AdminBroadcastStates.waiting_for_text)
|
||||||
|
await state.update_data(broadcast_mode=mode)
|
||||||
|
if mode == "poll":
|
||||||
|
prompt = (
|
||||||
|
"✅ Отправьте текст вопроса для опроса Да/Нет следующим сообщением.\n\n"
|
||||||
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
|
"Бот покажет предпросмотр и попросит подтвердить отправку.\n"
|
||||||
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
"📣 Отправьте текст уведомления следующим сообщением.\n\n"
|
||||||
|
"Бот красиво оформит текст, покажет предпросмотр и попросит подтвердить отправку.\n"
|
||||||
|
"Чтобы отменить действие, отправьте <code>/cancel</code>."
|
||||||
|
)
|
||||||
|
|
||||||
|
await callback.message.answer(prompt)
|
||||||
await callback.answer()
|
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:"))
|
||||||
|
|||||||
@@ -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 has_active:
|
||||||
|
if context.is_admin and context.referral_enabled:
|
||||||
|
builder.adjust(1, 1, 2, 1, 2, 1)
|
||||||
|
elif context.is_admin:
|
||||||
|
builder.adjust(1, 1, 2, 1, 1, 1)
|
||||||
|
elif context.referral_enabled:
|
||||||
|
builder.adjust(1, 1, 2, 2, 1)
|
||||||
|
else:
|
||||||
|
builder.adjust(1, 1, 2, 1, 1)
|
||||||
|
else:
|
||||||
if context.is_admin and context.referral_enabled:
|
if context.is_admin and context.referral_enabled:
|
||||||
builder.adjust(1, 1, 1, 2, 1)
|
builder.adjust(1, 1, 1, 2, 1)
|
||||||
elif context.is_admin:
|
elif context.is_admin:
|
||||||
builder.adjust(1, 1, 1, 1, 1)
|
builder.adjust(1, 1, 1, 1, 1)
|
||||||
else:
|
elif context.referral_enabled:
|
||||||
if 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:
|
||||||
|
if has_active:
|
||||||
|
if context.is_admin and context.referral_enabled:
|
||||||
|
builder.adjust(1, 2, 1, 2, 1)
|
||||||
|
elif context.is_admin:
|
||||||
|
builder.adjust(1, 2, 1, 1, 1)
|
||||||
|
elif context.referral_enabled:
|
||||||
|
builder.adjust(1, 2, 2, 1)
|
||||||
|
else:
|
||||||
|
builder.adjust(1, 2, 1, 1)
|
||||||
else:
|
else:
|
||||||
if context.is_admin and context.referral_enabled:
|
if context.is_admin and context.referral_enabled:
|
||||||
builder.adjust(1, 1, 2, 1)
|
builder.adjust(1, 1, 2, 1)
|
||||||
elif context.is_admin:
|
elif context.is_admin:
|
||||||
builder.adjust(1, 1, 1, 1)
|
builder.adjust(1, 1, 1, 1)
|
||||||
else:
|
elif context.referral_enabled:
|
||||||
if 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,15 +268,36 @@ 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 = (
|
||||||
|
renewal_target_username
|
||||||
|
or self._build_provision_username(
|
||||||
config=config,
|
config=config,
|
||||||
order_uuid=order_uuid,
|
order_uuid=order_uuid,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
username=username,
|
username=username,
|
||||||
first_name=first_name,
|
first_name=first_name,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async with self._session_factory() as session:
|
async with self._session_factory() as session:
|
||||||
user = await self._upsert_telegram_user(
|
user = await self._upsert_telegram_user(
|
||||||
@@ -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:
|
||||||
|
try:
|
||||||
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
remote_users = await self._remnawave_client.get_users_by_telegram_id(
|
||||||
order.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:
|
||||||
|
# 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("Заказ уже обрабатывается другим модератором.")
|
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
|
||||||
|
|
||||||
|
try:
|
||||||
return await self._remnawave_client.create_user(body)
|
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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
try:
|
||||||
response = await self._client.request(
|
response = await self._client.request(
|
||||||
method=method,
|
method=method,
|
||||||
url=path.lstrip("/"),
|
url=path.lstrip("/"),
|
||||||
params=params,
|
params=params,
|
||||||
json=json,
|
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)
|
||||||
|
|||||||
@@ -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,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
61
docker-compose.yml.bak-20260709055609
Normal file
61
docker-compose.yml.bak-20260709055609
Normal 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
BIN
mihomo/cache.db
Normal file
Binary file not shown.
36
mihomo/config.yaml
Normal file
36
mihomo/config.yaml
Normal 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
|
||||||
1
mihomo/proxy_providers/oreol.txt
Normal file
1
mihomo/proxy_providers/oreol.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
dmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZANDUuMTAuMTY1LjI0Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQTklRjAlOUYlODclQUElMjAlRDAlOTMlRDAlQjUlRDElODAlRDAlQkMlRDAlQjAlRDAlQkQlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC44NC4yMjc6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWNocm9tZSZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjglRjAlOUYlODclQUElMjAlRDAlQTglRDAlQjIlRDAlQjUlRDElODYlRDAlQjglRDElOEYKdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTM4LjEyNC40LjIwNjo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBQSVGMCU5RiU4NyVBQSUyMCVEMCVBRCVEMSU4MSVEMSU4MiVEMCVCRSVEMCVCRCVEMCVCOCVEMSU4Rgp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA4OS4zNy4xOTIuNTQ6NDQzP2VuY3J5cHRpb249bm9uZSZ0eXBlPXhodHRwJnBhdGg9JTJGJm1vZGU9c3RyZWFtLW9uZSZzZWN1cml0eT1yZWFsaXR5JnNuaT13d3cudHdpdGNoLnR2JmZwPWZpcmVmb3gmcGJrPUZkVHpfSjZ3bjA4U0g1dmlOUUUwXzVuYTNZU3VPejBzY09Dbjk4VnpKaVEjJUYwJTlGJTg3JUI3JUYwJTlGJTg3JUI0JTIwJUQwJUEwJUQxJTgzJUQwJUJDJUQxJThCJUQwJUJEJUQwJUI4JUQxJThGCnZsZXNzOi8vZTAzMjMxNDItOWE1NC00ODBlLTkyZWEtMmIwM2Y1YWNjNzM2QDE5My4yMDIuMTEuNTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVCQSVGMCU5RiU4NyVCOCUyMFVTQQp2bGVzczovL2UwMzIzMTQyLTlhNTQtNDgwZS05MmVhLTJiMDNmNWFjYzczNkA5MS45Mi4yMS41Njo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9ZmlyZWZveCZwYms9RmRUel9KNnduMDhTSDV2aU5RRTBfNW5hM1lTdU96MHNjT0NuOThWekppUSMlRjAlOUYlODclQjUlRjAlOUYlODclQjElMjAlRDAlOUYlRDAlQkUlRDAlQkIlRDElOEMlRDElODglRDAlQjAlMjBEYXRpZ28Kdmxlc3M6Ly9lMDMyMzE0Mi05YTU0LTQ4MGUtOTJlYS0yYjAzZjVhY2M3MzZAMTkxLjQ0LjExOC43OTo0NDM/ZW5jcnlwdGlvbj1ub25lJnR5cGU9eGh0dHAmcGF0aD0lMkYmbW9kZT1zdHJlYW0tb25lJnNlY3VyaXR5PXJlYWxpdHkmc25pPXd3dy50d2l0Y2gudHYmZnA9Y2hyb21lJnBiaz1GZFR6X0o2d24wOFNINXZpTlFFMF81bmEzWVN1T3owc2NPQ245OFZ6SmlRIyVGMCU5RiU4NyVBOSVGMCU5RiU4NyVBQSUyMCUyMCVEMCU5MyVEMCVCNSVEMSU4MCVEMCVCQyVEMCVCMCVEMCVCRCVEMCVCOCVEMSU4RiUyMERhdGlnbw==
|
||||||
@@ -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"
|
|
||||||
|
|||||||
@@ -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",),
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -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 "<важно>" 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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user