first commit

This commit is contained in:
2026-04-04 21:30:35 +07:00
commit d66041fa31
24 changed files with 503 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
BOT_TOKEN=your_telegram_bot_token
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/maleniavpn

44
.gitignore vendored Normal file
View File

@@ -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

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""MaleniaVPN Telegram bot application package."""

1
app/bot/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Aiogram bot wiring (routers, middlewares)."""

View File

@@ -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

View File

@@ -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(...): ...

37
app/bot/handlers/start.py Normal file
View File

@@ -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 <b>MaleniaVPN</b> — <a href="https://malenia.space">malenia.space</a>',
"",
"Your account is linked. VPN controls and billing will appear here as we ship them.",
]
if command.args:
lines.append("")
lines.append(
"<i>Referral link detected; attribution is stored for when rewards go live.</i>"
)
await message.answer("\n".join(lines), parse_mode="HTML")

View File

@@ -0,0 +1,4 @@
from app.bot.middlewares.database import DbSessionMiddleware
from app.bot.middlewares.services import ServicesMiddleware
__all__ = ("DbSessionMiddleware", "ServicesMiddleware")

View File

@@ -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

View File

@@ -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)

12
app/config.py Normal file
View File

@@ -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

4
app/db/__init__.py Normal file
View File

@@ -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")

5
app/db/base.py Normal file
View File

@@ -0,0 +1,5 @@
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass

23
app/db/session.py Normal file
View File

@@ -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,
)

3
app/models/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from app.models.user import User
__all__ = ("User",)

35
app/models/user.py Normal file
View File

@@ -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",
)

View File

@@ -0,0 +1,4 @@
from app.repositories.protocols import IUserRepository
from app.repositories.user import UserRepository
__all__ = ("IUserRepository", "UserRepository")

View File

@@ -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: ...

60
app/repositories/user.py Normal file
View File

@@ -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()

4
app/services/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from app.services.referral_service import ReferralService
from app.services.user_service import UserService
__all__ = ("ReferralService", "UserService")

View File

@@ -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_<code>`; 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

View File

@@ -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,
)

49
main.py Normal file
View File

@@ -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())

5
requirements.txt Normal file
View File

@@ -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