from __future__ import annotations import argparse import subprocess import sys import textwrap from pathlib import Path ROOT = Path.cwd() MIN_PYTHON = (3, 12) def norm(text: str) -> str: return textwrap.dedent(text).lstrip("\n").rstrip() + "\n" def write(path: Path, content: str, overwrite: bool = False) -> None: path.parent.mkdir(parents=True, exist_ok=True) if path.exists() and not overwrite: print(f"[SKIP] {path}") return path.write_text(norm(content), encoding="utf-8") print(f"[WRITE] {path}") def touch(path: Path) -> None: write(path, '"""Package marker."""\n', overwrite=False) def run(cmd: list[str], cwd: Path | None = None, allow_fail: bool = False) -> int: print("[RUN ]", " ".join(cmd)) try: completed = subprocess.run(cmd, cwd=cwd, check=not allow_fail) return completed.returncode except subprocess.CalledProcessError as exc: print(f"[FAIL] {' '.join(cmd)} -> {exc.returncode}") if not allow_fail: raise return exc.returncode def ensure_python() -> None: current = sys.version_info[:3] if current < MIN_PYTHON: req = ".".join(map(str, MIN_PYTHON)) got = ".".join(map(str, current)) raise SystemExit( f"Python {req}+ is required. Current interpreter: {got}. " f"Run with /opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install" ) def create_dirs() -> None: for rel in [ "app/src/bootstrap", "app/src/core", "app/src/telegram/handlers", "app/src/telegram/keyboards", "app/src/telegram/callbacks", "app/src/trading", "app/src/storage", "app/src/integrations", "app/src/shared", "app/tests", "docs/architecture", "docs/stages", "docs/decisions", "infra/docker", "infra/compose", ".vscode", ]: path = ROOT / rel path.mkdir(parents=True, exist_ok=True) print(f"[DIR ] {path}") def create_init_files() -> None: for rel in [ "app/src/__init__.py", "app/src/bootstrap/__init__.py", "app/src/core/__init__.py", "app/src/telegram/__init__.py", "app/src/telegram/handlers/__init__.py", "app/src/telegram/keyboards/__init__.py", "app/src/telegram/callbacks/__init__.py", "app/src/trading/__init__.py", "app/src/storage/__init__.py", "app/src/integrations/__init__.py", "app/src/shared/__init__.py", "app/tests/__init__.py", ]: touch(ROOT / rel) def create_root_files() -> None: write(ROOT / ".gitignore", """ __pycache__/ *.py[cod] .DS_Store .pytest_cache/ .mypy_cache/ .ruff_cache/ app/.env app/.venv/ .venv/ venv/ logs/ data/ """) write(ROOT / ".vscode" / "settings.json", """ { "python.defaultInterpreterPath": "app/.venv/bin/python" } """) write(ROOT / "README.md", """ # dzentra_bot Telegram-бот для автоторговли криптовалютой. ## Bootstrap v2 Это стабильный стартовый каркас проекта с: - чистой структурой каталогов - компактным верхним меню - базовыми handlers - документацией - Docker-файлами - опциональным созданием `.venv`, установкой зависимостей и первым git commit ## Верхнее меню - 🏠 Главная - 📈 Рынок - 💼 Портфель - ⚡ Торговля - 🤖 Авто - 📒 Журнал - ⚙️ Система ## Рекомендуемый запуск ```bash /opt/homebrew/bin/python3.12 bootstrap_project.py --with-venv --with-install ``` ## После bootstrap ```bash cd app source .venv/bin/activate python -m src.main ``` """) def create_app_files() -> None: write(ROOT / "app" / ".env.example", """ BOT_TOKEN=PUT_YOUR_TELEGRAM_BOT_TOKEN_HERE BOT_PARSE_MODE=HTML APP_ENV=dev LOG_LEVEL=INFO TZ=Europe/Madrid """) write(ROOT / "app" / "requirements.txt", """ aiogram==3.13.1 python-dotenv==1.0.1 """) write(ROOT / "app" / "README.md", """ # app Здесь находятся исходный код приложения, env-файлы и зависимости. """) def create_core_files() -> None: write(ROOT / "app/src/core/config.py", """ from __future__ import annotations import os from dataclasses import dataclass from pathlib import Path from dotenv import load_dotenv BASE_DIR = Path(__file__).resolve().parents[2] ENV_FILE = BASE_DIR / ".env" load_dotenv(ENV_FILE) @dataclass(slots=True) class Settings: bot_token: str bot_parse_mode: str app_env: str log_level: str tz: str def load_settings() -> Settings: bot_token = os.getenv("BOT_TOKEN", "").strip() if not bot_token: raise RuntimeError("BOT_TOKEN is not set in app/.env") return Settings( bot_token=bot_token, bot_parse_mode=os.getenv("BOT_PARSE_MODE", "HTML").strip() or "HTML", app_env=os.getenv("APP_ENV", "dev").strip() or "dev", log_level=os.getenv("LOG_LEVEL", "INFO").strip().upper() or "INFO", tz=os.getenv("TZ", "Europe/Madrid").strip() or "Europe/Madrid", ) """) write(ROOT / "app/src/core/constants.py", """ APP_NAME = "Dzentra Bot" APP_VERSION = "2.0.0" """) write(ROOT / "app/src/core/exceptions.py", """ class AppError(Exception): \"\"\"Base application exception.\"\"\" """) def create_bootstrap_files() -> None: write(ROOT / "app/src/bootstrap/logging.py", """ from __future__ import annotations import logging def setup_logging(log_level: str) -> None: logging.basicConfig( level=getattr(logging, log_level.upper(), logging.INFO), format="%(asctime)s | %(levelname)s | %(name)s | %(message)s", ) """) write(ROOT / "app/src/bootstrap/app_factory.py", """ from __future__ import annotations from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from src.bootstrap.logging import setup_logging from src.core.config import load_settings from src.telegram.routers import setup_routers def create_app() -> tuple[Bot, Dispatcher]: settings = load_settings() setup_logging(settings.log_level) bot = Bot( token=settings.bot_token, default=DefaultBotProperties(parse_mode=settings.bot_parse_mode), ) dispatcher = Dispatcher() setup_routers(dispatcher) return bot, dispatcher """) def create_telegram_files() -> None: write(ROOT / "app/src/telegram/menus.py", """ MAIN_MENU_TEXT = ( "Dzentra Bot\\n\\n" "Новый каркас проекта успешно создан.\\n\\n" "Выбери раздел через меню ниже." ) HOME_TEXT = ( "🏠 Главная\\n\\n" "Это главный экран бота.\\n\\n" "Сейчас здесь отображается базовый статус:\\n" "- бот запущен\\n" "- меню подключено\\n" "- handlers работают\\n" "- проект на этапе Bootstrap v2\\n" ) SYSTEM_TEXT = ( "⚙️ Система\\n\\n" "Системный экран.\\n\\n" "Справка\\n" "/start — запуск\\n" "/menu — показать меню\\n" "/help — краткая справка\\n" ) MARKET_TEXT = "📈 Рынок\\n\\nРаздел пока в разработке." PORTFOLIO_TEXT = "💼 Портфель\\n\\nРаздел пока в разработке." TRADE_TEXT = "⚡ Торговля\\n\\nРаздел пока в разработке." AUTO_TEXT = "🤖 Авто\\n\\nРаздел пока в разработке." JOURNAL_TEXT = "📒 Журнал\\n\\nРаздел пока в разработке." """) write(ROOT / "app/src/telegram/keyboards/reply.py", """ from aiogram.types import KeyboardButton, ReplyKeyboardMarkup def build_main_menu_keyboard() -> ReplyKeyboardMarkup: return ReplyKeyboardMarkup( keyboard=[ [ KeyboardButton(text="🏠 Главная"), KeyboardButton(text="📈 Рынок"), KeyboardButton(text="💼 Портфель"), ], [ KeyboardButton(text="⚡ Торговля"), KeyboardButton(text="🤖 Авто"), KeyboardButton(text="📒 Журнал"), ], [ KeyboardButton(text="⚙️ Система"), ], ], resize_keyboard=True, input_field_placeholder="Выбери раздел...", ) """) write(ROOT / "app/src/telegram/routers.py", """ from aiogram import Dispatcher from src.telegram.handlers.auto import router as auto_router from src.telegram.handlers.home import router as home_router from src.telegram.handlers.journal import router as journal_router from src.telegram.handlers.market import router as market_router from src.telegram.handlers.portfolio import router as portfolio_router from src.telegram.handlers.start import router as start_router from src.telegram.handlers.system import router as system_router from src.telegram.handlers.trade import router as trade_router def setup_routers(dispatcher: Dispatcher) -> None: dispatcher.include_router(start_router) dispatcher.include_router(home_router) dispatcher.include_router(market_router) dispatcher.include_router(portfolio_router) dispatcher.include_router(trade_router) dispatcher.include_router(auto_router) dispatcher.include_router(journal_router) dispatcher.include_router(system_router) """) write(ROOT / "app/src/telegram/handlers/start.py", """ from aiogram import F, Router from aiogram.filters import Command from aiogram.types import Message from src.telegram.keyboards.reply import build_main_menu_keyboard from src.telegram.menus import MAIN_MENU_TEXT, SYSTEM_TEXT router = Router(name="start") @router.message(Command("start")) async def cmd_start(message: Message) -> None: await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard()) @router.message(Command("menu")) async def cmd_menu(message: Message) -> None: await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard()) @router.message(Command("help")) async def cmd_help(message: Message) -> None: await message.answer(SYSTEM_TEXT, reply_markup=build_main_menu_keyboard()) @router.message(F.text == "Меню") async def menu_shortcut(message: Message) -> None: await message.answer(MAIN_MENU_TEXT, reply_markup=build_main_menu_keyboard()) """) write(ROOT / "app/src/telegram/handlers/home.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import HOME_TEXT router = Router(name="home") @router.message(F.text == "🏠 Главная") async def open_home(message: Message) -> None: await message.answer(HOME_TEXT) """) write(ROOT / "app/src/telegram/handlers/market.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import MARKET_TEXT router = Router(name="market") @router.message(F.text == "📈 Рынок") async def open_market(message: Message) -> None: await message.answer(MARKET_TEXT) """) write(ROOT / "app/src/telegram/handlers/portfolio.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import PORTFOLIO_TEXT router = Router(name="portfolio") @router.message(F.text == "💼 Портфель") async def open_portfolio(message: Message) -> None: await message.answer(PORTFOLIO_TEXT) """) write(ROOT / "app/src/telegram/handlers/trade.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import TRADE_TEXT router = Router(name="trade") @router.message(F.text == "⚡ Торговля") async def open_trade(message: Message) -> None: await message.answer(TRADE_TEXT) """) write(ROOT / "app/src/telegram/handlers/auto.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import AUTO_TEXT router = Router(name="auto") @router.message(F.text == "🤖 Авто") async def open_auto(message: Message) -> None: await message.answer(AUTO_TEXT) """) write(ROOT / "app/src/telegram/handlers/journal.py", """ from aiogram import F, Router from aiogram.types import Message from src.telegram.menus import JOURNAL_TEXT router = Router(name="journal") @router.message(F.text == "📒 Журнал") async def open_journal(message: Message) -> None: await message.answer(JOURNAL_TEXT) """) write(ROOT / "app/src/telegram/handlers/system.py", """ import platform from aiogram import F, Router from aiogram.types import Message from src.core.constants import APP_NAME, APP_VERSION from src.telegram.menus import SYSTEM_TEXT router = Router(name="system") @router.message(F.text.in_({"⚙️ Система", "⚙ Система"})) async def open_system(message: Message) -> None: runtime_info = ( "\\n\\nRuntime\\n" f"- app: {APP_NAME} {APP_VERSION}\\n" f"- python: {platform.python_version()}\\n" f"- os: {platform.system()} {platform.release()}" ) await message.answer(SYSTEM_TEXT + runtime_info) """) def create_misc_files() -> None: write(ROOT / "app/src/trading/README.md", """ Здесь будет торговая бизнес-логика. """) write(ROOT / "app/src/storage/README.md", """ Здесь будет слой доступа к данным. """) write(ROOT / "app/src/integrations/README.md", """ Здесь будут внешние интеграции, например биржа. """) write(ROOT / "app/src/shared/README.md", """ Здесь будут общие утилиты. """) write(ROOT / "app/tests/test_smoke.py", """ def test_smoke() -> None: assert True """) write(ROOT / "app/src/main.py", """ import asyncio from src.bootstrap.app_factory import create_app async def main() -> None: bot, dispatcher = create_app() await dispatcher.start_polling(bot) if __name__ == "__main__": asyncio.run(main()) """) write(ROOT / "infra/docker/Dockerfile", """ FROM python:3.12-slim ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 WORKDIR /project COPY app /project/app RUN pip install --no-cache-dir -r /project/app/requirements.txt WORKDIR /project/app CMD ["python", "-m", "src.main"] """) write(ROOT / "infra/compose/docker-compose.yml", """ services: bot: build: context: ../.. dockerfile: infra/docker/Dockerfile container_name: dzentra_bot restart: unless-stopped env_file: - ../../app/.env """) def create_docs() -> None: write(ROOT / "docs/changelog.md", """ # Changelog ## v2.0.0 - создан стабильный bootstrap v2 - добавлена чистая структура проекта - добавлено компактное верхнее меню 3 / 3 / 1 - добавлены базовые handlers - help перенесен в раздел `Система` - добавлены Docker-файлы - добавлена стартовая документация """) write(ROOT / "docs/architecture/overview.md", """ # Architecture Overview Проект строится как modular monolith с разделением по слоям: - `telegram` — меню, handlers, routers - `bootstrap` — сборка приложения - `core` — конфигурация и базовые сущности - `trading` — бизнес-логика торговли - `storage` — доступ к данным - `integrations` — внешние API - `shared` — общие утилиты """) write(ROOT / "docs/architecture/project_structure.md", """ # Project Structure ## Корневые папки - `app/` — код приложения - `docs/` — документация - `infra/` — Docker и compose ## Внутри `app/src` - `bootstrap/` - `core/` - `telegram/` - `trading/` - `storage/` - `integrations/` - `shared/` """) write(ROOT / "docs/architecture/telegram_menu.md", """ # Telegram Menu Верхнее меню bootstrap v2: 1. `🏠 Главная` `📈 Рынок` `💼 Портфель` 2. `⚡ Торговля` `🤖 Авто` `📒 Журнал` 3. `⚙️ Система` На старте реально работают: - `/start` - `/menu` - `/help` - все кнопки верхнего меню """) write(ROOT / "docs/stages/stage-01-bootstrap.md", """ # Stage 01 — Bootstrap ## Цель Получить стабильный стартовый каркас проекта без архитектурной каши. ## Что есть - структура каталогов - env - зависимости - базовый aiogram-бот - reply-меню - handlers - docs - Docker """) write(ROOT / "docs/decisions/0001-project-structure.md", """ # 0001 — Project Structure Решение: использовать modular monolith с разбиением на `core`, `bootstrap`, `telegram`, `trading`, `storage`, `integrations`, `shared`. """) write(ROOT / "docs/decisions/0002-menu-design.md", """ # 0002 — Menu Design Решение: использовать компактное верхнее меню 3 / 3 / 1: - Главная / Рынок / Портфель - Торговля / Авто / Журнал - Система Справка перенесена в раздел `Система`. """) write(ROOT / "docs/decisions/0003-python-workflow.md", """ # 0003 — Python Workflow Решение: - использовать Python 3.12 - создавать `.venv` внутри `app/` - не использовать conda/base для этого проекта - в VS Code выбирать `app/.venv/bin/python` """) def maybe_create_env() -> None: env = ROOT / "app/.env" example = ROOT / "app/.env.example" if not env.exists(): env.write_text(example.read_text(encoding="utf-8"), encoding="utf-8") print("[WRITE] app/.env created from .env.example") else: print("[SKIP] app/.env already exists") def create_venv_and_install(with_venv: bool, with_install: bool) -> None: app_dir = ROOT / "app" venv_dir = app_dir / ".venv" if with_venv and not venv_dir.exists(): run([sys.executable, "-m", "venv", str(venv_dir)]) if with_install: if not venv_dir.exists(): raise RuntimeError("app/.venv does not exist. Use --with-venv together with --with-install.") pip_path = venv_dir / "bin" / "pip" run([str(pip_path), "install", "--upgrade", "pip"], cwd=app_dir) run([str(pip_path), "install", "-r", "requirements.txt"], cwd=app_dir) def setup_git(with_git: bool) -> None: if not with_git: return if not (ROOT / ".git").exists(): run(["git", "init"], cwd=ROOT) run(["git", "add", "."], cwd=ROOT) status = subprocess.run(["git", "diff", "--cached", "--quiet"], cwd=ROOT, check=False) if status.returncode != 0: run(["git", "commit", "-m", "bootstrap v2"], cwd=ROOT, allow_fail=True) else: print("[INFO] Nothing new to commit.") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Stable bootstrap v2 for dzentra_bot.") parser.add_argument("--with-venv", action="store_true", help="Create app/.venv") parser.add_argument("--with-install", action="store_true", help="Install requirements into app/.venv") parser.add_argument("--with-git", action="store_true", help="Initialize git and create first commit") return parser.parse_args() def main() -> None: ensure_python() args = parse_args() if args.with_install and not args.with_venv: raise SystemExit("--with-install requires --with-venv") print("\n=== dzentra_bot bootstrap v2 ===\n") print(f"[INFO] Interpreter: {sys.executable}") print(f"[INFO] Python: {sys.version.split()[0]}") create_dirs() create_init_files() create_root_files() create_app_files() create_core_files() create_bootstrap_files() create_telegram_files() create_misc_files() create_docs() maybe_create_env() create_venv_and_install(args.with_venv, args.with_install) setup_git(args.with_git) print("\nBootstrap v2 completed.\n") print("Next steps:") print("1. Open app/.env and fill BOT_TOKEN") print("2. cd app") if args.with_venv: print("3. source .venv/bin/activate") else: print("3. Create venv later with --with-venv --with-install") print("4. python -m src.main") print("5. In VS Code use interpreter: app/.venv/bin/python") print("6. For Synology: sudo docker compose -f infra/compose/docker-compose.yml up --build -d") if __name__ == "__main__": main()