first commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal 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
44
.gitignore
vendored
Normal 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
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,
|
||||||
|
)
|
||||||
49
main.py
Normal file
49
main.py
Normal 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
5
requirements.txt
Normal 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
|
||||||
Reference in New Issue
Block a user