Compare commits

...

21 Commits

Author SHA1 Message Date
Egor 16ae480430 Update Dockerfile 2025-09-10 00:26:52 +03:00
Egor 0b222c06a1 Update docker-hub.yml 2025-09-10 00:26:21 +03:00
Egor ff340e90d6 Update docker-registry.yml 2025-09-10 00:26:02 +03:00
Egor 4125141d01 Merge pull request #49 from yazhog/main
Режим link для ссылки подписки, фикс напоминаний о истечении подписки
2025-09-10 00:06:38 +03:00
yazhog 2f40ad53b5 Merge pull request #10 from yazhog/codex/implement-notification-tracking-system
Persist subscription expiry notifications
2025-09-09 23:36:56 +03:00
yazhog 2d3674eff7 Cascade delete sent notifications 2025-09-09 23:29:06 +03:00
yazhog 560e60b856 Persist subscription expiry notifications 2025-09-09 19:13:15 +03:00
yazhog 7cf20234b8 Merge branch 'Fr1ngg:main' into main 2025-09-09 18:52:42 +03:00
yazhog ecab0a0f45 Merge pull request #9 from yazhog/codex/add-connect_button_mode-for-link-subscription
feat: add link connect button mode
2025-09-09 18:42:32 +03:00
yazhog 587722ed5c feat: add link connect button mode 2025-09-09 13:32:20 +03:00
Egor bd7d9b3d5e Update docker-registry.yml 2025-09-09 11:05:30 +03:00
Egor 60787a2aa9 Merge pull request #48 from yazhog/main
Тест подписка сразу после старта бота, продление в любой момент
2025-09-09 10:38:59 +03:00
yazhog 9675ac3c14 Allow subscription renewal anytime 2025-09-09 10:21:13 +03:00
yazhog ca9a2f5997 Add extend subscription button for paid users 2025-09-09 10:21:13 +03:00
yazhog 652eb4f5a7 Respect renewal period settings in extension keyboard 2025-09-09 10:21:13 +03:00
yazhog 6ffbad747d Правка текстов 2025-09-09 10:21:13 +03:00
yazhog af46c596a9 Фикс текста 2025-09-09 10:21:13 +03:00
yazhog 1fcc218f26 Тексты поменял 2025-09-09 10:21:00 +03:00
yazhog 39a5d674c9 feat: add greeting and VPN advantages to trial offer 2025-09-09 10:19:25 +03:00
yazhog bdfe6ae1cf feat: add trial prompt after registration 2025-09-09 10:19:25 +03:00
yazhog 06e6c65ada Убрал про Ваш реферер получит... 2025-09-09 10:19:25 +03:00
17 changed files with 291 additions and 181 deletions
+1
View File
@@ -194,6 +194,7 @@ CRYPTOBOT_INVOICE_EXPIRES_HOURS=24
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
+3 -3
View File
@@ -36,15 +36,15 @@ jobs:
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.5-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:latest,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:dev,fr1ngg/remnawave-bedolaga-telegram-bot:${VERSION}"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
TAGS="fr1ngg/remnawave-bedolaga-telegram-bot:pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
+34 -6
View File
@@ -35,6 +35,7 @@ jobs:
network=host
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -51,16 +52,30 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/}
echo "🏷️ Собираем релизную версию: $VERSION"
elif [[ $GITHUB_REF == refs/heads/main ]]; then
VERSION="v2.2.5"
VERSION="v2.2.6"
echo "🚀 Собираем версию из main: $VERSION"
elif [[ $GITHUB_REF == refs/heads/dev ]]; then
VERSION="v2.2.5-dev-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-dev-$(git rev-parse --short HEAD)"
echo "🧪 Собираем dev версию: $VERSION"
else
VERSION="v2.2.5-pr-$(git rev-parse --short HEAD)"
VERSION="v2.2.6-pr-$(git rev-parse --short HEAD)"
echo "🔀 Собираем PR версию: $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Определяем, нужно ли пушить образ
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.pull_request.head.repo.full_name }}" == "${{ github.repository }}" ]]; then
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ PR из того же репозитория - будем пушить"
else
echo "should_push=false" >> $GITHUB_OUTPUT
echo "⚠️ PR из внешнего форка - только build без push"
fi
else
echo "should_push=true" >> $GITHUB_OUTPUT
echo "✅ Push/Tag - будем пушить"
fi
- name: Extract metadata
id: meta
@@ -83,7 +98,7 @@ jobs:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
push: ${{ steps.version.outputs.should_push }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
@@ -101,7 +116,7 @@ jobs:
- name: Generate security report
uses: docker/scout-action@v1
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && steps.version.outputs.should_push == 'true'
with:
command: quickview,compare
image: ${{ steps.meta.outputs.tags }}
@@ -112,7 +127,7 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Summary
if: github.event_name != 'pull_request'
if: steps.version.outputs.should_push == 'true'
run: |
echo "## 🚀 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
@@ -124,3 +139,16 @@ jobs:
echo "| **Образ** | \`${{ env.IMAGE_NAME }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Ветка** | \`${{ github.ref_name }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Опубликован |" >> $GITHUB_STEP_SUMMARY
- name: Build Summary (No Push)
if: steps.version.outputs.should_push == 'false'
run: |
echo "## 🔨 Docker Build Summary (Test Only)" >> $GITHUB_STEP_SUMMARY
echo "| Параметр | Значение |" >> $GITHUB_STEP_SUMMARY
echo "|----------|----------|" >> $GITHUB_STEP_SUMMARY
echo "| **Версия** | \`${{ steps.version.outputs.version }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Коммит** | \`${{ steps.version.outputs.short_sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Дата сборки** | \`${{ steps.version.outputs.build_date }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| **Статус** | ✅ Собран успешно (без публикации) |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "⚠️ **Примечание:** Образ собран но не опубликован, так как это PR из внешнего форка." >> $GITHUB_STEP_SUMMARY
+1
View File
@@ -2,6 +2,7 @@
*
# Исключения: разрешаем только нужные файлы
!.dockerignore
!.env.example
!Dockerfile
!app-config.json
+1 -1
View File
@@ -14,7 +14,7 @@ RUN pip install --no-cache-dir --upgrade pip && \
FROM python:3.11-slim
ARG VERSION="v2.2.5"
ARG VERSION="v2.2.6"
ARG BUILD_DATE
ARG VCS_REF
+1
View File
@@ -362,6 +362,7 @@ PAYMENT_SUBSCRIPTION_TEMPLATE={service_name} - {description}
# guide - открывает гайд подключения (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
# miniapp_custom - открывает заданную ссылку в мини-приложении (режим 3)
# link - открывает ссылку подписки напрямую (режим 4)
CONNECT_BUTTON_MODE=guide
# URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom)
+52
View File
@@ -0,0 +1,52 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database.models import SentNotification
logger = logging.getLogger(__name__)
async def notification_sent(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> bool:
result = await db.execute(
select(SentNotification).where(
SentNotification.user_id == user_id,
SentNotification.subscription_id == subscription_id,
SentNotification.notification_type == notification_type,
SentNotification.days_before == days_before,
)
)
return result.scalar_one_or_none() is not None
async def record_notification(
db: AsyncSession,
user_id: int,
subscription_id: int,
notification_type: str,
days_before: Optional[int] = None,
) -> None:
notification = SentNotification(
user_id=user_id,
subscription_id=subscription_id,
notification_type=notification_type,
days_before=days_before,
)
db.add(notification)
await db.commit()
async def clear_notifications(db: AsyncSession, subscription_id: int) -> None:
await db.execute(
delete(SentNotification).where(
SentNotification.subscription_id == subscription_id
)
)
await db.commit()
+5 -3
View File
@@ -6,9 +6,10 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import (
Subscription, SubscriptionStatus, User,
Subscription, SubscriptionStatus, User,
SubscriptionServer
)
from app.database.crud.notification import clear_notifications
from app.utils.pricing_utils import calculate_months_from_days, get_remaining_months
from app.config import settings
@@ -121,10 +122,11 @@ async def extend_subscription(
await db.commit()
await db.refresh(subscription)
await clear_notifications(db, subscription.id)
logger.info(f"✅ Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
+14
View File
@@ -555,6 +555,20 @@ class MonitoringLog(Base):
created_at = Column(DateTime, default=func.now())
class SentNotification(Base):
__tablename__ = "sent_notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="CASCADE"), nullable=False)
notification_type = Column(String(50), nullable=False)
days_before = Column(Integer, nullable=True)
created_at = Column(DateTime, default=func.now())
user = relationship("User", backref="sent_notifications")
subscription = relationship("Subscription", backref="sent_notifications")
class BroadcastHistory(Base):
__tablename__ = "broadcast_history"
+36 -86
View File
@@ -12,7 +12,7 @@ from app.database.crud.user import (
)
from app.database.models import UserStatus
from app.keyboards.inline import (
get_rules_keyboard, get_main_menu_keyboard
get_rules_keyboard, get_main_menu_keyboard, get_post_registration_keyboard
)
from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
@@ -498,52 +498,28 @@ async def complete_registration_from_callback(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
user_name = callback.from_user.first_name or callback.from_user.username or "друг"
offer_text = (
f"<b>Привет, {user.full_name}!</b>\n\n"
f"🎁 <b>{settings.TRIAL_DURATION_DAYS} дней VPN бесплатно!</b>\n"
"Подключайтесь за минуту и забудьте о блокировках.\n\n"
"✅ До <b>1 Гбит/с</b> скорость\n"
"✅ Умный VPN — можно не отключать для большинства российских сервисов\n"
"✅ Современные протоколы — максимум защиты и анонимности\n\n"
f"👉 Всего <b>{int(settings.PRICE_30_DAYS/100)}₽/мес за 1 устройство</b>\n\n"
"👇 Жмите кнопку и подключайтесь!"
)
try:
await callback.message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = user.balance_kopeks // 100
await callback.message.answer(
f"Добро пожаловать, {user.full_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
)
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
except Exception as fallback_error:
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await callback.message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except Exception as final_error:
logger.error(f"⛔ Критическая ошибка при отправке простого сообщения: {final_error}")
logger.error(f"Ошибка при отправке предложения триала: {e}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
async def complete_registration(
@@ -661,53 +637,27 @@ async def complete_registration(
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
await state.clear()
has_active_subscription = False
subscription_is_active = False
menu_text = await get_main_menu_text_simple(user.full_name, texts, db)
user_name = message.from_user.first_name or message.from_user.username or "друг"
offer_text = (
f"Привет, {user_name}!\n\n"
f"Подключите VPN бесплатно! Дарим вам {settings.TRIAL_DURATION_DAYS} дней!\n\n"
"Наши преимущества:\n"
" • Высокая скорость соединения — до 1гб/с\n"
" • Умный VPN — можно не отключать для большинства российских сервисов\n"
" • Самые современные протоколы — высокая защита от блокировки\n\n"
"Стоимость 100₽/мес за 1 устройство\n\n"
"👇Жмите на кнопку👇 чтобы подключить в 1 клик"
)
try:
await message.answer(
menu_text,
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
offer_text,
reply_markup=get_post_registration_keyboard(),
)
logger.info(f"✅ Главное меню отправлено для пользователя {user.telegram_id}")
except Exception as e:
logger.error(f"Ошибка при отправке главного меню: {e}")
try:
balance_rubles = user.balance_kopeks // 100
await message.answer(
f"Добро пожаловать, {user.full_name}!\n"
f"Баланс: {balance_rubles}\n"
f"Подписка: Нет активной подписки",
reply_markup=get_main_menu_keyboard(
language=user.language,
is_admin=settings.is_admin(user.telegram_id),
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks
),
parse_mode="HTML"
)
logger.info(f"✅ Fallback главное меню отправлено для пользователя {user.telegram_id}")
except Exception as fallback_error:
logger.error(f"⛔ Критическая ошибка при отправке fallback меню: {fallback_error}")
try:
await message.answer(f"Добро пожаловать, {user.full_name}! Регистрация завершена.")
logger.info(f"✅ Простое приветствие отправлено для пользователя {user.telegram_id}")
except:
pass
logger.error(f"Ошибка при отправке предложения триала: {e}")
logger.info(f"✅ Регистрация завершена для пользователя: {user.telegram_id}")
+40 -38
View File
@@ -34,8 +34,8 @@ from app.keyboards.inline import (
get_manage_countries_keyboard,
get_device_selection_keyboard, get_connection_guide_keyboard,
get_app_selection_keyboard, get_specific_app_keyboard,
get_subscription_settings_keyboard, get_extend_subscription_keyboard_with_prices,
get_insufficient_balance_keyboard
get_subscription_settings_keyboard, get_insufficient_balance_keyboard,
get_extend_subscription_keyboard_with_prices,
)
from app.localization.texts import get_texts
from app.services.remnawave_service import RemnaWaveService
@@ -45,7 +45,8 @@ from app.utils.pricing_utils import (
calculate_months_from_days,
get_remaining_months,
calculate_prorated_price,
validate_pricing_calculation
validate_pricing_calculation,
format_period_description,
)
logger = logging.getLogger(__name__)
@@ -415,6 +416,12 @@ async def activate_trial(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "link":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
@@ -808,8 +815,6 @@ async def handle_extend_subscription(
db_user: User,
db: AsyncSession
):
from app.utils.pricing_utils import calculate_months_from_days, format_period_description
texts = get_texts(db_user.language)
subscription = db_user.subscription
@@ -817,10 +822,6 @@ async def handle_extend_subscription(
await callback.answer("⚠ Продление доступно только для платных подписок", show_alert=True)
return
if subscription.days_left > 3:
await callback.answer("⚠ Продление доступно за 3 дня до окончания подписки", show_alert=True)
return
subscription_service = SubscriptionService()
available_periods = settings.get_available_renewal_periods()
@@ -1229,34 +1230,6 @@ async def confirm_extend_subscription(
await callback.answer()
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text=f"📅 30 дней - {texts.format_price(prices[30])}",
callback_data="extend_period_30"
)
],
[
InlineKeyboardButton(
text=f"📅 90 дней - {texts.format_price(prices[90])}",
callback_data="extend_period_90"
)
],
[
InlineKeyboardButton(
text=f"📅 180 дней - {texts.format_price(prices[180])}",
callback_data="extend_period_180"
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
async def confirm_reset_traffic(
callback: types.CallbackQuery,
db_user: User,
@@ -1953,6 +1926,12 @@ async def confirm_purchase(
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
elif connect_mode == "link":
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)],
[InlineKeyboardButton(text="📱 Моя подписка", callback_data="menu_subscription")],
[InlineKeyboardButton(text="⬅️ В главное меню", callback_data="back_to_menu")],
])
else:
connect_keyboard = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")],
@@ -2720,7 +2699,30 @@ async def handle_connect_subscription(
reply_markup=keyboard,
parse_mode="HTML"
)
elif connect_mode == "link":
keyboard = InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🔗 Подключиться",
url=subscription.subscription_url
)
],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
]
])
await callback.message.edit_text(
f"""
🚀 <b>Подключить подписку</b>
🔗 Нажмите кнопку ниже, чтобы открыть ссылку подписки:
""",
reply_markup=keyboard,
parse_mode="HTML"
)
else:
device_text = f"""
📱 <b>Подключить подписку</b>
+31 -25
View File
@@ -5,6 +5,7 @@ from datetime import datetime
from app.config import settings, PERIOD_PRICES, TRAFFIC_PRICES
from app.localization.texts import get_texts
from app.utils.pricing_utils import format_period_description
import logging
logger = logging.getLogger(__name__)
@@ -19,8 +20,19 @@ def get_rules_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
def get_post_registration_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="🚀 Подключиться бесплатно 🚀", callback_data="menu_trial"
)
],
[InlineKeyboardButton(text="Пропустить ➡️", callback_data="back_to_menu")],
])
def get_main_menu_keyboard(
language: str = "ru",
language: str = "ru",
is_admin: bool = False,
has_had_paid_subscription: bool = False,
has_active_subscription: bool = False,
@@ -127,7 +139,7 @@ def get_subscription_keyboard(
texts = get_texts(language)
keyboard = []
if has_subscription:
if subscription and subscription.subscription_url:
connect_mode = settings.CONNECT_BUTTON_MODE
@@ -151,21 +163,23 @@ def get_subscription_keyboard(
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
])
elif connect_mode == "link":
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", url=subscription.subscription_url)
])
else:
keyboard.append([
InlineKeyboardButton(text="🔗 Подключиться", callback_data="subscription_connect")
])
if not is_trial and subscription and subscription.days_left <= 3:
keyboard.append([
InlineKeyboardButton(text="⏰ Продлить", callback_data="subscription_extend")
])
if not is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_EXTEND_SUBSCRIPTION, callback_data="subscription_extend")
])
keyboard.append([
InlineKeyboardButton(text="💳 Автоплатеж", callback_data="subscription_autopay")
])
if is_trial:
keyboard.append([
InlineKeyboardButton(text=texts.MENU_BUY_SUBSCRIPTION, callback_data="subscription_upgrade")
@@ -526,7 +540,7 @@ def get_subscription_expiring_keyboard(subscription_id: int, language: str = "ru
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="⏰ Продлить подписку",
text=texts.MENU_EXTEND_SUBSCRIPTION,
callback_data="subscription_extend"
)
],
@@ -1047,31 +1061,23 @@ def get_specific_app_keyboard(
def get_extend_subscription_keyboard_with_prices(language: str, prices: dict) -> InlineKeyboardMarkup:
texts = get_texts(language)
keyboard = []
available_periods = settings.get_available_renewal_periods()
period_display = {
14: "14 дней",
30: "30 дней",
60: "60 дней",
90: "90 дней",
180: "180 дней",
360: "360 дней"
}
for days in available_periods:
if days in prices and days in period_display:
if days in prices:
period_display = format_period_description(days, language)
keyboard.append([
InlineKeyboardButton(
text=f"📅 {period_display[days]} - {texts.format_price(prices[days])}",
text=f"📅 {period_display} - {texts.format_price(prices[days])}",
callback_data=f"extend_period_{days}"
)
])
keyboard.append([
InlineKeyboardButton(text="⬅️ Назад", callback_data="menu_subscription")
])
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_cryptobot_payment_keyboard(
+1 -1
View File
@@ -281,7 +281,7 @@ class RussianTexts(Texts):
30 дней всего за {price}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+19 -17
View File
@@ -17,6 +17,10 @@ from app.database.crud.user import (
get_user_by_id, get_inactive_users, delete_user,
subtract_user_balance
)
from app.database.crud.notification import (
notification_sent,
record_notification,
)
from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User
from app.services.subscription_service import SubscriptionService
from app.services.payment_service import PaymentService
@@ -186,31 +190,30 @@ class MonitoringService:
user = await get_user_by_id(db, subscription.user_id)
if not user:
continue
notification_key = f"expiring_{user.telegram_id}_{days}d_{subscription.id}"
user_key = f"user_{user.telegram_id}_today"
if (notification_key in self._notified_users or
if (await notification_sent(db, user.id, subscription.id, "expiring", days) or
user_key in all_processed_users):
logger.debug(f"🔄 Пропускаем дублирование для пользователя {user.telegram_id} на {days} дней")
continue
should_send = True
for other_days in warning_days:
if other_days < days:
if other_days < days:
other_subs = await self._get_expiring_paid_subscriptions(db, other_days)
if any(s.user_id == user.id for s in other_subs):
should_send = False
logger.debug(f"🎯 Пропускаем уведомление на {days} дней для пользователя {user.telegram_id}, есть более срочное на {other_days} дней")
break
if not should_send:
continue
if self.bot:
success = await self._send_subscription_expiring_notification(user, subscription, days)
if success:
self._notified_users.add(notification_key)
await record_notification(db, user.id, subscription.id, "expiring", days)
all_processed_users.add(user_key)
sent_count += 1
logger.info(f"✅ Пользователю {user.telegram_id} отправлено уведомление об истечении подписки через {days} дней")
@@ -249,15 +252,14 @@ class MonitoringService:
user = subscription.user
if not user:
continue
notification_key = f"trial_2h_{user.telegram_id}_{subscription.id}"
if notification_key in self._notified_users:
continue
if await notification_sent(db, user.id, subscription.id, "trial_2h"):
continue
if self.bot:
success = await self._send_trial_ending_notification(user, subscription)
if success:
self._notified_users.add(notification_key)
await record_notification(db, user.id, subscription.id, "trial_2h")
logger.info(f"🎁 Пользователю {user.telegram_id} отправлено уведомление об окончании тестовой подписки через 2 часа")
if trial_expiring:
@@ -453,13 +455,13 @@ class MonitoringService:
Ваша тестовая подписка истекает через 2 часа.
💎 <b>Не хотите остаться без VPN?</b>
Переходите на полную подписку со скидкой!
Переходите на полную подписку!
🔥 <b>Специальное предложение:</b>
30 дней всего за {settings.format_price(settings.PRICE_30_DAYS)}
Безлимитный трафик
Все серверы доступны
Поддержка до 3 устройств
Скорость до 1ГБит/сек
Успейте оформить до окончания тестового периода!
"""
+1 -1
View File
@@ -55,7 +55,7 @@ async def process_referral_registration(
f"Вы перешли по реферальной ссылке пользователя <b>{referrer.full_name}</b>!\n\n"
f"💰 При первом пополнении от {settings.format_price(settings.REFERRAL_MINIMUM_TOPUP_KOPEKS)} "
f"вы получите бонус {settings.format_price(settings.REFERRAL_FIRST_TOPUP_BONUS_KOPEKS)}!\n\n"
f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
# f"🎁 Ваш реферер также получит награду за ваше первое пополнение."
)
await send_referral_notification(bot, new_user.telegram_id, referral_notification)
@@ -0,0 +1,27 @@
"""add sent notifications table"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = '8fd1e338eb45'
down_revision: Union[str, None] = '3d9b35c6bd8f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'sent_notifications',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('subscription_id', sa.Integer(), sa.ForeignKey('subscriptions.id'), nullable=False),
sa.Column('notification_type', sa.String(length=50), nullable=False),
sa.Column('days_before', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
sa.UniqueConstraint('user_id', 'subscription_id', 'notification_type', 'days_before', name='uq_sent_notifications'),
)
def downgrade() -> None:
op.drop_table('sent_notifications')
@@ -0,0 +1,24 @@
"""add cascade delete to sent notifications"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'cbd1be472f3d'
down_revision: Union[str, None] = '8fd1e338eb45'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.drop_constraint('sent_notifications_user_id_fkey', 'sent_notifications', type_='foreignkey')
op.drop_constraint('sent_notifications_subscription_id_fkey', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('fk_sent_notifications_user_id_users', 'sent_notifications', 'users', ['user_id'], ['id'], ondelete='CASCADE')
op.create_foreign_key('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'], ondelete='CASCADE')
def downgrade() -> None:
op.drop_constraint('fk_sent_notifications_user_id_users', 'sent_notifications', type_='foreignkey')
op.drop_constraint('fk_sent_notifications_subscription_id_subscriptions', 'sent_notifications', type_='foreignkey')
op.create_foreign_key('sent_notifications_user_id_fkey', 'sent_notifications', 'users', ['user_id'], ['id'])
op.create_foreign_key('sent_notifications_subscription_id_fkey', 'sent_notifications', 'subscriptions', ['subscription_id'], ['id'])