Initial template
This commit is contained in:
@@ -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 %}
|
||||
+4
@@ -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"]
|
||||
+54
@@ -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 %}
|
||||
+60
@@ -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"),
|
||||
)
|
||||
+58
@@ -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 %}
|
||||
+5
@@ -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"]
|
||||
+35
@@ -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
|
||||
+88
@@ -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 %}
|
||||
+52
@@ -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 %}
|
||||
+49
@@ -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.[/]")
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+19
@@ -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"]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .admin import Admin
|
||||
|
||||
__all__ = ["Admin"]
|
||||
+12
@@ -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 %}
|
||||
+7
@@ -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 %}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .initializer import router
|
||||
|
||||
__all__ = ["router"]
|
||||
+17
@@ -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...")
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .start import router
|
||||
|
||||
__all__ = ["router"]
|
||||
+11
@@ -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())
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .deposit import router
|
||||
|
||||
__all__ = ["router"]
|
||||
+90
@@ -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}",
|
||||
)
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .withdraw import router
|
||||
|
||||
__all__ = ["router"]
|
||||
+87
@@ -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"
|
||||
)
|
||||
+33
@@ -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 %}
|
||||
+13
@@ -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 %}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import api
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(api.router, prefix="/api")
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import health
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(health.router, prefix="/health")
|
||||
+38
@@ -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 -%}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from .modules.client import PyroClient
|
||||
|
||||
__all__ = ["PyroClient"]
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
from .runner import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from . import echo, private
|
||||
|
||||
handlers = echo.handlers + private.handlers
|
||||
+12
@@ -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
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from . import pm_message
|
||||
|
||||
handlers = pm_message.handlers
|
||||
+13
@@ -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
|
||||
+1
@@ -0,0 +1 @@
|
||||
pass
|
||||
+27
@@ -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"]
|
||||
+62
@@ -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.[/]")
|
||||
+40
@@ -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.[/]")
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+23
@@ -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()
|
||||
+54
@@ -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 %}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from . import example
|
||||
|
||||
__all__ = ["example"]
|
||||
+8
@@ -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 }}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
from . import asyncio
|
||||
|
||||
__all__ = ["asyncio"]
|
||||
+25
@@ -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"]
|
||||
+52
@@ -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 %}
|
||||
+9
@@ -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)
|
||||
+21
@@ -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 %}
|
||||
+19
@@ -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)
|
||||
+93
@@ -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 %}
|
||||
Reference in New Issue
Block a user