├── app ├── __init__.py ├── models │ ├── __init__.py │ ├── sql │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ └── timestamp.py │ │ ├── __init__.py │ │ ├── base.py │ │ ├── tc_record.py │ │ ├── deep_link.py │ │ └── user.py │ ├── config │ │ ├── __init__.py │ │ ├── assets │ │ │ ├── ton_connect.py │ │ │ ├── gift_codes.py │ │ │ ├── __init__.py │ │ │ ├── shop.py │ │ │ └── assets.py │ │ └── env │ │ │ ├── server.py │ │ │ ├── sql_alchemy.py │ │ │ ├── base.py │ │ │ ├── redis.py │ │ │ ├── telegram.py │ │ │ ├── common.py │ │ │ ├── __init__.py │ │ │ ├── app.py │ │ │ └── postgres.py │ ├── dto │ │ ├── deep_link.py │ │ ├── __init__.py │ │ ├── ton.py │ │ ├── user.py │ │ └── gift_code.py │ └── base.py ├── utils │ ├── __init__.py │ ├── localization │ │ ├── __init__.py │ │ ├── manager.py │ │ └── patches.py │ ├── time.py │ ├── logging │ │ ├── __init__.py │ │ └── setup.py │ ├── mjson.py │ ├── ton │ │ ├── __init__.py │ │ ├── numbers.py │ │ └── address.py │ ├── qr.py │ ├── custom_types.py │ ├── yaml.py │ └── key_builder.py ├── controllers │ ├── __init__.py │ ├── ton │ │ ├── __init__.py │ │ ├── connect.py │ │ ├── waiter.py │ │ └── proof.py │ ├── gift_codes │ │ ├── __init__.py │ │ └── get.py │ ├── price.py │ └── auth.py ├── exceptions │ ├── __init__.py │ ├── base.py │ └── ton_connect.py ├── telegram │ ├── __init__.py │ ├── handlers │ │ ├── __init__.py │ │ ├── menu │ │ │ ├── shop │ │ │ │ ├── __init__.py │ │ │ │ ├── stars.py │ │ │ │ └── premium.py │ │ │ ├── __init__.py │ │ │ ├── gift_codes │ │ │ │ ├── __init__.py │ │ │ │ ├── share.py │ │ │ │ ├── confirm_creation.py │ │ │ │ ├── start_creation.py │ │ │ │ └── edit_creation.py │ │ │ ├── main.py │ │ │ ├── language.py │ │ │ └── referral_program.py │ │ ├── extra │ │ │ ├── __init__.py │ │ │ ├── lifespan.py │ │ │ ├── pm.py │ │ │ └── errors.py │ │ ├── ton_connect │ │ │ ├── __init__.py │ │ │ ├── select.py │ │ │ ├── unlink.py │ │ │ └── link.py │ │ └── admin │ │ │ └── __init__.py │ ├── helpers │ │ ├── __init__.py │ │ ├── exceptions.py │ │ └── paginator.py │ ├── results │ │ ├── __init__.py │ │ └── inline_query.py │ ├── keyboards │ │ ├── __init__.py │ │ ├── callback_data │ │ │ ├── __init__.py │ │ │ ├── ton_connect.py │ │ │ ├── purchase.py │ │ │ ├── gift_codes.py │ │ │ └── menu.py │ │ ├── language.py │ │ ├── referral.py │ │ ├── gift_codes.py │ │ ├── ton_connect.py │ │ ├── menu.py │ │ └── purchase.py │ ├── filters │ │ ├── __init__.py │ │ ├── magic_data.py │ │ └── states.py │ └── middlewares │ │ ├── __init__.py │ │ ├── event_typed.py │ │ ├── backend_provider.py │ │ ├── message_helper.py │ │ ├── ton_connect_checker.py │ │ ├── ton_connect.py │ │ └── user.py ├── services │ ├── __init__.py │ ├── backend │ │ ├── __init__.py │ │ ├── types │ │ │ ├── base.py │ │ │ ├── ton_proof_domain.py │ │ │ ├── recipient.py │ │ │ ├── new_gift_code.py │ │ │ ├── transaction.py │ │ │ ├── __init__.py │ │ │ ├── user.py │ │ │ ├── ton_proof.py │ │ │ └── transaction_message.py │ │ ├── methods │ │ │ ├── generate_ton_proof_payload.py │ │ │ ├── get_buy_fee.py │ │ │ ├── get_ton_rate.py │ │ │ ├── base.py │ │ │ ├── get_me.py │ │ │ ├── resolve_stars_recipient.py │ │ │ ├── resolve_premium_recipient.py │ │ │ ├── create_gift_code.py │ │ │ ├── create_user.py │ │ │ ├── use_gift_code.py │ │ │ ├── check_ton_proof.py │ │ │ ├── __init__.py │ │ │ ├── buy_stars.py │ │ │ └── buy_premium.py │ │ ├── errors.py │ │ └── session.py │ ├── database │ │ ├── redis │ │ │ ├── __init__.py │ │ │ ├── keys.py │ │ │ └── repository.py │ │ ├── __init__.py │ │ └── postgres │ │ │ ├── repositories │ │ │ ├── __init__.py │ │ │ ├── general.py │ │ │ ├── deep_links.py │ │ │ ├── users.py │ │ │ ├── ton_connect.py │ │ │ └── base.py │ │ │ ├── __init__.py │ │ │ ├── uow.py │ │ │ └── context.py │ ├── ton_connect │ │ ├── __init__.py │ │ ├── storage.py │ │ └── adapter.py │ ├── task_manager.py │ ├── deep_links.py │ └── user.py ├── enums │ ├── pagination_menu_type.py │ ├── payment_method.py │ ├── deep_link_action.py │ ├── gift_code_creation_status.py │ ├── fragment_error_type.py │ ├── __init__.py │ ├── middleware_event_type.py │ └── locale.py ├── factory │ ├── redis.py │ ├── telegram │ │ ├── __init__.py │ │ ├── bot.py │ │ ├── i18n.py │ │ └── dispatcher.py │ ├── __init__.py │ ├── app_config.py │ └── session_pool.py ├── types.py ├── const.py ├── __main__.py └── runners.py ├── .gitattributes ├── migrations ├── README ├── _get_revision_id.py ├── script.py.mako ├── versions │ ├── 002_create_indexes.py │ ├── 003_deep_links.py │ └── 001_initial.py └── env.py ├── scripts └── docker-entrypoint.sh ├── assets ├── gift_codes.yml ├── ton_connect.yml ├── shop.yml └── messages │ ├── en │ ├── gift_codes.ftl │ └── messages.ftl │ └── ru │ ├── gift_codes.ftl │ └── messages.ftl ├── .dockerignore ├── .editorconfig ├── .gitignore ├── Dockerfile ├── .pre-commit-config.yaml ├── nginx └── caller.example.conf ├── LICENSE ├── docker-compose.example.yml ├── .env.dist ├── Makefile ├── README.md ├── pyproject.toml └── alembic.ini /app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/ton/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/services/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/telegram/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/results/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/gift_codes/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/telegram/keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | .env.dist linguist-language=dotenv -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/exceptions/base.py: -------------------------------------------------------------------------------- 1 | class BotError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /app/services/backend/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Backend 2 | 3 | __all__ = ["Backend"] 4 | -------------------------------------------------------------------------------- /app/telegram/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .magic_data import MagicData 2 | 3 | __all__ = ["MagicData"] 4 | -------------------------------------------------------------------------------- /app/utils/localization/__init__.py: -------------------------------------------------------------------------------- 1 | from .manager import UserManager 2 | 3 | __all__ = ["UserManager"] 4 | -------------------------------------------------------------------------------- /app/models/sql/mixins/__init__.py: -------------------------------------------------------------------------------- 1 | from .timestamp import TimestampMixin 2 | 3 | __all__ = ["TimestampMixin"] 4 | -------------------------------------------------------------------------------- /app/services/database/redis/__init__.py: -------------------------------------------------------------------------------- 1 | from .repository import RedisRepository 2 | 3 | __all__ = ["RedisRepository"] 4 | -------------------------------------------------------------------------------- /app/models/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .assets import Assets 2 | from .env import AppConfig 3 | 4 | __all__ = ["Assets", "AppConfig"] 5 | -------------------------------------------------------------------------------- /scripts/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | uv run alembic upgrade head 6 | exec uv run python -O -m app 7 | -------------------------------------------------------------------------------- /assets/gift_codes.yml: -------------------------------------------------------------------------------- 1 | 2 | gift_codes: 3 | min_activations: 1 4 | max_activations: 100 5 | min_amount: 0.5 6 | max_amount: 100 7 | -------------------------------------------------------------------------------- /app/enums/pagination_menu_type.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class PaginationMenuType(StrEnum): 5 | TON_WALLET = auto() 6 | -------------------------------------------------------------------------------- /app/services/ton_connect/__init__.py: -------------------------------------------------------------------------------- 1 | from .adapter import TcAdapter 2 | from .storage import TcStorage 3 | 4 | __all__ = ["TcAdapter", "TcStorage"] 5 | -------------------------------------------------------------------------------- /app/enums/payment_method.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class PaymentMethod(StrEnum): 5 | TON_CONNECT = auto() 6 | CRYPTOMUS = auto() 7 | -------------------------------------------------------------------------------- /app/enums/deep_link_action.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, auto 2 | 3 | 4 | class DeepLinkAction(IntEnum): 5 | INVITE = auto() 6 | USE_GIFT_CODE = auto() 7 | -------------------------------------------------------------------------------- /assets/ton_connect.yml: -------------------------------------------------------------------------------- 1 | 2 | ton_connect: 3 | manifest_url: https://raw.githubusercontent.com/shibdev/tonconnect-data/refs/heads/main/split.json 4 | timeout: 60 5 | -------------------------------------------------------------------------------- /app/models/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from .deep_link import DeepLink 2 | from .tc_record import TcRecord 3 | from .user import User 4 | 5 | __all__ = ["DeepLink", "TcRecord", "User"] 6 | -------------------------------------------------------------------------------- /app/utils/time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from app.const import TIMEZONE 4 | 5 | 6 | def datetime_now() -> datetime: 7 | return datetime.now(tz=TIMEZONE) 8 | -------------------------------------------------------------------------------- /app/models/config/assets/ton_connect.py: -------------------------------------------------------------------------------- 1 | from app.models.base import PydanticModel 2 | 3 | 4 | class TonConnectConfig(PydanticModel): 5 | manifest_url: str 6 | timeout: int 7 | -------------------------------------------------------------------------------- /app/services/database/__init__.py: -------------------------------------------------------------------------------- 1 | from .postgres import Repository, SQLSessionContext, UoW 2 | from .redis import RedisRepository 3 | 4 | __all__ = ["RedisRepository", "Repository", "SQLSessionContext", "UoW"] 5 | -------------------------------------------------------------------------------- /app/factory/redis.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from redis.asyncio import ConnectionPool, Redis 4 | 5 | 6 | def create_redis(url: str) -> Redis: 7 | return Redis(connection_pool=ConnectionPool.from_url(url=url)) 8 | -------------------------------------------------------------------------------- /app/enums/gift_code_creation_status.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class GiftCodeCreationStatus(StrEnum): 5 | NOT_READY = auto() 6 | ACTIVATIONS_LIMIT = auto() 7 | AMOUNT_LIMIT = auto() 8 | READY = auto() 9 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Project 2 | .idea/ 3 | .github/ 4 | .*ignore 5 | README.md 6 | 7 | # Cache 8 | __pycache__/ 9 | *.py[cod] 10 | 11 | # Environment 12 | .env* 13 | .venv/ 14 | 15 | # Docker 16 | docker-compose*.yml 17 | Dockerfile 18 | -------------------------------------------------------------------------------- /app/enums/fragment_error_type.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class FragmentErrorType(StrEnum): 5 | ALREADY_PREMIUM = auto() 6 | USERNAME_NOT_ASSIGNED = auto() 7 | USERNAME_NOT_FOUND = auto() 8 | UNKNOWN = auto() 9 | -------------------------------------------------------------------------------- /app/models/config/assets/gift_codes.py: -------------------------------------------------------------------------------- 1 | from app.models.base import PydanticModel 2 | 3 | 4 | class GiftCodesConfig(PydanticModel): 5 | min_activations: int 6 | max_activations: int 7 | min_amount: float 8 | max_amount: float 9 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/shop/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import premium, stars 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers(premium.router, stars.router) 9 | -------------------------------------------------------------------------------- /app/models/config/assets/__init__.py: -------------------------------------------------------------------------------- 1 | from .assets import Assets 2 | from .gift_codes import GiftCodesConfig 3 | from .shop import ShopConfig 4 | from .ton_connect import TonConnectConfig 5 | 6 | __all__ = ["Assets", "GiftCodesConfig", "ShopConfig", "TonConnectConfig"] 7 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import errors, lifespan, pm 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers(errors.router, pm.router, lifespan.router) 9 | -------------------------------------------------------------------------------- /app/telegram/helpers/exceptions.py: -------------------------------------------------------------------------------- 1 | from contextlib import suppress 2 | 3 | from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError 4 | 5 | 6 | def silent_bot_request() -> suppress: 7 | return suppress(TelegramBadRequest, TelegramForbiddenError) 8 | -------------------------------------------------------------------------------- /app/factory/telegram/__init__.py: -------------------------------------------------------------------------------- 1 | from .bot import create_bot 2 | from .dispatcher import create_dispatcher 3 | from .i18n import create_i18n_middleware 4 | 5 | __all__ = [ 6 | "create_bot", 7 | "create_dispatcher", 8 | "create_i18n_middleware", 9 | ] 10 | -------------------------------------------------------------------------------- /app/exceptions/ton_connect.py: -------------------------------------------------------------------------------- 1 | from .base import BotError 2 | 3 | 4 | class TonConnectError(BotError): 5 | pass 6 | 7 | 8 | class InvalidTonProofError(TonConnectError): 9 | pass 10 | 11 | 12 | class TonIsAlreadyConnectedError(TonConnectError): 13 | pass 14 | -------------------------------------------------------------------------------- /app/models/config/assets/shop.py: -------------------------------------------------------------------------------- 1 | from app.models.base import PydanticModel 2 | 3 | 4 | class ShopConfig(PydanticModel): 5 | available_tickers: list[str] 6 | min_stars: int 7 | max_stars: int 8 | subscription_periods: dict[int, int] 9 | stars_price: float 10 | -------------------------------------------------------------------------------- /app/models/config/env/server.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | 3 | 4 | class ServerConfig(EnvSettings, env_prefix="SERVER_"): 5 | port: int 6 | host: str 7 | url: str 8 | 9 | def build_url(self, path: str) -> str: 10 | return f"{self.url}{path}" 11 | -------------------------------------------------------------------------------- /app/services/backend/types/base.py: -------------------------------------------------------------------------------- 1 | from stollen import MutableStollenObject, StollenObject 2 | 3 | from ..client import Backend 4 | 5 | 6 | class SplitObject(StollenObject[Backend]): 7 | pass 8 | 9 | 10 | class MutableSplitObject(MutableStollenObject[Backend]): 11 | pass 12 | -------------------------------------------------------------------------------- /app/utils/logging/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .setup import disable_aiogram_logs, setup_logger 4 | 5 | __all__ = [ 6 | "database", 7 | "disable_aiogram_logs", 8 | "setup_logger", 9 | ] 10 | 11 | database: logging.Logger = logging.getLogger("bot.database") 12 | -------------------------------------------------------------------------------- /app/services/backend/methods/generate_ton_proof_payload.py: -------------------------------------------------------------------------------- 1 | from .base import SplitMethod 2 | 3 | 4 | class GenerateTonProofPayload( 5 | SplitMethod[str], 6 | api_method="/ton-proof/generate_payload", 7 | returning=str, 8 | response_data_key=["message", "payload"], 9 | ): 10 | pass 11 | -------------------------------------------------------------------------------- /app/services/backend/types/ton_proof_domain.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import MutableSplitObject 4 | 5 | 6 | class TonProofDomain(MutableSplitObject): 7 | length_bytes: int = Field(alias="lengthBytes", description="Domain length") 8 | value: str = Field(description="Domain value") 9 | -------------------------------------------------------------------------------- /app/telegram/handlers/ton_connect/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import link, select, unlink 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers( 9 | link.router, 10 | select.router, 11 | unlink.router, 12 | ) 13 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/__init__.py: -------------------------------------------------------------------------------- 1 | from .deep_links import DeepLinksRepository 2 | from .general import Repository 3 | from .ton_connect import TonConnectRepository 4 | from .users import UsersRepository 5 | 6 | __all__ = ["DeepLinksRepository", "Repository", "UsersRepository", "TonConnectRepository"] 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | max_line_length = 99 11 | 12 | [**.{yml,yaml,json}] 13 | indent_size = 3 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /app/models/config/env/sql_alchemy.py: -------------------------------------------------------------------------------- 1 | from .base import EnvSettings 2 | 3 | 4 | class SQLAlchemyConfig(EnvSettings, env_prefix="ALCHEMY_"): 5 | echo: bool = False 6 | echo_pool: bool = False 7 | pool_size: int = 25 8 | max_overflow: int = 25 9 | pool_timeout: int = 10 10 | pool_recycle: int = 3600 11 | -------------------------------------------------------------------------------- /app/services/backend/methods/get_buy_fee.py: -------------------------------------------------------------------------------- 1 | from stollen.enums import HTTPMethod 2 | 3 | from .base import SplitMethod 4 | 5 | 6 | class GetBuyFee( 7 | SplitMethod[float], 8 | http_method=HTTPMethod.GET, 9 | api_method="/buy/fee", 10 | returning=float, 11 | response_data_key=["fee"], 12 | ): 13 | pass 14 | -------------------------------------------------------------------------------- /app/models/config/env/base.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import BaseSettings, SettingsConfigDict 2 | 3 | from app.const import ENV_FILE 4 | 5 | 6 | class EnvSettings(BaseSettings): 7 | model_config = SettingsConfigDict( 8 | extra="ignore", 9 | env_file=ENV_FILE, 10 | env_file_encoding="utf-8", 11 | ) 12 | -------------------------------------------------------------------------------- /assets/shop.yml: -------------------------------------------------------------------------------- 1 | shop: 2 | available_tickers: 3 | - USDT 4 | - BOLT 5 | - NOT 6 | - DOGS 7 | - SCALE 8 | 9 | min_stars: 50 10 | max_stars: 1000000 11 | subscription_periods: 12 | # month: price in USDT 13 | 3: 12 14 | 6: 16 15 | 12: 29 16 | 17 | stars_price: 0.015 18 | -------------------------------------------------------------------------------- /app/types.py: -------------------------------------------------------------------------------- 1 | from typing import Union 2 | 3 | from aiogram.types import ( 4 | ForceReply, 5 | InlineKeyboardMarkup, 6 | ReplyKeyboardMarkup, 7 | ReplyKeyboardRemove, 8 | ) 9 | 10 | AnyKeyboard = Union[ 11 | InlineKeyboardMarkup, 12 | ReplyKeyboardMarkup, 13 | ReplyKeyboardRemove, 14 | ForceReply, 15 | ] 16 | -------------------------------------------------------------------------------- /app/services/backend/methods/get_ton_rate.py: -------------------------------------------------------------------------------- 1 | from stollen.enums import HTTPMethod 2 | 3 | from .base import SplitMethod 4 | 5 | 6 | class GetTonRate( 7 | SplitMethod[float], 8 | http_method=HTTPMethod.GET, 9 | api_method="/buy/ton_rate", 10 | returning=float, 11 | response_data_key=["ton_rate"], 12 | ): 13 | pass 14 | -------------------------------------------------------------------------------- /app/services/database/postgres/__init__.py: -------------------------------------------------------------------------------- 1 | from .context import SQLSessionContext 2 | from .repositories import Repository, TonConnectRepository, UsersRepository 3 | from .uow import UoW 4 | 5 | __all__ = [ 6 | "Repository", 7 | "SQLSessionContext", 8 | "TonConnectRepository", 9 | "UoW", 10 | "UsersRepository", 11 | ] 12 | -------------------------------------------------------------------------------- /app/services/backend/methods/base.py: -------------------------------------------------------------------------------- 1 | from stollen import StollenMethod 2 | from stollen.enums import HTTPMethod 3 | from stollen.types import StollenT 4 | 5 | from ..client import Backend 6 | 7 | 8 | class SplitMethod( 9 | StollenMethod[StollenT, Backend], 10 | http_method=HTTPMethod.POST, 11 | abstract=True, 12 | ): 13 | pass 14 | -------------------------------------------------------------------------------- /app/services/backend/methods/get_me.py: -------------------------------------------------------------------------------- 1 | from stollen.enums import HTTPMethod 2 | 3 | from ..types import User 4 | from .base import SplitMethod 5 | 6 | 7 | class GetMe( 8 | SplitMethod[User], 9 | http_method=HTTPMethod.GET, 10 | api_method="/user/get", 11 | returning=User, 12 | response_data_key=["message", "user"], 13 | ): 14 | pass 15 | -------------------------------------------------------------------------------- /app/utils/mjson.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Final 2 | 3 | from msgspec.json import Decoder, Encoder 4 | 5 | decode: Final[Callable[..., Any]] = Decoder[dict[str, Any]]().decode 6 | bytes_encode: Final[Callable[..., bytes]] = Encoder().encode 7 | 8 | 9 | def encode(obj: Any) -> str: 10 | data: bytes = bytes_encode(obj) 11 | return data.decode() 12 | -------------------------------------------------------------------------------- /app/utils/ton/__init__.py: -------------------------------------------------------------------------------- 1 | from .address import AddressType, HasAddress, convert_address, short_address 2 | from .numbers import DEFAULT_TON_DECIMALS, from_nano, to_nano 3 | 4 | __all__ = [ 5 | "HasAddress", 6 | "AddressType", 7 | "convert_address", 8 | "short_address", 9 | "from_nano", 10 | "to_nano", 11 | "DEFAULT_TON_DECIMALS", 12 | ] 13 | -------------------------------------------------------------------------------- /app/factory/__init__.py: -------------------------------------------------------------------------------- 1 | from .app_config import create_app_config 2 | from .redis import create_redis 3 | from .session_pool import create_session_pool 4 | from .telegram import create_bot, create_dispatcher 5 | 6 | __all__ = [ 7 | "create_app_config", 8 | "create_bot", 9 | "create_dispatcher", 10 | "create_redis", 11 | "create_session_pool", 12 | ] 13 | -------------------------------------------------------------------------------- /app/services/database/redis/keys.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from app.utils.key_builder import StorageKey 4 | 5 | 6 | class TcRecordKey(StorageKey, prefix="tc_records"): 7 | telegram_id: int 8 | key: str 9 | 10 | 11 | class UserKey(StorageKey, prefix="users"): 12 | key: str 13 | 14 | 15 | class DeepLinkKey(StorageKey, prefix="deep_links"): 16 | id: UUID 17 | -------------------------------------------------------------------------------- /app/models/dto/deep_link.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | from app.enums import DeepLinkAction 5 | from app.models.base import PydanticModel 6 | 7 | 8 | class DeepLinkDto(PydanticModel): 9 | id: UUID 10 | owner_id: int 11 | action: DeepLinkAction 12 | gift_code_address: Optional[str] = None 13 | gift_code_seed: Optional[str] = None 14 | -------------------------------------------------------------------------------- /app/telegram/handlers/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import F, Router 4 | 5 | from app.telegram.filters import MagicData 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.message.filter(MagicData(F.chat.id == F.config.common.admin_chat_id)) 9 | router.callback_query.filter(MagicData(F.message.chat.id == F.config.common.admin_chat_id)) 10 | -------------------------------------------------------------------------------- /app/models/config/env/redis.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | 3 | from .base import EnvSettings 4 | 5 | 6 | class RedisConfig(EnvSettings, env_prefix="REDIS_"): 7 | host: str 8 | password: SecretStr 9 | port: int 10 | db: int 11 | 12 | def build_url(self) -> str: 13 | return f"redis://:{self.password.get_secret_value()}@{self.host}:{self.port}/{self.db}" 14 | -------------------------------------------------------------------------------- /app/services/backend/types/recipient.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import SplitObject 4 | 5 | 6 | class Recipient(SplitObject): 7 | recipient: str = Field(description="Recipient address for Fragment invoice payment") 8 | photo: str = Field(description="Recipient's photo data as HTML image object") 9 | name: str = Field(description="Recipient's display name") 10 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import gift_codes, language, main, referral_program, shop 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers( 9 | gift_codes.router, 10 | main.router, 11 | shop.router, 12 | language.router, 13 | referral_program.router, 14 | ) 15 | -------------------------------------------------------------------------------- /app/services/backend/methods/resolve_stars_recipient.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from ..types import Recipient 4 | from .base import SplitMethod 5 | 6 | 7 | class ResolveStarsRecipient( 8 | SplitMethod[Recipient], 9 | api_method="/recipients/stars", 10 | returning=Recipient, 11 | response_data_key=["message"], 12 | ): 13 | username: str = Field(description="Product recipient's username") 14 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/gift_codes/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram import Router 4 | 5 | from . import confirm_creation, edit_creation, share, start_creation, use 6 | 7 | router: Final[Router] = Router(name=__name__) 8 | router.include_routers( 9 | confirm_creation.router, 10 | edit_creation.router, 11 | share.router, 12 | start_creation.router, 13 | use.router, 14 | ) 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project 2 | .idea/ 3 | .vscode/ 4 | .code-workspace 5 | data/* 6 | 7 | # Environment 8 | .venv/ 9 | .env 10 | venv/ 11 | docker-compose.yml 12 | poetry.lock 13 | uv.lock 14 | .tests 15 | 16 | # Cache 17 | __pycache__/ 18 | *.py[cod] 19 | .cache/ 20 | .ruff_cache/ 21 | .mypy_cache/ 22 | .pytest_cache/ 23 | .coverage/ 24 | 25 | # Build 26 | build/ 27 | _build/ 28 | dist/ 29 | site/ 30 | *.egg-info/ 31 | *.egg 32 | -------------------------------------------------------------------------------- /app/services/backend/methods/resolve_premium_recipient.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from ..types import Recipient 4 | from .base import SplitMethod 5 | 6 | 7 | class ResolvePremiumRecipient( 8 | SplitMethod[Recipient], 9 | api_method="/recipients/premium", 10 | returning=Recipient, 11 | response_data_key=["message"], 12 | ): 13 | username: str = Field(description="Product recipient's username") 14 | -------------------------------------------------------------------------------- /app/services/backend/methods/create_gift_code.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from ..types import NewGiftCode 4 | from .base import SplitMethod 5 | 6 | 7 | class CreateGiftCode( 8 | SplitMethod[NewGiftCode], 9 | api_method="/gift-code/create", 10 | returning=NewGiftCode, 11 | response_data_key=["message"], 12 | ): 13 | seed: Optional[str] = None 14 | max_activations: int 15 | max_buy_amount: float 16 | -------------------------------------------------------------------------------- /app/services/backend/methods/create_user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | from ..types import User 6 | from .base import SplitMethod 7 | 8 | 9 | class CreateUser( 10 | SplitMethod[User], 11 | api_method="/user/create", 12 | returning=User, 13 | response_data_key=["message", "user"], 14 | ): 15 | inviter: Optional[str] = Field(default=None, description="Inviter's wallet address") 16 | -------------------------------------------------------------------------------- /app/utils/qr.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from io import BytesIO 3 | 4 | import qrcode 5 | from qrcode.image.base import BaseImage 6 | 7 | 8 | def make_qr(url: str) -> bytes: 9 | qr: BaseImage = qrcode.make(data=url) 10 | qr_io: BytesIO = BytesIO() 11 | qr.save(qr_io, "PNG") 12 | qr_io.seek(0) 13 | return qr_io.read() 14 | 15 | 16 | async def create_qr_code(url: str) -> bytes: 17 | return await asyncio.to_thread(make_qr, url=url) 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11-slim 2 | ENV PYTHONPATH "${PYTHONPATH}:/app" 3 | ENV PATH "/app/scripts:${PATH}" 4 | ENV PYTHONUNBUFFERED=1 5 | ENV PIP_DISABLE_PIP_VERSION_CHECK=1 6 | WORKDIR /app 7 | 8 | # Install project dependencies 9 | COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ 10 | COPY pyproject.toml /app/ 11 | RUN uv sync --no-dev 12 | 13 | # Prepare entrypoint 14 | ADD . /app/ 15 | RUN chmod +x scripts/* 16 | ENTRYPOINT ["docker-entrypoint.sh"] 17 | -------------------------------------------------------------------------------- /app/models/config/env/telegram.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | 3 | from app.utils.custom_types import StringList 4 | 5 | from .base import EnvSettings 6 | 7 | 8 | class TelegramConfig(EnvSettings, env_prefix="TELEGRAM_"): 9 | bot_token: SecretStr 10 | locales: StringList 11 | drop_pending_updates: bool = False 12 | use_webhook: bool = False 13 | reset_webhook: bool = True 14 | webhook_path: str 15 | webhook_secret: SecretStr 16 | -------------------------------------------------------------------------------- /app/services/backend/types/new_gift_code.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import SplitObject 4 | from .transaction import Transaction 5 | 6 | 7 | class NewGiftCode(SplitObject): 8 | transaction: Transaction = Field( 9 | description="Transaction body. Must be sent via TON Connect or executed manually.", 10 | ) 11 | seed: str = Field(description="Gift code seed") 12 | address: str = Field(description="Gift code address") 13 | -------------------------------------------------------------------------------- /app/models/dto/__init__.py: -------------------------------------------------------------------------------- 1 | from .deep_link import DeepLinkDto 2 | from .gift_code import FullGiftCodeData, GiftCodeActivation, GiftCodeCreation 3 | from .ton import TonConnection, TonConnectResult, TonWallet 4 | from .user import UserDto 5 | 6 | __all__ = [ 7 | "DeepLinkDto", 8 | "FullGiftCodeData", 9 | "GiftCodeActivation", 10 | "GiftCodeCreation", 11 | "UserDto", 12 | "TonWallet", 13 | "TonConnection", 14 | "TonConnectResult", 15 | ] 16 | -------------------------------------------------------------------------------- /app/utils/logging/setup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def disable_aiogram_logs() -> None: 5 | for name in ["aiogram.middlewares", "aiogram.event", "aiohttp.access"]: 6 | logging.getLogger(name).setLevel(logging.WARNING) 7 | 8 | 9 | def setup_logger(level: int = logging.INFO) -> None: 10 | logging.basicConfig( 11 | format="%(asctime)s %(levelname)s | %(name)s: %(message)s", 12 | datefmt="[%H:%M:%S]", 13 | level=level, 14 | ) 15 | -------------------------------------------------------------------------------- /app/models/config/env/common.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | 3 | from .base import EnvSettings 4 | 5 | 6 | class CommonConfig(EnvSettings, env_prefix="COMMON_"): 7 | admin_chat_id: int = 5945468457 8 | backend_url: str = "https://api.split.tg/" 9 | users_cache_time: int = 30 10 | deep_links_cache_time: int = 300 11 | ton_connect_cache_time: int = 30 12 | ton_center_key: SecretStr = SecretStr("") 13 | ton_api_bridge_key: SecretStr = SecretStr("") 14 | -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/ton_connect.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | 3 | 4 | class CDLinkWallet(CallbackData, prefix="link_wallet"): 5 | pass 6 | 7 | 8 | class CDUnlinkWallet(CallbackData, prefix="unlink_wallet"): 9 | pass 10 | 11 | 12 | class CDChooseWallet(CallbackData, prefix="choose_wallet"): 13 | wallet_name: str 14 | 15 | 16 | class CDCancelConnection(CallbackData, prefix="cancel_connection"): 17 | task_id: str 18 | -------------------------------------------------------------------------------- /app/utils/custom_types.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Annotated, NewType, TypeAlias 2 | 3 | from pydantic import PlainValidator 4 | 5 | if TYPE_CHECKING: 6 | ListStr: TypeAlias = list[str] 7 | else: 8 | ListStr = NewType("ListStr", list[str]) 9 | 10 | StringList: TypeAlias = Annotated[ListStr, PlainValidator(func=lambda x: x.split(","))] 11 | Int16: TypeAlias = Annotated[int, 16] 12 | Int32: TypeAlias = Annotated[int, 32] 13 | Int64: TypeAlias = Annotated[int, 64] 14 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.8.4 4 | hooks: 5 | - id: ruff-format 6 | args: [ "--config=pyproject.toml" ] 7 | - id: ruff 8 | args: [ "--config=pyproject.toml", "--fix" ] 9 | 10 | - repo: https://github.com/pre-commit/mirrors-mypy 11 | rev: v1.12.0 12 | hooks: 13 | - id: mypy 14 | args: [ "--config-file=pyproject.toml" ] 15 | -------------------------------------------------------------------------------- /app/models/sql/mixins/timestamp.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any, Final 3 | 4 | from sqlalchemy import Function, func 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | NowFunc: Final[Function[Any]] = func.timezone("UTC", func.now()) 8 | 9 | 10 | class TimestampMixin: 11 | created_at: Mapped[datetime] = mapped_column(server_default=NowFunc) 12 | updated_at: Mapped[datetime] = mapped_column(server_default=NowFunc, server_onupdate=NowFunc) 13 | -------------------------------------------------------------------------------- /app/models/config/env/__init__.py: -------------------------------------------------------------------------------- 1 | from .app import AppConfig 2 | from .common import CommonConfig 3 | from .postgres import PostgresConfig 4 | from .redis import RedisConfig 5 | from .server import ServerConfig 6 | from .sql_alchemy import SQLAlchemyConfig 7 | from .telegram import TelegramConfig 8 | 9 | __all__ = [ 10 | "AppConfig", 11 | "CommonConfig", 12 | "PostgresConfig", 13 | "RedisConfig", 14 | "ServerConfig", 15 | "SQLAlchemyConfig", 16 | "TelegramConfig", 17 | ] 18 | -------------------------------------------------------------------------------- /app/services/backend/types/transaction.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import SplitObject 4 | from .transaction_message import TransactionMessage 5 | 6 | 7 | class Transaction(SplitObject): 8 | messages: list[TransactionMessage] = Field(description="List of transaction messages") 9 | valid_until: int = Field( 10 | description="Transaction valid until timestamp", 11 | validation_alias="validUntil", 12 | serialization_alias="valid_until", 13 | ) 14 | -------------------------------------------------------------------------------- /app/telegram/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | from .backend_provider import BackendProviderMiddleware 2 | from .message_helper import MessageHelperMiddleware 3 | from .ton_connect import TonConnectMiddleware 4 | from .ton_connect_checker import TonConnectCheckerMiddleware 5 | from .user import UserMiddleware 6 | 7 | __all__ = [ 8 | "BackendProviderMiddleware", 9 | "MessageHelperMiddleware", 10 | "TonConnectCheckerMiddleware", 11 | "TonConnectMiddleware", 12 | "UserMiddleware", 13 | ] 14 | -------------------------------------------------------------------------------- /app/const.py: -------------------------------------------------------------------------------- 1 | from datetime import timezone 2 | from pathlib import Path 3 | from typing import Final 4 | 5 | from .enums import Locale 6 | 7 | TIMEZONE: Final[timezone] = timezone.utc 8 | DEFAULT_LOCALE: Final[str] = Locale.EN 9 | ROOT_DIR: Final[Path] = Path(__file__).parent.parent 10 | ENV_FILE: Final[Path] = ROOT_DIR / ".env" 11 | ASSETS_SOURCE_DIR: Final[Path] = ROOT_DIR / "assets" 12 | MESSAGES_SOURCE_DIR: Final[Path] = ASSETS_SOURCE_DIR / "messages" 13 | SITE_URL: Final[str] = "https://split.tg" 14 | -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/purchase.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | 3 | 4 | class CDSelectSubscriptionPeriod(CallbackData, prefix="subscription_period"): 5 | period: int 6 | 7 | 8 | class CDSelectCurrency(CallbackData, prefix="currency"): 9 | currency: str 10 | 11 | 12 | class CDConfirmPurchase(CallbackData, prefix="confirm_purchase"): 13 | pass 14 | 15 | 16 | class CDSelectUsername(CallbackData, prefix="select_username"): 17 | username: str 18 | -------------------------------------------------------------------------------- /app/services/backend/types/__init__.py: -------------------------------------------------------------------------------- 1 | from .new_gift_code import NewGiftCode 2 | from .recipient import Recipient 3 | from .ton_proof import TonProof 4 | from .ton_proof_domain import TonProofDomain 5 | from .transaction import Transaction 6 | from .transaction_message import TransactionMessage 7 | from .user import User 8 | 9 | __all__ = [ 10 | "NewGiftCode", 11 | "Recipient", 12 | "TonProof", 13 | "TonProofDomain", 14 | "Transaction", 15 | "TransactionMessage", 16 | "User", 17 | ] 18 | -------------------------------------------------------------------------------- /app/services/backend/types/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Optional 3 | 4 | from pydantic import Field 5 | 6 | from .base import SplitObject 7 | 8 | 9 | class User(SplitObject): 10 | id: int = Field(description="Internal user ID") 11 | created_at: datetime = Field(description="User's registration timestamp") 12 | wallet_address: str = Field(description="User's wallet address") 13 | inviter: Optional[str] = Field(default=None, description="Inviter's wallet address") 14 | -------------------------------------------------------------------------------- /app/services/backend/types/ton_proof.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from .base import MutableSplitObject 4 | from .ton_proof_domain import TonProofDomain 5 | 6 | 7 | class TonProof(MutableSplitObject): 8 | timestamp: int = Field(description="Proof timestamp") 9 | domain: TonProofDomain = Field(description="Proof domain") 10 | signature: str = Field(description="Proof signature") 11 | payload: str = Field(description="Custom proof payload") 12 | state_init: str = Field(description="State init") 13 | -------------------------------------------------------------------------------- /app/enums/__init__.py: -------------------------------------------------------------------------------- 1 | from .deep_link_action import DeepLinkAction 2 | from .fragment_error_type import FragmentErrorType 3 | from .gift_code_creation_status import GiftCodeCreationStatus 4 | from .locale import Locale 5 | from .middleware_event_type import MiddlewareEventType 6 | from .pagination_menu_type import PaginationMenuType 7 | 8 | __all__ = [ 9 | "DeepLinkAction", 10 | "FragmentErrorType", 11 | "GiftCodeCreationStatus", 12 | "Locale", 13 | "MiddlewareEventType", 14 | "PaginationMenuType", 15 | ] 16 | -------------------------------------------------------------------------------- /app/models/config/env/app.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from .common import CommonConfig 4 | from .postgres import PostgresConfig 5 | from .redis import RedisConfig 6 | from .server import ServerConfig 7 | from .sql_alchemy import SQLAlchemyConfig 8 | from .telegram import TelegramConfig 9 | 10 | 11 | class AppConfig(BaseModel): 12 | telegram: TelegramConfig 13 | postgres: PostgresConfig 14 | sql_alchemy: SQLAlchemyConfig 15 | redis: RedisConfig 16 | server: ServerConfig 17 | common: CommonConfig 18 | -------------------------------------------------------------------------------- /app/models/sql/base.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger 4 | from sqlalchemy.orm import DeclarativeBase, registry 5 | 6 | from app.utils.custom_types import Int16, Int32, Int64 7 | 8 | 9 | class Base(DeclarativeBase): 10 | registry = registry( 11 | type_annotation_map={ 12 | Int16: SmallInteger, 13 | Int32: Integer, 14 | Int64: BigInteger, 15 | datetime: DateTime(timezone=True), 16 | } 17 | ) 18 | -------------------------------------------------------------------------------- /app/models/config/assets/assets.py: -------------------------------------------------------------------------------- 1 | from pydantic_settings import SettingsConfigDict 2 | 3 | from app.utils.yaml import YAMLSettings, find_assets_sources 4 | 5 | from .gift_codes import GiftCodesConfig 6 | from .shop import ShopConfig 7 | from .ton_connect import TonConnectConfig 8 | 9 | 10 | class Assets(YAMLSettings): 11 | gift_codes: GiftCodesConfig 12 | shop: ShopConfig 13 | ton_connect: TonConnectConfig 14 | 15 | model_config = SettingsConfigDict( 16 | yaml_file_encoding="utf-8", 17 | yaml_file=find_assets_sources(), 18 | ) 19 | -------------------------------------------------------------------------------- /app/models/dto/ton.py: -------------------------------------------------------------------------------- 1 | from uuid import uuid4 2 | 3 | from pydantic import Field 4 | 5 | from app.models.base import PydanticModel 6 | 7 | 8 | class TonWallet(PydanticModel): 9 | name: str 10 | image: str 11 | about_url: str 12 | app_name: str 13 | bridge_url: str 14 | universal_url: str 15 | 16 | 17 | class TonConnection(PydanticModel): 18 | id: str = Field(default_factory=lambda: uuid4().hex) 19 | url: str 20 | ton_proof: str 21 | 22 | 23 | class TonConnectResult(PydanticModel): 24 | address: str 25 | access_token: str 26 | -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/gift_codes.py: -------------------------------------------------------------------------------- 1 | from uuid import UUID 2 | 3 | from aiogram.filters.callback_data import CallbackData 4 | 5 | 6 | class CDSetGiftCodeAmount(CallbackData, prefix="gift_code_amount"): 7 | pass 8 | 9 | 10 | class CDSetGiftCodeActivations(CallbackData, prefix="gift_code_activations"): 11 | pass 12 | 13 | 14 | class CDConfirmGiftCode(CallbackData, prefix="confirm_gift_code"): 15 | pass 16 | 17 | 18 | class CDCreateGiftCode(CallbackData, prefix="create_gift_code"): 19 | pass 20 | 21 | 22 | class CDUseGiftCode(CallbackData, prefix="use_gc"): 23 | link_id: UUID 24 | -------------------------------------------------------------------------------- /nginx/caller.example.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 443 ssl; 3 | server_name YOUR_DOMAIN; 4 | ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; 5 | ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; 6 | 7 | location / { 8 | proxy_set_header Host $http_host; 9 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 10 | proxy_set_header X-Real-IP $remote_addr; 11 | proxy_set_header X-Forwarded-Proto $scheme; 12 | proxy_redirect off; 13 | proxy_buffering off; 14 | proxy_pass http://127.0.0.1:8080; 15 | } 16 | } -------------------------------------------------------------------------------- /app/factory/app_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from app.models.config.env import ( 4 | AppConfig, 5 | CommonConfig, 6 | PostgresConfig, 7 | RedisConfig, 8 | ServerConfig, 9 | SQLAlchemyConfig, 10 | TelegramConfig, 11 | ) 12 | 13 | 14 | # noinspection PyArgumentList 15 | def create_app_config() -> AppConfig: 16 | return AppConfig( 17 | telegram=TelegramConfig(), 18 | postgres=PostgresConfig(), 19 | sql_alchemy=SQLAlchemyConfig(), 20 | redis=RedisConfig(), 21 | server=ServerConfig(), 22 | common=CommonConfig(), 23 | ) 24 | -------------------------------------------------------------------------------- /app/telegram/filters/magic_data.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiogram.filters import MagicData as _MagicData 4 | from magic_filter import MagicFilter 5 | 6 | 7 | class MagicData(_MagicData): 8 | def __init__(self, magic_data: MagicFilter | Any) -> None: 9 | """ 10 | Cause PyCharm complains about an expression like F.smth == F.smth2, 11 | thinking that it will return bool 12 | """ 13 | if not isinstance(magic_data, MagicFilter): 14 | raise TypeError(f"Expected MagicFilter got '{type(magic_data).__name__}'") 15 | super().__init__(magic_data=magic_data) 16 | -------------------------------------------------------------------------------- /app/services/backend/types/transaction_message.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from pydantic import Field 4 | 5 | from .base import SplitObject 6 | 7 | 8 | class TransactionMessage(SplitObject): 9 | address: str = Field(description="Recipient address") 10 | amount: int = Field(description="Amount of TON to send") 11 | payload: Optional[str] = Field( 12 | description="Transaction payload", 13 | default=None, 14 | ) 15 | state_init: Optional[str] = Field( 16 | default=None, 17 | description="Transaction state init", 18 | serialization_alias="stateInit", 19 | ) 20 | -------------------------------------------------------------------------------- /app/models/config/env/postgres.py: -------------------------------------------------------------------------------- 1 | from pydantic import SecretStr 2 | from sqlalchemy import URL 3 | 4 | from .base import EnvSettings 5 | 6 | 7 | class PostgresConfig(EnvSettings, env_prefix="POSTGRES_"): 8 | host: str 9 | db: str 10 | password: SecretStr 11 | port: int 12 | user: str 13 | 14 | def build_url(self) -> URL: 15 | return URL.create( 16 | drivername="postgresql+asyncpg", 17 | username=self.user, 18 | password=self.password.get_secret_value(), 19 | host=self.host, 20 | port=self.port, 21 | database=self.db, 22 | ) 23 | -------------------------------------------------------------------------------- /app/services/backend/methods/use_gift_code.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from ..types import Transaction 4 | from .base import SplitMethod 5 | 6 | 7 | class UseGiftCode( 8 | SplitMethod[Transaction], 9 | api_method="/gift-code/use", 10 | returning=Transaction, 11 | response_data_key=["message"], 12 | ): 13 | seed: str = Field(description="Gift code seed") 14 | amount: float = Field(description="Ton buy amount") 15 | recipient: str = Field(description="Telegram Stars recipient") 16 | gift_code_address: str = Field(description="Gift code contract address") 17 | owner_address: str = Field(description="Gift code owner address") 18 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/lifespan.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final 4 | 5 | from aiogram import Bot, Router 6 | from aiogram.types import BotCommand 7 | 8 | from app.services.backend.session import BackendSession 9 | 10 | router: Final[Router] = Router(name=__name__) 11 | 12 | 13 | @router.startup() 14 | async def set_bot_commands(bot: Bot) -> None: 15 | await bot.set_my_commands(commands=[BotCommand(command="start", description="Main menu")]) 16 | 17 | 18 | @router.shutdown() 19 | async def on_shutdown(bot: Bot, backend_session: BackendSession) -> None: 20 | await bot.session.close() 21 | await backend_session.close() 22 | -------------------------------------------------------------------------------- /app/__main__.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Dispatcher 2 | 3 | from .factory import create_app_config, create_bot, create_dispatcher 4 | from .models.config import AppConfig 5 | from .runners import run_polling, run_webhook 6 | from .utils.logging import setup_logger 7 | 8 | 9 | def main() -> None: 10 | setup_logger() 11 | config: AppConfig = create_app_config() 12 | dispatcher: Dispatcher = create_dispatcher(config=config) 13 | bot: Bot = create_bot(config=config) 14 | if config.telegram.use_webhook: 15 | return run_webhook(dispatcher=dispatcher, bot=bot, config=config) 16 | return run_polling(dispatcher=dispatcher, bot=bot) 17 | 18 | 19 | if __name__ == "__main__": 20 | main() 21 | -------------------------------------------------------------------------------- /migrations/_get_revision_id.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser, Namespace 2 | from pathlib import Path 3 | from sys import argv 4 | from typing import Final 5 | 6 | _DEFAULT_PATH: Final[str] = "./migrations/versions" 7 | 8 | 9 | def get_next_revision_id(path: Path) -> int: 10 | return len(list(path.glob("*.py"))) + 1 11 | 12 | 13 | def main() -> str: 14 | parser: ArgumentParser = ArgumentParser() 15 | parser.add_argument("-p", "--path", dest="path", type=Path, default=_DEFAULT_PATH) 16 | namespace: Namespace = parser.parse_args(argv[1:]) 17 | return "{id:03}".format(id=get_next_revision_id(path=namespace.path)) 18 | 19 | 20 | if __name__ == "__main__": 21 | print(main()) # noqa: T201 22 | -------------------------------------------------------------------------------- /migrations/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 Optional, Sequence 9 | 10 | import sqlalchemy as sa 11 | from alembic import op 12 | 13 | ${imports if imports else ""} 14 | 15 | # revision identifiers, used by Alembic. 16 | revision: str = ${repr(up_revision)} 17 | down_revision: Optional[str] = ${repr(down_revision)} 18 | branch_labels: Optional[Sequence[str]] = ${repr(branch_labels)} 19 | depends_on: Optional[Sequence[str]] = ${repr(depends_on)} 20 | 21 | 22 | def upgrade() -> None: 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade() -> None: 27 | ${downgrades if downgrades else "pass"} 28 | -------------------------------------------------------------------------------- /app/telegram/results/inline_query.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from aiogram import Bot 4 | from aiogram.types import InlineQueryResultArticle, InputTextMessageContent 5 | from aiogram.utils.link import create_telegram_link 6 | from aiogram_i18n import I18nContext 7 | 8 | from app.telegram.keyboards.referral import join_bot_keyboard 9 | 10 | 11 | async def not_found_answer(title: str, i18n: I18nContext, bot: Bot) -> InlineQueryResultArticle: 12 | username: str = cast(str, (await bot.me()).username) 13 | return InlineQueryResultArticle( 14 | id="null", 15 | title=title, 16 | input_message_content=InputTextMessageContent(message_text=r"¯\_(ツ)_/¯"), 17 | reply_markup=join_bot_keyboard(i18n=i18n, url=create_telegram_link(username)), 18 | ) 19 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/general.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | from .base import BaseRepository 6 | from .deep_links import DeepLinksRepository 7 | from .ton_connect import TonConnectRepository 8 | from .users import UsersRepository 9 | 10 | 11 | class Repository(BaseRepository): 12 | users: UsersRepository 13 | ton_connect: TonConnectRepository 14 | deep_links: DeepLinksRepository 15 | 16 | def __init__(self, session: AsyncSession) -> None: 17 | super().__init__(session=session) 18 | self.ton_connect = TonConnectRepository(session=session) 19 | self.users = UsersRepository(session=session) 20 | self.deep_links = DeepLinksRepository(session=session) 21 | -------------------------------------------------------------------------------- /app/services/backend/methods/check_ton_proof.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | from pydantic import Field 4 | 5 | from ..types import TonProof 6 | from .base import SplitMethod 7 | 8 | 9 | class CheckTonProof( 10 | SplitMethod[str], 11 | api_method="/ton-proof/check_proof", 12 | returning=str, 13 | response_data_key=["message", "token"], 14 | ): 15 | address: str = Field(description="Wallet address") 16 | network: Literal["-3", "-239"] = Field( 17 | description=( 18 | "Wallet masterchain ID represented represented as a string.\n" 19 | "-239 for the main network, -3 for the test network." 20 | ) 21 | ) 22 | public_key: str = Field(description="Wallet public key") 23 | proof: TonProof = Field(description="Wallet proof data") 24 | -------------------------------------------------------------------------------- /app/services/database/postgres/uow.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncSession 2 | 3 | from app.models.sql.base import Base 4 | 5 | 6 | class UoW: 7 | session: AsyncSession 8 | 9 | __slots__ = ("session",) 10 | 11 | def __init__(self, session: AsyncSession) -> None: 12 | self.session = session 13 | 14 | async def commit(self, *instances: Base) -> None: 15 | self.session.add_all(instances) 16 | await self.session.commit() 17 | 18 | async def merge(self, *instances: Base) -> None: 19 | for instance in instances: 20 | await self.session.merge(instance) 21 | 22 | async def delete(self, *instances: Base) -> None: 23 | for instance in instances: 24 | await self.session.delete(instance) 25 | await self.session.commit() 26 | -------------------------------------------------------------------------------- /app/telegram/filters/states.py: -------------------------------------------------------------------------------- 1 | from typing import Final 2 | 3 | from aiogram.filters import Filter, StateFilter 4 | from aiogram.fsm.state import State, StatesGroup 5 | 6 | NoneState: Final[Filter] = StateFilter(None) 7 | AnyState: Final[Filter] = ~NoneState 8 | 9 | 10 | class SGBuyPremium(StatesGroup): 11 | enter_username = State() 12 | select_period = State() 13 | # select_currency = State() 14 | confirm = State() 15 | 16 | 17 | class SGBuyStars(StatesGroup): 18 | enter_username = State() 19 | enter_count = State() 20 | # select_currency = State() 21 | confirm = State() 22 | 23 | 24 | class SGCreateGiftCode(StatesGroup): 25 | waiting = State() 26 | amount = State() 27 | activations = State() 28 | 29 | 30 | class SGUseGiftCode(StatesGroup): 31 | username = State() 32 | -------------------------------------------------------------------------------- /app/factory/session_pool.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy.ext.asyncio import ( 4 | AsyncEngine, 5 | AsyncSession, 6 | async_sessionmaker, 7 | create_async_engine, 8 | ) 9 | 10 | from app.models.config import AppConfig 11 | 12 | 13 | def create_session_pool(config: AppConfig) -> async_sessionmaker[AsyncSession]: 14 | engine: AsyncEngine = create_async_engine( 15 | url=config.postgres.build_url(), 16 | echo=config.sql_alchemy.echo, 17 | echo_pool=config.sql_alchemy.echo_pool, 18 | pool_size=config.sql_alchemy.pool_size, 19 | max_overflow=config.sql_alchemy.max_overflow, 20 | pool_timeout=config.sql_alchemy.pool_timeout, 21 | pool_recycle=config.sql_alchemy.pool_recycle, 22 | ) 23 | return async_sessionmaker(engine, expire_on_commit=False) 24 | -------------------------------------------------------------------------------- /app/utils/ton/numbers.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Final, Optional, cast 4 | 5 | DEFAULT_TON_DECIMALS: Final[int] = 9 6 | 7 | 8 | def from_nano( 9 | value: int, 10 | decimals: int = DEFAULT_TON_DECIMALS, 11 | precision: Optional[int] = None, 12 | ) -> float: 13 | if not isinstance(value, int) or value < 0: 14 | raise ValueError("Value must be a positive integer.") 15 | if precision is not None and precision < 0: 16 | raise ValueError("Precision must be a non-negative integer.") 17 | ton_value: float = value / (10**decimals) 18 | if precision is None: 19 | return ton_value 20 | return round(ton_value, precision) 21 | 22 | 23 | def to_nano(value: float, decimals: int = DEFAULT_TON_DECIMALS) -> int: 24 | return cast(int, round(value * (10**decimals))) 25 | -------------------------------------------------------------------------------- /app/utils/yaml.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pydantic_settings import ( 4 | BaseSettings, 5 | PydanticBaseSettingsSource, 6 | YamlConfigSettingsSource, 7 | ) 8 | 9 | from app.const import ASSETS_SOURCE_DIR 10 | 11 | 12 | class YAMLSettings(BaseSettings): 13 | @classmethod 14 | def settings_customise_sources( 15 | cls, 16 | settings_cls: type[BaseSettings], 17 | init_settings: PydanticBaseSettingsSource, 18 | env_settings: PydanticBaseSettingsSource, 19 | dotenv_settings: PydanticBaseSettingsSource, 20 | file_secret_settings: PydanticBaseSettingsSource, 21 | ) -> tuple[PydanticBaseSettingsSource, ...]: 22 | return (YamlConfigSettingsSource(settings_cls),) 23 | 24 | 25 | def find_assets_sources() -> list[Path | str]: 26 | return list(ASSETS_SOURCE_DIR.rglob("*.yml")) 27 | -------------------------------------------------------------------------------- /app/models/dto/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiogram import html 4 | from aiogram.utils.link import create_tg_link 5 | 6 | from app.models.base import PydanticModel 7 | 8 | 9 | class UserDto(PydanticModel): 10 | id: int 11 | telegram_id: int 12 | backend_user_id: Optional[int] = None 13 | backend_access_token: Optional[str] = None 14 | name: str 15 | wallet_address: Optional[str] = None 16 | locale: str 17 | bot_blocked: bool = False 18 | inviter: Optional[str] = None 19 | 20 | @property 21 | def url(self) -> str: 22 | return create_tg_link("user", id=self.telegram_id) 23 | 24 | @property 25 | def mention(self) -> str: 26 | return html.link(value=self.name, link=self.url) 27 | 28 | @property 29 | def wallet_connected(self) -> bool: 30 | return self.wallet_address is not None 31 | -------------------------------------------------------------------------------- /app/models/sql/tc_record.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from sqlalchemy import Index 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from app.utils.custom_types import Int64 7 | 8 | from .base import Base 9 | from .mixins.timestamp import TimestampMixin 10 | 11 | 12 | class TcRecord(Base, TimestampMixin): 13 | __tablename__ = "tc_records" 14 | __table_args__ = ( 15 | Index( 16 | "uix_telegram_id_key", 17 | "telegram_id", 18 | "key", 19 | unique=True, 20 | postgresql_include=["value"], 21 | ), 22 | ) 23 | 24 | id: Mapped[Int64] = mapped_column(primary_key=True, autoincrement=True) 25 | telegram_id: Mapped[Int64] = mapped_column(nullable=False) 26 | key: Mapped[str] = mapped_column(nullable=False) 27 | value: Mapped[str] = mapped_column(nullable=False) 28 | -------------------------------------------------------------------------------- /app/controllers/ton/connect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | from app.models.dto import TonConnection 6 | from app.services.backend import Backend 7 | 8 | if TYPE_CHECKING: 9 | from app.models.dto import TonWallet 10 | from app.services.ton_connect import TcAdapter 11 | 12 | 13 | async def generate_ton_connection( 14 | wallet_name: str, 15 | tc_adapter: TcAdapter, 16 | backend: Backend, 17 | ) -> TonConnection: 18 | wallet: Optional[TonWallet] = await tc_adapter.get_wallet(app_name=wallet_name) 19 | if wallet is None: 20 | raise ValueError("Wallet not found") 21 | ton_proof: str = await backend.generate_ton_proof_payload() 22 | return TonConnection( 23 | url=await tc_adapter.generate_connection_url(wallet=wallet, ton_proof=ton_proof), 24 | ton_proof=ton_proof, 25 | ) 26 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/deep_links.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | from uuid import UUID 3 | 4 | from sqlalchemy import ColumnExpressionArgument 5 | 6 | from app.enums import DeepLinkAction 7 | from app.models.sql import DeepLink 8 | 9 | from .base import BaseRepository 10 | 11 | 12 | class DeepLinksRepository(BaseRepository): 13 | async def get(self, link_id: UUID) -> Optional[DeepLink]: 14 | return await self._get(DeepLink, DeepLink.id == link_id) 15 | 16 | async def by_owner( 17 | self, 18 | owner_id: int, 19 | action: Optional[DeepLinkAction] = None, 20 | ) -> Optional[DeepLink]: 21 | conditions: list[ColumnExpressionArgument[Any]] = [DeepLink.owner_id == owner_id] 22 | if action is not None: 23 | conditions.append(DeepLink.action == action) 24 | return await self._get(DeepLink, DeepLink.owner_id == owner_id) 25 | -------------------------------------------------------------------------------- /app/services/backend/methods/__init__.py: -------------------------------------------------------------------------------- 1 | from .buy_premium import BuyPremium 2 | from .buy_stars import BuyStars 3 | from .check_ton_proof import CheckTonProof 4 | from .create_gift_code import CreateGiftCode 5 | from .create_user import CreateUser 6 | from .generate_ton_proof_payload import GenerateTonProofPayload 7 | from .get_buy_fee import GetBuyFee 8 | from .get_me import GetMe 9 | from .get_ton_rate import GetTonRate 10 | from .resolve_premium_recipient import ResolvePremiumRecipient 11 | from .resolve_stars_recipient import ResolveStarsRecipient 12 | from .use_gift_code import UseGiftCode 13 | 14 | __all__ = [ 15 | "BuyPremium", 16 | "BuyStars", 17 | "CheckTonProof", 18 | "CreateGiftCode", 19 | "CreateUser", 20 | "GenerateTonProofPayload", 21 | "GetBuyFee", 22 | "GetMe", 23 | "GetTonRate", 24 | "ResolvePremiumRecipient", 25 | "ResolveStarsRecipient", 26 | "UseGiftCode", 27 | ] 28 | -------------------------------------------------------------------------------- /app/enums/middleware_event_type.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum, auto 2 | 3 | 4 | class MiddlewareEventType(StrEnum): 5 | UPDATE = auto() 6 | MESSAGE = auto() 7 | EDITED_MESSAGE = auto() 8 | CHANNEL_POST = auto() 9 | EDITED_CHANNEL_POST = auto() 10 | BUSINESS_CONNECTION = auto() 11 | BUSINESS_MESSAGE = auto() 12 | EDITED_BUSINESS_MESSAGE = auto() 13 | DELETED_BUSINESS_MESSAGES = auto() 14 | MESSAGE_REACTION = auto() 15 | MESSAGE_REACTION_COUNT = auto() 16 | INLINE_QUERY = auto() 17 | CHOSEN_INLINE_RESULT = auto() 18 | CALLBACK_QUERY = auto() 19 | SHIPPING_QUERY = auto() 20 | PRE_CHECKOUT_QUERY = auto() 21 | PURCHASED_PAID_MEDIA = auto() 22 | POLL = auto() 23 | POLL_ANSWER = auto() 24 | MY_CHAT_MEMBER = auto() 25 | CHAT_MEMBER = auto() 26 | CHAT_JOIN_REQUEST = auto() 27 | CHAT_BOOST = auto() 28 | REMOVED_CHAT_BOOST = auto() 29 | ERROR = auto() 30 | -------------------------------------------------------------------------------- /app/factory/telegram/bot.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot 6 | from aiogram.client.default import DefaultBotProperties 7 | from aiogram.client.session.aiohttp import AiohttpSession 8 | from aiogram.contrib.middlewares import RetryRequestMiddleware 9 | from aiogram.enums import ParseMode 10 | 11 | from app.utils import mjson 12 | 13 | if TYPE_CHECKING: 14 | from app.models.config import AppConfig 15 | 16 | 17 | def create_bot(config: AppConfig) -> Bot: 18 | session: AiohttpSession = AiohttpSession(json_loads=mjson.decode, json_dumps=mjson.encode) 19 | session.middleware(RetryRequestMiddleware()) 20 | return Bot( 21 | token=config.telegram.bot_token.get_secret_value(), 22 | session=session, 23 | default=DefaultBotProperties( 24 | parse_mode=ParseMode.HTML, 25 | link_preview_is_disabled=True, 26 | ), 27 | ) 28 | -------------------------------------------------------------------------------- /app/models/sql/deep_link.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID, uuid4 3 | 4 | from sqlalchemy import ForeignKey 5 | from sqlalchemy.orm import Mapped, mapped_column 6 | 7 | from app.enums import DeepLinkAction 8 | from app.models.dto import DeepLinkDto 9 | from app.utils.custom_types import Int64 10 | 11 | from .base import Base 12 | from .mixins import TimestampMixin 13 | 14 | 15 | class DeepLink(Base, TimestampMixin): 16 | __tablename__ = "deep_links" 17 | 18 | id: Mapped[UUID] = mapped_column(default=uuid4, primary_key=True) 19 | owner_id: Mapped[Int64] = mapped_column(ForeignKey("users.id")) 20 | action: Mapped[DeepLinkAction] = mapped_column(default=DeepLinkAction.INVITE) 21 | gift_code_address: Mapped[Optional[str]] = mapped_column(nullable=True) 22 | gift_code_seed: Mapped[Optional[str]] = mapped_column(nullable=True) 23 | 24 | def dto(self) -> DeepLinkDto: 25 | return DeepLinkDto.model_validate(self) 26 | -------------------------------------------------------------------------------- /app/services/task_manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from contextlib import suppress 5 | from typing import Any, Coroutine, Optional 6 | 7 | 8 | class TaskManager: 9 | tasks: dict[str, asyncio.Task[Any]] 10 | 11 | def __init__(self) -> None: 12 | self.tasks = {} 13 | 14 | def run_task(self, task_name: str, coro: Coroutine[Any, Any, Any]) -> None: 15 | if task_name in self.tasks: 16 | raise ValueError(f"Task {task_name} is already running") 17 | task = self.tasks[task_name] = asyncio.create_task(coro=coro) 18 | task.add_done_callback(lambda _: self.tasks.pop(task_name, None)) 19 | 20 | async def cancel_task(self, task_name: str) -> None: 21 | task: Optional[asyncio.Task[Any]] = self.tasks.pop(task_name, None) 22 | if task is None: 23 | return 24 | task.cancel() 25 | with suppress(asyncio.CancelledError): 26 | await task 27 | -------------------------------------------------------------------------------- /app/telegram/keyboards/language.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | from aiogram_i18n import I18nContext 4 | 5 | from app.utils.localization.patches import FluentBool 6 | 7 | from .callback_data.menu import CDMenu, CDSetLanguage 8 | 9 | 10 | def language_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 11 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 12 | for locale in i18n.core.available_locales: 13 | builder.button( 14 | text=i18n.extra.selectable( 15 | value=i18n.get("extra-language", locale), 16 | selected=FluentBool(locale == i18n.locale), 17 | ), 18 | callback_data=CDSetLanguage(locale=locale), 19 | ) 20 | builder.adjust(2) 21 | builder.row(InlineKeyboardButton(text=i18n.buttons.back(), callback_data=CDMenu().pack())) 22 | return builder.as_markup() 23 | -------------------------------------------------------------------------------- /app/enums/locale.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from enum import StrEnum, auto 4 | 5 | 6 | class Locale(StrEnum): 7 | EN = auto() # English 8 | UK = auto() # Ukrainian 9 | AR = auto() # Arabic 10 | AZ = auto() # Azerbaijani 11 | BE = auto() # Belarusian 12 | CS = auto() # Czech 13 | DE = auto() # German 14 | ES = auto() # Spanish 15 | FA = auto() # Persian 16 | FR = auto() # French 17 | HE = auto() # Hebrew 18 | HI = auto() # Hindi 19 | ID = auto() # Indonesian 20 | IT = auto() # Italian 21 | JA = auto() # Japanese 22 | KK = auto() # Kazakh 23 | KO = auto() # Korean 24 | MS = auto() # Malay 25 | NL = auto() # Dutch 26 | PL = auto() # Polish 27 | PT = auto() # Portuguese 28 | RO = auto() # Romanian 29 | SR = auto() # Serbian 30 | TR = auto() # Turkish 31 | UZ = auto() # Uzbek 32 | VI = auto() # Vietnamese 33 | RU = auto() # Russian 34 | -------------------------------------------------------------------------------- /app/telegram/middlewares/event_typed.py: -------------------------------------------------------------------------------- 1 | from abc import ABC 2 | from typing import ClassVar, Final 3 | 4 | from aiogram import BaseMiddleware, Router 5 | 6 | from app.enums import MiddlewareEventType 7 | 8 | DEFAULT_UPDATE_TYPES: Final[list[MiddlewareEventType]] = [ 9 | MiddlewareEventType.MESSAGE, 10 | MiddlewareEventType.CALLBACK_QUERY, 11 | MiddlewareEventType.MY_CHAT_MEMBER, 12 | MiddlewareEventType.ERROR, 13 | MiddlewareEventType.INLINE_QUERY, 14 | ] 15 | 16 | 17 | class EventTypedMiddleware(BaseMiddleware, ABC): 18 | __event_types__: ClassVar[list[MiddlewareEventType]] = DEFAULT_UPDATE_TYPES 19 | 20 | def setup_inner(self, router: Router) -> None: 21 | for event_type in self.__event_types__: 22 | router.observers[event_type].middleware(self) 23 | 24 | def setup_outer(self, router: Router) -> None: 25 | for event_type in self.__event_types__: 26 | router.observers[event_type].outer_middleware(self) 27 | -------------------------------------------------------------------------------- /app/utils/localization/manager.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional, cast 4 | 5 | from aiogram.types import User as AiogramUser 6 | from aiogram_i18n.managers import BaseManager 7 | 8 | from app.services.user import UserService 9 | 10 | if TYPE_CHECKING: 11 | from app.models.dto import UserDto 12 | 13 | 14 | class UserManager(BaseManager): 15 | async def get_locale( 16 | self, 17 | event_from_user: Optional[AiogramUser] = None, 18 | user: Optional[UserDto] = None, 19 | ) -> str: 20 | if user is not None: 21 | return user.locale 22 | if event_from_user and event_from_user.language_code is not None: 23 | return event_from_user.language_code 24 | return cast(str, self.default_locale) 25 | 26 | async def set_locale(self, locale: str, user: UserDto, user_service: UserService) -> None: 27 | await user_service.update(user=user, locale=locale) 28 | -------------------------------------------------------------------------------- /app/models/base.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Self 2 | 3 | from aiogram.fsm.context import FSMContext 4 | from pydantic import BaseModel as _BaseModel 5 | from pydantic import ConfigDict, PrivateAttr 6 | 7 | 8 | class PydanticModel(_BaseModel): 9 | model_config = ConfigDict( 10 | extra="ignore", 11 | from_attributes=True, 12 | ) 13 | 14 | __updated: dict[str, Any] = PrivateAttr(default_factory=dict) 15 | 16 | @property 17 | def model_state(self) -> dict[str, Any]: 18 | return self.__updated 19 | 20 | def __setattr__(self, name: str, value: Any) -> None: 21 | super().__setattr__(name, value) 22 | self.__updated[name] = value 23 | 24 | @classmethod 25 | async def from_state(cls, state: FSMContext) -> Self: 26 | # noinspection PyArgumentList 27 | return cls(**await state.get_data()) 28 | 29 | async def update_state(self, state: FSMContext) -> None: 30 | await state.update_data(self.model_dump()) 31 | -------------------------------------------------------------------------------- /app/telegram/keyboards/callback_data/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.contrib.paginator import CDPagination as _CDPagination 2 | from aiogram.filters.callback_data import CallbackData 3 | from pydantic import Field 4 | 5 | from app.utils.time import datetime_now 6 | 7 | 8 | class CDMenu(CallbackData, prefix="menu"): 9 | pass 10 | 11 | 12 | class CDTelegramPremium(CallbackData, prefix="telegram_premium"): 13 | pass 14 | 15 | 16 | class CDTelegramStars(CallbackData, prefix="telegram_stars"): 17 | pass 18 | 19 | 20 | class CDLanguage(CallbackData, prefix="language"): 21 | pass 22 | 23 | 24 | class CDSetLanguage(CallbackData, prefix="set_language"): 25 | locale: str 26 | 27 | 28 | class CDReferralProgram(CallbackData, prefix="referral_program"): 29 | pass 30 | 31 | 32 | class CDPagination(_CDPagination, prefix="pt"): 33 | pass 34 | 35 | 36 | class CDRefresh(CallbackData, prefix="r"): 37 | timestamp: float = Field(default_factory=lambda: datetime_now().timestamp()) 38 | -------------------------------------------------------------------------------- /app/services/backend/methods/buy_stars.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from app.enums.payment_method import PaymentMethod 4 | 5 | from ..types import Transaction 6 | from .base import SplitMethod 7 | 8 | 9 | class BuyStars( 10 | SplitMethod[Transaction], 11 | api_method="/buy/stars", 12 | returning=Transaction, 13 | response_data_key=["message", "transaction"], 14 | ): 15 | recipient: str = Field( 16 | description=( 17 | "Recipient address for Fragment invoice payment. " 18 | "Can be found via search recipient method" 19 | ), 20 | ) 21 | payment_method: PaymentMethod = Field( 22 | default=PaymentMethod.TON_CONNECT, 23 | description=( 24 | "Payment method. Default is transaction that " 25 | "must be sent via TON Connect or executed manually on TON chain" 26 | ), 27 | ) 28 | quantity: int = Field(description="How much stars to buy") 29 | username: str = Field(description="Recipient's username") 30 | -------------------------------------------------------------------------------- /app/controllers/ton/waiter.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from pytonconnect import TonConnect 4 | 5 | from app.controllers.ton.proof import verify_ton_proof 6 | from app.models.dto import TonConnectResult 7 | from app.services.backend import Backend 8 | from app.services.ton_connect import TcAdapter 9 | from app.utils.ton import convert_address 10 | 11 | 12 | # noinspection PyProtectedMember 13 | async def wait_until_connected( 14 | ton_connect: TcAdapter, 15 | timeout: int, 16 | backend: Backend, 17 | ) -> TonConnectResult: 18 | connector: TonConnect = ton_connect.connector 19 | for _ in range(timeout): 20 | await asyncio.sleep(1) 21 | if not connector.connected or not connector.account or not connector.account.address: 22 | continue 23 | return TonConnectResult( 24 | address=convert_address(connector.account), 25 | access_token=await verify_ton_proof(connector=connector, backend=backend), 26 | ) 27 | raise TimeoutError("Connection timeout") 28 | -------------------------------------------------------------------------------- /app/services/backend/methods/buy_premium.py: -------------------------------------------------------------------------------- 1 | from pydantic import Field 2 | 3 | from app.enums.payment_method import PaymentMethod 4 | 5 | from ..types import Transaction 6 | from .base import SplitMethod 7 | 8 | 9 | class BuyPremium( 10 | SplitMethod[Transaction], 11 | api_method="/buy/premium", 12 | returning=Transaction, 13 | response_data_key=["message", "transaction"], 14 | ): 15 | recipient: str = Field( 16 | description=( 17 | "Recipient address for Fragment invoice payment. " 18 | "Can be found via search recipient method" 19 | ), 20 | ) 21 | payment_method: PaymentMethod = Field( 22 | default=PaymentMethod.TON_CONNECT, 23 | description=( 24 | "Payment method. Default is transaction that " 25 | "must be sent via TON Connect or executed manually on TON chain" 26 | ), 27 | ) 28 | months: int = Field(description="Subscription duration in months") 29 | username: str = Field(description="Recipient's username") 30 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/users.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from app.models.sql import User 4 | 5 | from .base import BaseRepository 6 | 7 | 8 | class UsersRepository(BaseRepository): 9 | async def get(self, user_id: int) -> Optional[User]: 10 | return await self._get(User, User.id == user_id) 11 | 12 | async def by_tg_id(self, telegram_id: int) -> Optional[User]: 13 | return await self._get(User, User.telegram_id == telegram_id) 14 | 15 | async def by_address(self, wallet_address: str) -> Optional[User]: 16 | return await self._get(User, User.wallet_address == wallet_address) 17 | 18 | async def update(self, user_id: int, **kwargs: Any) -> Optional[User]: 19 | return await self._update( 20 | model=User, 21 | conditions=[User.id == user_id], 22 | load_result=False, 23 | **kwargs, 24 | ) 25 | 26 | async def delete(self, user_id: int) -> bool: 27 | return await self._delete(User, User.id == user_id) 28 | -------------------------------------------------------------------------------- /app/utils/ton/address.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Protocol, TypeAlias, Union, cast 4 | 5 | from pytoniq_core import Address 6 | 7 | 8 | class HasAddress(Protocol): 9 | address: Address | str 10 | 11 | 12 | AddressType: TypeAlias = Union[HasAddress, Address, str] 13 | 14 | 15 | def convert_address( 16 | source: AddressType, 17 | is_user_friendly: bool = True, 18 | is_url_safe: bool = True, 19 | is_bounceable: bool = False, 20 | is_test_only: bool = False, 21 | ) -> str: 22 | if hasattr(source, "address"): 23 | source = source.address 24 | return cast( 25 | str, 26 | Address(source).to_str( 27 | is_user_friendly=is_user_friendly, 28 | is_url_safe=is_url_safe, 29 | is_bounceable=is_bounceable, 30 | is_test_only=is_test_only, 31 | ), 32 | ) 33 | 34 | 35 | def short_address(address: AddressType) -> str: 36 | address = convert_address(address) 37 | return f"{address[:6]}...{address[-6:]}" 38 | -------------------------------------------------------------------------------- /app/controllers/price.py: -------------------------------------------------------------------------------- 1 | from app.models.base import PydanticModel 2 | from app.models.config import Assets 3 | from app.services.backend import Backend 4 | 5 | 6 | class PriceDto(PydanticModel): 7 | usd_price: float 8 | ton_price: float 9 | 10 | 11 | async def get_price(base_usd_price: float, backend: Backend) -> PriceDto: 12 | fee: float = await backend.get_buy_fee() 13 | ton_price_usd: float = await backend.get_ton_rate() 14 | usd_price: float = base_usd_price * (1 + fee) 15 | return PriceDto( 16 | usd_price=usd_price, 17 | ton_price=usd_price / ton_price_usd, 18 | ) 19 | 20 | 21 | async def get_stars_price(quantity: int, backend: Backend, assets: Assets) -> PriceDto: 22 | return await get_price(base_usd_price=quantity * assets.shop.stars_price, backend=backend) 23 | 24 | 25 | async def get_premium_price(months: int, backend: Backend, assets: Assets) -> PriceDto: 26 | return await get_price( 27 | base_usd_price=assets.shop.subscription_periods[months], 28 | backend=backend, 29 | ) 30 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/pm.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router 6 | from aiogram.enums import ChatType 7 | from aiogram.filters import JOIN_TRANSITION, LEAVE_TRANSITION, ChatMemberUpdatedFilter 8 | from aiogram.types import ChatMemberUpdated 9 | 10 | if TYPE_CHECKING: 11 | from app.models.dto import UserDto 12 | from app.services.user import UserService 13 | 14 | router: Final[Router] = Router(name=__name__) 15 | router.my_chat_member.filter(F.chat.type == ChatType.PRIVATE) 16 | 17 | 18 | @router.my_chat_member(ChatMemberUpdatedFilter(JOIN_TRANSITION)) 19 | async def bot_unblocked(_: ChatMemberUpdated, user: UserDto, user_service: UserService) -> Any: 20 | await user_service.update(user=user, bot_blocked=False) 21 | 22 | 23 | @router.my_chat_member(ChatMemberUpdatedFilter(LEAVE_TRANSITION)) 24 | async def bot_blocked(_: ChatMemberUpdated, user: UserDto, user_service: UserService) -> Any: 25 | await user_service.update(user=user, bot_blocked=True) 26 | -------------------------------------------------------------------------------- /app/utils/localization/patches.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from fluent.runtime.types import FluentNumber, FluentType, NumberFormatOptions 4 | 5 | FluentNumber.default_number_format_options = NumberFormatOptions(useGrouping=False) 6 | 7 | 8 | class FluentBool(FluentType): 9 | def __init__(self, value: Any) -> None: 10 | self.value = bool(value) 11 | 12 | def format(self, *_: Any) -> str: 13 | if self.value: 14 | return "true" 15 | return "false" 16 | 17 | def __eq__(self, other: object) -> bool: 18 | if isinstance(other, str): 19 | return self.format() == other 20 | return False 21 | 22 | 23 | class FluentNullable(FluentType): 24 | def __init__(self, value: Optional[str | float] = None) -> None: 25 | self.value = value 26 | 27 | def format(self, *_: Any) -> str: 28 | if self.value is not None: 29 | return str(self.value) 30 | return "null" 31 | 32 | def __eq__(self, other: object) -> bool: 33 | if isinstance(other, str): 34 | return self.format() == other 35 | return False 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 itanarchy 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 | -------------------------------------------------------------------------------- /docker-compose.example.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | restart: always 5 | env_file: .env 6 | ports: 7 | - "${REDIS_PORT}:${REDIS_PORT}" 8 | expose: 9 | - "${REDIS_PORT}" 10 | volumes: 11 | - redis-data:${REDIS_DATA} 12 | command: [ "--requirepass", "${REDIS_PASSWORD}" ] 13 | 14 | postgres: 15 | image: postgres:16-alpine 16 | restart: always 17 | env_file: .env 18 | environment: 19 | POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} 20 | POSTGRES_USER: ${POSTGRES_USER} 21 | POSTGRES_DB: ${POSTGRES_DB} 22 | PGDATA: ${POSTGRES_DATA} 23 | ports: 24 | - "${POSTGRES_PORT}:${POSTGRES_PORT}" 25 | expose: 26 | - "${POSTGRES_PORT}" 27 | volumes: 28 | - postgres-data:${POSTGRES_DATA} 29 | 30 | bot: 31 | build: . 32 | restart: always 33 | env_file: .env 34 | depends_on: 35 | - redis 36 | - postgres 37 | ports: 38 | - "${SERVER_PORT}:${SERVER_PORT}" 39 | 40 | volumes: 41 | redis-data: 42 | postgres-data: 43 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/main.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Router 6 | from aiogram.filters import CommandStart 7 | from aiogram.fsm.context import FSMContext 8 | from aiogram.types import TelegramObject 9 | from aiogram_i18n import I18nContext 10 | 11 | from app.telegram.keyboards.callback_data.menu import CDMenu 12 | from app.telegram.keyboards.menu import menu_keyboard 13 | 14 | if TYPE_CHECKING: 15 | from app.models.dto import UserDto 16 | from app.telegram.helpers.messages import MessageHelper 17 | 18 | router: Final[Router] = Router(name=__name__) 19 | 20 | 21 | @router.message(CommandStart()) 22 | @router.callback_query(CDMenu.filter()) 23 | async def show_main_menu( 24 | _: TelegramObject, 25 | helper: MessageHelper, 26 | i18n: I18nContext, 27 | state: FSMContext, 28 | user: UserDto, 29 | ) -> Any: 30 | await state.clear() 31 | return await helper.answer( 32 | text=i18n.messages.hello(name=user.mention), 33 | reply_markup=menu_keyboard(i18n=i18n, wallet_connected=user.wallet_connected), 34 | ) 35 | -------------------------------------------------------------------------------- /migrations/versions/002_create_indexes.py: -------------------------------------------------------------------------------- 1 | """create_indexes 2 | 3 | Revision ID: 002 4 | Revises: 001 5 | Create Date: 2024-12-10 14:41:07.782284 6 | 7 | """ 8 | 9 | from typing import Optional, Sequence 10 | 11 | from alembic import op 12 | 13 | # revision identifiers, used by Alembic. 14 | revision: str = "002" 15 | down_revision: Optional[str] = "001" 16 | branch_labels: Optional[Sequence[str]] = None 17 | depends_on: Optional[Sequence[str]] = None 18 | 19 | 20 | def upgrade() -> None: 21 | op.create_index( 22 | index_name="uix_telegram_id_key", 23 | table_name="tc_records", 24 | columns=["telegram_id", "key"], 25 | unique=True, 26 | postgresql_include=["value"], 27 | ) 28 | op.create_unique_constraint( 29 | constraint_name="uix_wallet_address", 30 | table_name="users", 31 | columns=["wallet_address"], 32 | ) 33 | 34 | 35 | def downgrade() -> None: 36 | op.drop_index(index_name="uix_telegram_id_key", table_name="tc_records") 37 | op.drop_constraint( 38 | constraint_name="uix_wallet_address", 39 | table_name="users", 40 | type_="unique", 41 | ) 42 | -------------------------------------------------------------------------------- /app/models/sql/user.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy import String 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from app.models.dto import UserDto 7 | from app.utils.custom_types import Int64 8 | 9 | from .base import Base 10 | from .mixins import TimestampMixin 11 | 12 | 13 | class User(Base, TimestampMixin): 14 | __tablename__ = "users" 15 | 16 | id: Mapped[Int64] = mapped_column(primary_key=True, autoincrement=True) 17 | telegram_id: Mapped[Int64] = mapped_column(nullable=False, unique=True) 18 | backend_user_id: Mapped[Optional[Int64]] = mapped_column(nullable=True, unique=True) 19 | backend_access_token: Mapped[Optional[str]] = mapped_column(nullable=True) 20 | 21 | name: Mapped[str] = mapped_column(nullable=False) 22 | wallet_address: Mapped[Optional[str]] = mapped_column(nullable=True, unique=True) 23 | locale: Mapped[str] = mapped_column(String(length=2), nullable=False) 24 | bot_blocked: Mapped[bool] = mapped_column(default=False, nullable=False) 25 | inviter: Mapped[Optional[str]] = mapped_column(nullable=True) 26 | 27 | def dto(self) -> UserDto: 28 | return UserDto.model_validate(self) 29 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/language.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Router 6 | from aiogram.types import TelegramObject 7 | from aiogram_i18n import I18nContext 8 | 9 | from app.telegram.keyboards.callback_data.menu import CDLanguage, CDSetLanguage 10 | from app.telegram.keyboards.language import language_keyboard 11 | 12 | if TYPE_CHECKING: 13 | from app.telegram.helpers.messages import MessageHelper 14 | 15 | router: Final[Router] = Router(name=__name__) 16 | 17 | 18 | @router.callback_query(CDLanguage.filter()) 19 | async def show_language_menu(_: TelegramObject, helper: MessageHelper, i18n: I18nContext) -> Any: 20 | return await helper.answer( 21 | text=i18n.messages.language(), 22 | reply_markup=language_keyboard(i18n=i18n), 23 | ) 24 | 25 | 26 | @router.callback_query(CDSetLanguage.filter()) 27 | async def set_language( 28 | _: TelegramObject, 29 | callback_data: CDSetLanguage, 30 | helper: MessageHelper, 31 | i18n: I18nContext, 32 | ) -> Any: 33 | await i18n.set_locale(locale=callback_data.locale) 34 | return await show_language_menu(_, helper, i18n) 35 | -------------------------------------------------------------------------------- /app/telegram/handlers/ton_connect/select.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router 6 | from aiogram.types import TelegramObject 7 | from aiogram_i18n import I18nContext 8 | 9 | from app.enums import PaginationMenuType 10 | from app.telegram.keyboards.callback_data.menu import CDPagination 11 | 12 | if TYPE_CHECKING: 13 | from app.services.ton_connect import TcAdapter 14 | from app.telegram.helpers.messages import MessageHelper 15 | from app.telegram.helpers.paginator import Paginator 16 | 17 | router: Final[Router] = Router(name=__name__) 18 | 19 | 20 | # noinspection PyTypeChecker 21 | @router.callback_query(CDPagination.filter(F.type == PaginationMenuType.TON_WALLET)) 22 | async def select_ton_wallet( 23 | _: TelegramObject, 24 | helper: MessageHelper, 25 | i18n: I18nContext, 26 | ton_connect: TcAdapter, 27 | paginator: Paginator, 28 | ) -> Any: 29 | return await helper.answer( 30 | text=i18n.messages.choose_wallet(), 31 | reply_markup=paginator.choose_wallet_keyboard( 32 | i18n=i18n, 33 | wallets=await ton_connect.get_wallets(), 34 | ), 35 | ) 36 | -------------------------------------------------------------------------------- /app/services/backend/errors.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from stollen.exceptions import StollenAPIError 4 | 5 | from app.enums import FragmentErrorType 6 | 7 | 8 | def detect_fragment_error_type(message: str) -> str: 9 | if message == "This account is already subscribed to Telegram Premium.": 10 | return FragmentErrorType.ALREADY_PREMIUM 11 | if message == "Please enter a username assigned to a user.": 12 | return FragmentErrorType.USERNAME_NOT_ASSIGNED 13 | if message == "No Telegram users found.": 14 | return FragmentErrorType.USERNAME_NOT_FOUND 15 | return message 16 | 17 | 18 | class SplitAPIError(StollenAPIError): 19 | pass 20 | 21 | 22 | class SplitBadRequestError(SplitAPIError): 23 | def __init__(self, message: str, **kwargs: Any) -> None: 24 | super().__init__(message=detect_fragment_error_type(message), **kwargs) 25 | 26 | 27 | class SplitUnauthorizedError(SplitAPIError): 28 | pass 29 | 30 | 31 | class SplitNotFoundError(SplitAPIError): 32 | pass 33 | 34 | 35 | class SplitMethodNotAllowedError(SplitAPIError): 36 | pass 37 | 38 | 39 | class SplitConflictError(SplitAPIError): 40 | pass 41 | 42 | 43 | class SplitInternalError(SplitAPIError): 44 | pass 45 | -------------------------------------------------------------------------------- /app/telegram/handlers/ton_connect/unlink.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Router 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import CallbackQuery 8 | from aiogram_i18n import I18nContext 9 | 10 | from app.controllers.auth import logout_user 11 | from app.models.dto import UserDto 12 | from app.services.user import UserService 13 | from app.telegram.keyboards.callback_data.ton_connect import CDUnlinkWallet 14 | 15 | from ..menu.main import show_main_menu 16 | 17 | if TYPE_CHECKING: 18 | from app.services.ton_connect import TcAdapter 19 | from app.telegram.helpers.messages import MessageHelper 20 | 21 | router: Final[Router] = Router(name=__name__) 22 | 23 | 24 | @router.callback_query(CDUnlinkWallet.filter()) 25 | async def unlink_wallet( 26 | _: CallbackQuery, 27 | helper: MessageHelper, 28 | i18n: I18nContext, 29 | state: FSMContext, 30 | user: UserDto, 31 | user_service: UserService, 32 | ton_connect: TcAdapter, 33 | ) -> Any: 34 | await logout_user(user=user, user_service=user_service, ton_connect=ton_connect) 35 | await show_main_menu(_=_, helper=helper, i18n=i18n, state=state, user=user) 36 | -------------------------------------------------------------------------------- /app/controllers/gift_codes/get.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from aiohttp import ClientResponseError 4 | from pytoniq_core import Cell 5 | from tonutils.client import ToncenterClient 6 | 7 | from app.models.dto import FullGiftCodeData 8 | from app.utils.ton import from_nano 9 | 10 | 11 | async def get_giftcode_data(address: str, toncenter: ToncenterClient) -> FullGiftCodeData: 12 | try: 13 | giftcode_data: dict[str, Any] = await toncenter.run_get_method( 14 | address=address, 15 | method_name="get_giftcode_data", 16 | ) 17 | except ClientResponseError as error: 18 | if error.status == 401: 19 | raise ValueError("Not found") 20 | raise 21 | 22 | stack: list[Any] = giftcode_data["stack"] 23 | if len(stack) < 7: 24 | raise ValueError("Not found") 25 | 26 | return FullGiftCodeData( 27 | owner_address=( 28 | Cell.from_boc(data=stack[1]["value"])[0] 29 | .begin_parse() 30 | .load_address() 31 | .to_str(is_user_friendly=False) 32 | ), 33 | total_activations=int(stack[4]["value"], 16), 34 | max_activations=int(stack[5]["value"], 16), 35 | max_buy_amount=from_nano(int(stack[6]["value"], 16)), 36 | ) 37 | -------------------------------------------------------------------------------- /app/services/database/postgres/context.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from types import TracebackType 3 | from typing import Optional 4 | 5 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 6 | 7 | from .repositories import Repository 8 | from .uow import UoW 9 | 10 | 11 | class SQLSessionContext: 12 | _session_pool: async_sessionmaker[AsyncSession] 13 | _session: Optional[AsyncSession] 14 | 15 | __slots__ = ("_session_pool", "_session") 16 | 17 | def __init__(self, session_pool: async_sessionmaker[AsyncSession]) -> None: 18 | self._session_pool = session_pool 19 | self._session = None 20 | 21 | async def __aenter__(self) -> tuple[Repository, UoW]: 22 | self._session = await self._session_pool().__aenter__() 23 | return Repository(session=self._session), UoW(session=self._session) 24 | 25 | async def __aexit__( 26 | self, 27 | exc_type: Optional[type[BaseException]], 28 | exc_value: Optional[BaseException], 29 | traceback: Optional[TracebackType], 30 | ) -> None: 31 | if self._session is None: 32 | return 33 | task: asyncio.Task[None] = asyncio.create_task(self._session.close()) 34 | await asyncio.shield(task) 35 | self._session = None 36 | -------------------------------------------------------------------------------- /app/telegram/middlewares/backend_provider.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 4 | 5 | from aiogram.types import TelegramObject 6 | 7 | from app.services.backend import Backend 8 | from app.services.backend.session import BackendSession 9 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 10 | 11 | if TYPE_CHECKING: 12 | from app.models.config import AppConfig 13 | from app.models.dto import UserDto 14 | 15 | 16 | class BackendProviderMiddleware(EventTypedMiddleware): 17 | async def __call__( 18 | self, 19 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 20 | event: TelegramObject, 21 | data: dict[str, Any], 22 | ) -> Optional[Any]: 23 | user: Optional[UserDto] = data.get("user") 24 | if user is None: 25 | return await handler(event, data) 26 | config: AppConfig = data["config"] 27 | session: BackendSession = data["backend_session"] 28 | data["backend"] = Backend( 29 | base_url=config.common.backend_url, 30 | access_token=user.backend_access_token, 31 | session=session, 32 | ) 33 | return await handler(event, data) 34 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/ton_connect.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from sqlalchemy.dialects.postgresql import insert 4 | 5 | from app.models.sql import TcRecord 6 | 7 | from .base import BaseRepository 8 | 9 | 10 | # noinspection PyTypeChecker 11 | class TonConnectRepository(BaseRepository): 12 | async def remove_record(self, telegram_id: int, key: str) -> bool: 13 | return await self._delete( 14 | TcRecord, 15 | TcRecord.telegram_id == telegram_id, 16 | TcRecord.key == key, 17 | ) 18 | 19 | async def get_record_value(self, telegram_id: int, key: str) -> Optional[str]: 20 | return await self._get( 21 | TcRecord.value, 22 | TcRecord.telegram_id == telegram_id, 23 | TcRecord.key == key, 24 | ) 25 | 26 | async def set_record(self, telegram_id: int, key: str, value: str) -> None: 27 | query = ( 28 | insert(TcRecord) 29 | .values(telegram_id=telegram_id, key=key, value=value) 30 | .on_conflict_do_update( 31 | index_elements=["telegram_id", "key"], 32 | set_={"value": value}, 33 | ) 34 | ) 35 | await self.session.execute(query) 36 | await self.session.commit() 37 | -------------------------------------------------------------------------------- /app/telegram/keyboards/referral.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import CopyTextButton, InlineKeyboardMarkup 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | from aiogram_i18n import I18nContext 4 | 5 | from .callback_data.menu import CDMenu 6 | 7 | 8 | def referral_program_keyboard(i18n: I18nContext, url: str) -> InlineKeyboardMarkup: 9 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 10 | builder.button( 11 | text=i18n.buttons.copy_link(), 12 | copy_text=CopyTextButton(text=i18n.messages.referral.invite(link=url)), 13 | ) 14 | builder.button(text=i18n.buttons.share(), switch_inline_query="") 15 | builder.button(text=i18n.buttons.back(), callback_data=CDMenu()) 16 | builder.adjust(2, 1, repeat=True) 17 | return builder.as_markup() 18 | 19 | 20 | def join_bot_keyboard(i18n: I18nContext, url: str) -> InlineKeyboardMarkup: 21 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 22 | builder.button(text=i18n.buttons.join_bot(), url=url) 23 | return builder.as_markup() 24 | 25 | 26 | def share_keyboard(i18n: I18nContext, deep_link_id: str) -> InlineKeyboardMarkup: 27 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 28 | builder.button(text=i18n.buttons.share(), switch_inline_query=deep_link_id) 29 | return builder.as_markup() 30 | -------------------------------------------------------------------------------- /app/telegram/middlewares/message_helper.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Awaitable, Callable, cast 4 | 5 | from aiogram.types import CallbackQuery, ErrorEvent, Message, TelegramObject, Update 6 | 7 | from app.enums import MiddlewareEventType 8 | from app.telegram.helpers.messages import MessageHelper 9 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 10 | 11 | 12 | class MessageHelperMiddleware(EventTypedMiddleware): 13 | __event_types__ = [ 14 | MiddlewareEventType.MESSAGE, 15 | MiddlewareEventType.CALLBACK_QUERY, 16 | MiddlewareEventType.ERROR, 17 | ] 18 | 19 | async def __call__( 20 | self, 21 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 22 | event: TelegramObject, 23 | data: dict[str, Any], 24 | ) -> Any: 25 | update = event 26 | if isinstance(update, ErrorEvent): 27 | update = update.update 28 | if isinstance(update, Update): 29 | update = update.event 30 | data["helper"] = MessageHelper( 31 | update=cast(Message | CallbackQuery, update), 32 | bot=data["bot"], 33 | fsm=data["state"], 34 | i18n=data["i18n"], 35 | ) 36 | return await handler(event, data) 37 | -------------------------------------------------------------------------------- /app/telegram/middlewares/ton_connect_checker.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 4 | 5 | from aiogram import Bot 6 | from aiogram.types import Chat, TelegramObject 7 | from aiogram_i18n import I18nContext 8 | 9 | from app.telegram.keyboards.ton_connect import connect_wallet_keyboard 10 | 11 | from .event_typed import EventTypedMiddleware 12 | 13 | if TYPE_CHECKING: 14 | from app.models.sql import User 15 | 16 | 17 | class TonConnectCheckerMiddleware(EventTypedMiddleware): 18 | async def __call__( 19 | self, 20 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 21 | event: TelegramObject, 22 | data: dict[str, Any], 23 | ) -> Any: 24 | user: Optional[User] = data.get("user") 25 | if user is None: 26 | return await handler(event, data) 27 | if not user.wallet_address: 28 | chat: Chat = data["event_chat"] 29 | bot: Bot = data["bot"] 30 | i18n: I18nContext = data["i18n"] 31 | return await bot.send_message( 32 | chat_id=chat.id, 33 | text=i18n.messages.wallet_not_connected(), 34 | reply_markup=connect_wallet_keyboard(i18n=i18n), 35 | ) 36 | return await handler(event, data) 37 | -------------------------------------------------------------------------------- /app/telegram/helpers/paginator.py: -------------------------------------------------------------------------------- 1 | from aiogram.contrib.paginator import Paginator as _Paginator 2 | from aiogram.types import InlineKeyboardMarkup 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | from aiogram_i18n import I18nContext 5 | from aiogram_i18n.types import InlineKeyboardButton 6 | 7 | from app.enums import PaginationMenuType 8 | from app.models.dto import TonWallet 9 | 10 | from ..keyboards.callback_data.menu import CDMenu 11 | from ..keyboards.callback_data.ton_connect import CDChooseWallet 12 | 13 | 14 | def get_wallet_button(wallet: TonWallet) -> InlineKeyboardButton: 15 | return InlineKeyboardButton( 16 | text=wallet.name, 17 | callback_data=CDChooseWallet(wallet_name=wallet.app_name).pack(), 18 | ) 19 | 20 | 21 | class Paginator(_Paginator): 22 | def choose_wallet_keyboard( 23 | self, 24 | i18n: I18nContext, 25 | wallets: list[TonWallet], 26 | ) -> InlineKeyboardMarkup: 27 | self.recalculate_offset(total_count=len(wallets)) 28 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 29 | builder.button(text=i18n.buttons.back(), callback_data=CDMenu()) 30 | return self.get_keyboard( 31 | objects=wallets[self.offset : self.offset + self.rows_per_page], 32 | button_getter=get_wallet_button, 33 | menu_type=PaginationMenuType.TON_WALLET, 34 | attach=builder, 35 | ) 36 | -------------------------------------------------------------------------------- /app/services/backend/session.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional, cast 2 | 3 | from stollen import Stollen 4 | from stollen.requests import StollenRequest, StollenResponse 5 | from stollen.session.aiohttp import AiohttpSession 6 | 7 | 8 | def search_error_message(body: Any) -> Optional[str]: 9 | if "detail" in body: 10 | if isinstance(body["detail"], str): 11 | return cast(str, body.pop("detail")) 12 | body = body.pop("detail") 13 | return cast(Optional[str], body.get("error_message")) 14 | 15 | 16 | class BackendSession(AiohttpSession): 17 | @classmethod 18 | def prepare_response( 19 | cls, 20 | client: Stollen, 21 | request: StollenRequest, 22 | response: StollenResponse, 23 | ) -> Any: 24 | if response.body is not None: 25 | if isinstance(response.body, dict): 26 | if "code" in response.body: 27 | response.status_code = response.body["code"] 28 | response.body["error_message"] = search_error_message(response.body) 29 | elif response.status_code >= 400 and isinstance(response.body, str): 30 | response.body = { 31 | "ok": False, 32 | "status_code": response.status_code, 33 | "error_message": response.body, 34 | "message": None, 35 | } 36 | return super().prepare_response(client, request, response) 37 | -------------------------------------------------------------------------------- /app/telegram/keyboards/gift_codes.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardMarkup 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | from aiogram_i18n import I18nContext 4 | 5 | from app.enums import GiftCodeCreationStatus 6 | 7 | from .callback_data.gift_codes import ( 8 | CDConfirmGiftCode, 9 | CDSetGiftCodeActivations, 10 | CDSetGiftCodeAmount, 11 | ) 12 | from .callback_data.menu import CDMenu 13 | 14 | 15 | def gift_code_creation_keyboard( 16 | i18n: I18nContext, 17 | status: GiftCodeCreationStatus, 18 | ) -> InlineKeyboardMarkup: 19 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 20 | sizes: list[int] = [2, 1] 21 | if status == GiftCodeCreationStatus.READY: 22 | sizes.insert(0, 1) 23 | builder.button(text=i18n.buttons.create(), callback_data=CDConfirmGiftCode()) 24 | builder.button( 25 | text=i18n.buttons.set_gift_code_activations(), 26 | callback_data=CDSetGiftCodeActivations(), 27 | ) 28 | builder.button( 29 | text=i18n.buttons.set_gift_code_amount(), 30 | callback_data=CDSetGiftCodeAmount(), 31 | ) 32 | builder.button(text=i18n.buttons.back(), callback_data=CDMenu()) 33 | builder.adjust(*sizes) 34 | return builder.as_markup() 35 | 36 | 37 | def claim_gift_code_keyboard(i18n: I18nContext, url: str) -> InlineKeyboardMarkup: 38 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 39 | builder.button(text=i18n.buttons.claim_gift_code(), url=url) 40 | return builder.as_markup() 41 | -------------------------------------------------------------------------------- /app/controllers/ton/proof.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import base64 4 | import logging 5 | from typing import TYPE_CHECKING, Final 6 | 7 | from pytonconnect import TonConnect 8 | from pytonconnect.parsers import TonProof as _TonProof 9 | from stollen.exceptions import StollenAPIError 10 | 11 | from app.exceptions.ton_connect import InvalidTonProofError 12 | from app.services.backend.types import TonProof, TonProofDomain 13 | from app.utils.ton import convert_address 14 | 15 | if TYPE_CHECKING: 16 | from app.services.backend import Backend 17 | 18 | logger: Final[logging.Logger] = logging.getLogger(__name__) 19 | 20 | 21 | async def verify_ton_proof(connector: TonConnect, backend: Backend) -> str: 22 | proof: _TonProof = connector.wallet.ton_proof 23 | try: 24 | return await backend.check_ton_proof( 25 | address=convert_address(connector.account, is_user_friendly=False), 26 | network=connector.account.chain, 27 | public_key=connector.account.public_key, 28 | proof=TonProof( 29 | timestamp=proof.timestamp, 30 | domain=TonProofDomain(length_bytes=proof.domain_len, value=proof.domain_val), 31 | signature=base64.b64encode(proof.signature).decode(), 32 | payload=proof.payload, 33 | state_init=connector.account.wallet_state_init, 34 | ), 35 | ) 36 | except StollenAPIError as error: 37 | logger.error(error, exc_info=True) 38 | raise InvalidTonProofError() 39 | -------------------------------------------------------------------------------- /app/telegram/middlewares/ton_connect.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 4 | 5 | from aiogram.types import TelegramObject 6 | 7 | from app.controllers.auth import logout_user 8 | from app.services.ton_connect import TcAdapter 9 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 10 | 11 | if TYPE_CHECKING: 12 | from app.models.config import AppConfig, Assets 13 | from app.models.dto import UserDto 14 | 15 | 16 | class TonConnectMiddleware(EventTypedMiddleware): 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 | user: Optional[UserDto] = data.get("user") 24 | if user is None: 25 | return await handler(event, data) 26 | assets: Assets = data["assets"] 27 | config: AppConfig = data["config"] 28 | data["ton_connect"] = adapter = TcAdapter( 29 | manifest_url=assets.ton_connect.manifest_url, 30 | bridge_key=config.common.ton_api_bridge_key.get_secret_value(), 31 | telegram_id=user.telegram_id, 32 | session_pool=data["session_pool"], 33 | redis=data["redis"], 34 | cache_time=config.common.ton_connect_cache_time, 35 | ) 36 | if user.wallet_connected and not await adapter.is_connected(): 37 | await logout_user(user=user, user_service=data["user_service"], ton_connect=adapter) 38 | return await handler(event, data) 39 | -------------------------------------------------------------------------------- /app/factory/telegram/i18n.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import cast 4 | 5 | from aiogram import Dispatcher 6 | from aiogram_i18n import I18nMiddleware 7 | from aiogram_i18n.cores import FluentRuntimeCore 8 | 9 | from app.const import DEFAULT_LOCALE, MESSAGES_SOURCE_DIR 10 | from app.models.config import AppConfig 11 | from app.utils.localization import UserManager 12 | 13 | 14 | def create_i18n_core(config: AppConfig) -> FluentRuntimeCore: 15 | locales: list[str] = cast(list[str], config.telegram.locales) 16 | return FluentRuntimeCore( 17 | path=MESSAGES_SOURCE_DIR / "{locale}", 18 | raise_key_error=False, 19 | locales_map={locales[i]: locales[i + 1] for i in range(len(locales) - 1)}, 20 | ) 21 | 22 | 23 | def create_i18n_middleware(config: AppConfig) -> I18nMiddleware: 24 | return I18nMiddleware( 25 | core=create_i18n_core(config=config), 26 | manager=UserManager(), 27 | default_locale=DEFAULT_LOCALE, 28 | ) 29 | 30 | 31 | def setup_i18n_middleware(dispatcher: Dispatcher, config: AppConfig) -> None: 32 | middleware: I18nMiddleware = create_i18n_middleware(config=config) 33 | for event_type in dispatcher.resolve_used_update_types(): 34 | dispatcher.observers[event_type].middleware(middleware) 35 | dispatcher.error.middleware(middleware) 36 | dispatcher.startup.register(middleware.core.startup) 37 | dispatcher.shutdown.register(middleware.core.shutdown) 38 | dispatcher.startup.register(middleware.manager.startup) 39 | dispatcher.shutdown.register(middleware.manager.shutdown) 40 | if middleware.enabled_startup: 41 | dispatcher.startup.register(middleware.startup) 42 | dispatcher[middleware.middleware_key] = middleware 43 | -------------------------------------------------------------------------------- /app/telegram/keyboards/ton_connect.py: -------------------------------------------------------------------------------- 1 | from aiogram.types import InlineKeyboardMarkup 2 | from aiogram.utils.keyboard import InlineKeyboardBuilder 3 | from aiogram_i18n import I18nContext 4 | 5 | from app.enums import PaginationMenuType 6 | from app.models.dto import TonConnection, TonWallet 7 | 8 | from .callback_data.menu import CDMenu, CDPagination 9 | from .callback_data.ton_connect import CDCancelConnection, CDChooseWallet 10 | 11 | 12 | def connect_wallet_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 13 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 14 | builder.button( 15 | text=i18n.buttons.connect(), 16 | callback_data=CDPagination(type=PaginationMenuType.TON_WALLET), 17 | ) 18 | builder.adjust(1, repeat=True) 19 | return builder.as_markup() 20 | 21 | 22 | def choose_wallet_keyboard(i18n: I18nContext, wallets: list[TonWallet]) -> InlineKeyboardMarkup: 23 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 24 | for wallet in wallets: 25 | builder.button( 26 | text=wallet.name, 27 | callback_data=CDChooseWallet(wallet_name=wallet.app_name), 28 | ) 29 | builder.button(text=i18n.buttons.back(), callback_data=CDMenu()) 30 | builder.adjust(1, repeat=True) 31 | return builder.as_markup() 32 | 33 | 34 | def ton_connect_keyboard(i18n: I18nContext, connection: TonConnection) -> InlineKeyboardMarkup: 35 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 36 | builder.button(text=i18n.buttons.ton_connect_url(), url=connection.url) 37 | builder.button( 38 | text=i18n.buttons.cancel(), 39 | callback_data=CDCancelConnection(task_id=connection.id), 40 | ) 41 | builder.adjust(1, repeat=True) 42 | return builder.as_markup() 43 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | # - - - - - TELEGRAM BOT SETTINGS - - - - - # 2 | # Localization languages. Avoid spaces between entries. The order determines language priority. 3 | # For the full list of locales, refer to aiogram_bot_template/enums/locale.py 4 | TELEGRAM_LOCALES=ru,en 5 | 6 | # Telegram bot token, obtainable via https://t.me/BotFather 7 | TELEGRAM_BOT_TOKEN=42:ABC 8 | 9 | # Drop old updates when the bot was inactive (True/False) 10 | TELEGRAM_DROP_PENDING_UPDATES=False 11 | 12 | # Webhook configuration 13 | TELEGRAM_USE_WEBHOOK=False 14 | TELEGRAM_RESET_WEBHOOK=True 15 | TELEGRAM_WEBHOOK_PATH=/telegram 16 | TELEGRAM_WEBHOOK_SECRET=123456abcdef 17 | 18 | # - - - - - POSTGRESQL SETTINGS - - - - - # 19 | POSTGRES_HOST=postgres 20 | POSTGRES_PASSWORD=my_pg_password 21 | POSTGRES_DB=my_db_name 22 | POSTGRES_PORT=5433 23 | POSTGRES_USER=my_pg_user 24 | # Path to PostgreSQL data for Docker volumes 25 | POSTGRES_DATA=/var/lib/postgresql/data 26 | 27 | # - - - - - SQLALCHEMY SETTINGS - - - - - # 28 | ALCHEMY_ECHO=False 29 | ALCHEMY_ECHO_POOL=False 30 | ALCHEMY_POOL_SIZE=30 31 | ALCHEMY_MAX_OVERFLOW=50 32 | ALCHEMY_POOL_TIMEOUT=10 33 | ALCHEMY_POOL_RECYCLE=3600 34 | 35 | # - - - - - REDIS SETTINGS - - - - - # 36 | REDIS_HOST=redis 37 | REDIS_PORT=6380 38 | REDIS_DB=0 39 | REDIS_PASSWORD=12345abcdef 40 | # Path to Redis data for Docker volumes 41 | REDIS_DATA=/redis_data 42 | 43 | # - - - - - SERVER SETTINGS - - - - - # 44 | SERVER_HOST=127.0.0.1 45 | SERVER_PORT=8080 46 | SERVER_URL=https://my.server.com 47 | 48 | # - - - - - OTHER SETTINGS - - - - - # 49 | COMMON_ADMIN_CHAT_ID=5945468457 50 | COMMON_BACKEND_URL=https://api.split.tg 51 | COMMON_USERS_CACHE_TIME=30 52 | COMMON_DEEP_LINKS_CACHE_TIME=300 53 | COMMON_TON_CONNECT_CACHE_TIME=30 54 | COMMON_TON_CENTER_KEY=123456abcdef 55 | COMMON_TON_API_BRIDGE_KEY=123456abcdef 56 | -------------------------------------------------------------------------------- /app/controllers/auth.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from app.models.dto import TonConnectResult, UserDto 4 | from app.services.backend import Backend 5 | from app.services.backend.errors import SplitConflictError 6 | from app.services.backend.types import User as SplitUser 7 | from app.services.ton_connect import TcAdapter 8 | from app.services.user import UserService 9 | 10 | 11 | async def authorize_user( 12 | user: UserDto, 13 | result: TonConnectResult, 14 | backend: Backend, 15 | user_service: UserService, 16 | ton_connect: TcAdapter, 17 | ) -> None: 18 | by_address: Optional[UserDto] = await user_service.by_address(result.address) 19 | if by_address is not None: 20 | await logout_user( 21 | user=by_address, 22 | user_service=user_service, 23 | ton_connect=ton_connect.copy(telegram_id=by_address.telegram_id), 24 | ) 25 | backend.access_token = result.access_token 26 | user.wallet_address = result.address 27 | old_token: Optional[str] = user.backend_access_token 28 | if old_token != result.access_token: 29 | user.backend_access_token = result.access_token 30 | try: 31 | split_user: SplitUser = await backend.create_user(inviter=user.inviter) 32 | except SplitConflictError: 33 | split_user = await backend.get_me() 34 | user.backend_user_id = split_user.id 35 | user.inviter = split_user.inviter 36 | await user_service.update(user=user) 37 | 38 | 39 | async def logout_user(user: UserDto, user_service: UserService, ton_connect: TcAdapter) -> None: 40 | await user_service.update( 41 | user=user, 42 | wallet_address=None, 43 | backend_user_id=None, 44 | backend_access_token=None, 45 | ) 46 | await ton_connect.disconnect() 47 | -------------------------------------------------------------------------------- /migrations/versions/003_deep_links.py: -------------------------------------------------------------------------------- 1 | """deep_links 2 | 3 | Revision ID: 003 4 | Revises: 002 5 | Create Date: 2025-01-06 15:30:25.664931 6 | 7 | """ 8 | 9 | from typing import Optional, Sequence 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "003" 16 | down_revision: Optional[str] = "002" 17 | branch_labels: Optional[Sequence[str]] = None 18 | depends_on: Optional[Sequence[str]] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "deep_links", 25 | sa.Column("id", sa.Uuid(), nullable=False), 26 | sa.Column("owner_id", sa.BigInteger(), nullable=False), 27 | sa.Column( 28 | "action", 29 | sa.Enum("INVITE", "USE_GIFT_CODE", name="deeplinkaction"), 30 | nullable=False, 31 | ), 32 | sa.Column("gift_code_address", sa.String(), nullable=True), 33 | sa.Column("gift_code_seed", sa.String(), nullable=True), 34 | sa.Column( 35 | "created_at", 36 | sa.DateTime(timezone=True), 37 | server_default=sa.text("timezone('UTC', now())"), 38 | nullable=False, 39 | ), 40 | sa.Column( 41 | "updated_at", 42 | sa.DateTime(timezone=True), 43 | server_default=sa.text("timezone('UTC', now())"), 44 | nullable=False, 45 | ), 46 | sa.ForeignKeyConstraint( 47 | ["owner_id"], 48 | ["users.id"], 49 | ), 50 | sa.PrimaryKeyConstraint("id"), 51 | ) 52 | # ### end Alembic commands ### 53 | 54 | 55 | def downgrade() -> None: 56 | # ### commands auto generated by Alembic - please adjust! ### 57 | op.drop_table("deep_links") 58 | # ### end Alembic commands ### 59 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | project_dir := . 3 | package_dir := app 4 | 5 | .PHONY: help 6 | help: ## Display this help. 7 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 8 | 9 | ##@ Formatting & Linting 10 | 11 | .PHONY: reformat 12 | reformat: ## Reformat code 13 | @uv run black $(project_dir) 14 | @uv run ruff check $(project_dir) --fix 15 | 16 | .PHONY: lint 17 | lint: reformat ## Lint code 18 | @uv run mypy $(project_dir) 19 | 20 | ##@ Database 21 | 22 | .PHONY: migration 23 | migration: ## Make database migration 24 | @uv run alembic revision \ 25 | --autogenerate \ 26 | --rev-id $(shell python migrations/_get_revision_id.py) \ 27 | --message $(message) 28 | 29 | .PHONY: migrate 30 | migrate: ## Apply database migrations 31 | @uv run alembic upgrade head 32 | 33 | .PHONY: app-run-db 34 | app-run-db: ## Run bot database containers 35 | @docker compose up -d --remove-orphans postgres redis 36 | 37 | ##@ App commands 38 | 39 | .PHONY: run 40 | run: ## Run bot 41 | @uv run python -O -m $(package_dir) 42 | 43 | .PHONY: app-build 44 | app-build: ## Build bot image 45 | @docker compose build 46 | 47 | .PHONY: app-run 48 | app-run: ## Run bot in docker container 49 | @docker compose stop 50 | @docker compose up -d --remove-orphans 51 | 52 | .PHONY: app-stop 53 | app-stop: ## Stop docker containers 54 | @docker compose stop 55 | 56 | .PHONY: app-down 57 | app-down: ## Down docker containers 58 | @docker compose down 59 | 60 | .PHONY: app-destroy 61 | app-destroy: ## Destroy docker containers 62 | @docker compose down -v --remove-orphans 63 | 64 | .PHONY: app-logs 65 | app-logs: ## Show bot logs 66 | @docker compose logs -f bot 67 | 68 | .PHONY: app-drop 69 | app-drop: app-destroy ## Drop all docker containers, images and volumes 70 | docker container prune -f 71 | docker images -q | xargs docker rmi -f 72 | docker volume prune -f 73 | 74 | ##@ Other 75 | 76 | .PHONY: name 77 | name: ## Get top-level package name 78 | @echo $(package_dir) 79 | -------------------------------------------------------------------------------- /assets/messages/en/gift_codes.ftl: -------------------------------------------------------------------------------- 1 | 2 | buttons-create_gift_code = 🎁 Create Gift Code 3 | buttons-set_gift_code_activations = ✏️ Activations 4 | buttons-set_gift_code_amount = ✏️ Claim amount 5 | 6 | messages-gift_codes-info = 7 | 🎁 Gift code for stars 8 | 9 | ☑️ Total activations » { $activations -> 10 | [null] not set 11 | *[other] { $activations } 12 | } 13 | ⭐️ Claim amount » { $amount -> 14 | [null] not set 15 | *[other] { $amount } TON (~${ $usd_amount }) 16 | } 17 | 18 | { $status -> 19 | [not_ready] 🔘 Use buttons below to edit gift code 20 | [activations_limit] ❌ Total activations must be between { $min_activations } and { $max_activations } 21 | [amount_limit] ❌ Claim amount must be between { $min_amount } and { $max_amount } TON 22 | *[other] 🟢 Ready to create 23 | } 24 | 25 | 26 | messages-gift_codes-enter_amount = ✏️ Enter claim amount in TON ({ $min } - { $max }) 27 | messages-gift_codes-enter_activations = ✏️ Enter total activations ({ $min } - { $max }) 28 | 29 | messages-gift_codes-created = 30 | 🎁 Gift code created 31 | 32 | 🔗 Now anyone can activate it via link below » 33 | { $link } 34 | 35 |
⚠️ Please make sure that you have confirmed transaction before using gift code.
36 | 37 | messages-gift_codes-expired = 🎁 Gift code expired 38 | messages-gift_codes-not_found = 🎁 Gift code not found 39 | 40 | messages-gift_codes-view = 41 | 🎁 Gift code for { $max_buy_amount } TON 42 | 43 | 🚀 { $activations_left } of { $max_activations } activations left 44 | 45 | ✏️ Enter username below to claim stars 46 | 47 | messages-gift_codes-use_requested = 48 | 👛 A transaction to receive stars for username @{ $username } has been requested from your wallet 49 | 50 | messages-gift_codes-shared = 51 | 🎁 Gift code for { $total_amount } TON in stars 52 | 53 | Claim amount: { $amount } TON 54 | Activations left: { $activations_left } of { $max_activations } 55 | 56 | buttons-share_gift_code = 🎁 Share Gift Code 57 | buttons-claim_gift_code = Claim Stars 58 | -------------------------------------------------------------------------------- /app/utils/key_builder.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | from enum import Enum 3 | from fractions import Fraction 4 | from typing import TYPE_CHECKING, Any, ClassVar, Optional 5 | from uuid import UUID 6 | 7 | from pydantic import BaseModel 8 | 9 | 10 | class StorageKey(BaseModel): 11 | if TYPE_CHECKING: 12 | __separator__: ClassVar[str] 13 | """Data separator (default is :code:`:`)""" 14 | __prefix__: ClassVar[Optional[str]] 15 | """Callback prefix""" 16 | 17 | # noinspection PyMethodOverriding 18 | def __init_subclass__(cls, **kwargs: Any) -> None: 19 | cls.__separator__ = kwargs.pop("separator", ":") 20 | cls.__prefix__ = kwargs.pop("prefix", None) 21 | if cls.__separator__ in (cls.__prefix__ or ""): 22 | raise ValueError( 23 | f"Separator symbol {cls.__separator__!r} can not be used " 24 | f"inside prefix {cls.__prefix__!r}" 25 | ) 26 | super().__init_subclass__(**kwargs) 27 | 28 | @classmethod 29 | def encode_value(cls, key: str, value: Any) -> str: 30 | if value is None: 31 | return "" 32 | if isinstance(value, Enum): 33 | return str(value.value) 34 | if isinstance(value, UUID): 35 | return value.hex 36 | if isinstance(value, bool): 37 | return str(int(value)) 38 | if isinstance(value, (int, str, float, Decimal, Fraction)): 39 | return str(value) 40 | raise ValueError( 41 | f"Attribute {key}={value!r} of type {type(value).__name__!r}" 42 | f" can not be packed to callback data" 43 | ) 44 | 45 | def pack(self) -> str: 46 | result = [self.__prefix__] if self.__prefix__ else [] 47 | for key, value in self.model_dump(mode="json").items(): 48 | encoded = self.encode_value(key, value) 49 | if self.__separator__ in encoded: 50 | raise ValueError( 51 | f"Separator symbol {self.__separator__!r} can not be used " 52 | f"in value {key}={encoded!r}" 53 | ) 54 | result.append(encoded) 55 | return self.__separator__.join(result) 56 | -------------------------------------------------------------------------------- /app/models/dto/gift_code.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | from pydantic import Field 5 | 6 | from app.enums import GiftCodeCreationStatus 7 | from app.models.base import PydanticModel 8 | from app.models.config import Assets 9 | 10 | 11 | class GiftCodeCreation(PydanticModel): 12 | activations: Optional[int] = None 13 | amount: Optional[float] = None 14 | message_id: Optional[int] = None 15 | to_delete: list[int] = Field(default_factory=list) 16 | 17 | def status(self, assets: Assets) -> GiftCodeCreationStatus: 18 | min_activations: int = assets.gift_codes.min_activations 19 | max_activations: int = assets.gift_codes.max_activations 20 | min_amount: float = assets.gift_codes.min_amount 21 | max_amount: float = assets.gift_codes.max_amount 22 | if self.activations is not None and not ( 23 | min_activations <= self.activations <= max_activations 24 | ): 25 | return GiftCodeCreationStatus.ACTIVATIONS_LIMIT 26 | if self.amount is not None and not (min_amount <= self.amount <= max_amount): 27 | return GiftCodeCreationStatus.AMOUNT_LIMIT 28 | if self.amount is None or self.activations is None: 29 | return GiftCodeCreationStatus.NOT_READY 30 | return GiftCodeCreationStatus.READY 31 | 32 | def usd_amount(self, ton_rate: Optional[float] = None) -> Optional[float]: 33 | if ton_rate is None or self.amount is None: 34 | return None 35 | return round(self.amount * ton_rate, 2) 36 | 37 | 38 | class FullGiftCodeData(PydanticModel): 39 | owner_address: str 40 | total_activations: int 41 | max_activations: int 42 | max_buy_amount: float 43 | 44 | @property 45 | def activations_left(self) -> int: 46 | return self.max_activations - self.total_activations 47 | 48 | @property 49 | def fully_activated(self) -> bool: 50 | return self.activations_left <= 0 51 | 52 | @property 53 | def total_amount(self) -> float: 54 | return self.max_buy_amount * self.max_activations 55 | 56 | 57 | class GiftCodeActivation(PydanticModel): 58 | link_id: UUID 59 | contract_address: str 60 | seed: str 61 | message_id: int 62 | -------------------------------------------------------------------------------- /app/services/database/postgres/repositories/base.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Optional, TypeVar, Union, cast 4 | 5 | from sqlalchemy import ColumnExpressionArgument, delete, select, update 6 | from sqlalchemy.ext.asyncio import AsyncSession 7 | from sqlalchemy.orm import InstrumentedAttribute 8 | 9 | from ..uow import UoW 10 | 11 | T = TypeVar("T", bound=Any) 12 | ColumnClauseType = Union[ 13 | type[T], 14 | InstrumentedAttribute[T], 15 | ] 16 | 17 | 18 | # noinspection PyTypeChecker 19 | class BaseRepository: 20 | session: AsyncSession 21 | uow: UoW 22 | 23 | def __init__(self, session: AsyncSession) -> None: 24 | self.session = session 25 | self.uow = UoW(session=session) 26 | 27 | async def _get( 28 | self, 29 | model: ColumnClauseType[T], 30 | *conditions: ColumnExpressionArgument[Any], 31 | ) -> Optional[T]: 32 | return cast(Optional[T], await self.session.scalar(select(model).where(*conditions))) 33 | 34 | async def _get_many( 35 | self, 36 | model: ColumnClauseType[T], 37 | *conditions: ColumnExpressionArgument[Any], 38 | ) -> list[T]: 39 | return list(await self.session.scalars(select(model).where(*conditions))) 40 | 41 | async def _update( 42 | self, 43 | model: ColumnClauseType[T], 44 | conditions: list[ColumnExpressionArgument[Any]], 45 | load_result: bool = True, 46 | **kwargs: Any, 47 | ) -> Optional[T]: 48 | if not kwargs: 49 | if not load_result: 50 | return None 51 | return cast(Optional[T], await self._get(model, *conditions)) 52 | query = update(model).where(*conditions).values(**kwargs) 53 | if load_result: 54 | query = query.returning(model) 55 | result = await self.session.execute(query) 56 | await self.session.commit() 57 | return result.scalar_one_or_none() if load_result else None 58 | 59 | async def _delete( 60 | self, 61 | model: ColumnClauseType[T], 62 | *conditions: ColumnExpressionArgument[Any], 63 | ) -> bool: 64 | result = await self.session.execute(delete(model).where(*conditions)) 65 | await self.session.commit() 66 | return cast(bool, result.rowcount > 0) 67 | -------------------------------------------------------------------------------- /assets/messages/ru/gift_codes.ftl: -------------------------------------------------------------------------------- 1 | 2 | buttons-create_gift_code = 🎁 Новый подарочный код 3 | buttons-set_gift_code_activations = ✏️ Кол-во активаций 4 | buttons-set_gift_code_amount = ✏️ Сумма активации 5 | 6 | messages-gift_codes-info = 7 | 🎁 Подарочный код на звёзды 8 | 9 | ☑️ Всего активаций » { $activations -> 10 | [null] не установлено 11 | *[other] { $activations } 12 | } 13 | ⭐️ Сумма активации » { $amount -> 14 | [null] не установлено 15 | *[other] { $amount } TON (~${ $usd_amount }) 16 | } 17 | 18 | { $status -> 19 | [not_ready] 🔘 Используйте кнопки ниже, чтобы настроить подарочный код 20 | [activations_limit] ❌ Допустимое кол-во активаций: от { $min_activations } до { $max_activations } 21 | [amount_limit] ❌ Допустимая сумма активации: от { $min_amount } до { $max_amount } TON 22 | *[other] 🟢 Готов к созданию 23 | } 24 | 25 | 26 | messages-gift_codes-enter_amount = ✏️ Введите сумму активации в TON ({ $min } - { $max }) 27 | messages-gift_codes-enter_activations = ✏️ Введите кол-во активаций ({ $min } - { $max }) 28 | 29 | messages-gift_codes-created = 30 | 🎁 Подарочный код создан 31 | 32 | 🔗 Теперь любой может активировать его по ссылке снизу » 33 | { $link } 34 | 35 |
⚠️ Пожалуйста, убедитесь, что вы подтвердили транзакцию в кошельке перед использованием подарочного кода.
36 | 37 | messages-gift_codes-expired = 🎁 Подарочный код истёк 38 | messages-gift_codes-not_found = 🎁 Подарочный код не найден 39 | 40 | messages-gift_codes-view = 41 | 🎁 Подарочный код на { $max_buy_amount } TON 42 | 43 | 🚀 Осталось { $activations_left } из { $max_activations } активаций 44 | 45 | ✏️ Введите юзернейм пользователя, на которого хотите принять подарок 46 | 47 | messages-gift_codes-use_requested = 48 | 👛 Транзакция на получение звёзд на аккаунт @{ $username } была отправлена на подтверждение в ваш кошелёк 49 | 50 | messages-gift_codes-shared = 51 | 🎁 Подарочный код на { $total_amount } TON в звёздах 52 | 53 | Сумма активации: { $amount } TON 54 | Активаций осталось: { $activations_left } из { $max_activations } 55 | 56 | buttons-share_gift_code = 🎁 Поделиться подарочным кодом 57 | buttons-claim_gift_code = Получить звёзды 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⭐️ Split.tg bot 2 | [![Website](https://img.shields.io/badge/Website-split.tg-blue)](https://split.tg) 3 | [![Telegram](https://img.shields.io/badge/Telegram-Bot-blue)](https://t.me/FishStarsBot) 4 | [![Telegram](https://img.shields.io/badge/Telegram-Channel-blue)](https://t.me/SplitTg) 5 | [![License](https://img.shields.io/badge/License-MIT-blue)](#license) 6 | ![Pipeline](https://wpci.itanarchy.app/api/badges/14/status.svg) 7 | 8 | # About 9 | Buy Telegram Stars and Telegram Premium easily with [@FishStarsBot](https://t.me/FishStarsBot)! 10 | 11 | ## System dependencies 12 | - Python 3.11+ 13 | - Docker 14 | - docker-compose 15 | - make 16 | - uv 17 | 18 | ## 🐳 Quick Start with Docker compose 19 | - Rename `.env.dist` to `.env` and configure it 20 | - Rename `docker-compose.example.yml` to `docker-compose.yml` 21 | - Run `make app-build` command then `make app-run` to start the bot 22 | 23 | Use `make` to see all available commands 24 | 25 | ## 🔧 Development 26 | 27 | ### Setup environment 28 | ```bash 29 | uv sync 30 | ``` 31 | ### Update database tables structure 32 | **Make migration script:** 33 | ```bash 34 | make migration message=MESSAGE_WHAT_THE_MIGRATION_DOES 35 | ``` 36 | **Run migrations:** 37 | ```bash 38 | make migrate 39 | ``` 40 | 41 | ## 🚀 Used technologies: 42 | - [uv](https://docs.astral.sh/uv/) (an extremely fast Python package and project manager) 43 | - [Aiogram 3.x](https://github.com/aiogram/aiogram) (Telegram bot framework) 44 | - [PostgreSQL](https://www.postgresql.org/) (persistent relational database) 45 | - [SQLAlchemy](https://docs.sqlalchemy.org/en/20/) (working with database from Python) 46 | - [Alembic](https://alembic.sqlalchemy.org/en/latest/) (lightweight database migration tool) 47 | - [Redis](https://redis.io/docs/) (in-memory data storage for FSM and caching) 48 | - [Project Fluent](https://projectfluent.org/) (modern localization system) 49 | 50 | ## 🤝 Contributions 51 | 52 | ### 🐛 Bug Reports / ✨ Feature Requests 53 | 54 | If you want to report a bug or request a new feature, feel free to open a [new issue](https://github.com/itanarchy/split-bot/issues/new). 55 | 56 | ### Pull Requests 57 | 58 | If you want to help us improve the bot, you can create a new [Pull Request](https://github.com/itanarchy/split-bot/pulls). 59 | 60 | ## 📝 License 61 | 62 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "split_bot" 3 | version = "1.0" 4 | description = "" 5 | readme = "README.md" 6 | requires-python = ">=3.11,<3.13" 7 | dependencies = [ 8 | "aiogram~=3.15.0", 9 | "aiogram-i18n~=1.4", 10 | "aiohttp~=3.10.8", 11 | "alembic~=1.14.0", 12 | "asyncpg~=0.29.0", 13 | "redis~=5.1.0", 14 | "sqlalchemy~=2.0.35", 15 | "msgspec~=0.18.6", 16 | "pydantic~=2.9.2", 17 | "pydantic-settings[yaml]~=2.6.0", 18 | "fluent_runtime~=0.4.0", 19 | "aiogram-contrib>=1.1.3", 20 | "stollen>=0.6.0", 21 | "pytonconnect-fixed==0.3.1", 22 | "pytoniq-core>=0.1.40", 23 | "qrcode>=8.0", 24 | "pillow>=11.0.0", 25 | "tonutils==0.1.8", 26 | ] 27 | 28 | [tool.uv] 29 | dev-dependencies = [ 30 | "mypy~=1.12.0", 31 | "black~=24.10.0", 32 | "ruff~=0.7.1", 33 | "types-qrcode>=8.0.0.20241004", 34 | "pre-commit>=4.0.1", 35 | ] 36 | 37 | [tool.black] 38 | line-length = 99 39 | exclude = "\\.?venv" 40 | 41 | [tool.ruff] 42 | target-version = "py38" 43 | line-length = 99 44 | lint.select = [ 45 | "C", 46 | "DTZ", 47 | "E", 48 | "F", 49 | "I", 50 | "ICN", 51 | "ISC", 52 | "N", 53 | "PLC", 54 | "PLE", 55 | "Q", 56 | "T", 57 | "W", 58 | "YTT", 59 | ] 60 | lint.ignore = ["N805"] 61 | exclude = [ 62 | ".venv", 63 | ".idea", 64 | ] 65 | 66 | [tool.mypy] 67 | plugins = [ 68 | "sqlalchemy.ext.mypy.plugin", 69 | "pydantic.mypy" 70 | ] 71 | exclude = [ 72 | "venv", 73 | ".venv", 74 | ".idea", 75 | ".tests", 76 | ] 77 | warn_unused_configs = true 78 | disallow_any_generics = true 79 | disallow_subclassing_any = true 80 | disallow_untyped_calls = true 81 | disallow_untyped_defs = true 82 | disallow_incomplete_defs = true 83 | check_untyped_defs = true 84 | disallow_untyped_decorators = true 85 | warn_unused_ignores = true 86 | warn_return_any = true 87 | no_implicit_reexport = true 88 | strict_equality = true 89 | extra_checks = true 90 | 91 | [[tool.mypy.overrides]] 92 | module = [ 93 | "redis.*", 94 | "pytonconnect.*", 95 | "pytoniq_core.*", 96 | ] 97 | ignore_missing_imports = true 98 | disallow_untyped_defs = false 99 | 100 | [[tool.mypy.overrides]] 101 | module = ["app.telegram.handlers.*"] 102 | strict_optional = false 103 | warn_return_any = false 104 | disable_error_code = ["union-attr"] 105 | -------------------------------------------------------------------------------- /app/runners.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot, Dispatcher, loggers 6 | from aiogram.webhook import aiohttp_server as server 7 | from aiohttp import web 8 | 9 | if TYPE_CHECKING: 10 | from .models.config import AppConfig 11 | 12 | 13 | async def polling_startup(bots: list[Bot], config: AppConfig) -> None: 14 | for bot in bots: 15 | await bot.delete_webhook(drop_pending_updates=config.telegram.drop_pending_updates) 16 | if config.telegram.drop_pending_updates: 17 | loggers.dispatcher.info("Updates skipped successfully") 18 | 19 | 20 | async def webhook_startup(dispatcher: Dispatcher, bot: Bot, config: AppConfig) -> None: 21 | url: str = config.server.build_url(path=config.telegram.webhook_path) 22 | loggers.webhook.info("Trying to set main bot webhook on url %s", url) 23 | if await bot.set_webhook( 24 | url=url, 25 | allowed_updates=dispatcher.resolve_used_update_types(), 26 | secret_token=config.telegram.webhook_secret.get_secret_value(), 27 | drop_pending_updates=config.telegram.drop_pending_updates, 28 | ): 29 | return loggers.webhook.info("Main bot webhook successfully set") 30 | return loggers.webhook.error("Failed to set main bot webhook") 31 | 32 | 33 | async def webhook_shutdown(bot: Bot, config: AppConfig) -> None: 34 | if not config.telegram.reset_webhook: 35 | return 36 | if await bot.delete_webhook(): 37 | loggers.webhook.info("Dropped main bot webhook.") 38 | else: 39 | loggers.webhook.error("Failed to drop main bot webhook.") 40 | await bot.session.close() 41 | 42 | 43 | def run_polling(dispatcher: Dispatcher, bot: Bot) -> None: 44 | dispatcher.startup.register(polling_startup) 45 | return dispatcher.run_polling(bot) 46 | 47 | 48 | def run_webhook(dispatcher: Dispatcher, bot: Bot, config: AppConfig) -> None: 49 | app: web.Application = web.Application() 50 | server.SimpleRequestHandler( 51 | dispatcher=dispatcher, 52 | bot=bot, 53 | secret_token=config.telegram.webhook_secret.get_secret_value(), 54 | ).register(app, path=config.telegram.webhook_path) 55 | server.setup_application(app, dispatcher, bot=bot) 56 | app.update(**dispatcher.workflow_data, bot=bot) 57 | dispatcher.startup.register(webhook_startup) 58 | dispatcher.shutdown.register(webhook_shutdown) 59 | return web.run_app( 60 | app=app, 61 | host=config.server.host, 62 | port=config.server.port, 63 | ) 64 | -------------------------------------------------------------------------------- /app/telegram/keyboards/menu.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | from aiogram.types import InlineKeyboardMarkup, WebAppInfo 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | from aiogram_i18n import I18nContext 5 | 6 | from app.const import SITE_URL 7 | from app.enums import PaginationMenuType 8 | 9 | from .callback_data.gift_codes import CDCreateGiftCode 10 | from .callback_data.menu import ( 11 | CDLanguage, 12 | CDMenu, 13 | CDPagination, 14 | CDReferralProgram, 15 | CDTelegramPremium, 16 | CDTelegramStars, 17 | ) 18 | from .callback_data.ton_connect import CDUnlinkWallet 19 | 20 | 21 | def to_menu_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 22 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 23 | builder.button(text=i18n.buttons.menu(), callback_data=CDMenu()) 24 | return builder.as_markup() 25 | 26 | 27 | def cancel_keyboard(i18n: I18nContext, data: CallbackData) -> InlineKeyboardMarkup: 28 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 29 | builder.button(text=i18n.buttons.cancel(), callback_data=data) 30 | return builder.as_markup() 31 | 32 | 33 | def back_keyboard(i18n: I18nContext, data: CallbackData) -> InlineKeyboardMarkup: 34 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 35 | builder.button(text=i18n.buttons.back(), callback_data=data) 36 | return builder.as_markup() 37 | 38 | 39 | def menu_keyboard(i18n: I18nContext, wallet_connected: bool) -> InlineKeyboardMarkup: 40 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 41 | builder.button(text=i18n.buttons.app(), web_app=WebAppInfo(url=SITE_URL)) 42 | if wallet_connected: 43 | builder.button(text=i18n.buttons.premium(), callback_data=CDTelegramPremium()) 44 | builder.button(text=i18n.buttons.stars(), callback_data=CDTelegramStars()) 45 | builder.button(text=i18n.buttons.create_gift_code(), callback_data=CDCreateGiftCode()) 46 | builder.button(text=i18n.buttons.referral_program(), callback_data=CDReferralProgram()) 47 | builder.button(text=i18n.buttons.language(), callback_data=CDLanguage()) 48 | builder.button(text=i18n.buttons.disconnect(), callback_data=CDUnlinkWallet()) 49 | builder.adjust(1, 2, 1, 2, 1) 50 | else: 51 | builder.button( 52 | text=i18n.buttons.connect(), 53 | callback_data=CDPagination(type=PaginationMenuType.TON_WALLET), 54 | ) 55 | builder.button(text=i18n.buttons.language(), callback_data=CDLanguage()) 56 | builder.adjust(2, 1) 57 | return builder.as_markup() 58 | -------------------------------------------------------------------------------- /app/telegram/handlers/extra/errors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final, cast 4 | 5 | from aiogram import F, Router 6 | from aiogram.filters import ExceptionTypeFilter, StateFilter, or_f 7 | from aiogram.types import ErrorEvent 8 | from aiogram_i18n import I18nContext 9 | 10 | from app.controllers.auth import logout_user 11 | from app.exceptions.base import BotError 12 | from app.models.dto import UserDto 13 | from app.services.backend.errors import SplitBadRequestError, SplitUnauthorizedError 14 | from app.services.user import UserService 15 | from app.telegram.filters.states import SGBuyPremium, SGBuyStars, SGUseGiftCode 16 | from app.telegram.keyboards.callback_data.purchase import CDSelectUsername 17 | from app.telegram.keyboards.menu import to_menu_keyboard 18 | from app.telegram.keyboards.ton_connect import connect_wallet_keyboard 19 | 20 | if TYPE_CHECKING: 21 | from app.services.ton_connect import TcAdapter 22 | from app.telegram.helpers.messages import MessageHelper 23 | 24 | router: Final[Router] = Router(name=__name__) 25 | 26 | 27 | @router.error(ExceptionTypeFilter(BotError), F.update.message) 28 | async def handle_some_error(error: ErrorEvent, i18n: I18nContext) -> Any: 29 | await error.update.message.answer(text=i18n.messages.something_went_wrong()) 30 | 31 | 32 | @router.error(ExceptionTypeFilter(SplitUnauthorizedError)) 33 | async def expire_session( 34 | _: ErrorEvent, 35 | helper: MessageHelper, 36 | i18n: I18nContext, 37 | user: UserDto, 38 | user_service: UserService, 39 | ton_connect: TcAdapter, 40 | ) -> Any: 41 | await logout_user(user=user, user_service=user_service, ton_connect=ton_connect) 42 | return await helper.edit_current_message( 43 | text=i18n.messages.session_expired(), 44 | reply_markup=connect_wallet_keyboard(i18n=i18n), 45 | ) 46 | 47 | 48 | @router.error( 49 | ExceptionTypeFilter(SplitBadRequestError), 50 | StateFilter(SGBuyPremium(), SGBuyStars(), SGUseGiftCode()), 51 | or_f( 52 | F.update.event.text.as_("username"), 53 | F.update.event.data.func(CDSelectUsername.unpack).username.as_("username"), 54 | ), 55 | ) 56 | async def answer_bad_request( 57 | error: ErrorEvent, 58 | helper: MessageHelper, 59 | i18n: I18nContext, 60 | username: str = "", 61 | ) -> Any: 62 | return await helper.edit_current_message( 63 | text=i18n.messages.purchase.error( 64 | error=cast(SplitBadRequestError, error.exception).message, 65 | username=username.removeprefix("@"), 66 | ), 67 | reply_markup=to_menu_keyboard(i18n=i18n), 68 | ) 69 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/referral_program.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Bot, Router 6 | from aiogram.types import ( 7 | InlineQuery, 8 | InlineQueryResultArticle, 9 | InputTextMessageContent, 10 | TelegramObject, 11 | ) 12 | from aiogram.utils.deep_linking import create_start_link 13 | from aiogram_i18n import I18nContext 14 | 15 | from app.telegram.keyboards.callback_data.menu import CDReferralProgram 16 | from app.telegram.keyboards.menu import menu_keyboard 17 | from app.telegram.keyboards.referral import referral_program_keyboard 18 | from app.telegram.results.inline_query import not_found_answer 19 | 20 | if TYPE_CHECKING: 21 | from app.models.dto import UserDto 22 | from app.telegram.helpers.messages import MessageHelper 23 | 24 | router: Final[Router] = Router(name=__name__) 25 | 26 | 27 | @router.callback_query(CDReferralProgram.filter()) 28 | async def show_referral_link( 29 | _: TelegramObject, 30 | helper: MessageHelper, 31 | i18n: I18nContext, 32 | bot: Bot, 33 | user: UserDto, 34 | ) -> Any: 35 | if user.wallet_address is None: 36 | return await helper.answer( 37 | text=i18n.messages.wallet_not_connected(), 38 | reply_markup=menu_keyboard(i18n=i18n, wallet_connected=user.wallet_connected), 39 | ) 40 | url: str = await create_start_link(bot=bot, payload=user.wallet_address) 41 | return await helper.answer( 42 | text=i18n.messages.referral.info(link=url), 43 | reply_markup=referral_program_keyboard(i18n=i18n, url=url), 44 | ) 45 | 46 | 47 | @router.inline_query() 48 | async def show_inline_query_menu( 49 | query: InlineQuery, 50 | i18n: I18nContext, 51 | bot: Bot, 52 | user: UserDto, 53 | ) -> Any: 54 | results: list[InlineQueryResultArticle] = [] 55 | if user.wallet_connected: 56 | url: str = await create_start_link(bot=bot, payload=user.wallet_address) 57 | invite_text: str = i18n.messages.referral.invite(link=url) 58 | results.append( 59 | InlineQueryResultArticle( 60 | id=user.wallet_address, 61 | title=i18n.buttons.share(), 62 | input_message_content=InputTextMessageContent(message_text=invite_text), 63 | ) 64 | ) 65 | else: 66 | results.append( 67 | await not_found_answer( 68 | title=i18n.messages.wallet_not_connected(), 69 | i18n=i18n, 70 | bot=bot, 71 | ) 72 | ) 73 | return await query.answer(results=results, cache_time=0) # type: ignore 74 | -------------------------------------------------------------------------------- /app/telegram/keyboards/purchase.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from aiogram.types import InlineKeyboardMarkup 4 | from aiogram.utils.keyboard import InlineKeyboardBuilder 5 | from aiogram_i18n import I18nContext 6 | from aiogram_i18n.types import InlineKeyboardButton 7 | 8 | from app.models.config import Assets 9 | from app.telegram.keyboards.callback_data.menu import CDMenu 10 | from app.telegram.keyboards.callback_data.purchase import ( 11 | CDConfirmPurchase, 12 | CDSelectCurrency, 13 | CDSelectSubscriptionPeriod, 14 | CDSelectUsername, 15 | ) 16 | 17 | 18 | def enter_username_keyboard( 19 | i18n: I18nContext, 20 | username: Optional[str] = None, 21 | ) -> InlineKeyboardMarkup: 22 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 23 | if username is not None: 24 | builder.button( 25 | text=i18n.buttons.select_username(username=username), 26 | callback_data=CDSelectUsername(username=username), 27 | ) 28 | builder.button(text=i18n.buttons.menu(), callback_data=CDMenu()) 29 | builder.adjust(1, repeat=True) 30 | return builder.as_markup() 31 | 32 | 33 | def subscription_period_keyboard(i18n: I18nContext, assets: Assets) -> InlineKeyboardMarkup: 34 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 35 | for period in assets.shop.subscription_periods: 36 | builder.button( 37 | text=i18n.messages.purchase.subscription_period(period=period), 38 | callback_data=CDSelectSubscriptionPeriod(period=period), 39 | ) 40 | builder.adjust(3, repeat=True) 41 | back: InlineKeyboardButton = InlineKeyboardButton( 42 | text=i18n.buttons.menu(), 43 | callback_data=CDMenu().pack(), 44 | ) 45 | builder.row(back) 46 | return builder.as_markup() 47 | 48 | 49 | def currency_keyboard(i18n: I18nContext, assets: Assets) -> InlineKeyboardMarkup: 50 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 51 | for currency in assets.shop.available_tickers: 52 | builder.button(text=currency, callback_data=CDSelectCurrency(currency=currency)) 53 | builder.adjust(3, repeat=True) 54 | back: InlineKeyboardButton = InlineKeyboardButton( 55 | text=i18n.buttons.menu(), 56 | callback_data=CDMenu().pack(), 57 | ) 58 | builder.row(back) 59 | return builder.as_markup() 60 | 61 | 62 | def confirm_purchase_keyboard(i18n: I18nContext) -> InlineKeyboardMarkup: 63 | builder: InlineKeyboardBuilder = InlineKeyboardBuilder() 64 | builder.button(text=i18n.buttons.confirm(), callback_data=CDConfirmPurchase()) 65 | builder.button(text=i18n.buttons.cancel(), callback_data=CDMenu()) 66 | return builder.as_markup() 67 | -------------------------------------------------------------------------------- /app/services/deep_links.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from uuid import UUID 3 | 4 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 5 | 6 | from app.enums import DeepLinkAction 7 | from app.models.config import AppConfig 8 | from app.models.dto import DeepLinkDto 9 | from app.models.sql import DeepLink 10 | from app.services.database import RedisRepository, SQLSessionContext 11 | 12 | 13 | class DeepLinksService: 14 | session_pool: async_sessionmaker[AsyncSession] 15 | redis: RedisRepository 16 | config: AppConfig 17 | 18 | def __init__( 19 | self, 20 | session_pool: async_sessionmaker[AsyncSession], 21 | redis: RedisRepository, 22 | config: AppConfig, 23 | ) -> None: 24 | self.session_pool = session_pool 25 | self.redis = redis 26 | self.config = config 27 | 28 | async def create( 29 | self, 30 | owner_id: int, 31 | action: DeepLinkAction = DeepLinkAction.INVITE, 32 | gift_code_address: Optional[str] = None, 33 | gift_code_seed: Optional[str] = None, 34 | ) -> DeepLinkDto: 35 | async with SQLSessionContext(self.session_pool) as (repository, uow): 36 | deep_link: DeepLink = DeepLink( 37 | owner_id=owner_id, 38 | action=action, 39 | gift_code_address=gift_code_address, 40 | gift_code_seed=gift_code_seed, 41 | ) 42 | await uow.commit(deep_link) 43 | return deep_link.dto() 44 | 45 | async def get(self, link_id: UUID) -> Optional[DeepLinkDto]: 46 | async with SQLSessionContext(self.session_pool) as (repository, uow): 47 | deep_link_dto: Optional[DeepLinkDto] = await self.redis.get_deep_link(link_id=link_id) 48 | if deep_link_dto is None: 49 | deep_link: Optional[DeepLink] = await repository.deep_links.get(link_id=link_id) 50 | if deep_link is None: 51 | return None 52 | deep_link_dto = deep_link.dto() 53 | await self.redis.save_deep_link( 54 | link=deep_link_dto, 55 | cache_time=self.config.common.deep_links_cache_time, 56 | ) 57 | return deep_link_dto 58 | 59 | async def get_invite_link(self, owner_id: int) -> DeepLinkDto: 60 | async with SQLSessionContext(self.session_pool) as (repository, uow): 61 | deep_link: Optional[DeepLink] = await repository.deep_links.by_owner( 62 | owner_id=owner_id, 63 | action=DeepLinkAction.INVITE, 64 | ) 65 | if deep_link is None: 66 | return await self.create(owner_id=owner_id) 67 | return deep_link.dto() 68 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from alembic import context 4 | from alembic.config import Config 5 | from sqlalchemy import URL, MetaData 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine 8 | 9 | from app.models.config.env import PostgresConfig 10 | from app.models.sql.base import Base 11 | from app.utils.logging import setup_logger 12 | 13 | # this is the Alembic Config object, which provides 14 | # access to the values within the .ini file in use. 15 | config: Config = context.config 16 | 17 | # Interpret the config file for Python logging. 18 | # This line sets up loggers basically. 19 | setup_logger() 20 | 21 | # add your model's MetaData object here 22 | # for 'autogenerate' support 23 | # from myapp import mymodel 24 | # target_metadata = mymodel.Base.metadata 25 | target_metadata: MetaData = Base.metadata 26 | 27 | 28 | # other values from the config, defined by the needs of env.py, 29 | # can be acquired: 30 | # my_important_option = config.get_main_option("my_important_option") 31 | # ... etc. 32 | 33 | 34 | def _get_postgres_dsn() -> URL: 35 | _config: PostgresConfig = PostgresConfig() 36 | return _config.build_url() 37 | 38 | 39 | def run_migrations_offline() -> None: 40 | """Run migrations in 'offline' mode. 41 | 42 | This configures the context with just a URL 43 | and not an Engine, though an Engine is acceptable 44 | here as well. By skipping the Engine creation 45 | we don't even need a DBAPI to be available. 46 | 47 | Calls to context.execute() here emit the given string to the 48 | script output. 49 | 50 | """ 51 | context.configure( 52 | url=_get_postgres_dsn(), 53 | target_metadata=target_metadata, 54 | literal_binds=True, 55 | dialect_opts={"paramstyle": "named"}, 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | def do_run_migrations(connection: Connection) -> None: 63 | context.configure(connection=connection, target_metadata=target_metadata) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | 69 | async def run_async_migrations() -> None: 70 | """ 71 | In this scenario we need to create an Engine 72 | and associate a connection with the context. 73 | """ 74 | connectable: AsyncEngine = create_async_engine(url=_get_postgres_dsn()) 75 | 76 | async with connectable.connect() as connection: 77 | await connection.run_sync(do_run_migrations) 78 | 79 | await connectable.dispose() 80 | 81 | 82 | def run_migrations_online() -> None: 83 | """Run migrations in 'online' mode.""" 84 | 85 | asyncio.run(run_async_migrations()) 86 | 87 | 88 | if context.is_offline_mode(): 89 | run_migrations_offline() 90 | else: 91 | run_migrations_online() 92 | -------------------------------------------------------------------------------- /migrations/versions/001_initial.py: -------------------------------------------------------------------------------- 1 | """initial 2 | 3 | Revision ID: 001 4 | Revises: 5 | Create Date: 2024-12-04 19:53:32.499094 6 | 7 | """ 8 | 9 | from typing import Optional, Sequence 10 | 11 | import sqlalchemy as sa 12 | from alembic import op 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = "001" 16 | down_revision: Optional[str] = None 17 | branch_labels: Optional[Sequence[str]] = None 18 | depends_on: Optional[Sequence[str]] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table( 24 | "tc_records", 25 | sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), 26 | sa.Column("telegram_id", sa.BigInteger(), nullable=False), 27 | sa.Column("key", sa.String(), nullable=False), 28 | sa.Column("value", sa.String(), nullable=False), 29 | sa.Column( 30 | "created_at", 31 | sa.DateTime(timezone=True), 32 | server_default=sa.text("timezone('UTC', now())"), 33 | nullable=False, 34 | ), 35 | sa.Column( 36 | "updated_at", 37 | sa.DateTime(timezone=True), 38 | server_default=sa.text("timezone('UTC', now())"), 39 | nullable=False, 40 | ), 41 | sa.PrimaryKeyConstraint("id"), 42 | ) 43 | op.create_table( 44 | "users", 45 | sa.Column("id", sa.BigInteger(), autoincrement=True, nullable=False), 46 | sa.Column("telegram_id", sa.BigInteger(), nullable=False), 47 | sa.Column("backend_user_id", sa.BigInteger(), nullable=True), 48 | sa.Column("backend_access_token", sa.String(), nullable=True), 49 | sa.Column("name", sa.String(), nullable=False), 50 | sa.Column("wallet_address", sa.String(), nullable=True), 51 | sa.Column("locale", sa.String(length=2), nullable=False), 52 | sa.Column("bot_blocked", sa.Boolean(), nullable=False), 53 | sa.Column("inviter", sa.String(), nullable=True), 54 | sa.Column( 55 | "created_at", 56 | sa.DateTime(timezone=True), 57 | server_default=sa.text("timezone('UTC', now())"), 58 | nullable=False, 59 | ), 60 | sa.Column( 61 | "updated_at", 62 | sa.DateTime(timezone=True), 63 | server_default=sa.text("timezone('UTC', now())"), 64 | nullable=False, 65 | ), 66 | sa.PrimaryKeyConstraint("id"), 67 | sa.UniqueConstraint("backend_user_id"), 68 | sa.UniqueConstraint("telegram_id"), 69 | ) 70 | # ### end Alembic commands ### 71 | 72 | 73 | def downgrade() -> None: 74 | # ### commands auto generated by Alembic - please adjust! ### 75 | op.drop_table("users") 76 | op.drop_table("tc_records") 77 | # ### end Alembic commands ### 78 | -------------------------------------------------------------------------------- /app/services/ton_connect/storage.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Final, Optional 3 | 4 | from pytonconnect.storage import IStorage 5 | from sqlalchemy.exc import IntegrityError 6 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 7 | 8 | from app.services.database import RedisRepository, Repository 9 | 10 | logger: Final[logging.Logger] = logging.getLogger(name=__name__) 11 | 12 | 13 | class TcStorage(IStorage): # type: ignore 14 | def __init__( 15 | self, 16 | telegram_id: int, 17 | session_pool: async_sessionmaker[AsyncSession], 18 | redis: RedisRepository, 19 | cache_time: int, 20 | ) -> None: 21 | self.telegram_id = telegram_id 22 | self.session_pool = session_pool 23 | self.redis = redis 24 | self.cache_time = cache_time 25 | 26 | async def set_item(self, key: str, value: str) -> None: 27 | async with self.session_pool() as session: 28 | repository: Repository = Repository(session=session) 29 | try: 30 | await repository.ton_connect.set_record( 31 | telegram_id=self.telegram_id, 32 | key=key, 33 | value=value, 34 | ) 35 | await self.redis.set_tc_record( 36 | telegram_id=self.telegram_id, 37 | key=key, 38 | value=value, 39 | cache_time=self.cache_time, 40 | ) 41 | except IntegrityError: 42 | logger.error("Failed to set record %s for user %d", key, self.telegram_id) 43 | 44 | async def _get_from_db(self, key: str) -> Optional[str]: 45 | async with self.session_pool() as session: 46 | repository: Repository = Repository(session=session) 47 | value: Optional[str] = await repository.ton_connect.get_record_value( 48 | telegram_id=self.telegram_id, 49 | key=key, 50 | ) 51 | 52 | if value is not None: 53 | await self.redis.set_tc_record( 54 | telegram_id=self.telegram_id, 55 | key=key, 56 | value=value, 57 | cache_time=self.cache_time, 58 | ) 59 | 60 | return value 61 | 62 | async def get_item(self, key: str, default_value: Optional[str] = None) -> Optional[str]: 63 | tc_record_value: Optional[str] = await self.redis.get_tc_record( 64 | telegram_id=self.telegram_id, 65 | key=key, 66 | ) 67 | if tc_record_value is None: 68 | tc_record_value = await self._get_from_db(key=key) 69 | return tc_record_value or default_value 70 | 71 | async def remove_item(self, key: str) -> None: 72 | async with self.session_pool() as session: 73 | repository: Repository = Repository(session=session) 74 | await repository.ton_connect.remove_record(telegram_id=self.telegram_id, key=key) 75 | await self.redis.delete_tc_record(telegram_id=self.telegram_id, key=key) 76 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/gift_codes/share.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final, Optional 4 | from uuid import UUID 5 | 6 | from aiogram import Bot, F, Router 7 | from aiogram.dispatcher.event.bases import UNHANDLED 8 | from aiogram.types import ( 9 | InlineQuery, 10 | InlineQueryResultArticle, 11 | InputTextMessageContent, 12 | ) 13 | from aiogram.utils.deep_linking import create_start_link 14 | from aiogram_i18n import I18nContext 15 | from tonutils.client import ToncenterClient 16 | 17 | from app.controllers.gift_codes.get import get_giftcode_data 18 | from app.telegram.keyboards.gift_codes import claim_gift_code_keyboard 19 | from app.telegram.results.inline_query import not_found_answer 20 | 21 | if TYPE_CHECKING: 22 | from app.models.dto import DeepLinkDto, FullGiftCodeData 23 | from app.services.deep_links import DeepLinksService 24 | 25 | router: Final[Router] = Router(name=__name__) 26 | 27 | 28 | @router.inline_query(F.query.cast(UUID).as_("link_id")) 29 | async def show_deep_link( 30 | query: InlineQuery, 31 | link_id: UUID, 32 | i18n: I18nContext, 33 | bot: Bot, 34 | toncenter: ToncenterClient, 35 | deep_links: DeepLinksService, 36 | ) -> Any: 37 | deep_link: Optional[DeepLinkDto] = await deep_links.get(link_id=link_id) 38 | if deep_link is None: 39 | return UNHANDLED 40 | 41 | try: 42 | giftcode_data: FullGiftCodeData = await get_giftcode_data( 43 | address=deep_link.gift_code_address, 44 | toncenter=toncenter, 45 | ) 46 | except ValueError: 47 | return query.answer( 48 | results=[ 49 | await not_found_answer( 50 | title=i18n.messages.gift_codes.not_found(), 51 | i18n=i18n, 52 | bot=bot, 53 | ) 54 | ], 55 | cache_time=0, 56 | ) 57 | 58 | if giftcode_data.fully_activated: 59 | return query.answer( 60 | results=[ 61 | await not_found_answer( 62 | title=i18n.messages.gift_codes.expired(), 63 | i18n=i18n, 64 | bot=bot, 65 | ) 66 | ], 67 | cache_time=0, 68 | ) 69 | 70 | share_text: str = i18n.messages.gift_codes.shared( 71 | total_amount=giftcode_data.total_amount, 72 | amount=giftcode_data.max_buy_amount, 73 | activations_left=giftcode_data.activations_left, 74 | max_activations=giftcode_data.max_activations, 75 | ) 76 | url: str = await create_start_link(bot=bot, payload=deep_link.id.hex) 77 | return query.answer( 78 | results=[ 79 | InlineQueryResultArticle( 80 | id=deep_link.id.hex, 81 | title=i18n.buttons.share_gift_code(), 82 | input_message_content=InputTextMessageContent(message_text=share_text), 83 | reply_markup=claim_gift_code_keyboard(i18n=i18n, url=url), 84 | ), 85 | ], 86 | cache_time=0, 87 | ) 88 | -------------------------------------------------------------------------------- /app/services/database/redis/repository.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any, Optional, TypeVar 4 | from uuid import UUID 5 | 6 | from pydantic import BaseModel, TypeAdapter 7 | from redis.asyncio import Redis 8 | from redis.typing import ExpiryT 9 | 10 | from app.models.dto import DeepLinkDto, UserDto 11 | from app.utils import mjson 12 | from app.utils.key_builder import StorageKey 13 | 14 | from .keys import DeepLinkKey, TcRecordKey, UserKey 15 | 16 | T = TypeVar("T", bound=Any) 17 | 18 | 19 | class RedisRepository: 20 | def __init__(self, client: Redis) -> None: 21 | self.client = client 22 | 23 | async def get(self, key: StorageKey, validator: type[T]) -> Optional[T]: 24 | value: Optional[Any] = await self.client.get(key.pack()) 25 | if value is None: 26 | return None 27 | value = mjson.decode(value) 28 | return TypeAdapter[T](validator).validate_python(value) 29 | 30 | async def set(self, key: StorageKey, value: Any, ex: Optional[ExpiryT] = None) -> None: 31 | if isinstance(value, BaseModel): 32 | value = value.model_dump(exclude_defaults=True) 33 | await self.client.set(name=key.pack(), value=mjson.encode(value), ex=ex) 34 | 35 | async def delete(self, key: StorageKey) -> None: 36 | await self.client.delete(key.pack()) 37 | 38 | async def close(self) -> None: 39 | await self.client.aclose(close_connection_pool=True) 40 | 41 | async def set_tc_record( 42 | self, 43 | telegram_id: int, 44 | key: str, 45 | value: str, 46 | cache_time: int, 47 | ) -> None: 48 | tc_record_key: TcRecordKey = TcRecordKey(telegram_id=telegram_id, key=key) 49 | await self.client.set(name=tc_record_key.pack(), value=value, ex=cache_time) 50 | 51 | async def get_tc_record(self, telegram_id: int, key: str) -> Optional[str]: 52 | tc_record_key: TcRecordKey = TcRecordKey(telegram_id=telegram_id, key=key) 53 | result: Optional[bytes] = await self.client.get(tc_record_key.pack()) 54 | if result is not None: 55 | return result.decode() 56 | return None 57 | 58 | async def delete_tc_record(self, telegram_id: int, key: str) -> None: 59 | await self.delete(key=TcRecordKey(telegram_id=telegram_id, key=key)) 60 | 61 | async def save_user(self, key: Any, value: UserDto, cache_time: int) -> None: 62 | await self.set(key=UserKey(key=str(key)), value=value, ex=cache_time) 63 | 64 | async def get_user(self, key: Any) -> Optional[UserDto]: 65 | return await self.get(key=UserKey(key=str(key)), validator=UserDto) 66 | 67 | async def delete_user(self, key: Any) -> None: 68 | await self.delete(key=UserKey(key=str(key))) 69 | 70 | async def save_deep_link(self, link: DeepLinkDto, cache_time: int) -> None: 71 | await self.set(key=DeepLinkKey(id=link.id), value=link, ex=cache_time) 72 | 73 | async def get_deep_link(self, link_id: UUID) -> Optional[DeepLinkDto]: 74 | return await self.get(key=DeepLinkKey(id=link_id), validator=DeepLinkDto) 75 | -------------------------------------------------------------------------------- /app/services/ton_connect/adapter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from typing import Any, Optional, cast 5 | 6 | from pytonconnect import TonConnect 7 | from pytonconnect.exceptions import WalletNotConnectedError 8 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 9 | 10 | from app.models.dto import TonWallet 11 | 12 | from ..backend.types import Transaction 13 | from ..database import RedisRepository 14 | from .storage import TcStorage 15 | 16 | 17 | class TcAdapter: 18 | manifest_url: str 19 | bridge_key: str 20 | storage: TcStorage 21 | connector: TonConnect 22 | 23 | def __init__( 24 | self, 25 | manifest_url: str, 26 | telegram_id: int, 27 | session_pool: async_sessionmaker[AsyncSession], 28 | redis: RedisRepository, 29 | cache_time: int, 30 | bridge_key: str, 31 | ) -> None: 32 | self.manifest_url = manifest_url 33 | self.bridge_key = bridge_key 34 | self.storage = TcStorage( 35 | telegram_id=telegram_id, 36 | session_pool=session_pool, 37 | redis=redis, 38 | cache_time=cache_time, 39 | ) 40 | self.connector = TonConnect( 41 | manifest_url=manifest_url, 42 | storage=self.storage, 43 | api_tokens={"tonapi": bridge_key}, 44 | ) 45 | 46 | def copy(self, telegram_id: int) -> TcAdapter: 47 | return TcAdapter( 48 | manifest_url=self.manifest_url, 49 | telegram_id=telegram_id, 50 | session_pool=self.storage.session_pool, 51 | redis=self.storage.redis, 52 | cache_time=self.storage.cache_time, 53 | bridge_key=self.bridge_key, 54 | ) 55 | 56 | async def is_connected(self) -> bool: 57 | return cast(bool, await self.connector.restore_connection()) 58 | 59 | async def get_address(self) -> Optional[str]: 60 | if await self.is_connected(): 61 | return self.connector.account.address # type: ignore 62 | return None 63 | 64 | async def get_wallets(self) -> list[TonWallet]: 65 | return [TonWallet.model_validate(wallet) for wallet in self.connector.get_wallets()] 66 | 67 | async def get_wallet(self, app_name: str) -> Optional[TonWallet]: 68 | wallets: list[TonWallet] = await self.get_wallets() 69 | for wallet in wallets: 70 | if wallet.app_name == app_name: 71 | return wallet 72 | return None 73 | 74 | async def generate_connection_url(self, wallet: TonWallet, ton_proof: str) -> str: 75 | return cast( 76 | str, 77 | await self.connector.connect( 78 | wallet=wallet.model_dump(), 79 | request={"ton_proof": ton_proof}, 80 | ), 81 | ) 82 | 83 | async def send_transaction(self, transaction: Transaction) -> dict[str, Any]: 84 | data: dict[str, Any] = transaction.model_dump(exclude_defaults=True) 85 | return cast( 86 | dict[str, Any], 87 | await self.connector.send_transaction(transaction=data), 88 | ) 89 | 90 | async def disconnect(self) -> None: 91 | with suppress(WalletNotConnectedError): 92 | await self.connector.disconnect() 93 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/gift_codes/confirm_creation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Final 4 | 5 | from aiogram import Bot, Router 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import TelegramObject 8 | from aiogram.utils.deep_linking import create_start_link 9 | from aiogram_i18n import I18nContext 10 | from pytonconnect.exceptions import UserRejectsError 11 | 12 | from app.enums import DeepLinkAction, GiftCodeCreationStatus 13 | from app.models.dto import DeepLinkDto, GiftCodeCreation, UserDto 14 | from app.services.backend import Backend 15 | from app.services.backend.types import NewGiftCode 16 | from app.services.deep_links import DeepLinksService 17 | from app.services.ton_connect import TcAdapter 18 | from app.telegram.filters.states import SGCreateGiftCode 19 | from app.telegram.handlers.menu.gift_codes.start_creation import show_gift_code_creation 20 | from app.telegram.keyboards.callback_data.gift_codes import CDConfirmGiftCode 21 | from app.telegram.keyboards.menu import to_menu_keyboard 22 | from app.telegram.keyboards.referral import share_keyboard 23 | 24 | if TYPE_CHECKING: 25 | from app.models.config import Assets 26 | from app.telegram.helpers.messages import MessageHelper 27 | 28 | router: Final[Router] = Router(name=__name__) 29 | 30 | 31 | @router.callback_query(SGCreateGiftCode.waiting, CDConfirmGiftCode.filter()) 32 | async def create_gift_code( 33 | _: TelegramObject, 34 | helper: MessageHelper, 35 | i18n: I18nContext, 36 | state: FSMContext, 37 | bot: Bot, 38 | ton_connect: TcAdapter, 39 | backend: Backend, 40 | deep_links: DeepLinksService, 41 | user: UserDto, 42 | assets: Assets, 43 | ) -> None: 44 | gift_code_creation: GiftCodeCreation = await GiftCodeCreation.from_state(state=state) 45 | if gift_code_creation.status(assets=assets) != GiftCodeCreationStatus.READY: 46 | return await show_gift_code_creation( 47 | helper=helper, 48 | i18n=i18n, 49 | state=state, 50 | backend=backend, 51 | assets=assets, 52 | ) 53 | 54 | gift_code: NewGiftCode = await backend.create_gift_code( 55 | max_activations=gift_code_creation.activations, 56 | max_buy_amount=gift_code_creation.amount, 57 | ) 58 | 59 | await state.clear() 60 | await helper.answer( 61 | text=i18n.messages.confirm_transaction(), 62 | reply_markup=to_menu_keyboard(i18n=i18n), 63 | ) 64 | 65 | deep_link: DeepLinkDto = await deep_links.create( 66 | owner_id=user.id, 67 | action=DeepLinkAction.USE_GIFT_CODE, 68 | gift_code_address=gift_code.address, 69 | gift_code_seed=gift_code.seed, 70 | ) 71 | url: str = await create_start_link(bot=bot, payload=deep_link.id.hex) 72 | await helper.answer( 73 | text=i18n.messages.gift_codes.created(link=url), 74 | reply_markup=share_keyboard(i18n=i18n, deep_link_id=deep_link.id.hex), 75 | edit=False, 76 | delete=False, 77 | ) 78 | 79 | try: 80 | await ton_connect.send_transaction(gift_code.transaction) 81 | except UserRejectsError: 82 | await helper.answer( 83 | text=i18n.messages.transaction_canceled(), 84 | reply_markup=to_menu_keyboard(i18n=i18n), 85 | ) 86 | -------------------------------------------------------------------------------- /app/services/user.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Awaitable, Callable, Optional, cast 2 | 3 | from aiogram.types import User as AiogramUser 4 | from aiogram_i18n.cores import BaseCore 5 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 6 | 7 | from app.models.config import AppConfig 8 | from app.models.dto import UserDto 9 | from app.models.sql import User 10 | from app.services.database import RedisRepository, SQLSessionContext 11 | 12 | 13 | class UserService: 14 | session_pool: async_sessionmaker[AsyncSession] 15 | redis: RedisRepository 16 | config: AppConfig 17 | 18 | def __init__( 19 | self, 20 | session_pool: async_sessionmaker[AsyncSession], 21 | redis: RedisRepository, 22 | config: AppConfig, 23 | ) -> None: 24 | self.session_pool = session_pool 25 | self.redis = redis 26 | self.config = config 27 | 28 | async def create( 29 | self, 30 | aiogram_user: AiogramUser, 31 | i18n_core: BaseCore[Any], 32 | inviter: Optional[str] = None, 33 | ) -> UserDto: 34 | async with SQLSessionContext(self.session_pool) as (repository, uow): 35 | user: User = User( 36 | telegram_id=aiogram_user.id, 37 | name=aiogram_user.full_name, 38 | locale=( 39 | aiogram_user.language_code 40 | if aiogram_user.language_code in i18n_core.locales 41 | else cast(str, i18n_core.default_locale) 42 | ), 43 | inviter=inviter, 44 | ) 45 | await uow.commit(user) 46 | return user.dto() 47 | 48 | async def _get( 49 | self, 50 | getter: Callable[[Any], Awaitable[Optional[User]]], 51 | key: Any, 52 | ) -> Optional[UserDto]: 53 | user_dto: Optional[UserDto] = await self.redis.get_user(key=key) 54 | if user_dto is not None: 55 | return user_dto 56 | user: Optional[User] = await getter(key) 57 | if user is None: 58 | return None 59 | await self.redis.save_user( 60 | key=user.telegram_id, 61 | value=(user_dto := user.dto()), 62 | cache_time=self.config.common.users_cache_time, 63 | ) 64 | return user_dto 65 | 66 | async def get(self, user_id: int) -> Optional[UserDto]: 67 | async with SQLSessionContext(self.session_pool) as (repository, uow): 68 | return await self._get(repository.users.get, user_id) 69 | 70 | async def by_tg_id(self, telegram_id: int) -> Optional[UserDto]: 71 | async with SQLSessionContext(self.session_pool) as (repository, uow): 72 | return await self._get(repository.users.by_tg_id, telegram_id) 73 | 74 | async def by_address(self, wallet_address: str) -> Optional[UserDto]: 75 | async with SQLSessionContext(self.session_pool) as (repository, uow): 76 | return await self._get(repository.users.by_address, wallet_address) 77 | 78 | async def update(self, user: UserDto, **kwargs: Any) -> None: 79 | for key, value in kwargs.items(): 80 | setattr(user, key, value) 81 | async with SQLSessionContext(self.session_pool) as (repository, uow): 82 | await repository.users.update(user_id=user.id, **user.model_state) 83 | await self.redis.save_user( 84 | key=user.telegram_id, 85 | value=user, 86 | cache_time=self.config.common.users_cache_time, 87 | ) 88 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/gift_codes/start_creation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final, Optional 4 | 5 | from aiogram import Router 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import TelegramObject 8 | from aiogram_i18n import I18nContext 9 | 10 | from app.enums import GiftCodeCreationStatus 11 | from app.models.dto import GiftCodeCreation 12 | from app.services.backend import Backend 13 | from app.telegram.filters.states import NoneState, SGCreateGiftCode 14 | from app.telegram.keyboards.callback_data.gift_codes import CDCreateGiftCode 15 | from app.telegram.keyboards.callback_data.menu import CDRefresh 16 | from app.telegram.keyboards.gift_codes import gift_code_creation_keyboard 17 | from app.utils.localization.patches import FluentNullable 18 | 19 | if TYPE_CHECKING: 20 | from app.models.config import Assets 21 | from app.telegram.helpers.messages import MessageHelper 22 | 23 | router: Final[Router] = Router(name=__name__) 24 | 25 | 26 | async def show_gift_code_creation( 27 | helper: MessageHelper, 28 | i18n: I18nContext, 29 | state: FSMContext, 30 | backend: Backend, 31 | assets: Assets, 32 | gift_code_creation: Optional[GiftCodeCreation] = None, 33 | **kwargs: Any, 34 | ) -> Any: 35 | await state.set_state(SGCreateGiftCode.waiting) 36 | if gift_code_creation is None: 37 | gift_code_creation = await GiftCodeCreation.from_state(state=state) 38 | for key, value in kwargs.items(): 39 | setattr(gift_code_creation, key, value) 40 | status: GiftCodeCreationStatus = gift_code_creation.status(assets=assets) 41 | ton_rate: Optional[float] = None 42 | if gift_code_creation.amount is not None: 43 | ton_rate = await backend.get_ton_rate() 44 | 45 | answer, fsm_data = await helper.edit_current_message( 46 | text=i18n.messages.gift_codes.info( 47 | activations=FluentNullable(gift_code_creation.activations), 48 | amount=FluentNullable(gift_code_creation.amount), 49 | usd_amount=FluentNullable(gift_code_creation.usd_amount(ton_rate=ton_rate)), 50 | min_activations=assets.gift_codes.min_activations, 51 | max_activations=assets.gift_codes.max_activations, 52 | min_amount=assets.gift_codes.min_amount, 53 | max_amount=assets.gift_codes.max_amount, 54 | status=status, 55 | ), 56 | reply_markup=gift_code_creation_keyboard(i18n=i18n, status=status), 57 | ) 58 | 59 | old_message_id: Optional[int] = gift_code_creation.message_id 60 | new_message_id: Optional[int] = fsm_data.get("message_id") 61 | if new_message_id is not None and old_message_id != new_message_id: 62 | gift_code_creation.message_id = new_message_id 63 | gift_code_creation.to_delete.clear() 64 | await gift_code_creation.update_state(state=state) 65 | 66 | 67 | @router.callback_query(NoneState, CDCreateGiftCode.filter()) 68 | @router.callback_query(SGCreateGiftCode(), CDRefresh.filter()) 69 | async def start_gift_code_creation( 70 | _: TelegramObject, 71 | helper: MessageHelper, 72 | i18n: I18nContext, 73 | state: FSMContext, 74 | backend: Backend, 75 | assets: Assets, 76 | raw_state: Optional[str] = None, 77 | ) -> Any: 78 | await show_gift_code_creation( 79 | helper=helper, 80 | i18n=i18n, 81 | state=state, 82 | backend=backend, 83 | assets=assets, 84 | gift_code_creation=GiftCodeCreation() if raw_state is None else None, 85 | ) 86 | -------------------------------------------------------------------------------- /assets/messages/ru/messages.ftl: -------------------------------------------------------------------------------- 1 | messages-hello = 2 | 👋 Привет, { $name }! 3 | 4 | 🤩 Готов(-а) купить немного звёзд? 5 | 6 | ⚡️ Сделано с ❤️ для Split.tg 7 | 8 | messages-choose_wallet = 👛 Выберите кошелёк для привязки 9 | messages-ton_connect = 👇 Нажмите на кнопку ниже или отсканируйте QR-код, чтобы привязать ваш TON кошелёк 10 | messages-wallet_connected = 👛 Вы успешно привязали свой кошелёк { $address } 11 | messages-wallet_not_connected = 👛 Привяжите ваш TON кошелёк, чтобы продолжить 12 | messages-session_expired = ⏳ Ваша сессия истекла. Пожалуйста, подключите ваш TON кошелёк снова 13 | messages-connection_cancelled = ☑️ Подключение отменено 14 | messages-connection_timeout = ⏳ Время подключения истекло 15 | messages-something_went_wrong = Упс! Что-то пошло не так... 16 | messages-language = 🌎 Выберите язык, нажав на кнопку ниже: 17 | extra-language = 🇷🇺 Русский 18 | 19 | messages-purchase-enter_username = ✏️ Введите юзернейм пользователя 20 | messages-purchase-enter_count = ⭐️ Введите количество звёзд 21 | messages-purchase-wrong_count = 22 | ❌ Неверное количество звёзд! 23 | 24 | ↘️ Минимум » { $minimum } ⭐️ 25 | ↗️ Максимум » { $maximum } ⭐️ 26 | 27 | 🔢 Введено » { $entered } ⭐️ 28 | 29 | messages-purchase-stars = { $count } ⭐️ 30 | messages-purchase-subscription_period = { $period -> 31 | [one] { $period } месяц 32 | [few] { $period } месяца 33 | [12] 1 год 34 | *[other] { $period } месяцев 35 | } 36 | 37 | messages-purchase-error = { $error -> 38 | [already_premium] 😴 У @{ $username } уже есть подписка на Telegram Premium 39 | [username_not_assigned] ⛓️‍💥 Ссылка @{ $username } не привязана к пользователю 40 | [username_not_found] 🫗 Пользователь @{ $username } не найден 41 | *[other] { $error } 42 | } 43 | 44 | messages-purchase-currency_not_available = 🫗 Данная валюта больше не доступна 45 | messages-purchase-subscription_not_available = 🫗 Данная опция недоступна 46 | messages-purchase-premium = Telegram Premium на { messages-purchase-subscription_period } 47 | messages-purchase-select_period = 📅 Выберите период подписки 48 | messages-purchase-select_currency = 💱 Выберите валюту для оплаты 49 | messages-purchase-confirm = 50 | 👤 Получатель » @{ $username } 51 | 🛒 Товар » { $product } 52 | 💸 Цена » { $ton_price } TON (~${ $usd_price }) 53 | 54 | messages-confirm_transaction = 55 | 👛 Подтвердите транзакцию в приложении вашего кошелька, чтобы продолжить 56 | 57 | messages-transaction_canceled = ❌ Транзакция отменена 58 | 59 | messages-referral-info = 60 | 🌟 Приглашай друзей по реферальной ссылке и получай 40% от их комиссий! 61 | 62 | 👇 Нажми кнопку ниже, чтобы скопировать свою реферальную ссылку 63 | 64 | messages-referral-invite = 65 | ⭐️ Покупай звёзды без KYC через Split ❤️ 66 | 67 | 🚀 { $link } 68 | 69 | buttons-menu = 📚 Меню 70 | buttons-premium = 💠 Premium 71 | buttons-stars = ⭐️ Stars 72 | buttons-select_username = 👤 @{ $username } (Я) 73 | buttons-app = 📱 Открыть в приложении 74 | buttons-referral_program = 🚀 Реф. программа 75 | buttons-language = 🌎 Язык 76 | buttons-copy_link = 🔗 Скопировать ссылку 77 | buttons-share = 👥 Поделиться 78 | buttons-join_bot = 💝️ Покупай Stars через Split.tg 79 | buttons-disconnect = ⛓️‍💥 Отключить кошелёк 80 | buttons-connect = 🔌 Подключить кошелёк 81 | buttons-back = 🔙 Назад 82 | buttons-cancel = 🚫 Отмена 83 | buttons-ton_connect_url = 📱 Перейти к приложению 84 | buttons-confirm = ✅ Всё верно 85 | buttons-create = 🚀 Создать 86 | -------------------------------------------------------------------------------- /assets/messages/en/messages.ftl: -------------------------------------------------------------------------------- 1 | messages-hello = 2 | 👋 Hello, { $name }! 3 | 4 | 🤩 Ready to buy some stars? 5 | 6 | ⚡️ Powered by Split.tg 7 | 8 | messages-choose_wallet = 👛 Choose wallet to connect 9 | messages-ton_connect = 👇 Click the button below or scan QR code to connect your TON wallet 10 | messages-wallet_connected = 👛 You have successfuly linked your TON wallet { $address } 11 | messages-wallet_not_connected = 👛 Connect your TON wallet to continue 12 | messages-session_expired = ⏳ Your session has expired. Please reconnect your TON wallet again 13 | messages-connection_cancelled = ☑️ Connection cancelled 14 | messages-connection_timeout = ⏳ Connection expired 15 | messages-something_went_wrong = Oops! Something went wrong... 16 | messages-language = 🌎 Select your preferred language by clicking button below: 17 | 18 | extra-language = 🇬🇧 English 19 | extra-selectable = { $selected -> 20 | [true] [ {$value} ] 21 | *[other] { $value } 22 | } 23 | 24 | messages-purchase-enter_username = ✏️ Enter username 25 | messages-purchase-enter_count = ⭐️ Enter stars count 26 | messages-purchase-wrong_count = 27 | ❌ Wrong stars count! 28 | 29 | ↘️ Minimum » { $minimum } ⭐️ 30 | ↗️ Maximum » { $maximum } ⭐️ 31 | 32 | 🔢 Entered » { $entered } ⭐️ 33 | 34 | messages-purchase-stars = { $count } ⭐️ 35 | messages-purchase-subscription_period = { $period -> 36 | [1] 1 month 37 | [12] 1 year 38 | *[other] { $period } months 39 | } 40 | 41 | messages-purchase-error = { $error -> 42 | [already_premium] 😴 @{ $username } already has a premium subscription 43 | [username_not_assigned] ⛓️‍💥 Username @{ $username } is not assigned to a user. Maybe you should try again? 44 | [username_not_found] 🫗 No users found with username @{ $username }. Maybe you should try again? 45 | *[other] { $error } 46 | } 47 | 48 | messages-purchase-currency_not_available = 🫗 Currency is no longer available 49 | messages-purchase-subscription_not_available = 🫗 Subscription is no longer available 50 | messages-purchase-premium = Telegram Premium for { messages-purchase-subscription_period } 51 | messages-purchase-select_period = 📅 Select the subscription period 52 | messages-purchase-select_currency = 💱 Select the currency 53 | messages-purchase-confirm = 54 | 👤 Receiver » @{ $username } 55 | 🛒 Product » { $product } 56 | 💸 Price » { $ton_price } TON (~${ $usd_price }) 57 | 58 | messages-confirm_transaction = 👛 Confirm transaction in your wallet app to proceed 59 | messages-transaction_canceled = ❌ Transaction canceled 60 | 61 | messages-referral-info = 62 | 🌟 Invite friends with your referral link and get 40% of their commissions! 63 | 64 | 👇 Click button below to copy your referral link 65 | 66 | messages-referral-invite = 67 | ⭐️ Buy Stars without KYC via Split ❤️ 68 | 69 | 🚀 { $link } 70 | 71 | buttons-menu = 📚 Menu 72 | buttons-premium = 💠 Premium 73 | buttons-stars = ⭐️ Stars 74 | buttons-select_username = 👤 @{ $username } (Me) 75 | buttons-app = 📱 Open in App 76 | buttons-referral_program = 🚀 Referral Program 77 | buttons-language = 🌎 Language 78 | buttons-copy_link = 🔗 Copy Link 79 | buttons-share = 👥 Share 80 | buttons-join_bot = 💝️ Buy Stars via Split.tg 81 | buttons-disconnect = ⛓️‍💥 Disconnect Wallet 82 | buttons-connect = 🔌 Connect Wallet 83 | buttons-back = 🔙 Back 84 | buttons-cancel = 🚫 Cancel 85 | buttons-ton_connect_url = 📱 Go to the app 86 | buttons-confirm = ✅ Everything is correct 87 | buttons-create = 🚀 Create 88 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = migrations 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python-dateutil library that can be 18 | # installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to dateutil.tz.gettz() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "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 migrations/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:migrations/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 = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | output_encoding = utf-8 60 | 61 | 62 | [post_write_hooks] 63 | # post_write_hooks defines scripts or Python functions that are run 64 | # on newly generated revision scripts. See the documentation for further 65 | # detail and examples 66 | hooks = black,ruff 67 | 68 | black.type = console_scripts 69 | black.entrypoint = black 70 | black.options = -l 99 REVISION_SCRIPT_FILENAME 71 | 72 | ruff.type = exec 73 | ruff.executable = ruff 74 | ruff.options = check --fix --unsafe-fixes REVISION_SCRIPT_FILENAME 75 | 76 | # Logging configuration 77 | [loggers] 78 | keys = root,sqlalchemy,alembic 79 | 80 | [handlers] 81 | keys = console 82 | 83 | [formatters] 84 | keys = generic 85 | 86 | [logger_root] 87 | level = WARN 88 | handlers = console 89 | qualname = 90 | 91 | [logger_sqlalchemy] 92 | level = WARN 93 | handlers = 94 | qualname = sqlalchemy.engine 95 | 96 | [logger_alembic] 97 | level = INFO 98 | handlers = 99 | qualname = alembic 100 | 101 | [handler_console] 102 | class = StreamHandler 103 | args = (sys.stderr,) 104 | level = NOTSET 105 | formatter = generic 106 | 107 | [formatter_generic] 108 | format = %(levelname)-5.5s [%(name)s] %(message)s 109 | datefmt = %H:%M:%S 110 | -------------------------------------------------------------------------------- /app/factory/telegram/dispatcher.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from aiogram import Dispatcher, F 4 | from aiogram.contrib.paginator import PaginationFactory 5 | from aiogram.enums import ChatType 6 | from aiogram.fsm.storage.redis import RedisStorage 7 | from aiogram.utils.callback_answer import CallbackAnswerMiddleware 8 | from redis.asyncio import Redis 9 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker 10 | from stollen.requests import RequestSerializer 11 | from tonutils.client import ToncenterClient 12 | 13 | from app.enums import MiddlewareEventType 14 | from app.factory.redis import create_redis 15 | from app.factory.session_pool import create_session_pool 16 | from app.factory.telegram.i18n import setup_i18n_middleware 17 | from app.models.config import AppConfig, Assets 18 | from app.services.backend.session import BackendSession 19 | from app.services.database import RedisRepository 20 | from app.services.deep_links import DeepLinksService 21 | from app.services.task_manager import TaskManager 22 | from app.services.user import UserService 23 | from app.telegram.handlers import admin, extra, menu, ton_connect 24 | from app.telegram.helpers.paginator import Paginator 25 | from app.telegram.keyboards.callback_data.menu import CDPagination 26 | from app.telegram.middlewares import ( 27 | BackendProviderMiddleware, 28 | MessageHelperMiddleware, 29 | TonConnectMiddleware, 30 | UserMiddleware, 31 | ) 32 | from app.utils import mjson 33 | 34 | 35 | def setup_pagination(dispatcher: Dispatcher) -> None: 36 | PaginationFactory( 37 | paginator_cls=Paginator, 38 | page_button_data=CDPagination, 39 | rows_per_page=5, 40 | ).register( 41 | dispatcher, 42 | MiddlewareEventType.MESSAGE, 43 | MiddlewareEventType.CALLBACK_QUERY, 44 | ) 45 | 46 | 47 | def create_dispatcher(config: AppConfig) -> Dispatcher: 48 | redis: Redis = create_redis(url=config.redis.build_url()) 49 | session_pool: async_sessionmaker[AsyncSession] = create_session_pool(config=config) 50 | redis_repository: RedisRepository = RedisRepository(client=redis) 51 | 52 | # noinspection PyArgumentList 53 | dispatcher: Dispatcher = Dispatcher( 54 | name="main_dispatcher", 55 | storage=RedisStorage( 56 | redis=redis, 57 | json_loads=mjson.decode, 58 | json_dumps=mjson.encode, 59 | ), 60 | config=config, 61 | session_pool=session_pool, 62 | redis=redis_repository, 63 | task_manager=TaskManager(), 64 | assets=Assets(), 65 | backend_session=BackendSession(serializer=RequestSerializer(exclude_defaults=False)), 66 | user_service=UserService( 67 | session_pool=session_pool, 68 | redis=redis_repository, 69 | config=config, 70 | ), 71 | deep_links=DeepLinksService( 72 | session_pool=session_pool, 73 | redis=redis_repository, 74 | config=config, 75 | ), 76 | toncenter=ToncenterClient(api_key=config.common.ton_center_key.get_secret_value()), 77 | ) 78 | 79 | dispatcher.include_routers( 80 | admin.router, 81 | menu.router, 82 | ton_connect.router, 83 | extra.router, 84 | ) 85 | dispatcher.message.filter(F.chat.type == ChatType.PRIVATE) 86 | dispatcher.callback_query.filter(F.message.chat.type == ChatType.PRIVATE) 87 | 88 | UserMiddleware().setup_inner(router=dispatcher) 89 | BackendProviderMiddleware().setup_inner(router=dispatcher) 90 | setup_i18n_middleware(dispatcher=dispatcher, config=config) 91 | MessageHelperMiddleware().setup_inner(router=dispatcher) 92 | setup_pagination(dispatcher) 93 | TonConnectMiddleware().setup_inner(router=dispatcher) 94 | dispatcher.callback_query.middleware(CallbackAnswerMiddleware()) 95 | 96 | return dispatcher 97 | -------------------------------------------------------------------------------- /app/telegram/middlewares/user.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from contextlib import suppress 4 | from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional 5 | from uuid import UUID 6 | 7 | from aiogram import Bot, F 8 | from aiogram.filters import CommandObject, CommandStart 9 | from aiogram.filters.command import CommandException 10 | from aiogram.types import Message, TelegramObject, Update 11 | from aiogram.types import User as AiogramUser 12 | from aiogram_i18n import I18nMiddleware 13 | from pytoniq_core import Address 14 | 15 | from app.services.deep_links import DeepLinksService 16 | from app.services.user import UserService 17 | from app.telegram.middlewares.event_typed import EventTypedMiddleware 18 | from app.utils.logging import database as logger 19 | 20 | if TYPE_CHECKING: 21 | from app.models.dto import DeepLinkDto, UserDto 22 | 23 | 24 | class UserMiddleware(EventTypedMiddleware): 25 | def __init__(self) -> None: 26 | self.deep_links_filter = CommandStart(magic=F.args.cast(UUID)) 27 | self.address_filter = CommandStart(magic=F.args.cast(Address).to_str(is_bounceable=True)) 28 | 29 | async def resolve_inviter( 30 | self, 31 | event: TelegramObject, 32 | bot: Bot, 33 | user_service: UserService, 34 | deep_links: DeepLinksService, 35 | ) -> Optional[str]: 36 | if isinstance(event, Update): 37 | event = event.event 38 | 39 | if not isinstance(event, Message) or event.text is None: 40 | return None 41 | 42 | with suppress(CommandException): 43 | command: CommandObject = await self.address_filter.parse_command(event.text, bot) 44 | return command.magic_result 45 | 46 | with suppress(CommandException, ValueError): 47 | command = await self.deep_links_filter.parse_command(event.text, bot) 48 | if not isinstance(command.magic_result, UUID): 49 | raise ValueError() 50 | deep_link: Optional[DeepLinkDto] = await deep_links.get(link_id=command.magic_result) 51 | if deep_link is None: 52 | raise ValueError() 53 | owner: Optional[UserDto] = await user_service.get(user_id=deep_link.owner_id) 54 | if owner is None: 55 | raise ValueError() 56 | return owner.wallet_address 57 | 58 | return None 59 | 60 | async def __call__( 61 | self, 62 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], 63 | event: TelegramObject, 64 | data: dict[str, Any], 65 | ) -> Optional[Any]: 66 | aiogram_user: Optional[AiogramUser] = data.get("event_from_user") 67 | if aiogram_user is None or aiogram_user.is_bot: 68 | # Prevents the bot itself from being added to the database 69 | # when accepting chat_join_request and receiving chat_member updates. 70 | return await handler(event, data) 71 | 72 | user_service: UserService = data["user_service"] 73 | user: Optional[UserDto] = await user_service.by_tg_id(telegram_id=aiogram_user.id) 74 | if user is None: 75 | i18n: I18nMiddleware = data["i18n_middleware"] 76 | user = await user_service.create( 77 | aiogram_user=aiogram_user, 78 | i18n_core=i18n.core, 79 | inviter=await self.resolve_inviter( 80 | event=event, 81 | bot=data["bot"], 82 | user_service=user_service, 83 | deep_links=data["deep_links"], 84 | ), 85 | ) 86 | logger.info( 87 | "New user in database: %s (%d)", 88 | aiogram_user.full_name, 89 | aiogram_user.id, 90 | ) 91 | 92 | data["user"] = user 93 | return await handler(event, data) 94 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/gift_codes/edit_creation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import Message, TelegramObject 8 | from aiogram_i18n import I18nContext 9 | 10 | from app.models.dto import GiftCodeCreation 11 | from app.services.backend import Backend 12 | from app.telegram.filters.states import SGCreateGiftCode 13 | from app.telegram.handlers.menu.gift_codes.start_creation import show_gift_code_creation 14 | from app.telegram.keyboards.callback_data.gift_codes import ( 15 | CDSetGiftCodeActivations, 16 | CDSetGiftCodeAmount, 17 | ) 18 | from app.telegram.keyboards.callback_data.menu import CDRefresh 19 | from app.telegram.keyboards.menu import cancel_keyboard 20 | 21 | if TYPE_CHECKING: 22 | from app.models.config import Assets 23 | from app.telegram.helpers.messages import MessageHelper 24 | 25 | router: Final[Router] = Router(name=__name__) 26 | 27 | 28 | @router.callback_query(SGCreateGiftCode(), CDSetGiftCodeAmount.filter()) 29 | async def ask_gift_code_amount( 30 | _: TelegramObject, 31 | helper: MessageHelper, 32 | i18n: I18nContext, 33 | state: FSMContext, 34 | assets: Assets, 35 | ) -> Any: 36 | gift_code_creation: GiftCodeCreation = await GiftCodeCreation.from_state(state=state) 37 | message: Message = await helper.answer( # type: ignore 38 | text=i18n.messages.gift_codes.enter_amount( 39 | min=assets.gift_codes.min_amount, 40 | max=assets.gift_codes.max_amount, 41 | ), 42 | reply_markup=cancel_keyboard(i18n=i18n, data=CDRefresh()), 43 | edit=False, 44 | delete=False, 45 | ) 46 | gift_code_creation.to_delete.append(message.message_id) 47 | await state.set_state(SGCreateGiftCode.amount) 48 | await gift_code_creation.update_state(state=state) 49 | 50 | 51 | @router.callback_query(SGCreateGiftCode(), CDSetGiftCodeActivations.filter()) 52 | async def ask_gift_code_activations( 53 | _: TelegramObject, 54 | helper: MessageHelper, 55 | i18n: I18nContext, 56 | state: FSMContext, 57 | assets: Assets, 58 | ) -> Any: 59 | gift_code_creation: GiftCodeCreation = await GiftCodeCreation.from_state(state=state) 60 | message: Message = await helper.answer( # type: ignore 61 | text=i18n.messages.gift_codes.enter_activations( 62 | min=assets.gift_codes.min_activations, 63 | max=assets.gift_codes.max_activations, 64 | ), 65 | reply_markup=cancel_keyboard(i18n=i18n, data=CDRefresh()), 66 | edit=False, 67 | delete=False, 68 | ) 69 | gift_code_creation.to_delete.append(message.message_id) 70 | await state.set_state(SGCreateGiftCode.activations) 71 | await gift_code_creation.update_state(state=state) 72 | 73 | 74 | @router.message(SGCreateGiftCode.amount, F.text.cast(float).as_("amount")) 75 | async def set_gift_code_amount( 76 | _: TelegramObject, 77 | helper: MessageHelper, 78 | i18n: I18nContext, 79 | state: FSMContext, 80 | backend: Backend, 81 | assets: Assets, 82 | amount: float, 83 | ) -> Any: 84 | await show_gift_code_creation( 85 | helper=helper, 86 | i18n=i18n, 87 | state=state, 88 | backend=backend, 89 | assets=assets, 90 | amount=amount, 91 | ) 92 | 93 | 94 | @router.message(SGCreateGiftCode.activations, F.text.cast(int).as_("activations")) 95 | async def set_gift_code_activations( 96 | _: TelegramObject, 97 | helper: MessageHelper, 98 | i18n: I18nContext, 99 | state: FSMContext, 100 | backend: Backend, 101 | assets: Assets, 102 | activations: int, 103 | ) -> Any: 104 | await show_gift_code_creation( 105 | helper=helper, 106 | i18n=i18n, 107 | state=state, 108 | backend=backend, 109 | assets=assets, 110 | activations=activations, 111 | ) 112 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/shop/stars.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import CallbackQuery, Message 8 | from aiogram_i18n import I18nContext 9 | from pytonconnect.exceptions import UserRejectsError 10 | 11 | from app.controllers.price import PriceDto, get_stars_price 12 | from app.telegram.filters import MagicData 13 | from app.telegram.filters.states import SGBuyStars 14 | from app.telegram.keyboards.callback_data.menu import CDTelegramStars 15 | from app.telegram.keyboards.callback_data.purchase import CDConfirmPurchase, CDSelectUsername 16 | from app.telegram.keyboards.menu import to_menu_keyboard 17 | from app.telegram.keyboards.purchase import confirm_purchase_keyboard, enter_username_keyboard 18 | from app.telegram.middlewares import TonConnectCheckerMiddleware 19 | 20 | if TYPE_CHECKING: 21 | from app.models.config import Assets 22 | from app.services.backend import Backend 23 | from app.services.backend.types import Recipient, Transaction 24 | from app.services.ton_connect import TcAdapter 25 | from app.telegram.helpers.messages import MessageHelper 26 | 27 | router: Final[Router] = Router(name=__name__) 28 | TonConnectCheckerMiddleware().setup_inner(router) 29 | 30 | 31 | @router.callback_query(CDTelegramStars.filter()) 32 | async def proceed_stars_purchase( 33 | query: CallbackQuery, 34 | helper: MessageHelper, 35 | i18n: I18nContext, 36 | state: FSMContext, 37 | ) -> Any: 38 | await state.set_state(SGBuyStars.enter_username) 39 | message: Message = await helper.answer( # type: ignore[assignment] 40 | text=i18n.messages.purchase.enter_username(), 41 | reply_markup=enter_username_keyboard(i18n=i18n, username=query.from_user.username), 42 | ) 43 | await state.set_data({"message_id": message.message_id}) 44 | 45 | 46 | @router.message(SGBuyStars.enter_username, F.text.as_("username")) 47 | @router.callback_query( 48 | SGBuyStars.enter_username, 49 | CDSelectUsername.filter(), 50 | MagicData(F.callback_data.username.as_("username")), 51 | ) 52 | async def save_stars_recipient( 53 | _: Message, 54 | helper: MessageHelper, 55 | username: str, 56 | i18n: I18nContext, 57 | backend: Backend, 58 | ) -> Any: 59 | recipient: Recipient = await backend.resolve_stars_recipient(username=username) 60 | await helper.next_step( 61 | state=SGBuyStars.enter_count, 62 | text=i18n.messages.purchase.enter_count(), 63 | reply_markup=to_menu_keyboard(i18n=i18n), 64 | update={"username": username, "recipient": recipient.recipient}, 65 | ) 66 | 67 | 68 | @router.message(SGBuyStars.enter_count, F.text.cast(int).as_("quantity")) 69 | async def save_stars_count( 70 | _: Message, 71 | helper: MessageHelper, 72 | state: FSMContext, 73 | quantity: int, 74 | i18n: I18nContext, 75 | backend: Backend, 76 | assets: Assets, 77 | ) -> Any: 78 | if quantity < assets.shop.min_stars or quantity > assets.shop.max_stars: 79 | return await helper.edit_current_message( 80 | text=i18n.messages.purchase.wrong_count( 81 | minimum=assets.shop.min_stars, 82 | maximum=assets.shop.max_stars, 83 | entered=quantity, 84 | ), 85 | reply_markup=to_menu_keyboard(i18n=i18n), 86 | ) 87 | data: dict[str, Any] = await state.get_data() 88 | price: PriceDto = await get_stars_price(quantity=quantity, backend=backend, assets=assets) 89 | await helper.next_step( 90 | state=SGBuyStars.confirm, 91 | text=i18n.messages.purchase.confirm( 92 | username=data["username"].removeprefix("@"), 93 | product=i18n.messages.purchase.stars(count=quantity), 94 | usd_price=price.usd_price, 95 | ton_price=price.ton_price, 96 | ), 97 | reply_markup=confirm_purchase_keyboard(i18n=i18n), 98 | update={"quantity": quantity}, 99 | ) 100 | 101 | 102 | @router.callback_query(SGBuyStars.confirm, CDConfirmPurchase.filter()) 103 | async def buy_stars( 104 | _: CallbackQuery, 105 | helper: MessageHelper, 106 | i18n: I18nContext, 107 | state: FSMContext, 108 | ton_connect: TcAdapter, 109 | backend: Backend, 110 | ) -> Any: 111 | data: dict[str, Any] = await state.get_data() 112 | transaction: Transaction = await backend.buy_stars( 113 | recipient=data["recipient"], 114 | quantity=data["quantity"], 115 | username=data["username"], 116 | ) 117 | await state.clear() 118 | await helper.answer( 119 | text=i18n.messages.confirm_transaction(), 120 | reply_markup=to_menu_keyboard(i18n=i18n), 121 | ) 122 | try: 123 | await ton_connect.send_transaction(transaction) 124 | except UserRejectsError: 125 | await helper.answer( 126 | text=i18n.messages.transaction_canceled(), 127 | reply_markup=to_menu_keyboard(i18n=i18n), 128 | ) 129 | -------------------------------------------------------------------------------- /app/telegram/handlers/ton_connect/link.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import Router, html 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import BufferedInputFile, CallbackQuery, Message 8 | from aiogram_i18n import I18nContext 9 | from pytonconnect.exceptions import WalletAlreadyConnectedError 10 | 11 | from app.controllers.auth import authorize_user 12 | from app.controllers.ton.connect import generate_ton_connection 13 | from app.controllers.ton.waiter import wait_until_connected 14 | from app.exceptions.ton_connect import InvalidTonProofError 15 | from app.models.dto import UserDto 16 | from app.services.backend import Backend 17 | from app.telegram.handlers.menu.main import show_main_menu 18 | from app.telegram.helpers.exceptions import silent_bot_request 19 | from app.telegram.keyboards.callback_data.ton_connect import CDCancelConnection, CDChooseWallet 20 | from app.telegram.keyboards.menu import to_menu_keyboard 21 | from app.telegram.keyboards.ton_connect import ton_connect_keyboard 22 | from app.utils.qr import create_qr_code 23 | from app.utils.ton import short_address 24 | 25 | if TYPE_CHECKING: 26 | from app.models.config import Assets 27 | from app.models.dto import TonConnection, TonConnectResult 28 | from app.services.task_manager import TaskManager 29 | from app.services.ton_connect import TcAdapter 30 | from app.services.user import UserService 31 | from app.telegram.helpers.messages import MessageHelper 32 | 33 | router: Final[Router] = Router(name=__name__) 34 | 35 | 36 | async def _handle_ton_connection( 37 | message: Message, 38 | i18n: I18nContext, 39 | user: UserDto, 40 | user_service: UserService, 41 | ton_connect: TcAdapter, 42 | backend: Backend, 43 | assets: Assets, 44 | ) -> Any: 45 | try: 46 | result: TonConnectResult = await wait_until_connected( 47 | ton_connect=ton_connect, 48 | backend=backend, 49 | timeout=assets.ton_connect.timeout, 50 | ) 51 | except (TimeoutError, InvalidTonProofError): 52 | with silent_bot_request(): 53 | await message.delete() 54 | return await message.answer( 55 | text=i18n.messages.connection_timeout(), 56 | reply_markup=to_menu_keyboard(i18n=i18n), 57 | ) 58 | 59 | await authorize_user( 60 | user=user, 61 | result=result, 62 | ton_connect=ton_connect, 63 | user_service=user_service, 64 | backend=backend, 65 | ) 66 | 67 | with silent_bot_request(): 68 | await message.delete() 69 | await message.answer( 70 | text=i18n.messages.wallet_connected( 71 | address=html.link( 72 | value=short_address(address=result.address), 73 | link=f"https://tonviewer.com/{result.address}", 74 | ), 75 | ), 76 | reply_markup=to_menu_keyboard(i18n=i18n), 77 | ) 78 | 79 | 80 | @router.callback_query(CDChooseWallet.filter()) 81 | async def choose_wallet( 82 | query: CallbackQuery, 83 | helper: MessageHelper, 84 | callback_data: CDChooseWallet, 85 | i18n: I18nContext, 86 | state: FSMContext, 87 | user: UserDto, 88 | user_service: UserService, 89 | ton_connect: TcAdapter, 90 | backend: Backend, 91 | assets: Assets, 92 | task_manager: TaskManager, 93 | ) -> Any: 94 | try: 95 | connection: TonConnection = await generate_ton_connection( 96 | wallet_name=callback_data.wallet_name, 97 | backend=backend, 98 | tc_adapter=ton_connect, 99 | ) 100 | except WalletAlreadyConnectedError: 101 | return await show_main_menu(_=query, helper=helper, i18n=i18n, state=state, user=user) 102 | with silent_bot_request(): 103 | await query.message.delete() 104 | message: Message = await query.message.answer_photo( 105 | photo=BufferedInputFile( 106 | file=await create_qr_code(url=connection.url), 107 | filename=f"{connection.id}.png", 108 | ), 109 | caption=i18n.messages.ton_connect(), 110 | reply_markup=ton_connect_keyboard(i18n=i18n, connection=connection), 111 | ) 112 | task_manager.run_task( 113 | task_name=connection.id, 114 | coro=_handle_ton_connection( 115 | message=message, 116 | i18n=i18n, 117 | user=user, 118 | user_service=user_service, 119 | ton_connect=ton_connect, 120 | backend=backend, 121 | assets=assets, 122 | ), 123 | ) 124 | 125 | 126 | @router.callback_query(CDCancelConnection.filter()) 127 | async def cancel_connection( 128 | query: CallbackQuery, 129 | callback_data: CDCancelConnection, 130 | i18n: I18nContext, 131 | task_manager: TaskManager, 132 | ) -> Any: 133 | await task_manager.cancel_task(task_name=callback_data.task_id) 134 | with silent_bot_request(): 135 | await query.message.delete() 136 | return await query.message.answer( 137 | text=i18n.messages.connection_cancelled(), 138 | reply_markup=to_menu_keyboard(i18n=i18n), 139 | ) 140 | -------------------------------------------------------------------------------- /app/telegram/handlers/menu/shop/premium.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any, Final 4 | 5 | from aiogram import F, Router, flags 6 | from aiogram.fsm.context import FSMContext 7 | from aiogram.types import CallbackQuery, Message, TelegramObject 8 | from aiogram_i18n import I18nContext 9 | from pytonconnect.exceptions import UserRejectsError 10 | 11 | from app.controllers.price import PriceDto, get_premium_price 12 | from app.telegram.filters import MagicData 13 | from app.telegram.filters.states import SGBuyPremium 14 | from app.telegram.keyboards.callback_data.menu import CDTelegramPremium 15 | from app.telegram.keyboards.callback_data.purchase import ( 16 | CDConfirmPurchase, 17 | CDSelectSubscriptionPeriod, 18 | CDSelectUsername, 19 | ) 20 | from app.telegram.keyboards.menu import to_menu_keyboard 21 | from app.telegram.keyboards.purchase import ( 22 | confirm_purchase_keyboard, 23 | enter_username_keyboard, 24 | subscription_period_keyboard, 25 | ) 26 | from app.telegram.middlewares import TonConnectCheckerMiddleware 27 | 28 | if TYPE_CHECKING: 29 | from app.models.config import Assets 30 | from app.services.backend import Backend 31 | from app.services.backend.types import Recipient, Transaction 32 | from app.services.ton_connect import TcAdapter 33 | from app.telegram.helpers.messages import MessageHelper 34 | 35 | router: Final[Router] = Router(name=__name__) 36 | TonConnectCheckerMiddleware().setup_inner(router) 37 | 38 | 39 | @router.callback_query(CDTelegramPremium.filter()) 40 | async def proceed_premium_purchase( 41 | query: CallbackQuery, 42 | helper: MessageHelper, 43 | i18n: I18nContext, 44 | state: FSMContext, 45 | ) -> Any: 46 | await state.set_state(SGBuyPremium.enter_username) 47 | message: Message = await helper.answer( # type: ignore[assignment] 48 | text=i18n.messages.purchase.enter_username(), 49 | reply_markup=enter_username_keyboard(i18n=i18n, username=query.from_user.username), 50 | ) 51 | await state.set_data({"message_id": message.message_id}) 52 | 53 | 54 | @router.message(SGBuyPremium.enter_username, F.text.as_("username")) 55 | @router.callback_query( 56 | SGBuyPremium.enter_username, 57 | CDSelectUsername.filter(), 58 | MagicData(F.callback_data.username.as_("username")), 59 | ) 60 | async def save_premium_recipient( 61 | _: TelegramObject, 62 | helper: MessageHelper, 63 | username: str, 64 | i18n: I18nContext, 65 | backend: Backend, 66 | assets: Assets, 67 | ) -> Any: 68 | recipient: Recipient = await backend.resolve_premium_recipient(username=username) 69 | await helper.next_step( 70 | state=SGBuyPremium.select_period, 71 | text=i18n.messages.purchase.select_period(), 72 | reply_markup=subscription_period_keyboard(i18n=i18n, assets=assets), 73 | update={"username": username, "recipient": recipient.recipient}, 74 | ) 75 | 76 | 77 | @router.callback_query( 78 | SGBuyPremium.select_period, 79 | CDSelectSubscriptionPeriod.filter(), 80 | MagicData(F.callback_data.period.as_("period")), 81 | ) 82 | @flags.callback_query(disabled=True) 83 | async def save_subscription_period( 84 | query: CallbackQuery, 85 | helper: MessageHelper, 86 | state: FSMContext, 87 | period: int, 88 | i18n: I18nContext, 89 | backend: Backend, 90 | assets: Assets, 91 | ) -> Any: 92 | if period not in assets.shop.subscription_periods: 93 | await query.answer(text=i18n.messages.purchase.subscription_not_available()) 94 | return await helper.answer( 95 | text=i18n.messages.purchase.select_period(), 96 | reply_markup=subscription_period_keyboard(i18n=i18n, assets=assets), 97 | ) 98 | 99 | price: PriceDto = await get_premium_price(months=period, backend=backend, assets=assets) 100 | await query.answer() 101 | data: dict[str, Any] = await state.get_data() 102 | await helper.next_step( 103 | state=SGBuyPremium.confirm, 104 | text=i18n.messages.purchase.confirm( 105 | username=data["username"].removeprefix("@"), 106 | product=i18n.messages.purchase.premium(period=period), 107 | usd_price=price.usd_price, 108 | ton_price=price.ton_price, 109 | ), 110 | reply_markup=confirm_purchase_keyboard(i18n=i18n), 111 | update={"period": period}, 112 | ) 113 | 114 | 115 | @router.callback_query(SGBuyPremium.confirm, CDConfirmPurchase.filter()) 116 | async def buy_premium( 117 | _: CallbackQuery, 118 | helper: MessageHelper, 119 | i18n: I18nContext, 120 | state: FSMContext, 121 | ton_connect: TcAdapter, 122 | backend: Backend, 123 | ) -> Any: 124 | data: dict[str, Any] = await state.get_data() 125 | transaction: Transaction = await backend.buy_premium( 126 | recipient=data["recipient"], 127 | months=data["period"], 128 | username=data["username"], 129 | ) 130 | await state.clear() 131 | await helper.answer( 132 | text=i18n.messages.confirm_transaction(), 133 | reply_markup=to_menu_keyboard(i18n=i18n), 134 | ) 135 | try: 136 | await ton_connect.send_transaction(transaction) 137 | except UserRejectsError: 138 | await helper.answer( 139 | text=i18n.messages.transaction_canceled(), 140 | reply_markup=to_menu_keyboard(i18n=i18n), 141 | ) 142 | --------------------------------------------------------------------------------