From d66041fa318812bbf656d8f1b6e15790801b829f Mon Sep 17 00:00:00 2001 From: hexdev Date: Sat, 4 Apr 2026 21:30:35 +0700 Subject: [PATCH] first commit --- .env.example | 2 ++ .gitignore | 44 +++++++++++++++++++++++ app/__init__.py | 1 + app/bot/__init__.py | 1 + app/bot/handlers/__init__.py | 11 ++++++ app/bot/handlers/referral.py | 13 +++++++ app/bot/handlers/start.py | 37 ++++++++++++++++++++ app/bot/middlewares/__init__.py | 4 +++ app/bot/middlewares/database.py | 28 +++++++++++++++ app/bot/middlewares/services.py | 25 +++++++++++++ app/config.py | 12 +++++++ app/db/__init__.py | 4 +++ app/db/base.py | 5 +++ app/db/session.py | 23 ++++++++++++ app/models/__init__.py | 3 ++ app/models/user.py | 35 +++++++++++++++++++ app/repositories/__init__.py | 4 +++ app/repositories/protocols.py | 30 ++++++++++++++++ app/repositories/user.py | 60 ++++++++++++++++++++++++++++++++ app/services/__init__.py | 4 +++ app/services/referral_service.py | 57 ++++++++++++++++++++++++++++++ app/services/user_service.py | 46 ++++++++++++++++++++++++ main.py | 49 ++++++++++++++++++++++++++ requirements.txt | 5 +++ 24 files changed, 503 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 app/__init__.py create mode 100644 app/bot/__init__.py create mode 100644 app/bot/handlers/__init__.py create mode 100644 app/bot/handlers/referral.py create mode 100644 app/bot/handlers/start.py create mode 100644 app/bot/middlewares/__init__.py create mode 100644 app/bot/middlewares/database.py create mode 100644 app/bot/middlewares/services.py create mode 100644 app/config.py create mode 100644 app/db/__init__.py create mode 100644 app/db/base.py create mode 100644 app/db/session.py create mode 100644 app/models/__init__.py create mode 100644 app/models/user.py create mode 100644 app/repositories/__init__.py create mode 100644 app/repositories/protocols.py create mode 100644 app/repositories/user.py create mode 100644 app/services/__init__.py create mode 100644 app/services/referral_service.py create mode 100644 app/services/user_service.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59dc2dc --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +BOT_TOKEN=your_telegram_bot_token +DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/maleniavpn diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ae7e6b0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Bytecode +__pycache__/ +*.py[cod] +*$py.class +*.so + +# Virtual environments +.venv/ +venv/ +env/ +.env/ + +# Environment and secrets +.env +.env.local +*.pem + +# Packaging / build +build/ +dist/ +*.egg-info/ +.eggs/ +pip-wheel-metadata/ + +# Test / type / lint caches +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +htmlcov/ +.tox/ +.nox/ + +# IDE / editor +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..3bc4ff2 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""MaleniaVPN Telegram bot application package.""" diff --git a/app/bot/__init__.py b/app/bot/__init__.py new file mode 100644 index 0000000..db539e4 --- /dev/null +++ b/app/bot/__init__.py @@ -0,0 +1 @@ +"""Aiogram bot wiring (routers, middlewares).""" diff --git a/app/bot/handlers/__init__.py b/app/bot/handlers/__init__.py new file mode 100644 index 0000000..1188295 --- /dev/null +++ b/app/bot/handlers/__init__.py @@ -0,0 +1,11 @@ +from aiogram import Router + +from app.bot.handlers import referral as referral_handlers +from app.bot.handlers import start as start_handlers + + +def build_root_router() -> Router: + root = Router() + root.include_router(start_handlers.router) + root.include_router(referral_handlers.router) + return root diff --git a/app/bot/handlers/referral.py b/app/bot/handlers/referral.py new file mode 100644 index 0000000..800415b --- /dev/null +++ b/app/bot/handlers/referral.py @@ -0,0 +1,13 @@ +""" +Referral UX (invite links, balances, leaderboards) will register here. + +Deep-link attribution on /start is handled by ReferralService + UserService.ensure_user. +""" + +from aiogram import Router + +router = Router(name="referral") + +# Example (future): +# @router.message(Command("refer")) +# async def cmd_refer(...): ... diff --git a/app/bot/handlers/start.py b/app/bot/handlers/start.py new file mode 100644 index 0000000..bed073c --- /dev/null +++ b/app/bot/handlers/start.py @@ -0,0 +1,37 @@ +from aiogram import Router +from aiogram.filters import CommandObject, CommandStart +from aiogram.types import Message + +from app.services.user_service import UserService + +router = Router(name="start") + + +@router.message(CommandStart()) +async def cmd_start( + message: Message, + command: CommandObject, + user_service: UserService, +) -> None: + if message.from_user is None: + return + + await user_service.ensure_user( + telegram_id=message.from_user.id, + username=message.from_user.username, + first_name=message.from_user.first_name, + start_payload=command.args, + ) + + lines = [ + 'Welcome to MaleniaVPNmalenia.space', + "", + "Your account is linked. VPN controls and billing will appear here as we ship them.", + ] + if command.args: + lines.append("") + lines.append( + "Referral link detected; attribution is stored for when rewards go live." + ) + + await message.answer("\n".join(lines), parse_mode="HTML") diff --git a/app/bot/middlewares/__init__.py b/app/bot/middlewares/__init__.py new file mode 100644 index 0000000..9e909ee --- /dev/null +++ b/app/bot/middlewares/__init__.py @@ -0,0 +1,4 @@ +from app.bot.middlewares.database import DbSessionMiddleware +from app.bot.middlewares.services import ServicesMiddleware + +__all__ = ("DbSessionMiddleware", "ServicesMiddleware") diff --git a/app/bot/middlewares/database.py b/app/bot/middlewares/database.py new file mode 100644 index 0000000..2111100 --- /dev/null +++ b/app/bot/middlewares/database.py @@ -0,0 +1,28 @@ +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + + +class DbSessionMiddleware(BaseMiddleware): + """Opens one SQLAlchemy async session per update; commits on success.""" + + def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: + self._session_factory = session_factory + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + async with self._session_factory() as session: + data["session"] = session + try: + result = await handler(event, data) + await session.commit() + return result + except Exception: + await session.rollback() + raise diff --git a/app/bot/middlewares/services.py b/app/bot/middlewares/services.py new file mode 100644 index 0000000..176c9cb --- /dev/null +++ b/app/bot/middlewares/services.py @@ -0,0 +1,25 @@ +from typing import Any, Awaitable, Callable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject +from sqlalchemy.ext.asyncio import AsyncSession + +from app.repositories.user import UserRepository +from app.services.referral_service import ReferralService +from app.services.user_service import UserService + + +class ServicesMiddleware(BaseMiddleware): + """Builds application services from the request-scoped session (dependency injection).""" + + async def __call__( + self, + handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: dict[str, Any], + ) -> Any: + session: AsyncSession = data["session"] + users = UserRepository(session) + referrals = ReferralService(users) + data["user_service"] = UserService(users, referrals) + return await handler(event, data) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..22d8166 --- /dev/null +++ b/app/config.py @@ -0,0 +1,12 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + bot_token: str + database_url: str diff --git a/app/db/__init__.py b/app/db/__init__.py new file mode 100644 index 0000000..e741efd --- /dev/null +++ b/app/db/__init__.py @@ -0,0 +1,4 @@ +from app.db.base import Base +from app.db.session import get_session_factory, init_engine + +__all__ = ("Base", "get_session_factory", "init_engine") diff --git a/app/db/base.py b/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/app/db/session.py b/app/db/session.py new file mode 100644 index 0000000..1b50eb6 --- /dev/null +++ b/app/db/session.py @@ -0,0 +1,23 @@ +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) + + +def init_engine(database_url: str) -> AsyncEngine: + return create_async_engine( + database_url, + echo=False, + pool_pre_ping=True, + ) + + +def get_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: + return async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autoflush=False, + ) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..5c8c6e9 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.user import User + +__all__ = ("User",) diff --git a/app/models/user.py b/app/models/user.py new file mode 100644 index 0000000..e475dd1 --- /dev/null +++ b/app/models/user.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + telegram_id: Mapped[int] = mapped_column(BigInteger, unique=True, index=True) + username: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + first_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + server_default=func.now(), + ) + referrer_id: Mapped[Optional[int]] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + + referrer: Mapped[Optional[User]] = relationship( + back_populates="referrals", + remote_side="User.id", + ) + referrals: Mapped[list[User]] = relationship( + back_populates="referrer", + ) diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py new file mode 100644 index 0000000..0bc3b6d --- /dev/null +++ b/app/repositories/__init__.py @@ -0,0 +1,4 @@ +from app.repositories.protocols import IUserRepository +from app.repositories.user import UserRepository + +__all__ = ("IUserRepository", "UserRepository") diff --git a/app/repositories/protocols.py b/app/repositories/protocols.py new file mode 100644 index 0000000..0d0bc5d --- /dev/null +++ b/app/repositories/protocols.py @@ -0,0 +1,30 @@ +from typing import Protocol + +from app.models.user import User + + +class IUserRepository(Protocol): + async def get_by_telegram_id(self, telegram_id: int) -> User | None: ... + + async def create( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + referrer_id: int | None, + ) -> User: ... + + async def update_profile( + self, + user: User, + *, + username: str | None, + first_name: str | None, + ) -> User: ... + + async def set_referrer_if_unset( + self, + user: User, + referrer_internal_id: int, + ) -> None: ... diff --git a/app/repositories/user.py b/app/repositories/user.py new file mode 100644 index 0000000..ab708f3 --- /dev/null +++ b/app/repositories/user.py @@ -0,0 +1,60 @@ +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.user import User +from app.repositories.protocols import IUserRepository + + +class UserRepository(IUserRepository): + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def get_by_telegram_id(self, telegram_id: int) -> User | None: + result = await self._session.execute( + select(User).where(User.telegram_id == telegram_id).limit(1) + ) + return result.scalar_one_or_none() + + async def create( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + referrer_id: int | None, + ) -> User: + user = User( + telegram_id=telegram_id, + username=username, + first_name=first_name, + referrer_id=referrer_id, + ) + self._session.add(user) + await self._session.flush() + await self._session.refresh(user) + return user + + async def update_profile( + self, + user: User, + *, + username: str | None, + first_name: str | None, + ) -> User: + user.username = username + user.first_name = first_name + await self._session.flush() + await self._session.refresh(user) + return user + + async def set_referrer_if_unset( + self, + user: User, + referrer_internal_id: int, + ) -> None: + if user.referrer_id is not None: + return + if user.id == referrer_internal_id: + return + user.referrer_id = referrer_internal_id + await self._session.flush() diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..c7b67b8 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1,4 @@ +from app.services.referral_service import ReferralService +from app.services.user_service import UserService + +__all__ = ("ReferralService", "UserService") diff --git a/app/services/referral_service.py b/app/services/referral_service.py new file mode 100644 index 0000000..35e2f77 --- /dev/null +++ b/app/services/referral_service.py @@ -0,0 +1,57 @@ +from typing import Protocol + +from app.repositories.protocols import IUserRepository + + +class IReferralPayloadParser(Protocol): + """Parse Telegram deep-link payload (e.g. `ref_ABC123`) into internal referrer key.""" + + def parse(self, raw: str | None) -> str | None: ... + + +class DefaultReferralPayloadParser: + """Placeholder: expects `ref_`; extend with signed tokens or DB lookup later.""" + + PREFIX = "ref_" + + def parse(self, raw: str | None) -> str | None: + if not raw: + return None + if raw.startswith(self.PREFIX): + return raw[len(self.PREFIX) :].strip() or None + return None + + +class ReferralService: + """ + Resolves referral payloads to referrer user rows. + Placeholder: code is treated as referrer's telegram_id string for demo wiring; + replace with dedicated referral codes table or signed deep links in production. + """ + + def __init__( + self, + users: IUserRepository, + parser: IReferralPayloadParser | None = None, + ) -> None: + self._users = users + self._parser = parser or DefaultReferralPayloadParser() + + async def resolve_referrer_id( + self, + start_payload: str | None, + *, + invitee_telegram_id: int, + ) -> int | None: + code = self._parser.parse(start_payload) + if not code: + return None + # Placeholder: interpret code as referrer's telegram_id (digits only). + if not code.isdigit(): + return None + referrer = await self._users.get_by_telegram_id(int(code)) + if referrer is None: + return None + if referrer.telegram_id == invitee_telegram_id: + return None + return referrer.id diff --git a/app/services/user_service.py b/app/services/user_service.py new file mode 100644 index 0000000..884f69d --- /dev/null +++ b/app/services/user_service.py @@ -0,0 +1,46 @@ +from app.models.user import User +from app.repositories.protocols import IUserRepository +from app.services.referral_service import ReferralService + + +class UserService: + def __init__( + self, + users: IUserRepository, + referrals: ReferralService, + ) -> None: + self._users = users + self._referrals = referrals + + async def ensure_user( + self, + *, + telegram_id: int, + username: str | None, + first_name: str | None, + start_payload: str | None, + ) -> User: + referrer_internal_id = await self._referrals.resolve_referrer_id( + start_payload, + invitee_telegram_id=telegram_id, + ) + existing = await self._users.get_by_telegram_id(telegram_id) + if existing is not None: + await self._users.update_profile( + existing, + username=username, + first_name=first_name, + ) + if referrer_internal_id is not None: + await self._users.set_referrer_if_unset( + existing, + referrer_internal_id, + ) + return existing + + return await self._users.create( + telegram_id=telegram_id, + username=username, + first_name=first_name, + referrer_id=referrer_internal_id, + ) diff --git a/main.py b/main.py new file mode 100644 index 0000000..c6ffbbd --- /dev/null +++ b/main.py @@ -0,0 +1,49 @@ +import asyncio +import logging + +from aiogram import Bot, Dispatcher +from aiogram.enums import ParseMode +from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker + +from app.bot.handlers import build_root_router +from app.bot.middlewares import DbSessionMiddleware, ServicesMiddleware +from app.config import Settings +from app.db.base import Base +from app.db.session import get_session_factory, init_engine + + +async def create_schema(engine: AsyncEngine) -> None: + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + +def build_dispatcher(session_factory: async_sessionmaker) -> Dispatcher: + dp = Dispatcher() + dp.update.middleware(DbSessionMiddleware(session_factory)) + dp.update.middleware(ServicesMiddleware()) + dp.include_router(build_root_router()) + return dp + + +async def main() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + settings = Settings() + engine = init_engine(settings.database_url) + await create_schema(engine) + session_factory = get_session_factory(engine) + bot = Bot( + settings.bot_token, + default_parse_mode=ParseMode.HTML, + ) + dp = build_dispatcher(session_factory) + try: + await dp.start_polling(bot) + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c0f03e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +aiogram==3.27.0 +asyncpg>=0.31.0 +sqlalchemy[asyncio]>=2.0.40 +pydantic-settings==2.7.0 +python-dotenv==1.0.1