├── 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 | "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"