first commit
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""MaleniaVPN Telegram bot application package."""
|
||||
1
app/bot/__init__.py
Normal file
1
app/bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Aiogram bot wiring (routers, middlewares)."""
|
||||
11
app/bot/handlers/__init__.py
Normal file
11
app/bot/handlers/__init__.py
Normal 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
|
||||
13
app/bot/handlers/referral.py
Normal file
13
app/bot/handlers/referral.py
Normal 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
37
app/bot/handlers/start.py
Normal 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")
|
||||
4
app/bot/middlewares/__init__.py
Normal file
4
app/bot/middlewares/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.bot.middlewares.database import DbSessionMiddleware
|
||||
from app.bot.middlewares.services import ServicesMiddleware
|
||||
|
||||
__all__ = ("DbSessionMiddleware", "ServicesMiddleware")
|
||||
28
app/bot/middlewares/database.py
Normal file
28
app/bot/middlewares/database.py
Normal 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
|
||||
25
app/bot/middlewares/services.py
Normal file
25
app/bot/middlewares/services.py
Normal 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
12
app/config.py
Normal 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
4
app/db/__init__.py
Normal 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
5
app/db/base.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
23
app/db/session.py
Normal file
23
app/db/session.py
Normal 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
3
app/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.models.user import User
|
||||
|
||||
__all__ = ("User",)
|
||||
35
app/models/user.py
Normal file
35
app/models/user.py
Normal 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",
|
||||
)
|
||||
4
app/repositories/__init__.py
Normal file
4
app/repositories/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.repositories.protocols import IUserRepository
|
||||
from app.repositories.user import UserRepository
|
||||
|
||||
__all__ = ("IUserRepository", "UserRepository")
|
||||
30
app/repositories/protocols.py
Normal file
30
app/repositories/protocols.py
Normal 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
60
app/repositories/user.py
Normal 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
4
app/services/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from app.services.referral_service import ReferralService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
__all__ = ("ReferralService", "UserService")
|
||||
57
app/services/referral_service.py
Normal file
57
app/services/referral_service.py
Normal 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
|
||||
46
app/services/user_service.py
Normal file
46
app/services/user_service.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user