mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-05-04 20:15:56 +00:00
Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot
This commit is contained in:
27
.github/workflows/lint.yml
vendored
Normal file
27
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- run: uv sync --group dev
|
||||
|
||||
- name: Check formatting
|
||||
run: uv run ruff format --check .
|
||||
|
||||
- name: Check linting
|
||||
run: uv run ruff check .
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -31,6 +31,13 @@ docker-compose.override.yml
|
||||
# Разрешаем .gitignore чтобы он попал в репозиторий
|
||||
!.gitignore
|
||||
|
||||
# Разрешаем .github/ (workflows, pre-commit и т.д.)
|
||||
!.github/
|
||||
!.github/**
|
||||
|
||||
# Разрешаем Makefile
|
||||
!Makefile
|
||||
|
||||
# Внутри разрешенных папок игнорируем служебные файлы
|
||||
app/__pycache__/
|
||||
app/**/__pycache__/
|
||||
|
||||
4
Makefile
4
Makefile
@@ -45,7 +45,5 @@ help: ## Показать список доступных команд
|
||||
@echo ""
|
||||
@echo "📘 Команды Makefile:"
|
||||
@echo ""
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?##' $(MAKEFILE_LIST) | \
|
||||
sed -E 's/:.*?## /| /' | \
|
||||
awk -F'|' '{printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
|
||||
@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## / {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
@echo ""
|
||||
|
||||
@@ -688,7 +688,7 @@ async def get_top_referrers(
|
||||
referrer_ids = list(referrers_data.keys())
|
||||
if referrer_ids:
|
||||
users_query = await db.execute(
|
||||
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name).where(
|
||||
select(User.id, User.telegram_id, User.username, User.first_name, User.last_name, User.email).where(
|
||||
User.id.in_(referrer_ids)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -284,6 +284,19 @@ async def get_payment_methods():
|
||||
)
|
||||
)
|
||||
|
||||
# Tribute
|
||||
if settings.TRIBUTE_ENABLED and settings.TRIBUTE_DONATE_LINK:
|
||||
methods.append(
|
||||
PaymentMethodResponse(
|
||||
id='tribute',
|
||||
name='Tribute',
|
||||
description='Pay with bank card via Tribute',
|
||||
min_amount_kopeks=10000,
|
||||
max_amount_kopeks=10000000,
|
||||
is_available=True,
|
||||
)
|
||||
)
|
||||
|
||||
return methods
|
||||
|
||||
|
||||
@@ -715,6 +728,17 @@ async def create_topup(
|
||||
detail='Failed to create FreeKassa payment',
|
||||
)
|
||||
|
||||
elif request.payment_method == 'tribute':
|
||||
if not settings.TRIBUTE_ENABLED or not settings.TRIBUTE_DONATE_LINK:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Tribute payment method is unavailable',
|
||||
)
|
||||
|
||||
user_identifier = user.telegram_id or user.id
|
||||
payment_url = f'{settings.TRIBUTE_DONATE_LINK}&user_id={user_identifier}'
|
||||
payment_id = f'tribute_{user_identifier}_{request.amount_kopeks}'
|
||||
|
||||
else:
|
||||
# For other payment methods, redirect to bot
|
||||
raise HTTPException(
|
||||
|
||||
@@ -4140,7 +4140,7 @@ async def create_button_click_logs_table() -> bool:
|
||||
CREATE TABLE button_click_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
@@ -4152,7 +4152,7 @@ async def create_button_click_logs_table() -> bool:
|
||||
CREATE TABLE button_click_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL REFERENCES users(telegram_id) ON DELETE SET NULL,
|
||||
user_id INTEGER NULL REFERENCES users(id) ON DELETE SET NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT NOW(),
|
||||
button_type VARCHAR(20) NULL,
|
||||
@@ -4164,12 +4164,12 @@ async def create_button_click_logs_table() -> bool:
|
||||
CREATE TABLE button_click_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
button_id VARCHAR(100) NOT NULL,
|
||||
user_id BIGINT NULL,
|
||||
user_id INTEGER NULL,
|
||||
callback_data VARCHAR(255) NULL,
|
||||
clicked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
button_type VARCHAR(20) NULL,
|
||||
button_text VARCHAR(255) NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(telegram_id) ON DELETE SET NULL
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB
|
||||
"""
|
||||
|
||||
@@ -4194,6 +4194,74 @@ async def create_button_click_logs_table() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
async def fix_button_click_logs_fk() -> bool:
|
||||
"""Исправляет FK button_click_logs.user_id: users(telegram_id) -> users(id)."""
|
||||
table_exists = await check_table_exists('button_click_logs')
|
||||
if not table_exists:
|
||||
return True
|
||||
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
db_type = await get_database_type()
|
||||
|
||||
if db_type == 'postgresql':
|
||||
# Проверяем, ссылается ли FK на telegram_id (ошибочный вариант)
|
||||
check_sql = text("""
|
||||
SELECT ccu.column_name
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON tc.constraint_name = ccu.constraint_name
|
||||
WHERE tc.table_name = 'button_click_logs'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'users'
|
||||
LIMIT 1
|
||||
""")
|
||||
result = await conn.execute(check_sql)
|
||||
row = result.fetchone()
|
||||
|
||||
if row and row[0] == 'telegram_id':
|
||||
logger.info('🔧 Исправляем FK button_click_logs.user_id: telegram_id -> id')
|
||||
|
||||
# Обнуляем невалидные user_id (которые были internal id, а не telegram_id)
|
||||
await conn.execute(text("""
|
||||
UPDATE button_click_logs
|
||||
SET user_id = NULL
|
||||
WHERE user_id IS NOT NULL
|
||||
AND user_id NOT IN (SELECT telegram_id FROM users)
|
||||
"""))
|
||||
|
||||
# Удаляем старый FK
|
||||
await conn.execute(text(
|
||||
'ALTER TABLE button_click_logs DROP CONSTRAINT IF EXISTS button_click_logs_user_id_fkey'
|
||||
))
|
||||
|
||||
# Меняем тип колонки и добавляем правильный FK
|
||||
await conn.execute(text(
|
||||
'ALTER TABLE button_click_logs ALTER COLUMN user_id TYPE INTEGER'
|
||||
))
|
||||
|
||||
# Обнуляем все значения, т.к. они были записаны неправильно
|
||||
await conn.execute(text(
|
||||
'UPDATE button_click_logs SET user_id = NULL'
|
||||
))
|
||||
|
||||
await conn.execute(text(
|
||||
'ALTER TABLE button_click_logs '
|
||||
'ADD CONSTRAINT button_click_logs_user_id_fkey '
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL'
|
||||
))
|
||||
|
||||
logger.info('✅ FK button_click_logs.user_id исправлен')
|
||||
else:
|
||||
logger.debug('FK button_click_logs.user_id уже корректен')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f'❌ Ошибка исправления FK button_click_logs: {error}')
|
||||
return False
|
||||
|
||||
|
||||
async def create_web_api_tokens_table() -> bool:
|
||||
table_exists = await check_table_exists('web_api_tokens')
|
||||
if table_exists:
|
||||
@@ -6190,6 +6258,13 @@ async def run_universal_migration():
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с таблицей button_click_logs')
|
||||
|
||||
logger.info('=== ИСПРАВЛЕНИЕ FK BUTTON_CLICK_LOGS ===')
|
||||
fk_fixed = await fix_button_click_logs_fk()
|
||||
if fk_fixed:
|
||||
logger.info('✅ FK button_click_logs проверен')
|
||||
else:
|
||||
logger.warning('⚠️ Проблемы с FK button_click_logs')
|
||||
|
||||
logger.info('=== ДОБАВЛЕНИЕ КОЛОНКИ ДЛЯ ТРИАЛЬНЫХ СКВАДОВ ===')
|
||||
trial_column_ready = await add_server_trial_flag_column()
|
||||
if trial_column_ready:
|
||||
|
||||
6
app/external/remnawave_api.py
vendored
6
app/external/remnawave_api.py
vendored
@@ -341,7 +341,11 @@ class RemnaWaveAPI:
|
||||
|
||||
connector = aiohttp.TCPConnector(**connector_kwargs)
|
||||
|
||||
session_kwargs = {'timeout': aiohttp.ClientTimeout(total=60, connect=10), 'headers': headers, 'connector': connector}
|
||||
session_kwargs = {
|
||||
'timeout': aiohttp.ClientTimeout(total=60, connect=10),
|
||||
'headers': headers,
|
||||
'connector': connector,
|
||||
}
|
||||
|
||||
if cookies:
|
||||
session_kwargs['cookies'] = cookies
|
||||
|
||||
@@ -1581,6 +1581,11 @@ async def handle_extend_subscription(callback: types.CallbackQuery, db_user: Use
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_total_base = devices_price_per_month * months_in_period
|
||||
@@ -1805,6 +1810,11 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
# Модем добавляет +1 к device_limit, но оплачивается отдельно,
|
||||
# поэтому не должен учитываться как платное устройство при продлении
|
||||
if getattr(subscription, 'modem_enabled', False):
|
||||
device_limit = max(1, device_limit - 1)
|
||||
|
||||
additional_devices = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount(
|
||||
|
||||
@@ -362,6 +362,10 @@ class HeleketPaymentMixin:
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
# Перезагружаем пользователя с зависимостями после коммита,
|
||||
# чтобы избежать lazy load в async-контексте (MissingGreenlet)
|
||||
user = await get_user_by_id(db, user.id) or user
|
||||
|
||||
if getattr(self, 'bot', None):
|
||||
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
|
||||
referrer_info = format_referrer_info(user)
|
||||
|
||||
@@ -383,6 +383,22 @@ class TelegramStarsMixin:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Начисляем реферальную комиссию за прямую покупку подписки
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as ref_error:
|
||||
logger.error(
|
||||
'Ошибка реферального начисления при покупке подписки через Stars: %s',
|
||||
ref_error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Обработан Stars платеж как покупка подписки: пользователь %s, %s звезд → %s',
|
||||
user.id,
|
||||
|
||||
@@ -472,7 +472,10 @@ class WataPaymentMixin:
|
||||
user.balance_kopeks += payment.amount_kopeks
|
||||
user.updated_at = datetime.utcnow()
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
user = await payment_module.get_user_by_id(db, user.id)
|
||||
if not user:
|
||||
logger.error('Пользователь %s не найден после коммита WATA', payment.user_id)
|
||||
return payment
|
||||
|
||||
promo_group = user.get_primary_promo_group()
|
||||
subscription = getattr(user, 'subscription', None)
|
||||
|
||||
@@ -687,6 +687,22 @@ class YooKassaPaymentMixin:
|
||||
payment.yookassa_payment_id,
|
||||
user.id,
|
||||
)
|
||||
|
||||
# Начисляем реферальную комиссию за прямую покупку подписки
|
||||
try:
|
||||
from app.services.referral_service import process_referral_topup
|
||||
|
||||
await process_referral_topup(
|
||||
db,
|
||||
user.id,
|
||||
payment.amount_kopeks,
|
||||
getattr(self, 'bot', None),
|
||||
)
|
||||
except Exception as ref_error:
|
||||
logger.error(
|
||||
'Ошибка реферального начисления при покупке подписки YooKassa: %s',
|
||||
ref_error,
|
||||
)
|
||||
else:
|
||||
old_balance = getattr(user, 'balance_kopeks', 0)
|
||||
was_first_topup = not getattr(user, 'has_made_first_topup', False)
|
||||
|
||||
@@ -79,11 +79,12 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
|
||||
PaymentMethod.YOOKASSA,
|
||||
PaymentMethod.MULENPAY,
|
||||
PaymentMethod.PAL24,
|
||||
PaymentMethod.WATA,
|
||||
PaymentMethod.CRYPTOBOT,
|
||||
PaymentMethod.PLATEGA,
|
||||
# CloudPayments removed - API returns "Completed" during authorization
|
||||
# before final result, causing premature balance credits. Webhooks work correctly.
|
||||
# WATA removed - API returns 429 "Use webhook – polling is rate-limited".
|
||||
# Payments are processed via webhook (wata_webhook.py).
|
||||
PaymentMethod.FREEKASSA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1174,10 +1174,7 @@ class RemnaWaveService:
|
||||
user.remnawave_uuid: user for user in bot_users if getattr(user, 'remnawave_uuid', None)
|
||||
}
|
||||
# Index users by email for email-only sync
|
||||
bot_users_by_email = {
|
||||
user.email.lower(): user for user in bot_users
|
||||
if user.email and user.email_verified
|
||||
}
|
||||
bot_users_by_email = {user.email.lower(): user for user in bot_users if user.email and user.email_verified}
|
||||
# Also index email-only users by their remnawave_uuid for sync
|
||||
email_users_count = sum(1 for u in bot_users if u.telegram_id is None)
|
||||
if email_users_count > 0:
|
||||
@@ -1203,8 +1200,7 @@ class RemnaWaveService:
|
||||
|
||||
# Email-only пользователи из панели (без telegram_id, но с email)
|
||||
panel_users_email_only = [
|
||||
user for user in panel_users
|
||||
if user.get('telegramId') is None and user.get('email')
|
||||
user for user in panel_users if user.get('telegramId') is None and user.get('email')
|
||||
]
|
||||
if panel_users_email_only:
|
||||
logger.info(f'📧 Пользователей в панели с Email (без Telegram): {len(panel_users_email_only)}')
|
||||
@@ -1843,7 +1839,10 @@ class RemnaWaveService:
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name, username=user.username, telegram_id=user.telegram_id, email=user.email
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
),
|
||||
active_internal_squads=sub.connected_squads,
|
||||
)
|
||||
|
||||
@@ -192,6 +192,12 @@ class SubscriptionService:
|
||||
logger.error(f'Ошибка валидации подписки для пользователя {self._format_user_log(user)}')
|
||||
return None
|
||||
|
||||
# Загружаем tariff заранее, чтобы избежать lazy loading в async контексте
|
||||
try:
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception:
|
||||
pass # tariff может быть None или уже загружен
|
||||
|
||||
user_tag = self._resolve_user_tag(subscription)
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
|
||||
@@ -553,8 +553,8 @@ class TrafficMonitoringServiceV2:
|
||||
|
||||
# Получаем период за последние 24 часа
|
||||
now = datetime.utcnow()
|
||||
start_date = (now - timedelta(hours=24)).strftime('%Y-%m-%dT%H:%M:%S.000Z')
|
||||
end_date = now.strftime('%Y-%m-%dT%H:%M:%S.999Z')
|
||||
start_date = (now - timedelta(hours=24)).strftime('%Y-%m-%d')
|
||||
end_date = now.strftime('%Y-%m-%d')
|
||||
|
||||
users = await self.get_all_users_with_traffic()
|
||||
semaphore = asyncio.Semaphore(self.get_concurrency())
|
||||
|
||||
Reference in New Issue
Block a user