Single part at the root, COMPOSE_FILE in .env

This commit is contained in:
hh
2026-09-08 20:38:54 +02:00
parent 321e4b6b35
commit aac037676c
86 changed files with 68 additions and 27 deletions
@@ -0,0 +1,5 @@
{% if dynamic_config %}from .config import config
{% endif %}from .env import env
from .logging import logger
__all__ = [{% if dynamic_config %}"config", {% endif %}"env", "logger"]
@@ -0,0 +1,117 @@
{% if database != 'none' or use_redis %}import os
{% endif %}from pydantic import Field{% if database != 'none' or 'aiogram_bot' in backend_services or include_payments or use_llm or use_redis %}, SecretStr{% endif %}
from pydantic_settings import BaseSettings, SettingsConfigDict
{% if database != 'none' or use_redis %}
def is_prod() -> bool:
return os.getenv("RUN_ENVIRONMENT") == "prod"
{% endif %}
class LogSettings(BaseSettings):
level: str = "INFO"
level_external: str = "WARNING"
show_time: bool = False
console_width: int = 150
{% if database == 'mongo' %}
class DatabaseSettings(BaseSettings):
host: str = "mongodb"
port: int = 27017
user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}"
connection_params: str = "?authSource=admin"
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
password = self.password.get_secret_value()
return f"mongodb://{self.user}:{password}@{host}:{self.port}/{self.db_name}{self.connection_params}"
{% elif database == 'postgres' %}
class DatabaseSettings(BaseSettings):
host: str = "postgres"
port: int = 5432
user: str = "{{ project_slug }}"
password: SecretStr = SecretStr("{{ project_slug }}")
db_name: str = "{{ project_slug }}"
min_pool_size: int = 5
max_pool_size: int = 20
@property
def connection_url(self) -> str:
host = self.host if is_prod() else "localhost"
password = self.password.get_secret_value()
return f"postgresql://{self.user}:{password}@{host}:{self.port}/{self.db_name}"
@property
def async_connection_url(self) -> str:
return self.connection_url.replace("postgresql://", "postgresql+asyncpg://", 1)
{% endif %}{% if use_redis %}
class RedisSettings(BaseSettings):
host: str = "redis"
port: int = 6379
db: int = 0
password: SecretStr | None = None
cache_ttl: int = 300
@property
def url(self) -> str:
host = self.host if is_prod() else "localhost"
auth = f":{self.password.get_secret_value()}@" if self.password else ""
return f"redis://{auth}{host}:{self.port}/{self.db}"
{% endif %}{% if 'api' in backend_services %}
class ApiSettings(BaseSettings):
host: str = "0.0.0.0" # noqa: S104
port: int = 8080
workers: int = 1
{%- if serve_spa %}
static_dir: str = "static"
{%- endif %}
{% endif %}{% if 'aiogram_bot' in backend_services %}
class BotSettings(BaseSettings):
token: SecretStr = SecretStr("")
admins: list[int] = Field(default_factory=list)
{% endif %}{% if include_payments %}
class CryptoPaySettings(BaseSettings):
token: SecretStr = SecretStr("")
{% endif %}{% if use_llm %}
class LlmSettings(BaseSettings):
model: str = "google-gla:gemini-2.5-flash"
gemini_api_key: SecretStr = SecretStr("")
{% endif %}
class Settings(BaseSettings):
log: LogSettings = Field(default_factory=LogSettings)
{%- if database != 'none' %}
db: DatabaseSettings = Field(default_factory=DatabaseSettings)
{%- endif %}
{%- if use_redis %}
redis: RedisSettings = Field(default_factory=RedisSettings)
{%- endif %}
{%- if 'api' in backend_services %}
api: ApiSettings = Field(default_factory=ApiSettings)
{%- endif %}
{%- if 'aiogram_bot' in backend_services %}
bot: BotSettings = Field(default_factory=BotSettings)
{%- endif %}
{%- if include_payments %}
crypto_pay: CryptoPaySettings = Field(default_factory=CryptoPaySettings)
{%- endif %}
{%- if use_llm %}
llm: LlmSettings = Field(default_factory=LlmSettings)
{%- endif %}
model_config = SettingsConfigDict(
case_sensitive=False, env_file=".env", env_nested_delimiter="__", extra="ignore"
)
env = Settings()
@@ -0,0 +1,38 @@
import logging
from rich.console import Console
from rich.logging import RichHandler
from rich.traceback import install
from .env import env
console = Console(width=env.log.console_width, color_system="auto", force_terminal=True)
def setup_logging() -> None:
{% if 'aiogram_bot' in backend_services %} from aiogram.dispatcher import router # noqa: PLC0415
{% endif %} logging.basicConfig(
level=env.log.level_external,
format="",
datefmt=None,
handlers=[
RichHandler(
console=console,
markup=True,
rich_tracebacks=True,
enable_link_path=False,
tracebacks_show_locals=True,
omit_repeated_times=False,
show_time=env.log.show_time,
{%- if 'aiogram_bot' in backend_services %}
tracebacks_suppress=[router],
{%- endif %}
)
],
)
install(console=console, show_locals=True)
logger = logging.getLogger("{{ project_slug }}")
logger.setLevel(env.log.level)
@@ -0,0 +1,19 @@
{% if database == 'postgres' -%}
async def init_db() -> None:
from . import models # noqa: F401, PLC0415
{%- else -%}
from beanie import init_beanie
from pymongo import AsyncMongoClient
from utils.env import env
client = AsyncMongoClient(env.db.connection_url)
async def init_db() -> None:
from .models import {% if dynamic_config %}DynamicConfig, {% endif %}User # noqa: PLC0415
{% if dynamic_config %} await init_beanie(
database=client[env.db.db_name], document_models=[DynamicConfig, User]
){% else %} await init_beanie(database=client[env.db.db_name], document_models=[User]){% endif %}
{%- endif %}
@@ -0,0 +1,4 @@
{% if dynamic_config %}from .config import BotConfig, DynamicConfig, DynamicConfigBase
{% endif %}from .user import User
__all__ = [{% if dynamic_config %}"BotConfig", "DynamicConfig", "DynamicConfigBase", {% endif %}"User"]
@@ -0,0 +1,54 @@
{% if database == 'postgres' -%}
from datetime import datetime
from sqlalchemy import BigInteger, Column
from sqlmodel import Field as SQLField
from sqlmodel import SQLModel
from .base import created_at_col
class User(SQLModel, table=True):
__tablename__ = "user_account"
id: int | None = SQLField(default=None, primary_key=True)
tg_id: int = SQLField(sa_column=Column(BigInteger, unique=True, index=True))
username: str | None = None
first_name: str | None = None
last_name: str | None = None
created_at: datetime | None = created_at_col()
{%- else -%}
from beanie import Document
class User(Document):
id: int
balance: float = 0
class Settings:
name = "users"
@classmethod
async def get_by_id(cls, id_: int) -> "User | None":
return await cls.find_one(cls.id == id_)
@classmethod
async def get_or_create(cls, id_: int) -> "User":
user = await cls.get_by_id(id_)
if user is None:
user = cls(id=id_)
await user.insert()
return user
async def add_balance(self, amount: float) -> None:
self.balance += amount
await self.save()
async def subtract_balance(self, amount: float) -> bool:
if self.balance >= amount:
self.balance -= amount
await self.save()
return True
return False
{%- endif %}
@@ -0,0 +1,60 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, func, text
from sqlalchemy.dialects import postgresql
from sqlmodel import Field as SQLField
def uuid_pk() -> uuid.UUID:
return SQLField(
default_factory=uuid.uuid4,
sa_column=Column(postgresql.UUID(as_uuid=True), primary_key=True),
)
def uuid_fk(target: str, *, nullable: bool = True) -> Column:
return Column(
postgresql.UUID(as_uuid=True),
ForeignKey(target, ondelete="SET NULL"),
nullable=nullable,
)
def bigint_col(*, nullable: bool = True) -> Column:
return Column(BigInteger, nullable=nullable)
def nullable_ts_col() -> datetime | None:
return SQLField(
default=None, sa_column=Column(DateTime(timezone=True), nullable=True)
)
def created_at_col() -> datetime:
return SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True), nullable=False, server_default=func.now()
),
)
def updated_at_col() -> datetime:
return SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
),
)
def jsonb_column(*, nullable: bool = False, default: str = "[]") -> Column:
return Column(
postgresql.JSONB,
nullable=nullable,
server_default=None if nullable else text(f"'{default}'::jsonb"),
)
@@ -0,0 +1,58 @@
{% if database == 'postgres' -%}
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects import postgresql
from sqlmodel import Field as SQLField
from sqlmodel import SQLModel
class BotConfig(BaseModel):
admins: list[int] = Field(default_factory=list)
class DynamicConfigBase(BaseModel):
bot: BotConfig = Field(default_factory=BotConfig)
class DynamicConfig(SQLModel, table=True):
__tablename__ = "config"
id: int = SQLField(default=1, primary_key=True)
data: dict[str, Any] = SQLField(sa_column=Column(postgresql.JSONB, nullable=False))
updated_at: datetime | None = SQLField(
default=None,
sa_column=Column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
),
)
{%- else -%}
from beanie import Document
from pydantic import BaseModel, Field
class BotConfig(BaseModel):
admins: list[int] = Field(default_factory=list)
class DynamicConfigBase(BaseModel):
bot: BotConfig = Field(default_factory=BotConfig)
class DynamicConfig(DynamicConfigBase, Document):
class Settings:
name = "config"
@classmethod
async def get_or_create(cls) -> "DynamicConfig":
config = await cls.find_one()
if config is None:
config = cls()
await config.save()
return config
{%- endif %}
@@ -0,0 +1,5 @@
{% if use_redis %}from .cache import RedisCache
{% endif %}{% if dynamic_config %}from .config import ConfigRepository
{% endif %}from .user import UserRepository
__all__ = [{% if dynamic_config %}"ConfigRepository", {% endif %}{% if use_redis %}"RedisCache", {% endif %}"UserRepository"]
@@ -0,0 +1,35 @@
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models import User
class UserRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get(self, user_id: int) -> User | None:
return await self._session.get(User, user_id)
async def by_tg_id(self, tg_id: int) -> User | None:
stmt = select(User).where(User.tg_id == tg_id)
return (await self._session.exec(stmt)).first()
async def upsert(
self,
tg_id: int,
*,
username: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> User:
user = await self.by_tg_id(tg_id)
if user is None:
user = User(tg_id=tg_id)
user.username = username
user.first_name = first_name
user.last_name = last_name
self._session.add(user)
await self._session.commit()
await self._session.refresh(user)
return user
@@ -0,0 +1,88 @@
{% if use_redis -%}
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models.config import DynamicConfig, DynamicConfigBase
from .cache import RedisCache
CONFIG_CACHE_KEY = "config:dynamic"
CONFIG_ROW_ID = 1
class ConfigRepository:
def __init__(
self, sessionmaker: async_sessionmaker[AsyncSession], cache: RedisCache
) -> None:
self._sessionmaker = sessionmaker
self._cache = cache
async def get(self) -> DynamicConfigBase:
return await self._cache.get_or_set_model(
CONFIG_CACHE_KEY, DynamicConfigBase, self._load
)
async def _load(self) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
config = DynamicConfigBase()
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
await session.commit()
return config
return DynamicConfigBase.model_validate(row.data)
async def save(self, config: DynamicConfigBase) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
else:
row.data = config.model_dump()
session.add(row)
await session.commit()
await self._cache.set_model(CONFIG_CACHE_KEY, config)
return config
async def reset(self) -> DynamicConfigBase:
return await self.save(DynamicConfigBase())
async def invalidate(self) -> None:
await self._cache.invalidate(CONFIG_CACHE_KEY)
{%- else -%}
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.db.models.config import DynamicConfig, DynamicConfigBase
CONFIG_ROW_ID = 1
class ConfigRepository:
def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None:
self._sessionmaker = sessionmaker
async def get(self) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
config = DynamicConfigBase()
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
await session.commit()
return config
return DynamicConfigBase.model_validate(row.data)
async def save(self, config: DynamicConfigBase) -> DynamicConfigBase:
async with self._sessionmaker() as session:
row = await session.get(DynamicConfig, CONFIG_ROW_ID)
if row is None:
session.add(DynamicConfig(id=CONFIG_ROW_ID, data=config.model_dump()))
else:
row.data = config.model_dump()
session.add(row)
await session.commit()
return config
async def reset(self) -> DynamicConfigBase:
return await self.save(DynamicConfigBase())
{%- endif %}
@@ -0,0 +1,52 @@
from collections.abc import Awaitable, Callable
from typing import TypeVar
from pydantic import BaseModel
from redis.asyncio import Redis
ModelT = TypeVar("ModelT", bound=BaseModel)
NAMESPACE = "{{ project_slug }}"
class RedisCache:
def __init__(
self, redis: Redis, *, namespace: str = NAMESPACE, ttl: int = 300
) -> None:
self._redis = redis
self._namespace = namespace
self._ttl = ttl
def _key(self, key: str) -> str:
return f"{self._namespace}:{key}"
async def get_model(self, key: str, model: type[ModelT]) -> ModelT | None:
raw = await self._redis.get(self._key(key))
if raw is None:
return None
return model.model_validate_json(raw)
async def set_model(
self, key: str, value: BaseModel, *, ttl: int | None = None
) -> None:
await self._redis.set(
self._key(key), value.model_dump_json(), ex=ttl or self._ttl
)
async def get_or_set_model(
self,
key: str,
model: type[ModelT],
loader: Callable[[], Awaitable[ModelT]],
*,
ttl: int | None = None,
) -> ModelT:
cached = await self.get_model(key, model)
if cached is not None:
return cached
value = await loader()
await self.set_model(key, value, ttl=ttl)
return value
async def invalidate(self, *keys: str) -> None:
if keys:
await self._redis.delete(*(self._key(key) for key in keys))
@@ -0,0 +1,15 @@
{% if database == 'postgres' -%}
from utils.db.models.config import DynamicConfigBase
from utils.db.repositories import ConfigRepository
async def config() -> DynamicConfigBase:
from dependencies.container import container # noqa: PLC0415
repo = await container.get(ConfigRepository)
return await repo.get()
{%- else -%}
from utils.db.models.config import DynamicConfig
config = DynamicConfig.get_or_create
{%- endif %}
@@ -0,0 +1,49 @@
{%- set third = [] -%}
{%- set first = [] -%}
{%- if use_dishka %}{% set _ = third.append(' from dishka.integrations.aiogram import setup_dishka # noqa: PLC0415') %}{% set _ = first.append(' from dependencies.container import container # noqa: PLC0415') %}{% endif -%}
{%- if database != 'none' %}{% set _ = first.append(' from utils.db import init_db # noqa: PLC0415') %}{% endif -%}
{%- set groups = [] -%}
{%- if third %}{% set _ = groups.append(third | join('\n')) %}{% endif -%}
{%- if first %}{% set _ = groups.append(first | join('\n')) %}{% endif -%}
{%- set _ = groups.append(' from . import handlers # noqa: PLC0415\n from .common import bot, cp, dp # noqa: PLC0415') -%}
import asyncio
import contextlib
from utils.logging import logger, setup_logging
setup_logging()
async def runner() -> None:
{{ groups | join('\n\n') }}
{%- if database != 'none' %}
await init_db()
{%- endif %}
dp.include_routers(handlers.router)
{%- if use_dishka %}
setup_dishka(container, dp, auto_inject=True)
{%- endif %}
await bot.delete_webhook(drop_pending_updates=True)
tasks = [dp.start_polling(bot)]
if cp is not None:
tasks.append(cp.start_polling())
{% if use_dishka %}
try:
await asyncio.gather(*tasks)
finally:
await container.close()
{% else %}
await asyncio.gather(*tasks)
{% endif %}
def main() -> None:
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,4 @@
from . import main
if __name__ == "__main__":
main()
@@ -0,0 +1,19 @@
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
{% if use_redis %}from aiogram.fsm.storage.redis import RedisStorage
{% else %}from aiogram.fsm.storage.memory import MemoryStorage
{% endif %}{% if include_payments %}from aiosend import CryptoPay
{% endif %}
from utils.env import env
bot = Bot(
token=env.bot.token.get_secret_value(),
default=DefaultBotProperties(parse_mode="HTML"),
)
storage = {% if use_redis %}RedisStorage.from_url(env.redis.url){% else %}MemoryStorage(){% endif %}
dp = Dispatcher(storage=storage)
cp = {% if include_payments %}CryptoPay(env.crypto_pay.token.get_secret_value()){% else %}None{% endif %}
__all__ = ["bot", "cp", "dp"]
@@ -0,0 +1,3 @@
from .admin import Admin
__all__ = ["Admin"]
@@ -0,0 +1,12 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
{% if dynamic_config %}from utils.config import config
{% else %}from utils.env import env
{% endif %}
class Admin(BaseFilter):
async def __call__(self, message: Message) -> bool:
if not message.from_user:
return False
return message.from_user.id in {% if dynamic_config %}(await config()).bot.admins{% else %}env.bot.admins{% endif %}
@@ -0,0 +1,7 @@
from aiogram import Router
from . import {% if include_payments %}deposit, {% endif %}initialize, start{% if include_payments %}, withdraw{% endif %}
router = Router()
{% if include_payments %}router.include_routers(initialize.router, start.router, deposit.router, withdraw.router){% else %}router.include_routers(initialize.router, start.router){% endif %}
@@ -0,0 +1,3 @@
from .initializer import router
__all__ = ["router"]
@@ -0,0 +1,17 @@
from aiogram import Bot, Router, types
from rich import print # noqa: A004
router = Router()
@router.startup()
async def startup(bot: Bot) -> None:
await bot.set_my_commands(
[types.BotCommand(command="start", description="Start the bot")]
)
print(f"[green]Started as[/] @{(await bot.me()).username}")
@router.shutdown()
async def shutdown() -> None:
print("Shutting down bot...")
@@ -0,0 +1,3 @@
from .start import router
__all__ = ["router"]
@@ -0,0 +1,11 @@
from aiogram import Router, types
from aiogram.filters import CommandStart
from bot.keyboards import main_menu_kb
router = Router()
@router.message(CommandStart())
async def on_start(message: types.Message) -> None:
await message.answer("hello", reply_markup=main_menu_kb())
@@ -0,0 +1,90 @@
{% if database != 'mongo' %}from collections import defaultdict
{% endif %}from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiosend.types import Invoice
from bot.common import bot, cp
from bot.keyboards import deposit_amounts_kb, pay_invoice_kb
{% if database == 'mongo' %}from utils.db.models import User
{% endif %}
router = Router()
{% if database != 'mongo' %}balances: defaultdict[int, float] = defaultdict(float)
{% endif %}
class DepositStates(StatesGroup):
waiting_custom_amount = State()
@router.message(F.text == "Deposit")
async def on_deposit_menu(message: types.Message, state: FSMContext) -> None:
await state.clear()
await message.answer("Choose an amount:", reply_markup=deposit_amounts_kb())
@router.callback_query(F.data.startswith("deposit:"))
async def on_deposit_amount(callback: types.CallbackQuery, state: FSMContext) -> None:
if not callback.data or not callback.message or not callback.from_user:
return
amount_str = callback.data.split(":")[1]
if amount_str == "custom":
await state.set_state(DepositStates.waiting_custom_amount)
if isinstance(callback.message, types.Message):
await callback.message.edit_text("Enter an amount in USD (e.g. 2.5):")
await callback.answer()
return
await create_and_send_invoice(callback.from_user.id, float(amount_str))
await callback.answer()
@router.message(DepositStates.waiting_custom_amount)
async def on_custom_amount(message: types.Message, state: FSMContext) -> None:
if not message.text or not message.from_user:
return
try:
amount = float(message.text.replace(",", "."))
except ValueError:
await message.answer("Invalid format. Enter a number (e.g. 2.5):")
return
if amount <= 0:
await message.answer("Amount must be greater than 0.")
return
await state.clear()
await create_and_send_invoice(message.from_user.id, amount)
async def create_and_send_invoice(user_id: int, amount: float) -> None:
invoice = await cp.create_invoice(amount, "USDT")
invoice.poll(user_id=user_id)
await bot.send_message(
user_id,
f"💳 <b>Invoice for ${amount:.2f}</b>\n\nTap below to pay.",
reply_markup=pay_invoice_kb(invoice.bot_invoice_url),
)
@cp.invoice_paid()
async def handle_payment(invoice: Invoice, user_id: int) -> None:
{%- if database == 'mongo' %}
user = await User.get_or_create(user_id)
await user.add_balance(float(invoice.amount))
new_balance = user.balance
{%- else %}
balances[user_id] += float(invoice.amount)
new_balance = balances[user_id]
{%- endif %}
await bot.send_message(
user_id,
f"✅ <b>Payment received!</b>\n\n"
f"Amount: ${float(invoice.amount):.2f}\n"
f"New balance: ${new_balance:.2f}",
)
@@ -0,0 +1,87 @@
import uuid
from aiogram import F, Router, types
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from bot.common import cp
{% if database == 'mongo' %}from utils.db.models import User
{% else %}from bot.handlers.deposit.deposit import balances
{% endif %}
router = Router()
MIN_WITHDRAW = 1.2
WITHDRAW_FEE = 0.10
class WithdrawStates(StatesGroup):
waiting_amount = State()
@router.message(F.text == "Withdraw")
async def on_withdraw_menu(message: types.Message, state: FSMContext) -> None:
if not message.from_user:
return
await state.clear()
await state.set_state(WithdrawStates.waiting_amount)
await message.answer(
f"💸 <b>Withdraw</b>\n\n"
f"Minimum: ${MIN_WITHDRAW:.2f}\n"
f"Fee: {int(WITHDRAW_FEE * 100)}%\n\n"
f"Enter an amount in USDT:"
)
@router.message(WithdrawStates.waiting_amount)
async def on_withdraw_amount(message: types.Message, state: FSMContext) -> None:
if not message.text or not message.from_user:
return
try:
amount = float(message.text.replace(",", "."))
except ValueError:
await message.answer("Invalid format. Enter a number (e.g. 1.5):")
return
if amount < MIN_WITHDRAW:
await message.answer(f"Minimum withdrawal: ${MIN_WITHDRAW:.2f}")
return
user_id = message.from_user.id
{%- if database == 'mongo' %}
user = await User.get_by_id(user_id)
if not user or not await user.subtract_balance(amount):
await message.answer("Insufficient funds.")
return
{%- else %}
if balances[user_id] < amount:
await message.answer("Insufficient funds.")
return
balances[user_id] -= amount
{%- endif %}
await state.clear()
fee = amount * WITHDRAW_FEE
payout = amount - fee
try:
await cp.transfer(
user_id=user_id, asset="USDT", amount=payout, spend_id=str(uuid.uuid4())
)
except Exception as e:
{%- if database == 'mongo' %}
await user.add_balance(amount)
{%- else %}
balances[user_id] += amount
{%- endif %}
await message.answer(f"❌ Withdrawal failed. Funds returned.\n<code>{e}</code>")
return
await message.answer(
f"✅ <b>Withdrawn!</b>\n\n"
f"Amount: ${amount:.2f}\n"
f"Fee: ${fee:.2f}\n"
f"Paid out: ${payout:.2f} USDT"
)
@@ -0,0 +1,33 @@
{% if include_payments %}from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
KeyboardButton,
ReplyKeyboardMarkup,
)
{% else %}from aiogram.types import KeyboardButton, ReplyKeyboardMarkup
{% endif %}
def main_menu_kb() -> ReplyKeyboardMarkup:
{% if include_payments %} buttons = [[KeyboardButton(text="Deposit"), KeyboardButton(text="Withdraw")]]
{% else %} buttons = [[KeyboardButton(text="Menu")]]
{% endif %} return ReplyKeyboardMarkup(keyboard=buttons, resize_keyboard=True)
{% if include_payments %}
def deposit_amounts_kb() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="$1", callback_data="deposit:1"),
InlineKeyboardButton(text="$5", callback_data="deposit:5"),
InlineKeyboardButton(text="$10", callback_data="deposit:10"),
],
[InlineKeyboardButton(text="Custom", callback_data="deposit:custom")],
]
)
def pay_invoice_kb(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text="💳 Pay", url=url)]]
)
{% endif %}
@@ -0,0 +1,13 @@
import uvicorn
from utils.env import env
def main() -> None:
uvicorn.run(
"api.app:app", host=env.api.host, port=env.api.port, workers=env.api.workers
)
if __name__ == "__main__":
main()
@@ -0,0 +1,53 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
{% if serve_spa %}from pathlib import Path
{% endif %}
{% if use_dishka %}from dishka.integrations.fastapi import setup_dishka
{% endif %}from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
{% if serve_spa %}from fastapi.responses import FileResponse
{% endif %}
from api import routers
{% if use_dishka %}from dependencies.container import container
{% endif %}{% if database != 'none' %}from utils.db import init_db
{% endif %}{% if serve_spa %}from utils.env import env
{% endif %}from utils.logging import setup_logging
{% if serve_spa %}
IMMUTABLE_HEADERS = {"Cache-Control": "public, max-age=31536000, immutable"}
NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
{% endif %}
@asynccontextmanager
async def lifespan(app_: FastAPI) -> AsyncGenerator[None]:
setup_logging()
{%- if database != 'none' %}
await init_db()
{%- endif %}
yield
{%- if use_dishka %}
await app_.state.dishka_container.close()
{%- endif %}
app = FastAPI(title="{{ project_name }} API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]
)
app.include_router(routers.router)
{% if serve_spa %}
spa_dir = Path(env.api.static_dir).resolve()
if spa_dir.is_dir():
@app.get("/{spa_path:path}")
async def serve_spa(spa_path: str) -> FileResponse:
candidate = (spa_dir / spa_path).resolve()
if spa_path and candidate.is_relative_to(spa_dir) and candidate.is_file():
immutable = spa_path.startswith("_app/immutable/")
headers = IMMUTABLE_HEADERS if immutable else NO_CACHE_HEADERS
return FileResponse(candidate, headers=headers)
return FileResponse(spa_dir / "index.html", headers=NO_CACHE_HEADERS)
{% endif %}
{% if use_dishka %}setup_dishka(container, app)
{% endif %}
@@ -0,0 +1,6 @@
from fastapi import APIRouter
from . import api
router = APIRouter()
router.include_router(api.router, prefix="/api")
@@ -0,0 +1,6 @@
from fastapi import APIRouter
from . import health
router = APIRouter()
router.include_router(health.router, prefix="/health")
@@ -0,0 +1,38 @@
{% if use_dishka and database == 'postgres' -%}
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from sqlalchemy import text
from sqlmodel.ext.asyncio.session import AsyncSession
router = APIRouter(tags=["health"], route_class=DishkaRoute)
@router.get("")
@router.get("/")
async def health(session: FromDishka[AsyncSession]) -> dict[str, bool]:
result = await session.scalars(text("SELECT 1"))
return {"db": result.first() == 1}
{% elif use_dishka and database == 'mongo' -%}
from dishka.integrations.fastapi import DishkaRoute, FromDishka
from fastapi import APIRouter
from pymongo import AsyncMongoClient
router = APIRouter(tags=["health"], route_class=DishkaRoute)
@router.get("")
@router.get("/")
async def health(client: FromDishka[AsyncMongoClient]) -> dict[str, bool]:
await client.admin.command("ping")
return {"db": True}
{% else -%}
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("")
@router.get("/")
async def health() -> dict[str, bool]:
return {"ok": True}
{% endif -%}
@@ -0,0 +1,3 @@
from .modules.client import PyroClient
__all__ = ["PyroClient"]
@@ -0,0 +1,4 @@
from .runner import main
if __name__ == "__main__":
main()
@@ -0,0 +1,3 @@
from . import echo, private
handlers = echo.handlers + private.handlers
@@ -0,0 +1,12 @@
from pyrogram import filters, types
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.me)
async def echo(_: PyroClient, message: types.Message) -> None:
logger.info(f"[cyan]me:[/] {message.text}")
handlers = echo.handlers
@@ -0,0 +1,3 @@
from . import pm_message
handlers = pm_message.handlers
@@ -0,0 +1,13 @@
from pyrogram import filters, types
from userbot import PyroClient
from utils.logging import logger
@PyroClient.on_message(filters.private & ~filters.me)
async def pm_message(_: PyroClient, message: types.Message) -> None:
sender = message.from_user.id if message.from_user else "unknown"
logger.info(f"[green]pm from {sender}:[/] {message.text}")
handlers = pm_message.handlers
@@ -0,0 +1,27 @@
from pyrogram import Client, enums
class PyroClient(Client):
def __init__(
self, name: str, *, workdir: str = "sessions", load_handlers: bool = True
) -> None:
super().__init__(
name,
workdir=workdir,
api_id=2040,
api_hash="b18441a1ff607e10a989891a5462e627",
device_model="Desktop",
system_version="Windows 11 x64",
app_version="6.2.4 x64",
lang_pack="tdesktop",
client_platform=enums.ClientPlatform.DESKTOP,
)
if load_handlers:
from userbot import handlers # noqa: PLC0415
for handler in handlers.handlers:
self.add_handler(*handler)
__all__ = ["PyroClient"]
@@ -0,0 +1,62 @@
import asyncio
import contextlib
import anyio
import uvloop
{% if use_dishka %}from dependencies.container import container
{% endif %}from userbot.modules.client import PyroClient
{% if database != 'none' %}from utils.db import init_db
{% endif %}from utils.logging import logger, setup_logging
setup_logging()
async def runner() -> None:
{% if database != 'none' %} await init_db()
{% endif %} sessions_dir = anyio.Path("sessions")
await sessions_dir.mkdir(parents=True, exist_ok=True)
started: set[str] = set()
clients: dict[int, PyroClient] = {}
try:
while True:
current = {path.name async for path in sessions_dir.glob("*.session")}
for session_file in current - started:
started.add(session_file)
name = session_file.removesuffix(".session")
try:
client = PyroClient(name)
await client.start()
except Exception as e:
logger.warning(f"[red]Client {name} failed to start: {e}[/]")
continue
me = client.me
logger.info(
f"[green]Client started as[/] "
f"{me.full_name if me else 'unknown'} ({me.id if me else '?'})"
)
if me:
clients[me.id] = client
await asyncio.sleep(10)
finally:
for client in clients.values():
with contextlib.suppress(Exception):
await client.stop()
{% if use_dishka %} await container.close()
{% endif %}
def main() -> None:
uvloop.install()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,40 @@
import asyncio
import contextlib
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
{% if database != 'none' %}from utils.db import init_db
{% endif %}from utils.logging import logger, setup_logging
scheduler = AsyncIOScheduler()
async def example_job() -> None:
logger.info("example job ran")
async def runner() -> None:
{%- if database != 'none' %}
await init_db()
{%- endif %}
scheduler.add_job(
example_job,
IntervalTrigger(seconds=60),
id="example_job",
replace_existing=True,
)
scheduler.start()
logger.info("Scheduler started")
with contextlib.suppress(asyncio.CancelledError):
await asyncio.Event().wait()
def main() -> None:
setup_logging()
logger.info("Starting...")
with contextlib.suppress(KeyboardInterrupt):
asyncio.run(runner())
logger.info("[red]Stopped.[/]")
@@ -0,0 +1,23 @@
import os
{% if 'scheduler' in backend_services %}import sys
{% endif %}
BROKER = "worker.broker:broker"
{% if 'scheduler' in backend_services %}SCHEDULER = "worker.broker:scheduler"
{% endif %}TASKS = "worker.tasks"
def main() -> None:
{%- if 'scheduler' in backend_services %}
mode = sys.argv[1] if len(sys.argv) > 1 else "worker"
if mode == "scheduler":
argv = ["taskiq", "scheduler", SCHEDULER, TASKS]
else:
argv = ["taskiq", "worker", BROKER, TASKS]
{%- else %}
argv = ["taskiq", "worker", BROKER, TASKS]
{%- endif %}
os.execvp("taskiq", argv) # noqa: S606, S607
if __name__ == "__main__":
main()
@@ -0,0 +1,54 @@
{% if taskiq_broker == 'redis' -%}
from collections.abc import AsyncGenerator
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from redis.asyncio import Redis
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import TimeoutError as RedisTimeoutError
{% if 'scheduler' in backend_services %}from taskiq import TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource
{% endif %}from taskiq_redis import ListQueueBroker, RedisAsyncResultBackend
{% if use_dishka %}from dependencies.container import container
{% endif %}from utils.env import env
from utils.logging import logger
BRPOP_TIMEOUT = 2
class ResilientListQueueBroker(ListQueueBroker):
async def listen(self) -> AsyncGenerator[bytes]:
while True:
try:
async with Redis(connection_pool=self.connection_pool) as conn:
result = await conn.brpop(self.queue_name, timeout=BRPOP_TIMEOUT)
if result is None:
continue
value = result[1]
yield value if isinstance(value, bytes) else value.encode()
except RedisTimeoutError:
continue
except (RedisConnectionError, OSError) as exc:
logger.warning("redis listen error: %s", exc)
broker = ResilientListQueueBroker(env.redis.url).with_result_backend(
RedisAsyncResultBackend(env.redis.url)
)
{% if 'scheduler' in backend_services %}scheduler = TaskiqScheduler(broker, sources=[LabelScheduleSource(broker)])
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- else -%}
{% if use_dishka %}from dishka.integrations.taskiq import setup_dishka
{% endif %}from taskiq import InMemoryBroker{% if 'scheduler' in backend_services %}, TaskiqScheduler
from taskiq.schedule_sources import LabelScheduleSource{% endif %}
{% if use_dishka %}from dependencies.container import container
{% endif %}
broker = InMemoryBroker()
{% if 'scheduler' in backend_services %}scheduler = TaskiqScheduler(broker, sources=[LabelScheduleSource(broker)])
{% endif %}{% if use_dishka %}
setup_dishka(container, broker)
{% endif %}
{%- endif %}
@@ -0,0 +1,3 @@
from . import example
__all__ = ["example"]
@@ -0,0 +1,8 @@
from utils.logging import logger
from worker.broker import broker
@broker.task{% if 'scheduler' in backend_services %}(schedule=[{"cron": "*/5 * * * *"}]){% endif %}
async def example() -> str:
logger.info("example task ran")
return "ok"
@@ -0,0 +1,29 @@
{%- set providers = [] -%}
{%- if database != 'none' %}{% set _ = providers.append('DbProvider()') %}{% endif -%}
{%- if dynamic_config %}{% set _ = providers.append('ConfigProvider()') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = providers.append('RepositoryProvider()') %}{% endif -%}
{%- if use_redis %}{% set _ = providers.append('RedisProvider()') %}{% endif -%}
{%- if use_llm %}{% set _ = providers.append('LlmProvider()') %}{% endif -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = providers.append('AiogramProvider()') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = providers.append('FastapiProvider()') %}{% endif -%}
{%- set third = ['from dishka import make_async_container'] -%}
{%- if 'aiogram_bot' in backend_services %}{% set _ = third.append('from dishka.integrations.aiogram import AiogramProvider') %}{% endif -%}
{%- if 'api' in backend_services %}{% set _ = third.append('from dishka.integrations.fastapi import FastapiProvider') %}{% endif -%}
{%- set first = [] -%}
{%- if dynamic_config %}{% set _ = first.append('from dependencies.providers.config import ConfigProvider') %}{% endif -%}
{%- if database != 'none' %}{% set _ = first.append('from dependencies.providers.db import DbProvider') %}{% endif -%}
{%- if use_llm %}{% set _ = first.append('from dependencies.providers.llm import LlmProvider') %}{% endif -%}
{%- if use_redis %}{% set _ = first.append('from dependencies.providers.redis import RedisProvider') %}{% endif -%}
{%- if database == 'postgres' %}{% set _ = first.append('from dependencies.providers.repositories import RepositoryProvider') %}{% endif -%}
{%- set imports = (third | join('\n')) ~ (('\n\n' ~ (first | join('\n'))) if first else '') -%}
{%- set args = providers | join(', ') -%}
{%- set oneline = 'container = make_async_container(' ~ args ~ ')' -%}
{%- set hugged = 'container = make_async_container(\n ' ~ args ~ '\n)' -%}
{%- set indented = [] -%}
{%- for p in providers %}{% set _ = indented.append(' ' ~ p ~ ',') %}{% endfor -%}
{%- set exploded = 'container = make_async_container(\n' ~ (indented | join('\n')) ~ '\n)' -%}
{#- ruff-format order: one line, else all args on one continuation line, else one per line -#}
{%- set body = oneline if (oneline | length) <= 88 else (hugged if (args | length) + 4 <= 88 else exploded) -%}
{{ imports }}
{{ body }}
@@ -0,0 +1,3 @@
from . import asyncio
__all__ = ["asyncio"]
@@ -0,0 +1,25 @@
from collections.abc import Callable
from dishka import FromDishka, Scope
from dishka.integrations.base import wrap_injection
from dependencies.container import container
def inject[**P, T](
_func: Callable[P, T] | None = None, *, scope: Scope | None = None
) -> Callable[P, T] | Callable[[Callable[P, T]], Callable[P, T]]:
def decorator(func: Callable[P, T]) -> Callable[P, T]:
return wrap_injection(
func=func,
is_async=True,
container_getter=lambda _args, _kwargs: container,
scope=scope,
)
if _func is None:
return decorator
return decorator(_func)
__all__ = ["FromDishka", "Scope", "container", "inject"]
@@ -0,0 +1,52 @@
{% if database == 'postgres' -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from sqlmodel.ext.asyncio.session import AsyncSession
from utils.env import env
class DbProvider(Provider):
@provide(scope=Scope.APP)
async def get_engine(self) -> AsyncGenerator[AsyncEngine]:
engine = create_async_engine(
env.db.async_connection_url,
pool_size=env.db.min_pool_size,
max_overflow=env.db.max_pool_size - env.db.min_pool_size,
pool_pre_ping=True,
)
try:
yield engine
finally:
await engine.dispose()
@provide(scope=Scope.APP)
def get_sessionmaker(self, engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
return async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@provide(scope=Scope.REQUEST)
async def get_session(
self, maker: async_sessionmaker[AsyncSession]
) -> AsyncGenerator[AsyncSession]:
async with maker() as session:
yield session
{%- else -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from pymongo import AsyncMongoClient
from utils.db import client, init_db
class DbProvider(Provider):
@provide(scope=Scope.APP)
async def get_client(self) -> AsyncGenerator[AsyncMongoClient]:
await init_db()
try:
yield client
finally:
await client.close()
{%- endif %}
@@ -0,0 +1,9 @@
from dishka import Provider, Scope, provide
from utils.db.repositories import UserRepository
class RepositoryProvider(Provider):
scope = Scope.REQUEST
user = provide(UserRepository)
@@ -0,0 +1,21 @@
{% if database == 'postgres' -%}
from dishka import Provider, Scope, provide
from utils.db.repositories import ConfigRepository
class ConfigProvider(Provider):
config_repository = provide(ConfigRepository, scope=Scope.APP)
{%- else -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from utils.db.models import DynamicConfig
class ConfigProvider(Provider):
@provide(scope=Scope.REQUEST)
async def provide_config(self) -> AsyncGenerator[DynamicConfig]:
yield await DynamicConfig.get_or_create()
{%- endif %}
@@ -0,0 +1,19 @@
from dishka import Provider, Scope, provide
from pydantic_ai import Agent
from pydantic_ai.models.google import GoogleModel, GoogleModelSettings
from pydantic_ai.providers.google import GoogleProvider
from utils.env import env
class LlmProvider(Provider):
@provide(scope=Scope.APP)
def agent(self) -> Agent:
model = GoogleModel(
env.llm.model.split(":", 1)[-1],
provider=GoogleProvider(api_key=env.llm.gemini_api_key.get_secret_value()),
settings=GoogleModelSettings(
temperature=0.0, google_thinking_config={"thinking_budget": 0}
),
)
return Agent(model)
@@ -0,0 +1,93 @@
{% if database == 'postgres' -%}
from collections.abc import AsyncGenerator
from dishka import Provider, Scope, provide
from redis.asyncio import Redis
from utils.db.repositories import RedisCache
from utils.env import env
class RedisProvider(Provider):
@provide(scope=Scope.APP)
async def get_redis(self) -> AsyncGenerator[Redis]:
client = Redis.from_url(env.redis.url, decode_responses=True)
try:
yield client
finally:
await client.aclose()
@provide(scope=Scope.APP)
def get_cache(self, redis: Redis) -> RedisCache:
return RedisCache(redis, ttl=env.redis.cache_ttl)
{%- else -%}
from collections.abc import AsyncGenerator, Awaitable, Callable
from typing import TypeVar
from dishka import Provider, Scope, provide
from pydantic import BaseModel
from redis.asyncio import Redis
from utils.env import env
ModelT = TypeVar("ModelT", bound=BaseModel)
NAMESPACE = "{{ project_slug }}"
class RedisCache:
def __init__(
self, redis: Redis, *, namespace: str = NAMESPACE, ttl: int = 300
) -> None:
self._redis = redis
self._namespace = namespace
self._ttl = ttl
def _key(self, key: str) -> str:
return f"{self._namespace}:{key}"
async def get_model(self, key: str, model: type[ModelT]) -> ModelT | None:
raw = await self._redis.get(self._key(key))
if raw is None:
return None
return model.model_validate_json(raw)
async def set_model(
self, key: str, value: BaseModel, *, ttl: int | None = None
) -> None:
await self._redis.set(
self._key(key), value.model_dump_json(), ex=ttl or self._ttl
)
async def get_or_set_model(
self,
key: str,
model: type[ModelT],
loader: Callable[[], Awaitable[ModelT]],
*,
ttl: int | None = None,
) -> ModelT:
cached = await self.get_model(key, model)
if cached is not None:
return cached
value = await loader()
await self.set_model(key, value, ttl=ttl)
return value
async def invalidate(self, *keys: str) -> None:
if keys:
await self._redis.delete(*(self._key(key) for key in keys))
class RedisProvider(Provider):
@provide(scope=Scope.APP)
async def get_redis(self) -> AsyncGenerator[Redis]:
client = Redis.from_url(env.redis.url, decode_responses=True)
try:
yield client
finally:
await client.aclose()
@provide(scope=Scope.APP)
def get_cache(self, redis: Redis) -> RedisCache:
return RedisCache(redis, ttl=env.redis.cache_ttl)
{%- endif %}