33 Commits

Author SHA1 Message Date
Vladless 943027f17f back button in about vpn 2025-05-11 14:57:53 +03:00
Vladless ea0a3dc962 remnawave_link 2025-05-11 13:48:47 +03:00
Vladless 2d2a11e5b6 Merge branch 'base-web-endpoints' 2025-05-11 01:49:45 +03:00
Vladislav Lisitsyn 55fe1342cf Merge pull request #203 from Vladless/dev
fix gifts state
2025-05-11 01:28:18 +03:00
Vladislav Lisitsyn 105f798c26 Merge pull request #202 from Capybara-z/patch-5
reset state
2025-05-11 01:20:59 +03:00
Capybara-z 944295165d reset state
- сброс состояния после завершения всей логики.
2025-05-11 01:20:18 +03:00
Vladless 18be0049cc fix gifts state 2025-05-11 00:45:35 +03:00
Vladislav Lisitsyn 26a32b6f07 Merge pull request #201 from Vladless/dev
patch 4.2.1
2025-05-11 00:13:25 +03:00
Vladislav Lisitsyn f477094614 Merge branch 'main' into dev 2025-05-11 00:12:33 +03:00
Vladless 367331d113 add state.clear 2025-05-09 20:35:50 +03:00
Vladless 51d40323e5 user_data in check_subscription 2025-05-09 20:11:35 +03:00
Vladless 5ddf7f8242 ruff formatting 2025-05-09 01:34:11 +03:00
Vladless e4a73f9aaa set null recipient_gifts for delete user 2025-05-09 00:19:19 +03:00
Vladless b99c090a84 add web router 2025-05-08 23:23:58 +03:00
Vladislav Lisitsyn 15ad1a0f79 Update README.md 2025-05-08 23:14:42 +03:00
hteppl 344e4757ef oops 2025-05-08 17:19:00 +03:00
hteppl 55461aa50b Create base __init__ for web open api routes 2025-05-08 15:50:53 +03:00
Vladislav Lisitsyn 24928fe561 Update README.md 2025-05-08 03:12:58 +03:00
Vladislav Lisitsyn f16084b941 Update README.md 2025-05-08 03:04:47 +03:00
Vladislav Lisitsyn caeb531445 Update README.md 2025-05-08 02:57:33 +03:00
Vladislav Lisitsyn fefbf4da39 Update README.md 2025-05-08 02:36:49 +03:00
Vladless cd3c19b79d 4.2.1 patch 2025-05-08 00:51:51 +03:00
Vladless 6cd1dd8bc2 Refactored various modules, applied multiple improvements and fixed critical bugs 2025-05-08 00:50:18 +03:00
Vladislav Lisitsyn 3837f47bb6 Merge pull request #200 from Capybara-z/patch-4
fix sub in country mode
2025-05-07 22:07:34 +03:00
Capybara-z bff7350224 fix sub in country mode
- Исправлена выдачи подписки в режиме стран для Remnawawe
2025-05-07 22:02:45 +03:00
Vladless cd4c526da0 fix payment in UTM 2025-05-07 21:44:09 +03:00
Vladislav Lisitsyn 38e931446a Merge pull request #199 from Capybara-z/patch-3
fix update user
2025-05-07 21:30:02 +03:00
Capybara-z 55af3bde3d fix update user
- Испрлавена активность юзера и обновление его данных.
2025-05-07 21:28:17 +03:00
Vladislav Lisitsyn 6bca31f92c Merge pull request #198 from Capybara-z/patch-2
Fix notification
2025-05-07 20:42:32 +03:00
Capybara-z f8858e8561 Added toggle for country shuffle in subscription, revamped traffic handling
- Добавлена возможность вкл/выкл перемешки стран в подписке
- Переработан механизм получения трафика и его обработки
2025-05-07 20:32:57 +03:00
Capybara-z d3c729cdc2 Update notification 2025-05-05 21:40:06 +03:00
Capybara-z 4c954409cd Update notification 2025-05-05 21:39:20 +03:00
Capybara-z b2dbdb60b6 Update notification 2025-05-05 21:38:11 +03:00
28 changed files with 637 additions and 412 deletions
+40 -78
View File
@@ -1,97 +1,47 @@
# 🚀 SoloBot # 🚀 SoloBot
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS. ## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
<img src=".github/images/preview.gif" alt="SoloBot" width="1600"> <img src=".github/images/preview.gif" alt="SoloBot" width="1600">
# Описание # Описание
Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд. ## Бот, предоставляющий инструменты под различные реализации. Хорошая кастомизация и подстройка под свой бренд.
# Основные возможности ## ⚙️ Основные возможности SoloBot
**SoloBot** реализует множество функций, включая: | 📌 Раздел | 💡 Возможности |
|----------|----------------|
| **Мультипанель** | • 3x-ui <br> • Remnawave <br> • Работа в режиме совместимости (две панели в одном боте) |
| **Управление подписками** | • Выдача подписок на 1 / 3 / 6 / 12 месяцев или заданные сроки <br> • Пробный период на заданный период <br> • Продление ключей по тарифному плану <br> • Поддержка различных клиентов (Happ, Hiddify, v2raytun) <br> • Кастомизируемые заголовки профилей |
| **Полный контроль над клиентом** | • Просмотр ключа, сервера и оставшегося времени через админку <br> • Продление и удаление ключей, начисление дней, отключение клиента <br> • Смена локации между серверами <br> • Поддержка нескольких устройств |
| **Реферальная программа** | • Уникальные ссылки для приглашений <br> • Инлайн-режим и обычные сообщения <br> • Награда: процент или фиксированная сумма за пополнение |
| **UTM-аналитика** | • Отслеживание рекламных переходов <br> • Привязка по рефералам, купонам, пробникам <br> • Анализ конверсий: регистрации, покупки, триалы <br> • Удалённый просмотр и контроль через админку |
| **Интеграция платёжных систем** | • Юкасса (ИП / Самозанятые) <br> • Юмани (Физ. лица, TrackLine) <br> • Robokassa (Физ. лица) <br> • Cryptobot (izzzzzi) <br> • Звёзды Telegram |
| **Безопасность и стабильность** | • Периодические бэкапы <br> • Смена домена в случае переезда <br> • Проверка доступности серверов <br> • Уведомление о недоступности сервера и его аптайм <br> |
| **Уведомления** | • Напоминания об истекающих подписках (24ч / 6ч / момент) <br> • Напоминания о неиспользованном трафике |
| **Серверная часть** | • Мультисерверность (добавление серверов в неограниченном количестве) <br> • Выдача в разных режимах (по одной локации или в формате подписки) <br> • Автопроверка доступности <br> • Балансировка нагрузки при выдаче ключей <br> • Синхронизация клиентов между серверами <br> • Ограничение максимального количества ключей на сервер <br> • Возможность включения/отключения отдельных серверов |
| **Админ-панель** | • Поиск по TG ID / username / ключу / email <br> • Управление балансом, подписками, заморозками <br> • Перезагрузка, бан-лист <br> • Создание купонов, UTM, статистика |
| **Кастомизация** | • Все функции бота кастомизируемы, вплоть до логики работы <br> |
### Управление подписками ## 🛠 Обновления
- Выдача подписок на различные периоды (**1 месяц, 3 месяца, полгода, год**).
- Использование **тестового периода** подписки.
- Продление ключей на указанные периоды.
### Полный контроль клиента над своими ключами ### SoloBot регулярно получает новые функции и улучшения.
- Просмотр информации о ключе (**сервер, оставшееся время, сам ключ**).
- Продление ключей.
- Удаление ключей.
- Просмотр полной информации вплоть до минут истечения
- Смена локации (**перемещение ключа между серверами**).
- Поддержка **нескольких ключей и устройств** для одного клиента.
### Реферальная программа #### [➡ Все обновления и версии](https://github.com/Vladless/Solo_bot/releases)
- Уникальная ссылка для приглашений.
- Инлайн режим или обычные сообщения
### Интеграция платёжных систем # Наш сайт и полная версия
В боте реализована интеграция касс как для физических лиц, так и для самозанятых и ИП:
- **Юкасса** (Самозанятость и ИП)
- **Юмани** (Физические Лица) (TrackLine)
- **Robokassa** (Физические Лица)
- **Cryptobot** (Криптовалюта) (izzzzzi)
- **Звезды Telegram**
### Безопасность и стабильность ### Переходи на наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
- **Периодические бэкапы** базы данных клиентов и их восстановление.
- **Смена домена** в случае переезда.
### Уведомления #### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
- **Произвольные сообщения** через панель администратора.
- Уведомления о **неиспользованных пробниках**.
- Уведомления об **истекающих ключах** (_за сутки, за 6 часов и в момент истечения_).
- **Воронка продаж**. Если клиент не взял пробник предлагаются более выгодные условия.
- **Рассылка лично или всем.** С сохранением форматирования и отправкой картинок
### Серверы
- **Мультисерверность** — добавление сервера прямо в админке.
- **Проверка на доступность** — бот следит за серверами и их откликом.
- **Балансировка** — бот выдает подписку на наименее загруженный сервер.
- **Сихронизация** — перемещайте клиентов на другие сервера бесшовно.
### Удобная админ-панель
- **Поиск клиента** — по айди, нику или ключу.
- **Управление подпиской клиента** — перевыпуск, изменение баланса и остальное.
- **Управление ботом** — перезагрузка, списки банов, смена домена.
- *А также рассылки, создание купонов, просмотр детальной статистики и многое другое*
<img src=".github/images/preview_2.gif" alt="SoloBot" width="1600">
### Кастомизация
- **Пробник** — Сами решаете будет или нет. Количество дней пробника также кастомизируется.
- **Реферальная программа** — процент с каждого пополнения или фиксированная сумма.
- **Все меню** — меняйте все меню как вам захочется, описания и тексты.
- **Кешбэк** — начисляйте кэшбек за пополнения, если хотите.
- **Настройки** — капча, подписка на канал, купоны и прочее.
- **Автоматика** — удалять ли ключи после продления, продлевать ли их и остальное.
### *Настраивайте как вам удобно!*
## Список версий
#### Бот постоянно развивается, обрастая новыми возможностями.
#### Для полного рассмотрения каждой версии ознакомьтесь с [**релизами**](https://github.com/Vladless/Solo_bot/releases).
## 🔗 SoloBot в Telegram и Полная версия
#### Наш [**➡ сайт**](https://pocomacho.ru/solonetbot/):
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131) ![image](https://github.com/user-attachments/assets/28f317f0-6b26-4d86-a501-df9800646131)
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot). Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
#### Отзывы пользователей: ## Отзывы пользователей:
SoloBot уже помог сотням пользователей в нашем сообществе: #### SoloBot уже помог сотням пользователей в нашем сообществе:
![image](https://github.com/user-attachments/assets/fe13a00f-94d3-419c-afd0-d1c1efec5179) ![image](https://github.com/user-attachments/assets/597e6c4e-68be-4d8f-826b-35754c682a30)
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/) **Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
@@ -99,8 +49,20 @@ SoloBot уже помог сотням пользователей в нашем
Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин Связаться с нами через [**➡ поддержку**](https://t.me/solonet_sup). Там вы сможете купить полную версию и получить логин
и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы! и пароль от сайта, получить доступ в наш чат сообщества, а также задать необходимые вопросы!
## Права на использование ## 🚨 Права на использование
#### Перепродажа кода запрещена, ознакомьтесь с лицензией | ❗ | **Этот проект распространяется по лицензии [CC BY-NC 4.0](LICENSE)** |
|----|---------------------------------------------------------------------|
| ⛔ **Монетизация кода запрещена** | Нельзя продавать или перепродавать код без разрешения автора. |
| ✅ **Для личного использования** | Код можно использовать и модифицировать для личного использования. |
> **Нарушение приведёт к блокировке доступа и может повлечь ответственность согласно законодательству РФ.**
## Участники проекта
Благодарим всех, кто помогает развивать SoloBot! 💖
<a href="https://github.com/Vladless/Solo_bot/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Vladless/Solo_bot" />
</a>
[Этот проект использует лицензию CC BY-NC 4.0](LICENSE)
+2 -1
View File
@@ -19,7 +19,8 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
storage = MemoryStorage() storage = MemoryStorage()
dp = Dispatcher(bot=bot, storage=storage) dp = Dispatcher(bot=bot, storage=storage)
version = "4.2" version = "4.2.1"
register_middleware(dp) register_middleware(dp)
+11 -13
View File
@@ -28,7 +28,7 @@ console = Console()
BACK_DIR = os.path.expanduser("~/.solobot_backup") BACK_DIR = os.path.expanduser("~/.solobot_backup")
TEMP_DIR = os.path.expanduser("~/.solobot_tmp") TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
PROJECT_DIR = os.path.abspath(os.path.dirname(__file__)) PROJECT_DIR = os.path.abspath(os.path.dirname(__file__))
IS_ROOT_DIR = PROJECT_DIR == '/root' IS_ROOT_DIR = PROJECT_DIR == "/root"
if IS_ROOT_DIR: if IS_ROOT_DIR:
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]") console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
@@ -73,12 +73,12 @@ def fix_permissions():
"""Устанавливает корректные права на файлы проекта""" """Устанавливает корректные права на файлы проекта"""
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]") console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
try: try:
user = os.getenv('SUDO_USER') or os.getenv('USER') user = os.getenv("SUDO_USER") or os.getenv("USER")
if user: if user:
subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True) subprocess.run(["sudo", "chown", "-R", f"{user}:{user}", PROJECT_DIR], check=True)
subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True) subprocess.run(["sudo", "chmod", "-R", "u=rwX,go=rX", PROJECT_DIR], check=True)
console.print("[green]✅ Права успешно установлены[/green]") console.print("[green]✅ Права успешно установлены[/green]")
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]") console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
@@ -195,7 +195,7 @@ def update_from_beta():
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"): if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
return return
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]") console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"): if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
return return
@@ -215,11 +215,11 @@ def update_from_beta():
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")]) subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
clean_project_dir_safe(update_buttons=update_buttons) clean_project_dir_safe(update_buttons=update_buttons)
exclude_options = "--exclude=img" exclude_options = "--exclude=img"
if not update_buttons: if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py" exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR]) subprocess.run(["rm", "-rf", TEMP_DIR])
@@ -258,8 +258,7 @@ def update_from_release():
console.print(f"[cyan]{idx}.[/cyan] {tag}") console.print(f"[cyan]{idx}.[/cyan] {tag}")
selected = Prompt.ask( selected = Prompt.ask(
"[bold blue]Выберите номер релиза[/bold blue]", "[bold blue]Выберите номер релиза[/bold blue]", choices=[str(i) for i in range(1, len(tag_choices) + 1)]
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
) )
tag_name = tag_choices[int(selected) - 1] tag_name = tag_choices[int(selected) - 1]
@@ -277,7 +276,7 @@ def update_from_release():
exclude_options = "--exclude=img" exclude_options = "--exclude=img"
if not update_buttons: if not update_buttons:
exclude_options += " --exclude=handlers/buttons.py" exclude_options += " --exclude=handlers/buttons.py"
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True) subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
subprocess.run(["rm", "-rf", TEMP_DIR]) subprocess.run(["rm", "-rf", TEMP_DIR])
@@ -291,7 +290,6 @@ def update_from_release():
def show_update_menu(): def show_update_menu():
if IS_ROOT_DIR: if IS_ROOT_DIR:
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]") console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]") console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
@@ -335,9 +333,9 @@ def update_cli_launcher():
try: try:
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py" url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
response = requests.get(url, timeout=10) response = requests.get(url, timeout=10)
if response.status_code == 200: if response.status_code == 200:
with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), 'w', encoding='utf-8') as f: with open(os.path.join(PROJECT_DIR, "cli_launcher.py"), "w", encoding="utf-8") as f:
f.write(response.text) f.write(response.text)
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]") console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755) os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
+27 -6
View File
@@ -846,9 +846,31 @@ async def upsert_user(
if only_if_exists: if only_if_exists:
logger.debug(f"[upsert_user] Режим only_if_exists: проверяю наличие пользователя {tg_id}") logger.debug(f"[upsert_user] Режим only_if_exists: проверяю наличие пользователя {tg_id}")
exists = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM users WHERE tg_id = $1)", tg_id)
if not exists:
return None
user_data = await conn.fetchrow( user_data = await conn.fetchrow(
"SELECT tg_id, username, first_name, last_name, language_code, is_bot, created_at, updated_at FROM users WHERE tg_id = $1", """
UPDATE users
SET
username = COALESCE($2, username),
first_name = COALESCE($3, first_name),
last_name = COALESCE($4, last_name),
language_code = COALESCE($5, language_code),
is_bot = $6,
updated_at = CURRENT_TIMESTAMP
WHERE tg_id = $1
RETURNING
tg_id, username, first_name, last_name, language_code,
is_bot, created_at, updated_at
""",
tg_id, tg_id,
username,
first_name,
last_name,
language_code,
is_bot,
) )
return dict(user_data) if user_data else None return dict(user_data) if user_data else None
@@ -1064,7 +1086,8 @@ async def get_servers(session: Any = None, include_enabled: bool = False):
async def delete_user_data(session: Any, tg_id: int): async def delete_user_data(session: Any, tg_id: int):
await session.execute("DELETE FROM notifications WHERE tg_id = $1", tg_id) await session.execute("DELETE FROM notifications WHERE tg_id = $1", tg_id)
await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1 OR recipient_tg_id = $1", tg_id) await session.execute("DELETE FROM gifts WHERE sender_tg_id = $1", tg_id)
await session.execute("UPDATE gifts SET recipient_tg_id = NULL WHERE recipient_tg_id = $1", tg_id)
await session.execute("DELETE FROM payments WHERE tg_id = $1", tg_id) await session.execute("DELETE FROM payments WHERE tg_id = $1", tg_id)
await session.execute("DELETE FROM referrals WHERE referrer_tg_id = $1 OR referred_tg_id = $1", tg_id) await session.execute("DELETE FROM referrals WHERE referrer_tg_id = $1 OR referred_tg_id = $1", tg_id)
await session.execute("DELETE FROM coupon_usages WHERE user_id = $1", tg_id) await session.execute("DELETE FROM coupon_usages WHERE user_id = $1", tg_id)
@@ -1468,16 +1491,14 @@ async def get_tracking_source_stats(code: str, session) -> dict:
COUNT(DISTINCT u.tg_id) AS registrations, COUNT(DISTINCT u.tg_id) AS registrations,
COUNT(DISTINCT CASE WHEN u.trial = 1 THEN u.tg_id END) AS trials, COUNT(DISTINCT CASE WHEN u.trial = 1 THEN u.tg_id END) AS trials,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN p.status = 'success' AND NOT EXISTS ( WHEN p.status = 'success' THEN u.tg_id
SELECT 1 FROM keys k WHERE k.tg_id = u.tg_id
) THEN u.tg_id
END) AS payments END) AS payments
FROM tracking_sources ts FROM tracking_sources ts
LEFT JOIN users u ON u.source_code = ts.code LEFT JOIN users u ON u.source_code = ts.code
LEFT JOIN payments p ON p.tg_id = u.tg_id LEFT JOIN payments p ON p.tg_id = u.tg_id
WHERE ts.code = $1 WHERE ts.code = $1
GROUP BY ts.code, ts.name, ts.created_at GROUP BY ts.code, ts.name, ts.created_at
""", """,
code, code,
) )
return dict(result) if result else {} return dict(result) if result else {}
@@ -6,10 +6,10 @@ from asyncpg import Connection
from filters.admin import IsAdminFilter from filters.admin import IsAdminFilter
from logger import logger from logger import logger
from middlewares import maintenance
from ..panel.keyboard import build_admin_back_kb from ..panel.keyboard import build_admin_back_kb
from .keyboard import AdminPanelCallback, build_management_kb from .keyboard import AdminPanelCallback, build_management_kb
from middlewares import maintenance
router = Router() router = Router()
@@ -78,10 +78,9 @@ async def process_new_domain(message: Message, state: FSMContext, session: Conne
@router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance")) @router.callback_query(AdminPanelCallback.filter(F.action == "toggle_maintenance"))
async def toggle_maintenance_mode(callback: CallbackQuery): async def toggle_maintenance_mode(callback: CallbackQuery):
maintenance.maintenance_mode = not maintenance.maintenance_mode maintenance.maintenance_mode = not maintenance.maintenance_mode
new_status = "включён" if maintenance.maintenance_mode else "выключен" new_status = "включён" if maintenance.maintenance_mode else "выключен"
await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True) await callback.answer(f"🛠️ Режим обслуживания {new_status}.", show_alert=True)
await callback.message.edit_reply_markup(reply_markup=build_management_kb()) await callback.message.edit_reply_markup(reply_markup=build_management_kb())
+52 -29
View File
@@ -24,12 +24,36 @@ async def generate_captcha(message: Message, state: FSMContext):
all_emojis = [correct_emoji] + wrong_emojis all_emojis = [correct_emoji] + wrong_emojis
random.shuffle(all_emojis) random.shuffle(all_emojis)
await state.update_data( state_data = await state.get_data()
correct_emoji=correct_emoji,
message_id=message.message_id, if "user_data" not in state_data:
chat_id=message.chat.id, from_user = message.from_user
original_text=message.text, if not from_user:
) logger.warning("[CAPTCHA] ❗ from_user отсутствует — невозможно сохранить user_data")
return None
await state.update_data(
user_data={
"tg_id": from_user.id,
"username": getattr(from_user, "username", None),
"first_name": getattr(from_user, "first_name", None),
"last_name": getattr(from_user, "last_name", None),
"language_code": getattr(from_user, "language_code", None),
"is_bot": getattr(from_user, "is_bot", False),
}
)
update_data = {
"correct_emoji": correct_emoji,
"message_id": message.message_id,
"chat_id": message.chat.id,
}
state_data = await state.get_data()
if "original_text" not in state_data:
update_data["original_text"] = message.text
await state.update_data(**update_data)
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
for emoji in all_emojis: for emoji in all_emojis:
@@ -44,34 +68,33 @@ async def generate_captcha(message: Message, state: FSMContext):
@router.callback_query(F.data.startswith("captcha_")) @router.callback_query(F.data.startswith("captcha_"))
async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool): async def check_captcha(callback: CallbackQuery, state: FSMContext, session: Any, admin: bool):
from handlers.start import start_command from handlers.start import process_start_logic
selected_emoji = callback.data.split("captcha_")[1] selected_emoji = callback.data.split("captcha_")[1]
state_data = await state.get_data() state_data = await state.get_data()
correct_emoji = state_data.get("correct_emoji") correct_emoji = state_data.get("correct_emoji")
message_id = state_data.get("message_id") original_text = state_data.get("original_text")
chat_id = state_data.get("chat_id") user_data = state_data.get("user_data")
if not message_id or not chat_id: target_message = callback.message
target_message = callback.message
else:
try:
target_message = await callback.bot.edit_message_text(
chat_id=chat_id,
message_id=message_id,
text=callback.message.text,
)
except Exception:
target_message = callback.message
if selected_emoji == correct_emoji: if selected_emoji == correct_emoji:
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу") logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
await start_command(target_message, state, session, admin, captcha=False) logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
else: await process_start_logic(
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу") message=target_message,
captcha = await generate_captcha(target_message, state) state=state,
await edit_or_send_message( session=session,
target_message=target_message, admin=admin,
text=captcha["text"], text_to_process=original_text,
reply_markup=captcha["markup"], user_data=user_data,
) )
else:
logger.warning(f"Пользователь {callback.from_user.id} неверно ответил на капчу")
captcha = await generate_captcha(target_message, state)
if captcha:
await edit_or_send_message(
target_message=target_message,
text=captcha["text"],
reply_markup=captcha["markup"],
)
+22 -12
View File
@@ -69,7 +69,14 @@ async def process_coupon_code(message: Message, state: FSMContext, session: Any)
await activate_coupon(message, state, session, coupon_code=coupon_code) await activate_coupon(message, state, session, coupon_code=coupon_code)
async def activate_coupon(message: Message, state: FSMContext, session: Any, coupon_code: str, admin: bool = False): async def activate_coupon(
message: Message,
state: FSMContext,
session: Any,
coupon_code: str,
admin: bool = False,
user_data: dict | None = None,
):
logger.info(f"Активация купона: {coupon_code}") logger.info(f"Активация купона: {coupon_code}")
coupon_record = await get_coupon_by_code(coupon_code, session) coupon_record = await get_coupon_by_code(coupon_code, session)
@@ -84,7 +91,8 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
await state.clear() await state.clear()
return return
user_id = message.chat.id user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
usage = await check_coupon_usage(coupon_record["id"], user_id, session) usage = await check_coupon_usage(coupon_record["id"], user_id, session)
if usage: if usage:
@@ -94,16 +102,18 @@ async def activate_coupon(message: Message, state: FSMContext, session: Any, cou
user_exists = await check_user_exists(user_id) user_exists = await check_user_exists(user_id)
if not user_exists: if not user_exists:
from_user = message.from_user if isinstance(user, dict):
await add_user( await add_user(session=session, **user)
tg_id=from_user.id, else:
username=from_user.username, await add_user(
first_name=from_user.first_name, tg_id=user.id,
last_name=from_user.last_name, username=getattr(user, "username", None),
language_code=from_user.language_code, first_name=getattr(user, "first_name", None),
is_bot=from_user.is_bot, last_name=getattr(user, "last_name", None),
session=session, language_code=getattr(user, "language_code", None),
) is_bot=getattr(user, "is_bot", False),
session=session,
)
if coupon_record["amount"] > 0: if coupon_record["amount"] > 0:
try: try:
+27 -7
View File
@@ -3,21 +3,21 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
import asyncpg
import asyncpg
import pytz import pytz
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message from aiogram.types import CallbackQuery, InlineKeyboardButton, Message, WebAppInfo
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from py3xui import AsyncApi from py3xui import AsyncApi
from config import ( from config import (
DATABASE_URL,
ADMIN_PASSWORD, ADMIN_PASSWORD,
ADMIN_USERNAME, ADMIN_USERNAME,
CONNECT_PHONE_BUTTON, CONNECT_PHONE_BUTTON,
DATABASE_URL,
PUBLIC_LINK, PUBLIC_LINK,
REMNAWAVE_LOGIN, REMNAWAVE_LOGIN,
REMNAWAVE_PASSWORD, REMNAWAVE_PASSWORD,
@@ -39,7 +39,12 @@ from handlers.texts import (
SELECT_COUNTRY_MSG, SELECT_COUNTRY_MSG,
key_message_success, key_message_success,
) )
from handlers.utils import edit_or_send_message, generate_random_email, get_least_loaded_cluster from handlers.utils import (
edit_or_send_message,
generate_random_email,
get_least_loaded_cluster,
is_full_remnawave_cluster,
)
from logger import logger from logger import logger
from panels.remnawave import RemnawaveAPI from panels.remnawave import RemnawaveAPI
from panels.three_xui import delete_client, get_xui_instance from panels.three_xui import delete_client, get_xui_instance
@@ -289,6 +294,12 @@ async def finalize_key_creation(
remnawave_link = None remnawave_link = None
created_at = int(datetime.now(moscow_tz).timestamp() * 1000) created_at = int(datetime.now(moscow_tz).timestamp() * 1000)
cluster_info = await check_server_name_by_cluster(selected_country, session)
if not cluster_info:
raise ValueError(f"Кластер для сервера {selected_country} не найден")
is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if old_key_name: if old_key_name:
old_server_id = old_key_details.get("server_id") old_server_id = old_key_details.get("server_id")
if old_server_id: if old_server_id:
@@ -329,7 +340,7 @@ async def finalize_key_creation(
except Exception as e: except Exception as e:
logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}") logger.warning(f"[Delete] Ошибка при удалении клиента с сервера {old_server_id}: {e}")
if panel_type == "remnawave": if panel_type == "remnawave" or is_full_remnawave:
remna = RemnawaveAPI(server_info["api_url"]) remna = RemnawaveAPI(server_info["api_url"])
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD): if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})") raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
@@ -424,8 +435,16 @@ async def finalize_key_creation(
return return
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
if CONNECT_PHONE_BUTTON: is_full_remnawave = await is_full_remnawave_cluster(cluster_info["cluster_name"], session)
if is_full_remnawave and (public_link or remnawave_link):
builder.row(
InlineKeyboardButton(
text=CONNECT_DEVICE,
web_app=WebAppInfo(url=public_link or remnawave_link),
)
)
elif CONNECT_PHONE_BUTTON:
builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}")) builder.row(InlineKeyboardButton(text=CONNECT_PHONE, callback_data=f"connect_phone|{key_name}"))
builder.row( builder.row(
InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"), InlineKeyboardButton(text=PC_BUTTON, callback_data=f"connect_pc|{email}"),
@@ -433,6 +452,7 @@ async def finalize_key_creation(
) )
else: else:
builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}")) builder.row(InlineKeyboardButton(text=CONNECT_DEVICE, callback_data=f"connect_device|{key_name}"))
builder.row(InlineKeyboardButton(text=SUPPORT, url=SUPPORT_CHAT_URL))
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
+3 -1
View File
@@ -232,7 +232,9 @@ async def process_callback_view_key(callback_query: CallbackQuery, session: Any)
f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>" f"Осталось: <b>{format_days(days)}</b>, <b>{format_hours(hours)}</b>, <b>{format_minutes(minutes)}</b>"
) )
formatted_expiry_date = f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года" formatted_expiry_date = (
f"{expiry_date.strftime('%d')} {get_russian_month(expiry_date)} {expiry_date.strftime('%Y')} года"
)
response_message = key_message( response_message = key_message(
final_link, final_link,
formatted_expiry_date, formatted_expiry_date,
+82 -46
View File
@@ -13,9 +13,9 @@ from aiohttp import web
from config import ( from config import (
DATABASE_URL, DATABASE_URL,
PROJECT_NAME, PROJECT_NAME,
RANDOM_SUBSCRIPTIONS,
SUPERNODE, SUPERNODE,
SUPPORT_CHAT_URL, SUPPORT_CHAT_URL,
TOTAL_GB,
USERNAME_BOT, USERNAME_BOT,
USE_COUNTRY_SELECTION, USE_COUNTRY_SELECTION,
) )
@@ -24,33 +24,45 @@ from handlers.utils import convert_to_bytes
from logger import logger from logger import logger
async def fetch_url_content(url: str, identifier: str) -> list[str]: async def fetch_url_content(url: str, identifier: str) -> tuple[list[str], dict[str, str]]:
try: try:
timeout = aiohttp.ClientTimeout(total=5) timeout = aiohttp.ClientTimeout(total=5)
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, ssl=False) as response: async with session.get(url, ssl=False) as response:
if response.status == 200: if response.status == 200:
content = await response.text() content = await response.text()
return base64.b64decode(content).decode("utf-8").split("\n") lines = base64.b64decode(content).decode("utf-8").split("\n")
return [] headers = {k.lower(): v for k, v in response.headers.items()}
except Exception: logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
return [] return lines, headers
return [], {}
except Exception as e:
logger.error(f"Error fetching URL {url}: {e}")
return [], {}
async def combine_unique_lines(urls: list[str], identifier: str, query_string: str) -> list[str]: async def combine_unique_lines(
urls: list[str], identifier: str, query_string: str
) -> tuple[list[str], list[dict[str, str]]]:
if SUPERNODE: if SUPERNODE:
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
if not urls: if not urls:
return [] return [], []
url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0] url_with_query = f"{urls[0]}?{query_string}" if query_string else urls[0]
return await fetch_url_content(url_with_query, identifier) lines, headers = await fetch_url_content(url_with_query, identifier)
return lines, [headers]
urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls] urls_with_query = [f"{url}?{query_string}" if query_string else url for url in urls]
tasks = [fetch_url_content(url, identifier) for url in urls_with_query] tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
all_lines = set() all_lines = set()
for lines in results: all_headers = []
all_lines.update(filter(None, lines)) for result in results:
return list(all_lines) if isinstance(result, tuple):
lines, headers = result
all_lines.update(filter(None, lines))
all_headers.append(headers)
return list(all_lines), all_headers
async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]: async def get_subscription_urls(server_id: str, email: str, conn, include_remnawave_key: str = None) -> list[str]:
@@ -72,38 +84,61 @@ async def get_subscription_urls(server_id: str, email: str, conn, include_remnaw
return urls return urls
def calculate_traffic(cleaned_subscriptions: list[str], expiry_time_ms: int | None) -> str: def calculate_traffic(
cleaned_subscriptions: list[str], expiry_time_ms: int | None, headers_list: list[dict[str, str]]
) -> str:
logger.debug(f"Calculating traffic with subscriptions: {cleaned_subscriptions}, headers: {headers_list}")
expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0 expire_timestamp = int(expiry_time_ms / 1000) if expiry_time_ms else 0
if TOTAL_GB != 0:
country_remaining = {} upload = 0
for line in cleaned_subscriptions: download = 0
if "#" not in line: total = 0
continue for headers in headers_list:
try: userinfo = headers.get("subscription-userinfo", "")
_, meta = line.split("#", 1) if userinfo:
except ValueError: parts = userinfo.split(";")
continue for part in parts:
parts = meta.split("-") part = part.strip()
country = parts[0].strip() if part.startswith("upload="):
remaining_str = parts[1].strip() if len(parts) == 2 else "" upload += int(part.split("=")[1])
if remaining_str: elif part.startswith("download="):
remaining_str = remaining_str.replace(",", ".") download += int(part.split("=")[1])
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE) elif part.startswith("total="):
if m_total: total += int(part.split("=")[1])
value = float(m_total.group(1)) logger.debug(f"Processed Subscription-Userinfo: {userinfo}")
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit) country_remaining = {}
country_remaining[country] = remaining_bytes for line in cleaned_subscriptions:
num_countries = len(country_remaining) if "#" not in line:
issued_per_country = TOTAL_GB continue
total_traffic_bytes = issued_per_country * num_countries try:
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values()) _, meta = line.split("#", 1)
if consumed_traffic_bytes < 0: except ValueError:
consumed_traffic_bytes = 0 continue
else: parts = meta.split("-")
consumed_traffic_bytes = 1 country = parts[0].strip()
total_traffic_bytes = 0 remaining_str = parts[1].strip() if len(parts) == 2 else ""
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}" if remaining_str:
remaining_str = remaining_str.replace(",", ".")
m_total = re.search(r"([\d\.]+)\s*([GMKTB]B)", remaining_str, re.IGNORECASE)
if m_total:
value = float(m_total.group(1))
unit = m_total.group(2).upper()
remaining_bytes = convert_to_bytes(value, unit)
country_remaining[country] = remaining_bytes
logger.debug(f"Found traffic: {value}{unit} for {country}")
consumed_traffic_bytes = total - sum(country_remaining.values()) if country_remaining else download
if consumed_traffic_bytes < 0:
consumed_traffic_bytes = 0
download = max(download, consumed_traffic_bytes)
if download == 0 and total == 0 and not country_remaining:
download = 1
result = f"upload={upload}; download={download}; total={total}; expire={expire_timestamp}"
logger.debug(f"Calculated subscription-userinfo: {result}")
return result
def clean_subscription_line(line: str) -> str: def clean_subscription_line(line: str) -> str:
@@ -211,8 +246,9 @@ async def handle_subscription(request: web.Request) -> web.Response:
return web.Response(text="❌ Сервер не найден.", status=404) return web.Response(text="❌ Сервер не найден.", status=404)
query_string = request.query_string query_string = request.query_string
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string) combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
random.shuffle(combined_subscriptions) if RANDOM_SUBSCRIPTIONS:
random.shuffle(combined_subscriptions)
cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions] cleaned_subscriptions = [clean_subscription_line(line) for line in combined_subscriptions]
@@ -220,7 +256,7 @@ async def handle_subscription(request: web.Request) -> web.Response:
subscription_info = f"📄 Подписка: {email} - {time_left}" subscription_info = f"📄 Подписка: {email} - {time_left}"
user_agent = request.headers.get("User-Agent", "") user_agent = request.headers.get("User-Agent", "")
subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms) subscription_userinfo = calculate_traffic(cleaned_subscriptions, expiry_time_ms, headers_list)
headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo) headers = prepare_headers(user_agent, PROJECT_NAME, subscription_info, subscription_userinfo)
return web.Response(text=base64_encoded, headers=headers) return web.Response(text=base64_encoded, headers=headers)
+83 -22
View File
@@ -33,7 +33,10 @@ from database import (
update_key_expiry, update_key_expiry,
) )
from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster from handlers.keys.key_utils import delete_key_from_cluster, renew_key_in_cluster
from handlers.notifications.notify_kb import build_notification_expired_kb, build_notification_kb from handlers.notifications.notify_kb import (
build_notification_expired_kb,
build_notification_kb,
)
from handlers.texts import ( from handlers.texts import (
KEY_DELETED_MSG, KEY_DELETED_MSG,
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG, KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
@@ -53,10 +56,7 @@ from .special_notifications import notify_inactive_trial_users, notify_users_no_
router = Router() router = Router()
moscow_tz = pytz.timezone("Europe/Moscow") moscow_tz = pytz.timezone("Europe/Moscow")
notification_lock = asyncio.Lock() notification_lock = asyncio.Lock()
@@ -76,7 +76,6 @@ async def periodic_notifications(bot: Bot):
try: try:
conn = await asyncpg.connect(DATABASE_URL) conn = await asyncpg.connect(DATABASE_URL)
current_time = int(datetime.now(moscow_tz).timestamp() * 1000) current_time = int(datetime.now(moscow_tz).timestamp() * 1000)
threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000) threshold_time_10h = int((datetime.now(moscow_tz) + timedelta(hours=10)).timestamp() * 1000)
threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000) threshold_time_24h = int((datetime.now(moscow_tz) + timedelta(days=1)).timestamp() * 1000)
@@ -90,17 +89,35 @@ async def periodic_notifications(bot: Bot):
keys = [] keys = []
if not TRIAL_TIME_DISABLE: if not TRIAL_TIME_DISABLE:
await notify_inactive_trial_users(bot, conn) try:
await notify_inactive_trial_users(bot, conn)
except Exception as e:
logger.error(f"Ошибка в notify_inactive_trial_users: {e}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys) try:
await notify_24h_keys(bot, conn, current_time, threshold_time_24h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_24h_keys: {e}")
await asyncio.sleep(1) await asyncio.sleep(1)
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
try:
await notify_10h_keys(bot, conn, current_time, threshold_time_10h, keys)
except Exception as e:
logger.error(f"Ошибка в notify_10h_keys: {e}")
await asyncio.sleep(1) await asyncio.sleep(1)
await handle_expired_keys(bot, conn, current_time, keys)
try:
await handle_expired_keys(bot, conn, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в handle_expired_keys: {e}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
if NOTIFY_INACTIVE_TRAFFIC: if NOTIFY_INACTIVE_TRAFFIC:
await notify_users_no_traffic(bot, conn, current_time, keys) try:
await notify_users_no_traffic(bot, conn, current_time, keys)
except Exception as e:
logger.error(f"Ошибка в notify_users_no_traffic: {e}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
logger.info("Завершена обработка уведомлений") logger.info("Завершена обработка уведомлений")
@@ -161,7 +178,13 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
) )
if NOTIFY_RENEW: if NOTIFY_RENEW:
await process_auto_renew_or_notify(bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text) try:
await process_auto_renew_or_notify(
bot, conn, key, notification_id, 1, "notify_24h.jpg", notification_text
)
except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else: else:
keyboard = build_notification_kb(email) keyboard = build_notification_kb(email)
messages.append({ messages.append({
@@ -169,12 +192,23 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
"text": notification_text, "text": notification_text,
"photo": "notify_24h.jpg", "photo": "notify_24h.jpg",
"keyboard": keyboard, "keyboard": keyboard,
"notification_id": notification_id,
}) })
await add_notification(tg_id, notification_id, session=conn)
if messages: if messages:
await send_messages_with_limit(bot, messages) results = await send_messages_with_limit(bot, messages, conn=conn)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.") sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 24 часа.")
logger.info("Обработка всех уведомлений за 24 часа завершена.") logger.info("Обработка всех уведомлений за 24 часа завершена.")
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -232,6 +266,7 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
) )
except Exception as e: except Exception as e:
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}") logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
continue
else: else:
keyboard = build_notification_kb(email) keyboard = build_notification_kb(email)
messages.append({ messages.append({
@@ -239,12 +274,23 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
"text": notification_text, "text": notification_text,
"photo": "notify_10h.jpg", "photo": "notify_10h.jpg",
"keyboard": keyboard, "keyboard": keyboard,
"notification_id": notification_id,
}) })
await add_notification(tg_id, notification_id, session=conn)
if messages: if messages:
await send_messages_with_limit(bot, messages) results = await send_messages_with_limit(bot, messages, conn=conn)
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.") sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекающей подписке {msg['email']} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекающей подписке {msg['email']} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истечении подписки через 10 часов.")
logger.info("Обработка всех уведомлений за 10 часов завершена.") logger.info("Обработка всех уведомлений за 10 часов завершена.")
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -316,6 +362,8 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
"text": KEY_DELETED_MSG.format(email=email), "text": KEY_DELETED_MSG.format(email=email),
"photo": "notify_expired.jpg", "photo": "notify_expired.jpg",
"keyboard": keyboard, "keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}) })
except Exception as e: except Exception as e:
logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}") logger.error(f"Ошибка удаления ключа {client_id} для пользователя {tg_id}: {e}")
@@ -349,12 +397,25 @@ async def handle_expired_keys(bot: Bot, conn: asyncpg.Connection, current_time:
"text": delay_message, "text": delay_message,
"photo": "notify_expired.jpg", "photo": "notify_expired.jpg",
"keyboard": keyboard, "keyboard": keyboard,
"notification_id": notification_id,
"email": email,
}) })
await add_notification(tg_id, notification_id, session=conn)
if messages: if messages:
await send_messages_with_limit(bot, messages) results = await send_messages_with_limit(bot, messages, conn=conn)
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.") sent_count = 0
for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
email = msg["email"]
if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📢 Отправлено уведомление об истекшем ключе для подписки {email} пользователю {tg_id}.")
else:
logger.warning(
f"📢 Не удалось отправить уведомление об истекшем ключе для подписки {email} пользователю {tg_id}."
)
logger.info(f"Отправлено {sent_count} уведомлений об истекших ключах.")
logger.info("Обработка истекших ключей завершена.") logger.info("Обработка истекших ключей завершена.")
await asyncio.sleep(1) await asyncio.sleep(1)
@@ -403,7 +464,7 @@ async def process_auto_renew_or_notify(
total_gb = int(renewal_period_months * TOTAL_GB * 1024**3) total_gb = int(renewal_period_months * TOTAL_GB * 1024**3)
logger.info( logger.info(
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}" f"Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
) )
try: try:
@@ -436,9 +497,9 @@ async def process_auto_renew_or_notify(
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}") logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
else: else:
keyboard = build_notification_kb(email) keyboard = build_notification_kb(email)
await add_notification(tg_id, notification_id, session=conn)
result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard) result = await send_notification(bot, tg_id, standard_photo, standard_caption, keyboard)
if result: if result:
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.") logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
await add_notification(tg_id, notification_id, session=conn)
else: else:
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.") logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
+54 -11
View File
@@ -2,29 +2,70 @@ import asyncio
import os import os
import aiofiles import aiofiles
import asyncpg
from aiogram import Bot from aiogram import Bot
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
from database import create_blocked_user
from logger import logger from logger import logger
async def send_messages_with_limit(bot: Bot, messages: list[dict], messages_per_second: int = 25): async def send_messages_with_limit(
bot: Bot,
messages: list[dict],
conn: asyncpg.Connection = None,
source_file: str = None,
messages_per_second: int = 25,
):
""" """
Отправляет сообщения с ограничением по количеству сообщений в секунду. Отправляет сообщения с ограничением по количеству сообщений в секунду.
Возвращает список результатов отправки (True для успеха, False для ошибки).
""" """
batch_size = messages_per_second batch_size = messages_per_second
results = []
for i in range(0, len(messages), batch_size): for i in range(0, len(messages), batch_size):
batch = messages[i : i + batch_size] batch = messages[i : i + batch_size]
tasks = [] tasks = []
for msg in batch: for msg in batch:
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard"))) tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
try: batch_results = await asyncio.gather(*tasks, return_exceptions=True)
await asyncio.gather(*tasks, return_exceptions=True) processed_results = []
except Exception as e: for msg, result in zip(batch, batch_results, strict=False):
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}") tg_id = msg["tg_id"]
if isinstance(result, bool) and result:
processed_results.append(True)
elif isinstance(result, TelegramForbiddenError):
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
elif isinstance(result, TelegramBadRequest) and "chat not found" in str(result).lower():
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
else:
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id}.")
if source_file == "special_notifications" and conn:
try:
await create_blocked_user(tg_id, conn)
logger.info(f"Пользователь {tg_id} добавлен в blocked_users.")
except Exception:
pass
processed_results.append(False)
results.extend(processed_results)
await asyncio.sleep(1.0) await asyncio.sleep(1.0)
return results
def rate_limited_send(func): def rate_limited_send(func):
@@ -38,7 +79,11 @@ def rate_limited_send(func):
await asyncio.sleep(retry_in) await asyncio.sleep(retry_in)
except TelegramForbiddenError: except TelegramForbiddenError:
tg_id = kwargs.get("tg_id") or args[1] tg_id = kwargs.get("tg_id") or args[1]
logger.warning(f"Пользователь {tg_id} заблокировал бота.") logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
return False
except TelegramBadRequest:
tg_id = kwargs.get("tg_id") or args[1]
logger.warning(f"🚫 Чат не найден для пользователя {tg_id}.")
return False return False
except Exception as e: except Exception as e:
tg_id = kwargs.get("tg_id") or args[1] tg_id = kwargs.get("tg_id") or args[1]
@@ -85,8 +130,7 @@ async def _send_photo_notification(
buffered_photo = BufferedInputFile(image_data, filename=image_filename) buffered_photo = BufferedInputFile(image_data, filename=image_filename)
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard) await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
return True return True
except TelegramForbiddenError: except (TelegramForbiddenError, TelegramBadRequest):
logger.error(f"Пользователь {tg_id} заблокировал бота")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}") logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
@@ -104,8 +148,7 @@ async def _send_text_notification(
try: try:
await bot.send_message(tg_id, caption, reply_markup=keyboard) await bot.send_message(tg_id, caption, reply_markup=keyboard)
return True return True
except TelegramForbiddenError: except (TelegramForbiddenError, TelegramBadRequest):
logger.error(f"Пользователь {tg_id} заблокировал бота")
return False return False
except Exception as e: except Exception as e:
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}") logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
+58 -57
View File
@@ -6,27 +6,30 @@ import asyncpg
import pytz import pytz
from aiogram import Bot, Router, types from aiogram import Bot, Router, types
from aiogram.exceptions import TelegramForbiddenError
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME from config import (
from database import ( NOTIFY_EXTRA_DAYS,
add_notification, NOTIFY_INACTIVE,
check_notifications_bulk, NOTIFY_INACTIVE_TRAFFIC,
create_blocked_user, SUPPORT_CHAT_URL,
TRIAL_TIME,
) )
from database import add_notification, check_notifications_bulk, create_blocked_user
from handlers.buttons import MAIN_MENU from handlers.buttons import MAIN_MENU
from handlers.keys.key_utils import get_user_traffic from handlers.keys.key_utils import get_user_traffic
from handlers.texts import TRIAL_INACTIVE_BONUS_MSG, TRIAL_INACTIVE_FIRST_MSG, ZERO_TRAFFIC_MSG from handlers.texts import (
TRIAL_INACTIVE_BONUS_MSG,
TRIAL_INACTIVE_FIRST_MSG,
ZERO_TRAFFIC_MSG,
)
from handlers.utils import format_days from handlers.utils import format_days
from logger import logger from logger import logger
from handlers.utils import format_days
from .notify_utils import send_messages_with_limit, send_notification from .notify_utils import send_messages_with_limit, send_notification
router = Router() router = Router()
moscow_tz = pytz.timezone("Europe/Moscow") moscow_tz = pytz.timezone("Europe/Moscow")
@@ -37,19 +40,15 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня. Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
""" """
logger.info("Проверка пользователей, не активировавших пробный период...") logger.info("Проверка пользователей, не активировавших пробный период...")
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn) users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.") logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
messages = [] messages = []
for user in users: for user in users:
tg_id = user["tg_id"] tg_id = user["tg_id"]
username = user["username"] username = user["username"]
first_name = user["first_name"] first_name = user["first_name"]
last_name = user["last_name"] last_name = user["last_name"]
display_name = username or first_name or last_name or "Пользователь" display_name = username or first_name or last_name or "Пользователь"
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row( builder.row(
types.InlineKeyboardButton( types.InlineKeyboardButton(
@@ -59,9 +58,7 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
) )
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup() keyboard = builder.as_markup()
trial_extended = user["last_notification_time"] is not None trial_extended = user["last_notification_time"] is not None
if trial_extended: if trial_extended:
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
message = TRIAL_INACTIVE_BONUS_MSG.format( message = TRIAL_INACTIVE_BONUS_MSG.format(
@@ -74,21 +71,26 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
message = TRIAL_INACTIVE_FIRST_MSG.format( message = TRIAL_INACTIVE_FIRST_MSG.format(
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME) display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
) )
messages.append({
try: "tg_id": tg_id,
await bot.send_message(tg_id, message, reply_markup=keyboard) "text": message,
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.") "keyboard": keyboard,
await add_notification(tg_id, "inactive_trial", session=conn) "notification_id": "inactive_trial",
})
except TelegramForbiddenError: if messages:
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.") results = await send_messages_with_limit(
await create_blocked_user(tg_id, conn) bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
)
except Exception as e: sent_count = 0
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}") for msg, result in zip(messages, results, strict=False):
tg_id = msg["tg_id"]
await asyncio.sleep(1) if result:
await add_notification(tg_id, msg["notification_id"], session=conn)
sent_count += 1
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
else:
logger.warning(f"📩 Не удалось отправить уведомление неактивному пользователю {tg_id}.")
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.") logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
@@ -99,8 +101,8 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
но исключает пользователей, у которых подписка недавно продлилась. но исключает пользователей, у которых подписка недавно продлилась.
""" """
logger.info("Проверка пользователей с нулевым трафиком...") logger.info("Проверка пользователей с нулевым трафиком...")
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz) current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
messages = []
for key in keys: for key in keys:
tg_id = key.get("tg_id") tg_id = key.get("tg_id")
@@ -124,7 +126,6 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz) expiry_dt = pytz.utc.localize(datetime.fromtimestamp(expiry_time / 1000)).astimezone(moscow_tz)
renewal_threshold = expiry_dt - timedelta(days=30) renewal_threshold = expiry_dt - timedelta(days=30)
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC) renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
if renewal_recent: if renewal_recent:
continue continue
@@ -144,39 +145,39 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
total_traffic = sum( total_traffic = sum(
value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values() value if isinstance(value, int | float) else 0 for value in traffic_data.get("traffic", {}).values()
) )
logger.info(f"Ключ для {email}: общий трафик: {total_traffic} ГБ")
try:
await conn.execute("UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id)
except Exception as e:
logger.error(f"Ошибка обновления notified для пользователя {tg_id} (client_id: {client_id}): {e}")
continue
if total_traffic == 0: if total_traffic == 0:
logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.") logger.info(f"У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL)) builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
keyboard = builder.as_markup() keyboard = builder.as_markup()
message = ZERO_TRAFFIC_MSG.format(email=email) message = ZERO_TRAFFIC_MSG.format(email=email)
messages.append({
"tg_id": tg_id,
"text": message,
"keyboard": keyboard,
"client_id": client_id,
})
try: if messages:
result = await send_notification(bot, tg_id, None, message, keyboard) results = await send_messages_with_limit(
await conn.execute( bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id )
) sent_count = 0
if result: for msg, result in zip(messages, results, strict=False):
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.") tg_id = msg["tg_id"]
else: if result:
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.") sent_count += 1
except TelegramForbiddenError: logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.") else:
await create_blocked_user(tg_id, conn) logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
except Exception as e: logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
else:
try:
await conn.execute(
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
)
logger.info(f"Ключ для {email} имеет трафик. Обновлено notified = TRUE.")
except Exception as e:
logger.error(f"Ошибка обновления notified для пользователя {tg_id}: {e}")
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.") logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
+28 -22
View File
@@ -213,49 +213,55 @@ async def top_referrals_handler(callback_query: CallbackQuery):
await conn.close() await conn.close()
async def handle_referral_link(referral_code: str, message: Message, state: FSMContext, session: Any): async def handle_referral_link(
referral_code: str,
message: Message,
state: FSMContext,
session: Any,
user_data: dict | None = None,
):
try: try:
referrer_tg_id = int(referral_code) referrer_tg_id = int(referral_code)
user_exists_now = await check_user_exists(message.chat.id)
if referrer_tg_id == message.chat.id: user = user_data or message.from_user or message.chat
user_id = user["tg_id"] if isinstance(user, dict) else user.id
if referrer_tg_id == user_id:
await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.") await message.answer("❌ Вы не можете быть реферальной ссылкой самого себя.")
return return
if user_exists_now: existing_referral = await get_referral_by_referred_id(user_id, session)
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
return
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
if existing_referral: if existing_referral:
await message.answer("❌ Вы уже использовали реферальную ссылку.") await message.answer("❌ Вы уже использовали реферальную ссылку.")
return return
await add_referral(message.chat.id, referrer_tg_id, session) user_exists = await check_user_exists(user_id)
if not user_exists:
if isinstance(user, dict):
await add_user(session=session, **user)
else:
await add_user(
tg_id=user.id,
username=getattr(user, "username", None),
first_name=getattr(user, "first_name", None),
last_name=getattr(user, "last_name", None),
language_code=getattr(user, "language_code", None),
is_bot=getattr(user, "is_bot", False),
session=session,
)
from_user = message.from_user await add_referral(user_id, referrer_tg_id, session)
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
try: try:
await bot.send_message( await bot.send_message(
referrer_tg_id, referrer_tg_id,
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id), NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
) )
except Exception as e: except Exception as e:
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}") logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id)) await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
return
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}") logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.") await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
return
+105 -76
View File
@@ -75,35 +75,6 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
state_data = await state.get_data() state_data = await state.get_data()
text_to_process = state_data.get("original_text", message.text) text_to_process = state_data.get("original_text", message.text)
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
try:
member = await bot.get_chat_member(CHANNEL_ID, message.chat.id)
if member.status not in ["member", "administrator", "creator"]:
await state.update_data(original_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
else:
logger.info(
f"Пользователь {message.chat.id} подписан на канал (статус: {member.status}). Продолжаем работу."
)
except Exception as e:
logger.error(f"Ошибка проверки подписки пользователя {message.chat.id}: {e}")
await state.update_data(start_text=text_to_process)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
await process_start_logic(message, state, session, admin, text_to_process) await process_start_logic(message, state, session, admin, text_to_process)
@@ -126,10 +97,16 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
else: else:
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG) await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
data = await state.get_data() data = await state.get_data()
original_text = data.get("original_text") original_text = data.get("original_text") or callback_query.message.text
if not original_text: user_data = data.get("user_data")
original_text = callback_query.message.text await process_start_logic(
await process_start_logic(callback_query.message, state, session, admin, text_to_process=original_text) message=callback_query.message,
state=state,
session=session,
admin=admin,
text_to_process=original_text,
user_data=user_data
)
logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}") logger.info(f"[CALLBACK] Завершен вызов process_start_logic для пользователя {user_id}")
except Exception as e: except Exception as e:
logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True) logger.error(f"[CALLBACK] Ошибка проверки подписки для пользователя {user_id}: {e}", exc_info=True)
@@ -137,12 +114,74 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
async def process_start_logic( async def process_start_logic(
message: Message, state: FSMContext, session: Any, admin: bool, text_to_process: str = None message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
): ):
text = text_to_process or message.text or message.caption text = text_to_process or message.text or message.caption
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
"username": getattr(message.from_user, "username", None),
"first_name": getattr(message.from_user, "first_name", None),
"last_name": getattr(message.from_user, "last_name", None),
"language_code": getattr(message.from_user, "language_code", None),
"is_bot": getattr(message.from_user, "is_bot", False),
}
async def process_start_logic(
message: Message,
state: FSMContext,
session: Any,
admin: bool,
text_to_process: str = None,
user_data: dict | None = None,
):
from config import CHANNEL_EXISTS, CHANNEL_REQUIRED, CHANNEL_ID, CHANNEL_URL
from handlers.texts import SUBSCRIPTION_REQUIRED_MSG
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardButton
user_data = user_data or {
"tg_id": (message.from_user or message.chat).id,
"username": getattr(message.from_user, "username", None),
"first_name": getattr(message.from_user, "first_name", None),
"last_name": getattr(message.from_user, "last_name", None),
"language_code": getattr(message.from_user, "language_code", None),
"is_bot": getattr(message.from_user, "is_bot", False),
}
text = text_to_process or message.text or message.caption
if CHANNEL_EXISTS and CHANNEL_REQUIRED:
try:
member = await bot.get_chat_member(CHANNEL_ID, user_data["tg_id"])
if member.status not in ["member", "administrator", "creator"]:
await state.update_data(original_text=text, user_data=user_data)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
except Exception as e:
logger.error(f"Ошибка проверки подписки для {user_data['tg_id']}: {e}")
await state.update_data(original_text=text, user_data=user_data)
builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text="✅ Я подписался", callback_data="check_subscription"))
await edit_or_send_message(
target_message=message,
text=SUBSCRIPTION_REQUIRED_MSG,
reply_markup=builder.as_markup(),
)
return
if not text: if not text:
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {message.chat.id}") logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
await show_start_menu(message, admin, session) await show_start_menu(message, admin, session)
return return
@@ -159,7 +198,7 @@ async def process_start_logic(
if "coupons" in part: if "coupons" in part:
logger.info(f"Обнаружена ссылка на купон: {part}") logger.info(f"Обнаружена ссылка на купон: {part}")
coupon_code = part.split("coupons")[1].strip("_") coupon_code = part.split("coupons")[1].strip("_")
await activate_coupon(message, state, session, coupon_code=coupon_code, admin=admin) await activate_coupon(message, state, session, coupon_code, admin=admin, user_data=user_data)
continue continue
if "gift" in part: if "gift" in part:
@@ -172,7 +211,7 @@ async def process_start_logic(
gift_id = parts[0] gift_id = parts[0]
sender_id = parts[1] sender_id = parts[1]
logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}") logger.info(f"[GIFT] Обнаружен подарок {gift_id} от {sender_id}")
await handle_gift_link(gift_id, message, state, session) await handle_gift_link(gift_id, message, state, session, user_data=user_data)
gift_detected = True gift_detected = True
break break
@@ -180,34 +219,26 @@ async def process_start_logic(
referrer_tg_id = part.split("referral")[1].strip("_") referrer_tg_id = part.split("referral")[1].strip("_")
try: try:
referrer_tg_id = int(referrer_tg_id) referrer_tg_id = int(referrer_tg_id)
await handle_referral_link(referrer_tg_id, message, state, session) await handle_referral_link(referrer_tg_id, message, state, session, user_data=user_data)
except (ValueError, IndexError): except (ValueError, IndexError):
pass pass
continue continue
if "utm" in part: if "utm" in part:
utm_code = part utm_code = part
logger.info(f"Обнаружена ссылка на UTM: {utm_code}") logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
await handle_utm_link(utm_code, message, state, session) await handle_utm_link(utm_code, message, state, session, user_data=user_data)
continue continue
await state.clear()
if gift_detected: if gift_detected:
return return
user_exists = await check_user_exists(message.chat.id) user_exists = await check_user_exists(user_data["tg_id"])
if not user_exists: if not user_exists:
from_user = message.from_user await add_user(session=session, **user_data)
await add_user(
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
)
trial_status = await get_trial(message.chat.id, session) trial_status = await get_trial(user_data["tg_id"], session)
if SHOW_START_MENU_ONCE: if SHOW_START_MENU_ONCE:
if trial_status > 0: if trial_status > 0:
@@ -216,33 +247,26 @@ async def process_start_logic(
await show_start_menu(message, admin, session) await show_start_menu(message, admin, session)
else: else:
await show_start_menu(message, admin, session) await show_start_menu(message, admin, session)
await state.clear()
except Exception as e: except Exception as e:
logger.error(f"Ошибка при обработке текста {message.text}{e}", exc_info=True) logger.error(f"Ошибка при обработке текста {text}{e}", exc_info=True)
await message.answer("❌ Произошла ошибка. Попробуйте позже.") await message.answer("❌ Произошла ошибка. Попробуйте позже.")
async def handle_utm_link(utm_code, message, state, session): async def handle_utm_link(utm_code, message, state, session, user_data: dict):
user_id = user_data["tg_id"]
exists = await session.fetchval("SELECT 1 FROM tracking_sources WHERE code = $1", utm_code) exists = await session.fetchval("SELECT 1 FROM tracking_sources WHERE code = $1", utm_code)
if exists: if exists:
current_code = await session.fetchval("SELECT source_code FROM users WHERE tg_id = $1", message.chat.id) current_code = await session.fetchval("SELECT source_code FROM users WHERE tg_id = $1", user_id)
if current_code is None: if current_code is None:
user_exists = await check_user_exists(message.chat.id) user_exists = await check_user_exists(user_id)
from_user = message.from_user
if not user_exists: if not user_exists:
await add_user( await add_user(session=session, source_code=utm_code, **user_data)
tg_id=from_user.id,
username=from_user.username,
first_name=from_user.first_name,
last_name=from_user.last_name,
language_code=from_user.language_code,
is_bot=from_user.is_bot,
session=session,
source_code=utm_code,
)
else: else:
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, message.chat.id) await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, user_id)
logger.info(f"[UTM] Привязана {utm_code} к пользователю {message.chat.id}") logger.info(f"[UTM] Привязана {utm_code} к пользователю {user_id}")
else: else:
await message.answer("❌ UTM ссылка не найдена.") await message.answer("❌ UTM ссылка не найдена.")
@@ -255,17 +279,16 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
image_path = os.path.join("img", "pic.jpg") image_path = os.path.join("img", "pic.jpg")
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
trial_status = None
if session is not None: if session is not None:
trial_status = await get_trial(message.chat.id, session) trial_status = await get_trial(message.chat.id, session)
logger.info(f"Trial status для {message.chat.id}: {trial_status}") logger.info(f"Trial status для {message.chat.id}: {trial_status}")
if trial_status == 0: if trial_status == 0:
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key")) builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
else:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
else: else:
logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}") logger.warning(f"Сессия базы данных отсутствует, пропускаем проверку триала для {message.chat.id}")
if not SHOW_START_MENU_ONCE: if trial_status != 0 or not SHOW_START_MENU_ONCE:
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
if CHANNEL_EXISTS: if CHANNEL_EXISTS:
@@ -292,7 +315,12 @@ async def show_start_menu(message: Message, admin: bool, session: Any):
@router.callback_query(F.data == "about_vpn") @router.callback_query(F.data == "about_vpn")
async def handle_about_vpn(callback_query: CallbackQuery): async def handle_about_vpn(callback_query: CallbackQuery, session: Any):
user_id = callback_query.from_user.id
trial = await get_trial(user_id, session)
back_target = "profile" if SHOW_START_MENU_ONCE and trial > 0 else "start"
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
if DONATIONS_ENABLE: if DONATIONS_ENABLE:
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate")) builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
@@ -303,7 +331,8 @@ async def handle_about_vpn(callback_query: CallbackQuery):
else: else:
builder.row(support_btn) builder.row(support_btn)
builder.row(InlineKeyboardButton(text=BACK, callback_data="start")) builder.row(InlineKeyboardButton(text=BACK, callback_data=back_target))
text = get_about_vpn("3.2.3-minor") text = get_about_vpn("3.2.3-minor")
image_path = os.path.join("img", "pic.jpg") image_path = os.path.join("img", "pic.jpg")
@@ -313,4 +342,4 @@ async def handle_about_vpn(callback_query: CallbackQuery):
reply_markup=builder.as_markup(), reply_markup=builder.as_markup(),
media_path=image_path, media_path=image_path,
force_text=False, force_text=False,
) )
+18 -15
View File
@@ -4,13 +4,14 @@ import re
import secrets import secrets
import string import string
from datetime import datetime
import aiofiles import aiofiles
import aiohttp import aiohttp
import asyncpg import asyncpg
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
from datetime import datetime
from bot import bot from bot import bot
from config import ADMIN_ID, DATABASE_URL from config import ADMIN_ID, DATABASE_URL
from database import get_all_keys, get_servers from database import get_all_keys, get_servers
@@ -146,12 +147,14 @@ def format_days(days: int) -> str:
return "0 дней" return "0 дней"
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}" return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
def format_hours(hours: int) -> str: def format_hours(hours: int) -> str:
"""Форматирует количество часов с правильным склонением""" """Форматирует количество часов с правильным склонением"""
if hours <= 0: if hours <= 0:
return "0 часов" return "0 часов"
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}" return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
def format_minutes(minutes: int) -> str: def format_minutes(minutes: int) -> str:
"""Форматирует количество минут с правильным склонением""" """Форматирует количество минут с правильным склонением"""
if minutes <= 0: if minutes <= 0:
@@ -272,28 +275,28 @@ def sanitize_key_name(key_name: str) -> str:
RUSSIAN_MONTHS = { RUSSIAN_MONTHS = {
'January': 'Января', "January": "Января",
'February': 'Февраля', "February": "Февраля",
'March': 'Марта', "March": "Марта",
'April': 'Апреля', "April": "Апреля",
'May': 'Мая', "May": "Мая",
'June': 'Июня', "June": "Июня",
'July': 'Июля', "July": "Июля",
'August': 'Августа', "August": "Августа",
'September': 'Сентября', "September": "Сентября",
'October': 'Октября', "October": "Октября",
'November': 'Ноября', "November": "Ноября",
'December': 'Декабря' "December": "Декабря",
} }
def get_russian_month(date: datetime) -> str: def get_russian_month(date: datetime) -> str:
""" """
Преобразует английское название месяца в русское. Преобразует английское название месяца в русское.
Args: Args:
date: Объект datetime, из которого извлекается месяц. date: Объект datetime, из которого извлекается месяц.
Returns: Returns:
Название месяца на русском языке. Название месяца на русском языке.
""" """
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -5
View File
@@ -5,11 +5,10 @@ from aiogram.dispatcher.middlewares.base import BaseMiddleware
from .admin import AdminMiddleware from .admin import AdminMiddleware
from .loggings import LoggingMiddleware from .loggings import LoggingMiddleware
from .maintenance import MaintenanceModeMiddleware
from .session import SessionMiddleware from .session import SessionMiddleware
from .throttling import ThrottlingMiddleware from .throttling import ThrottlingMiddleware
from .user import UserMiddleware from .user import UserMiddleware
from .maintenance import MaintenanceModeMiddleware
def register_middleware( def register_middleware(
@@ -17,13 +16,12 @@ def register_middleware(
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None, middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
exclude: Iterable[str] | None = None, exclude: Iterable[str] | None = None,
) -> None: ) -> None:
"""Регистрирует middleware в диспетчере. """Регистрирует middleware в диспетчере."""
"""
if middlewares is None: if middlewares is None:
available_middlewares = { available_middlewares = {
"admin": AdminMiddleware(), "admin": AdminMiddleware(),
"session": SessionMiddleware(), "session": SessionMiddleware(),
"maintenance": MaintenanceModeMiddleware(), "maintenance": MaintenanceModeMiddleware(),
"logging": LoggingMiddleware(), "logging": LoggingMiddleware(),
"throttling": ThrottlingMiddleware(), "throttling": ThrottlingMiddleware(),
"user": UserMiddleware(), "user": UserMiddleware(),
+5 -2
View File
@@ -1,9 +1,12 @@
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.types import Message, CallbackQuery from aiogram.types import CallbackQuery, Message
from config import ADMIN_ID from config import ADMIN_ID
maintenance_mode = False maintenance_mode = False
class MaintenanceModeMiddleware(BaseMiddleware): class MaintenanceModeMiddleware(BaseMiddleware):
async def __call__(self, handler, event, data): async def __call__(self, handler, event, data):
if maintenance_mode: if maintenance_mode:
@@ -16,5 +19,5 @@ class MaintenanceModeMiddleware(BaseMiddleware):
if user_id and user_id not in ADMIN_ID: if user_id and user_id not in ADMIN_ID:
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.") await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
return return
return await handler(event, data) return await handler(event, data)
Binary file not shown.
+4 -4
View File
@@ -1,6 +1,6 @@
import csv import csv
from datetime import datetime
from datetime import datetime
from io import StringIO from io import StringIO
from typing import Any from typing import Any
@@ -185,9 +185,9 @@ async def export_keys_csv(session) -> BufferedInputFile:
buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n") buffer.write("tg_id,client_id,email,created_at,expiry_time,key,server_id,is_frozen,alias\n")
for row in keys: for row in keys:
created_at = datetime.utcfromtimestamp(row['created_at'] / 1000).strftime("%Y-%m-%d %H:%M:%S") created_at = datetime.utcfromtimestamp(row["created_at"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
expiry_time = datetime.utcfromtimestamp(row['expiry_time'] / 1000).strftime("%Y-%m-%d %H:%M:%S") expiry_time = datetime.utcfromtimestamp(row["expiry_time"] / 1000).strftime("%Y-%m-%d %H:%M:%S")
buffer.write( buffer.write(
f"{row['tg_id']},{row['client_id']},{row['email']}," f"{row['tg_id']},{row['client_id']},{row['email']},"
f"{created_at},{expiry_time},{row['key']}," f"{created_at},{expiry_time},{row['key']},"
+9
View File
@@ -0,0 +1,9 @@
from aiohttp.web_urldispatcher import UrlDispatcher
import bot
async def register_web_routes(router: UrlDispatcher) -> None:
dp = bot.dp
# todo: add your api routes here