├── app ├── __init__.py ├── db │ ├── __init__.py │ ├── models │ │ ├── __init__.py │ │ ├── _base.py │ │ ├── invite.py │ │ ├── transaction.py │ │ ├── server.py │ │ └── promocode.py │ ├── migration │ │ ├── script.py.mako │ │ ├── versions │ │ │ ├── 1f557db4f100_remove_current_clients.py │ │ │ ├── dbf2ed0f9dad_add_language_code_for_user.py │ │ │ ├── 5c8c426595b0_fix_promocode.py │ │ │ ├── 579d48dd94ef_referrer_rewards.py │ │ │ ├── 3a79f6c8490e_delete_subscription_url_for_server.py │ │ │ ├── 032f2bef8d8d_add_invites_table_update_users_table.py │ │ │ ├── 0d6e179d7d34_user_trial_period_and_referral_model.py │ │ │ ├── 9aa6ddb8e352_update_transaction_status_enum.py │ │ │ └── 8dd30c5fd47d_initial.py │ │ └── env.py │ ├── database.py │ └── alembic.ini ├── logs │ └── .gitkeep ├── bot │ ├── __init__.py │ ├── utils │ │ ├── __init__.py │ │ ├── time.py │ │ ├── validation.py │ │ ├── commands.py │ │ ├── network.py │ │ ├── misc.py │ │ ├── formatting.py │ │ ├── navigation.py │ │ └── constants.py │ ├── routers │ │ ├── download │ │ │ ├── __init__.py │ │ │ ├── keyboard.py │ │ │ └── handler.py │ │ ├── main_menu │ │ │ ├── __init__.py │ │ │ └── keyboard.py │ │ ├── profile │ │ │ ├── __init__.py │ │ │ ├── keyboard.py │ │ │ └── handler.py │ │ ├── referral │ │ │ ├── __init__.py │ │ │ └── keyboard.py │ │ ├── support │ │ │ ├── __init__.py │ │ │ ├── handler.py │ │ │ └── keyboard.py │ │ ├── misc │ │ │ ├── __init__.py │ │ │ ├── notification_handler.py │ │ │ ├── keyboard.py │ │ │ └── error_handler.py │ │ ├── subscription │ │ │ ├── __init__.py │ │ │ ├── trial_handler.py │ │ │ ├── promocode_handler.py │ │ │ ├── payment_handler.py │ │ │ └── keyboard.py │ │ ├── admin_tools │ │ │ ├── __init__.py │ │ │ ├── statistics_handler.py │ │ │ ├── user_handler.py │ │ │ ├── restart_handler.py │ │ │ ├── backup_handler.py │ │ │ ├── maintenance_handler.py │ │ │ └── admin_tools_handler.py │ │ └── __init__.py │ ├── tasks │ │ ├── __init__.py │ │ ├── referral.py │ │ ├── transactions.py │ │ └── subscription_expiry.py │ ├── models │ │ ├── __init__.py │ │ ├── invite_stats.py │ │ ├── subscription_data.py │ │ ├── services_container.py │ │ ├── plan.py │ │ └── client_data.py │ ├── filters │ │ ├── is_private.py │ │ ├── __init__.py │ │ ├── is_dev.py │ │ └── is_admin.py │ ├── payment_gateways │ │ ├── __init__.py │ │ ├── gateway_factory.py │ │ ├── telegram_stars.py │ │ ├── yookassa.py │ │ ├── yoomoney.py │ │ ├── heleket.py │ │ ├── cryptomus.py │ │ └── _gateway.py │ ├── middlewares │ │ ├── __init__.py │ │ ├── garbage.py │ │ ├── database.py │ │ ├── maintenance.py │ │ └── throttling.py │ └── services │ │ ├── __init__.py │ │ ├── plan.py │ │ ├── subscription.py │ │ ├── invite_stats.py │ │ └── payment_stats.py ├── logger.py └── __main__.py ├── .dockerignore ├── Dockerfile ├── .env.example ├── pyproject.toml ├── .github └── workflows │ └── release.yml ├── plans.example.json ├── LICENSE ├── scripts ├── delete_logs.sh ├── manage_migrations.sh └── manage_translations.sh ├── docker-compose.yml └── .gitignore /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/db/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/logs/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/bot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/bot/routers/download/__init__.py: -------------------------------------------------------------------------------- 1 | from . import handler 2 | -------------------------------------------------------------------------------- /app/bot/routers/main_menu/__init__.py: -------------------------------------------------------------------------------- 1 | from . import handler 2 | -------------------------------------------------------------------------------- /app/bot/routers/profile/__init__.py: -------------------------------------------------------------------------------- 1 | from . import handler 2 | -------------------------------------------------------------------------------- /app/bot/routers/referral/__init__.py: -------------------------------------------------------------------------------- 1 | from . import handler 2 | -------------------------------------------------------------------------------- /app/bot/routers/support/__init__.py: -------------------------------------------------------------------------------- 1 | from . import handler 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.zip 3 | *.gz 4 | .env 5 | __pycache__ 6 | *.pyc -------------------------------------------------------------------------------- /app/bot/routers/misc/__init__.py: -------------------------------------------------------------------------------- 1 | from . import error_handler, notification_handler 2 | -------------------------------------------------------------------------------- /app/bot/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from . import referral, subscription_expiry, transactions 2 | -------------------------------------------------------------------------------- /app/bot/routers/subscription/__init__.py: -------------------------------------------------------------------------------- 1 | from . import payment_handler, promocode_handler, subscription_handler, trial_handler 2 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim-bullseye 2 | 3 | ENV PYTHONPATH=/ 4 | 5 | COPY pyproject.toml / 6 | RUN pip install poetry && poetry install 7 | 8 | COPY ./app /app -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BOT_TOKEN="1234567890:qwerty" 2 | BOT_DEV_ID=123456789 3 | BOT_SUPPORT_ID=123456789 4 | BOT_DOMAIN=3xui-shop.com 5 | 6 | XUI_USERNAME=admin 7 | XUI_PASSWORD=admin 8 | 9 | LETSENCRYPT_EMAIL=example@email.com -------------------------------------------------------------------------------- /app/bot/models/__init__.py: -------------------------------------------------------------------------------- 1 | from .client_data import ClientData 2 | from .invite_stats import InviteStats 3 | from .plan import Plan 4 | from .services_container import ServicesContainer 5 | from .subscription_data import SubscriptionData 6 | -------------------------------------------------------------------------------- /app/db/models/__init__.py: -------------------------------------------------------------------------------- 1 | from ._base import Base 2 | from .invite import Invite 3 | from .promocode import Promocode 4 | from .referral import Referral 5 | from .referrer_reward import ReferrerReward 6 | from .server import Server 7 | from .transaction import Transaction 8 | from .user import User 9 | -------------------------------------------------------------------------------- /app/bot/filters/is_private.py: -------------------------------------------------------------------------------- 1 | from aiogram.enums import ChatType 2 | from aiogram.filters import BaseFilter 3 | from aiogram.types import Chat 4 | 5 | 6 | class IsPrivate(BaseFilter): 7 | async def __call__(self, event_chat: Chat) -> bool: 8 | return event_chat.type == ChatType.PRIVATE 9 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/__init__.py: -------------------------------------------------------------------------------- 1 | from ._gateway import PaymentGateway 2 | from .cryptomus import Cryptomus 3 | from .gateway_factory import GatewayFactory 4 | from .heleket import Heleket 5 | from .telegram_stars import TelegramStars 6 | from .yookassa import Yookassa 7 | from .yoomoney import Yoomoney 8 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/__init__.py: -------------------------------------------------------------------------------- 1 | from . import ( 2 | admin_tools_handler, 3 | backup_handler, 4 | invites_handler, 5 | maintenance_handler, 6 | notification_handler, 7 | promocode_handler, 8 | restart_handler, 9 | server_handler, 10 | statistics_handler, 11 | user_handler, 12 | ) 13 | -------------------------------------------------------------------------------- /app/bot/models/invite_stats.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Dict 3 | 4 | 5 | @dataclass 6 | class InviteStats: 7 | revenue: Dict[str, float] = field(default_factory=dict) 8 | users_count: int = 0 9 | trial_users_count: int = 0 10 | paid_users_count: int = 0 11 | repeat_customers_count: int = 0 12 | -------------------------------------------------------------------------------- /app/bot/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | 3 | from .is_admin import IsAdmin 4 | from .is_dev import IsDev 5 | from .is_private import IsPrivate 6 | 7 | 8 | def register(dispatcher: Dispatcher, developer_id: int, admins_ids: list[int]) -> None: 9 | dispatcher.update.filter(IsPrivate()) 10 | IsDev.set_developer(developer_id) 11 | IsAdmin.set_admins(admins_ids) 12 | -------------------------------------------------------------------------------- /app/bot/models/subscription_data.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | 3 | from app.bot.utils.navigation import NavSubscription 4 | 5 | 6 | class SubscriptionData(CallbackData, prefix="subscription"): 7 | state: NavSubscription 8 | is_extend: bool = False 9 | is_change: bool = False 10 | user_id: int = 0 11 | devices: int = 0 12 | duration: int = 0 13 | price: float = 0 14 | -------------------------------------------------------------------------------- /app/db/models/_base.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import MetaData 2 | from sqlalchemy.orm import declarative_base 3 | 4 | Base = declarative_base( 5 | metadata=MetaData( 6 | naming_convention={ 7 | "ix": "ix_%(column_0_label)s", 8 | "uq": "uq_%(table_name)s_%(column_0_name)s", 9 | "ck": "ck_%(table_name)s_`%(constraint_name)s`", 10 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 11 | "pk": "pk_%(table_name)s", 12 | } 13 | ) 14 | ) 15 | -------------------------------------------------------------------------------- /app/bot/utils/time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta, timezone 2 | 3 | 4 | def get_current_timestamp() -> int: 5 | return int(datetime.now(timezone.utc).timestamp() * 1000) 6 | 7 | 8 | def add_days_to_timestamp(timestamp: int, days: int) -> int: 9 | new_datetime = datetime.fromtimestamp(timestamp / 1000, tz=timezone.utc) + timedelta(days=days) 10 | return int(new_datetime.timestamp() * 1000) 11 | 12 | 13 | def days_to_timestamp(days: int) -> int: 14 | return add_days_to_timestamp(get_current_timestamp(), days) 15 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "3xui-shop" 3 | version = "1.0.0" 4 | description = "This is a Telegram bot for selling VPN subscriptions. It works with 3X-UI." 5 | authors = ["snoups"] 6 | readme = "README.md" 7 | package-mode = false 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.12" 11 | aiogram = "^3.15.0" 12 | babel = "^2.16.0" 13 | environs = "^11.2.1" 14 | cachetools = "^5.5.0" 15 | py3xui = "^0.3.2" 16 | yookassa = "^3.4.3" 17 | sqlalchemy = {extras = ["asyncio"], version = "^2.0.36"} 18 | aiosqlite = "^0.20.0" 19 | alembic = "^1.14.0" 20 | redis = "^5.2.1" 21 | apscheduler = "^3.11.0" 22 | 23 | [build-system] 24 | requires = ["poetry-core"] 25 | build-backend = "poetry.core.masonry.api" 26 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/statistics_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.types import CallbackQuery 5 | from aiogram.utils.i18n import gettext as _ 6 | 7 | from app.bot.filters import IsAdmin 8 | from app.bot.utils.navigation import NavAdminTools 9 | from app.db.models import User 10 | 11 | logger = logging.getLogger(__name__) 12 | router = Router(name=__name__) 13 | 14 | 15 | @router.callback_query(F.data == NavAdminTools.STATISTICS, IsAdmin()) 16 | async def callback_statistics(callback: CallbackQuery, user: User) -> None: 17 | logger.info(f"Admin {user.tg_id} opened statistics.") 18 | await callback.answer(text=_("global:popup:development"), show_alert=True) 19 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/user_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.types import CallbackQuery 5 | from aiogram.utils.i18n import gettext as _ 6 | 7 | from app.bot.filters import IsAdmin 8 | from app.bot.utils.navigation import NavAdminTools 9 | from app.db.models import User 10 | 11 | logger = logging.getLogger(__name__) 12 | router = Router(name=__name__) 13 | 14 | 15 | @router.callback_query(F.data == NavAdminTools.USER_EDITOR, IsAdmin()) 16 | async def callback_user_editor(callback: CallbackQuery, user: User) -> None: 17 | logger.info(f"Admin {user.tg_id} opened user editor.") 18 | await callback.answer(text=_("global:popup:development"), show_alert=True) 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Generate and Upload Source Archive 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v3 13 | 14 | - name: Generate source archive 15 | run: | 16 | mkdir -p dist 17 | git archive --format=tar.gz --output=dist/3xui-shop-${{ github.ref_name }}.tar.gz ${{ github.ref }} 18 | 19 | - name: Upload archive to release 20 | uses: softprops/action-gh-release@v1 21 | with: 22 | files: dist/3xui-shop-${{ github.ref_name }}.tar.gz 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | -------------------------------------------------------------------------------- /app/bot/utils/validation.py: -------------------------------------------------------------------------------- 1 | import re 2 | from urllib.parse import urlparse 3 | 4 | IP_PATTERN = re.compile( 5 | r"^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}" r"(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$" 6 | ) 7 | 8 | 9 | def is_valid_host(data: str) -> bool: 10 | parsed = urlparse(data) 11 | if all([parsed.scheme, parsed.netloc]): 12 | return True 13 | return bool(IP_PATTERN.match(data)) 14 | 15 | 16 | def is_valid_client_count(data: str) -> bool: 17 | return data.isdigit() and 1 <= int(data) <= 10000 18 | 19 | 20 | def is_valid_user_id(data: str) -> bool: 21 | return data.isdigit() and 1 <= int(data) <= 1000000000000 22 | 23 | 24 | def is_valid_message_text(data: str) -> bool: 25 | return len(data) <= 4096 26 | -------------------------------------------------------------------------------- /app/db/migration/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /app/bot/routers/referral/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.routers.misc.keyboard import back_to_main_menu_button 6 | from app.bot.utils.navigation import NavDownload 7 | 8 | 9 | def referral_keyboard(connect: bool = False) -> InlineKeyboardMarkup: 10 | builder = InlineKeyboardBuilder() 11 | if connect: 12 | builder.row( 13 | InlineKeyboardButton( 14 | text=_("subscription:button:connect"), 15 | callback_data=NavDownload.MAIN, 16 | ) 17 | ) 18 | builder.row(back_to_main_menu_button()) 19 | 20 | return builder.as_markup() 21 | -------------------------------------------------------------------------------- /plans.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "durations": [30, 60, 180, 365], 3 | 4 | "plans": [ 5 | { 6 | "devices": 1, 7 | "prices": { 8 | "RUB": { 9 | "30": 70, 10 | "60": 120, 11 | "180": 300, 12 | "365": 600 13 | }, 14 | "USD": { 15 | "30": 0.7, 16 | "60": 1.2, 17 | "180": 3, 18 | "365": 6 19 | }, 20 | "XTR": { 21 | "30": 60, 22 | "60": 100, 23 | "180": 250, 24 | "365": 500 25 | } 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /app/bot/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiogram.utils.i18n import I18n, SimpleI18nMiddleware 3 | from sqlalchemy.ext.asyncio import async_sessionmaker 4 | 5 | from .database import DBSessionMiddleware 6 | from .garbage import GarbageMiddleware 7 | from .maintenance import MaintenanceMiddleware 8 | from .throttling import ThrottlingMiddleware 9 | 10 | 11 | def register(dispatcher: Dispatcher, i18n: I18n, session: async_sessionmaker) -> None: 12 | middlewares = [ 13 | ThrottlingMiddleware(), 14 | GarbageMiddleware(), 15 | SimpleI18nMiddleware(i18n), 16 | MaintenanceMiddleware(), 17 | DBSessionMiddleware(session), 18 | ] 19 | 20 | for middleware in middlewares: 21 | dispatcher.update.middleware.register(middleware) 22 | -------------------------------------------------------------------------------- /app/bot/utils/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot 4 | from aiogram.types import BotCommand, BotCommandScopeAllPrivateChats 5 | 6 | from .navigation import NavMain 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | async def setup(bot: Bot) -> None: 12 | commands = [ 13 | BotCommand(command=NavMain.START, description="Открыть главное меню"), 14 | ] 15 | 16 | await bot.set_my_commands( 17 | commands=commands, 18 | scope=BotCommandScopeAllPrivateChats(), 19 | ) 20 | logger.info("Bot commands configured successfully.") 21 | 22 | 23 | async def delete(bot: Bot) -> None: 24 | await bot.delete_my_commands( 25 | scope=BotCommandScopeAllPrivateChats(), 26 | ) 27 | logger.info("Bot commands removed successfully.") 28 | -------------------------------------------------------------------------------- /app/bot/models/services_container.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from app.bot.services import ( 7 | NotificationService, 8 | PlanService, 9 | ServerPoolService, 10 | VPNService, 11 | ReferralService, 12 | SubscriptionService, 13 | PaymentStatsService, 14 | InviteStatsService, 15 | ) 16 | 17 | from dataclasses import dataclass 18 | 19 | 20 | @dataclass 21 | class ServicesContainer: 22 | server_pool: ServerPoolService 23 | plan: PlanService 24 | vpn: VPNService 25 | notification: NotificationService 26 | referral: ReferralService 27 | subscription: SubscriptionService 28 | payment_stats: PaymentStatsService 29 | invite_stats: InviteStatsService 30 | -------------------------------------------------------------------------------- /app/bot/filters/is_dev.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram.filters import BaseFilter 4 | from aiogram.types import TelegramObject 5 | from aiogram.types import User as TelegramUser 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | class IsDev(BaseFilter): 11 | developer_id: int 12 | 13 | async def __call__( 14 | self, 15 | event: TelegramObject | None = None, 16 | user_id: int | None = None, 17 | ) -> bool: 18 | if user_id: 19 | return user_id == self.developer_id 20 | 21 | user: TelegramUser | None = event.from_user 22 | 23 | if not user: 24 | return False 25 | 26 | return user.id == self.developer_id 27 | 28 | @classmethod 29 | def set_developer(cls, developer_id: int) -> None: 30 | cls.developer_id = developer_id 31 | logger.info(f"Developer set: {developer_id}") 32 | -------------------------------------------------------------------------------- /app/bot/models/plan.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Any 3 | 4 | from app.bot.utils.constants import Currency 5 | 6 | 7 | @dataclass 8 | class Plan: 9 | devices: int 10 | prices: dict[str, dict[int, float]] 11 | 12 | @classmethod 13 | def from_dict(cls, data: dict[str, Any]) -> "Plan": 14 | return cls( 15 | devices=data["devices"], 16 | prices={k: {int(m): p for m, p in v.items()} for k, v in data["prices"].items()}, 17 | ) 18 | 19 | def to_dict(self) -> dict[str, Any]: 20 | return { 21 | "devices": self.devices, 22 | "prices": {k: {str(m): p for m, p in v.items()} for k, v in self.prices.items()}, 23 | } 24 | 25 | def get_price(self, currency: Currency | str, duration: int) -> float: 26 | if isinstance(currency, str): 27 | currency = Currency.from_code(currency) 28 | 29 | return self.prices[currency.code][duration] 30 | -------------------------------------------------------------------------------- /app/bot/utils/network.py: -------------------------------------------------------------------------------- 1 | import time 2 | from urllib.parse import parse_qs, urljoin, urlparse 3 | 4 | import aiohttp 5 | 6 | 7 | def parse_redirect_url(query_string: str) -> dict[str, str]: 8 | return {key: value[0] for key, value in parse_qs(query_string).items() if value} 9 | 10 | 11 | async def ping_url(url: str, timeout: int = 5) -> float | None: 12 | try: 13 | async with aiohttp.ClientSession() as session: 14 | start_time = time.time() 15 | async with session.get(url=url, timeout=timeout, ssl=False) as response: 16 | if response.status != 200: 17 | return None 18 | return round((time.time() - start_time) * 1000) 19 | except Exception: 20 | return None 21 | 22 | 23 | def extract_base_url(url: str, port: int, path: str) -> str: 24 | parsed_url = urlparse(url) 25 | base_url = f"{parsed_url.scheme}://{parsed_url.hostname}:{port}" 26 | return urljoin(base_url, path) 27 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/restart_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | 5 | from aiogram import F, Router 6 | from aiogram.types import CallbackQuery, User 7 | from aiogram.utils.i18n import gettext as _ 8 | 9 | from app.bot.filters import IsAdmin 10 | from app.bot.models import ServicesContainer 11 | from app.bot.utils.navigation import NavAdminTools 12 | from app.db.models import User 13 | 14 | logger = logging.getLogger(__name__) 15 | router = Router(name=__name__) 16 | 17 | 18 | @router.callback_query(F.data == NavAdminTools.RESTART_BOT, IsAdmin()) 19 | async def callback_restart_bot( 20 | callback: CallbackQuery, 21 | user: User, 22 | services: ServicesContainer, 23 | ) -> None: 24 | logger.info(f"Admin {user.tg_id} restarted bot.") 25 | 26 | await services.notification.show_popup( 27 | callback=callback, 28 | text=_("restart_bot:popup:process"), 29 | ) 30 | 31 | os.execv(sys.executable, [sys.executable, *sys.argv]) 32 | -------------------------------------------------------------------------------- /app/bot/filters/is_admin.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram.filters import BaseFilter 4 | from aiogram.types import TelegramObject 5 | from aiogram.types import User as TelegramUser 6 | 7 | from .is_dev import IsDev 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class IsAdmin(BaseFilter): 13 | admins_ids: list[int] = [] 14 | 15 | async def __call__( 16 | self, 17 | event: TelegramObject | None = None, 18 | user_id: int | None = None, 19 | ) -> bool: 20 | if user_id: 21 | is_dev = await IsDev()(user_id=user_id) 22 | return user_id in self.admins_ids or is_dev 23 | 24 | user: TelegramUser | None = event.from_user 25 | 26 | if not user: 27 | return False 28 | 29 | is_dev = await IsDev()(event) 30 | return user.id in self.admins_ids or is_dev 31 | 32 | @classmethod 33 | def set_admins(cls, admins_ids: list[int]) -> None: 34 | cls.admins_ids = admins_ids 35 | logger.info(f"Admins set: {admins_ids}") 36 | -------------------------------------------------------------------------------- /app/bot/utils/misc.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import secrets 3 | import string 4 | import uuid 5 | from datetime import datetime 6 | 7 | CHARSET = string.ascii_uppercase + string.digits 8 | 9 | 10 | def split_text(text: str, chunk_size: int = 4096) -> list[str]: 11 | """Split text into chunks of a given size.""" 12 | return [text[i : i + chunk_size] for i in range(0, len(text), chunk_size)] 13 | 14 | 15 | def generate_code(length: int = 8) -> str: 16 | """Generate an 8-character alphanumeric promocode.""" 17 | return "".join(secrets.choice(CHARSET) for _ in range(length)) 18 | 19 | 20 | def generate_hash(text: str, length: int = 8) -> str: 21 | """ 22 | Generate a hash from text, using timestamp for uniqueness. 23 | Always includes at least one letter to distinguish from numeric IDs. 24 | """ 25 | timestamp = datetime.utcnow().timestamp() 26 | combined = f"{text}_{timestamp}" 27 | full_hash = hashlib.md5(combined.encode()).hexdigest() 28 | 29 | result = full_hash[: length - 1] 30 | 31 | result += secrets.choice(string.ascii_lowercase) 32 | 33 | return result 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 snoups 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/db/migration/versions/1f557db4f100_remove_current_clients.py: -------------------------------------------------------------------------------- 1 | """remove current_clients 2 | 3 | Revision ID: 1f557db4f100 4 | Revises: 8dd30c5fd47d 5 | Create Date: 2025-01-31 19:08:35.312152 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "1f557db4f100" 16 | down_revision: Union[str, None] = "8dd30c5fd47d" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("servers", schema=None) as batch_op: 24 | batch_op.drop_column("current_clients") 25 | # ### end Alembic commands ### 26 | 27 | 28 | def downgrade() -> None: 29 | # ### commands auto generated by Alembic - please adjust! ### 30 | with op.batch_alter_table("servers", schema=None) as batch_op: 31 | batch_op.add_column(sa.Column("current_clients", sa.INTEGER(), nullable=False)) 32 | # ### end Alembic commands ### 33 | -------------------------------------------------------------------------------- /app/db/migration/versions/dbf2ed0f9dad_add_language_code_for_user.py: -------------------------------------------------------------------------------- 1 | """add language_code for user 2 | 3 | Revision ID: dbf2ed0f9dad 4 | Revises: 9aa6ddb8e352 5 | Create Date: 2025-02-15 18:29:10.660892 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "dbf2ed0f9dad" 16 | down_revision: Union[str, None] = "9aa6ddb8e352" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("users", schema=None) as batch_op: 24 | batch_op.add_column(sa.Column("language_code", sa.String(length=5), nullable=False)) 25 | 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade() -> None: 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | with op.batch_alter_table("users", schema=None) as batch_op: 32 | batch_op.drop_column("language_code") 33 | 34 | # ### end Alembic commands ### 35 | -------------------------------------------------------------------------------- /app/bot/routers/profile/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.routers.misc.keyboard import back_to_main_menu_button 6 | from app.bot.utils.navigation import NavDownload, NavProfile, NavSubscription 7 | 8 | 9 | def buy_subscription_keyboard() -> InlineKeyboardMarkup: 10 | builder = InlineKeyboardBuilder() 11 | 12 | builder.row( 13 | InlineKeyboardButton( 14 | text=_("profile:button:buy_subscription"), 15 | callback_data=NavSubscription.MAIN, 16 | ) 17 | ) 18 | 19 | builder.row(back_to_main_menu_button()) 20 | return builder.as_markup() 21 | 22 | 23 | def profile_keyboard() -> InlineKeyboardMarkup: 24 | builder = InlineKeyboardBuilder() 25 | 26 | builder.row( 27 | InlineKeyboardButton( 28 | text=_("profile:button:show_key"), 29 | callback_data=NavProfile.SHOW_KEY, 30 | ) 31 | ) 32 | builder.row( 33 | InlineKeyboardButton( 34 | text=_("profile:button:connect"), 35 | callback_data=NavDownload.MAIN, 36 | ) 37 | ) 38 | 39 | builder.row(back_to_main_menu_button()) 40 | return builder.as_markup() 41 | -------------------------------------------------------------------------------- /app/db/migration/versions/5c8c426595b0_fix_promocode.py: -------------------------------------------------------------------------------- 1 | """fix promocode 2 | 3 | Revision ID: 5c8c426595b0 4 | Revises: 3a79f6c8490e 5 | Create Date: 2025-02-21 18:38:53.093866 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "5c8c426595b0" 16 | down_revision: Union[str, None] = "3a79f6c8490e" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("promocodes", schema=None) as batch_op: 24 | batch_op.alter_column( 25 | "code", 26 | existing_type=sa.VARCHAR(length=8), 27 | type_=sa.String(length=32), 28 | existing_nullable=False, 29 | ) 30 | 31 | # ### end Alembic commands ### 32 | 33 | 34 | def downgrade() -> None: 35 | # ### commands auto generated by Alembic - please adjust! ### 36 | with op.batch_alter_table("promocodes", schema=None) as batch_op: 37 | batch_op.alter_column( 38 | "code", 39 | existing_type=sa.String(length=32), 40 | type_=sa.VARCHAR(length=8), 41 | existing_nullable=False, 42 | ) 43 | 44 | # ### end Alembic commands ### 45 | -------------------------------------------------------------------------------- /app/db/migration/versions/579d48dd94ef_referrer_rewards.py: -------------------------------------------------------------------------------- 1 | """referrer_rewards 2 | 3 | Revision ID: 579d48dd94ef 4 | Revises: 0d6e179d7d34 5 | Create Date: 2025-03-23 00:09:34.785225 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "579d48dd94ef" 16 | down_revision: Union[str, None] = "0d6e179d7d34" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("referrals", schema=None) as batch_op: 24 | batch_op.add_column(sa.Column("referred_bonus_days", sa.Integer(), nullable=True)) 25 | batch_op.drop_column("referrer_rewarded_at") 26 | batch_op.drop_column("bonus_days") 27 | 28 | # ### end Alembic commands ### 29 | 30 | 31 | def downgrade() -> None: 32 | # ### commands auto generated by Alembic - please adjust! ### 33 | with op.batch_alter_table("referrals", schema=None) as batch_op: 34 | batch_op.add_column(sa.Column("bonus_days", sa.INTEGER(), nullable=False)) 35 | batch_op.add_column(sa.Column("referrer_rewarded_at", sa.DATETIME(), nullable=True)) 36 | batch_op.drop_column("referred_bonus_days") 37 | 38 | # ### end Alembic commands ### 39 | -------------------------------------------------------------------------------- /app/bot/middlewares/garbage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from mailbox import Message 3 | from typing import Any, Awaitable, Callable 4 | 5 | from aiogram import BaseMiddleware 6 | from aiogram.types import TelegramObject, Update 7 | 8 | from app.bot.utils.navigation import NavMain 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class GarbageMiddleware(BaseMiddleware): 14 | def __init__(self) -> None: 15 | logger.debug("Garbage Middleware initialized.") 16 | 17 | async def __call__( 18 | self, 19 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 20 | event: TelegramObject, 21 | data: dict[str, Any], 22 | ) -> Any: 23 | if isinstance(event, Update) and event.message: 24 | user_id = event.message.from_user.id 25 | 26 | if user_id == event.bot.id: 27 | logger.debug(f"Message from bot {event.bot.id} skipped.") 28 | elif ( 29 | event.message.text 30 | and not event.message.text.endswith(NavMain.START) 31 | or event.message.forward_from 32 | ): 33 | try: 34 | await event.message.delete() 35 | logger.debug(f"Message {event.message.text} from user {user_id} deleted.") 36 | except Exception as exception: 37 | logger.error(f"Failed to delete message from user {user_id}: {exception}") 38 | 39 | return await handler(event, data) 40 | -------------------------------------------------------------------------------- /app/bot/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Dispatcher 2 | from aiohttp.web import Application 3 | 4 | from app.bot.utils.constants import CONNECTION_WEBHOOK 5 | 6 | from . import ( 7 | admin_tools, 8 | download, 9 | main_menu, 10 | misc, 11 | profile, 12 | referral, 13 | subscription, 14 | support, 15 | ) 16 | 17 | 18 | def include(app: Application, dispatcher: Dispatcher) -> None: 19 | app.router.add_get(CONNECTION_WEBHOOK, download.handler.redirect_to_connection) 20 | dispatcher.include_routers( 21 | misc.error_handler.router, 22 | misc.notification_handler.router, 23 | main_menu.handler.router, 24 | profile.handler.router, 25 | referral.handler.router, 26 | support.handler.router, 27 | download.handler.router, 28 | subscription.subscription_handler.router, 29 | subscription.payment_handler.router, 30 | subscription.promocode_handler.router, 31 | subscription.trial_handler.router, 32 | admin_tools.admin_tools_handler.router, 33 | admin_tools.backup_handler.router, 34 | admin_tools.invites_handler.router, 35 | admin_tools.maintenance_handler.router, 36 | admin_tools.notification_handler.router, 37 | admin_tools.promocode_handler.router, 38 | admin_tools.restart_handler.router, 39 | admin_tools.server_handler.router, 40 | admin_tools.statistics_handler.router, 41 | admin_tools.user_handler.router, 42 | ) 43 | -------------------------------------------------------------------------------- /app/db/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Self 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 5 | 6 | from app.config import DatabaseConfig 7 | 8 | from . import models 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Database: 14 | def __init__(self, config: DatabaseConfig) -> None: 15 | self.engine = create_async_engine( 16 | url=config.url(), 17 | pool_pre_ping=True, 18 | ) 19 | self.session = async_sessionmaker( 20 | bind=self.engine, 21 | class_=AsyncSession, 22 | expire_on_commit=False, 23 | ) 24 | logger.debug("Database engine and session maker initialized successfully.") 25 | 26 | async def initialize(self) -> Self: 27 | try: 28 | async with self.engine.begin() as connection: 29 | await connection.run_sync(models.Base.metadata.create_all) 30 | logger.debug("Database schema initialized successfully.") 31 | except Exception as exception: 32 | logger.error(f"Error initializing database schema: {exception}") 33 | raise 34 | return self 35 | 36 | async def close(self) -> None: 37 | try: 38 | await self.engine.dispose() 39 | logger.debug("Database engine closed successfully.") 40 | except Exception as exception: 41 | logger.error(f"Error closing database engine: {exception}") 42 | raise 43 | -------------------------------------------------------------------------------- /app/db/migration/versions/3a79f6c8490e_delete_subscription_url_for_server.py: -------------------------------------------------------------------------------- 1 | """delete subscription url for server 2 | 3 | Revision ID: 3a79f6c8490e 4 | Revises: dbf2ed0f9dad 5 | Create Date: 2025-02-18 18:19:52.827382 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "3a79f6c8490e" 16 | down_revision: Union[str, None] = "dbf2ed0f9dad" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | with op.batch_alter_table("servers", schema=None) as batch_op: 24 | batch_op.drop_column("subscription") 25 | 26 | # ### end Alembic commands ### 27 | 28 | 29 | def downgrade() -> None: 30 | # ### commands auto generated by Alembic - please adjust! ### 31 | with op.batch_alter_table("servers", schema=None) as batch_op: 32 | batch_op.add_column( 33 | sa.Column( 34 | "subscription", 35 | sa.VARCHAR(length=255), 36 | nullable=True, 37 | ) 38 | ) 39 | 40 | op.execute("UPDATE servers SET subscription = '' WHERE subscription IS NULL") 41 | 42 | with op.batch_alter_table("servers", schema=None) as batch_op: 43 | batch_op.alter_column( 44 | "subscription", 45 | nullable=False, 46 | ) 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /app/bot/routers/misc/notification_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.types import CallbackQuery 6 | 7 | from app.bot.routers.download.handler import callback_download 8 | from app.bot.utils.navigation import NavMain 9 | from app.db.models import User 10 | 11 | logger = logging.getLogger(__name__) 12 | router = Router(name=__name__) 13 | 14 | 15 | @router.callback_query(F.data.startswith(NavMain.CLOSE_NOTIFICATION)) 16 | async def callback_close_notification(callback: CallbackQuery, user: User) -> None: 17 | logger.debug(f"User {user.tg_id} closed notification: {callback.message.message_id}") 18 | try: 19 | await callback.message.delete() 20 | logger.debug(f"Notification for user {user.tg_id} deleted.") 21 | except Exception as exception: 22 | logger.error(f"Failed to delete notification for user {user.tg_id}: {exception}") 23 | 24 | 25 | @router.callback_query(F.data.startswith(NavMain.REDIRECT_TO_DOWNLOAD)) 26 | async def callback_redirect_to_download( 27 | callback: CallbackQuery, 28 | user: User, 29 | state: FSMContext, 30 | ) -> None: 31 | logger.debug(f"User {user.tg_id} redirected to download: {callback.message.message_id}") 32 | try: 33 | await callback.message.delete() 34 | logger.debug(f"Notification for user {user.tg_id} deleted.") 35 | except Exception as exception: 36 | logger.error(f"Failed to delete notification for user {user.tg_id}: {exception}") 37 | 38 | await callback_download(callback=callback, user=user, state=state) 39 | -------------------------------------------------------------------------------- /app/bot/services/__init__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot 2 | from sqlalchemy.ext.asyncio import async_sessionmaker 3 | 4 | from app.bot.models import ServicesContainer 5 | from app.config import Config 6 | 7 | from .invite_stats import InviteStatsService 8 | from .notification import NotificationService 9 | from .payment_stats import PaymentStatsService 10 | from .plan import PlanService 11 | from .referral import ReferralService 12 | from .server_pool import ServerPoolService 13 | from .subscription import SubscriptionService 14 | from .vpn import VPNService 15 | 16 | 17 | async def initialize( 18 | config: Config, 19 | session: async_sessionmaker, 20 | bot: Bot, 21 | ) -> ServicesContainer: 22 | server_pool = ServerPoolService(config=config, session=session) 23 | plan = PlanService() 24 | vpn = VPNService(config=config, session=session, server_pool_service=server_pool) 25 | notification = NotificationService(config=config, bot=bot) 26 | referral = ReferralService(config=config, session_factory=session, vpn_service=vpn) 27 | subscription = SubscriptionService(config=config, session_factory=session, vpn_service=vpn) 28 | payment_stats = PaymentStatsService(session_factory=session) 29 | invite_stats = InviteStatsService(session_factory=session, payment_stats_service=payment_stats) 30 | 31 | return ServicesContainer( 32 | server_pool=server_pool, 33 | plan=plan, 34 | vpn=vpn, 35 | notification=notification, 36 | referral=referral, 37 | subscription=subscription, 38 | payment_stats=payment_stats, 39 | invite_stats=invite_stats, 40 | ) 41 | -------------------------------------------------------------------------------- /app/bot/routers/support/handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.types import CallbackQuery 5 | from aiogram.utils.i18n import gettext as _ 6 | 7 | from app.bot.utils.navigation import NavSupport 8 | from app.config import Config 9 | from app.db.models import User 10 | 11 | from .keyboard import contact_keyboard, how_to_connect_keyboard, support_keyboard 12 | 13 | logger = logging.getLogger(__name__) 14 | router = Router(name=__name__) 15 | 16 | 17 | @router.callback_query(F.data == NavSupport.MAIN) 18 | async def callback_support(callback: CallbackQuery, user: User, config: Config) -> None: 19 | logger.info(f"User {user.tg_id} opened support page.") 20 | await callback.message.edit_text( 21 | text=_("support:message:main"), 22 | reply_markup=support_keyboard(config.bot.SUPPORT_ID), 23 | ) 24 | 25 | 26 | @router.callback_query(F.data == NavSupport.HOW_TO_CONNECT) 27 | async def callback_how_to_connect(callback: CallbackQuery, user: User, config: Config) -> None: 28 | logger.info(f"User {user.tg_id} opened how to connect page.") 29 | await callback.message.edit_text( 30 | text=_("support:message:how_to_connect"), 31 | reply_markup=how_to_connect_keyboard(config.bot.SUPPORT_ID), 32 | ) 33 | 34 | 35 | @router.callback_query(F.data == NavSupport.VPN_NOT_WORKING) 36 | async def callback_vpn_not_working(callback: CallbackQuery, user: User, config: Config) -> None: 37 | logger.info(f"User {user.tg_id} opened vpn not working page.") 38 | await callback.message.edit_text( 39 | text=_("support:message:vpn_not_working"), 40 | reply_markup=contact_keyboard(config.bot.SUPPORT_ID), 41 | ) 42 | -------------------------------------------------------------------------------- /scripts/delete_logs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # delete_logs.sh 4 | # ----------------------------------------------------------------------------- 5 | # Script to delete .log, .zip, and .gz files from a specified directory. 6 | # 7 | # Options: 8 | # --dir PATH Specify the directory to clean logs and archives from (default: app/logs). 9 | # --help Display this help message. 10 | # ----------------------------------------------------------------------------- 11 | 12 | DEFAULT_LOG_DIR="app/logs" 13 | LOG_DIR="$DEFAULT_LOG_DIR" 14 | 15 | show_help() { 16 | grep '^#' "$0" | cut -c 5- 17 | } 18 | 19 | while [[ $# -gt 0 ]]; do 20 | case "$1" in 21 | --dir) 22 | shift 23 | if [[ -n "$1" ]]; then 24 | LOG_DIR="$1" 25 | shift 26 | else 27 | echo "❌ Error: --dir flag requires a directory path." >&2 28 | exit 1 29 | fi 30 | ;; 31 | --help) 32 | show_help 33 | exit 0 34 | ;; 35 | *) 36 | echo "❌ Error: Unknown option '$1'. Use --help for usage information." >&2 37 | exit 1 38 | ;; 39 | esac 40 | done 41 | 42 | if [[ -d "$LOG_DIR" ]]; then 43 | echo "🔍 Searching for .log, .zip, and .gz files in: $LOG_DIR" 44 | 45 | if find "$LOG_DIR" -type f \( -name "*.log" -o -name "*.zip" -o -name "*.gz" \) -exec rm -f {} +; then 46 | echo "✅ All .log, .zip, and .gz files were successfully deleted from: $LOG_DIR" 47 | else 48 | echo "❌ Failed to delete some files." >&2 49 | exit 1 50 | fi 51 | else 52 | echo "❌ Directory '$LOG_DIR' does not exist." >&2 53 | exit 1 54 | fi 55 | -------------------------------------------------------------------------------- /app/bot/routers/misc/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.utils.navigation import NavMain 6 | 7 | 8 | def close_notification_button() -> InlineKeyboardButton: 9 | return InlineKeyboardButton( 10 | text=_("misc:button:close_notification"), 11 | callback_data=NavMain.CLOSE_NOTIFICATION, 12 | ) 13 | 14 | 15 | def close_notification_keyboard() -> InlineKeyboardMarkup: 16 | builder = InlineKeyboardBuilder() 17 | builder.row(close_notification_button()) 18 | return builder.as_markup() 19 | 20 | 21 | def back_button(callback: str, text: str | None = None) -> InlineKeyboardButton: 22 | if text is None: 23 | text = _("misc:button:back") 24 | return InlineKeyboardButton(text=text, callback_data=callback) 25 | 26 | 27 | def back_keyboard(callback: str) -> InlineKeyboardMarkup: 28 | return InlineKeyboardMarkup(inline_keyboard=[[back_button(callback)]]) 29 | 30 | 31 | def back_to_main_menu_button() -> InlineKeyboardButton: 32 | return InlineKeyboardButton( 33 | text=_("misc:button:back_to_main_menu"), 34 | callback_data=NavMain.MAIN_MENU, 35 | ) 36 | 37 | 38 | def back_to_main_menu_keyboard() -> InlineKeyboardMarkup: 39 | return InlineKeyboardMarkup(inline_keyboard=[[back_to_main_menu_button()]]) 40 | 41 | 42 | def cancel_button(callback: str) -> InlineKeyboardButton: 43 | return InlineKeyboardButton(text=_("misc:button:cancel"), callback_data=callback) 44 | 45 | 46 | def cancel_keyboard(callback: str) -> InlineKeyboardMarkup: 47 | return InlineKeyboardMarkup(inline_keyboard=[[cancel_button(callback)]]) 48 | -------------------------------------------------------------------------------- /app/db/migration/versions/032f2bef8d8d_add_invites_table_update_users_table.py: -------------------------------------------------------------------------------- 1 | """Add invites table; Update users table 2 | 3 | Revision ID: 032f2bef8d8d 4 | Revises: 5c8c426595b0 5 | Create Date: 2025-03-19 20:12:38.425398 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "032f2bef8d8d" 16 | down_revision: Union[str, None] = "579d48dd94ef" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "invites", 25 | sa.Column("id", sa.Integer(), primary_key=True), 26 | sa.Column("name", sa.String(), unique=True, nullable=False), 27 | sa.Column("hash_code", sa.String(), unique=True, nullable=False), 28 | sa.Column("clicks", sa.Integer(), default=0), 29 | sa.Column("created_at", sa.DateTime(), default=sa.func.now()), 30 | sa.Column("is_active", sa.Boolean(), default=True), 31 | sa.PrimaryKeyConstraint("id", name=op.f("pk_invites")), 32 | ) 33 | 34 | with op.batch_alter_table("users", schema=None) as batch_op: 35 | batch_op.add_column(sa.Column("source_invite_name", sa.String(length=100), nullable=True)) 36 | 37 | # ### end Alembic commands ### 38 | 39 | 40 | def downgrade() -> None: 41 | # ### commands auto generated by Alembic - please adjust! ### 42 | op.drop_table("invites") 43 | 44 | with op.batch_alter_table("users", schema=None) as batch_op: 45 | batch_op.drop_column("source_invite_name") 46 | 47 | # ### end Alembic commands ### 48 | -------------------------------------------------------------------------------- /app/bot/tasks/referral.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 5 | from sqlalchemy import select 6 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 7 | 8 | from app.bot.services import ReferralService 9 | from app.db.models import ReferrerReward 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | async def reward_pending_referrals_after_payment( 15 | session_factory: async_sessionmaker, 16 | referral_service: ReferralService, 17 | ) -> None: 18 | session: AsyncSession 19 | async with session_factory() as session: 20 | stmt = select(ReferrerReward).where(ReferrerReward.rewarded_at.is_(None)) 21 | result = await session.execute(stmt) 22 | pending_rewards = result.scalars().all() 23 | 24 | logger.info(f"[Background check] Found {len(pending_rewards)} not proceed rewards.") 25 | 26 | for reward in pending_rewards: 27 | success = await referral_service.process_referrer_rewards_after_payment(reward=reward) 28 | if not success: 29 | logger.warning( 30 | f"[Background check] Reward {reward.id} was NOT proceed successfully." 31 | ) 32 | 33 | logger.info("[Background check] Referrer rewards check finished.") 34 | 35 | 36 | def start_scheduler( 37 | session_factory: async_sessionmaker, 38 | referral_service: ReferralService, 39 | ) -> None: 40 | scheduler = AsyncIOScheduler() 41 | scheduler.add_job( 42 | reward_pending_referrals_after_payment, 43 | "interval", 44 | minutes=15, 45 | args=[session_factory, referral_service], 46 | next_run_time=datetime.now(), 47 | ) 48 | scheduler.start() 49 | -------------------------------------------------------------------------------- /app/bot/tasks/transactions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 5 | from sqlalchemy import select 6 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 7 | 8 | from app.bot.utils.constants import TransactionStatus 9 | from app.db.models import Transaction 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | async def cancel_expired_transactions( 15 | session_factory: async_sessionmaker, 16 | expiration_minutes: int = 15, 17 | ) -> None: 18 | session: AsyncSession 19 | async with session_factory() as session: 20 | expiration_time = datetime.now(timezone.utc) - timedelta(minutes=expiration_minutes) 21 | stmt = select(Transaction).where( 22 | Transaction.status == TransactionStatus.PENDING, 23 | Transaction.created_at <= expiration_time, 24 | ) 25 | result = await session.execute(stmt) 26 | expired_transactions = result.scalars().all() 27 | 28 | if expired_transactions: 29 | logger.info( 30 | f"[Background check] Found {len(expired_transactions)} expired transactions." 31 | ) 32 | 33 | for transaction in expired_transactions: 34 | transaction.status = TransactionStatus.CANCELED 35 | await session.commit() 36 | 37 | logger.info("[Background check] Successfully canceled expired transactions.") 38 | else: 39 | logger.info("[Background check] No expired transactions found.") 40 | 41 | 42 | def start_scheduler(session: async_sessionmaker) -> None: 43 | scheduler = AsyncIOScheduler() 44 | scheduler.add_job( 45 | cancel_expired_transactions, 46 | "interval", 47 | minutes=15, 48 | args=[session], 49 | next_run_time=datetime.now(), 50 | ) 51 | scheduler.start() 52 | -------------------------------------------------------------------------------- /app/db/migration/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from logging.config import fileConfig 3 | 4 | from alembic import context 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from app.config import load_config 10 | from app.db.models import Base 11 | 12 | database = load_config().database 13 | 14 | config = context.config 15 | config.set_main_option("sqlalchemy.url", database.url()) 16 | 17 | if config.config_file_name is not None: 18 | fileConfig(config.config_file_name) 19 | 20 | target_metadata = Base.metadata 21 | 22 | 23 | def run_migrations_offline() -> None: 24 | url = config.get_main_option("sqlalchemy.url") 25 | context.configure( 26 | url=url, 27 | target_metadata=target_metadata, 28 | literal_binds=True, 29 | dialect_opts={"paramstyle": "named"}, 30 | render_as_batch=True, 31 | ) 32 | 33 | with context.begin_transaction(): 34 | context.run_migrations() 35 | 36 | 37 | def do_run_migrations(connection: Connection) -> None: 38 | context.configure( 39 | connection=connection, 40 | target_metadata=target_metadata, 41 | render_as_batch=True, 42 | ) 43 | 44 | with context.begin_transaction(): 45 | context.run_migrations() 46 | 47 | 48 | async def run_async_migrations() -> None: 49 | connectable = async_engine_from_config( 50 | config.get_section(config.config_ini_section, {}), 51 | prefix="sqlalchemy.", 52 | poolclass=pool.NullPool, 53 | ) 54 | 55 | async with connectable.connect() as connection: 56 | await connection.run_sync(do_run_migrations) 57 | 58 | await connectable.dispose() 59 | 60 | 61 | def run_migrations_online() -> None: 62 | asyncio.run(run_async_migrations()) 63 | 64 | 65 | if context.is_offline_mode(): 66 | run_migrations_offline() 67 | else: 68 | run_migrations_online() 69 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | traefik: 3 | image: "traefik:v3.0" 4 | container_name: "traefik" 5 | restart: always 6 | ports: 7 | - "80:80" 8 | - "443:443" 9 | env_file: 10 | - .env 11 | volumes: 12 | - "/var/run/docker.sock:/var/run/docker.sock:ro" 13 | - "letsencrypt_data:/letsencrypt" 14 | command: 15 | - "--entrypoints.web.address=:80" 16 | - "--entrypoints.websecure.address=:443" 17 | - "--providers.docker=true" 18 | - "--providers.docker.exposedbydefault=false" 19 | - "--certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL}" 20 | - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" 21 | - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" 22 | - "--log.level=INFO" 23 | labels: 24 | - "traefik.enable=true" 25 | 26 | redis: 27 | image: redis:latest 28 | container_name: 3xui-shop-redis 29 | restart: always 30 | volumes: 31 | - redis_data:/data 32 | 33 | bot: 34 | build: . 35 | container_name: 3xui-shop-bot 36 | volumes: 37 | - ./app/data:/app/data 38 | - ./plans.json:/app/data/plans.json 39 | - ./app/locales:/app/locales 40 | - ./app/logs:/app/logs 41 | env_file: 42 | - .env 43 | stop_signal: SIGINT 44 | restart: unless-stopped 45 | command: sh -c " 46 | poetry run pybabel compile -d /app/locales -D bot && 47 | poetry run alembic -c /app/db/alembic.ini upgrade head && 48 | poetry run python /app/__main__.py" 49 | depends_on: 50 | - redis 51 | labels: 52 | - "traefik.enable=true" 53 | - "traefik.http.routers.bot.rule=Host(`${BOT_DOMAIN}`)" 54 | - "traefik.http.routers.bot.entrypoints=websecure" 55 | - "traefik.http.routers.bot.tls.certresolver=letsencrypt" 56 | - "traefik.http.services.bot.loadbalancer.server.port=8080" 57 | 58 | volumes: 59 | redis_data: 60 | letsencrypt_data: -------------------------------------------------------------------------------- /app/bot/middlewares/database.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import uuid 3 | from typing import Any, Awaitable, Callable 4 | 5 | from aiogram import BaseMiddleware 6 | from aiogram.types import TelegramObject 7 | from aiogram.types import User as TelegramUser 8 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 9 | 10 | from app.db.models import User 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class DBSessionMiddleware(BaseMiddleware): 16 | def __init__(self, session: async_sessionmaker) -> None: 17 | self.session = session 18 | logger.debug("Database Session Middleware initialized.") 19 | 20 | async def __call__( 21 | self, 22 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 23 | event: TelegramObject, 24 | data: dict[str, Any], 25 | ) -> Any: 26 | session: AsyncSession 27 | async with self.session() as session: 28 | tg_user: TelegramUser | None = event.event.from_user 29 | 30 | if tg_user is not None and not tg_user.is_bot: 31 | user = await User.get(session=session, tg_id=tg_user.id) 32 | is_new_user = False 33 | 34 | if not user: 35 | is_new_user = True 36 | user = await User.create( 37 | session=session, 38 | tg_id=tg_user.id, 39 | vpn_id=str(uuid.uuid4()), 40 | first_name=tg_user.first_name, 41 | username=tg_user.username, 42 | language_code=tg_user.language_code, 43 | ) 44 | logger.info(f"New user {user.tg_id} created.") 45 | 46 | data["user"] = user 47 | data["session"] = session 48 | data["is_new_user"] = is_new_user 49 | else: 50 | logger.debug("No user found in event data.") 51 | 52 | return await handler(event, data) 53 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/gateway_factory.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot 2 | from aiogram.fsm.storage.redis import RedisStorage 3 | from aiogram.utils.i18n import I18n 4 | from aiohttp.web import Application 5 | from sqlalchemy.ext.asyncio import async_sessionmaker 6 | 7 | from app.bot.models import ServicesContainer 8 | from app.config import Config 9 | 10 | from ._gateway import PaymentGateway 11 | from .cryptomus import Cryptomus 12 | from .heleket import Heleket 13 | from .telegram_stars import TelegramStars 14 | from .yookassa import Yookassa 15 | from .yoomoney import Yoomoney 16 | 17 | 18 | class GatewayFactory: 19 | def __init__(self) -> None: 20 | self._gateways: dict[str, PaymentGateway] = {} 21 | 22 | def register_gateway(self, gateway: PaymentGateway) -> None: 23 | self._gateways[gateway.callback] = gateway 24 | 25 | def get_gateway(self, name: str) -> PaymentGateway: 26 | gateway = self._gateways.get(name) 27 | if not gateway: 28 | raise ValueError(f"Gateway {name} is not registered.") 29 | return gateway 30 | 31 | def get_gateways(self) -> list[PaymentGateway]: 32 | return list(self._gateways.values()) 33 | 34 | def register_gateways( 35 | self, 36 | app: Application, 37 | config: Config, 38 | session: async_sessionmaker, 39 | storage: RedisStorage, 40 | bot: Bot, 41 | i18n: I18n, 42 | services: ServicesContainer, 43 | ) -> None: 44 | dependencies = [app, config, session, storage, bot, i18n, services] 45 | 46 | gateways = [ 47 | (config.shop.PAYMENT_STARS_ENABLED, TelegramStars), 48 | (config.shop.PAYMENT_CRYPTOMUS_ENABLED, Cryptomus), 49 | (config.shop.PAYMENT_HELEKET_ENABLED, Heleket), 50 | (config.shop.PAYMENT_YOOKASSA_ENABLED, Yookassa), 51 | (config.shop.PAYMENT_YOOMONEY_ENABLED, Yoomoney), 52 | ] 53 | 54 | for enabled, gateway_cls in gateways: 55 | if enabled: 56 | self.register_gateway(gateway_cls(*dependencies)) 57 | -------------------------------------------------------------------------------- /app/bot/routers/support/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.routers.misc.keyboard import back_button, back_to_main_menu_button 6 | from app.bot.utils.navigation import NavDownload, NavSubscription, NavSupport 7 | 8 | 9 | def contact_button(support_id: int) -> InlineKeyboardButton: 10 | return InlineKeyboardButton(text=_("support:button:contact"), url=f"tg://user?id={support_id}") 11 | 12 | 13 | def support_keyboard(support_id: int) -> InlineKeyboardMarkup: 14 | builder = InlineKeyboardBuilder() 15 | 16 | builder.row( 17 | InlineKeyboardButton( 18 | text=_("support:button:how_to_connect"), 19 | callback_data=NavSupport.HOW_TO_CONNECT, 20 | ) 21 | ) 22 | builder.row( 23 | InlineKeyboardButton( 24 | text=_("support:button:vpn_not_working"), 25 | callback_data=NavSupport.VPN_NOT_WORKING, 26 | ) 27 | ) 28 | 29 | builder.row(contact_button(support_id)) 30 | builder.row(back_to_main_menu_button()) 31 | return builder.as_markup() 32 | 33 | 34 | def how_to_connect_keyboard(support_id: int) -> InlineKeyboardMarkup: 35 | builder = InlineKeyboardBuilder() 36 | 37 | builder.row( 38 | InlineKeyboardButton( 39 | text=_("support:button:buy_subscription"), 40 | callback_data=NavSubscription.MAIN, 41 | ) 42 | ) 43 | builder.row( 44 | InlineKeyboardButton( 45 | text=_("support:button:download_app"), 46 | callback_data=NavDownload.MAIN, 47 | ) 48 | ) 49 | 50 | builder.row(contact_button(support_id)) 51 | builder.row(back_button(NavSupport.MAIN)) 52 | return builder.as_markup() 53 | 54 | 55 | def contact_keyboard(support_id: int) -> InlineKeyboardMarkup: 56 | builder = InlineKeyboardBuilder() 57 | builder.row(contact_button(support_id)) 58 | builder.row(back_button(NavSupport.MAIN)) 59 | return builder.as_markup() 60 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/backup_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | 4 | from aiogram import F, Router 5 | from aiogram.exceptions import TelegramAPIError 6 | from aiogram.types import CallbackQuery, FSInputFile 7 | from aiogram.utils.i18n import gettext as _ 8 | 9 | from app.bot.filters import IsAdmin 10 | from app.bot.models import ServicesContainer 11 | from app.bot.utils.constants import BACKUP_CREATED_TAG, DB_FORMAT 12 | from app.bot.utils.navigation import NavAdminTools 13 | from app.config import DEFAULT_DATA_DIR, Config 14 | from app.db.models import User 15 | 16 | logger = logging.getLogger(__name__) 17 | router = Router(name=__name__) 18 | 19 | 20 | @router.callback_query(F.data == NavAdminTools.CREATE_BACKUP, IsAdmin()) 21 | async def callback_create_backup( 22 | callback: CallbackQuery, 23 | user: User, 24 | config: Config, 25 | services: ServicesContainer, 26 | ) -> None: 27 | logger.info(f"Admin {user.tg_id} initiated backup creation.") 28 | try: 29 | file = FSInputFile( 30 | path=f"{DEFAULT_DATA_DIR}/{config.database.NAME}.{DB_FORMAT}", 31 | filename=f"backup_{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.{DB_FORMAT}", 32 | ) 33 | await services.notification.notify_developer(text=BACKUP_CREATED_TAG, document=file) 34 | await services.notification.show_popup(callback=callback, text=_("backup:popup:success")) 35 | logger.info(f"Backup sent to developer: {config.bot.DEV_ID}") 36 | except FileNotFoundError: 37 | logger.error("Database file not found.") 38 | await services.notification.show_popup(callback=callback, text=_("backup:popup:not_found")) 39 | except TelegramAPIError as exception: 40 | logger.error(f"Failed to send backup to developer: {exception}") 41 | await services.notification.show_popup(callback=callback, text=_("backup:popup:failed")) 42 | except Exception as exception: 43 | logger.error(f"Unexpected error during backup creation: {exception}") 44 | await services.notification.show_popup(callback=callback, text=_("backup:popup:error")) 45 | -------------------------------------------------------------------------------- /app/bot/services/plan.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | from app.bot.models import Plan 6 | from app.config import DEFAULT_PLANS_DIR 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | class PlanService: 12 | def __init__(self) -> None: 13 | file_path = DEFAULT_PLANS_DIR 14 | 15 | if not os.path.isfile(file_path): 16 | logger.error(f"File '{file_path}' does not exist.") 17 | raise FileNotFoundError(f"File '{file_path}' does not exist.") 18 | 19 | try: 20 | with open(file_path, "r") as f: 21 | self.data = json.load(f) 22 | logger.info(f"Loaded plans data from '{file_path}'.") 23 | except json.JSONDecodeError: 24 | logger.error(f"Failed to parse file '{file_path}'. Invalid JSON format.") 25 | raise ValueError(f"File '{file_path}' is not a valid JSON file.") 26 | 27 | if "plans" not in self.data or not isinstance(self.data["plans"], list): 28 | logger.error(f"'plans' key is missing or not a list in '{file_path}'.") 29 | raise ValueError(f"'plans' key is missing or not a list in '{file_path}'.") 30 | 31 | if "durations" not in self.data or not isinstance(self.data["durations"], list): 32 | logger.error(f"'durations' key is missing or not a list in '{file_path}'.") 33 | raise ValueError(f"'durations' key is missing or not a list in '{file_path}'.") 34 | 35 | self._plans: list[Plan] = [Plan.from_dict(plan) for plan in self.data["plans"]] 36 | self._durations: list[int] = self.data["durations"] 37 | logger.info("Plans loaded successfully.") 38 | 39 | def get_plan(self, devices: int) -> Plan | None: 40 | plan = next((plan for plan in self._plans if plan.devices == devices), None) 41 | 42 | if not plan: 43 | logger.critical(f"Plan with {devices} devices not found.") 44 | 45 | return plan 46 | 47 | def get_all_plans(self) -> list[Plan]: 48 | return self._plans 49 | 50 | def get_durations(self) -> list[int]: 51 | return self._durations 52 | -------------------------------------------------------------------------------- /scripts/manage_migrations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # manage_migrations.sh 4 | # ──────────────────────────────────────────────────────────────── 5 | # Script for managing database migrations using Alembic. 6 | # 7 | # Options: 8 | # --generate "Migration description" Generate a new migration 9 | # --upgrade Upgrade database to the latest version 10 | # --downgrade VERSION Downgrade database to a specific version 11 | # --help Show usage information 12 | # ──────────────────────────────────────────────────────────────── 13 | 14 | set -e 15 | 16 | ALEMBIC_INI_PATH="app/db/alembic.ini" 17 | 18 | show_help() { 19 | echo "Usage:" 20 | echo " --generate \"description\" Generate a new migration with the given description." 21 | echo " --upgrade Upgrade database to the latest version." 22 | echo " --downgrade VERSION Downgrade database to the specified version." 23 | echo " --help Show this help message." 24 | } 25 | 26 | if [[ $# -eq 0 ]]; then 27 | echo "❌ Error: No options provided. Use --help for usage information." 28 | exit 1 29 | fi 30 | 31 | case "$1" in 32 | --generate) 33 | if [[ -z "$2" ]]; then 34 | echo "❌ Error: Migration description is required." 35 | exit 1 36 | fi 37 | alembic -c "$ALEMBIC_INI_PATH" revision --autogenerate -m "$2" 38 | echo "✅ Migration created with description: $2" 39 | ;; 40 | --upgrade) 41 | alembic -c "$ALEMBIC_INI_PATH" upgrade head 42 | echo "✅ Database upgraded to the latest version." 43 | ;; 44 | --downgrade) 45 | if [[ -z "$2" ]]; then 46 | echo "❌ Error: Version is required for downgrade." 47 | exit 1 48 | fi 49 | alembic -c "$ALEMBIC_INI_PATH" downgrade "$2" 50 | echo "✅ Database downgraded to version: $2" 51 | ;; 52 | --help) 53 | show_help 54 | ;; 55 | *) 56 | echo "❌ Error: Unknown option '$1'. Use --help for usage information." 57 | exit 1 58 | ;; 59 | esac 60 | -------------------------------------------------------------------------------- /app/db/models/invite.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Optional, Self 4 | 5 | from sqlalchemy import Boolean, DateTime, Integer, String, select 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import Mapped, mapped_column 8 | 9 | from app.bot.utils.misc import generate_hash 10 | 11 | from . import Base 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Invite(Base): 17 | __tablename__ = "invites" 18 | 19 | id: Mapped[int] = mapped_column(Integer, primary_key=True) 20 | name: Mapped[str] = mapped_column(String, unique=True, nullable=False) 21 | hash_code: Mapped[str] = mapped_column(String, unique=True, nullable=False) 22 | clicks: Mapped[int] = mapped_column(Integer, default=0) 23 | created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) 24 | is_active: Mapped[bool] = mapped_column(Boolean, default=True) 25 | 26 | @classmethod 27 | async def create(cls, session: AsyncSession, name: str) -> Self: 28 | hash_code = generate_hash(name) 29 | invite = cls(name=name, hash_code=hash_code) 30 | session.add(invite) 31 | try: 32 | await session.commit() 33 | await session.refresh(invite) 34 | return invite 35 | except Exception as e: 36 | logger.error(f"Failed to create invite: {e}") 37 | await session.rollback() 38 | raise 39 | 40 | @classmethod 41 | async def get_by_hash(cls, session: AsyncSession, hash_code: str) -> Optional[Self]: 42 | result = await session.execute(select(cls).where(cls.hash_code == hash_code)) 43 | return result.scalars().first() 44 | 45 | @classmethod 46 | async def get_all(cls, session: AsyncSession) -> list[Self]: 47 | result = await session.execute(select(cls).order_by(cls.created_at.desc())) 48 | return list(result.scalars().all()) 49 | 50 | @classmethod 51 | async def increment_clicks(cls, session: AsyncSession, invite_id: int) -> None: 52 | invite = await session.get(cls, invite_id) 53 | if invite: 54 | invite.clicks += 1 55 | await session.commit() 56 | -------------------------------------------------------------------------------- /app/bot/routers/misc/error_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from aiogram import Router 5 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError 6 | from aiogram.filters import ExceptionTypeFilter 7 | from aiogram.types import BufferedInputFile, ErrorEvent 8 | from aiogram.utils.formatting import Bold, Code, Text 9 | 10 | from app.bot.models import ServicesContainer 11 | from app.bot.utils.misc import split_text 12 | from app.config import Config 13 | 14 | logger = logging.getLogger(__name__) 15 | router = Router(name=__name__) 16 | 17 | 18 | @router.errors(ExceptionTypeFilter(Exception)) 19 | async def errors_handler(event: ErrorEvent, config: Config, services: ServicesContainer) -> bool: 20 | if isinstance(event.exception, TelegramForbiddenError): 21 | logger.info(f"User {event.update.message.from_user.id} blocked the bot.") 22 | return True 23 | 24 | if isinstance(event.exception, TelegramBadRequest): 25 | logger.warning( 26 | f"User {event.update.callback_query.from_user.id} bad request for edit/send message." 27 | ) 28 | return True 29 | 30 | logger.exception(f"Update: {event.update}\nException: {event.exception}") 31 | 32 | if not config.bot.DEV_ID: 33 | return True 34 | 35 | try: 36 | text = Text(Bold((type(event.exception).__name__)), f": {str(event.exception)[:1021]}...") 37 | await services.notification.notify_developer( 38 | text=text.as_html(), 39 | document=BufferedInputFile( 40 | file=traceback.format_exc().encode(), 41 | filename=f"error_{event.update.update_id}.txt", 42 | ), 43 | ) 44 | 45 | update_json = event.update.model_dump_json(indent=2, exclude_none=True) 46 | for chunk in split_text(update_json): 47 | await services.notification.notify_developer( 48 | text=Code(chunk).as_html(), 49 | ) 50 | 51 | except TelegramBadRequest as exception: 52 | logger.warning(f"Failed to send error details: {exception}") 53 | except Exception as exception: 54 | logger.error(f"Unexpected error in error handler: {exception}") 55 | 56 | return True 57 | -------------------------------------------------------------------------------- /app/bot/routers/main_menu/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.utils.navigation import ( 6 | NavAdminTools, 7 | NavProfile, 8 | NavReferral, 9 | NavSubscription, 10 | NavSupport, 11 | ) 12 | 13 | 14 | def main_menu_keyboard( 15 | is_admin: bool = False, 16 | is_referral_available: bool = False, 17 | is_trial_available: bool = False, 18 | is_referred_trial_available: bool = False, 19 | ) -> InlineKeyboardMarkup: 20 | builder = InlineKeyboardBuilder() 21 | 22 | if is_referred_trial_available: 23 | builder.row( 24 | InlineKeyboardButton( 25 | text=_("referral:button:get_referred_trial"), 26 | callback_data=NavReferral.GET_REFERRED_TRIAL, 27 | ) 28 | ) 29 | elif is_trial_available: 30 | builder.row( 31 | InlineKeyboardButton( 32 | text=_("subscription:button:get_trial"), callback_data=NavSubscription.GET_TRIAL 33 | ) 34 | ) 35 | 36 | builder.row( 37 | InlineKeyboardButton( 38 | text=_("main_menu:button:profile"), 39 | callback_data=NavProfile.MAIN, 40 | ), 41 | InlineKeyboardButton( 42 | text=_("main_menu:button:subscription"), 43 | callback_data=NavSubscription.MAIN, 44 | ), 45 | ) 46 | builder.row( 47 | *( 48 | [ 49 | InlineKeyboardButton( 50 | text=_("main_menu:button:referral"), 51 | callback_data=NavReferral.MAIN, 52 | ) 53 | ] 54 | if is_referral_available 55 | else [] 56 | ), 57 | InlineKeyboardButton( 58 | text=_("main_menu:button:support"), 59 | callback_data=NavSupport.MAIN, 60 | ), 61 | ) 62 | 63 | if is_admin: 64 | builder.row( 65 | InlineKeyboardButton( 66 | text=_("main_menu:button:admin_tools"), 67 | callback_data=NavAdminTools.MAIN, 68 | ) 69 | ) 70 | 71 | return builder.as_markup() 72 | -------------------------------------------------------------------------------- /app/bot/middlewares/maintenance.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Awaitable, Callable 3 | 4 | from aiogram import BaseMiddleware 5 | from aiogram.types import TelegramObject, Update 6 | from aiogram.types import User as TelegramUser 7 | from aiogram.utils.i18n import gettext as _ 8 | 9 | from app.bot.filters import IsAdmin 10 | from app.bot.services import NotificationService 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class MaintenanceMiddleware(BaseMiddleware): 16 | active: bool = False 17 | 18 | def __init__(self) -> None: 19 | logger.debug("Maintenance Middleware initialized.") 20 | 21 | async def __call__( 22 | self, 23 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 24 | event: TelegramObject, 25 | data: dict[str, Any], 26 | ) -> Any: 27 | if isinstance(event, Update): 28 | user: TelegramUser | None = event.event.from_user 29 | 30 | if user is not None: 31 | is_admin = await IsAdmin()(user_id=user.id) 32 | logger.debug(f"Is user {user.id} an admin? {'Yes' if is_admin else 'No'}") 33 | 34 | if self.active and not is_admin and user.id != event.bot.id: 35 | logger.info(f"User {user.id} tried to use bot in maintenance") 36 | 37 | if event.message: 38 | message = event.message 39 | elif event.callback_query and event.callback_query.message: 40 | message = event.callback_query.message 41 | 42 | if message: 43 | await NotificationService.notify_by_message( 44 | message=message, 45 | text=_("maintenance:ntf:try_later"), 46 | duration=5, 47 | ) 48 | 49 | return None 50 | else: 51 | logger.debug(f"User {user.id} is allowed to interact with the bot.") 52 | 53 | return await handler(event, data) 54 | 55 | @classmethod 56 | def set_mode(cls, active: bool) -> None: 57 | MaintenanceMiddleware.active = active 58 | logger.info(f"Maintenance Mode: {'enabled' if active else 'disabled'}") 59 | -------------------------------------------------------------------------------- /app/bot/models/client_data.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from aiogram.utils.i18n import gettext as _ 4 | 5 | from app.bot.utils.constants import UNLIMITED 6 | from app.bot.utils.formatting import format_remaining_time, format_size 7 | 8 | 9 | class ClientData: 10 | def __init__( 11 | self, 12 | max_devices: int, 13 | traffic_total: int, 14 | traffic_remaining: int, 15 | traffic_used: int, 16 | traffic_up: int, 17 | traffic_down: int, 18 | expiry_time: str, 19 | ) -> None: 20 | self._max_devices = max_devices 21 | self._traffic_total = traffic_total 22 | self._traffic_remaining = traffic_remaining 23 | self._traffic_used = traffic_used 24 | self._traffic_up = traffic_up 25 | self._traffic_down = traffic_down 26 | self._expiry_time = expiry_time 27 | 28 | def __str__(self) -> str: 29 | return ( 30 | f"ClientData(max_devices={self._max_devices}, traffic_total={self._traffic_total}, " 31 | f"traffic_remaining={self._traffic_remaining}, traffic_used={self._traffic_used}, " 32 | f"traffic_up={self._traffic_up}, traffic_down={self._traffic_down}, " 33 | f"expiry_time={self._expiry_time})" 34 | ) 35 | 36 | @property 37 | def max_devices(self) -> str: 38 | devices = self._max_devices 39 | if devices == -1: 40 | return UNLIMITED 41 | return devices 42 | 43 | @property 44 | def traffic_total(self) -> str: 45 | return format_size(self._traffic_total) 46 | 47 | @property 48 | def traffic_remaining(self) -> str: 49 | return format_size(self._traffic_remaining) 50 | 51 | @property 52 | def traffic_used(self) -> str: 53 | return format_size(self._traffic_used) 54 | 55 | @property 56 | def traffic_up(self) -> str: 57 | return format_size(self._traffic_up) 58 | 59 | @property 60 | def traffic_down(self) -> str: 61 | return format_size(self._traffic_down) 62 | 63 | @property 64 | def expiry_time(self) -> str: 65 | return format_remaining_time(self._expiry_time) 66 | 67 | @property 68 | def has_subscription_expired(self) -> bool: 69 | current_time = time.time() * 1000 70 | expired = self._expiry_time != -1 and current_time > self._expiry_time 71 | return expired 72 | -------------------------------------------------------------------------------- /app/db/migration/versions/0d6e179d7d34_user_trial_period_and_referral_model.py: -------------------------------------------------------------------------------- 1 | """user_trial_period_and_referral_model 2 | 3 | Revision ID: 0d6e179d7d34 4 | Revises: 5c8c426595b0 5 | Create Date: 2025-03-11 13:54:42.811224 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "0d6e179d7d34" 16 | down_revision: Union[str, None] = "5c8c426595b0" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "referrals", 25 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 26 | sa.Column("referred_tg_id", sa.Integer(), nullable=False), 27 | sa.Column("referrer_tg_id", sa.Integer(), nullable=False), 28 | sa.Column("created_at", sa.DateTime(), nullable=False), 29 | sa.Column("referred_rewarded_at", sa.DateTime(), nullable=True), 30 | sa.Column("referrer_rewarded_at", sa.DateTime(), nullable=True), 31 | sa.Column("bonus_days", sa.Integer(), nullable=False), 32 | sa.ForeignKeyConstraint( 33 | ["referred_tg_id"], 34 | ["users.tg_id"], 35 | name=op.f("fk_referrals_referred_tg_id_users"), 36 | ondelete="CASCADE", 37 | ), 38 | sa.ForeignKeyConstraint( 39 | ["referrer_tg_id"], 40 | ["users.tg_id"], 41 | name=op.f("fk_referrals_referrer_tg_id_users"), 42 | ondelete="CASCADE", 43 | ), 44 | sa.PrimaryKeyConstraint("id", name=op.f("pk_referrals")), 45 | sa.UniqueConstraint("referred_tg_id", name=op.f("uq_referrals_referred_tg_id")), 46 | ) 47 | with op.batch_alter_table("users", schema=None) as batch_op: 48 | batch_op.add_column( 49 | sa.Column("is_trial_used", sa.Boolean(), nullable=False, server_default=sa.false()) 50 | ) 51 | 52 | # ### end Alembic commands ### 53 | 54 | 55 | def downgrade() -> None: 56 | # ### commands auto generated by Alembic - please adjust! ### 57 | with op.batch_alter_table("users", schema=None) as batch_op: 58 | batch_op.drop_column("is_trial_used") 59 | 60 | op.drop_table("referrals") 61 | # ### end Alembic commands ### 62 | -------------------------------------------------------------------------------- /app/bot/middlewares/throttling.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Awaitable, Callable, MutableMapping 3 | 4 | from aiogram import BaseMiddleware 5 | from aiogram.dispatcher.flags import get_flag 6 | from aiogram.types import TelegramObject, Update 7 | from aiogram.types import User as TelegramUser 8 | from cachetools import TTLCache 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ThrottlingMiddleware(BaseMiddleware): 14 | def __init__( 15 | self, 16 | *, 17 | default_key: str | None = "default", 18 | default_ttl: float = 0.3, 19 | **ttl_map: dict[str, float], 20 | ) -> None: 21 | if default_key: 22 | ttl_map[default_key] = default_ttl 23 | 24 | self.default_key = default_key 25 | self.caches: dict[str, MutableMapping[int, None]] = {} 26 | 27 | for name, ttl in ttl_map.items(): 28 | self.caches[name] = TTLCache(maxsize=10_000, ttl=ttl) 29 | 30 | logger.debug("Throttling Middleware initialized.") 31 | 32 | async def __call__( 33 | self, 34 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 35 | event: TelegramObject, 36 | data: dict[str, Any], 37 | ) -> Any: 38 | if not isinstance(event, Update): 39 | logger.debug(f"Received event of type {type(event)}, skipping throttling.") 40 | return await handler(event, data) 41 | 42 | if event.pre_checkout_query: 43 | logger.debug("Pre-checkout query event, skipping throttling.") 44 | return await handler(event, data) 45 | 46 | if event.message and event.message.successful_payment: 47 | logger.debug("Successful payment event, skipping throttling.") 48 | return await handler(event, data) 49 | 50 | user: TelegramUser | None = event.event.from_user 51 | 52 | if user is not None: 53 | key = get_flag(handler=data, name="throttling_key", default=self.default_key) 54 | 55 | if key: 56 | if user.id in self.caches[key]: 57 | logger.warning(f"User {user.id} throttled.") 58 | return None 59 | logger.debug(f"User {user.id} not throttled.") 60 | self.caches[key][user.id] = None 61 | else: 62 | logger.debug(f"No throttle key for user {user.id}") 63 | 64 | return await handler(event, data) 65 | -------------------------------------------------------------------------------- /app/bot/routers/download/keyboard.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.i18n import gettext as _ 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from app.bot.routers.misc.keyboard import back_button, back_to_main_menu_button 6 | from app.bot.utils.constants import ( 7 | APP_ANDROID_LINK, 8 | APP_ANDROID_SCHEME, 9 | APP_IOS_LINK, 10 | APP_IOS_SCHEME, 11 | APP_WINDOWS_LINK, 12 | APP_WINDOWS_SCHEME, 13 | CONNECTION_WEBHOOK, 14 | ) 15 | from app.bot.utils.navigation import NavDownload, NavMain, NavSubscription, NavSupport 16 | 17 | 18 | def platforms_keyboard(previous_callback: str = None) -> InlineKeyboardMarkup: 19 | builder = InlineKeyboardBuilder() 20 | 21 | builder.row( 22 | InlineKeyboardButton( 23 | text=_("download:button:ios"), 24 | callback_data=NavDownload.PLATFORM_IOS, 25 | ), 26 | InlineKeyboardButton( 27 | text=_("download:button:android"), 28 | callback_data=NavDownload.PLATFORM_ANDROID, 29 | ), 30 | InlineKeyboardButton( 31 | text=_("download:button:windows"), 32 | callback_data=NavDownload.PLATFORM_WINDOWS, 33 | ), 34 | ) 35 | 36 | if previous_callback == NavMain.MAIN_MENU: 37 | builder.row(back_to_main_menu_button()) 38 | else: 39 | back_callback = previous_callback if previous_callback else NavSupport.HOW_TO_CONNECT 40 | builder.row(back_button(back_callback)) 41 | 42 | return builder.as_markup() 43 | 44 | 45 | def download_keyboard(platform: NavDownload, url: str, key: str) -> InlineKeyboardMarkup: 46 | builder = InlineKeyboardBuilder() 47 | 48 | match platform: 49 | case NavDownload.PLATFORM_IOS: 50 | scheme = APP_IOS_SCHEME 51 | download = APP_IOS_LINK 52 | case NavDownload.PLATFORM_ANDROID: 53 | scheme = APP_ANDROID_SCHEME 54 | download = APP_ANDROID_LINK 55 | case _: 56 | scheme = APP_WINDOWS_SCHEME 57 | download = APP_WINDOWS_LINK 58 | 59 | connect = f"{url}{CONNECTION_WEBHOOK}?scheme={scheme}&key={key}" 60 | 61 | builder.button(text=_("download:button:download"), url=download) 62 | 63 | builder.button( 64 | text=_("download:button:connect"), 65 | url=connect if key else None, 66 | callback_data=NavSubscription.MAIN if not key else None, 67 | ) 68 | 69 | builder.row(back_button(NavDownload.MAIN)) 70 | return builder.as_markup() 71 | -------------------------------------------------------------------------------- /app/bot/routers/subscription/trial_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.types import CallbackQuery 6 | from aiogram.utils.i18n import gettext as _ 7 | 8 | from app.bot.models import ServicesContainer 9 | from app.bot.routers.subscription.keyboard import trial_success_keyboard 10 | from app.bot.utils.constants import MAIN_MESSAGE_ID_KEY, PREVIOUS_CALLBACK_KEY 11 | from app.bot.utils.formatting import format_subscription_period 12 | from app.bot.utils.navigation import NavMain, NavSubscription 13 | from app.config import Config 14 | from app.db.models import User 15 | 16 | logger = logging.getLogger(__name__) 17 | router = Router(name=__name__) 18 | 19 | 20 | @router.callback_query(F.data == NavSubscription.GET_TRIAL) 21 | async def callback_get_trial( 22 | callback: CallbackQuery, 23 | user: User, 24 | state: FSMContext, 25 | services: ServicesContainer, 26 | config: Config, 27 | ) -> None: 28 | logger.info(f"User {user.tg_id} triggered getting non-referral trial period.") 29 | await state.update_data({PREVIOUS_CALLBACK_KEY: NavMain.MAIN_MENU}) 30 | 31 | server = await services.server_pool.get_available_server() 32 | 33 | if not server: 34 | await services.notification.show_popup( 35 | callback=callback, text=_("subscription:popup:no_available_servers") 36 | ) 37 | return 38 | 39 | is_trial_available = await services.subscription.is_trial_available(user=user) 40 | 41 | if not is_trial_available: 42 | await services.notification.show_popup( 43 | callback=callback, text=_("subscription:popup:trial_unavailable_for_user") 44 | ) 45 | return 46 | else: 47 | trial_period = config.shop.TRIAL_PERIOD 48 | success = await services.subscription.gift_trial(user=user) 49 | 50 | main_message_id = await state.get_value(MAIN_MESSAGE_ID_KEY) 51 | if success: 52 | await callback.bot.edit_message_text( 53 | text=_("subscription:ntf:trial_activate_success").format( 54 | duration=format_subscription_period(trial_period), 55 | ), 56 | chat_id=callback.message.chat.id, 57 | message_id=main_message_id, 58 | reply_markup=trial_success_keyboard(), 59 | ) 60 | else: 61 | text = _("subscription:popup:trial_activate_failed") 62 | await services.notification.show_popup(callback=callback, text=text) 63 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/maintenance_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.types import CallbackQuery 5 | from aiogram.utils.i18n import gettext as _ 6 | 7 | from app.bot.filters import IsAdmin 8 | from app.bot.models import ServicesContainer 9 | from app.bot.utils.navigation import NavAdminTools 10 | from app.db.models import User 11 | 12 | from .keyboard import maintenance_mode_keyboard 13 | 14 | logger = logging.getLogger(__name__) 15 | router = Router(name=__name__) 16 | 17 | 18 | @router.callback_query(F.data == NavAdminTools.MAINTENANCE_MODE, IsAdmin()) 19 | async def callback_maintenance_mode(callback: CallbackQuery, user: User) -> None: 20 | logger.info(f"Admin {user.tg_id} navigated to maintenance mode options.") 21 | from app.bot.middlewares import MaintenanceMiddleware 22 | 23 | status = ( 24 | _("maintenance:status:enabled") 25 | if MaintenanceMiddleware.active 26 | else _("maintenance:status:disabled") 27 | ) 28 | await callback.message.edit_text( 29 | text=_("maintenance:message:main").format(status=status), 30 | reply_markup=maintenance_mode_keyboard(), 31 | ) 32 | 33 | 34 | @router.callback_query(F.data == NavAdminTools.MAINTENANCE_MODE_ENABLE, IsAdmin()) 35 | async def callback_maintenance_mode_enable( 36 | callback: CallbackQuery, 37 | user: User, 38 | services: ServicesContainer, 39 | ) -> None: 40 | logger.info(f"Admin {user.tg_id} enabled maintenance mode.") 41 | from app.bot.middlewares import MaintenanceMiddleware 42 | 43 | MaintenanceMiddleware.set_mode(True) 44 | await callback.message.edit_text( 45 | text=_("maintenance:message:main").format(status=_("maintenance:status:enabled")), 46 | reply_markup=maintenance_mode_keyboard(), 47 | ) 48 | await services.notification.show_popup( 49 | callback=callback, 50 | text=_("maintenance:popup:enabled"), 51 | ) 52 | 53 | 54 | @router.callback_query(F.data == NavAdminTools.MAINTENANCE_MODE_DISABLE, IsAdmin()) 55 | async def callback_maintenance_mode_disable( 56 | callback: CallbackQuery, 57 | user: User, 58 | services: ServicesContainer, 59 | ) -> None: 60 | logger.info(f"Admin {user.tg_id} disabled maintenance mode.") 61 | from app.bot.middlewares import MaintenanceMiddleware 62 | 63 | MaintenanceMiddleware.set_mode(False) 64 | await callback.message.edit_text( 65 | text=_("maintenance:message:main").format(status=_("maintenance:status:disabled")), 66 | reply_markup=maintenance_mode_keyboard(), 67 | ) 68 | await services.notification.show_popup( 69 | callback=callback, 70 | text=_("maintenance:popup:disabled"), 71 | ) 72 | -------------------------------------------------------------------------------- /app/bot/services/subscription.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from app.bot.services import VPNService 7 | 8 | import logging 9 | 10 | from sqlalchemy.ext.asyncio import async_sessionmaker 11 | 12 | from app.config import Config 13 | from app.db.models import Referral, User 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class SubscriptionService: 19 | def __init__( 20 | self, 21 | config: Config, 22 | session_factory: async_sessionmaker, 23 | vpn_service: VPNService, 24 | ) -> None: 25 | self.config = config 26 | self.session_factory = session_factory 27 | self.vpn_service = vpn_service 28 | logger.info("Subscription Service initialized") 29 | 30 | async def is_trial_available(self, user: User) -> bool: 31 | is_first_check_ok = ( 32 | self.config.shop.TRIAL_ENABLED and not user.server_id and not user.is_trial_used 33 | ) 34 | 35 | if not is_first_check_ok: 36 | return False 37 | 38 | async with self.session_factory() as session: 39 | referral = await Referral.get_referral(session, user.tg_id) 40 | 41 | return not referral or (referral and not self.config.shop.REFERRED_TRIAL_ENABLED) 42 | 43 | async def gift_trial(self, user: User) -> bool: 44 | if not await self.is_trial_available(user=user): 45 | logger.warning( 46 | f"Failed to activate trial for user {user.tg_id}. Trial period is not available." 47 | ) 48 | return False 49 | 50 | async with self.session_factory() as session: 51 | trial_used = await User.update_trial_status( 52 | session=session, tg_id=user.tg_id, used=True 53 | ) 54 | 55 | if not trial_used: 56 | logger.critical(f"Failed to activate trial for user {user.tg_id}.") 57 | return False 58 | 59 | logger.info(f"Begun giving trial period for user {user.tg_id}.") 60 | trial_success = await self.vpn_service.process_bonus_days( 61 | user, 62 | duration=self.config.shop.TRIAL_PERIOD, 63 | devices=self.config.shop.BONUS_DEVICES_COUNT, 64 | ) 65 | 66 | if trial_success: 67 | logger.info( 68 | f"Successfully gave {self.config.shop.TRIAL_PERIOD} days to a user {user.tg_id}" 69 | ) 70 | return True 71 | 72 | async with self.session_factory() as session: 73 | await User.update_trial_status(session=session, tg_id=user.tg_id, used=False) 74 | 75 | logger.warning(f"Failed to apply trial period for user {user.tg_id} due to failure.") 76 | return False 77 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/telegram_stars.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot 4 | from aiogram.fsm.storage.redis import RedisStorage 5 | from aiogram.types import LabeledPrice 6 | from aiogram.utils.i18n import I18n 7 | from aiogram.utils.i18n import gettext as _ 8 | from aiogram.utils.i18n import lazy_gettext as __ 9 | from aiohttp.web import Application 10 | from sqlalchemy.ext.asyncio import async_sessionmaker 11 | 12 | from app.bot.filters.is_dev import IsDev 13 | from app.bot.models import ServicesContainer, SubscriptionData 14 | from app.bot.payment_gateways import PaymentGateway 15 | from app.bot.utils.constants import Currency 16 | from app.bot.utils.formatting import format_device_count, format_subscription_period 17 | from app.bot.utils.navigation import NavSubscription 18 | from app.config import Config 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class TelegramStars(PaymentGateway): 24 | name = "" 25 | currency = Currency.XTR 26 | callback = NavSubscription.PAY_TELEGRAM_STARS 27 | 28 | def __init__( 29 | self, 30 | app: Application, 31 | config: Config, 32 | session: async_sessionmaker, 33 | storage: RedisStorage, 34 | bot: Bot, 35 | i18n: I18n, 36 | services: ServicesContainer, 37 | ) -> None: 38 | self.name = __("payment:gateway:telegram_stars") 39 | self.app = app 40 | self.config = config 41 | self.session = session 42 | self.storage = storage 43 | self.bot = bot 44 | self.services = services 45 | self.i18n = i18n 46 | logger.info("TelegramStars payment gateway initialized.") 47 | 48 | async def create_payment(self, data: SubscriptionData) -> str: 49 | if await IsDev()(user_id=data.user_id): 50 | amount = 1 51 | else: 52 | amount = int(data.price) 53 | 54 | prices = [LabeledPrice(label=self.currency.code, amount=amount)] 55 | devices = format_device_count(data.devices) 56 | duration = format_subscription_period(data.duration) 57 | title = _("payment:invoice:title").format(devices=devices, duration=duration) 58 | description = _("payment:invoice:description").format(devices=devices, duration=duration) 59 | pay_url = await self.bot.create_invoice_link( 60 | title=title, 61 | description=description, 62 | prices=prices, 63 | payload=data.pack(), 64 | currency=self.currency.code, 65 | ) 66 | logger.info(f"Payment link created for user {data.user_id}: {pay_url}") 67 | return pay_url 68 | 69 | async def handle_payment_succeeded(self, payment_id: str) -> None: 70 | await self._on_payment_succeeded(payment_id) 71 | 72 | async def handle_payment_canceled(self, payment_id: str) -> None: 73 | await self._on_payment_canceled(payment_id) 74 | -------------------------------------------------------------------------------- /app/bot/utils/formatting.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import math 3 | from datetime import datetime, timezone 4 | from decimal import ROUND_DOWN, Decimal 5 | 6 | from aiogram.utils.i18n import gettext as _ 7 | 8 | from app.bot.utils.constants import UNLIMITED 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | def format_size(size_bytes: int) -> str: 14 | try: 15 | if size_bytes == -1: 16 | return UNLIMITED 17 | if size_bytes == 0: 18 | return f"0 {_('MB')}" 19 | 20 | size_units = [_("MB"), _("GB"), _("TB"), _("PB"), _("EB"), _("ZB"), _("YB")] 21 | size_in_mb = max(size_bytes / 1024**2, 1) 22 | i = min(int(math.log(size_in_mb, 1024)), len(size_units) - 1) 23 | s = round(size_in_mb / (1024**i), 2) 24 | 25 | return f"{int(s) if s.is_integer() else s} {size_units[i]}" 26 | except Exception as exception: 27 | logger.error(f"Error converting size: {exception}") 28 | return f"0 {_('MB')}" 29 | 30 | 31 | def format_remaining_time(timestamp: int) -> str: 32 | try: 33 | if timestamp == -1: 34 | return UNLIMITED 35 | 36 | now = datetime.now(timezone.utc) 37 | expiry_datetime = datetime.fromtimestamp(timestamp / 1000, timezone.utc) 38 | time_left = expiry_datetime - now 39 | 40 | days, remainder = divmod(time_left.total_seconds(), 86400) 41 | hours, remainder = divmod(remainder, 3600) 42 | minutes = remainder // 60 43 | 44 | parts = [] 45 | if days > 0: 46 | parts.append(f"{int(days)}{_('d')}") 47 | if hours > 0: 48 | parts.append(f"{int(hours)}{_('h')}") 49 | if minutes > 0 or not parts: 50 | parts.append(f"{int(minutes)}{_('m')}") 51 | 52 | return " ".join(parts) 53 | except Exception as exception: 54 | logger.error(f"Error calculating time to expiry: {exception}") 55 | return f"0{_('m')}" 56 | 57 | 58 | def format_device_count(devices: int) -> str: 59 | return ( 60 | UNLIMITED + _("devices") 61 | if devices == -1 62 | else _("1 device", "{} devices", devices).format(devices) 63 | ) 64 | 65 | 66 | def format_subscription_period(days: int) -> str: 67 | if days == -1: 68 | return UNLIMITED 69 | if days % 365 == 0 and days != 0: 70 | return _("1 year", "{} years", days // 365).format(days // 365) 71 | if days % 30 == 0 and days != 0: 72 | return _("1 month", "{} months", days // 30).format(days // 30) 73 | return _("1 day", "{} days", days).format(days) 74 | 75 | 76 | def to_decimal(amount: float | str | Decimal | int) -> Decimal: 77 | DECIMAL_SCALE = 18 78 | DECIMAL_FORMAT = f"1.{'0' * DECIMAL_SCALE}" 79 | 80 | if isinstance(amount, Decimal): 81 | result = amount 82 | else: 83 | result = Decimal(str(amount)) 84 | 85 | return result.quantize(Decimal(DECIMAL_FORMAT), rounding=ROUND_DOWN) 86 | -------------------------------------------------------------------------------- /app/bot/routers/admin_tools/admin_tools_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.types import CallbackQuery 5 | from aiogram.utils.i18n import gettext as _ 6 | 7 | from app.bot.filters import IsAdmin, IsDev 8 | from app.bot.services import ServicesContainer 9 | from app.bot.utils.navigation import NavAdminTools 10 | from app.db.models import User 11 | 12 | from .keyboard import admin_tools_keyboard 13 | 14 | logger = logging.getLogger(__name__) 15 | router = Router(name=__name__) 16 | 17 | 18 | @router.callback_query(F.data == NavAdminTools.MAIN, IsAdmin()) 19 | async def callback_admin_tools(callback: CallbackQuery, user: User) -> None: 20 | logger.info(f"Admin {user.tg_id} opened admin tools.") 21 | is_dev = await IsDev()(user_id=user.tg_id) 22 | await callback.message.edit_text( 23 | text=_("admin_tools:message:main"), 24 | reply_markup=admin_tools_keyboard(is_dev), 25 | ) 26 | 27 | 28 | from sqlalchemy.ext.asyncio import AsyncSession 29 | 30 | from app.db.models import Transaction 31 | 32 | 33 | @router.callback_query(F.data == NavAdminTools.TEST, IsAdmin()) 34 | async def callback_admin_tools( 35 | callback: CallbackQuery, 36 | user: User, 37 | session: AsyncSession, 38 | services: ServicesContainer, 39 | ) -> None: 40 | logger.info(f"Admin {user.tg_id} clicked TEST BUTTON.") 41 | 42 | text = ( 43 | "bold\n" 44 | "italic\n" 45 | "underline\n" 46 | "strikethrough\n" 47 | "spoiler\n\n" 48 | "inline URL\n" 49 | "inline mention of a user\n" 50 | "👍\n\n" 51 | "inline fixed-width code\n" 52 | "
pre-formatted fixed-width code block
\n" 53 | "
pre-formatted fixed-width code block written in the Python programming language
\n\n" 54 | "
Block quotation started\nBlock quotation continued\nThe last line of the block quotation
\n" 55 | "
Expandable block quotation started\nExpandable block quotation continued\nExpandable block quotation continued\nHidden by default part of the block quotation started\nExpandable block quotation continued\nThe last line of the block quotation
\n" 56 | ) 57 | 58 | await callback.message.answer(text=text) 59 | # logger.info( 60 | # f"{user}\n\n{user.transactions}\n\n{user.server}\n\n{user.activated_promocodes}\n\n" 61 | # ) 62 | # logger.info(f"{await vpn_service.get_key(user.tg_id)}\n\n") 63 | 64 | # connection = await server_pool_service.get_connection(user.server_id) 65 | 66 | # server = connection.server 67 | # logger.info(f"{server}\n\n{server.users}\n\n") 68 | 69 | # transaction = await Transaction.get(session=session, payment_id="test") 70 | # logger.info(f"{transaction}\n\n{transaction.user}") 71 | 72 | # logger.info(server.current_clients) 73 | -------------------------------------------------------------------------------- /app/bot/routers/subscription/promocode_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.fsm.state import State, StatesGroup 6 | from aiogram.types import CallbackQuery, Message 7 | from aiogram.utils.i18n import gettext as _ 8 | from sqlalchemy.ext.asyncio import AsyncSession 9 | 10 | from app.bot.models import ServicesContainer 11 | from app.bot.utils.constants import MAIN_MESSAGE_ID_KEY 12 | from app.bot.utils.formatting import format_subscription_period 13 | from app.bot.utils.navigation import NavSubscription 14 | from app.db.models import Promocode, User 15 | 16 | from .keyboard import promocode_keyboard 17 | 18 | logger = logging.getLogger(__name__) 19 | router = Router(name=__name__) 20 | 21 | 22 | class ActivatePromocodeStates(StatesGroup): 23 | promocode_input = State() 24 | 25 | 26 | @router.callback_query(F.data == NavSubscription.PROMOCODE) 27 | async def callback_promocode(callback: CallbackQuery, user: User, state: FSMContext) -> None: 28 | logger.info(f"User {user.tg_id} started activating promocode.") 29 | await state.set_state(ActivatePromocodeStates.promocode_input) 30 | await callback.message.edit_text( 31 | text=_("promocode:message:main"), 32 | reply_markup=promocode_keyboard(), 33 | ) 34 | 35 | 36 | @router.message(ActivatePromocodeStates.promocode_input) 37 | async def handle_promocode_input( 38 | message: Message, 39 | user: User, 40 | session: AsyncSession, 41 | state: FSMContext, 42 | services: ServicesContainer, 43 | ) -> None: 44 | input_promocode = message.text.strip() 45 | logger.info(f"User {user.tg_id} entered promocode: {input_promocode} for activating.") 46 | 47 | server = await services.server_pool.get_available_server() 48 | 49 | if not server: 50 | await services.notification.notify_by_message( 51 | message=message, 52 | text=_("promocode:ntf:no_available_servers"), 53 | duration=5, 54 | ) 55 | return 56 | 57 | promocode = await Promocode.get(session=session, code=input_promocode) 58 | if promocode and not promocode.is_activated: 59 | success = await services.vpn.activate_promocode(user=user, promocode=promocode) 60 | main_message_id = await state.get_value(MAIN_MESSAGE_ID_KEY) 61 | if success: 62 | await message.bot.edit_message_text( 63 | text=_("promocode:message:activated_success").format( 64 | promocode=input_promocode, 65 | duration=format_subscription_period(promocode.duration), 66 | ), 67 | chat_id=message.chat.id, 68 | message_id=main_message_id, 69 | reply_markup=promocode_keyboard(), 70 | ) 71 | else: 72 | text = _("promocode:ntf:activate_failed") 73 | await services.notification.notify_by_message(message=message, text=text, duration=5) 74 | else: 75 | text = _("promocode:ntf:activate_invalid").format(promocode=input_promocode) 76 | await services.notification.notify_by_message(message=message, text=text, duration=5) 77 | -------------------------------------------------------------------------------- /app/bot/utils/navigation.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class NavMain(str, Enum): 5 | START = "start" 6 | MAIN_MENU = "main_menu" 7 | CLOSE_NOTIFICATION = "close_notification" 8 | REDIRECT_TO_DOWNLOAD = "redirect_to_download" 9 | 10 | 11 | class NavProfile(str, Enum): 12 | MAIN = "profile" 13 | SHOW_KEY = "show_key" 14 | 15 | 16 | class NavReferral(str, Enum): 17 | MAIN = "referral" 18 | GET_REFERRED_TRIAL = "get_referral_trial" 19 | 20 | 21 | class NavSupport(str, Enum): 22 | MAIN = "support" 23 | HOW_TO_CONNECT = "how_to_connect" 24 | VPN_NOT_WORKING = "vpn_not_working" 25 | 26 | 27 | class NavDownload(str, Enum): 28 | MAIN = "download" 29 | PLATFORM = "platform" 30 | PLATFORM_IOS = f"{PLATFORM}_ios" 31 | PLATFORM_ANDROID = f"{PLATFORM}_android" 32 | PLATFORM_WINDOWS = f"{PLATFORM}_windows" 33 | 34 | 35 | class NavSubscription(str, Enum): 36 | MAIN = "subscription" 37 | CHANGE = "change" 38 | EXTEND = "extend" 39 | PROCESS = "process" 40 | DEVICES = "devices" 41 | DURATION = "duration" 42 | PROMOCODE = "promocode" 43 | GET_TRIAL = "get_trial" 44 | 45 | PAY = "pay" 46 | PAY_YOOKASSA = f"{PAY}_yookassa" 47 | PAY_TELEGRAM_STARS = f"{PAY}_telegram_stars" 48 | PAY_CRYPTOMUS = f"{PAY}_cryptomus" 49 | PAY_HELEKET = f"{PAY}_heleket" 50 | PAY_YOOMONEY = f"{PAY}_yoomoney" 51 | BACK_TO_DURATION = "back_to_duration" 52 | BACK_TO_PAYMENT = "back_to_payment" 53 | 54 | 55 | class NavAdminTools(str, Enum): 56 | MAIN = "admin_tools" 57 | TEST = "test" 58 | SERVER_MANAGEMENT = "server_management" 59 | SHOW_SERVER = "show_server" 60 | PING_SERVER = "ping_server" 61 | ADD_SERVER = "add_server" 62 | ADD_SERVER_BACK = "add_server_back" 63 | СONFIRM_ADD_SERVER = "сonfirm_add_server" 64 | DELETE_SERVER = "delete_server" 65 | EDIT_SERVER = "edit_server" 66 | SYNC_SERVERS = "sync_servers" 67 | STATISTICS = "statistics" 68 | USER_EDITOR = "user_editor" 69 | 70 | INVITE_EDITOR = "invite_editor" 71 | CREATE_INVITE = "create_invite" 72 | DELETE_INVITE = "delete_invite" 73 | LIST_INVITES = "list_invites" 74 | SHOW_INVITE_PAGE = "show_invite_page" 75 | SHOW_INVITE_DETAILS = "show_invite_details" 76 | TOGGLE_INVITE_STATUS = "toggle_invite_status" 77 | CONFIRM_DELETE_INVITE = "confirm_delete_invite" 78 | 79 | PROMOCODE_EDITOR = "promocode_editor" 80 | CREATE_PROMOCODE = "create_promocode" 81 | DELETE_PROMOCODE = "delete_promocode" 82 | EDIT_PROMOCODE = "edit_promocode" 83 | 84 | NOTIFICATION = "notification" 85 | SEND_NOTIFICATION_USER = "send_notification_user" 86 | SEND_NOTIFICATION_ALL = "send_notification_all" 87 | CONFIRM_SEND_NOTIFICATION = "confirm_send_notification" 88 | LAST_NOTIFICATION = "last_notification" 89 | EDIT_NOTIFICATION = "edit_notification" 90 | DELETE_NOTIFICATION = "delete_notification" 91 | 92 | CREATE_BACKUP = "create_backup" 93 | 94 | MAINTENANCE_MODE = "maintenance_mode" 95 | MAINTENANCE_MODE_ENABLE = "maintenance_mode_enable" 96 | MAINTENANCE_MODE_DISABLE = "maintenance_mode_disable" 97 | 98 | RESTART_BOT = "restart_bot" 99 | -------------------------------------------------------------------------------- /app/db/migration/versions/9aa6ddb8e352_update_transaction_status_enum.py: -------------------------------------------------------------------------------- 1 | """update transaction status enum 2 | 3 | Revision ID: 9aa6ddb8e352 4 | Revises: 1f557db4f100 5 | Create Date: 2025-02-05 19:25:06.863986 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "9aa6ddb8e352" 16 | down_revision: Union[str, None] = "1f557db4f100" 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | new_enum = sa.Enum("pending", "completed", "canceled", "refunded", name="transactionstatus") 24 | 25 | op.create_table( 26 | "transactions_new", 27 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 28 | sa.Column("tg_id", sa.Integer(), nullable=False), 29 | sa.Column("payment_id", sa.String(length=64), nullable=False), 30 | sa.Column("subscription", sa.String(length=255), nullable=False), 31 | sa.Column("status", new_enum, nullable=False), 32 | sa.Column("created_at", sa.DateTime(), nullable=False), 33 | sa.Column("updated_at", sa.DateTime(), nullable=False), 34 | sa.ForeignKeyConstraint(["tg_id"], ["users.tg_id"]), 35 | sa.PrimaryKeyConstraint("id"), 36 | sa.UniqueConstraint("payment_id", name="uq_transactions_payment_id"), 37 | ) 38 | 39 | op.execute( 40 | "INSERT INTO transactions_new SELECT id, tg_id, payment_id, subscription, " 41 | "CASE WHEN status = 'failed' THEN 'canceled' ELSE status END, " 42 | "created_at, updated_at FROM transactions" 43 | ) 44 | 45 | op.drop_table("transactions") 46 | op.rename_table("transactions_new", "transactions") 47 | # ### end Alembic commands ### 48 | 49 | 50 | def downgrade() -> None: 51 | # ### commands auto generated by Alembic - please adjust! ### 52 | old_enum = sa.Enum("pending", "completed", "failed", "refunded", name="transactionstatus") 53 | 54 | op.create_table( 55 | "transactions_new", 56 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 57 | sa.Column("tg_id", sa.Integer(), nullable=False), 58 | sa.Column("payment_id", sa.String(length=64), nullable=False), 59 | sa.Column("subscription", sa.String(length=255), nullable=False), 60 | sa.Column("status", old_enum, nullable=False), 61 | sa.Column("created_at", sa.DateTime(), nullable=False), 62 | sa.Column("updated_at", sa.DateTime(), nullable=False), 63 | sa.ForeignKeyConstraint(["tg_id"], ["users.tg_id"]), 64 | sa.PrimaryKeyConstraint("id"), 65 | sa.UniqueConstraint("payment_id", name="uq_transactions_payment_id"), 66 | ) 67 | 68 | op.execute( 69 | "INSERT INTO transactions_new SELECT id, tg_id, payment_id, subscription, " 70 | "CASE WHEN status = 'canceled' THEN 'failed' ELSE status END, " 71 | "created_at, updated_at FROM transactions" 72 | ) 73 | 74 | op.drop_table("transactions") 75 | op.rename_table("transactions_new", "transactions") 76 | # ### end Alembic commands ### 77 | -------------------------------------------------------------------------------- /app/bot/routers/profile/handler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from aiogram import F, Router 5 | from aiogram.fsm.context import FSMContext 6 | from aiogram.types import CallbackQuery 7 | from aiogram.utils.i18n import gettext as _ 8 | 9 | from app.bot.models import ClientData 10 | from app.bot.services import ServicesContainer 11 | from app.bot.utils.constants import PREVIOUS_CALLBACK_KEY 12 | from app.bot.utils.navigation import NavProfile 13 | from app.db.models import User 14 | 15 | from .keyboard import buy_subscription_keyboard, profile_keyboard 16 | 17 | logger = logging.getLogger(__name__) 18 | router = Router(name=__name__) 19 | 20 | 21 | async def prepare_message(user: User, client_data: ClientData | None) -> str: 22 | profile = _("profile:message:main").format(name=user.first_name, id=user.tg_id) 23 | 24 | if not client_data: 25 | subscription = _("profile:message:subscription_none") 26 | return profile + subscription 27 | 28 | subscription = _("profile:message:subscription").format(devices=client_data.max_devices) 29 | 30 | subscription += ( 31 | _("profile:message:subscription_expiry_time").format(expiry_time=client_data.expiry_time) 32 | if not client_data.has_subscription_expired 33 | else _("profile:message:subscription_expired") 34 | ) 35 | 36 | statistics = _("profile:message:statistics").format( 37 | total=client_data.traffic_used, 38 | up=client_data.traffic_up, 39 | down=client_data.traffic_down, 40 | ) 41 | 42 | return profile + subscription + statistics 43 | 44 | 45 | @router.callback_query(F.data == NavProfile.MAIN) 46 | async def callback_profile( 47 | callback: CallbackQuery, 48 | user: User, 49 | services: ServicesContainer, 50 | state: FSMContext, 51 | ) -> None: 52 | logger.info(f"User {user.tg_id} opened profile page.") 53 | await state.update_data({PREVIOUS_CALLBACK_KEY: NavProfile.MAIN}) 54 | 55 | client_data = None 56 | if user.server_id: 57 | client_data = await services.vpn.get_client_data(user) 58 | if not client_data: 59 | await services.notification.show_popup( 60 | callback=callback, 61 | text=_("subscription:popup:error_fetching_data"), 62 | ) 63 | return 64 | 65 | reply_markup = ( 66 | profile_keyboard() 67 | if client_data and not client_data.has_subscription_expired 68 | else buy_subscription_keyboard() 69 | ) 70 | await callback.message.edit_text( 71 | text=await prepare_message(user=user, client_data=client_data), 72 | reply_markup=reply_markup, 73 | ) 74 | 75 | 76 | @router.callback_query(F.data == NavProfile.SHOW_KEY) 77 | async def callback_show_key( 78 | callback: CallbackQuery, 79 | user: User, 80 | services: ServicesContainer, 81 | ) -> None: 82 | logger.info(f"User {user.tg_id} looked key.") 83 | key = await services.vpn.get_key(user) 84 | key_text = _("profile:message:key") 85 | message = await callback.message.answer(key_text.format(key=key, seconds_text=_("10 seconds"))) 86 | 87 | for seconds in range(9, 0, -1): 88 | seconds_text = _("1 second", "{} seconds", seconds).format(seconds) 89 | await asyncio.sleep(1) 90 | await message.edit_text(text=key_text.format(key=key, seconds_text=seconds_text)) 91 | await message.delete() 92 | -------------------------------------------------------------------------------- /app/bot/tasks/subscription_expiry.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime, timedelta, timezone 3 | 4 | from aiogram.utils.i18n import I18n 5 | from apscheduler.schedulers.asyncio import AsyncIOScheduler 6 | from redis.asyncio.client import Redis 7 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 8 | 9 | from app.bot.services import NotificationService, VPNService 10 | from app.db.models import User 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | async def notify_users_with_expiring_subscription( 16 | session_factory: async_sessionmaker, 17 | redis: Redis, 18 | i18n: I18n, 19 | vpn_service: VPNService, 20 | notification_service: NotificationService, 21 | ) -> None: 22 | session: AsyncSession 23 | async with session_factory() as session: 24 | users = await User.get_all(session=session) 25 | 26 | logger.info( 27 | f"[Background task] Starting subscription expiration check for {len(users)} users." 28 | ) 29 | 30 | for user in users: 31 | user_notified_key = f"user:notified:{user.tg_id}" 32 | 33 | # Check if user was recently notified 34 | if await redis.get(user_notified_key): 35 | continue 36 | 37 | client_data = await vpn_service.get_client_data(user) 38 | 39 | # Skip if no client data or subscription is unlimited 40 | if not client_data or client_data._expiry_time == -1: 41 | continue 42 | 43 | now = datetime.now(timezone.utc) 44 | expiry_datetime = datetime.fromtimestamp( 45 | client_data._expiry_time / 1000, timezone.utc 46 | ) 47 | time_left = expiry_datetime - now 48 | 49 | # Skip if not within the notification threshold 50 | if not (timedelta(0) < time_left <= timedelta(hours=24)): 51 | continue 52 | 53 | # BUG: The button and expiry_time will not be translated 54 | # (the translation logic needs to be changed outside the current context) 55 | await notification_service.notify_by_id( 56 | chat_id=user.tg_id, 57 | text=i18n.gettext( 58 | "task:message:subscription_expiry", 59 | locale=user.language_code, 60 | ).format( 61 | devices=client_data.max_devices, 62 | expiry_time=client_data.expiry_time, 63 | ), 64 | # reply_markup=keyboard_extend 65 | ) 66 | 67 | await redis.set(user_notified_key, "true", ex=timedelta(hours=24)) 68 | logger.info( 69 | f"[Background task] Sent expiry notification to user {user.tg_id}." 70 | ) 71 | logger.info("[Background task] Subscription check finished.") 72 | 73 | 74 | def start_scheduler( 75 | session_factory: async_sessionmaker, 76 | redis: Redis, 77 | i18n: I18n, 78 | vpn_service: VPNService, 79 | notification_service: NotificationService, 80 | ) -> None: 81 | scheduler = AsyncIOScheduler() 82 | scheduler.add_job( 83 | notify_users_with_expiring_subscription, 84 | "interval", 85 | minutes=15, 86 | args=[session_factory, redis, i18n, vpn_service, notification_service], 87 | next_run_time=datetime.now(tz=timezone.utc), 88 | ) 89 | scheduler.start() 90 | -------------------------------------------------------------------------------- /app/bot/routers/download/handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.types import CallbackQuery 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiohttp.web import HTTPFound, Request, Response 8 | 9 | from app.bot.models import ServicesContainer 10 | from app.bot.utils.constants import ( 11 | APP_ANDROID_SCHEME, 12 | APP_IOS_SCHEME, 13 | APP_WINDOWS_SCHEME, 14 | MAIN_MESSAGE_ID_KEY, 15 | PREVIOUS_CALLBACK_KEY, 16 | ) 17 | from app.bot.utils.navigation import NavDownload, NavMain 18 | from app.bot.utils.network import parse_redirect_url 19 | from app.config import Config 20 | from app.db.models import User 21 | 22 | from .keyboard import download_keyboard, platforms_keyboard 23 | 24 | logger = logging.getLogger(__name__) 25 | router = Router(name=__name__) 26 | 27 | 28 | async def redirect_to_connection(request: Request) -> Response: 29 | query_string = request.query_string 30 | 31 | if not query_string: 32 | return Response(status=400, reason="Missing query string.") 33 | 34 | params = parse_redirect_url(query_string) 35 | scheme = params.get("scheme") 36 | key = params.get("key") 37 | 38 | if not scheme or not key: 39 | raise Response(status=400, reason="Invalid parameters.") 40 | 41 | redirect_url = f"{scheme}{key}" # TODO: #namevpn 42 | if scheme in { 43 | APP_IOS_SCHEME, 44 | APP_ANDROID_SCHEME, 45 | APP_WINDOWS_SCHEME, 46 | }: 47 | raise HTTPFound(redirect_url) 48 | 49 | return Response(status=400, reason="Unsupported application.") 50 | 51 | 52 | @router.callback_query(F.data == NavDownload.MAIN) 53 | async def callback_download(callback: CallbackQuery, user: User, state: FSMContext) -> None: 54 | logger.info(f"User {user.tg_id} opened download apps page.") 55 | 56 | main_message_id = await state.get_value(MAIN_MESSAGE_ID_KEY) 57 | previous_callback = await state.get_value(PREVIOUS_CALLBACK_KEY) 58 | 59 | logger.debug("--------------------------------") 60 | logger.debug(f"callback.message.message_id: {callback.message.message_id}") 61 | logger.debug(f"main_message_id: {main_message_id}") 62 | logger.debug(f"previous_callback: {previous_callback}") 63 | logger.debug("--------------------------------") 64 | if callback.message.message_id != main_message_id: 65 | await state.update_data({PREVIOUS_CALLBACK_KEY: NavMain.MAIN_MENU}) 66 | previous_callback = NavMain.MAIN_MENU 67 | await callback.bot.edit_message_text( 68 | text=_("download:message:choose_platform"), 69 | chat_id=user.tg_id, 70 | message_id=main_message_id, 71 | reply_markup=platforms_keyboard(previous_callback), 72 | ) 73 | else: 74 | await callback.message.edit_text( 75 | text=_("download:message:choose_platform"), 76 | reply_markup=platforms_keyboard(previous_callback), 77 | ) 78 | 79 | 80 | @router.callback_query(F.data.startswith(NavDownload.PLATFORM)) 81 | async def callback_platform( 82 | callback: CallbackQuery, 83 | user: User, 84 | services: ServicesContainer, 85 | config: Config, 86 | ) -> None: 87 | logger.info(f"User {user.tg_id} selected platform: {callback.data}") 88 | key = await services.vpn.get_key(user) 89 | 90 | match callback.data: 91 | case NavDownload.PLATFORM_IOS: 92 | platform = _("download:message:platform_ios") 93 | case NavDownload.PLATFORM_ANDROID: 94 | platform = _("download:message:platform_android") 95 | case _: 96 | platform = _("download:message:platform_windows") 97 | 98 | await callback.message.edit_text( 99 | text=_("download:message:connect_to_vpn").format(platform=platform), 100 | reply_markup=download_keyboard(platform=callback.data, key=key, url=config.bot.DOMAIN), 101 | ) 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | .idea/ 161 | 162 | # MacOS 163 | .DS_Store 164 | 165 | # Database 166 | *.db 167 | *.sqlite3 168 | 169 | # Archive logs 170 | *.zip 171 | *.gz 172 | 173 | # Plans 174 | plans.json 175 | 176 | tmp_test/ -------------------------------------------------------------------------------- /app/db/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts. 5 | # Use forward slashes (/) also on windows to provide an os agnostic path 6 | script_location = app/db/migration 7 | 8 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 9 | # Uncomment the line below if you want the files to be prepended with date and time 10 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 11 | 12 | # sys.path path, will be prepended to sys.path if present. 13 | # defaults to the current working directory. 14 | prepend_sys_path = . 15 | 16 | # timezone to use when rendering the date within the migration file 17 | # as well as the filename. 18 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 19 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 20 | # string value is passed to ZoneInfo() 21 | # leave blank for localtime 22 | # timezone = 23 | 24 | # max length of characters to apply to the "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to migration/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:migration/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | # version_path_separator = newline 51 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 52 | 53 | # set to 'true' to search source files recursively 54 | # in each "version_locations" directory 55 | # new in Alembic version 1.10 56 | # recursive_version_locations = false 57 | 58 | # the output encoding used when revision files 59 | # are written from script.py.mako 60 | # output_encoding = utf-8 61 | 62 | sqlalchemy.url = driver://user:pass@localhost/dbname 63 | 64 | 65 | [post_write_hooks] 66 | # post_write_hooks defines scripts or Python functions that are run 67 | # on newly generated revision scripts. See the documentation for further 68 | # detail and examples 69 | 70 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 71 | # hooks = black 72 | # black.type = console_scripts 73 | # black.entrypoint = black 74 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 75 | 76 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 77 | # hooks = ruff 78 | # ruff.type = exec 79 | # ruff.executable = %(here)s/.venv/bin/ruff 80 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 81 | 82 | # Logging configuration 83 | [loggers] 84 | keys = root,sqlalchemy,alembic 85 | 86 | [handlers] 87 | keys = console 88 | 89 | [formatters] 90 | keys = generic 91 | 92 | [logger_root] 93 | level = WARNING 94 | handlers = console 95 | qualname = 96 | 97 | [logger_sqlalchemy] 98 | level = WARNING 99 | handlers = 100 | qualname = sqlalchemy.engine 101 | 102 | [logger_alembic] 103 | level = INFO 104 | handlers = 105 | qualname = alembic 106 | 107 | [handler_console] 108 | class = StreamHandler 109 | args = (sys.stderr,) 110 | level = NOTSET 111 | formatter = generic 112 | 113 | [formatter_generic] 114 | format = %(levelname)-5.5s [%(name)s] %(message)s 115 | datefmt = %H:%M:%S 116 | -------------------------------------------------------------------------------- /app/bot/utils/constants.py: -------------------------------------------------------------------------------- 1 | # region: Download 2 | APP_IOS_LINK = "https://apps.apple.com/ru/app/happ-proxy-utility-plus/id6746188973" 3 | APP_ANDROID_LINK = "https://play.google.com/store/apps/details?id=com.happproxy" 4 | APP_WINDOWS_LINK = ( 5 | "https://github.com/Happ-proxy/happ-desktop/releases/latest/download/setup-Happ.x86.exe" 6 | ) 7 | 8 | APP_IOS_SCHEME = "happ://add/" 9 | APP_ANDROID_SCHEME = "happ://add/" 10 | APP_WINDOWS_SCHEME = "happ://add/" 11 | 12 | # endregion 13 | 14 | # region: Keys 15 | MAIN_MESSAGE_ID_KEY = "main_message_id" 16 | PREVIOUS_CALLBACK_KEY = "previous_callback" 17 | 18 | INPUT_PROMOCODE_KEY = "input_promocode" 19 | 20 | SERVER_NAME_KEY = "server_name" 21 | SERVER_HOST_KEY = "server_host" 22 | SERVER_MAX_CLIENTS_KEY = "server_max_clients" 23 | 24 | NOTIFICATION_CHAT_IDS_KEY = "notification_chat_ids" 25 | NOTIFICATION_LAST_MESSAGE_IDS_KEY = "notification_last_message_ids" 26 | NOTIFICATION_MESSAGE_TEXT_KEY = "notification_message_text" 27 | NOTIFICATION_PRE_MESSAGE_TEXT_KEY = "notification_pre_message_text" 28 | # endregion 29 | 30 | # region: Webhook paths 31 | TELEGRAM_WEBHOOK = "/webhook" # Webhook path for Telegram bot updates 32 | CONNECTION_WEBHOOK = "/connection" # Webhook path for receiving connection requests 33 | CRYPTOMUS_WEBHOOK = "/cryptomus" # Webhook path for receiving Cryptomus payment notifications 34 | HELEKET_WEBHOOK = "/heleket" # Webhook path for receiving Heleket payment notifications 35 | YOOKASSA_WEBHOOK = "/yookassa" # Webhook path for receiving Yookassa payment notifications 36 | YOOMONEY_WEBHOOK = "/yoomoney" # Webhook path for receiving Yoomoney payment notifications 37 | # endregion 38 | 39 | # region: Notification tags 40 | BOT_STARTED_TAG = "#BotStarted" 41 | BOT_STOPPED_TAG = "#BotStopped" 42 | BACKUP_CREATED_TAG = "#BackupCreated" 43 | EVENT_PAYMENT_SUCCEEDED_TAG = "#EventPaymentSucceeded" 44 | EVENT_PAYMENT_CANCELED_TAG = "#EventPaymentCanceled" 45 | # endregion 46 | 47 | # region: I18n settings 48 | DEFAULT_LANGUAGE = "en" 49 | I18N_DOMAIN = "bot" 50 | # endregion 51 | 52 | # region: Constants 53 | UNLIMITED = "∞" 54 | DB_FORMAT = "sqlite3" 55 | LOG_ZIP_ARCHIVE_FORMAT = "zip" 56 | LOG_GZ_ARCHIVE_FORMAT = "gz" 57 | MESSAGE_EFFECT_IDS = { 58 | "🔥": "5104841245755180586", 59 | "👍": "5107584321108051014", 60 | "👎": "5104858069142078462", 61 | "❤️": "5044134455711629726", 62 | "🎉": "5046509860389126442", 63 | "💩": "5046589136895476101", 64 | } 65 | # endregion 66 | 67 | # region: Enums 68 | from enum import Enum 69 | from typing import Any, Optional 70 | 71 | 72 | class TransactionStatus(Enum): 73 | PENDING = "pending" 74 | COMPLETED = "completed" 75 | CANCELED = "canceled" 76 | REFUNDED = "refunded" 77 | 78 | 79 | class Currency(Enum): 80 | RUB = ("RUB", "₽") 81 | USD = ("USD", "$") 82 | XTR = ("XTR", "★") 83 | 84 | @property 85 | def symbol(self) -> str: 86 | return self.value[1] 87 | 88 | @property 89 | def code(self) -> str: 90 | return self.value[0] 91 | 92 | @classmethod 93 | def from_code(cls, code: str) -> "Currency": 94 | code = code.upper() 95 | for currency in cls: 96 | if currency.code == code: 97 | return currency 98 | raise ValueError(f"Invalid currency code: {code}") 99 | 100 | 101 | class ReferrerRewardType(Enum): 102 | DAYS = "days" 103 | MONEY = "money" # TODO: consider using currencies instead? depends on balance implementation 104 | 105 | @classmethod 106 | def from_str(cls, value: str) -> Optional["ReferrerRewardType"]: 107 | try: 108 | return cls[value.upper()] 109 | except KeyError: 110 | try: 111 | return cls(value.lower()) 112 | except ValueError: 113 | return None 114 | 115 | 116 | class ReferrerRewardLevel(Enum): 117 | FIRST_LEVEL = 1 118 | SECOND_LEVEL = 2 119 | 120 | @classmethod 121 | def from_value(cls, value: Any) -> Optional["ReferrerRewardLevel"]: 122 | try: 123 | return cls(int(value)) 124 | except (ValueError, KeyError): 125 | return None 126 | 127 | 128 | # endregion 129 | -------------------------------------------------------------------------------- /app/bot/routers/subscription/payment_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot, F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.fsm.state import State, StatesGroup 6 | from aiogram.types import CallbackQuery, Message, PreCheckoutQuery 7 | from aiogram.utils.i18n import gettext as _ 8 | from sqlalchemy.ext.asyncio import AsyncSession 9 | 10 | from app.bot.filters.is_dev import IsDev 11 | from app.bot.models import ServicesContainer, SubscriptionData 12 | from app.bot.payment_gateways import GatewayFactory 13 | from app.bot.utils.constants import TransactionStatus 14 | from app.bot.utils.formatting import format_subscription_period 15 | from app.bot.utils.navigation import NavSubscription 16 | from app.db.models import Transaction, User 17 | 18 | from .keyboard import pay_keyboard 19 | 20 | logger = logging.getLogger(__name__) 21 | router = Router(name=__name__) 22 | 23 | 24 | class PaymentState(StatesGroup): 25 | processing = State() 26 | 27 | 28 | @router.callback_query(SubscriptionData.filter(F.state.startswith(NavSubscription.PAY))) 29 | async def callback_payment_method_selected( 30 | callback: CallbackQuery, 31 | user: User, 32 | callback_data: SubscriptionData, 33 | services: ServicesContainer, 34 | bot: Bot, 35 | gateway_factory: GatewayFactory, 36 | state: FSMContext, 37 | ) -> None: 38 | if await state.get_state() == PaymentState.processing: 39 | logger.debug(f"User {user.tg_id} is already processing payment.") 40 | return 41 | 42 | await state.set_state(PaymentState.processing) 43 | 44 | try: 45 | method = callback_data.state 46 | devices = callback_data.devices 47 | duration = callback_data.duration 48 | logger.info(f"User {user.tg_id} selected payment method: {method}") 49 | logger.info(f"User {user.tg_id} selected {devices} devices and {duration} days.") 50 | gateway = gateway_factory.get_gateway(method) 51 | plan = services.plan.get_plan(devices) 52 | price = plan.get_price(currency=gateway.currency, duration=duration) 53 | callback_data.price = price 54 | 55 | pay_url = await gateway.create_payment(callback_data) 56 | 57 | if callback_data.is_extend: 58 | text = _("payment:message:order_extend") 59 | elif callback_data.is_change: 60 | text = _("payment:message:order_change") 61 | else: 62 | text = _("payment:message:order") 63 | 64 | await callback.message.edit_text( 65 | text=text.format( 66 | devices=devices, 67 | duration=format_subscription_period(duration), 68 | price=price, 69 | currency=gateway.currency.symbol, 70 | ), 71 | reply_markup=pay_keyboard(pay_url=pay_url, callback_data=callback_data), 72 | ) 73 | except Exception as exception: 74 | logger.error(f"Error processing payment: {exception}") 75 | await services.notification.show_popup(callback=callback, text=_("payment:popup:error")) 76 | finally: 77 | await state.set_state(None) 78 | 79 | 80 | @router.pre_checkout_query() 81 | async def pre_checkout_handler(pre_checkout_query: PreCheckoutQuery, user: User) -> None: 82 | logger.info(f"Pre-checkout query received from user {user.tg_id}") 83 | if pre_checkout_query.invoice_payload: 84 | await pre_checkout_query.answer(ok=True) 85 | else: 86 | await pre_checkout_query.answer(ok=False) 87 | 88 | 89 | @router.message(F.successful_payment) 90 | async def successful_payment( 91 | message: Message, 92 | user: User, 93 | session: AsyncSession, 94 | bot: Bot, 95 | gateway_factory: GatewayFactory, 96 | ) -> None: 97 | if await IsDev()(user_id=user.tg_id): 98 | await bot.refund_star_payment( 99 | user_id=user.tg_id, 100 | telegram_payment_charge_id=message.successful_payment.telegram_payment_charge_id, 101 | ) 102 | 103 | data = SubscriptionData.unpack(message.successful_payment.invoice_payload) 104 | transaction = await Transaction.create( 105 | session=session, 106 | tg_id=user.tg_id, 107 | subscription=data.pack(), 108 | payment_id=message.successful_payment.telegram_payment_charge_id, 109 | status=TransactionStatus.COMPLETED, 110 | ) 111 | 112 | gateway = gateway_factory.get_gateway(NavSubscription.PAY_TELEGRAM_STARS) 113 | await gateway.handle_payment_succeeded(payment_id=transaction.payment_id) 114 | -------------------------------------------------------------------------------- /app/bot/services/invite_stats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Dict, Optional 5 | 6 | if TYPE_CHECKING: 7 | from app.bot.services import PaymentStatsService 8 | 9 | from sqlalchemy import func, select 10 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 11 | 12 | from app.bot.models import InviteStats 13 | from app.bot.utils.constants import TransactionStatus 14 | from app.db.models import Transaction, User 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | 19 | class InviteStatsService: 20 | """Service for collecting and analyzing invite link statistics.""" 21 | 22 | def __init__( 23 | self, 24 | session_factory: async_sessionmaker, 25 | payment_stats_service: PaymentStatsService, 26 | ) -> None: 27 | """ 28 | Initialize InviteStatsService. 29 | 30 | Args: 31 | session_factory: SQLAlchemy async session maker 32 | payment_stats_service: Instance of PaymentStatsService for payment data retrieval 33 | """ 34 | self.session_factory = session_factory 35 | self.payment_stats = payment_stats_service 36 | logger.debug("InviteStatsService initialized") 37 | 38 | async def get_detailed_stats( 39 | self, 40 | invite_name: str, 41 | session: Optional[AsyncSession] = None, 42 | payment_method_currencies: Optional[Dict[str, str]] = None, 43 | ) -> InviteStats: 44 | """ 45 | Get detailed statistics for a specific invite link. 46 | 47 | Args: 48 | invite_name: Name of the invite link 49 | session: Optional existing database session 50 | payment_method_currencies: Dictionary mapping payment methods to currency codes 51 | 52 | Returns: 53 | InviteStats object containing detailed statistics 54 | """ 55 | 56 | async def _get_stats(s: AsyncSession) -> InviteStats: 57 | # Get users who came from this invite 58 | users_query = await s.execute( 59 | select(User).where(User.source_invite_name == invite_name) 60 | ) 61 | users = users_query.scalars().all() 62 | 63 | if not users: 64 | return InviteStats() 65 | 66 | user_ids = [user.tg_id for user in users] 67 | trial_users_count = sum(1 for user in users if user.is_trial_used) 68 | 69 | # Get users with transactions 70 | tx_users_query = await s.execute( 71 | select(Transaction.tg_id) 72 | .where( 73 | Transaction.tg_id.in_(user_ids), 74 | Transaction.status == TransactionStatus.COMPLETED, 75 | ) 76 | .distinct() 77 | ) 78 | paid_users = set(user_id for user_id, in tx_users_query) 79 | 80 | # Get users with more than one transaction 81 | repeat_users_query = await s.execute( 82 | select(Transaction.tg_id) 83 | .where( 84 | Transaction.tg_id.in_(user_ids), 85 | Transaction.status == TransactionStatus.COMPLETED, 86 | ) 87 | .group_by(Transaction.tg_id) 88 | .having(func.count(Transaction.id) > 1) 89 | ) 90 | repeat_customers = set(user_id for user_id, in repeat_users_query) 91 | 92 | # Get revenue totals from payment stats service 93 | all_revenue: Dict[str, float] = {} 94 | for user_id in user_ids: 95 | user_revenue = await self.payment_stats.get_user_payment_stats( 96 | user_id=user_id, session=s, payment_method_currencies=payment_method_currencies 97 | ) 98 | for currency, amount in user_revenue.items(): 99 | if currency not in all_revenue: 100 | all_revenue[currency] = 0 101 | all_revenue[currency] += amount 102 | 103 | return InviteStats( 104 | revenue=all_revenue, 105 | users_count=len(users), 106 | trial_users_count=trial_users_count, 107 | paid_users_count=len(paid_users), 108 | repeat_customers_count=len(repeat_customers), 109 | ) 110 | 111 | if session: 112 | return await _get_stats(session) 113 | async with self.session_factory() as session: 114 | return await _get_stats(session) 115 | -------------------------------------------------------------------------------- /app/db/models/transaction.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Any, Self 4 | 5 | from sqlalchemy import * 6 | from sqlalchemy.exc import IntegrityError 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload 9 | from sqlalchemy.types import Enum 10 | 11 | from app.bot.utils.constants import TransactionStatus 12 | 13 | from . import Base 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class Transaction(Base): 19 | """ 20 | Represents a transaction in the database. 21 | 22 | Attributes: 23 | id (int): Unique identifier for the transaction (primary key). 24 | tg_id (int): Telegram user ID associated with the transaction. 25 | payment_id (str): Unique payment identifier for the transaction. 26 | subscription (str): Name of the subscription plan associated with the transaction. 27 | status (TransactionStatus): Current status of the transaction (e.g., pending, completed). 28 | created_at (datetime): Timestamp when the transaction was created. 29 | updated_at (datetime): Timestamp when the transaction was last updated. 30 | user (User): Related user object. 31 | """ 32 | 33 | __tablename__ = "transactions" 34 | 35 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 36 | tg_id: Mapped[int] = mapped_column(ForeignKey("users.tg_id"), nullable=False) 37 | payment_id: Mapped[str] = mapped_column(String(length=64), unique=True, nullable=False) 38 | subscription: Mapped[str] = mapped_column(String(length=255), nullable=False) 39 | status: Mapped[TransactionStatus] = mapped_column( 40 | Enum(TransactionStatus, values_callable=lambda obj: [e.value for e in obj]), 41 | nullable=False, 42 | ) 43 | created_at: Mapped[datetime] = mapped_column(default=func.now(), nullable=False) 44 | updated_at: Mapped[datetime] = mapped_column( 45 | default=func.now(), 46 | onupdate=func.now(), 47 | nullable=False, 48 | ) 49 | user: Mapped["User"] = relationship("User", back_populates="transactions") # type: ignore 50 | 51 | def __repr__(self) -> str: 52 | return ( 53 | f"" 56 | ) 57 | 58 | @classmethod 59 | async def get_by_id(cls, session: AsyncSession, payment_id: str) -> Self | None: 60 | filter = [Transaction.payment_id == payment_id] 61 | query = await session.execute( 62 | select(Transaction).options(selectinload(Transaction.user)).where(*filter) 63 | ) 64 | return query.scalar_one_or_none() 65 | 66 | @classmethod 67 | async def get_by_user(cls, session: AsyncSession, tg_id: int) -> list[Self]: 68 | filter = [Transaction.tg_id == tg_id] 69 | query = await session.execute( 70 | select(Transaction).options(selectinload(Transaction.user)).where(*filter) 71 | ) 72 | return query.scalars().all() 73 | 74 | @classmethod 75 | async def create(cls, session: AsyncSession, payment_id: str, **kwargs: Any) -> Self | None: 76 | transaction = await Transaction.get_by_id(session=session, payment_id=payment_id) 77 | 78 | if transaction: 79 | logger.warning(f"Transaction {payment_id} already exists.") 80 | return None 81 | 82 | transaction = Transaction(payment_id=payment_id, **kwargs) 83 | session.add(transaction) 84 | 85 | try: 86 | await session.commit() 87 | logger.info(f"Transaction {payment_id} created.") 88 | return transaction 89 | except IntegrityError as exception: 90 | await session.rollback() 91 | logger.error(f"Error occurred while creating transaction {payment_id}: {exception}") 92 | return None 93 | 94 | @classmethod 95 | async def update(cls, session: AsyncSession, payment_id: str, **kwargs: Any) -> Self | None: 96 | transaction = await Transaction.get_by_id(session=session, payment_id=payment_id) 97 | 98 | if transaction: 99 | filter = [Transaction.id == transaction.id] 100 | await session.execute(update(Transaction).where(*filter).values(**kwargs)) 101 | await session.commit() 102 | logger.info(f"Transaction {payment_id} updated.") 103 | return transaction 104 | 105 | logger.warning(f"Transaction {payment_id} not found for update.") 106 | return None 107 | -------------------------------------------------------------------------------- /app/db/migration/versions/8dd30c5fd47d_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 8dd30c5fd47d 4 | Revises: 5 | Create Date: 2025-01-24 21:10:36.554531 6 | 7 | """ 8 | 9 | from typing import Sequence, Union 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "8dd30c5fd47d" 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "servers", 25 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 26 | sa.Column("name", sa.String(length=255), nullable=False), 27 | sa.Column("host", sa.String(length=255), nullable=False), 28 | sa.Column("subscription", sa.String(length=255), nullable=False), 29 | sa.Column("max_clients", sa.Integer(), nullable=False), 30 | sa.Column("current_clients", sa.Integer(), nullable=False), 31 | sa.Column("location", sa.String(length=32), nullable=True), 32 | sa.Column("online", sa.Boolean(), nullable=False), 33 | sa.PrimaryKeyConstraint("id", name=op.f("pk_servers")), 34 | sa.UniqueConstraint("name", name=op.f("uq_servers_name")), 35 | ) 36 | op.create_table( 37 | "users", 38 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 39 | sa.Column("tg_id", sa.Integer(), nullable=False), 40 | sa.Column("vpn_id", sa.String(length=36), nullable=False), 41 | sa.Column("server_id", sa.Integer(), nullable=True), 42 | sa.Column("first_name", sa.String(length=32), nullable=False), 43 | sa.Column("username", sa.String(length=32), nullable=True), 44 | sa.Column("created_at", sa.DateTime(), nullable=False), 45 | sa.ForeignKeyConstraint( 46 | ["server_id"], 47 | ["servers.id"], 48 | name=op.f("fk_users_server_id_servers"), 49 | ondelete="SET NULL", 50 | ), 51 | sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), 52 | sa.UniqueConstraint("tg_id", name=op.f("uq_users_tg_id")), 53 | sa.UniqueConstraint("vpn_id", name=op.f("uq_users_vpn_id")), 54 | ) 55 | op.create_table( 56 | "promocodes", 57 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 58 | sa.Column("code", sa.String(length=8), nullable=False), 59 | sa.Column("duration", sa.Integer(), nullable=False), 60 | sa.Column("is_activated", sa.Boolean(), nullable=False), 61 | sa.Column("activated_by", sa.Integer(), nullable=True), 62 | sa.Column("created_at", sa.DateTime(), nullable=False), 63 | sa.ForeignKeyConstraint( 64 | ["activated_by"], 65 | ["users.tg_id"], 66 | name=op.f( 67 | "fk_promocodes_activated_by_users", 68 | ), 69 | ), 70 | sa.PrimaryKeyConstraint("id", name=op.f("pk_promocodes")), 71 | sa.UniqueConstraint("code", name=op.f("uq_promocodes_code")), 72 | ) 73 | op.create_table( 74 | "transactions", 75 | sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), 76 | sa.Column("tg_id", sa.Integer(), nullable=False), 77 | sa.Column("payment_id", sa.String(length=64), nullable=False), 78 | sa.Column("subscription", sa.String(length=255), nullable=False), 79 | sa.Column( 80 | "status", 81 | sa.Enum( 82 | "pending", 83 | "completed", 84 | "failed", 85 | "refunded", 86 | name="transactionstatus", 87 | ), 88 | nullable=False, 89 | ), 90 | sa.Column("created_at", sa.DateTime(), nullable=False), 91 | sa.Column("updated_at", sa.DateTime(), nullable=False), 92 | sa.ForeignKeyConstraint( 93 | ["tg_id"], 94 | ["users.tg_id"], 95 | name=op.f( 96 | "fk_transactions_tg_id_users", 97 | ), 98 | ), 99 | sa.PrimaryKeyConstraint("id", name=op.f("pk_transactions")), 100 | sa.UniqueConstraint( 101 | "payment_id", 102 | name=op.f("uq_transactions_payment_id"), 103 | ), 104 | ) 105 | # ### end Alembic commands ### 106 | 107 | 108 | def downgrade() -> None: 109 | # ### commands auto generated by Alembic - please adjust! ### 110 | op.drop_table("transactions") 111 | op.drop_table("promocodes") 112 | op.drop_table("users") 113 | op.drop_table("servers") 114 | # ### end Alembic commands ### 115 | -------------------------------------------------------------------------------- /app/db/models/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Self 3 | 4 | from sqlalchemy import * 5 | from sqlalchemy.exc import IntegrityError 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.ext.hybrid import hybrid_property 8 | from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload 9 | 10 | from . import Base 11 | from .user import User 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class Server(Base): 17 | """ 18 | Represents a VPN server in the database. 19 | 20 | Attributes: 21 | id (int): Unique identifier for the server. 22 | name (str): Unique server name. 23 | host (str): Server host address or IP. 24 | max_clients (int): Maximum allowed number of clients. 25 | location (str | None): Server location if available. 26 | online (bool): Indicates whether the server is online. 27 | users (list[User]): List of users associated with the server. 28 | """ 29 | 30 | __tablename__ = "servers" 31 | 32 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 33 | name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) 34 | host: Mapped[str] = mapped_column(String(255), nullable=False) 35 | max_clients: Mapped[int] = mapped_column(Integer, nullable=False) 36 | location: Mapped[str | None] = mapped_column(String(32), nullable=True) 37 | online: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) 38 | users: Mapped[list["User"]] = relationship("User", back_populates="server") # type: ignore 39 | 40 | @hybrid_property 41 | def current_clients(self) -> int: 42 | return len(self.users) 43 | 44 | @current_clients.expression 45 | def current_clients(cls) -> Select: 46 | return ( 47 | select(func.count(User.id)).where(User.server_id == Server.id).label("current_clients") 48 | ) 49 | 50 | def __repr__(self) -> str: 51 | return ( 52 | f"" 54 | ) 55 | 56 | @classmethod 57 | async def get_by_id(cls, session: AsyncSession, id: int) -> Self | None: 58 | filter = [Server.id == id] 59 | query = await session.execute( 60 | select(Server).options(selectinload(Server.users)).where(*filter) 61 | ) 62 | return query.scalar_one_or_none() 63 | 64 | @classmethod 65 | async def get_by_name(cls, session: AsyncSession, name: str) -> Self | None: 66 | filter = [Server.name == name] 67 | query = await session.execute( 68 | select(Server).options(selectinload(Server.users)).where(*filter) 69 | ) 70 | return query.scalar_one_or_none() 71 | 72 | @classmethod 73 | async def get_all(cls, session: AsyncSession) -> list[Self]: 74 | query = await session.execute(select(Server).options(selectinload(Server.users))) 75 | return query.scalars().all() 76 | 77 | @classmethod 78 | async def create(cls, session: AsyncSession, name: str, **kwargs: Any) -> Self | None: 79 | server = await Server.get_by_name(session=session, name=name) 80 | 81 | if server: 82 | logger.warning(f"Server {name} already exists.") 83 | return None 84 | 85 | server = Server(name=name, **kwargs) 86 | session.add(server) 87 | 88 | try: 89 | await session.commit() 90 | logger.info(f"Server {name} created.") 91 | return server 92 | except IntegrityError as exception: 93 | await session.rollback() 94 | logger.error(f"Error occurred while creating server {name}: {exception}") 95 | return None 96 | 97 | @classmethod 98 | async def update(cls, session: AsyncSession, name: str, **kwargs: Any) -> Self | None: 99 | server = await Server.get_by_name(session=session, name=name) 100 | 101 | if server: 102 | filter = [Server.id == server.id] 103 | await session.execute(update(Server).where(*filter).values(**kwargs)) 104 | await session.commit() 105 | logger.debug(f"Server {name} updated.") 106 | return server 107 | 108 | logger.warning(f"Server {name} not found for update.") 109 | return None 110 | 111 | @classmethod 112 | async def delete(cls, session: AsyncSession, name: str) -> bool: 113 | server = await Server.get_by_name(session=session, name=name) 114 | 115 | if server: 116 | await session.delete(server) 117 | await session.commit() 118 | logger.info(f"Server {name} deleted.") 119 | return True 120 | 121 | logger.warning(f"Server {name} not found for deletion.") 122 | return False 123 | -------------------------------------------------------------------------------- /scripts/manage_translations.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # manage_translations.sh 4 | # ───────────────────────────────────────────────────────────────────────────── 5 | # Script for managing translations using Babel 6 | # 7 | # Options: 8 | # --test init - Create a test project 9 | # --test merge - Update translations in the test project 10 | # --merge - Update translations in the production project 11 | # --update - Extract and compile translations 12 | # --help - Show help 13 | # ───────────────────────────────────────────────────────────────────────────── 14 | 15 | set -e 16 | 17 | DOMAIN="bot" 18 | MAIN_TRANSLATIONS_DIR="app/locales" 19 | BACKUP_TRANSLATIONS_DIR="app/locales/backup" 20 | LOCALES_DIR="app/locales" 21 | MESSAGES_POT="$LOCALES_DIR/$DOMAIN.pot" 22 | PROJECT_NAME="$DOMAIN" 23 | VERSION="0.1" 24 | COPYRIGHT_HOLDER="snoups" 25 | INPUT_DIR="." 26 | 27 | enable_test_mode() { 28 | echo "🧪 Test mode enabled." 29 | TMP_TEST_DIR="tmp_test" 30 | MAIN_TRANSLATIONS_DIR="$TMP_TEST_DIR/main_project/locales" 31 | BACKUP_TRANSLATIONS_DIR="$TMP_TEST_DIR/backup_project/locales" 32 | } 33 | 34 | test_init() { 35 | enable_test_mode 36 | echo "🔨 Creating mock structure..." 37 | mkdir -p "$MAIN_TRANSLATIONS_DIR/ru/LC_MESSAGES" "$BACKUP_TRANSLATIONS_DIR/ru/LC_MESSAGES" 38 | 39 | cat > "$MAIN_TRANSLATIONS_DIR/ru/LC_MESSAGES/bot.po" < "$BACKUP_TRANSLATIONS_DIR/ru/LC_MESSAGES/bot.po" < None: 45 | super().doRollover() 46 | 47 | timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") 48 | dir_name = os.path.dirname(self.baseFilename) 49 | archive_name = os.path.join(dir_name, f"{timestamp}.{self.archive_format}") 50 | 51 | self._archive_log_file(archive_name) 52 | self._remove_old_logs() 53 | 54 | def _archive_log_file(self, archive_name: str) -> None: 55 | logger.info(f"Archiving {self.baseFilename} to {archive_name}") 56 | if os.path.exists(self.baseFilename): 57 | if self.archive_format == LOG_ZIP_ARCHIVE_FORMAT: 58 | self._archive_to_zip(archive_name) 59 | elif self.archive_format == LOG_GZ_ARCHIVE_FORMAT: 60 | self._archive_to_gz(archive_name) 61 | else: 62 | logger.warning(f"Log file {self.baseFilename} does not exist, skipping archive.") 63 | 64 | def _archive_to_zip(self, archive_name: str) -> None: 65 | log = self.getFilesToDelete()[0] 66 | new_log_name = self._get_log_filename(archive_name) 67 | with zipfile.ZipFile(archive_name, "w", zipfile.ZIP_DEFLATED) as archive: 68 | archive.write(filename=log, arcname=new_log_name) 69 | 70 | def _archive_to_gz(self, archive_name: str) -> None: 71 | log = self.getFilesToDelete()[0] 72 | new_log_name = self._get_log_filename(archive_name) 73 | with tarfile.open(archive_name, "w:gz") as archive: 74 | archive.add(name=log, arcname=new_log_name) 75 | 76 | def _get_log_filename(self, archive_name: str) -> str: 77 | return os.path.splitext(os.path.basename(archive_name))[0] + ".log" 78 | 79 | def _remove_old_logs(self) -> None: 80 | files_to_delete = self.getFilesToDelete() 81 | logger.debug(f"Removing old log files: {files_to_delete}") 82 | for file in files_to_delete: 83 | if os.path.exists(file): 84 | try: 85 | os.remove(file) 86 | logger.debug(f"Successfully deleted old log file: {file}") 87 | except Exception as exception: 88 | logger.error(f"Error deleting {file}: {exception}") 89 | 90 | 91 | def setup_logging(config: LoggingConfig) -> None: 92 | os.makedirs(LOG_DIR, exist_ok=True) 93 | log_file = os.path.join(LOG_DIR, LOG_FILENAME) 94 | 95 | logging.basicConfig( 96 | level=getattr(logging, config.LEVEL.upper(), logging.INFO), 97 | format=config.FORMAT, 98 | handlers=[ 99 | ArchiveRotatingFileHandler( 100 | filename=log_file, 101 | when=LOG_WHEN, 102 | interval=LOG_INTERVAL, 103 | encoding=LOG_ENCODING, 104 | archive_format=config.ARCHIVE_FORMAT, 105 | ), 106 | logging.StreamHandler(), 107 | ], 108 | ) 109 | 110 | for record in memory_handler.buffer: 111 | logger.handle(record) 112 | 113 | logger.debug( 114 | f"Logging configuration: level={config.LEVEL}, " 115 | f"format={config.FORMAT}, archive_format={config.ARCHIVE_FORMAT}" 116 | ) 117 | 118 | # Suppresses logs to avoid unnecessary output 119 | aiogram_logger = logging.getLogger("aiogram.event") 120 | aiogram_logger.setLevel(logging.CRITICAL) 121 | 122 | aiosqlite_logger = logging.getLogger("aiosqlite") 123 | aiosqlite_logger.setLevel(logging.INFO) 124 | 125 | httpcore_logger = logging.getLogger("httpcore") 126 | httpcore_logger.setLevel(logging.INFO) 127 | 128 | aiohttp_logger = logging.getLogger("aiohttp") 129 | aiohttp_logger.setLevel(logging.WARNING) 130 | 131 | httpx_logger = logging.getLogger("httpx") 132 | httpx_logger.setLevel(logging.WARNING) 133 | 134 | urllib_logger = logging.getLogger("urllib3") 135 | urllib_logger.setLevel(logging.WARNING) 136 | 137 | apscheduler_logger = logging.getLogger("apscheduler") 138 | apscheduler_logger.setLevel(logging.WARNING) 139 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/yookassa.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from aiogram import Bot 4 | from aiogram.fsm.storage.redis import RedisStorage 5 | from aiogram.utils.i18n import I18n 6 | from aiogram.utils.i18n import gettext as _ 7 | from aiogram.utils.i18n import lazy_gettext as __ 8 | from aiohttp.web import Application, Request, Response 9 | from sqlalchemy.ext.asyncio import async_sessionmaker 10 | from yookassa import Configuration, Payment 11 | from yookassa.domain.common import SecurityHelper 12 | from yookassa.domain.common.confirmation_type import ConfirmationType 13 | from yookassa.domain.models.receipt import Receipt, ReceiptItem 14 | from yookassa.domain.notification import ( 15 | WebhookNotificationEventType, 16 | WebhookNotificationFactory, 17 | ) 18 | from yookassa.domain.request.payment_request import PaymentRequest 19 | 20 | from app.bot.models import ServicesContainer, SubscriptionData 21 | from app.bot.payment_gateways import PaymentGateway 22 | from app.bot.utils.constants import YOOKASSA_WEBHOOK, Currency, TransactionStatus 23 | from app.bot.utils.formatting import format_device_count, format_subscription_period 24 | from app.bot.utils.navigation import NavSubscription 25 | from app.config import Config 26 | from app.db.models import Transaction 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class Yookassa(PaymentGateway): 32 | name = "" 33 | currency = Currency.RUB 34 | callback = NavSubscription.PAY_YOOKASSA 35 | 36 | def __init__( 37 | self, 38 | app: Application, 39 | config: Config, 40 | session: async_sessionmaker, 41 | storage: RedisStorage, 42 | bot: Bot, 43 | i18n: I18n, 44 | services: ServicesContainer, 45 | ) -> None: 46 | self.name = __("payment:gateway:yookassa") 47 | self.app = app 48 | self.config = config 49 | self.session = session 50 | self.storage = storage 51 | self.bot = bot 52 | self.i18n = i18n 53 | self.services = services 54 | 55 | Configuration.configure(self.config.yookassa.SHOP_ID, self.config.yookassa.TOKEN) 56 | self.app.router.add_post(YOOKASSA_WEBHOOK, self.webhook_handler) 57 | logger.info("YooKassa payment gateway initialized.") 58 | 59 | async def create_payment(self, data: SubscriptionData) -> str: 60 | bot_username = (await self.bot.get_me()).username 61 | redirect_url = f"https://t.me/{bot_username}" 62 | 63 | description = _("payment:invoice:description").format( 64 | devices=format_device_count(data.devices), 65 | duration=format_subscription_period(data.duration), 66 | ) 67 | 68 | price = str(data.price) 69 | 70 | receipt = Receipt( 71 | customer={"email": self.config.shop.EMAIL}, 72 | items=[ 73 | ReceiptItem( 74 | description=description, 75 | quantity=1, 76 | amount={"value": price, "currency": self.currency.code}, 77 | vat_code=1, 78 | ) 79 | ], 80 | ) 81 | 82 | request = PaymentRequest( 83 | amount={"value": price, "currency": self.currency.code}, 84 | confirmation={"type": ConfirmationType.REDIRECT, "return_url": redirect_url}, 85 | capture=True, 86 | save_payment_method=False, 87 | description=description, 88 | receipt=receipt, 89 | ) 90 | 91 | response = Payment.create(request) 92 | 93 | async with self.session() as session: 94 | await Transaction.create( 95 | session=session, 96 | tg_id=data.user_id, 97 | subscription=data.pack(), 98 | payment_id=response.id, 99 | status=TransactionStatus.PENDING, 100 | ) 101 | 102 | pay_url = response.confirmation["confirmation_url"] 103 | logger.info(f"Payment link created for user {data.user_id}: {pay_url}") 104 | return pay_url 105 | 106 | async def handle_payment_succeeded(self, payment_id: str) -> None: 107 | await self._on_payment_succeeded(payment_id) 108 | 109 | async def handle_payment_canceled(self, payment_id: str) -> None: 110 | await self._on_payment_canceled(payment_id) 111 | 112 | async def webhook_handler(self, request: Request) -> Response: 113 | ip = request.headers.get("X-Forwarded-For", request.remote) 114 | 115 | if not SecurityHelper().is_ip_trusted(ip): 116 | return Response(status=403) 117 | 118 | try: 119 | event_json = await request.json() 120 | notification_object = WebhookNotificationFactory().create(event_json) 121 | response_object = notification_object.object 122 | payment_id = response_object.id 123 | 124 | match notification_object.event: 125 | case WebhookNotificationEventType.PAYMENT_SUCCEEDED: 126 | await self.handle_payment_succeeded(payment_id) 127 | return Response(status=200) 128 | 129 | case WebhookNotificationEventType.PAYMENT_CANCELED: 130 | await self.handle_payment_canceled(payment_id) 131 | return Response(status=200) 132 | 133 | case _: 134 | return Response(status=400) 135 | 136 | except Exception as exception: 137 | logger.exception(f"Error processing YooKassa webhook: {exception}") 138 | return Response(status=400) 139 | -------------------------------------------------------------------------------- /app/db/models/promocode.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime 3 | from typing import Self 4 | 5 | from sqlalchemy import * 6 | from sqlalchemy.exc import IntegrityError 7 | from sqlalchemy.ext.asyncio import AsyncSession 8 | from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload 9 | 10 | from app.bot.utils.misc import generate_code 11 | 12 | from . import Base 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | class Promocode(Base): 18 | """ 19 | Represents a promocode entity in the database. 20 | 21 | Attributes: 22 | id (int): Unique identifier (primary key) 23 | code (str): Unique promocode value (8 characters max) 24 | duration (int): Associated subscription duration in days 25 | is_activated (bool): Flag indicating activation status 26 | activated_by (int | None): Telegram ID of activating user 27 | created_at (datetime): Timestamp of creation 28 | activated_user (User | None): Relationship to User model 29 | """ 30 | 31 | __tablename__ = "promocodes" 32 | 33 | id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 34 | code: Mapped[str] = mapped_column(String(length=32), unique=True, nullable=False) 35 | duration: Mapped[int] = mapped_column(nullable=False) 36 | is_activated: Mapped[bool] = mapped_column(default=False, nullable=False) 37 | activated_by: Mapped[int | None] = mapped_column(ForeignKey("users.tg_id"), nullable=True) 38 | created_at: Mapped[datetime] = mapped_column(default=func.now(), nullable=False) 39 | activated_user: Mapped["User | None"] = relationship( # type: ignore 40 | "User", back_populates="activated_promocodes" 41 | ) 42 | 43 | def __repr__(self) -> str: 44 | return ( 45 | f"" 48 | ) 49 | 50 | @classmethod 51 | async def get(cls, session: AsyncSession, code: str) -> Self | None: 52 | filter = [Promocode.code == code] 53 | query = await session.execute( 54 | select(Promocode).options(selectinload(Promocode.activated_user)).where(*filter) 55 | ) 56 | return query.scalar_one_or_none() 57 | 58 | @classmethod 59 | async def create(cls, session: AsyncSession, **kwargs: Any) -> Self | None: 60 | while True: 61 | code = generate_code() 62 | promocode = await Promocode.get(session=session, code=code) 63 | if not promocode: 64 | break 65 | 66 | promocode = Promocode(code=code, **kwargs) 67 | session.add(promocode) 68 | 69 | try: 70 | await session.commit() 71 | logger.info(f"Promocode {promocode.code} created.") 72 | return promocode 73 | except IntegrityError as exception: 74 | await session.rollback() 75 | logger.error(f"Error occurred while creating promocode {promocode.code}: {exception}") 76 | return None 77 | 78 | @classmethod 79 | async def update(cls, session: AsyncSession, code: str, **kwargs: Any) -> Self | None: 80 | promocode = await Promocode.get(session=session, code=code) 81 | 82 | if not promocode: 83 | logger.warning(f"Promocode {code} not found for update.") 84 | return None 85 | 86 | # if promocode.is_activated: 87 | # logger.warning(f"Promocode {code} is activated and cannot be updated.") 88 | # return None 89 | 90 | filter = [Promocode.code == code] 91 | await session.execute(update(Promocode).where(*filter).values(**kwargs)) 92 | await session.commit() 93 | logger.info(f"Promocode {code} updated.") 94 | return promocode 95 | 96 | @classmethod 97 | async def delete(cls, session: AsyncSession, code: str) -> bool: 98 | promocode = await Promocode.get(session=session, code=code) 99 | 100 | if promocode: 101 | await session.delete(promocode) 102 | await session.commit() 103 | logger.info(f"Promocode {code} deleted.") 104 | return True 105 | 106 | logger.warning(f"Promocode {code} not found for deletion.") 107 | return False 108 | 109 | @classmethod 110 | async def set_activated(cls, session: AsyncSession, code: str, user_id: int) -> bool: 111 | promocode = await Promocode.get(session=session, code=code) 112 | 113 | if not promocode: 114 | logger.warning(f"Promocode {code} not found for activation.") 115 | return False 116 | 117 | if promocode.is_activated: 118 | logger.warning(f"Promocode {code} is already activated.") 119 | return False 120 | 121 | await Promocode.update(session=session, code=code, is_activated=True, activated_by=user_id) 122 | return True 123 | 124 | @classmethod 125 | async def set_deactivated(cls, session: AsyncSession, code: str) -> bool: 126 | promocode = await Promocode.get(session=session, code=code) 127 | 128 | if not promocode: 129 | logger.warning(f"Promocode {code} not found for deactivation.") 130 | return False 131 | 132 | if not promocode.is_activated: 133 | logger.warning(f"Promocode {code} is already deactivated.") 134 | return False 135 | 136 | await Promocode.update(session=session, code=code, is_activated=False, activated_by=None) 137 | return True 138 | -------------------------------------------------------------------------------- /app/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from urllib.parse import urljoin 4 | 5 | from aiogram import Bot, Dispatcher 6 | from aiogram.client.default import DefaultBotProperties 7 | from aiogram.enums import ParseMode 8 | from aiogram.fsm.storage.memory import MemoryStorage 9 | from aiogram.fsm.storage.redis import RedisStorage 10 | from aiogram.utils.i18n import I18n 11 | from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application 12 | from aiohttp.web import Application, _run_app 13 | from redis.asyncio.client import Redis 14 | 15 | from app import logger 16 | from app.bot import filters, middlewares, routers, services, tasks 17 | from app.bot.middlewares import MaintenanceMiddleware 18 | from app.bot.models import ServicesContainer 19 | from app.bot.payment_gateways import GatewayFactory 20 | from app.bot.utils import commands 21 | from app.bot.utils.constants import ( 22 | BOT_STARTED_TAG, 23 | BOT_STOPPED_TAG, 24 | DEFAULT_LANGUAGE, 25 | I18N_DOMAIN, 26 | TELEGRAM_WEBHOOK, 27 | ) 28 | from app.config import DEFAULT_BOT_HOST, DEFAULT_LOCALES_DIR, Config, load_config 29 | from app.db.database import Database 30 | 31 | 32 | async def on_shutdown(db: Database, bot: Bot, services: ServicesContainer) -> None: 33 | await services.notification.notify_developer(BOT_STOPPED_TAG) 34 | await commands.delete(bot) 35 | await bot.delete_webhook() 36 | await bot.session.close() 37 | await db.close() 38 | logging.info("Bot stopped.") 39 | 40 | 41 | async def on_startup( 42 | config: Config, 43 | bot: Bot, 44 | services: ServicesContainer, 45 | db: Database, 46 | redis: Redis, 47 | i18n: I18n, 48 | ) -> None: 49 | webhook_url = urljoin(config.bot.DOMAIN, TELEGRAM_WEBHOOK) 50 | 51 | if await bot.get_webhook_info() != webhook_url: 52 | await bot.set_webhook(webhook_url) 53 | 54 | current_webhook = await bot.get_webhook_info() 55 | logging.info(f"Current webhook URL: {current_webhook.url}") 56 | 57 | await services.notification.notify_developer(BOT_STARTED_TAG) 58 | logging.info("Bot started.") 59 | 60 | tasks.transactions.start_scheduler(db.session) 61 | if config.shop.REFERRER_REWARD_ENABLED: 62 | tasks.referral.start_scheduler( 63 | session_factory=db.session, referral_service=services.referral 64 | ) 65 | tasks.subscription_expiry.start_scheduler( 66 | session_factory=db.session, 67 | redis=redis, 68 | i18n=i18n, 69 | vpn_service=services.vpn, 70 | notification_service=services.notification, 71 | ) 72 | 73 | 74 | async def main() -> None: 75 | # Create web application 76 | app = Application() 77 | 78 | # Load configuration 79 | config = load_config() 80 | 81 | # Set up logging 82 | logger.setup_logging(config.logging) 83 | 84 | # Initialize database 85 | db = Database(config.database) 86 | await db.initialize() 87 | 88 | # Set up storage for FSM (Finite State Machine) 89 | storage = RedisStorage.from_url(url=config.redis.url()) 90 | # storage = MemoryStorage() 91 | 92 | # Initialize the bot with the token and default properties 93 | bot = Bot( 94 | token=config.bot.TOKEN, 95 | default=DefaultBotProperties( 96 | parse_mode=ParseMode.HTML, link_preview_is_disabled=True 97 | ), 98 | ) 99 | 100 | # Set up internationalization (i18n) 101 | i18n = I18n( 102 | path=DEFAULT_LOCALES_DIR, 103 | default_locale=DEFAULT_LANGUAGE, 104 | domain=I18N_DOMAIN, 105 | ) 106 | I18n.set_current(i18n) 107 | 108 | # Initialize services 109 | services_container = await services.initialize( 110 | config=config, 111 | session=db.session, 112 | bot=bot, 113 | ) 114 | 115 | # Sync servers 116 | await services_container.server_pool.sync_servers() 117 | 118 | # Register payment gateways 119 | gateway_factory = GatewayFactory() 120 | gateway_factory.register_gateways( 121 | app=app, 122 | config=config, 123 | session=db.session, 124 | storage=storage, 125 | bot=bot, 126 | i18n=i18n, 127 | services=services_container, 128 | ) 129 | 130 | # Create the dispatcher 131 | dispatcher = Dispatcher( 132 | db=db, 133 | storage=storage, 134 | config=config, 135 | bot=bot, 136 | services=services_container, 137 | gateway_factory=gateway_factory, 138 | redis=storage.redis, 139 | i18n=i18n, 140 | ) 141 | 142 | # Register event handlers 143 | dispatcher.startup.register(on_startup) 144 | dispatcher.shutdown.register(on_shutdown) 145 | 146 | # Enable Maintenance mode for developing # WARNING: remove before production 147 | MaintenanceMiddleware.set_mode(False) 148 | 149 | # Register middlewares 150 | middlewares.register(dispatcher=dispatcher, i18n=i18n, session=db.session) 151 | 152 | # Register filters 153 | filters.register( 154 | dispatcher=dispatcher, 155 | developer_id=config.bot.DEV_ID, 156 | admins_ids=config.bot.ADMINS, 157 | ) 158 | 159 | # Include bot routers 160 | routers.include(app=app, dispatcher=dispatcher) 161 | 162 | # Set up bot commands 163 | await commands.setup(bot) 164 | 165 | # Set up webhook request handler 166 | webhook_requests_handler = SimpleRequestHandler(dispatcher=dispatcher, bot=bot) 167 | webhook_requests_handler.register(app, path=TELEGRAM_WEBHOOK) 168 | 169 | # Set up application and run 170 | setup_application(app, dispatcher, bot=bot) 171 | await _run_app(app, host=DEFAULT_BOT_HOST, port=config.bot.PORT) 172 | 173 | 174 | if __name__ == "__main__": 175 | try: 176 | asyncio.run(main()) 177 | except (KeyboardInterrupt, SystemExit): 178 | logging.info("Bot stopped.") 179 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/yoomoney.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import uuid 4 | 5 | import requests 6 | from aiogram import Bot 7 | from aiogram.fsm.storage.redis import RedisStorage 8 | from aiogram.utils.i18n import I18n 9 | from aiogram.utils.i18n import gettext as _ 10 | from aiogram.utils.i18n import lazy_gettext as __ 11 | from aiohttp.web import Application, Request, Response 12 | from sqlalchemy.ext.asyncio import async_sessionmaker 13 | 14 | from app.bot.models import ServicesContainer, SubscriptionData 15 | from app.bot.payment_gateways import PaymentGateway 16 | from app.bot.utils.constants import YOOMONEY_WEBHOOK, Currency, TransactionStatus 17 | from app.bot.utils.formatting import format_device_count, format_subscription_period 18 | from app.bot.utils.navigation import NavSubscription 19 | from app.config import Config 20 | from app.db.models import Transaction 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | class Yoomoney(PaymentGateway): 26 | name = "" 27 | currency = Currency.RUB 28 | callback = NavSubscription.PAY_YOOMONEY 29 | 30 | def __init__( 31 | self, 32 | app: Application, 33 | config: Config, 34 | session: async_sessionmaker, 35 | storage: RedisStorage, 36 | bot: Bot, 37 | i18n: I18n, 38 | services: ServicesContainer, 39 | ) -> None: 40 | self.name = __("payment:gateway:yoomoney") 41 | self.app = app 42 | self.config = config 43 | self.session = session 44 | self.storage = storage 45 | self.bot = bot 46 | self.i18n = i18n 47 | self.services = services 48 | 49 | self.app.router.add_post(YOOMONEY_WEBHOOK, self.webhook_handler) 50 | logger.info("YooMoney payment gateway initialized.") 51 | 52 | async def create_payment(self, data: SubscriptionData) -> str: 53 | bot_username = (await self.bot.get_me()).username 54 | redirect_url = f"https://t.me/{bot_username}" 55 | 56 | description = _("payment:invoice:description").format( 57 | devices=format_device_count(data.devices), 58 | duration=format_subscription_period(data.duration), 59 | ) 60 | 61 | price = str(data.price) 62 | payment_id = str(uuid.uuid4()) 63 | 64 | pay_url = self.create_quickpay_url( 65 | receiver=self.config.yoomoney.WALLET_ID, 66 | quickpay_form="shop", 67 | targets=description, 68 | paymentType="SB", 69 | sum=price, 70 | label=payment_id, 71 | successURL=redirect_url, 72 | ) 73 | 74 | async with self.session() as session: 75 | await Transaction.create( 76 | session=session, 77 | tg_id=data.user_id, 78 | subscription=data.pack(), 79 | payment_id=payment_id, 80 | status=TransactionStatus.PENDING, 81 | ) 82 | 83 | logger.info(f"Payment link created for user {data.user_id}: {pay_url}") 84 | return pay_url 85 | 86 | async def handle_payment_succeeded(self, payment_id: str) -> None: 87 | await self._on_payment_succeeded(payment_id) 88 | 89 | async def handle_payment_canceled(self, payment_id: str) -> None: 90 | await self._on_payment_canceled(payment_id) 91 | 92 | async def webhook_handler(self, request: Request) -> Response: 93 | try: 94 | event_data = await request.post() 95 | logger.debug(f"Parsed form data: {dict(event_data)}") 96 | 97 | if not self.verify_notification(event_data): 98 | logger.error("YooMoney verification failed.") 99 | return Response(status=403) 100 | 101 | logger.debug("YooMoney verified successfully.") 102 | await self.handle_payment_succeeded(event_data.get("label")) 103 | return Response(status=200) 104 | 105 | except Exception as exception: 106 | logger.exception(f"Error processing YooMoney webhook: {exception}") 107 | return Response(status=400) 108 | 109 | def create_quickpay_url( 110 | self, 111 | receiver: str, 112 | quickpay_form: str, 113 | targets: str, 114 | paymentType: str, 115 | sum: float, 116 | label: str = None, 117 | successURL: str = None, 118 | ) -> str: 119 | base_url = "https://yoomoney.ru/quickpay/confirm.xml?" 120 | payload = { 121 | "receiver": receiver, 122 | "quickpay_form": quickpay_form, 123 | "targets": targets, 124 | "paymentType": paymentType, 125 | "sum": sum, 126 | "label": label, 127 | "successURL": successURL, 128 | } 129 | 130 | query = "&".join( 131 | f"{key.replace('_', '-')}" + "=" + str(value) 132 | for key, value in payload.items() 133 | if value is not None 134 | ).replace(" ", "%20") 135 | 136 | response = requests.post(base_url + query) 137 | return response.url 138 | 139 | def verify_notification(self, data: dict) -> bool: 140 | params = [ 141 | data.get("notification_type", ""), 142 | data.get("operation_id", ""), 143 | data.get("amount", ""), 144 | data.get("currency", ""), 145 | data.get("datetime", ""), 146 | data.get("sender", ""), 147 | data.get("codepro", ""), 148 | self.config.yoomoney.NOTIFICATION_SECRET, 149 | data.get("label", ""), 150 | ] 151 | 152 | sign_str = "&".join(params) 153 | computed_hash = hashlib.sha1(sign_str.encode("utf-8")).hexdigest() 154 | 155 | is_valid = computed_hash == data.get("sha1_hash", "") 156 | if not is_valid: 157 | logger.warning( 158 | f"Invalid signature. Expected {computed_hash}, received {data.get('sha1_hash')}." 159 | ) 160 | 161 | return is_valid 162 | -------------------------------------------------------------------------------- /app/bot/services/payment_stats.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Optional 3 | 4 | from sqlalchemy import select 5 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 6 | 7 | from app.bot.models import SubscriptionData 8 | from app.bot.utils.constants import TransactionStatus 9 | from app.db.models import Transaction 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class PaymentStatsService: 15 | """Service for tracking payment statistics and revenue from transaction records.""" 16 | 17 | def __init__(self, session_factory: async_sessionmaker) -> None: 18 | """ 19 | Initialize PaymentStatsService. 20 | 21 | Args: 22 | session_factory: SQLAlchemy async session maker 23 | """ 24 | self.session_factory = session_factory 25 | logger.debug("PaymentStatsService initialized") 26 | 27 | async def get_user_payment_stats( 28 | self, 29 | user_id: int, 30 | session: Optional[AsyncSession] = None, 31 | payment_method_currencies: Optional[Dict[str, str]] = None, 32 | ) -> Dict[str, float]: 33 | """ 34 | Calculate total payments by currency for a specific user using transactions table. 35 | 36 | Args: 37 | user_id: Telegram user ID 38 | session: Optional existing database session 39 | payment_method_currencies: Dictionary mapping payment methods to currency codes 40 | 41 | Returns: 42 | Dict mapping currency codes to total amounts 43 | """ 44 | 45 | async def _get_stats(s: AsyncSession) -> Dict[str, float]: 46 | # Get completed transactions for this user 47 | query = await s.execute( 48 | select(Transaction).where( 49 | Transaction.tg_id == user_id, Transaction.status == TransactionStatus.COMPLETED 50 | ) 51 | ) 52 | transactions = query.scalars().all() 53 | 54 | results = {} 55 | for tx in transactions: 56 | try: 57 | data = SubscriptionData.unpack(tx.subscription) 58 | payment_method = data.state.value 59 | 60 | currency = None 61 | if payment_method_currencies: 62 | for method, curr in payment_method_currencies.items(): 63 | if method in payment_method: 64 | currency = curr 65 | break 66 | else: 67 | logger.warning( 68 | f"payment_method_currencies not provided for payment_method: {payment_method}" 69 | ) 70 | continue 71 | 72 | if not currency: 73 | logger.warning(f"Unknown payment method: {payment_method}") 74 | continue 75 | 76 | if currency not in results: 77 | results[currency] = 0 78 | results[currency] += float(data.price) 79 | except Exception as e: 80 | logger.warning( 81 | f"Could not parse subscription data: {tx.subscription}. Error: {e}" 82 | ) 83 | 84 | return results 85 | 86 | if session: 87 | return await _get_stats(session) 88 | else: 89 | async with self.session_factory() as session: 90 | return await _get_stats(session) 91 | 92 | async def get_total_revenue_stats( 93 | self, 94 | session: Optional[AsyncSession] = None, 95 | payment_method_currencies: Optional[Dict[str, str]] = None, 96 | ) -> Dict[str, float]: 97 | """ 98 | Calculate total revenue across all completed transactions by currency. 99 | 100 | Args: 101 | session: Optional existing database session 102 | payment_method_currencies: Dictionary mapping payment methods to currency codes 103 | 104 | Returns: 105 | Dict mapping currency codes to total amounts 106 | """ 107 | 108 | async def _get_stats(s: AsyncSession) -> Dict[str, float]: 109 | query = await s.execute( 110 | select(Transaction).where(Transaction.status == TransactionStatus.COMPLETED) 111 | ) 112 | transactions = query.scalars().all() 113 | 114 | results = {} 115 | for tx in transactions: 116 | try: 117 | data = SubscriptionData.unpack(tx.subscription) 118 | payment_method = data.state.value 119 | 120 | currency = None 121 | if payment_method_currencies: 122 | for method, curr in payment_method_currencies.items(): 123 | if method in payment_method: 124 | currency = curr 125 | break 126 | else: 127 | logger.warning( 128 | f"payment_method_currencies not provided for payment_method: {payment_method}" 129 | ) 130 | continue 131 | 132 | if not currency: 133 | logger.warning(f"Unknown payment method: {payment_method}") 134 | continue 135 | 136 | if currency not in results: 137 | results[currency] = 0 138 | results[currency] += float(data.price) 139 | except Exception as e: 140 | logger.warning( 141 | f"Could not parse subscription data: {tx.subscription}. Error: {e}" 142 | ) 143 | 144 | return results 145 | 146 | if session: 147 | return await _get_stats(session) 148 | else: 149 | async with self.session_factory() as session: 150 | return await _get_stats(session) 151 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/heleket.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import logging 5 | import uuid 6 | from hmac import compare_digest 7 | 8 | import aiohttp 9 | from aiogram import Bot 10 | from aiogram.fsm.storage.redis import RedisStorage 11 | from aiogram.utils.i18n import I18n 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.i18n import lazy_gettext as __ 14 | from aiohttp.web import Application, Request, Response 15 | from sqlalchemy.ext.asyncio import async_sessionmaker 16 | 17 | from app.bot.models import ServicesContainer, SubscriptionData 18 | from app.bot.payment_gateways import PaymentGateway 19 | from app.bot.utils.constants import HELEKET_WEBHOOK, Currency, TransactionStatus 20 | from app.bot.utils.navigation import NavSubscription 21 | from app.config import Config 22 | from app.db.models import Transaction 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class Heleket(PaymentGateway): 28 | name = "" 29 | currency = Currency.USD 30 | callback = NavSubscription.PAY_HELEKET 31 | 32 | def __init__( 33 | self, 34 | app: Application, 35 | config: Config, 36 | session: async_sessionmaker, 37 | storage: RedisStorage, 38 | bot: Bot, 39 | i18n: I18n, 40 | services: ServicesContainer, 41 | ) -> None: 42 | self.name = __("payment:gateway:heleket") 43 | self.app = app 44 | self.config = config 45 | self.session = session 46 | self.storage = storage 47 | self.bot = bot 48 | self.i18n = i18n 49 | self.services = services 50 | 51 | self.app.router.add_post(HELEKET_WEBHOOK, self.webhook_handler) 52 | logger.info("Heleket payment gateway initialized.") 53 | 54 | async def create_payment(self, data: SubscriptionData) -> str: 55 | bot_username = (await self.bot.get_me()).username 56 | redirect_url = f"https://t.me/{bot_username}" 57 | order_id = str(uuid.uuid4()) 58 | price = str(data.price) 59 | 60 | payload = { 61 | "amount": price, 62 | "currency": self.currency.code, 63 | "order_id": order_id, 64 | "url_return": redirect_url, 65 | "url_success": redirect_url, 66 | "url_callback": self.config.bot.DOMAIN + HELEKET_WEBHOOK, 67 | "lifetime": 1800, 68 | "is_payment_multiple": False, 69 | } 70 | headers = { 71 | "merchant": self.config.heleket.MERCHANT_ID, 72 | "sign": self.generate_signature(json.dumps(payload)), 73 | "Content-Type": "application/json", 74 | } 75 | 76 | async with aiohttp.ClientSession() as session: 77 | url = "https://api.heleket.com/v1/payment" 78 | async with session.post(url, json=payload, headers=headers) as response: 79 | result = await response.json() 80 | if response.status == 200 and result.get("result", {}).get("url"): 81 | pay_url = result["result"]["url"] 82 | else: 83 | raise Exception(f"Error: {response.status}; Result: {result}; Data: {data}") 84 | 85 | async with self.session() as session: 86 | await Transaction.create( 87 | session=session, 88 | tg_id=data.user_id, 89 | subscription=data.pack(), 90 | payment_id=result["result"]["order_id"], 91 | status=TransactionStatus.PENDING, 92 | ) 93 | 94 | logger.info(f"Payment link created for user {data.user_id}: {pay_url}") 95 | return pay_url 96 | 97 | async def handle_payment_succeeded(self, payment_id: str) -> None: 98 | await self._on_payment_succeeded(payment_id) 99 | 100 | async def handle_payment_canceled(self, payment_id: str) -> None: 101 | await self._on_payment_canceled(payment_id) 102 | 103 | async def webhook_handler(self, request: Request) -> Response: 104 | logger.debug(f"Received Heleket webhook request") 105 | try: 106 | event_json = await request.json() 107 | 108 | if not self.verify_webhook(request, event_json): 109 | return Response(status=403) 110 | 111 | match event_json.get("status"): 112 | case "paid" | "paid_over": 113 | order_id = event_json.get("order_id") 114 | await self.handle_payment_succeeded(order_id) 115 | return Response(status=200) 116 | 117 | case "cancel": 118 | order_id = event_json.get("order_id") 119 | await self.handle_payment_canceled(order_id) 120 | return Response(status=200) 121 | 122 | case _: 123 | return Response(status=400) 124 | 125 | except Exception as exception: 126 | logger.exception(f"Error processing Heleket webhook: {exception}") 127 | return Response(status=400) 128 | 129 | def verify_webhook(self, request: Request, data: dict) -> bool: 130 | client_ip = ( 131 | request.headers.get("CF-Connecting-IP") 132 | or request.headers.get("X-Real-IP") 133 | or request.headers.get("X-Forwarded-For") 134 | or request.remote 135 | ) 136 | if client_ip not in ["31.133.220.8"]: 137 | logger.warning(f"Unauthorized IP: {client_ip}") 138 | return False 139 | 140 | sign = data.pop("sign", None) 141 | if not sign: 142 | logger.warning("Missing signature.") 143 | return False 144 | 145 | json_data = json.dumps(data, separators=(",", ":")) 146 | hash_value = self.generate_signature(json_data) 147 | 148 | if not compare_digest(hash_value, sign): 149 | logger.warning(f"Invalid signature.") 150 | return False 151 | 152 | return True 153 | 154 | def generate_signature(self, data: str) -> str: 155 | base64_encoded = base64.b64encode(data.encode()).decode() 156 | raw_string = f"{base64_encoded}{self.config.heleket.API_KEY}" 157 | return hashlib.md5(raw_string.encode()).hexdigest() 158 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/cryptomus.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import logging 5 | import uuid 6 | from hmac import compare_digest 7 | 8 | import aiohttp 9 | from aiogram import Bot 10 | from aiogram.fsm.storage.redis import RedisStorage 11 | from aiogram.utils.i18n import I18n 12 | from aiogram.utils.i18n import gettext as _ 13 | from aiogram.utils.i18n import lazy_gettext as __ 14 | from aiohttp.web import Application, Request, Response 15 | from sqlalchemy.ext.asyncio import async_sessionmaker 16 | 17 | from app.bot.models import ServicesContainer, SubscriptionData 18 | from app.bot.payment_gateways import PaymentGateway 19 | from app.bot.utils.constants import CRYPTOMUS_WEBHOOK, Currency, TransactionStatus 20 | from app.bot.utils.navigation import NavSubscription 21 | from app.config import Config 22 | from app.db.models import Transaction 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class Cryptomus(PaymentGateway): 28 | name = "" 29 | currency = Currency.USD 30 | callback = NavSubscription.PAY_CRYPTOMUS 31 | 32 | def __init__( 33 | self, 34 | app: Application, 35 | config: Config, 36 | session: async_sessionmaker, 37 | storage: RedisStorage, 38 | bot: Bot, 39 | i18n: I18n, 40 | services: ServicesContainer, 41 | ) -> None: 42 | self.name = __("payment:gateway:cryptomus") 43 | self.app = app 44 | self.config = config 45 | self.session = session 46 | self.storage = storage 47 | self.bot = bot 48 | self.i18n = i18n 49 | self.services = services 50 | 51 | self.app.router.add_post(CRYPTOMUS_WEBHOOK, self.webhook_handler) 52 | logger.info("Cryptomus payment gateway initialized.") 53 | 54 | async def create_payment(self, data: SubscriptionData) -> str: 55 | bot_username = (await self.bot.get_me()).username 56 | redirect_url = f"https://t.me/{bot_username}" 57 | order_id = str(uuid.uuid4()) 58 | price = str(data.price) 59 | 60 | payload = { 61 | "amount": price, 62 | "currency": self.currency.code, 63 | "order_id": order_id, 64 | "url_return": redirect_url, 65 | "url_success": redirect_url, 66 | "url_callback": self.config.bot.DOMAIN + CRYPTOMUS_WEBHOOK, 67 | "lifetime": 1800, 68 | "is_payment_multiple": False, 69 | } 70 | headers = { 71 | "merchant": self.config.cryptomus.MERCHANT_ID, 72 | "sign": self.generate_signature(json.dumps(payload)), 73 | "Content-Type": "application/json", 74 | } 75 | 76 | async with aiohttp.ClientSession() as session: 77 | url = "https://api.cryptomus.com/v1/payment" 78 | async with session.post(url, json=payload, headers=headers) as response: 79 | result = await response.json() 80 | if response.status == 200 and result.get("result", {}).get("url"): 81 | pay_url = result["result"]["url"] 82 | else: 83 | raise Exception(f"Error: {response.status}; Result: {result}; Data: {data}") 84 | 85 | async with self.session() as session: 86 | await Transaction.create( 87 | session=session, 88 | tg_id=data.user_id, 89 | subscription=data.pack(), 90 | payment_id=result["result"]["order_id"], 91 | status=TransactionStatus.PENDING, 92 | ) 93 | 94 | logger.info(f"Payment link created for user {data.user_id}: {pay_url}") 95 | return pay_url 96 | 97 | async def handle_payment_succeeded(self, payment_id: str) -> None: 98 | await self._on_payment_succeeded(payment_id) 99 | 100 | async def handle_payment_canceled(self, payment_id: str) -> None: 101 | await self._on_payment_canceled(payment_id) 102 | 103 | async def webhook_handler(self, request: Request) -> Response: 104 | logger.debug(f"Received Cryptomus webhook request") 105 | try: 106 | event_json = await request.json() 107 | 108 | if not self.verify_webhook(request, event_json): 109 | return Response(status=403) 110 | 111 | match event_json.get("status"): 112 | case "paid" | "paid_over": 113 | order_id = event_json.get("order_id") 114 | await self.handle_payment_succeeded(order_id) 115 | return Response(status=200) 116 | 117 | case "cancel": 118 | order_id = event_json.get("order_id") 119 | await self.handle_payment_canceled(order_id) 120 | return Response(status=200) 121 | 122 | case _: 123 | return Response(status=400) 124 | 125 | except Exception as exception: 126 | logger.exception(f"Error processing Cryptomus webhook: {exception}") 127 | return Response(status=400) 128 | 129 | def verify_webhook(self, request: Request, data: dict) -> bool: 130 | client_ip = ( 131 | request.headers.get("CF-Connecting-IP") 132 | or request.headers.get("X-Real-IP") 133 | or request.headers.get("X-Forwarded-For") 134 | or request.remote 135 | ) 136 | if client_ip not in ["91.227.144.54"]: 137 | logger.warning(f"Unauthorized IP: {client_ip}") 138 | return False 139 | 140 | sign = data.pop("sign", None) 141 | if not sign: 142 | logger.warning("Missing signature.") 143 | return False 144 | 145 | json_data = json.dumps(data, separators=(",", ":")) 146 | hash_value = self.generate_signature(json_data) 147 | 148 | if not compare_digest(hash_value, sign): 149 | logger.warning(f"Invalid signature.") 150 | return False 151 | 152 | return True 153 | 154 | def generate_signature(self, data: str) -> str: 155 | base64_encoded = base64.b64encode(data.encode()).decode() 156 | raw_string = f"{base64_encoded}{self.config.cryptomus.API_KEY}" 157 | return hashlib.md5(raw_string.encode()).hexdigest() 158 | -------------------------------------------------------------------------------- /app/bot/payment_gateways/_gateway.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from abc import ABC, abstractmethod 3 | 4 | from aiogram import Bot 5 | from aiogram.fsm.storage.redis import RedisStorage 6 | from aiogram.utils.i18n import I18n 7 | from aiogram.utils.i18n import gettext as _ 8 | from aiogram.utils.i18n import lazy_gettext as __ 9 | from aiohttp.web import Application 10 | from sqlalchemy.ext.asyncio import async_sessionmaker 11 | 12 | from app.bot.models import ServicesContainer, SubscriptionData 13 | from app.bot.routers.main_menu.handler import redirect_to_main_menu 14 | from app.bot.utils.constants import ( 15 | DEFAULT_LANGUAGE, 16 | EVENT_PAYMENT_CANCELED_TAG, 17 | EVENT_PAYMENT_SUCCEEDED_TAG, 18 | Currency, 19 | TransactionStatus, 20 | ) 21 | from app.bot.utils.formatting import format_device_count, format_subscription_period 22 | from app.config import Config 23 | from app.db.models import Transaction, User 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | from app.bot.models import SubscriptionData 28 | from app.bot.utils.constants import Currency 29 | 30 | 31 | class PaymentGateway(ABC): 32 | name: str 33 | currency: Currency 34 | callback: str 35 | 36 | def __init__( 37 | self, 38 | app: Application, 39 | config: Config, 40 | session: async_sessionmaker, 41 | storage: RedisStorage, 42 | bot: Bot, 43 | i18n: I18n, 44 | services: ServicesContainer, 45 | ) -> None: 46 | self.app = app 47 | self.config = config 48 | self.session = session 49 | self.storage = storage 50 | self.bot = bot 51 | self.i18n = i18n 52 | self.services = services 53 | 54 | @abstractmethod 55 | async def create_payment(self, data: SubscriptionData) -> str: 56 | pass 57 | 58 | @abstractmethod 59 | async def handle_payment_succeeded(self, payment_id: str) -> None: 60 | pass 61 | 62 | @abstractmethod 63 | async def handle_payment_canceled(self, payment_id: str) -> None: 64 | pass 65 | 66 | async def _on_payment_succeeded(self, payment_id: str) -> None: 67 | logger.info(f"Payment succeeded {payment_id}") 68 | 69 | async with self.session() as session: 70 | transaction = await Transaction.get_by_id(session=session, payment_id=payment_id) 71 | data = SubscriptionData.unpack(transaction.subscription) 72 | logger.debug(f"Subscription data unpacked: {data}") 73 | user = await User.get(session=session, tg_id=data.user_id) 74 | 75 | await Transaction.update( 76 | session=session, 77 | payment_id=payment_id, 78 | status=TransactionStatus.COMPLETED, 79 | ) 80 | 81 | if self.config.shop.REFERRER_REWARD_ENABLED: 82 | await self.services.referral.add_referrers_rewards_on_payment( 83 | referred_tg_id=data.user_id, 84 | payment_amount=data.price, # TODO: (!) add currency unified processing 85 | payment_id=payment_id, 86 | ) 87 | 88 | await self.services.notification.notify_developer( 89 | text=EVENT_PAYMENT_SUCCEEDED_TAG 90 | + "\n\n" 91 | + _("payment:event:payment_succeeded").format( 92 | payment_id=payment_id, 93 | user_id=user.tg_id, 94 | devices=format_device_count(data.devices), 95 | duration=format_subscription_period(data.duration), 96 | ), 97 | ) 98 | 99 | locale = user.language_code if user else DEFAULT_LANGUAGE 100 | with self.i18n.use_locale(locale): 101 | await redirect_to_main_menu( 102 | bot=self.bot, 103 | user=user, 104 | services=self.services, 105 | config=self.config, 106 | storage=self.storage, 107 | ) 108 | 109 | if data.is_extend: 110 | await self.services.vpn.extend_subscription( 111 | user=user, 112 | devices=data.devices, 113 | duration=data.duration, 114 | ) 115 | logger.info(f"Subscription extended for user {user.tg_id}") 116 | await self.services.notification.notify_extend_success( 117 | user_id=user.tg_id, 118 | data=data, 119 | ) 120 | elif data.is_change: 121 | await self.services.vpn.change_subscription( 122 | user=user, 123 | devices=data.devices, 124 | duration=data.duration, 125 | ) 126 | logger.info(f"Subscription changed for user {user.tg_id}") 127 | await self.services.notification.notify_change_success( 128 | user_id=user.tg_id, 129 | data=data, 130 | ) 131 | else: 132 | await self.services.vpn.create_subscription( 133 | user=user, 134 | devices=data.devices, 135 | duration=data.duration, 136 | ) 137 | logger.info(f"Subscription created for user {user.tg_id}") 138 | key = await self.services.vpn.get_key(user) 139 | await self.services.notification.notify_purchase_success( 140 | user_id=user.tg_id, 141 | key=key, 142 | ) 143 | 144 | async def _on_payment_canceled(self, payment_id: str) -> None: 145 | logger.info(f"Payment canceled {payment_id}") 146 | async with self.session() as session: 147 | transaction = await Transaction.get_by_id(session=session, payment_id=payment_id) 148 | data = SubscriptionData.unpack(transaction.subscription) 149 | 150 | await Transaction.update( 151 | session=session, 152 | payment_id=payment_id, 153 | status=TransactionStatus.CANCELED, 154 | ) 155 | 156 | await self.services.notification.notify_developer( 157 | text=EVENT_PAYMENT_CANCELED_TAG 158 | + "\n\n" 159 | + _("payment:event:payment_canceled").format( 160 | payment_id=payment_id, 161 | user_id=data.user_id, 162 | devices=format_device_count(data.devices), 163 | duration=format_subscription_period(data.duration), 164 | ), 165 | ) 166 | -------------------------------------------------------------------------------- /app/bot/routers/subscription/keyboard.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from app.bot.services import PlanService 7 | 8 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 9 | from aiogram.utils.i18n import gettext as _ 10 | from aiogram.utils.keyboard import InlineKeyboardBuilder 11 | 12 | from app.bot.models import SubscriptionData 13 | from app.bot.models.plan import Plan 14 | from app.bot.payment_gateways import PaymentGateway 15 | from app.bot.routers.misc.keyboard import ( 16 | back_button, 17 | back_to_main_menu_button, 18 | close_notification_button, 19 | ) 20 | from app.bot.utils.constants import Currency 21 | from app.bot.utils.formatting import format_device_count, format_subscription_period 22 | from app.bot.utils.navigation import NavDownload, NavMain, NavSubscription 23 | 24 | 25 | def change_subscription_button() -> InlineKeyboardButton: 26 | return InlineKeyboardButton( 27 | text=_("subscription:button:change"), 28 | callback_data=NavSubscription.CHANGE, 29 | ) 30 | 31 | 32 | def subscription_keyboard( 33 | has_subscription: bool, 34 | callback_data: SubscriptionData, 35 | ) -> InlineKeyboardMarkup: 36 | builder = InlineKeyboardBuilder() 37 | 38 | if not has_subscription: 39 | builder.button( 40 | text=_("subscription:button:buy"), 41 | callback_data=callback_data, 42 | ) 43 | else: 44 | callback_data.state = NavSubscription.EXTEND 45 | builder.button( 46 | text=_("subscription:button:extend"), 47 | callback_data=callback_data, 48 | ) 49 | callback_data.state = NavSubscription.CHANGE 50 | builder.button( 51 | text=_("subscription:button:change"), 52 | callback_data=callback_data, 53 | ) 54 | 55 | builder.button( 56 | text=_("subscription:button:activate_promocode"), 57 | callback_data=NavSubscription.PROMOCODE, 58 | ) 59 | builder.adjust(1) 60 | builder.row(back_to_main_menu_button()) 61 | return builder.as_markup() 62 | 63 | 64 | def devices_keyboard( 65 | plans: list[Plan], 66 | callback_data: SubscriptionData, 67 | ) -> InlineKeyboardMarkup: 68 | builder = InlineKeyboardBuilder() 69 | 70 | for plan in plans: 71 | callback_data.devices = plan.devices 72 | builder.button( 73 | text=format_device_count(plan.devices), 74 | callback_data=callback_data, 75 | ) 76 | 77 | builder.adjust(2) 78 | builder.row(back_button(NavSubscription.MAIN)) 79 | builder.row(back_to_main_menu_button()) 80 | return builder.as_markup() 81 | 82 | 83 | def duration_keyboard( 84 | plan_service: PlanService, 85 | callback_data: SubscriptionData, 86 | currency: str, 87 | ) -> InlineKeyboardMarkup: 88 | builder = InlineKeyboardBuilder() 89 | durations = plan_service.get_durations() 90 | currency: Currency = Currency.from_code(currency) 91 | 92 | for duration in durations: 93 | callback_data.duration = duration 94 | period = format_subscription_period(duration) 95 | plan = plan_service.get_plan(callback_data.devices) 96 | price = plan.get_price(currency=currency, duration=duration) 97 | builder.button( 98 | text=f"{period} | {price} {currency.symbol}", 99 | callback_data=callback_data, 100 | ) 101 | 102 | builder.adjust(2) 103 | 104 | if callback_data.is_extend: 105 | builder.row(back_button(NavSubscription.MAIN)) 106 | else: 107 | callback_data.state = NavSubscription.PROCESS 108 | builder.row( 109 | back_button( 110 | callback_data.pack(), 111 | text=_("subscription:button:change_devices"), 112 | ) 113 | ) 114 | 115 | builder.row(back_to_main_menu_button()) 116 | return builder.as_markup() 117 | 118 | 119 | def pay_keyboard(pay_url: str, callback_data: SubscriptionData) -> InlineKeyboardMarkup: 120 | builder = InlineKeyboardBuilder() 121 | 122 | builder.row(InlineKeyboardButton(text=_("subscription:button:pay"), url=pay_url)) 123 | 124 | callback_data.state = NavSubscription.DURATION 125 | builder.row( 126 | back_button( 127 | callback_data.pack(), 128 | text=_("subscription:button:change_payment_method"), 129 | ) 130 | ) 131 | builder.row(back_to_main_menu_button()) 132 | return builder.as_markup() 133 | 134 | 135 | def payment_method_keyboard( 136 | plan: Plan, 137 | callback_data: SubscriptionData, 138 | gateways: list[PaymentGateway], 139 | ) -> InlineKeyboardMarkup: 140 | builder = InlineKeyboardBuilder() 141 | for gateway in gateways: 142 | price = plan.get_price(currency=gateway.currency, duration=callback_data.duration) 143 | if price is None: 144 | continue 145 | 146 | callback_data.state = gateway.callback 147 | builder.row( 148 | InlineKeyboardButton( 149 | text=f"{gateway.name} | {price} {gateway.currency.symbol}", 150 | callback_data=callback_data.pack(), 151 | ) 152 | ) 153 | 154 | callback_data.state = NavSubscription.DEVICES 155 | builder.row( 156 | back_button( 157 | callback_data.pack(), 158 | text=_("subscription:button:change_duration"), 159 | ) 160 | ) 161 | 162 | builder.row(back_to_main_menu_button()) 163 | return builder.as_markup() 164 | 165 | 166 | def payment_success_keyboard() -> InlineKeyboardMarkup: 167 | builder = InlineKeyboardBuilder() 168 | 169 | builder.row( 170 | InlineKeyboardButton( 171 | text=_("subscription:button:download_app"), 172 | callback_data=NavMain.REDIRECT_TO_DOWNLOAD, 173 | ) 174 | ) 175 | 176 | builder.row(close_notification_button()) 177 | return builder.as_markup() 178 | 179 | 180 | def trial_success_keyboard() -> InlineKeyboardMarkup: 181 | builder = InlineKeyboardBuilder() 182 | 183 | builder.row( 184 | InlineKeyboardButton( 185 | text=_("subscription:button:connect"), 186 | callback_data=NavDownload.MAIN, 187 | ) 188 | ) 189 | 190 | builder.row(back_to_main_menu_button()) 191 | return builder.as_markup() 192 | 193 | 194 | def promocode_keyboard() -> InlineKeyboardMarkup: 195 | builder = InlineKeyboardBuilder() 196 | builder.row(back_button(NavSubscription.MAIN)) 197 | builder.row(back_to_main_menu_button()) 198 | return builder.as_markup() 199 | --------------------------------------------------------------------------------