Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 943027f17f | |||
| ea0a3dc962 | |||
| 2d2a11e5b6 | |||
| 55fe1342cf | |||
| 105f798c26 | |||
| 944295165d | |||
| 18be0049cc | |||
| 26a32b6f07 | |||
| f477094614 | |||
| 367331d113 | |||
| 51d40323e5 | |||
| 5ddf7f8242 | |||
| e4a73f9aaa | |||
| b99c090a84 | |||
| 15ad1a0f79 | |||
| 344e4757ef | |||
| 55461aa50b | |||
| 24928fe561 | |||
| f16084b941 | |||
| caeb531445 | |||
| fefbf4da39 | |||
| cd3c19b79d | |||
| 6cd1dd8bc2 | |||
| 3837f47bb6 | |||
| bff7350224 | |||
| cd4c526da0 | |||
| 38e931446a | |||
| 55af3bde3d | |||
| 6bca31f92c | |||
| f8858e8561 | |||
| d3c729cdc2 | |||
| 4c954409cd | |||
| b2dbdb60b6 |
@@ -1,97 +1,47 @@
|
||||
# 🚀 SoloBot
|
||||
|
||||
**SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||
## **SoloBot** — ваш идеальный помощник для управления 3x-UI или Remnawave на протоколе VLESS.
|
||||
|
||||
<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/):
|
||||
|
||||
Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||
#### Всегда актуальные гайды по установке, файлы для запуска и ссылка на общий чат:
|
||||

|
||||
|
||||
|
||||
Попробовать SoloBot прямо сейчас в Telegram [**➡ Попробовать**](https://t.me/SoloNetVPN_bot).
|
||||
|
||||
#### Отзывы пользователей:
|
||||
SoloBot уже помог сотням пользователей в нашем сообществе:
|
||||

|
||||
## Отзывы пользователей:
|
||||
#### SoloBot уже помог сотням пользователей в нашем сообществе:
|
||||

|
||||
|
||||
|
||||
**Читать** [**➡ Отзывы**](https://pocomacho.ru/solonetbot/reviews/)
|
||||
@@ -99,8 +49,20 @@ SoloBot уже помог сотням пользователей в нашем
|
||||
Связаться с нами через [**➡ поддержку**](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)
|
||||
|
||||
@@ -19,7 +19,8 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM
|
||||
storage = MemoryStorage()
|
||||
dp = Dispatcher(bot=bot, storage=storage)
|
||||
|
||||
version = "4.2"
|
||||
version = "4.2.1"
|
||||
|
||||
|
||||
register_middleware(dp)
|
||||
|
||||
|
||||
+11
-13
@@ -28,7 +28,7 @@ console = Console()
|
||||
BACK_DIR = os.path.expanduser("~/.solobot_backup")
|
||||
TEMP_DIR = os.path.expanduser("~/.solobot_tmp")
|
||||
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:
|
||||
console.print("[bold red]⛔ КРИТИЧЕСКАЯ ОШИБКА:[/bold red]")
|
||||
@@ -73,12 +73,12 @@ def fix_permissions():
|
||||
"""Устанавливает корректные права на файлы проекта"""
|
||||
console.print("[yellow]🔧 Устанавливаю права на файлы...[/yellow]")
|
||||
try:
|
||||
user = os.getenv('SUDO_USER') or os.getenv('USER')
|
||||
user = os.getenv("SUDO_USER") or os.getenv("USER")
|
||||
if user:
|
||||
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)
|
||||
|
||||
|
||||
console.print("[green]✅ Права успешно установлены[/green]")
|
||||
except subprocess.CalledProcessError as e:
|
||||
console.print(f"[red]❌ Ошибка при установке прав: {e}[/red]")
|
||||
@@ -195,7 +195,7 @@ def update_from_beta():
|
||||
|
||||
if not Confirm.ask("[yellow]🔁 Подтвердите обновление Solobot с ветки DEV[/yellow]"):
|
||||
return
|
||||
|
||||
|
||||
console.print("[red]⚠️ ВНИМАНИЕ! Папка бота будет перезаписана![/red]")
|
||||
if not Confirm.ask("[red]❓ Продолжить обновление?[/red]"):
|
||||
return
|
||||
@@ -215,11 +215,11 @@ def update_from_beta():
|
||||
|
||||
subprocess.run(["sudo", "rm", "-rf", os.path.join(PROJECT_DIR, "venv")])
|
||||
clean_project_dir_safe(update_buttons=update_buttons)
|
||||
|
||||
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
@@ -258,8 +258,7 @@ def update_from_release():
|
||||
console.print(f"[cyan]{idx}.[/cyan] {tag}")
|
||||
|
||||
selected = Prompt.ask(
|
||||
"[bold blue]Выберите номер релиза[/bold blue]",
|
||||
choices=[str(i) for i in range(1, len(tag_choices) + 1)]
|
||||
"[bold blue]Выберите номер релиза[/bold blue]", choices=[str(i) for i in range(1, len(tag_choices) + 1)]
|
||||
)
|
||||
tag_name = tag_choices[int(selected) - 1]
|
||||
|
||||
@@ -277,7 +276,7 @@ def update_from_release():
|
||||
exclude_options = "--exclude=img"
|
||||
if not update_buttons:
|
||||
exclude_options += " --exclude=handlers/buttons.py"
|
||||
|
||||
|
||||
subprocess.run(f"rsync -a {exclude_options} {TEMP_DIR}/ {PROJECT_DIR}/", shell=True)
|
||||
subprocess.run(["rm", "-rf", TEMP_DIR])
|
||||
|
||||
@@ -291,7 +290,6 @@ def update_from_release():
|
||||
|
||||
|
||||
def show_update_menu():
|
||||
|
||||
if IS_ROOT_DIR:
|
||||
console.print("[red]⛔ Обновление невозможно: бот находится в /root[/red]")
|
||||
console.print("[yellow]Перенесите бота в отдельную папку и повторите попытку[/yellow]")
|
||||
@@ -335,9 +333,9 @@ def update_cli_launcher():
|
||||
try:
|
||||
url = "https://raw.githubusercontent.com/Vladless/Solo_bot/dev/cli_launcher.py"
|
||||
response = requests.get(url, timeout=10)
|
||||
|
||||
|
||||
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)
|
||||
console.print("[green]✅ CLI лаунчер успешно обновлён[/green]")
|
||||
os.chmod(os.path.join(PROJECT_DIR, "cli_launcher.py"), 0o755)
|
||||
|
||||
+27
-6
@@ -846,9 +846,31 @@ async def upsert_user(
|
||||
|
||||
if only_if_exists:
|
||||
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(
|
||||
"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,
|
||||
username,
|
||||
first_name,
|
||||
last_name,
|
||||
language_code,
|
||||
is_bot,
|
||||
)
|
||||
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):
|
||||
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 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)
|
||||
@@ -1468,16 +1491,14 @@ async def get_tracking_source_stats(code: str, session) -> dict:
|
||||
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 p.status = 'success' AND NOT EXISTS (
|
||||
SELECT 1 FROM keys k WHERE k.tg_id = u.tg_id
|
||||
) THEN u.tg_id
|
||||
WHEN p.status = 'success' THEN u.tg_id
|
||||
END) AS payments
|
||||
FROM tracking_sources ts
|
||||
LEFT JOIN users u ON u.source_code = ts.code
|
||||
LEFT JOIN payments p ON p.tg_id = u.tg_id
|
||||
WHERE ts.code = $1
|
||||
GROUP BY ts.code, ts.name, ts.created_at
|
||||
""",
|
||||
""",
|
||||
code,
|
||||
)
|
||||
return dict(result) if result else {}
|
||||
|
||||
@@ -6,10 +6,10 @@ from asyncpg import Connection
|
||||
|
||||
from filters.admin import IsAdminFilter
|
||||
from logger import logger
|
||||
from middlewares import maintenance
|
||||
|
||||
from ..panel.keyboard import build_admin_back_kb
|
||||
from .keyboard import AdminPanelCallback, build_management_kb
|
||||
from middlewares import maintenance
|
||||
|
||||
|
||||
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"))
|
||||
async def toggle_maintenance_mode(callback: CallbackQuery):
|
||||
|
||||
maintenance.maintenance_mode = not maintenance.maintenance_mode
|
||||
|
||||
new_status = "включён" if maintenance.maintenance_mode else "выключен"
|
||||
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
@@ -24,12 +24,36 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
all_emojis = [correct_emoji] + wrong_emojis
|
||||
random.shuffle(all_emojis)
|
||||
|
||||
await state.update_data(
|
||||
correct_emoji=correct_emoji,
|
||||
message_id=message.message_id,
|
||||
chat_id=message.chat.id,
|
||||
original_text=message.text,
|
||||
)
|
||||
state_data = await state.get_data()
|
||||
|
||||
if "user_data" not in state_data:
|
||||
from_user = message.from_user
|
||||
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()
|
||||
for emoji in all_emojis:
|
||||
@@ -44,34 +68,33 @@ async def generate_captcha(message: Message, state: FSMContext):
|
||||
|
||||
@router.callback_query(F.data.startswith("captcha_"))
|
||||
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]
|
||||
state_data = await state.get_data()
|
||||
correct_emoji = state_data.get("correct_emoji")
|
||||
message_id = state_data.get("message_id")
|
||||
chat_id = state_data.get("chat_id")
|
||||
original_text = state_data.get("original_text")
|
||||
user_data = state_data.get("user_data")
|
||||
|
||||
if not message_id or not chat_id:
|
||||
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
|
||||
target_message = callback.message
|
||||
|
||||
if selected_emoji == correct_emoji:
|
||||
logger.info(f"Пользователь {callback.message.chat.id} успешно прошел капчу")
|
||||
await start_command(target_message, state, session, admin, captcha=False)
|
||||
else:
|
||||
logger.warning(f"Пользователь {callback.message.chat.id} неверно ответил на капчу")
|
||||
captcha = await generate_captcha(target_message, state)
|
||||
await edit_or_send_message(
|
||||
target_message=target_message,
|
||||
text=captcha["text"],
|
||||
reply_markup=captcha["markup"],
|
||||
logger.info(f"Пользователь {callback.from_user.id} успешно прошел капчу")
|
||||
logger.debug(f"[CAPTCHA] user_data передано в process_start_logic: {user_data}")
|
||||
await process_start_logic(
|
||||
message=target_message,
|
||||
state=state,
|
||||
session=session,
|
||||
admin=admin,
|
||||
text_to_process=original_text,
|
||||
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
@@ -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)
|
||||
|
||||
|
||||
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}")
|
||||
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()
|
||||
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)
|
||||
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)
|
||||
if not user_exists:
|
||||
from_user = message.from_user
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if coupon_record["amount"] > 0:
|
||||
try:
|
||||
|
||||
@@ -3,21 +3,21 @@ import uuid
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
import asyncpg
|
||||
|
||||
import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import F, Router
|
||||
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 py3xui import AsyncApi
|
||||
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
ADMIN_PASSWORD,
|
||||
ADMIN_USERNAME,
|
||||
CONNECT_PHONE_BUTTON,
|
||||
DATABASE_URL,
|
||||
PUBLIC_LINK,
|
||||
REMNAWAVE_LOGIN,
|
||||
REMNAWAVE_PASSWORD,
|
||||
@@ -39,7 +39,12 @@ from handlers.texts import (
|
||||
SELECT_COUNTRY_MSG,
|
||||
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 panels.remnawave import RemnawaveAPI
|
||||
from panels.three_xui import delete_client, get_xui_instance
|
||||
@@ -289,6 +294,12 @@ async def finalize_key_creation(
|
||||
remnawave_link = None
|
||||
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:
|
||||
old_server_id = old_key_details.get("server_id")
|
||||
if old_server_id:
|
||||
@@ -329,7 +340,7 @@ async def finalize_key_creation(
|
||||
except Exception as 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"])
|
||||
if not await remna.login(REMNAWAVE_LOGIN, REMNAWAVE_PASSWORD):
|
||||
raise ValueError(f"❌ Не удалось авторизоваться в Remnawave ({selected_country})")
|
||||
@@ -424,8 +435,16 @@ async def finalize_key_creation(
|
||||
return
|
||||
|
||||
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=PC_BUTTON, callback_data=f"connect_pc|{email}"),
|
||||
@@ -433,6 +452,7 @@ async def finalize_key_creation(
|
||||
)
|
||||
else:
|
||||
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"))
|
||||
|
||||
|
||||
@@ -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>"
|
||||
)
|
||||
|
||||
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(
|
||||
final_link,
|
||||
formatted_expiry_date,
|
||||
|
||||
@@ -13,9 +13,9 @@ from aiohttp import web
|
||||
from config import (
|
||||
DATABASE_URL,
|
||||
PROJECT_NAME,
|
||||
RANDOM_SUBSCRIPTIONS,
|
||||
SUPERNODE,
|
||||
SUPPORT_CHAT_URL,
|
||||
TOTAL_GB,
|
||||
USERNAME_BOT,
|
||||
USE_COUNTRY_SELECTION,
|
||||
)
|
||||
@@ -24,33 +24,45 @@ from handlers.utils import convert_to_bytes
|
||||
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:
|
||||
timeout = aiohttp.ClientTimeout(total=5)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, ssl=False) as response:
|
||||
if response.status == 200:
|
||||
content = await response.text()
|
||||
return base64.b64decode(content).decode("utf-8").split("\n")
|
||||
return []
|
||||
except Exception:
|
||||
return []
|
||||
lines = base64.b64decode(content).decode("utf-8").split("\n")
|
||||
headers = {k.lower(): v for k, v in response.headers.items()}
|
||||
logger.debug(f"Fetched {url}: {len(lines)} lines, headers: {headers}")
|
||||
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:
|
||||
logger.info(f"Режим SUPERNODE активен. Возвращаем первую ссылку для идентификатора: {identifier}")
|
||||
if not urls:
|
||||
return []
|
||||
return [], []
|
||||
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]
|
||||
tasks = [fetch_url_content(url, identifier) for url in urls_with_query]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
all_lines = set()
|
||||
for lines in results:
|
||||
all_lines.update(filter(None, lines))
|
||||
return list(all_lines)
|
||||
all_headers = []
|
||||
for result in results:
|
||||
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]:
|
||||
@@ -72,38 +84,61 @@ async def get_subscription_urls(server_id: str, email: str, conn, include_remnaw
|
||||
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
|
||||
if TOTAL_GB != 0:
|
||||
country_remaining = {}
|
||||
for line in cleaned_subscriptions:
|
||||
if "#" not in line:
|
||||
continue
|
||||
try:
|
||||
_, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip()
|
||||
remaining_str = parts[1].strip() if len(parts) == 2 else ""
|
||||
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
|
||||
num_countries = len(country_remaining)
|
||||
issued_per_country = TOTAL_GB
|
||||
total_traffic_bytes = issued_per_country * num_countries
|
||||
consumed_traffic_bytes = total_traffic_bytes - sum(country_remaining.values())
|
||||
if consumed_traffic_bytes < 0:
|
||||
consumed_traffic_bytes = 0
|
||||
else:
|
||||
consumed_traffic_bytes = 1
|
||||
total_traffic_bytes = 0
|
||||
return f"upload=0; download={consumed_traffic_bytes}; total={total_traffic_bytes}; expire={expire_timestamp}"
|
||||
|
||||
upload = 0
|
||||
download = 0
|
||||
total = 0
|
||||
for headers in headers_list:
|
||||
userinfo = headers.get("subscription-userinfo", "")
|
||||
if userinfo:
|
||||
parts = userinfo.split(";")
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if part.startswith("upload="):
|
||||
upload += int(part.split("=")[1])
|
||||
elif part.startswith("download="):
|
||||
download += int(part.split("=")[1])
|
||||
elif part.startswith("total="):
|
||||
total += int(part.split("=")[1])
|
||||
logger.debug(f"Processed Subscription-Userinfo: {userinfo}")
|
||||
|
||||
country_remaining = {}
|
||||
for line in cleaned_subscriptions:
|
||||
if "#" not in line:
|
||||
continue
|
||||
try:
|
||||
_, meta = line.split("#", 1)
|
||||
except ValueError:
|
||||
continue
|
||||
parts = meta.split("-")
|
||||
country = parts[0].strip()
|
||||
remaining_str = parts[1].strip() if len(parts) == 2 else ""
|
||||
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:
|
||||
@@ -211,8 +246,9 @@ async def handle_subscription(request: web.Request) -> web.Response:
|
||||
return web.Response(text="❌ Сервер не найден.", status=404)
|
||||
|
||||
query_string = request.query_string
|
||||
combined_subscriptions = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
random.shuffle(combined_subscriptions)
|
||||
combined_subscriptions, headers_list = await combine_unique_lines(urls, tg_id or email, query_string)
|
||||
if RANDOM_SUBSCRIPTIONS:
|
||||
random.shuffle(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}"
|
||||
|
||||
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)
|
||||
|
||||
return web.Response(text=base64_encoded, headers=headers)
|
||||
|
||||
@@ -33,7 +33,10 @@ from database import (
|
||||
update_key_expiry,
|
||||
)
|
||||
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 (
|
||||
KEY_DELETED_MSG,
|
||||
KEY_EXPIRED_DELAY_HOURS_MINUTES_MSG,
|
||||
@@ -53,10 +56,7 @@ from .special_notifications import notify_inactive_trial_users, notify_users_no_
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
notification_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@@ -76,7 +76,6 @@ async def periodic_notifications(bot: Bot):
|
||||
try:
|
||||
conn = await asyncpg.connect(DATABASE_URL)
|
||||
current_time = int(datetime.now(moscow_tz).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)
|
||||
|
||||
@@ -90,17 +89,35 @@ async def periodic_notifications(bot: Bot):
|
||||
keys = []
|
||||
|
||||
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 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 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 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)
|
||||
|
||||
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)
|
||||
|
||||
logger.info("Завершена обработка уведомлений")
|
||||
@@ -161,7 +178,13 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
)
|
||||
|
||||
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:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
@@ -169,12 +192,23 @@ async def notify_24h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
"text": notification_text,
|
||||
"photo": "notify_24h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 24 часа.")
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
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 часа завершена.")
|
||||
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:
|
||||
logger.error(f"Ошибка авто-продления/уведомления для пользователя {tg_id}: {e}")
|
||||
continue
|
||||
else:
|
||||
keyboard = build_notification_kb(email)
|
||||
messages.append({
|
||||
@@ -239,12 +274,23 @@ async def notify_10h_keys(bot: Bot, conn: asyncpg.Connection, current_time: int,
|
||||
"text": notification_text,
|
||||
"photo": "notify_10h.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истечении подписки через 10 часов.")
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
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 часов завершена.")
|
||||
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),
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
except Exception as 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,
|
||||
"photo": "notify_expired.jpg",
|
||||
"keyboard": keyboard,
|
||||
"notification_id": notification_id,
|
||||
"email": email,
|
||||
})
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
|
||||
if messages:
|
||||
await send_messages_with_limit(bot, messages)
|
||||
logger.info(f"Отправлено {len(messages)} уведомлений об истекших ключах.")
|
||||
results = await send_messages_with_limit(bot, messages, conn=conn)
|
||||
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("Обработка истекших ключей завершена.")
|
||||
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)
|
||||
|
||||
logger.info(
|
||||
f"[Автопродление] Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
f"Продление подписки {email} на {renewal_period_months} мес. для пользователя {tg_id}. Баланс: {balance}, списываем: {renewal_cost}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -436,9 +497,9 @@ async def process_auto_renew_or_notify(
|
||||
logger.error(f"❌ Ошибка при продлении ключа {client_id} для пользователя {tg_id}: {e}")
|
||||
else:
|
||||
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)
|
||||
if result:
|
||||
logger.info(f"📢 Отправлено уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
await add_notification(tg_id, notification_id, session=conn)
|
||||
else:
|
||||
logger.warning(f"📢 Не удалось отправить уведомление об истекающей подписке {email} пользователю {tg_id}.")
|
||||
@@ -2,29 +2,70 @@ import asyncio
|
||||
import os
|
||||
|
||||
import aiofiles
|
||||
import asyncpg
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.exceptions import TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramRetryAfter
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup
|
||||
|
||||
from database import create_blocked_user
|
||||
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
|
||||
results = []
|
||||
for i in range(0, len(messages), batch_size):
|
||||
batch = messages[i : i + batch_size]
|
||||
tasks = []
|
||||
for msg in batch:
|
||||
tasks.append(send_notification(bot, msg["tg_id"], msg.get("photo"), msg["text"], msg.get("keyboard")))
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке сообщений в батче: {e}")
|
||||
batch_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
processed_results = []
|
||||
for msg, result in zip(batch, batch_results, strict=False):
|
||||
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)
|
||||
return results
|
||||
|
||||
|
||||
def rate_limited_send(func):
|
||||
@@ -38,7 +79,11 @@ def rate_limited_send(func):
|
||||
await asyncio.sleep(retry_in)
|
||||
except TelegramForbiddenError:
|
||||
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
|
||||
except Exception as e:
|
||||
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)
|
||||
await bot.send_photo(tg_id, buffered_photo, caption=caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отправки фото для пользователя {tg_id}: {e}")
|
||||
@@ -104,8 +148,7 @@ async def _send_text_notification(
|
||||
try:
|
||||
await bot.send_message(tg_id, caption, reply_markup=keyboard)
|
||||
return True
|
||||
except TelegramForbiddenError:
|
||||
logger.error(f"Пользователь {tg_id} заблокировал бота")
|
||||
except (TelegramForbiddenError, TelegramBadRequest):
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Неизвестная ошибка при отправке сообщения для пользователя {tg_id}: {e}")
|
||||
|
||||
@@ -6,27 +6,30 @@ import asyncpg
|
||||
import pytz
|
||||
|
||||
from aiogram import Bot, Router, types
|
||||
from aiogram.exceptions import TelegramForbiddenError
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
|
||||
from config import NOTIFY_EXTRA_DAYS, NOTIFY_INACTIVE, NOTIFY_INACTIVE_TRAFFIC, SUPPORT_CHAT_URL, TRIAL_TIME
|
||||
from database import (
|
||||
add_notification,
|
||||
check_notifications_bulk,
|
||||
create_blocked_user,
|
||||
from config import (
|
||||
NOTIFY_EXTRA_DAYS,
|
||||
NOTIFY_INACTIVE,
|
||||
NOTIFY_INACTIVE_TRAFFIC,
|
||||
SUPPORT_CHAT_URL,
|
||||
TRIAL_TIME,
|
||||
)
|
||||
from database import add_notification, check_notifications_bulk, create_blocked_user
|
||||
from handlers.buttons import MAIN_MENU
|
||||
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 logger import logger
|
||||
from handlers.utils import format_days
|
||||
|
||||
from .notify_utils import send_messages_with_limit, send_notification
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
moscow_tz = pytz.timezone("Europe/Moscow")
|
||||
|
||||
|
||||
@@ -37,19 +40,15 @@ async def notify_inactive_trial_users(bot: Bot, conn: asyncpg.Connection):
|
||||
Если прошло 24 часа и триал не активирован, отправляется уведомление с бонусом +2 дня.
|
||||
"""
|
||||
logger.info("Проверка пользователей, не активировавших пробный период...")
|
||||
|
||||
users = await check_notifications_bulk("inactive_trial", NOTIFY_INACTIVE, conn)
|
||||
logger.info(f"Найдено {len(users)} неактивных пользователей для уведомления.")
|
||||
|
||||
messages = []
|
||||
|
||||
for user in users:
|
||||
tg_id = user["tg_id"]
|
||||
username = user["username"]
|
||||
first_name = user["first_name"]
|
||||
last_name = user["last_name"]
|
||||
display_name = username or first_name or last_name or "Пользователь"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(
|
||||
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"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
trial_extended = user["last_notification_time"] is not None
|
||||
|
||||
if trial_extended:
|
||||
total_days = NOTIFY_EXTRA_DAYS + TRIAL_TIME
|
||||
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(
|
||||
display_name=display_name, trial_time_formatted=format_days(TRIAL_TIME)
|
||||
)
|
||||
|
||||
try:
|
||||
await bot.send_message(tg_id, message, reply_markup=keyboard)
|
||||
logger.info(f"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
await add_notification(tg_id, "inactive_trial", session=conn)
|
||||
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}. Добавляем в blocked_users.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"⚠ Ошибка при отправке уведомления пользователю {tg_id}: {e}")
|
||||
|
||||
await asyncio.sleep(1)
|
||||
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"notification_id": "inactive_trial",
|
||||
})
|
||||
if messages:
|
||||
results = await send_messages_with_limit(
|
||||
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
|
||||
)
|
||||
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"📩 Отправлено уведомление неактивному пользователю {tg_id}.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление неактивному пользователю {tg_id}.")
|
||||
logger.info(f"Отправлено {sent_count} уведомлений неактивным пользователям.")
|
||||
logger.info("✅ Проверка пользователей с неактивным пробным периодом завершена.")
|
||||
|
||||
|
||||
@@ -99,8 +101,8 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
но исключает пользователей, у которых подписка недавно продлилась.
|
||||
"""
|
||||
logger.info("Проверка пользователей с нулевым трафиком...")
|
||||
|
||||
current_dt = datetime.fromtimestamp(current_time / 1000, tz=moscow_tz)
|
||||
messages = []
|
||||
|
||||
for key in keys:
|
||||
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)
|
||||
renewal_threshold = expiry_dt - timedelta(days=30)
|
||||
renewal_recent = current_dt - renewal_threshold < timedelta(hours=NOTIFY_INACTIVE_TRAFFIC)
|
||||
|
||||
if renewal_recent:
|
||||
continue
|
||||
|
||||
@@ -144,39 +145,39 @@ async def notify_users_no_traffic(bot: Bot, conn: asyncpg.Connection, current_ti
|
||||
total_traffic = sum(
|
||||
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:
|
||||
logger.info(f"⚠ У пользователя {tg_id} ({email}) 0 ГБ трафика. Отправляем уведомление.")
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.row(types.InlineKeyboardButton(text="🔧 Написать в поддержку", url=SUPPORT_CHAT_URL))
|
||||
builder.row(types.InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
keyboard = builder.as_markup()
|
||||
|
||||
message = ZERO_TRAFFIC_MSG.format(email=email)
|
||||
messages.append({
|
||||
"tg_id": tg_id,
|
||||
"text": message,
|
||||
"keyboard": keyboard,
|
||||
"client_id": client_id,
|
||||
})
|
||||
|
||||
try:
|
||||
result = await send_notification(bot, tg_id, None, message, keyboard)
|
||||
await conn.execute(
|
||||
"UPDATE keys SET notified = TRUE WHERE tg_id = $1 AND client_id = $2", tg_id, client_id
|
||||
)
|
||||
if result:
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
except TelegramForbiddenError:
|
||||
logger.warning(f"🚫 Бот заблокирован пользователем {tg_id}.")
|
||||
await create_blocked_user(tg_id, conn)
|
||||
except Exception as e:
|
||||
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}")
|
||||
if messages:
|
||||
results = await send_messages_with_limit(
|
||||
bot, messages, conn=conn, source_file="special_notifications", messages_per_second=25
|
||||
)
|
||||
sent_count = 0
|
||||
for msg, result in zip(messages, results, strict=False):
|
||||
tg_id = msg["tg_id"]
|
||||
if result:
|
||||
sent_count += 1
|
||||
logger.info(f"📩 Отправлено уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
else:
|
||||
logger.warning(f"📩 Не удалось отправить уведомление пользователю {tg_id} о нулевом трафике.")
|
||||
logger.info(f"Отправлено {sent_count} уведомлений о нулевом трафике.")
|
||||
|
||||
logger.info("✅ Обработка пользователей с нулевым трафиком завершена.")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+28
-22
@@ -213,49 +213,55 @@ async def top_referrals_handler(callback_query: CallbackQuery):
|
||||
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:
|
||||
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("❌ Вы не можете быть реферальной ссылкой самого себя.")
|
||||
return
|
||||
|
||||
if user_exists_now:
|
||||
await message.answer("❌ Вы уже зарегистрированы и не можете использовать реферальную ссылку.")
|
||||
return
|
||||
|
||||
existing_referral = await get_referral_by_referred_id(message.chat.id, session)
|
||||
existing_referral = await get_referral_by_referred_id(user_id, session)
|
||||
if existing_referral:
|
||||
await message.answer("❌ Вы уже использовали реферальную ссылку.")
|
||||
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_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,
|
||||
)
|
||||
await add_referral(user_id, referrer_tg_id, session)
|
||||
|
||||
try:
|
||||
await bot.send_message(
|
||||
referrer_tg_id,
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=message.chat.id),
|
||||
NEW_REFERRAL_NOTIFICATION.format(referred_id=user_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось отправить уведомление пригласившему ({referrer_tg_id}): {e}")
|
||||
|
||||
await message.answer(REFERRAL_SUCCESS_MSG.format(referrer_tg_id=referrer_tg_id))
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке реферальной ссылки {referral_code}: {e}")
|
||||
await message.answer("❌ Произошла ошибка при обработке реферальной ссылки.")
|
||||
return
|
||||
|
||||
+105
-76
@@ -75,35 +75,6 @@ async def start_command(message: Message, state: FSMContext, session: Any, admin
|
||||
|
||||
state_data = await state.get_data()
|
||||
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)
|
||||
|
||||
|
||||
@@ -126,10 +97,16 @@ async def check_subscription_callback(callback_query: CallbackQuery, state: FSMC
|
||||
else:
|
||||
await callback_query.answer(SUBSCRIPTION_CONFIRMED_MSG)
|
||||
data = await state.get_data()
|
||||
original_text = data.get("original_text")
|
||||
if not original_text:
|
||||
original_text = callback_query.message.text
|
||||
await process_start_logic(callback_query.message, state, session, admin, text_to_process=original_text)
|
||||
original_text = data.get("original_text") or callback_query.message.text
|
||||
user_data = data.get("user_data")
|
||||
await process_start_logic(
|
||||
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}")
|
||||
except Exception as e:
|
||||
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(
|
||||
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
|
||||
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:
|
||||
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {message.chat.id}")
|
||||
logger.info(f"[StartLogic] Текста нет — вызываю стартовое меню для {user_data['tg_id']}")
|
||||
await show_start_menu(message, admin, session)
|
||||
return
|
||||
|
||||
@@ -159,7 +198,7 @@ async def process_start_logic(
|
||||
if "coupons" in part:
|
||||
logger.info(f"Обнаружена ссылка на купон: {part}")
|
||||
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
|
||||
|
||||
if "gift" in part:
|
||||
@@ -172,7 +211,7 @@ async def process_start_logic(
|
||||
gift_id = parts[0]
|
||||
sender_id = parts[1]
|
||||
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
|
||||
break
|
||||
|
||||
@@ -180,34 +219,26 @@ async def process_start_logic(
|
||||
referrer_tg_id = part.split("referral")[1].strip("_")
|
||||
try:
|
||||
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):
|
||||
pass
|
||||
continue
|
||||
|
||||
if "utm" in part:
|
||||
utm_code = part
|
||||
logger.info(f"Обнаружена ссылка на UTM: {utm_code}")
|
||||
await handle_utm_link(utm_code, message, state, session)
|
||||
logger.info(f"[UTM] Обнаружена ссылка на UTM: {utm_code}")
|
||||
await handle_utm_link(utm_code, message, state, session, user_data=user_data)
|
||||
continue
|
||||
|
||||
|
||||
await state.clear()
|
||||
if gift_detected:
|
||||
return
|
||||
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
user_exists = await check_user_exists(user_data["tg_id"])
|
||||
if not user_exists:
|
||||
from_user = message.from_user
|
||||
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,
|
||||
)
|
||||
await add_user(session=session, **user_data)
|
||||
|
||||
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 trial_status > 0:
|
||||
@@ -216,33 +247,26 @@ async def process_start_logic(
|
||||
await show_start_menu(message, admin, session)
|
||||
else:
|
||||
await show_start_menu(message, admin, session)
|
||||
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при обработке текста {message.text} — {e}", exc_info=True)
|
||||
logger.error(f"Ошибка при обработке текста {text} — {e}", exc_info=True)
|
||||
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)
|
||||
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:
|
||||
user_exists = await check_user_exists(message.chat.id)
|
||||
from_user = message.from_user
|
||||
user_exists = await check_user_exists(user_id)
|
||||
if not user_exists:
|
||||
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,
|
||||
source_code=utm_code,
|
||||
)
|
||||
await add_user(session=session, source_code=utm_code, **user_data)
|
||||
else:
|
||||
await session.execute("UPDATE users SET source_code = $1 WHERE tg_id = $2", utm_code, message.chat.id)
|
||||
logger.info(f"[UTM] Привязана {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} к пользователю {user_id}")
|
||||
else:
|
||||
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")
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
trial_status = None
|
||||
if session is not None:
|
||||
trial_status = await get_trial(message.chat.id, session)
|
||||
logger.info(f"Trial status для {message.chat.id}: {trial_status}")
|
||||
if trial_status == 0:
|
||||
builder.row(InlineKeyboardButton(text=TRIAL_SUB, callback_data="create_key"))
|
||||
else:
|
||||
builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile"))
|
||||
else:
|
||||
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"))
|
||||
|
||||
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")
|
||||
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()
|
||||
if DONATIONS_ENABLE:
|
||||
builder.row(InlineKeyboardButton(text="💰 Поддержать проект", callback_data="donate"))
|
||||
@@ -303,7 +331,8 @@ async def handle_about_vpn(callback_query: CallbackQuery):
|
||||
else:
|
||||
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")
|
||||
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(),
|
||||
media_path=image_path,
|
||||
force_text=False,
|
||||
)
|
||||
)
|
||||
+18
-15
@@ -4,13 +4,14 @@ import re
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import aiofiles
|
||||
import aiohttp
|
||||
import asyncpg
|
||||
|
||||
from aiogram.types import BufferedInputFile, InlineKeyboardMarkup, InputMediaPhoto, Message
|
||||
|
||||
from datetime import datetime
|
||||
from bot import bot
|
||||
from config import ADMIN_ID, DATABASE_URL
|
||||
from database import get_all_keys, get_servers
|
||||
@@ -146,12 +147,14 @@ def format_days(days: int) -> str:
|
||||
return "0 дней"
|
||||
return f"{days} {get_plural_form(days, 'день', 'дня', 'дней')}"
|
||||
|
||||
|
||||
def format_hours(hours: int) -> str:
|
||||
"""Форматирует количество часов с правильным склонением"""
|
||||
if hours <= 0:
|
||||
return "0 часов"
|
||||
return f"{hours} {get_plural_form(hours, 'час', 'часа', 'часов')}"
|
||||
|
||||
|
||||
def format_minutes(minutes: int) -> str:
|
||||
"""Форматирует количество минут с правильным склонением"""
|
||||
if minutes <= 0:
|
||||
@@ -272,28 +275,28 @@ def sanitize_key_name(key_name: str) -> str:
|
||||
|
||||
|
||||
RUSSIAN_MONTHS = {
|
||||
'January': 'Января',
|
||||
'February': 'Февраля',
|
||||
'March': 'Марта',
|
||||
'April': 'Апреля',
|
||||
'May': 'Мая',
|
||||
'June': 'Июня',
|
||||
'July': 'Июля',
|
||||
'August': 'Августа',
|
||||
'September': 'Сентября',
|
||||
'October': 'Октября',
|
||||
'November': 'Ноября',
|
||||
'December': 'Декабря'
|
||||
"January": "Января",
|
||||
"February": "Февраля",
|
||||
"March": "Марта",
|
||||
"April": "Апреля",
|
||||
"May": "Мая",
|
||||
"June": "Июня",
|
||||
"July": "Июля",
|
||||
"August": "Августа",
|
||||
"September": "Сентября",
|
||||
"October": "Октября",
|
||||
"November": "Ноября",
|
||||
"December": "Декабря",
|
||||
}
|
||||
|
||||
|
||||
def get_russian_month(date: datetime) -> str:
|
||||
"""
|
||||
Преобразует английское название месяца в русское.
|
||||
|
||||
|
||||
Args:
|
||||
date: Объект datetime, из которого извлекается месяц.
|
||||
|
||||
|
||||
Returns:
|
||||
Название месяца на русском языке.
|
||||
"""
|
||||
|
||||
@@ -5,11 +5,10 @@ from aiogram.dispatcher.middlewares.base import BaseMiddleware
|
||||
|
||||
from .admin import AdminMiddleware
|
||||
from .loggings import LoggingMiddleware
|
||||
from .maintenance import MaintenanceModeMiddleware
|
||||
from .session import SessionMiddleware
|
||||
from .throttling import ThrottlingMiddleware
|
||||
from .user import UserMiddleware
|
||||
from .maintenance import MaintenanceModeMiddleware
|
||||
|
||||
|
||||
|
||||
def register_middleware(
|
||||
@@ -17,13 +16,12 @@ def register_middleware(
|
||||
middlewares: Iterable[BaseMiddleware | type[BaseMiddleware]] | None = None,
|
||||
exclude: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""Регистрирует middleware в диспетчере.
|
||||
"""
|
||||
"""Регистрирует middleware в диспетчере."""
|
||||
if middlewares is None:
|
||||
available_middlewares = {
|
||||
"admin": AdminMiddleware(),
|
||||
"session": SessionMiddleware(),
|
||||
"maintenance": MaintenanceModeMiddleware(),
|
||||
"maintenance": MaintenanceModeMiddleware(),
|
||||
"logging": LoggingMiddleware(),
|
||||
"throttling": ThrottlingMiddleware(),
|
||||
"user": UserMiddleware(),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import Message, CallbackQuery
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
|
||||
from config import ADMIN_ID
|
||||
|
||||
|
||||
maintenance_mode = False
|
||||
|
||||
|
||||
class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
async def __call__(self, handler, event, data):
|
||||
if maintenance_mode:
|
||||
@@ -16,5 +19,5 @@ class MaintenanceModeMiddleware(BaseMiddleware):
|
||||
if user_id and user_id not in ADMIN_ID:
|
||||
await event.answer("⚙️ Бот временно недоступен. Ведутся технические работы.")
|
||||
return
|
||||
|
||||
|
||||
return await handler(event, data)
|
||||
|
||||
Binary file not shown.
+4
-4
@@ -1,6 +1,6 @@
|
||||
import csv
|
||||
from datetime import datetime
|
||||
|
||||
from datetime import datetime
|
||||
from io import StringIO
|
||||
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")
|
||||
|
||||
for row in keys:
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
buffer.write(
|
||||
f"{row['tg_id']},{row['client_id']},{row['email']},"
|
||||
f"{created_at},{expiry_time},{row['key']},"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user