From 68221821a5e966aea8c0869dff95b4e28c3850e6 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 07:17:25 +0500 Subject: [PATCH] Initial local state --- .dockerignore | 35 + .env.example | 10 + .gitignore | 217 ++++ Dockerfile | 19 + README.md | 61 ++ alembic.ini | 42 + dj_bot.conf | 13 + docker-compose.yml | 11 + main.py | 196 ++++ migrate.py | 188 ++++ migrations/env.py | 184 ++++ migrations/script.py.mako | 24 + .../versions/20260529_0001_shop_schema.py | 515 ++++++++++ ...9_add_column_status_stars_into_storage_.py | 32 + ...7_add_column_stars_course_into_storage_.py | 32 + ...7b0e6_add_column_misc_method_prod_into_.py | 32 + pyproject.toml | 31 + requirements.txt | 16 + tgbot/__init__.py | 0 tgbot/data/__init__.py | 0 tgbot/data/config.py | 168 +++ tgbot/database/__init__.py | 20 + tgbot/database/core.py | 47 + tgbot/database/db_category.py | 47 + tgbot/database/db_item.py | 73 ++ tgbot/database/db_payments.py | 64 ++ tgbot/database/db_position.py | 79 ++ tgbot/database/db_purchases.py | 331 ++++++ tgbot/database/db_refill.py | 146 +++ tgbot/database/db_settings.py | 76 ++ tgbot/database/db_users.py | 128 +++ tgbot/database/entities.py | 110 ++ tgbot/database/migration_runner.py | 55 + tgbot/database/repository.py | 162 +++ tgbot/keyboards/__init__.py | 0 tgbot/keyboards/inline_admin.py | 255 +++++ tgbot/keyboards/inline_admin_page.py | 188 ++++ tgbot/keyboards/inline_admin_products.py | 198 ++++ tgbot/keyboards/inline_helper.py | 82 ++ tgbot/keyboards/inline_user.py | 84 ++ tgbot/keyboards/inline_user_page.py | 98 ++ tgbot/keyboards/inline_user_products.py | 41 + tgbot/keyboards/reply_main.py | 80 ++ tgbot/middlewares/__init__.py | 20 + tgbot/middlewares/middleware_throttling.py | 77 ++ tgbot/middlewares/middleware_user.py | 50 + tgbot/routers/__init__.py | 53 + tgbot/routers/admin/__init__.py | 0 tgbot/routers/admin/admin_functions.py | 327 ++++++ tgbot/routers/admin/admin_menu.py | 120 +++ tgbot/routers/admin/admin_payments.py | 355 +++++++ tgbot/routers/admin/admin_products.py | 969 ++++++++++++++++++ tgbot/routers/admin/admin_settings.py | 376 +++++++ tgbot/routers/main_errors.py | 35 + tgbot/routers/main_missed.py | 37 + tgbot/routers/main_start.py | 146 +++ tgbot/routers/user/__init__.py | 0 tgbot/routers/user/user_menu.py | 187 ++++ tgbot/routers/user/user_products.py | 276 +++++ tgbot/routers/user/user_transactions.py | 407 ++++++++ tgbot/services/__init__.py | 0 tgbot/services/api_cryptobot.py | 219 ++++ tgbot/services/api_discord.py | 239 +++++ tgbot/services/api_hosting_text.py | 157 +++ tgbot/services/api_session.py | 31 + tgbot/services/api_stars.py | 297 ++++++ tgbot/services/api_telegraph.py | 87 ++ tgbot/services/api_yoomoney.py | 339 ++++++ tgbot/utils/__init__.py | 0 tgbot/utils/const_functions.py | 388 +++++++ tgbot/utils/misc/__init__.py | 0 tgbot/utils/misc/bot_commands.py | 33 + tgbot/utils/misc/bot_filters.py | 67 ++ tgbot/utils/misc/bot_logging.py | 52 + tgbot/utils/misc/bot_models.py | 7 + tgbot/utils/misc_functions.py | 196 ++++ tgbot/utils/products_functions.py | 143 +++ tgbot/utils/text_functions.py | 438 ++++++++ 78 files changed, 10318 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 alembic.ini create mode 100644 dj_bot.conf create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 migrate.py create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/20260529_0001_shop_schema.py create mode 100644 migrations/versions/20260530_1915_492769059249_add_column_status_stars_into_storage_.py create mode 100644 migrations/versions/20260530_1928_700275128e27_add_column_stars_course_into_storage_.py create mode 100644 migrations/versions/20260601_1934_36380377b0e6_add_column_misc_method_prod_into_.py create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 tgbot/__init__.py create mode 100644 tgbot/data/__init__.py create mode 100644 tgbot/data/config.py create mode 100644 tgbot/database/__init__.py create mode 100644 tgbot/database/core.py create mode 100644 tgbot/database/db_category.py create mode 100644 tgbot/database/db_item.py create mode 100644 tgbot/database/db_payments.py create mode 100644 tgbot/database/db_position.py create mode 100644 tgbot/database/db_purchases.py create mode 100644 tgbot/database/db_refill.py create mode 100644 tgbot/database/db_settings.py create mode 100644 tgbot/database/db_users.py create mode 100644 tgbot/database/entities.py create mode 100644 tgbot/database/migration_runner.py create mode 100644 tgbot/database/repository.py create mode 100644 tgbot/keyboards/__init__.py create mode 100644 tgbot/keyboards/inline_admin.py create mode 100644 tgbot/keyboards/inline_admin_page.py create mode 100644 tgbot/keyboards/inline_admin_products.py create mode 100644 tgbot/keyboards/inline_helper.py create mode 100644 tgbot/keyboards/inline_user.py create mode 100644 tgbot/keyboards/inline_user_page.py create mode 100644 tgbot/keyboards/inline_user_products.py create mode 100644 tgbot/keyboards/reply_main.py create mode 100644 tgbot/middlewares/__init__.py create mode 100644 tgbot/middlewares/middleware_throttling.py create mode 100644 tgbot/middlewares/middleware_user.py create mode 100644 tgbot/routers/__init__.py create mode 100644 tgbot/routers/admin/__init__.py create mode 100644 tgbot/routers/admin/admin_functions.py create mode 100644 tgbot/routers/admin/admin_menu.py create mode 100644 tgbot/routers/admin/admin_payments.py create mode 100644 tgbot/routers/admin/admin_products.py create mode 100644 tgbot/routers/admin/admin_settings.py create mode 100644 tgbot/routers/main_errors.py create mode 100644 tgbot/routers/main_missed.py create mode 100644 tgbot/routers/main_start.py create mode 100644 tgbot/routers/user/__init__.py create mode 100644 tgbot/routers/user/user_menu.py create mode 100644 tgbot/routers/user/user_products.py create mode 100644 tgbot/routers/user/user_transactions.py create mode 100644 tgbot/services/__init__.py create mode 100644 tgbot/services/api_cryptobot.py create mode 100644 tgbot/services/api_discord.py create mode 100644 tgbot/services/api_hosting_text.py create mode 100644 tgbot/services/api_session.py create mode 100644 tgbot/services/api_stars.py create mode 100644 tgbot/services/api_telegraph.py create mode 100644 tgbot/services/api_yoomoney.py create mode 100644 tgbot/utils/__init__.py create mode 100644 tgbot/utils/const_functions.py create mode 100644 tgbot/utils/misc/__init__.py create mode 100644 tgbot/utils/misc/bot_commands.py create mode 100644 tgbot/utils/misc/bot_filters.py create mode 100644 tgbot/utils/misc/bot_logging.py create mode 100644 tgbot/utils/misc/bot_models.py create mode 100644 tgbot/utils/misc_functions.py create mode 100644 tgbot/utils/products_functions.py create mode 100644 tgbot/utils/text_functions.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5df56e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,35 @@ +.git +.gitignore +.DS_Store +.env +.env.* +!.env.example + +__pycache__/ +*.py[cod] +*$py.class + +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +.coverage.* +htmlcov/ + +.idea/ +.vscode/ +.venv/ +venv/ +ENV/ +env/ + +*.db +*.db-journal +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +*.log +tgbot/data/*.db* +tgbot/data/*.sqlite* +tgbot/data/*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..13d042e --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +BOT_TOKEN= +BOT_ADMIN_IDS= +BOT_DATABASE_EXPORT=True +BOT_STATUS_NOTIFICATION=True +BOT_TIMEZONE=Europe/Moscow +BOT_USER_CACHE_TTL=300 +BOT_THROTTLE_RATE=0.5 +PATH_DATABASE=tgbot/data/database.db +PATH_LOGS=tgbot/data/logs.log +YOOMONEY_CLIENT_ID= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..71761e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,217 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +.DS_Store + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# IPython Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# dotenv +.env + +# virtualenv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject +### VirtualEnv template +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +pip-selfcheck.json + +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +auto-import. +.idea/artifacts +.idea/compiler.xml +.idea/jarRepositories.xml +.idea/modules.xml +.idea/*.iml +.idea/modules +*.iml +*.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +# idea folder, uncomment if you don't need it +.idea + +# Users exceptions +/other/ +/tgbot/data/database +/tgbot/data/database.db +/tgbot/data/*.db +/tgbot/data/*.db-wal +/tgbot/data/*.db-shm +/tgbot/data/xdatabase.db +/tgbot/data/logs.log +/settings.ini +settings.ini +main.sh +database.* +database.db + +# Local secrets and runtime data +.env.* +!.env.example +.vscode/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +*.db +*.db-journal +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 +*.log +tgbot/data/*.db* +tgbot/data/*.sqlite* +tgbot/data/*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1847c07 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN useradd --create-home --shell /usr/sbin/nologin bot + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY --chown=bot:bot . . +RUN mkdir -p /app/tgbot/data && chown -R bot:bot /app + +USER bot + +CMD ["sh", "-c", "python migrate.py up && python main.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..35404fa --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Telegram Autoshop Bot by Djimbo + +Telegram bot на `aiogram 3` для магазина с категориями, позициями, товарами, покупками, пополнениями и админкой. + +## Быстрый запуск + +1. Установи Python `3.11+`. +2. Поставь зависимости: + +```bash +python3.11 -m pip install -r requirements.txt +``` + +3. Создай `.env` по примеру: + +```bash +cp .env.example .env +``` + +4. Заполни минимум: + +```env +BOT_TOKEN= +BOT_ADMIN_IDS= +YOOMONEY_CLIENT_ID= +``` + +Конфигурация читается только из `.env` или переменных окружения. + +5. Примени миграции: + +```bash +python3 migrate.py up +``` + +6. Запусти бота: + +```bash +python3 main.py +``` + +## Миграции + +Основные команды: + +```bash +python3 migrate.py status +python3 migrate.py up +python3 migrate.py down +python3 migrate.py auto "change description" +``` + +Схема БД теперь живет в `migrations/`, модели — в `tgbot/database/db_*.py`. + +## Docker + +```bash +docker compose up -d --build +``` + +Контейнер сам применяет миграции перед запуском. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..49fb9b5 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,42 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +path_separator = os +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s +timezone = Europe/Moscow + +sqlalchemy.url = sqlite+aiosqlite:///tgbot/data/database.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/dj_bot.conf b/dj_bot.conf new file mode 100644 index 0000000..19e317a --- /dev/null +++ b/dj_bot.conf @@ -0,0 +1,13 @@ +# supervisorctl +[program:dj_shop] +directory=/root/autoshopDjimbo/ +command=python3.11 -u main.py +environment=PYTHONUNBUFFERED="1" + +autostart=True +autorestart=True + +stderr_logfile=/root/autoshopDjimbo/tgbot/data/sv_log_err.log +stderr_logfile_maxbytes=50MB +stdout_logfile=/root/autoshopDjimbo/tgbot/data/sv_log_out.log +stdout_logfile_maxbytes=50MB diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a5a285a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,11 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + env_file: + - .env + volumes: + - ./tgbot/data:/app/tgbot/data + stop_grace_period: 30s diff --git a/main.py b/main.py new file mode 100644 index 0000000..d515c75 --- /dev/null +++ b/main.py @@ -0,0 +1,196 @@ +# - *- coding: utf- 8 - *- +import asyncio +import os +import sys + +import colorama +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.client.session.aiohttp import AiohttpSession +from aiogram.enums import ParseMode + +from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_SCHEDULER, BOT_TOKEN, get_admins, validate_bot_config +from tgbot.database.core import close_database +from tgbot.database.repository import prepare_database +from tgbot.middlewares import register_all_middlewares +from tgbot.routers import register_all_routers +from tgbot.services.api_session import AsyncRequestSession +from tgbot.utils.misc.bot_commands import set_commands +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.misc_functions import ( + autobackup_admin, + autosettings_unix, + check_bot_username, + check_mail, + check_update, + startup_notify, + update_profit_day, + update_profit_month, + update_profit_week, +) + + +# Включение мгновенного вывода консоли +def configure_console_output() -> None: + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(line_buffering=True, write_through=True) + + +configure_console_output() +colorama.init() + + +# Подключение шедулеров +async def scheduler_start(bot: Bot, arSession: ARS): + # Автоматический сброс ежемесячной статистики + BOT_SCHEDULER.add_job( + update_profit_month, + trigger="cron", + day=1, + hour=0, + minute=0, + second=5, + id="update_profit_month", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + # Автоматический сброс еженедельной статистики + BOT_SCHEDULER.add_job( + update_profit_week, + trigger="cron", + day_of_week="mon", + hour=0, + minute=0, + second=10, + id="update_profit_week", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + # Автоматический сброс ежедневной статистики + BOT_SCHEDULER.add_job( + update_profit_day, + trigger="cron", + hour=0, + minute=0, + second=15, + args=(bot,), + id="update_profit_day", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + # Автоматический бэкап БД (если включено) + if BOT_DATABASE_EXPORT: + BOT_SCHEDULER.add_job( + autobackup_admin, + trigger="cron", + hour=0, + args=(bot,), + id="autobackup_admin", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + # Ежедневная проверка обновлений + BOT_SCHEDULER.add_job( + check_update, + trigger="cron", + hour=0, + args=(bot, arSession), + id="check_update", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + # Ежедневная проверка важных пушей + BOT_SCHEDULER.add_job( + check_mail, + trigger="cron", + hour=12, + args=(bot, arSession), + id="check_mail", + replace_existing=True, + coalesce=True, + misfire_grace_time=60, + ) + + if not BOT_SCHEDULER.running: + BOT_SCHEDULER.start() + + +# Запуск миграций, роутеров и polling +async def main(): + validate_bot_config() + await prepare_database() + + dp = Dispatcher() + arSession = AsyncRequestSession() + + proxy = "" + session = AiohttpSession(proxy=proxy) if len(proxy) > 1 else AiohttpSession() + + bot = Bot( + token=BOT_TOKEN, + session=session, + default=DefaultBotProperties( + parse_mode=ParseMode.HTML + ), + ) + + register_all_middlewares(dp) + register_all_routers(dp) + + try: + await autosettings_unix() + await set_commands(bot) + await check_bot_username(bot) + await check_update(bot, arSession) + await check_mail(bot, arSession) + await startup_notify(bot, arSession) + await scheduler_start(bot, arSession) + + bot_info = await bot.get_me() + bot_logger.info("Бот запущен: @%s", bot_info.username) + print(colorama.Fore.LIGHTYELLOW_EX + f"~~~~~ Бот запущен - @{bot_info.username} ~~~~~") + print(colorama.Fore.LIGHTBLUE_EX + "~~~~~ TG developer - @djimbox ~~~~~") + print(colorama.Fore.RESET) + + if len(get_admins()) == 0: + print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****") + + await bot.delete_webhook() + await bot.get_updates(offset=-1) + + await dp.start_polling( + bot, + arSession=arSession, + allowed_updates=dp.resolve_used_update_types(), + ) + finally: + if BOT_SCHEDULER.running: + BOT_SCHEDULER.shutdown(wait=False) + + await arSession.close() + await bot.session.close() + await close_database() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + bot_logger.warning("Бот остановлен") + finally: + if sys.platform.startswith("win"): + os.system("cls") + else: + os.system("clear") diff --git a/migrate.py b/migrate.py new file mode 100644 index 0000000..fba8c8b --- /dev/null +++ b/migrate.py @@ -0,0 +1,188 @@ +# - *- coding: utf- 8 - *- +import argparse +from typing import Optional + +from alembic import command +from alembic.config import Config + +HELP_TEXT = """ +Миграции базы данных + +Безопасное правило: + python migrate.py только показывает эту справку + действия с БД выполняются только при явной команде + +Основные команды: + python migrate.py help показать эту справку + python migrate.py up применить все миграции до head + python migrate.py down откатить последнюю миграцию + python migrate.py new "add users" создать пустую миграцию + python migrate.py auto "add users" создать миграцию по SQLAlchemy-моделям + python migrate.py status показать текущую версию и последние версии + +Короткие алиасы: + up -> upgrade + down -> downgrade + new -> revision + auto -> revision --autogenerate + autogen -> auto + cur -> current + hist -> history + st -> status +""".strip() + + +# Сбор CLI-парсера миграций +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Удобный CLI для Alembic-миграций", + formatter_class=argparse.RawTextHelpFormatter, + ) + subparsers = parser.add_subparsers(dest="command") + + help_parser = subparsers.add_parser("help", aliases=["h"], help="Показать понятную справку") + help_parser.set_defaults(action=show_help) + + upgrade_parser = subparsers.add_parser("upgrade", aliases=["up"], help="Применить миграции") + upgrade_parser.add_argument("revision", nargs="?", default="head", help="Версия миграции, по умолчанию head") + upgrade_parser.set_defaults(action=run_upgrade) + + downgrade_parser = subparsers.add_parser("downgrade", aliases=["down"], help="Откатить миграции") + downgrade_parser.add_argument("revision", nargs="?", default="-1", help="Версия отката, по умолчанию -1") + downgrade_parser.set_defaults(action=run_downgrade) + + revision_parser = subparsers.add_parser("revision", aliases=["new"], help="Создать пустую миграцию") + revision_parser.add_argument("message", nargs="?", help="Название миграции") + revision_parser.add_argument("-m", "--message-option", dest="message_option", help="Название миграции") + revision_parser.add_argument("--autogenerate", "-a", action="store_true", help="Собрать изменения из моделей") + revision_parser.set_defaults(action=run_revision) + + auto_parser = subparsers.add_parser("auto", aliases=["autogen"], help="Создать миграцию по моделям") + auto_parser.add_argument("message", nargs="?", help="Название миграции") + auto_parser.add_argument("-m", "--message-option", dest="message_option", help="Название миграции") + auto_parser.set_defaults(action=run_auto_revision) + + current_parser = subparsers.add_parser("current", aliases=["cur"], help="Показать текущую версию БД") + current_parser.set_defaults(action=run_current) + + history_parser = subparsers.add_parser("history", aliases=["hist"], help="Показать историю миграций") + history_parser.set_defaults(action=run_history) + + heads_parser = subparsers.add_parser("heads", help="Показать последние версии веток") + heads_parser.set_defaults(action=run_heads) + + status_parser = subparsers.add_parser("status", aliases=["st"], help="Показать текущую версию и heads") + status_parser.set_defaults(action=run_status) + + return parser + + +# Просмотр справки по миграциям +def show_help(_config: Optional[Config], _args: argparse.Namespace) -> None: + print(HELP_TEXT) + + +# Применение миграций до версии +def run_upgrade(config: Config, args: argparse.Namespace) -> None: + print(f"Применяю миграции до версии: {args.revision}") + command.upgrade(config, args.revision) + print("Готово") + + +# Откат миграции до версии +def run_downgrade(config: Config, args: argparse.Namespace) -> None: + print(f"Откатываю миграции до версии: {args.revision}") + command.downgrade(config, args.revision) + print("Готово") + + +# Создание пустой миграции +def run_revision(config: Config, args: argparse.Namespace) -> None: + message = get_revision_message(args) + print(f"Создаю миграцию: {message}") + config.attributes["autogenerate_empty"] = False + command.revision(config, message=message, autogenerate=args.autogenerate) + if config.attributes.get("autogenerate_empty"): + print("Изменений в моделях не найдено, файл миграции не создан") + return + + print("Готово") + + +# Создание миграции по моделям +def run_auto_revision(config: Config, args: argparse.Namespace) -> None: + message = get_revision_message(args) + print(f"Создаю миграцию по моделям: {message}") + config.attributes["autogenerate_empty"] = False + command.revision(config, message=message, autogenerate=True) + if config.attributes.get("autogenerate_empty"): + print("Изменений в моделях не найдено, файл миграции не создан") + return + + print("Готово") + + +# Просмотр текущей версии БД +def run_current(config: Config, _args: argparse.Namespace) -> None: + print("Текущая версия БД:") + command.current(config) + + +# Просмотр истории миграций +def run_history(config: Config, _args: argparse.Namespace) -> None: + print("История миграций:") + command.history(config) + + +# Просмотр heads миграций +def run_heads(config: Config, _args: argparse.Namespace) -> None: + print("Последние версии миграций:") + command.heads(config) + + +# Просмотр текущей версии и heads +def run_status(config: Config, args: argparse.Namespace) -> None: + run_current(config, args) + print() + run_heads(config, args) + + +# Получение названия миграции +def get_revision_message(args: argparse.Namespace) -> str: + message: Optional[str] = args.message_option or args.message + + if not message: + raise SystemExit("Укажи название миграции. Пример: python migrate.py auto \"add users\"") + + return message + + +# Получение Alembic-конфига +def get_config() -> Config: + from tgbot.database.migration_runner import get_alembic_config + + return get_alembic_config() + + +# Запуск CLI миграций +def main() -> None: + parser = build_parser() + args = parser.parse_args() + + if args.command is None: + show_help(None, args) + return + + if args.action == show_help: + show_help(None, args) + return + + if args.action in (run_revision, run_auto_revision): + get_revision_message(args) + + config = get_config() + args.action(config, args) + + +if __name__ == "__main__": + main() diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..385350e --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,184 @@ +# - *- coding: utf- 8 - *- +import asyncio +from logging.config import fileConfig +from typing import Any, List, Optional + +from alembic import context +from alembic.operations import ops +from sqlalchemy import pool, text +from sqlalchemy.engine import Connection +from sqlalchemy.schema import DefaultClause +from sqlalchemy.ext.asyncio import async_engine_from_config + +from tgbot.database.core import Base + +import tgbot.database # noqa: F401 + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata +INDEX_DIFF_NOISE = {"ix_storage_payments_id", "ix_storage_settings_id"} + + +# Отбрасывание SQLite-шума из автогенерации +def _filter_autogenerated_ops(container: Any) -> None: + filtered_ops = [] + + for operation in container.ops: + if isinstance(operation, ops.AlterColumnOp): + continue + + if _is_index_noise(operation): + continue + + if isinstance(operation, ops.ModifyTableOps): + _filter_autogenerated_ops(operation) + + if operation.ops: + filtered_ops.append(operation) + + continue + + filtered_ops.append(operation) + + container.ops = filtered_ops + + +# Подготовка новых колонок к безопасному SQLite ADD COLUMN +def _prepare_sqlite_add_columns(container: Any) -> None: + for operation in container.ops: + if isinstance(operation, ops.AddColumnOp): + _apply_server_default(operation.column) + continue + + if isinstance(operation, ops.ModifyTableOps): + _prepare_sqlite_add_columns(operation) + + +# Перенос default модели в server_default миграции +def _apply_server_default(column: Any) -> None: + if column.nullable or column.server_default is not None or column.default is None: + return + + default_value = _get_simple_default(column.default.arg) + + if default_value is None: + return + + column.server_default = DefaultClause(text(default_value)) + + +# Получение SQL-литерала для простых default-значений +def _get_simple_default(value: Any) -> Optional[str]: + if callable(value): + return None + + if isinstance(value, bool): + return "1" if value else "0" + + if isinstance(value, (int, float)): + return str(value) + + if isinstance(value, str): + return repr(value) + + return None + + +# Игнорирование индексов, которые SQLite отражает иначе +def _is_index_noise(operation: Any) -> bool: + if not isinstance(operation, (ops.CreateIndexOp, ops.DropIndexOp)): + return False + + return getattr(operation, "index_name", "") in INDEX_DIFF_NOISE + + +# Ограничение сравнения SQLite только реальными добавлениями/удалениями +def _include_object(object_: Any, name: str, type_: str, reflected: bool, compare_to: Any) -> bool: + if type_ == "column" and compare_to is not None: + return False + + if type_ == "index" and name in INDEX_DIFF_NOISE: + return False + + return True + + +# Очистка автосозданной миграции перед записью +def _process_revision_directives(_context: Any, _revision: Any, directives: List[Any]) -> None: + if not directives: + return + + script = directives[0] + _prepare_sqlite_add_columns(script.upgrade_ops) + _filter_autogenerated_ops(script.upgrade_ops) + _filter_autogenerated_ops(script.downgrade_ops) + + if not script.upgrade_ops.ops and not script.downgrade_ops.ops: + config.attributes["autogenerate_empty"] = True + directives.clear() + + +# Запуск миграций без подключения +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + render_as_batch=True, + compare_type=False, + include_object=_include_object, + process_revision_directives=_process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + +# Запуск миграций на соединении +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + render_as_batch=True, + compare_type=False, + include_object=_include_object, + process_revision_directives=_process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + +# Запуск миграций через async-engine +async def run_async_migrations() -> None: + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + poolclass=pool.NullPool, + ) + connection = connectable.connect() + + try: + await connection.start() + await connection.run_sync(do_run_migrations) + finally: + await connection.close() + + await connectable.dispose() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + external_connection = config.attributes.get("connection") + + if external_connection is not None: + do_run_migrations(external_connection) + else: + asyncio.run(run_async_migrations()) diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..590f5b3 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/20260529_0001_shop_schema.py b/migrations/versions/20260529_0001_shop_schema.py new file mode 100644 index 0000000..73bbb69 --- /dev/null +++ b/migrations/versions/20260529_0001_shop_schema.py @@ -0,0 +1,515 @@ +"""Начальная схема Телеграм-магазина + +Revision ID: 0001_shop_schema +Revises: +Create Date: 2026-05-29 00:00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +revision: str = "0001_shop_schema" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +# Проверка наличия таблицы +def _table_exists(table_name: str) -> bool: + bind = op.get_bind() + result = bind.execute( + sa.text( + """ + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name = :table_name + """ + ), + {"table_name": table_name}, + ) + + return result.scalar_one_or_none() is not None + + +# Проверка наличия колонки +def _column_exists(table_name: str, column_name: str) -> bool: + bind = op.get_bind() + result = bind.execute(sa.text(f"PRAGMA table_info({table_name})")) + + return column_name in [row.name for row in result] + + +# Добавление колонки, если ее нет +def _add_column(table_name: str, column_name: str, column_sql: str) -> None: + if not _column_exists(table_name, column_name): + op.execute(sa.text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_sql}")) + + +# Создание индекса, если его нет +def _create_index(index_name: str, table_name: str, columns: str, *, unique: bool = False) -> None: + unique_sql = "UNIQUE " if unique else "" + op.execute(sa.text(f"CREATE {unique_sql}INDEX IF NOT EXISTS {index_name} ON {table_name} ({columns})")) + + +# Нормализация текстовых bool-значений +def _normalize_boolean_text(table_name: str, column_name: str, default_value: str) -> None: + bind = op.get_bind() + bind.execute( + sa.text( + f""" + UPDATE {table_name} + SET {column_name} = CASE LOWER(CAST(COALESCE({column_name}, :default_value) AS TEXT)) + WHEN '1' THEN 'True' + WHEN 'true' THEN 'True' + WHEN 'yes' THEN 'True' + WHEN 'on' THEN 'True' + WHEN 'да' THEN 'True' + WHEN '0' THEN 'False' + WHEN 'false' THEN 'False' + WHEN 'no' THEN 'False' + WHEN 'off' THEN 'False' + WHEN 'нет' THEN 'False' + ELSE :default_value + END + """ + ), + {"default_value": default_value}, + ) + + +# Сбор SQL для переноса текстовой колонки +def _copy_expr(table_name: str, column_name: str, default_value: str) -> str: + if _column_exists(table_name, column_name): + return f"COALESCE({column_name}, '{default_value}')" + + return f"'{default_value}'" + + +# Сбор SQL для переноса числовой колонки +def _copy_int_expr(table_name: str, column_name: str, default_value: int = 0) -> str: + if _column_exists(table_name, column_name): + return f"COALESCE({column_name}, {default_value})" + + return str(default_value) + + +# Создание или правка таблицы пользователей +def _ensure_users() -> None: + bind = op.get_bind() + + if not _table_exists("storage_users"): + op.create_table( + "storage_users", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("user_login", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_name", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_surname", sa.String(length=255), nullable=False, server_default=""), + sa.Column("user_fullname", sa.String(length=511), nullable=False, server_default=""), + sa.Column("user_balance", sa.Float(), nullable=False, server_default="0"), + sa.Column("user_refill", sa.Float(), nullable=False, server_default="0"), + sa.Column("user_give", sa.Float(), nullable=False, server_default="0"), + sa.Column("user_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_users", "user_login", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_users", "user_name", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_users", "user_surname", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_users", "user_fullname", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_users", "user_balance", "REAL NOT NULL DEFAULT 0") + _add_column("storage_users", "user_refill", "REAL NOT NULL DEFAULT 0") + _add_column("storage_users", "user_give", "REAL NOT NULL DEFAULT 0") + _add_column("storage_users", "user_unix", "INTEGER NOT NULL DEFAULT 0") + bind.execute(sa.text("UPDATE storage_users SET user_login = COALESCE(user_login, '')")) + bind.execute(sa.text("UPDATE storage_users SET user_name = COALESCE(user_name, '')")) + bind.execute(sa.text("UPDATE storage_users SET user_surname = COALESCE(user_surname, '')")) + bind.execute( + sa.text( + """ + UPDATE storage_users + SET user_fullname = CASE + WHEN user_fullname IS NULL OR user_fullname = '' THEN TRIM(user_name || ' ' || user_surname) + ELSE user_fullname + END + """ + ) + ) + bind.execute(sa.text("UPDATE storage_users SET user_balance = COALESCE(user_balance, 0)")) + bind.execute(sa.text("UPDATE storage_users SET user_refill = COALESCE(user_refill, 0)")) + bind.execute(sa.text("UPDATE storage_users SET user_give = COALESCE(user_give, 0)")) + bind.execute(sa.text("UPDATE storage_users SET user_unix = COALESCE(user_unix, 0)")) + bind.execute(sa.text("DELETE FROM storage_users WHERE user_id IS NULL")) + bind.execute( + sa.text( + """ + DELETE + FROM storage_users + WHERE user_id IS NOT NULL + AND rowid NOT IN ( + SELECT MAX(rowid) + FROM storage_users + WHERE user_id IS NOT NULL + GROUP BY user_id + ) + """ + ) + ) + + _create_index("ix_storage_users_user_id", "storage_users", "user_id", unique=True) + + +# Создание или правка таблицы настроек +def _ensure_settings() -> None: + bind = op.get_bind() + + if not _table_exists("storage_settings"): + op.create_table( + "storage_settings", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("status_work", sa.String(length=16), nullable=False, server_default="True"), + sa.Column("status_refill", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("status_buy", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("notification_refill", sa.String(length=16), nullable=False, server_default="True"), + sa.Column("notification_buy", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_faq", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_support", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_bot", sa.String(length=255), nullable=False, server_default="None"), + sa.Column("misc_hosting_text", sa.String(length=64), nullable=False, server_default="telegraph"), + sa.Column("misc_token_telegraph", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_discord_webhook_url", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_discord_webhook_name", sa.String(length=255), nullable=False, server_default="None"), + sa.Column("misc_hide_category", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_hide_position", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_profit_day", sa.Integer(), nullable=False, server_default="0"), + sa.Column("misc_profit_week", sa.Integer(), nullable=False, server_default="0"), + sa.Column("misc_profit_month", sa.Integer(), nullable=False, server_default="0"), + sa.PrimaryKeyConstraint("id"), + ) + else: + bind.execute(sa.text("DROP TABLE IF EXISTS storage_settings_new")) + op.create_table( + "storage_settings_new", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("status_work", sa.String(length=16), nullable=False, server_default="True"), + sa.Column("status_refill", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("status_buy", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("notification_refill", sa.String(length=16), nullable=False, server_default="True"), + sa.Column("notification_buy", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_faq", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_support", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_bot", sa.String(length=255), nullable=False, server_default="None"), + sa.Column("misc_hosting_text", sa.String(length=64), nullable=False, server_default="telegraph"), + sa.Column("misc_token_telegraph", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_discord_webhook_url", sa.String(), nullable=False, server_default="None"), + sa.Column("misc_discord_webhook_name", sa.String(length=255), nullable=False, server_default="None"), + sa.Column("misc_hide_category", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_hide_position", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("misc_profit_day", sa.Integer(), nullable=False, server_default="0"), + sa.Column("misc_profit_week", sa.Integer(), nullable=False, server_default="0"), + sa.Column("misc_profit_month", sa.Integer(), nullable=False, server_default="0"), + sa.PrimaryKeyConstraint("id"), + ) + bind.execute( + sa.text( + f""" + INSERT INTO storage_settings_new ( + id, + status_work, + status_refill, + status_buy, + notification_refill, + notification_buy, + misc_faq, + misc_support, + misc_bot, + misc_hosting_text, + misc_token_telegraph, + misc_discord_webhook_url, + misc_discord_webhook_name, + misc_hide_category, + misc_hide_position, + misc_profit_day, + misc_profit_week, + misc_profit_month + ) + SELECT + 1, + {_copy_expr("storage_settings", "status_work", "True")}, + {_copy_expr("storage_settings", "status_refill", "False")}, + {_copy_expr("storage_settings", "status_buy", "False")}, + {_copy_expr("storage_settings", "notification_refill", "True")}, + {_copy_expr("storage_settings", "notification_buy", "False")}, + {_copy_expr("storage_settings", "misc_faq", "None")}, + {_copy_expr("storage_settings", "misc_support", "None")}, + {_copy_expr("storage_settings", "misc_bot", "None")}, + {_copy_expr("storage_settings", "misc_hosting_text", "telegraph")}, + {_copy_expr("storage_settings", "misc_token_telegraph", "None")}, + {_copy_expr("storage_settings", "misc_discord_webhook_url", "None")}, + {_copy_expr("storage_settings", "misc_discord_webhook_name", "None")}, + {_copy_expr("storage_settings", "misc_hide_category", "False")}, + {_copy_expr("storage_settings", "misc_hide_position", "False")}, + {_copy_int_expr("storage_settings", "misc_profit_day")}, + {_copy_int_expr("storage_settings", "misc_profit_week")}, + {_copy_int_expr("storage_settings", "misc_profit_month")} + FROM storage_settings + LIMIT 1 + """ + ) + ) + bind.execute(sa.text("DROP TABLE storage_settings")) + bind.execute(sa.text("ALTER TABLE storage_settings_new RENAME TO storage_settings")) + + bind.execute( + sa.text( + """ + INSERT INTO storage_settings (id) + SELECT 1 + WHERE NOT EXISTS (SELECT 1 FROM storage_settings WHERE id = 1) + """ + ) + ) + for column, default in ( + ("status_work", "True"), + ("status_refill", "False"), + ("status_buy", "False"), + ("notification_refill", "True"), + ("notification_buy", "False"), + ("misc_hide_category", "False"), + ("misc_hide_position", "False"), + ): + _normalize_boolean_text("storage_settings", column, default) + + _create_index("ix_storage_settings_id", "storage_settings", "id", unique=True) + + +# Создание или правка таблицы платежей +def _ensure_payments() -> None: + bind = op.get_bind() + + if not _table_exists("storage_payments"): + op.create_table( + "storage_payments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("cryptobot_token", sa.String(), nullable=False, server_default="None"), + sa.Column("yoomoney_token", sa.String(), nullable=False, server_default="None"), + sa.Column("status_cryptobot", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("status_yoomoney", sa.String(length=16), nullable=False, server_default="False"), + sa.PrimaryKeyConstraint("id"), + ) + else: + bind.execute(sa.text("DROP TABLE IF EXISTS storage_payments_new")) + op.create_table( + "storage_payments_new", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("cryptobot_token", sa.String(), nullable=False, server_default="None"), + sa.Column("yoomoney_token", sa.String(), nullable=False, server_default="None"), + sa.Column("status_cryptobot", sa.String(length=16), nullable=False, server_default="False"), + sa.Column("status_yoomoney", sa.String(length=16), nullable=False, server_default="False"), + sa.PrimaryKeyConstraint("id"), + ) + bind.execute( + sa.text( + f""" + INSERT INTO storage_payments_new ( + id, + cryptobot_token, + yoomoney_token, + status_cryptobot, + status_yoomoney + ) + SELECT + 1, + {_copy_expr("storage_payments", "cryptobot_token", "None")}, + {_copy_expr("storage_payments", "yoomoney_token", "None")}, + {_copy_expr("storage_payments", "status_cryptobot", "False")}, + {_copy_expr("storage_payments", "status_yoomoney", "False")} + FROM storage_payments + LIMIT 1 + """ + ) + ) + bind.execute(sa.text("DROP TABLE storage_payments")) + bind.execute(sa.text("ALTER TABLE storage_payments_new RENAME TO storage_payments")) + + bind.execute( + sa.text( + """ + INSERT INTO storage_payments (id) + SELECT 1 + WHERE NOT EXISTS (SELECT 1 FROM storage_payments WHERE id = 1) + """ + ) + ) + _normalize_boolean_text("storage_payments", "status_cryptobot", "False") + _normalize_boolean_text("storage_payments", "status_yoomoney", "False") + _create_index("ix_storage_payments_id", "storage_payments", "id", unique=True) + + +# Создание или правка таблицы категорий +def _ensure_category() -> None: + if not _table_exists("storage_category"): + op.create_table( + "storage_category", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("category_id", sa.BigInteger(), nullable=False), + sa.Column("category_name", sa.String(length=255), nullable=False), + sa.Column("category_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_category", "category_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_category", "category_name", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_category", "category_unix", "INTEGER NOT NULL DEFAULT 0") + + _create_index("ix_storage_category_category_id", "storage_category", "category_id") + + +# Создание или правка таблицы позиций +def _ensure_position() -> None: + if not _table_exists("storage_position"): + op.create_table( + "storage_position", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("category_id", sa.BigInteger(), nullable=False), + sa.Column("position_id", sa.BigInteger(), nullable=False), + sa.Column("position_name", sa.String(length=255), nullable=False), + sa.Column("position_price", sa.Float(), nullable=False, server_default="0"), + sa.Column("position_desc", sa.Text(), nullable=False, server_default="None"), + sa.Column("position_photo", sa.Text(), nullable=False, server_default="None"), + sa.Column("position_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_position", "category_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_position", "position_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_position", "position_name", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_position", "position_price", "REAL NOT NULL DEFAULT 0") + _add_column("storage_position", "position_desc", "TEXT NOT NULL DEFAULT 'None'") + _add_column("storage_position", "position_photo", "TEXT NOT NULL DEFAULT 'None'") + _add_column("storage_position", "position_unix", "INTEGER NOT NULL DEFAULT 0") + + _create_index("ix_storage_position_category_id", "storage_position", "category_id") + _create_index("ix_storage_position_position_id", "storage_position", "position_id") + + +# Создание или правка таблицы товаров +def _ensure_item() -> None: + if not _table_exists("storage_item"): + op.create_table( + "storage_item", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("category_id", sa.BigInteger(), nullable=False), + sa.Column("position_id", sa.BigInteger(), nullable=False), + sa.Column("item_id", sa.BigInteger(), nullable=False), + sa.Column("item_unix", sa.Integer(), nullable=False), + sa.Column("item_data", sa.Text(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_item", "user_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_item", "category_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_item", "position_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_item", "item_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_item", "item_unix", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_item", "item_data", "TEXT NOT NULL DEFAULT ''") + + _create_index("ix_storage_item_user_id", "storage_item", "user_id") + _create_index("ix_storage_item_category_id", "storage_item", "category_id") + _create_index("ix_storage_item_position_id", "storage_item", "position_id") + _create_index("ix_storage_item_item_id", "storage_item", "item_id") + + +# Создание или правка таблицы покупок +def _ensure_purchases() -> None: + if not _table_exists("storage_purchases"): + op.create_table( + "storage_purchases", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("user_balance_before", sa.Float(), nullable=False, server_default="0"), + sa.Column("user_balance_after", sa.Float(), nullable=False, server_default="0"), + sa.Column("purchase_receipt", sa.BigInteger(), nullable=False), + sa.Column("purchase_data", sa.Text(), nullable=False), + sa.Column("purchase_count", sa.Integer(), nullable=False, server_default="1"), + sa.Column("purchase_price", sa.Float(), nullable=False, server_default="0"), + sa.Column("purchase_price_one", sa.Float(), nullable=False, server_default="0"), + sa.Column("purchase_position_id", sa.BigInteger(), nullable=False), + sa.Column("purchase_position_name", sa.String(length=255), nullable=False), + sa.Column("purchase_category_id", sa.BigInteger(), nullable=False), + sa.Column("purchase_category_name", sa.String(length=255), nullable=False), + sa.Column("purchase_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_purchases", "user_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_purchases", "user_balance_before", "REAL NOT NULL DEFAULT 0") + _add_column("storage_purchases", "user_balance_after", "REAL NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_receipt", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_data", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_purchases", "purchase_count", "INTEGER NOT NULL DEFAULT 1") + _add_column("storage_purchases", "purchase_price", "REAL NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_price_one", "REAL NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_position_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_position_name", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_purchases", "purchase_category_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_purchases", "purchase_category_name", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_purchases", "purchase_unix", "INTEGER NOT NULL DEFAULT 0") + + _create_index("ix_storage_purchases_user_id", "storage_purchases", "user_id") + _create_index("ix_storage_purchases_purchase_receipt", "storage_purchases", "purchase_receipt") + _create_index("ix_storage_purchases_purchase_position_id", "storage_purchases", "purchase_position_id") + _create_index("ix_storage_purchases_purchase_category_id", "storage_purchases", "purchase_category_id") + + +# Создание или правка таблицы пополнений +def _ensure_refill() -> None: + if not _table_exists("storage_refill"): + op.create_table( + "storage_refill", + sa.Column("increment", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("refill_receipt", sa.BigInteger(), nullable=False), + sa.Column("refill_comment", sa.String(length=255), nullable=False, server_default=""), + sa.Column("refill_amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("refill_method", sa.String(length=64), nullable=False), + sa.Column("refill_unix", sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint("increment"), + ) + else: + _add_column("storage_refill", "user_id", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_refill", "refill_receipt", "INTEGER NOT NULL DEFAULT 0") + _add_column("storage_refill", "refill_comment", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_refill", "refill_amount", "REAL NOT NULL DEFAULT 0") + _add_column("storage_refill", "refill_method", "TEXT NOT NULL DEFAULT ''") + _add_column("storage_refill", "refill_unix", "INTEGER NOT NULL DEFAULT 0") + + _create_index("ix_storage_refill_user_id", "storage_refill", "user_id") + _create_index("ix_storage_refill_refill_receipt", "storage_refill", "refill_receipt") + + +# Применение начальной схемы магазина +def upgrade() -> None: + _ensure_users() + _ensure_settings() + _ensure_payments() + _ensure_category() + _ensure_position() + _ensure_item() + _ensure_purchases() + _ensure_refill() + + +# Откат начальной схемы магазина +def downgrade() -> None: + op.drop_table("storage_refill") + op.drop_table("storage_purchases") + op.drop_table("storage_item") + op.drop_table("storage_position") + op.drop_table("storage_category") + op.drop_table("storage_payments") + op.drop_table("storage_settings") + op.drop_table("storage_users") diff --git a/migrations/versions/20260530_1915_492769059249_add_column_status_stars_into_storage_.py b/migrations/versions/20260530_1915_492769059249_add_column_status_stars_into_storage_.py new file mode 100644 index 0000000..5e543cc --- /dev/null +++ b/migrations/versions/20260530_1915_492769059249_add_column_status_stars_into_storage_.py @@ -0,0 +1,32 @@ +"""add column 'status_stars' into storage_payments + +Revision ID: 492769059249 +Revises: 0001_shop_schema +Create Date: 2026-05-30 19:15:00.214301+03:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '492769059249' +down_revision: Union[str, None] = '0001_shop_schema' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('status_stars', sa.String(length=16), server_default=sa.text("'False'"), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_payments', schema=None) as batch_op: + batch_op.drop_column('status_stars') + + # ### end Alembic commands ### diff --git a/migrations/versions/20260530_1928_700275128e27_add_column_stars_course_into_storage_.py b/migrations/versions/20260530_1928_700275128e27_add_column_stars_course_into_storage_.py new file mode 100644 index 0000000..ea3f605 --- /dev/null +++ b/migrations/versions/20260530_1928_700275128e27_add_column_stars_course_into_storage_.py @@ -0,0 +1,32 @@ +"""add column 'stars_course' into storage_payment + +Revision ID: 700275128e27 +Revises: 492769059249 +Create Date: 2026-05-30 19:28:42.490458+03:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '700275128e27' +down_revision: Union[str, None] = '492769059249' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_payments', schema=None) as batch_op: + batch_op.add_column(sa.Column('stars_course', sa.Float(), server_default=sa.text('(1.5)'), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_payments', schema=None) as batch_op: + batch_op.drop_column('stars_course') + + # ### end Alembic commands ### diff --git a/migrations/versions/20260601_1934_36380377b0e6_add_column_misc_method_prod_into_.py b/migrations/versions/20260601_1934_36380377b0e6_add_column_misc_method_prod_into_.py new file mode 100644 index 0000000..5d36be1 --- /dev/null +++ b/migrations/versions/20260601_1934_36380377b0e6_add_column_misc_method_prod_into_.py @@ -0,0 +1,32 @@ +"""add column 'misc_method_prod' into 'storage_settings' + +Revision ID: 36380377b0e6 +Revises: 700275128e27 +Create Date: 2026-06-01 19:34:35.858117+03:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '36380377b0e6' +down_revision: Union[str, None] = '700275128e27' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_settings', schema=None) as batch_op: + batch_op.add_column(sa.Column('misc_method_prod', sa.String(length=16), server_default=sa.text("'skip'"), nullable=False)) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('storage_settings', schema=None) as batch_op: + batch_op.drop_column('misc_method_prod') + + # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..69c96fa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "djimbo-autoshop" +version = "0.1.0" +description = "Telegram autoshop bot on aiogram 3 by Djimbo" +requires-python = ">=3.11" +dependencies = [ + "APScheduler>=3.11.2,<4.0", + "aiogram>=3.28,<4.0", + "aiofiles>=25.1,<26.0", + "aiohttp>=3.13.5,<4.0", + "aiosqlite>=0.20,<1.0", + "alembic>=1.13,<2.0", + "cachetools>=7.1,<8.0", + "certifi>=2025.8,<2027.0", + "colorama>=0.4,<1.0", + "colorlog>=6.10,<7.0", + "pydantic>=2.13,<3.0", + "pydantic-settings>=2.6,<3.0", + "python-dotenv>=1.2,<2.0", + "pytz>=2026.2,<2027.0", + "SQLAlchemy>=2.0,<3.0", + "ujson>=5.12,<6.0", +] + +[tool.setuptools.packages.find] +include = ["tgbot*"] +exclude = ["migrations*"] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2ffa36b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,16 @@ +APScheduler==3.11.2 +aiogram==3.28.2 +aiofiles==25.1.0 +aiohttp==3.13.5 +aiosqlite==0.22.1 +alembic==1.18.4 +cachetools==7.1.4 +certifi==2026.5.20 +colorama==0.4.6 +colorlog==6.10.1 +pydantic==2.13.4 +pydantic-settings==2.14.1 +python-dotenv==1.2.2 +pytz==2026.2 +SQLAlchemy==2.0.50 +ujson==5.12.1 diff --git a/tgbot/__init__.py b/tgbot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/data/__init__.py b/tgbot/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/data/config.py b/tgbot/data/config.py new file mode 100644 index 0000000..c8ec410 --- /dev/null +++ b/tgbot/data/config.py @@ -0,0 +1,168 @@ +# - *- coding: utf- 8 - *- +from functools import lru_cache +from pathlib import Path +from typing import List + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict +from pytz import UnknownTimeZoneError, timezone + +BASE_DIR = Path(__file__).resolve().parents[2] +ENV_PATH = BASE_DIR / ".env" + + +# Все настройки из окружения и локального .env +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=ENV_PATH, + env_file_encoding="utf-8", + extra="ignore", + populate_by_name=True, + enable_decoding=False, + ) + + bot_token: str = Field(default="", validation_alias="BOT_TOKEN") + admin_ids: List[int] = Field(default_factory=list, validation_alias="BOT_ADMIN_IDS") + database_export: bool = Field( + default=True, + validation_alias="BOT_DATABASE_EXPORT", + ) + status_notification: bool = Field(default=True, validation_alias="BOT_STATUS_NOTIFICATION") + timezone: str = Field(default="Europe/Moscow", validation_alias="BOT_TIMEZONE") + database_path: str = Field(default="tgbot/data/database.db", validation_alias="PATH_DATABASE") + logs_path: str = Field(default="tgbot/data/logs.log", validation_alias="PATH_LOGS") + user_cache_ttl: int = Field(default=300, ge=0, validation_alias="BOT_USER_CACHE_TTL") + throttle_rate: float = Field(default=0.5, ge=0, validation_alias="BOT_THROTTLE_RATE") + yoomoney_client_id: str = Field(default="", validation_alias="YOOMONEY_CLIENT_ID") + + # Очистка строковых значений из окружения + @field_validator("bot_token", "timezone", "database_path", "logs_path", "yoomoney_client_id", mode="before") + @classmethod + def _strip_string(cls, value: object) -> str: + return str(value or "").strip() + + # Разбор списка телеграм ID админов + @field_validator("admin_ids", mode="before") + @classmethod + def _parse_admin_ids(cls, value: object) -> List[int]: + if value is None or value == "": + return [] + + if isinstance(value, int): + values = [value] + elif isinstance(value, str): + values = [admin_id for admin_id in value.replace(" ", "").split(",") if admin_id] + elif isinstance(value, (list, tuple, set)): + values = list(value) + else: + raise ValueError("BOT_ADMIN_IDS должен быть числом или списком чисел через запятую") + + admin_ids = [] + + for admin_id in values: + try: + parsed_id = int(admin_id) + except (TypeError, ValueError) as error: + raise ValueError("BOT_ADMIN_IDS должен содержать только Телеграм ID через запятую") from error + + if parsed_id <= 0: + raise ValueError("BOT_ADMIN_IDS должен содержать Телеграм ID больше нуля") + + admin_ids.append(parsed_id) + + return admin_ids + + # Проверка корректности timezone + @field_validator("timezone") + @classmethod + def _validate_timezone(cls, value: str) -> str: + try: + timezone(value) + except UnknownTimeZoneError as error: + raise ValueError("BOT_TIMEZONE должен быть корректной временной зоной, например Europe/Moscow") from error + + return value + + # Проверка непустых путей файлов + @field_validator("database_path", "logs_path") + @classmethod + def _validate_path(cls, value: str) -> str: + if not value: + raise ValueError("Путь к файлу не должен быть пустым") + + return value + + # Получение копии списка админов + @property + def admins(self) -> List[int]: + return list(self.admin_ids) + + # Преобразование относительного пути в абсолютный + @staticmethod + def resolve_path(path_value: str) -> Path: + path = Path(path_value) + + if path.is_absolute(): + return path + + return BASE_DIR / path + + +# Кэширование настроек +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +settings = get_settings() + +# Константы для старых импортов +BOT_TOKEN = settings.bot_token.replace(" ", "") +PATH_DATABASE = str(Settings.resolve_path(settings.database_path)) +PATH_LOGS = str(Settings.resolve_path(settings.logs_path)) +BOT_STATUS_NOTIFICATION = settings.status_notification +BOT_DATABASE_EXPORT = settings.database_export +BOT_TIMEZONE = settings.timezone +BOT_USER_CACHE_TTL = settings.user_cache_ttl +BOT_THROTTLE_RATE = settings.throttle_rate +YOOMONEY_CLIENT_ID = settings.yoomoney_client_id +BOT_SCHEDULER = AsyncIOScheduler(timezone=BOT_TIMEZONE) +BOT_VERSION = 4.2 + + +# Получение администраторов бота +def get_admins() -> List[int]: + return settings.admins + + +# Проверка настроек, которые нужны именно для запуска бота +def validate_bot_config() -> None: + if not BOT_TOKEN: + raise RuntimeError("В .env не заполнен BOT_TOKEN") + + +# Получение описания +def get_text_desc() -> str: + from tgbot.utils.const_functions import ded + + return ded(f""" + ♻️ Версия бота: {BOT_VERSION} + 👑 Разработчик бота - @djimbox + 🍩 Донат разработчику: Click me + 🤖 Новости и обновления: Click me + 🔗 Тема с ботом [LOLZ]: Click me + """).strip() + + +# Получение варнинг-текста +def get_text_warning() -> str: + from tgbot.utils.const_functions import ded + + return ded(f""" + ❗️ Если вы видите данное сообщение, значит Администратор бота не изменил текст установленный + по умолчанию. Скорее всего данный бот занимается скамом, обманом или другими схожими действиями. + + ❗️ Если вас обманули, не надо писать РАЗРАБОТЧИКУ бота, он ничем не сможет помочь. Бот является + открытым и распространяется публично. Абсолютно любой желающий мог его запустить. + """) diff --git a/tgbot/database/__init__.py b/tgbot/database/__init__.py new file mode 100644 index 0000000..da6dde5 --- /dev/null +++ b/tgbot/database/__init__.py @@ -0,0 +1,20 @@ +from .db_category import CategoryModel, Categoryx +from .db_item import ItemModel, Itemx +from .db_payments import PaymentsModel, Paymentsx +from .db_position import PositionModel, Positionx +from .db_purchases import PurchaseModel, Purchasesx +from .db_refill import RefillModel, Refillx +from .db_settings import SettingsModel, Settingsx +from .db_users import UserModel, UsersRepository, Userx +from .entities import Category, Item, Payments, Position, Purchase, Refill, Settings, User + +ModelCategory = Category +ModelItem = Item +ModelPayments = Payments +ModelPosition = Position +ModelPurchases = Purchase +ModelRefill = Refill +ModelSettings = Settings +ModelUser = User +ModelUsers = User +SettingsRepository = Settingsx diff --git a/tgbot/database/core.py b/tgbot/database/core.py new file mode 100644 index 0000000..33f6fc4 --- /dev/null +++ b/tgbot/database/core.py @@ -0,0 +1,47 @@ +# - *- coding: utf- 8 - *- +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +from sqlalchemy import event +from sqlalchemy.ext.asyncio import AsyncAttrs, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from tgbot.data.config import PATH_DATABASE + +database_path = Path(PATH_DATABASE) +database_url = f"sqlite+aiosqlite:///{database_path.as_posix()}" + +engine = create_async_engine(database_url, echo=False) +session_factory = async_sessionmaker(engine, expire_on_commit=False) + + +# Общая база для всех SQLAlchemy-моделей +class Base(AsyncAttrs, DeclarativeBase): + pass + + +# Включение SQLite-настроек на каждом соединении +@event.listens_for(engine.sync_engine, "connect") +def _configure_sqlite(dbapi_connection, connection_record) -> None: + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.execute("PRAGMA busy_timeout=5000") + cursor.close() + + +# Открытие сессии с автокоммитом или откатом +@asynccontextmanager +async def session_scope() -> AsyncIterator[AsyncSession]: + async with session_factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +# Закрытие пула соединений БД +async def close_database() -> None: + await engine.dispose() diff --git a/tgbot/database/db_category.py b/tgbot/database/db_category.py new file mode 100644 index 0000000..92bd699 --- /dev/null +++ b/tgbot/database/db_category.py @@ -0,0 +1,47 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional, Union + +from sqlalchemy import BigInteger, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base +from tgbot.database.entities import Category +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +class CategoryModel(Base): + __tablename__ = "storage_category" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + category_name: Mapped[str] = mapped_column(String(255), nullable=False) + category_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = Category +BaseModel = Category + + +class Categoryx(BaseRepository[CategoryModel, Category]): + # Подключение модели категорий + def __init__(self): + super().__init__() + self.table_model = CategoryModel + self.entity_model = Category + self.storage_name = CategoryModel.__tablename__ + + # Добавление категории товара + async def add(self, category_id: int, category_name: str) -> Category: + return await self._insert( + category_id=category_id, + category_name=category_name, + category_unix=get_unix(), + ) + + # Обновление категории по ID или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"category_id": where} + + return await self._update(where=where, **kwargs) diff --git a/tgbot/database/db_item.py b/tgbot/database/db_item.py new file mode 100644 index 0000000..d51de83 --- /dev/null +++ b/tgbot/database/db_item.py @@ -0,0 +1,73 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import BigInteger, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.entities import Item +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import clear_list, gen_id, get_unix + + +class ItemModel(Base): + __tablename__ = "storage_item" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + item_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + item_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + item_data: Mapped[str] = mapped_column(Text, nullable=False) + + +ModelBase = Item +BaseModel = Item + + +class Itemx(BaseRepository[ItemModel, Item]): + # Подключение модели товаров + def __init__(self): + super().__init__() + self.table_model = ItemModel + self.entity_model = Item + self.storage_name = ItemModel.__tablename__ + + # Добавление товаров пачкой + async def add( + self, + user_id: int, + category_id: int, + position_id: int, + item_datas: List[str], + ) -> Optional[List[Item]]: + item_unix = get_unix() + item_datas = clear_list(item_datas) + + if len(item_datas) == 0: + return None + + rows = [ + ItemModel( + user_id=user_id, + category_id=category_id, + position_id=position_id, + item_id=gen_id(17), + item_unix=item_unix, + item_data=item_data.strip(), + ) + for item_data in item_datas + ] + + async with session_scope() as session: + session.add_all(rows) + + return [self._to_entity(row) for row in rows] + + # Обновление товара по ID или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"item_id": where} + + return await self._update(where=where, **kwargs) \ No newline at end of file diff --git a/tgbot/database/db_payments.py b/tgbot/database/db_payments.py new file mode 100644 index 0000000..e5e0589 --- /dev/null +++ b/tgbot/database/db_payments.py @@ -0,0 +1,64 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional + +from sqlalchemy import Integer, String, Float +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base +from tgbot.database.entities import Payments +from tgbot.database.repository import BaseRepository + + +class PaymentsModel(Base): + __tablename__ = "storage_payments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + cryptobot_token: Mapped[str] = mapped_column(String, nullable=False, default="None") + yoomoney_token: Mapped[str] = mapped_column(String, nullable=False, default="None") + stars_course: Mapped[float] = mapped_column(Float, nullable=False, default=1.5) + status_cryptobot: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + status_yoomoney: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + status_stars: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + + +ModelBase = Payments +BaseModel = Payments + + +class Paymentsx(BaseRepository[PaymentsModel, Payments]): + # Подключение модели платежных настроек + def __init__(self): + super().__init__() + self.table_model = PaymentsModel + self.entity_model = Payments + self.storage_name = PaymentsModel.__tablename__ + + # Создание дефолтной строки платежей + async def ensure_default(self) -> None: + payments = await BaseRepository.get(self, id=1) + + if payments is None: + await self._insert(id=1) + + # Получение платежных настроек + async def get(self, **kwargs) -> Payments: + if not kwargs: + kwargs = {"id": 1} + + payments = await BaseRepository.get(self, **kwargs) + + if payments is None and kwargs == {"id": 1}: + await self.ensure_default() + payments = await BaseRepository.get(self, id=1) + + if payments is None: + raise RuntimeError("Настройки платежных систем по умолчанию не сохранились") + + return payments + + # Обновление платежных настроек + async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int: + if where is None: + where = {"id": 1} + + return await self._update(where=where, **kwargs) diff --git a/tgbot/database/db_position.py b/tgbot/database/db_position.py new file mode 100644 index 0000000..ef71886 --- /dev/null +++ b/tgbot/database/db_position.py @@ -0,0 +1,79 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional, Union + +from sqlalchemy import BigInteger, Float, Integer, String, Text, func, select +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.entities import Position +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +class PositionModel(Base): + __tablename__ = "storage_position" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + position_name: Mapped[str] = mapped_column(String(255), nullable=False) + position_price: Mapped[float] = mapped_column(Float, nullable=False, default=0) + position_desc: Mapped[str] = mapped_column(Text, nullable=False, default="None") + position_photo: Mapped[str] = mapped_column(Text, nullable=False, default="None") + position_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = Position +BaseModel = Position + + +class Positionx(BaseRepository[PositionModel, Position]): + # Подключение модели позиций + def __init__(self): + super().__init__() + self.table_model = PositionModel + self.entity_model = Position + self.storage_name = PositionModel.__tablename__ + + # Добавление позиции товара + async def add( + self, + category_id: int, + position_id: int, + position_name: str, + position_price: float, + position_desc: str, + position_photo: str, + ) -> Position: + return await self._insert( + category_id=category_id, + position_id=position_id, + position_name=position_name, + position_price=position_price, + position_desc=position_desc, + position_photo=position_photo, + position_unix=get_unix(), + ) + + # Обновление позиции по ID или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"position_id": where} + + return await self._update(where=where, **kwargs) + + # Получение остатков товаров по позициям + @staticmethod + async def item_counts() -> Dict[int, int]: + from tgbot.database.db_item import ItemModel + + item_table = ItemModel.__table__ + statement = ( + select(item_table.c.position_id, func.count(item_table.c.increment).label("item_count")) + .group_by(item_table.c.position_id) + ) + + async with session_scope() as session: + rows = await session.execute(statement) + + return {int(position_id): int(item_count) for position_id, item_count in rows.all()} diff --git a/tgbot/database/db_purchases.py b/tgbot/database/db_purchases.py new file mode 100644 index 0000000..31e984b --- /dev/null +++ b/tgbot/database/db_purchases.py @@ -0,0 +1,331 @@ +# - *- coding: utf- 8 - *- +from dataclasses import dataclass +from typing import Any, Dict, List, Optional, Union + +from sqlalchemy import BigInteger, Float, Integer, String, Text, text +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_factory +from tgbot.database.entities import Purchase +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import gen_id, get_unix + +TELEGRAM_LIMIT = 4000 + + +@dataclass +class PurchaseResult: + receipt: int + items: List[List[str]] + purchase_count: int + purchase_unix: int + purchase_price: float + new_balance: float + position_name: str + + +class PurchaseModel(Base): + __tablename__ = "storage_purchases" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + user_balance_before: Mapped[float] = mapped_column(Float, nullable=False, default=0) + user_balance_after: Mapped[float] = mapped_column(Float, nullable=False, default=0) + purchase_receipt: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + purchase_data: Mapped[str] = mapped_column(Text, nullable=False) + purchase_count: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + purchase_price: Mapped[float] = mapped_column(Float, nullable=False, default=0) + purchase_price_one: Mapped[float] = mapped_column(Float, nullable=False, default=0) + purchase_position_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + purchase_position_name: Mapped[str] = mapped_column(String(255), nullable=False) + purchase_category_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + purchase_category_name: Mapped[str] = mapped_column(String(255), nullable=False) + purchase_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = Purchase +BaseModel = Purchase + + +class Purchasesx(BaseRepository[PurchaseModel, Purchase]): + # Подключение модели покупок + def __init__(self): + super().__init__() + self.table_model = PurchaseModel + self.entity_model = Purchase + self.storage_name = PurchaseModel.__tablename__ + + # Добавление записи покупки + async def add( + self, + user_id: int, + user_balance_before: float, + user_balance_after: float, + purchase_receipt: int, + purchase_data: str, + purchase_count: int, + purchase_price: float, + purchase_price_one: float, + purchase_position_id: int, + purchase_position_name: str, + purchase_category_id: int, + purchase_category_name: str, + ) -> Purchase: + return await self._insert( + user_id=user_id, + user_balance_before=user_balance_before, + user_balance_after=user_balance_after, + purchase_receipt=purchase_receipt, + purchase_data=purchase_data, + purchase_count=purchase_count, + purchase_price=purchase_price, + purchase_price_one=purchase_price_one, + purchase_position_id=purchase_position_id, + purchase_position_name=purchase_position_name, + purchase_category_id=purchase_category_id, + purchase_category_name=purchase_category_name, + purchase_unix=get_unix(), + ) + + # Обновление покупки по чеку или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"purchase_receipt": where} + + return await self._update(where=where, **kwargs) + + # Атомарная покупка товаров + @staticmethod + async def buy(*, user_id: int, position_id: int, requested_count: int) -> Union[PurchaseResult, str]: + async with session_factory() as session: + try: + await session.execute(text("BEGIN IMMEDIATE")) + + user_result = await session.execute( + text( + """ + SELECT user_balance + FROM storage_users + WHERE user_id = :user_id + """ + ), + {"user_id": user_id}, + ) + get_user = user_result.mappings().first() + + if get_user is None: + await session.rollback() + return "USER_NOT_FOUND" + + position_result = await session.execute( + text( + """ + SELECT position_price, position_name, category_id + FROM storage_position + WHERE position_id = :position_id + """ + ), + {"position_id": position_id}, + ) + get_position = position_result.mappings().first() + + if get_position is None: + await session.rollback() + return "POSITION_NOT_FOUND" + + items_result = await session.execute( + text( + """ + SELECT increment, item_id, item_data + FROM storage_item + WHERE position_id = :position_id + ORDER BY increment + LIMIT :requested_count + """ + ), + {"position_id": position_id, "requested_count": requested_count}, + ) + items = items_result.mappings().all() + + if len(items) < requested_count: + await session.rollback() + return "NOT_ENOUGH_ITEMS" + + purchase_price = round(float(get_position["position_price"]) * requested_count, 2) + + if float(get_user["user_balance"]) < purchase_price: + await session.rollback() + return "NOT_ENOUGH_BALANCE" + + item_ids = [row["item_id"] for row in items] + delete_params = {f"item_id_{index}": item_id for index, item_id in enumerate(item_ids)} + delete_marks = ", ".join(f":item_id_{index}" for index in range(len(item_ids))) + await session.execute( + text(f"DELETE FROM storage_item WHERE item_id IN ({delete_marks})"), + delete_params, + ) + + new_balance = round(float(get_user["user_balance"]) - purchase_price, 2) + + await session.execute( + text( + """ + UPDATE storage_users + SET user_balance = :new_balance + WHERE user_id = :user_id + """ + ), + {"new_balance": new_balance, "user_id": user_id}, + ) + + receipt = gen_id() + purchase_data = "\n".join([row["item_data"] for row in items]) + purchase_unix = get_unix() + + category_result = await session.execute( + text("SELECT category_name FROM storage_category WHERE category_id = :category_id"), + {"category_id": get_position["category_id"]}, + ) + category = category_result.mappings().first() + category_name = category["category_name"] if category else "Unknown" + + await session.execute( + text( + """ + INSERT INTO storage_purchases (user_id, + user_balance_before, + user_balance_after, + purchase_receipt, + purchase_data, + purchase_count, + purchase_price, + purchase_price_one, + purchase_position_id, + purchase_position_name, + purchase_category_id, + purchase_category_name, + purchase_unix) + VALUES (:user_id, + :balance_before, + :balance_after, + :receipt, + :purchase_data, + :purchase_count, + :purchase_price, + :purchase_price_one, + :position_id, + :position_name, + :category_id, + :category_name, + :purchase_unix) + """ + ), + { + "user_id": user_id, + "balance_before": float(get_user["user_balance"]), + "balance_after": new_balance, + "receipt": receipt, + "purchase_data": purchase_data, + "purchase_count": requested_count, + "purchase_price": purchase_price, + "purchase_price_one": float(get_position["position_price"]), + "position_id": position_id, + "position_name": get_position["position_name"], + "category_id": get_position["category_id"], + "category_name": category_name, + "purchase_unix": purchase_unix, + }, + ) + await session.commit() + except Exception: + await session.rollback() + raise + + purchase_items = [row["item_data"] for row in items] + purchase_items_parts = chunk_items_by_len(purchase_items) + + return PurchaseResult( + receipt=receipt, + items=purchase_items_parts, + purchase_count=requested_count, + purchase_unix=purchase_unix, + purchase_price=purchase_price, + position_name=get_position["position_name"], + new_balance=new_balance, + ) + + +# Разделение товаров на сообщения под лимит телеграма +def chunk_items_by_len(items: List[str], limit: int = TELEGRAM_LIMIT, separator: str = "\n\n") -> List[List[str]]: + chunks: List[List[str]] = [] + current_chunk: List[str] = [] + current_len = 0 + sep_len = len(separator) + + for raw_item in items: + item = str(raw_item) + + if len(item) > limit: + if current_chunk: + chunks.append(current_chunk) + current_chunk = [] + current_len = 0 + + for part in split_long_item(item, limit): + chunks.append([part]) + + continue + + add_len = len(item) if not current_chunk else sep_len + len(item) + + if current_len + add_len <= limit: + current_chunk.append(item) + current_len += add_len + else: + chunks.append(current_chunk) + current_chunk = [item] + current_len = len(item) + + if current_chunk: + chunks.append(current_chunk) + + return chunks + + +# Разделение длинного товара без разрыва строк +def split_long_item(item: str, limit: int = TELEGRAM_LIMIT) -> List[str]: + chunks: List[str] = [] + current_lines: List[str] = [] + current_len = 0 + + for line in item.splitlines(): + line_len = len(line) + add_len = line_len if not current_lines else 1 + line_len + + if current_lines and current_len + add_len > limit: + chunks.append("\n".join(current_lines)) + current_lines = [] + current_len = 0 + add_len = line_len + + if line_len > limit: + if current_lines: + chunks.append("\n".join(current_lines)) + current_lines = [] + current_len = 0 + + start = 0 + + while start < line_len: + chunks.append(line[start:start + limit]) + start += limit + + continue + + current_lines.append(line) + current_len += add_len + + if current_lines: + chunks.append("\n".join(current_lines)) + + return chunks diff --git a/tgbot/database/db_refill.py b/tgbot/database/db_refill.py new file mode 100644 index 0000000..d2159e8 --- /dev/null +++ b/tgbot/database/db_refill.py @@ -0,0 +1,146 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional, Union + +from sqlalchemy import BigInteger, Float, Integer, String, text +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_factory +from tgbot.database.entities import Refill +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +class RefillModel(Base): + __tablename__ = "storage_refill" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + refill_receipt: Mapped[int] = mapped_column(BigInteger, nullable=False, index=True) + refill_comment: Mapped[str] = mapped_column(String(255), nullable=False, default="") + refill_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + refill_method: Mapped[str] = mapped_column(String(64), nullable=False) + refill_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = Refill +BaseModel = Refill + + +class Refillx(BaseRepository[RefillModel, Refill]): + # Подключение модели пополнений + def __init__(self): + super().__init__() + self.table_model = RefillModel + self.entity_model = Refill + self.storage_name = RefillModel.__tablename__ + + # Добавление записи пополнения + async def add( + self, + user_id: int, + refill_receipt: int, + refill_comment: str, + refill_amount: float, + refill_method: str, + ) -> Refill: + return await self._insert( + user_id=user_id, + refill_comment=refill_comment, + refill_amount=refill_amount, + refill_receipt=refill_receipt, + refill_method=refill_method, + refill_unix=get_unix(), + ) + + # Обновление пополнения по чеку или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"refill_receipt": where} + + return await self._update(where=where, **kwargs) + + # Успешное зачисление пополнения + @staticmethod + async def success( + user_id: int, + pay_receipt: int, + pay_comment: str, + pay_amount: float, + pay_method: str, + ) -> str: + async with session_factory() as session: + try: + await session.execute(text("BEGIN IMMEDIATE")) + + if pay_comment != "": + duplicate_refill = await session.execute( + text("SELECT increment FROM storage_refill WHERE refill_comment = :comment LIMIT 1"), + {"comment": pay_comment}, + ) + else: + duplicate_refill = await session.execute( + text("SELECT increment FROM storage_refill WHERE refill_receipt = :receipt LIMIT 1"), + {"receipt": pay_receipt}, + ) + + if duplicate_refill.mappings().first() is not None: + await session.rollback() + return "ALREADY" + + user_result = await session.execute( + text( + """ + SELECT user_balance, user_refill + FROM storage_users + WHERE user_id = :user_id + """ + ), + {"user_id": user_id}, + ) + get_user = user_result.mappings().first() + + if get_user is None: + await session.rollback() + return "USER_NOT_FOUND" + + new_balance = round(float(get_user["user_balance"]) + float(pay_amount), 2) + new_refill = round(float(get_user["user_refill"]) + float(pay_amount), 2) + + await session.execute( + text( + """ + INSERT INTO storage_refill (user_id, + refill_receipt, + refill_comment, + refill_amount, + refill_method, + refill_unix) + VALUES (:user_id, :receipt, :comment, :amount, :method, :unix) + """ + ), + { + "user_id": user_id, + "receipt": pay_receipt, + "comment": pay_comment, + "amount": pay_amount, + "method": pay_method, + "unix": get_unix(), + }, + ) + await session.execute( + text( + """ + UPDATE storage_users + SET user_balance = :balance, + user_refill = :refill + WHERE user_id = :user_id + """ + ), + {"balance": new_balance, "refill": new_refill, "user_id": user_id}, + ) + await session.commit() + except Exception: + await session.rollback() + raise + + return "ok" diff --git a/tgbot/database/db_settings.py b/tgbot/database/db_settings.py new file mode 100644 index 0000000..826539b --- /dev/null +++ b/tgbot/database/db_settings.py @@ -0,0 +1,76 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional + +from sqlalchemy import Integer, String +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base +from tgbot.database.entities import Settings +from tgbot.database.repository import BaseRepository + + +class SettingsModel(Base): + __tablename__ = "storage_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1) + status_work: Mapped[str] = mapped_column(String(16), nullable=False, default="True") + status_refill: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + status_buy: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + notification_refill: Mapped[str] = mapped_column(String(16), nullable=False, default="True") + notification_buy: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + misc_faq: Mapped[str] = mapped_column(String, nullable=False, default="None") + misc_support: Mapped[str] = mapped_column(String, nullable=False, default="None") + misc_bot: Mapped[str] = mapped_column(String(255), nullable=False, default="None") + misc_hosting_text: Mapped[str] = mapped_column(String(64), nullable=False, default="telegraph") + misc_token_telegraph: Mapped[str] = mapped_column(String, nullable=False, default="None") + misc_discord_webhook_url: Mapped[str] = mapped_column(String, nullable=False, default="None") + misc_discord_webhook_name: Mapped[str] = mapped_column(String(255), nullable=False, default="None") + misc_hide_category: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + misc_hide_position: Mapped[str] = mapped_column(String(16), nullable=False, default="False") + misc_method_prod: Mapped[str] = mapped_column(String(16), nullable=False, default="skip") + misc_profit_day: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + misc_profit_week: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + misc_profit_month: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +ModelBase = Settings +BaseModel = Settings + + +class Settingsx(BaseRepository[SettingsModel, Settings]): + # Подключение модели настроек + def __init__(self): + super().__init__() + self.table_model = SettingsModel + self.entity_model = Settings + self.storage_name = SettingsModel.__tablename__ + + # Создание дефолтной строки настроек + async def ensure_default(self) -> None: + settings = await BaseRepository.get(self, id=1) + + if settings is None: + await self._insert(id=1) + + # Получение настроек бота + async def get(self, **kwargs) -> Settings: + if not kwargs: + kwargs = {"id": 1} + + settings = await BaseRepository.get(self, **kwargs) + + if settings is None and kwargs == {"id": 1}: + await self.ensure_default() + settings = await BaseRepository.get(self, id=1) + + if settings is None: + raise RuntimeError("Настройки бота по умолчанию не сохранились") + + return settings + + # Обновление настроек бота + async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int: + if where is None: + where = {"id": 1} + + return await self._update(where=where, **kwargs) diff --git a/tgbot/database/db_users.py b/tgbot/database/db_users.py new file mode 100644 index 0000000..30b9c13 --- /dev/null +++ b/tgbot/database/db_users.py @@ -0,0 +1,128 @@ +# - *- coding: utf- 8 - *- +from typing import Any, Dict, Optional, Union + +from sqlalchemy import BigInteger, Float, Integer, String, or_ +from sqlalchemy import update as sqlalchemy_update +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.entities import User +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +class UserModel(Base): + __tablename__ = "storage_users" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + user_login: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_surname: Mapped[str] = mapped_column(String(255), nullable=False, default="") + user_fullname: Mapped[str] = mapped_column(String(511), nullable=False, default="") + user_balance: Mapped[float] = mapped_column(Float, nullable=False, default=0) + user_refill: Mapped[float] = mapped_column(Float, nullable=False, default=0) + user_give: Mapped[float] = mapped_column(Float, nullable=False, default=0) + user_unix: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + + +ModelBase = User +BaseModel = User + + +class UsersRepository(BaseRepository[UserModel, User]): + # Подключение модели пользователей + def __init__(self): + super().__init__() + self.table_model = UserModel + self.entity_model = User + self.storage_name = UserModel.__tablename__ + + # Добавление или обновление пользователя + async def add( + self, + user_id: int, + user_login: str, + user_name: str, + user_surname: str = "", + user_fullname: str = "", + ) -> User: + return await self.upsert( + user_id=user_id, + user_login=user_login, + user_name=user_name, + user_surname=user_surname, + user_fullname=user_fullname or " ".join(filter(None, [user_name, user_surname])), + ) + + # Выполнение upsert пользователя по телеграм ID + async def upsert( + self, + user_id: int, + user_login: str, + user_name: str, + user_surname: str = "", + user_fullname: str = "", + ) -> User: + user_fullname = user_fullname or " ".join(filter(None, [user_name, user_surname])) + user_table = UserModel.__table__ + statement = insert(UserModel).values( + user_id=user_id, + user_login=(user_login or "").lower(), + user_name=user_name or "", + user_surname=user_surname or "", + user_fullname=user_fullname or "", + user_balance=0, + user_refill=0, + user_give=0, + user_unix=get_unix(), + ) + statement = statement.on_conflict_do_update( + index_elements=[user_table.c.user_id], + set_={ + "user_login": statement.excluded.user_login, + "user_name": statement.excluded.user_name, + "user_surname": statement.excluded.user_surname, + "user_fullname": statement.excluded.user_fullname, + }, + where=or_( + user_table.c.user_login != statement.excluded.user_login, + user_table.c.user_name != statement.excluded.user_name, + user_table.c.user_surname != statement.excluded.user_surname, + user_table.c.user_fullname != statement.excluded.user_fullname, + ), + ) + + async with session_scope() as session: + await session.execute(statement) + + user = await self.get(user_id=user_id) + + if user is None: + raise RuntimeError("Пользователь не сохранился") + + return user + + # Обновление пользователя по ID или фильтру + async def update(self, where: Optional[Union[Dict[str, Any], int]] = None, **kwargs) -> int: + if isinstance(where, int): + where = {"user_id": where} + + return await self._update(where=where, **kwargs) + + # Обновление пользователя по телеграм ID + @staticmethod + async def update_by_user_id(user_id: int, **kwargs) -> None: + if not kwargs: + return + + async with session_scope() as session: + await session.execute( + sqlalchemy_update(UserModel) + .where(UserModel.__table__.c.user_id == user_id) + .values(**kwargs) + ) + + +Userx = UsersRepository diff --git a/tgbot/database/entities.py b/tgbot/database/entities.py new file mode 100644 index 0000000..3033f7c --- /dev/null +++ b/tgbot/database/entities.py @@ -0,0 +1,110 @@ +# - *- coding: utf- 8 - *- +from dataclasses import dataclass + + +@dataclass +class Category: + increment: int + category_id: int + category_name: str + category_unix: int + + +@dataclass +class Position: + increment: int + category_id: int + position_id: int + position_name: str + position_price: float + position_desc: str + position_photo: str + position_unix: int + + +@dataclass +class Item: + increment: int + user_id: int + category_id: int + position_id: int + item_id: int + item_unix: int + item_data: str + + +@dataclass +class Payments: + id: int + cryptobot_token: str + yoomoney_token: str + stars_course: float + status_cryptobot: str + status_yoomoney: str + status_stars: str + + +@dataclass +class Purchase: + increment: int + user_id: int + user_balance_before: float + user_balance_after: float + purchase_receipt: int + purchase_data: str + purchase_count: int + purchase_price: float + purchase_price_one: float + purchase_position_id: int + purchase_position_name: str + purchase_category_id: int + purchase_category_name: str + purchase_unix: int + + +@dataclass +class Refill: + increment: int + user_id: int + refill_receipt: int + refill_comment: str + refill_amount: float + refill_method: str + refill_unix: int + + +@dataclass +class Settings: + id: int + status_work: str + status_refill: str + status_buy: str + notification_refill: str + notification_buy: str + misc_faq: str + misc_support: str + misc_bot: str + misc_hosting_text: str + misc_token_telegraph: str + misc_discord_webhook_url: str + misc_discord_webhook_name: str + misc_hide_category: str + misc_hide_position: str + misc_method_prod: str + misc_profit_day: int + misc_profit_week: int + misc_profit_month: int + + +@dataclass +class User: + increment: int + user_id: int + user_login: str + user_name: str + user_surname: str + user_fullname: str + user_balance: float + user_refill: float + user_give: float + user_unix: int diff --git a/tgbot/database/migration_runner.py b/tgbot/database/migration_runner.py new file mode 100644 index 0000000..abb02fd --- /dev/null +++ b/tgbot/database/migration_runner.py @@ -0,0 +1,55 @@ +# - *- coding: utf- 8 - *- +import asyncio +from pathlib import Path +from typing import Optional + +from alembic import command +from alembic.config import Config +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import AsyncEngine + +from tgbot.database.core import database_url + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +ALEMBIC_INI = PROJECT_ROOT / "alembic.ini" +MIGRATIONS_DIR = PROJECT_ROOT / "migrations" + + +# Сбор Alembic-конфига от корня проекта +def get_alembic_config(url: str = database_url) -> Config: + config = Config(str(ALEMBIC_INI)) + config.set_main_option("script_location", str(MIGRATIONS_DIR)) + config.set_main_option("sqlalchemy.url", url) + + return config + + +# Применение миграций до последней версии +async def run_migrations(engine: Optional[AsyncEngine] = None) -> None: + if engine is None: + config = get_alembic_config() + await asyncio.to_thread(command.upgrade, config, "head") + return + + config = get_alembic_config(str(engine.url)) + + connection = engine.connect() + await connection.start() + transaction = connection.begin() + await transaction.start() + + try: + await connection.run_sync(_upgrade_with_connection, config) + except Exception: + await transaction.rollback() + raise + else: + await transaction.commit() + finally: + await connection.close() + + +# Запуск Alembic на готовом соединении +def _upgrade_with_connection(connection: Connection, config: Config) -> None: + config.attributes["connection"] = connection + command.upgrade(config, "head") diff --git a/tgbot/database/repository.py b/tgbot/database/repository.py new file mode 100644 index 0000000..3256ef2 --- /dev/null +++ b/tgbot/database/repository.py @@ -0,0 +1,162 @@ +# - *- coding: utf- 8 - *- +from dataclasses import fields +from typing import Any, Dict, Generic, List, Optional, Type, TypeVar + +from sqlalchemy import delete as sqlalchemy_delete +from sqlalchemy import select +from sqlalchemy import update as sqlalchemy_update +from tgbot.database.core import Base, database_path, session_scope +from tgbot.database.migration_runner import run_migrations +from tgbot.utils.misc.bot_logging import bot_logger + +ModelTranslator = TypeVar("ModelTranslator", bound=Base) +EntityTranslator = TypeVar("EntityTranslator") + + +# Базовый репозиторий для SQLAlchemy-моделей +class BaseRepository(Generic[ModelTranslator, EntityTranslator]): + # Настройка модели репозитория + def __init__(self): + self.storage_name = "storage" + self.table_model: Optional[Type[ModelTranslator]] = None + self.entity_model: Optional[Type[EntityTranslator]] = None + + # Получение SQLAlchemy-модели репозитория + def _model(self) -> Type[ModelTranslator]: + if self.table_model is None: + raise RuntimeError("Модель базы данных не настроена") + + return self.table_model + + # Преобразование ORM-строки в безопасный DTO + def _to_entity(self, instance: ModelTranslator) -> EntityTranslator: + if self.entity_model is None: + raise RuntimeError("DTO-модель базы данных не настроена") + + values = { + field.name: getattr(instance, field.name) + for field in fields(self.entity_model) + } + + return self.entity_model(**values) + + # Добавление записи через текущую модель + async def _insert(self, **kwargs) -> EntityTranslator: + model = self._model() + instance = model(**kwargs) + + async with session_scope() as session: + session.add(instance) + await session.flush() + await session.refresh(instance) + + return self._to_entity(instance) + + # Добавление записи с возвратом DTO + async def add(self, **kwargs) -> EntityTranslator: + return await self._insert(**kwargs) + + # Удаление записи по явному фильтру + async def delete(self, **kwargs) -> None: + if not kwargs: + raise ValueError("Для удаления нужен хотя бы один фильтр") + + model = self._model() + + async with session_scope() as session: + await session.execute(sqlalchemy_delete(model).filter_by(**kwargs)) + + # Очистка текущей таблицы + async def clear(self) -> None: + await self.delete_all_rows() + + # Удаление всех строк текущей таблицы + async def delete_all_rows(self) -> None: + model = self._model() + + async with session_scope() as session: + await session.execute(sqlalchemy_delete(model)) + + # Получение первой записи по фильтру + async def get(self, **kwargs) -> Optional[EntityTranslator]: + model = self._model() + statement = select(model) + + if kwargs: + statement = statement.filter_by(**kwargs) + + if hasattr(model, "increment"): + statement = statement.order_by(getattr(model, "increment")) + + async with session_scope() as session: + response = await session.execute(statement) + instance = response.scalars().first() + + if instance is None: + return None + + return self._to_entity(instance) + + # Получение записи или явно падает + async def get_required(self, **kwargs) -> EntityTranslator: + entity = await self.get(**kwargs) + + if entity is None: + raise LookupError(f"Запись не найдена в {self.storage_name}: {kwargs}") + + return entity + + # Получение списка записей по фильтру + async def gets(self, **kwargs) -> List[EntityTranslator]: + model = self._model() + statement = select(model) + + if kwargs: + statement = statement.filter_by(**kwargs) + + if hasattr(model, "increment"): + statement = statement.order_by(getattr(model, "increment")) + + async with session_scope() as session: + response = await session.execute(statement) + + return [self._to_entity(instance) for instance in response.scalars().all()] + + # Получение всех записей таблицы + async def get_all(self) -> List[EntityTranslator]: + return await self.gets() + + # Обновление записи через текущую модель + async def _update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int: + if not kwargs: + return 0 + + model = self._model() + statement = sqlalchemy_update(model).values(**kwargs) + + if where: + statement = statement.filter_by(**where) + + async with session_scope() as session: + result = await session.execute(statement) + rowcount = getattr(result, "rowcount", 0) + + return int(rowcount or 0) + + # Обновление записи по фильтру + async def update(self, where: Optional[Dict[str, Any]] = None, **kwargs) -> int: + return await self._update(where=where, **kwargs) + + +# Применение миграций и создание дефолтных строк +async def prepare_database() -> None: + database_path.parent.mkdir(parents=True, exist_ok=True) + await run_migrations() + + from tgbot.database.db_payments import Paymentsx + from tgbot.database.db_settings import Settingsx + + await Settingsx().ensure_default() + await Paymentsx().ensure_default() + + bot_logger.info("База данных готова") diff --git a/tgbot/keyboards/__init__.py b/tgbot/keyboards/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/keyboards/inline_admin.py b/tgbot/keyboards/inline_admin.py new file mode 100644 index 0000000..0b54e95 --- /dev/null +++ b/tgbot/keyboards/inline_admin.py @@ -0,0 +1,255 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.database import Settingsx, Paymentsx +from tgbot.utils.const_functions import ikb + + +################################################################################ +#################################### ПРОЧЕЕ #################################### +# Удаление сообщения +def close_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("❌ Закрыть", data="close_this"), + ) + + return keyboard.as_markup() + + +# Рассылка +def mail_confirm_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Отправить", data="mail_confirm:Yes"), + ikb("❌ Отменить", data="mail_confirm:Not"), + ) + + return keyboard.as_markup() + + +# Поиск профиля пользователя +def profile_edit_finl(user_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("💰 Изменить баланс", data=f"admin_user_balance_set:{user_id}"), + ikb("💰 Выдать баланс", data=f"admin_user_balance_add:{user_id}"), + ).row( + ikb("🎁 Покупки", data=f"admin_user_purchases:{user_id}"), + ikb("💌 Отправить СМС", data=f"admin_user_message:{user_id}"), + ).row( + ikb("🔄 Обновить", data=f"admin_user_refresh:{user_id}"), + ) + + return keyboard.as_markup() + + +# Переход к профилю пользователя +def profile_edit_return_finl(user_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("❌ Отменить", data=f"admin_user_refresh:{user_id}"), + ) + + return keyboard.as_markup() + + +################################################################################ +############################## ПЛАТЕЖНЫЕ СИСТЕМЫ ############################### +# Управление - CryptoBot +async def payment_cryptobot_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_payments = await Paymentsx().get() + + if get_payments.cryptobot_token == "None": + assets_symbol = "➖" + else: + assets_symbol = "➕" + + if get_payments.status_cryptobot == "True": + status_kb = ikb(f"{assets_symbol} | Статус: Включено ✅", data="payment_cryptobot_status:False") + else: + status_kb = ikb(f"{assets_symbol} | Статус: Выключено ❌", data="payment_cryptobot_status:True") + + keyboard.row( + ikb("Информация ♻️", data="payment_cryptobot_check"), + ).row( + ikb("Баланс 💰", data="payment_cryptobot_balance"), + ).row( + ikb("Изменить 🖍", data="payment_cryptobot_edit"), + ).row( + ikb("⁠", data="..."), + ).row( + status_kb, + ) + + return keyboard.as_markup() + + +# Управление - ЮMoney +async def payment_yoomoney_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_payments = await Paymentsx().get() + + if get_payments.yoomoney_token == "None": + assets_symbol = "➖" + else: + assets_symbol = "➕" + + if get_payments.status_yoomoney == "True": + status_kb = ikb(f"{assets_symbol} | Статус: Включено ✅", data="payment_yoomoney_status:False") + else: + status_kb = ikb(f"{assets_symbol} | Статус: Выключено ❌", data="payment_yoomoney_status:True") + + keyboard.row( + ikb("Информация ♻️", data="payment_yoomoney_check"), + ).row( + ikb("Баланс 💰", data="payment_yoomoney_balance"), + ).row( + ikb("Изменить 🖍", data="payment_yoomoney_edit"), + ).row( + ikb("⁠", data="..."), + ).row( + status_kb, + ) + + return keyboard.as_markup() + + +# Управление - Звёзды +async def payment_stars_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_payments = await Paymentsx().get() + + assets_symbol = "➕" + + if get_payments.status_stars == "True": + status_kb = ikb(f"{assets_symbol} | Статус: Включено ✅", data="payment_stars_status:False") + else: + status_kb = ikb(f"{assets_symbol} | Статус: Выключено ❌", data="payment_stars_status:True") + + keyboard.row( + ikb("Информация ♻️", data="payment_stars_check"), + ).row( + ikb("Баланс 💰", data="payment_stars_balance"), + ).row( + ikb("Изменить курс 🖍", data="payment_stars_edit"), + ).row( + ikb("⁠", data="..."), + ).row( + status_kb, + ) + + return keyboard.as_markup() + + +################################################################################ +################################## НАСТРОЙКИ ################################### +# Основные настройки +async def settings_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_settings = await Settingsx().get() + + # Текст для FAQ + if get_settings.misc_faq == "None": + faq_kb = ikb("Не установлено ❌", data="settings_edit_faq") + else: + faq_kb = ikb(f"{get_settings.misc_faq[:15]}... ✅", data="settings_edit_faq") + + # Контакты поддержки + if get_settings.misc_support == "None": + support_kb = ikb("Не установлена ❌", data="settings_edit_support") + else: + support_kb = ikb(f"@{get_settings.misc_support} ✅", data="settings_edit_support") + + # Вебхук дискорда + if get_settings.misc_discord_webhook_url == "None": + discord_webhook_kb = ikb("Отсутствует ❌", data="settings_edit_discord_webhook") + else: + discord_webhook_kb = ikb(f"{get_settings.misc_discord_webhook_name} ✅", data="settings_edit_discord_webhook") + + # Текстовый хостинг по умолчанию + hosting_text_default_kb = ikb(get_settings.misc_hosting_text.title(), data="settings_edit_hosting_text") + + # Скрытие категорий без товаров + if get_settings.misc_hide_category == "True": + hide_category_kb = ikb("Скрыты", data="settings_edit_hide_category:False") + else: + hide_category_kb = ikb("Отображены", data="settings_edit_hide_category:True") + + # Скрытие позиций без товаров + if get_settings.misc_hide_position == "True": + hide_position_kb = ikb("Скрыты", data="settings_edit_hide_position:False") + else: + hide_position_kb = ikb("Отображены", data="settings_edit_hide_position:True") + + # Метод добавления товаров + if get_settings.misc_method_prod == "skip": + method_prod_kb = ikb("Через строку", data="settings_edit_method_prod:every") + else: + method_prod_kb = ikb("На каждой строке", data="settings_edit_method_prod:skip") + + keyboard.row( + ikb("❔ FAQ", data="..."), faq_kb, + ).row( + ikb("☎️ Поддержка", data="..."), support_kb, + ).row( + ikb("🧿 Дискорд Webhook", url="https://teletype.in/@djimbox/djimboshop-discord"), discord_webhook_kb, + ).row( + ikb("🗯 Текстовый хостинг", data="..."), hosting_text_default_kb, + ).row( + ikb("🎁 Категории без товаров", data="..."), hide_category_kb, + ).row( + ikb("🎁 Позиции без товаров", data="..."), hide_position_kb, + ).row( + ikb("🎁 Метод добавления", data="..."), method_prod_kb, + ) + + return keyboard.as_markup() + + +# Выключатели +async def settings_status_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_settings = await Settingsx().get() + + status_work_kb = ikb("Включены ✅", data="settings_status_work:False") + status_buy_kb = ikb("Включены ✅", data="settings_status_buy:False") + status_refill_kb = ikb("Включены ✅", data="settings_status_refill:False") + notification_buy_kb = ikb("Включены 🔔", data="settings_notification_buy:False") + notification_refill_kb = ikb("Включены 🔔", data="settings_notification_refill:False") + + if get_settings.status_buy == "False": + status_buy_kb = ikb("Выключены ❌", data="settings_status_buy:True") + if get_settings.status_work == "False": + status_work_kb = ikb("Выключены ❌", data="settings_status_work:True") + if get_settings.status_refill == "False": + status_refill_kb = ikb("Выключены ❌", data="settings_status_refill:True") + if get_settings.notification_buy == "False": + notification_buy_kb = ikb("Выключены 🔕", data="settings_notification_buy:True") + if get_settings.notification_refill == "False": + notification_refill_kb = ikb("Выключены 🔕", data="settings_notification_refill:True") + + keyboard.row( + ikb("⛔ Тех. работы", data="..."), status_work_kb, + ).row( + ikb("💰 Пополнения", data="..."), status_refill_kb, + ).row( + ikb("🎁 Покупки", data="..."), status_buy_kb, + ).row( + ikb("📢 Увед. о покупках", data="..."), notification_buy_kb, + ).row( + ikb("📢 Увед. о пополнениях", data="..."), notification_refill_kb, + ) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/inline_admin_page.py b/tgbot/keyboards/inline_admin_page.py new file mode 100644 index 0000000..08b563a --- /dev/null +++ b/tgbot/keyboards/inline_admin_page.py @@ -0,0 +1,188 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.database import Categoryx, Positionx, Itemx +from tgbot.keyboards.inline_helper import build_pagination_finl +from tgbot.utils.const_functions import ikb + + +# fp - flip page + +################################################################################ +############################## ИЗМЕНЕНИЕ КАТЕГОРИИ ############################# +# Cтраницы выбора категории для изменения +async def category_edit_swipe_fp(remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_categories = await Categoryx().get_all() + + for count, select in enumerate(range(remover, len(get_categories))): + if count < 10: + category = get_categories[select] + + keyboard.row( + ikb( + category.category_name, + data=f"category_edit_open:{category.category_id}:{remover}", + ) + ) + + buildp_kb = build_pagination_finl(get_categories, f"category_edit_swipe", remover) + keyboard.row(*buildp_kb) + + return keyboard.as_markup() + + +################################################################################ +################################ СОЗДАНИЕ ПОЗИЦИИ ############################## +# Страницы выбора категории для позиции +async def position_add_swipe_fp(remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_categories = await Categoryx().get_all() + + for count, select in enumerate(range(remover, len(get_categories))): + if count < 10: + category = get_categories[select] + + keyboard.row( + ikb( + category.category_name, + data=f"position_add_open:{category.category_id}", + ) + ) + + buildp_kb = build_pagination_finl(get_categories, f"position_add_swipe", remover) + keyboard.row(*buildp_kb) + + return keyboard.as_markup() + + +################################################################################ +############################### ИЗМЕНЕНИЕ ПОЗИЦИИ ############################## +# Cтраницы категорий для изменения позиции +async def position_edit_category_swipe_fp(remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_categories = await Categoryx().get_all() + + for count, select in enumerate(range(remover, len(get_categories))): + if count < 10: + category = get_categories[select] + + keyboard.row( + ikb( + category.category_name, + data=f"position_edit_category_open:{category.category_id}" + ) + ) + + buildp_kb = build_pagination_finl(get_categories, f"position_edit_category_swipe", remover) + keyboard.row(*buildp_kb) + + return keyboard.as_markup() + + +# Cтраницы выбора позиции для изменения +async def position_edit_swipe_fp(remover: int, category_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_positions = await Positionx().gets(category_id=category_id) + + for count, select in enumerate(range(remover, len(get_positions))): + if count < 10: + position = get_positions[select] + get_items = await Itemx().gets(position_id=position.position_id) + + keyboard.row( + ikb( + f"{position.position_name} | {position.position_price}₽ | {len(get_items)} шт", + data=f"position_edit_open:{position.position_id}:{remover}", + ) + ) + + buildp_kb = build_pagination_finl(get_positions, f"position_edit_swipe:{category_id}", remover) + keyboard.row(*buildp_kb) + + keyboard.row(ikb("🔙 Вернуться", data="position_edit_category_swipe:0")) + + return keyboard.as_markup() + + +################################################################################ +############################### ДОБАВЛЕНИЕ ТОВАРОВ ############################# +# Страницы категорий для добавления товаров +async def item_add_category_swipe_fp(remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_categories = await Categoryx().get_all() + + for count, select in enumerate(range(remover, len(get_categories))): + if count < 10: + category = get_categories[select] + + keyboard.row( + ikb( + category.category_name, + data=f"item_add_category_open:{category.category_id}:{remover}", + ) + ) + + buildp_kb = build_pagination_finl(get_categories, f"item_add_category_swipe", remover) + keyboard.row(*buildp_kb) + + return keyboard.as_markup() + + +# Страницы позиций для добавления товаров +async def item_add_position_swipe_fp(remover: int, category_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_positions = await Positionx().gets(category_id=category_id) + + for count, select in enumerate(range(remover, len(get_positions))): + if count < 10: + position = get_positions[select] + get_items = await Itemx().gets(position_id=position.position_id) + + keyboard.row( + ikb( + f"{position.position_name} | {position.position_price}₽ | {len(get_items)} шт", + data=f"item_add_position_open:{position.position_id}", + ) + ) + + buildp_kb = build_pagination_finl(get_positions, f"item_add_position_swipe:{category_id}", remover) + keyboard.row(*buildp_kb) + + keyboard.row(ikb("🔙 Вернуться", data="item_add_category_swipe:0")) + + return keyboard.as_markup() + + +################################################################################ +################################ УДАЛЕНИЕ ТОВАРОВ ############################## +# Страницы товаров для удаления +async def item_delete_swipe_fp(remover: int, position_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_items = await Itemx().gets(position_id=position_id) + + for count, select in enumerate(range(remover, len(get_items))): + if count < 10: + item = get_items[select] + + keyboard.row( + ikb( + item.item_data, + data=f"item_delete_open:{item.item_id}", + ) + ) + + buildp_kb = build_pagination_finl(get_items, f"item_delete_swipe:{position_id}", remover) + keyboard.row(*buildp_kb) + + keyboard.row(ikb("🔙 Вернуться", data=f"position_edit_open:{position_id}:0")) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/inline_admin_products.py b/tgbot/keyboards/inline_admin_products.py new file mode 100644 index 0000000..2901401 --- /dev/null +++ b/tgbot/keyboards/inline_admin_products.py @@ -0,0 +1,198 @@ +# - *- coding: utf- 8 - *- +from aiogram import Bot +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.database import Positionx +from tgbot.utils.const_functions import ikb + + +################################################################################ +################################### КАТЕГОРИИ ################################## +# Изменение категории +async def category_edit_open_finl(bot: Bot, category_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_bot = await bot.get_me() + + keyboard.row( + ikb("▪️ Изм. Название", data=f"category_edit_name:{category_id}:{remover}"), + ikb("▪️ Добавить позицию", data=f"position_add_open:{category_id}"), + ).row( + ikb("▪️ Скопировать ссылку", copy=f"t.me/{get_bot.username}?start=c_{category_id}"), + ikb("▪️ Удалить", data=f"category_edit_delete:{category_id}:{remover}"), + ).row( + ikb("🔙 Вернуться", data=f"category_edit_swipe:{remover}"), + ikb("▪️ Обновить", data=f"category_edit_open:{category_id}:{remover}"), + ) + + return keyboard.as_markup() + + +# Подтверждение удаления категории +def category_edit_delete_finl(category_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, удалить", data=f"category_edit_delete_confirm:{category_id}:{remover}"), + ikb("❌ Нет, отменить", data=f"category_edit_open:{category_id}:{remover}") + ) + + return keyboard.as_markup() + + +# Отмена изменения категории и возвращение +def category_edit_cancel_finl(category_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("❌ Отменить", data=f"category_edit_open:{category_id}:{remover}"), + ) + + return keyboard.as_markup() + + +################################################################################ +#################################### ПОЗИЦИИ ################################### +# Кнопки при открытии позиции для изменения +async def position_edit_open_finl(bot: Bot, position_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_position = await Positionx().get_required(position_id=position_id) + get_bot = await bot.get_me() + + keyboard.row( + ikb("▪️ Изм. Название", data=f"position_edit_name:{position_id}:{remover}"), + ikb("▪️ Изм. Цену", data=f"position_edit_price:{position_id}:{remover}"), + ).row( + ikb("▪️ Изм. Описание", data=f"position_edit_desc:{position_id}:{remover}"), + ikb("▪️ Изм. Фото", data=f"position_edit_photo:{position_id}:{remover}"), + ).row( + ikb("▪️ Добавить Товары", data=f"item_add_position_open:{position_id}"), + ikb("▪️ Выгрузить Товары", data=f"position_edit_items:{position_id}:{remover}"), + ).row( + ikb("▪️ Очистить Товары", data=f"position_edit_clear:{position_id}:{remover}"), + ikb("▪️ Удалить Товар", data=f"item_delete_swipe:{position_id}:0"), + ).row( + ikb("▪️ Скопировать ссылку", copy=f"t.me/{get_bot.username}?start=p_{position_id}"), + ikb("▪️ Удалить Позицию", data=f"position_edit_delete:{position_id}:{remover}"), + ).row( + ikb("🔙 Вернуться", data=f"position_edit_swipe:{get_position.category_id}:{remover}"), + ikb("▪️ Обновить", data=f"position_edit_open:{position_id}:{remover}"), + ) + + return keyboard.as_markup() + + +# Подтверждение удаления позиции +def position_edit_delete_finl(position_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, удалить", data=f"position_edit_delete_confirm:{position_id}:{remover}"), + ikb("❌ Нет, отменить", data=f"position_edit_open:{position_id}:{remover}") + ) + + return keyboard.as_markup() + + +# Подтверждение очистки позиции +def position_edit_clear_finl(position_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, очистить", data=f"position_edit_clear_confirm:{position_id}:{remover}"), + ikb("❌ Нет, отменить", data=f"position_edit_open:{position_id}:{remover}") + ) + + return keyboard.as_markup() + + +# Отмена изменения позиции и возвращение +def position_edit_cancel_finl(position_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("❌ Отменить", data=f"position_edit_open:{position_id}:{remover}"), + ) + + return keyboard.as_markup() + + +################################################################################ +##################################### ТОВАРЫ ################################### +# Отмена изменения позиции и возвращение +def item_add_finish_finl(position_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Завершить загрузку", data=f"item_add_position_finish:{position_id}"), + ) + + return keyboard.as_markup() + + +# Удаление товара +def item_delete_finl(item_id: int, position_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("▪️ Удалить товар", data=f"item_delete_confirm:{item_id}"), + ).row( + ikb("🔙 Вернуться", data=f"item_delete_swipe:{position_id}:0"), + ) + + return keyboard.as_markup() + + +################################################################################ +############################### УДАЛЕНИЕ РАЗДЕЛОВ ############################## +# Выбор раздела для удаления +def products_removes_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("🗃 Удалить все категории", data=f"prod_removes_categories"), + ).row( + ikb("📁 Удалить все позиции", data=f"prod_removes_positions"), + ).row( + ikb("🎁 Удалить все товары", data=f"prod_removes_items"), + ) + + return keyboard.as_markup() + + +# Удаление всех категорий +def products_removes_categories_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, удалить все", data="prod_removes_categories_confirm"), + ikb("❌ Нет, отменить", data="prod_removes_return") + ) + + return keyboard.as_markup() + + +# Удаление всех позиций +def products_removes_positions_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, удалить все", data="prod_removes_positions_confirm"), + ikb("❌ Нет, отменить", data="prod_removes_return") + ) + + return keyboard.as_markup() + + +# Удаление всех товаров +def products_removes_items_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Да, удалить все", data="prod_removes_items_confirm"), + ikb("❌ Нет, отменить", data="prod_removes_return") + ) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/inline_helper.py b/tgbot/keyboards/inline_helper.py new file mode 100644 index 0000000..9d81777 --- /dev/null +++ b/tgbot/keyboards/inline_helper.py @@ -0,0 +1,82 @@ +# - *- coding: utf- 8 - *- +import math +from typing import List + +from aiogram.types import InlineKeyboardButton + +from tgbot.utils.const_functions import ikb + + +# Генерация пагинации страниц +def build_pagination_finl( + get_list: List[object], + button_data: str, + remover: int, +) -> List[InlineKeyboardButton]: + save_kb = [] + + if len(get_list) % 10 == 0: + remover_page = len(get_list) + else: + remover_page = len(get_list) - len(get_list) % 10 + + if len(get_list) % 10 == 0 and remover_page == len(get_list): + remover_page -= 10 + + if remover >= len(get_list): remover -= 10 + if remover < 0: remover = 0 + + if len(get_list) <= 10: + ... + elif len(get_list) > 10 and remover < 10: + if len(get_list) > 20: + save_kb += [ + ikb(f"1/{math.ceil(len(get_list) / 10)}", data="..."), + ikb("➡️", data=f"{button_data}:{remover + 10}"), + ikb("⏩", data=f"{button_data}:{remover_page}"), + ] + else: + save_kb += [ + ikb(f"1/{math.ceil(len(get_list) / 10)}", data="..."), + ikb("➡️", data=f"{button_data}:{remover + 10}"), + ] + elif remover + 10 >= len(get_list): + if len(get_list) > 20: + save_kb += [ + ikb("⏪", data=f"{button_data}:0"), + ikb("⬅️", data=f"{button_data}:{remover - 10}"), + ikb(f"{str(remover + 10)[:-1]}/{math.ceil(len(get_list) / 10)}", data="..."), + ] + else: + save_kb += [ + ikb("⬅️", data=f"{button_data}:{remover - 10}"), + ikb(f"{str(remover + 10)[:-1]}/{math.ceil(len(get_list) / 10)}", data="..."), + ] + else: + if len(get_list) > 20: + if remover >= 20: + save_kb += [ + ikb("⏪", data=f"{button_data}:0"), + ikb("⬅️", data=f"{button_data}:{remover - 10}"), + ikb(f"{str(remover + 10)[:-1]}/{math.ceil(len(get_list) / 10)}", data="..."), + ikb("➡️", data=f"{button_data}:{remover + 10}"), + ] + else: + save_kb += [ + ikb("⬅️", data=f"{button_data}:{remover - 10}"), + ikb(f"{str(remover + 10)[:-1]}/{math.ceil(len(get_list) / 10)}", data="..."), + ikb("➡️", data=f"{button_data}:{remover + 10}"), + ] + + if remover_page - 10 > remover: + save_kb += [ + ikb("⏩", data=f"{button_data}:{remover_page}"), + ] + else: + save_kb += [ + ikb("⬅️", data=f"{button_data}:{remover - 10}"), + ikb(f"{str(remover + 10)[:-1]}/{math.ceil(len(get_list) / 10)}", data="..."), + ikb("➡️", data=f"{button_data}:{remover + 10}"), + ] + + return save_kb diff --git a/tgbot/keyboards/inline_user.py b/tgbot/keyboards/inline_user.py new file mode 100644 index 0000000..095b29a --- /dev/null +++ b/tgbot/keyboards/inline_user.py @@ -0,0 +1,84 @@ +# - *- coding: utf- 8 - *- +from typing import Union + +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.database import Paymentsx +from tgbot.utils.const_functions import ikb + + +################################################################################ +#################################### ПРОЧЕЕ #################################### +# Открытие своего профиля +def user_profile_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("💰 Пополнить", data="user_refill"), + ikb("🎁 Мои покупки", data="user_purchases"), + ) + + return keyboard.as_markup() + + +# Ссылка на поддержку +def user_support_finl(support_login: str) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("💌 Написать в поддержку", url=f"https://t.me/{support_login}"), + ) + + return keyboard.as_markup() + + +################################################################################ +################################### ПЛАТЕЖИ #################################### +# Выбор способа пополнения +async def refill_method_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_payments = await Paymentsx().get() + + if get_payments.status_cryptobot == "True": + keyboard.row(ikb("🔷 CryptoBot", data="user_refill_method:Cryptobot")) + if get_payments.status_yoomoney == "True": + keyboard.row(ikb("🔮 ЮMoney", data="user_refill_method:Yoomoney")) + if get_payments.status_stars == "True": + keyboard.row(ikb("⭐️ Звёзды", data="user_refill_method:Stars")) + + keyboard.row(ikb("🔙 Вернуться", data="user_profile")) + + return keyboard.as_markup() + + +# Проверка платежа +def refill_bill_finl(pay_link: str, pay_receipt: Union[str, int], pay_method: str) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("🌀 Перейти к оплате", url=pay_link), + ).row( + ikb("🔄 Проверить оплату", data=f"Pay:{pay_method}:{pay_receipt}"), + ) + + return keyboard.as_markup() + + +# Выбор способа пополнения при нехватке баланса во время покупки товара +async def refill_method_buy_finl() -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_payments = await Paymentsx().get() + + if get_payments.status_cryptobot == "True": + keyboard.row(ikb("🔷 CryptoBot", data="user_refill_method:Cryptobot")) + if get_payments.status_yoomoney == "True": + keyboard.row(ikb("🔮 ЮMoney", data="user_refill_method:Yoomoney")) + if get_payments.status_stars == "True": + keyboard.row(ikb("⭐️ Звёзды", data="user_refill_method:Stars")) + + keyboard.row(ikb("❌ Закрыть", data="close_this")) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/inline_user_page.py b/tgbot/keyboards/inline_user_page.py new file mode 100644 index 0000000..b09e8fb --- /dev/null +++ b/tgbot/keyboards/inline_user_page.py @@ -0,0 +1,98 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.database import Itemx +from tgbot.keyboards.inline_helper import build_pagination_finl +from tgbot.utils.const_functions import ikb +from tgbot.utils.products_functions import get_positions_items, get_categories_items + + +# fp - flip page + + +################################################################################ +################################ ПОКУПКИ ТОВАРОВ ############################### +# Страницы категорий при покупке товара +async def prod_item_category_swipe_fp(remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_categories = await get_categories_items() + + for count, select in enumerate(range(remover, len(get_categories))): + if count < 10: + category = get_categories[select] + + keyboard.row( + ikb( + category.category_name, + data=f"buy_category_open:{category.category_id}:0", + ) + ) + + buildp_kb = build_pagination_finl(get_categories, f"buy_category_swipe", remover) + keyboard.row(*buildp_kb) + + return keyboard.as_markup() + + +# Страницы позиций для покупки товаров +async def prod_item_position_swipe_fp(remover: int, category_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + get_positions = await get_positions_items(category_id) + + for count, select in enumerate(range(remover, len(get_positions))): + if count < 10: + position = get_positions[select] + get_items = await Itemx().gets(position_id=get_positions[select].position_id) + + keyboard.row( + ikb( + f"{position.position_name} | {position.position_price}₽ | {len(get_items)} шт", + data=f"buy_position_open:{position.position_id}:{remover}", + ) + ) + + buildp_kb = build_pagination_finl(get_positions, f"buy_position_swipe:{category_id}", remover) + keyboard.row(*buildp_kb) + + keyboard.row(ikb("🔙 Вернуться", data=f"buy_category_swipe:0")) + + return keyboard.as_markup() + + +################################################################################ +################################ НАЛИЧИЕ ТОВАРОВ ############################### +# Страницы наличия товаров +def prod_available_swipe_fp(remover_now: int, remover_max: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + if remover_max > 1: + if remover_now > 1 and remover_max >= 3: + keyboard.add( + ikb("⏪", data=f"user_available_swipe:{0}"), + ) + + if remover_now >= 1: + keyboard.add( + ikb("⬅️", data=f"user_available_swipe:{remover_now - 1}"), + ) + + keyboard.add( + ikb(f"{remover_now + 1}/{remover_max}", data="...") + ) + + if remover_now + 1 < remover_max: + keyboard.add( + ikb("➡️", data=f"user_available_swipe:{remover_now + 1}"), + ) + + if remover_now + 1 < remover_max - 1 and remover_max >= 3: + keyboard.add( + ikb("⏩", data=f"user_available_swipe:{remover_max - 1}"), + ) + + keyboard.adjust(5) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/inline_user_products.py b/tgbot/keyboards/inline_user_products.py new file mode 100644 index 0000000..74b61e1 --- /dev/null +++ b/tgbot/keyboards/inline_user_products.py @@ -0,0 +1,41 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import InlineKeyboardMarkup +from aiogram.utils.keyboard import InlineKeyboardBuilder + +from tgbot.utils.const_functions import ikb + + +# Открытие позиции для просмотра +def products_open_finl(position_id: int, category_id: int, remover: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("💰 Купить товар", data=f"buy_item_open:{position_id}:{remover}"), + ).row( + ikb("🔙 Вернуться", data=f"buy_category_open:{category_id}:{remover}"), + ) + + return keyboard.as_markup() + + +# Подтверждение покупки товара +def products_buy_confirm_finl(position_id: int, category_id: int, get_count: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("✅ Подтвердить", data=f"buy_item_confirm:{position_id}:{get_count}"), + ikb("❌ Отменить", data=f"buy_position_open:{position_id}:0"), + ) + + return keyboard.as_markup() + + +# Возврат к позиции при отмене ввода +def products_return_finl(position_id: int, category_id: int) -> InlineKeyboardMarkup: + keyboard = InlineKeyboardBuilder() + + keyboard.row( + ikb("🔙 Вернуться", data=f"buy_position_open:{position_id}:0"), + ) + + return keyboard.as_markup() diff --git a/tgbot/keyboards/reply_main.py b/tgbot/keyboards/reply_main.py new file mode 100644 index 0000000..c603b64 --- /dev/null +++ b/tgbot/keyboards/reply_main.py @@ -0,0 +1,80 @@ +# - *- coding: utf- 8 - *- +from aiogram.types import ReplyKeyboardMarkup +from aiogram.utils.keyboard import ReplyKeyboardBuilder + +from tgbot.data.config import get_admins +from tgbot.utils.const_functions import rkb + + +# Главное меню +def menu_frep(user_id: int) -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("🎁 Купить"), rkb("👤 Профиль"), rkb("🧮 Наличие товаров"), + ).row( + rkb("☎️ Поддержка"), rkb("❔ FAQ"), + ) + + if user_id in get_admins(): + keyboard.row( + rkb("🎁 Управление товарами"), rkb("📊 Статистика"), + ).row( + rkb("⚙️ Настройки"), rkb("🔆 Общие функции"), rkb("🔑 Платежные системы"), + ) + + return keyboard.as_markup(resize_keyboard=True) + + +# Платежные системы +def payments_frep() -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("🔷 CryptoBot"), rkb("🔮 ЮMoney"), rkb("⭐️ Звёзды"), + ).row( + rkb("🔙 Главное меню"), + ) + + return keyboard.as_markup(resize_keyboard=True) + + +# Общие функции +def functions_frep() -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("🔍 Поиск"), rkb("📢 Рассылка"), + ).row( + rkb("🔙 Главное меню"), + ) + + return keyboard.as_markup(resize_keyboard=True) + + +# Настройки бота +def settings_frep() -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("🖍 Изменить данные"), rkb("🕹 Выключатели"), + ).row( + rkb("🔙 Главное меню"), + ) + + return keyboard.as_markup(resize_keyboard=True) + + +# Управление товарами +def items_frep() -> ReplyKeyboardMarkup: + keyboard = ReplyKeyboardBuilder() + + keyboard.row( + rkb("📁 Создать позицию ➕"), rkb("🗃 Создать категорию ➕"), + ).row( + rkb("📁 Изменить позицию 🖍"), rkb("🗃 Изменить категорию 🖍"), + ).row( + rkb("🔙 Главное меню"), rkb("🎁 Добавить товары ➕"), rkb("❌ Удаление"), + ) + + return keyboard.as_markup(resize_keyboard=True) diff --git a/tgbot/middlewares/__init__.py b/tgbot/middlewares/__init__.py new file mode 100644 index 0000000..1a23180 --- /dev/null +++ b/tgbot/middlewares/__init__.py @@ -0,0 +1,20 @@ +# - *- coding: utf- 8 - *- +from aiogram import Dispatcher + +from tgbot.middlewares.middleware_throttling import ThrottlingMiddleware +from tgbot.middlewares.middleware_user import ExistsUserMiddleware + + +# Регистрация пользовательских middleware +def register_all_middlewares(dp: Dispatcher): + dp.callback_query.outer_middleware(ExistsUserMiddleware()) + dp.message.outer_middleware(ExistsUserMiddleware()) + + throttling = ThrottlingMiddleware() + dp.message.middleware(throttling) + dp.callback_query.middleware(throttling) + + +# Совместимое имя для старых импортов +def register_all_middlwares(dp: Dispatcher): + register_all_middlewares(dp) diff --git a/tgbot/middlewares/middleware_throttling.py b/tgbot/middlewares/middleware_throttling.py new file mode 100644 index 0000000..276aaa7 --- /dev/null +++ b/tgbot/middlewares/middleware_throttling.py @@ -0,0 +1,77 @@ +# - *- coding: utf- 8 - *- +import time +from typing import Any, Awaitable, Callable, Dict, Optional, Union + +from aiogram import BaseMiddleware +from aiogram.dispatcher.flags import get_flag +from aiogram.types import CallbackQuery, Message, TelegramObject, User +from cachetools import TTLCache + +from tgbot.data.config import BOT_THROTTLE_RATE + + +class ThrottlingMiddleware(BaseMiddleware): + # Создание хранилищ лимитов + def __init__(self, default_rate: Union[int, float] = BOT_THROTTLE_RATE) -> None: + self.default_rate = default_rate + self.message_users = TTLCache(maxsize=10_000, ttl=600) + self.callback_users = TTLCache(maxsize=10_000, ttl=600) + + # Ограничение частых сообщений и колбэков + async def __call__(self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data): + this_user: Optional[User] = data.get("event_from_user") + + if this_user is None: + return await handler(event, data) + + flag_rate = get_flag(data, "rate") + rate = float(self.default_rate if flag_rate is None else flag_rate) + + if rate == 0: + return await handler(event, data) + + now_time = time.monotonic() + bucket = self._get_bucket(event) + user_key = this_user.id + + if user_key not in bucket: + bucket[user_key] = { + "last_throttled": now_time, + "count_throttled": 0, + "now_rate": rate, + } + + return await handler(event, data) + + if now_time - bucket[user_key]["last_throttled"] >= bucket[user_key]["now_rate"]: + bucket.pop(user_key) + + return await handler(event, data) + + bucket[user_key]["last_throttled"] = now_time + bucket[user_key]["count_throttled"] += 1 + + if bucket[user_key]["count_throttled"] == 1: + bucket[user_key]["now_rate"] = rate + 2 + await self._warn_user(event) + elif bucket[user_key]["count_throttled"] == 2: + bucket[user_key]["now_rate"] = rate + 3 + else: + bucket[user_key]["now_rate"] = rate + 5 + + return None + + # Выбор bucket под тип апдейта + def _get_bucket(self, event: TelegramObject) -> TTLCache: + if isinstance(event, CallbackQuery): + return self.callback_users + + return self.message_users + + # Предупреждение пользователя о спаме + @staticmethod + async def _warn_user(event: TelegramObject) -> None: + if isinstance(event, Message): + await event.reply("❗ Пожалуйста, не спамьте") + elif isinstance(event, CallbackQuery) or hasattr(event, "answer"): + await event.answer("❗ Пожалуйста, не спамьте", cache_time=1) diff --git a/tgbot/middlewares/middleware_user.py b/tgbot/middlewares/middleware_user.py new file mode 100644 index 0000000..9f4bd15 --- /dev/null +++ b/tgbot/middlewares/middleware_user.py @@ -0,0 +1,50 @@ +# - *- coding: utf- 8 - *- +from aiogram import BaseMiddleware +from cachetools import TTLCache + +from tgbot.data.config import BOT_USER_CACHE_TTL +from tgbot.database import Userx +from tgbot.utils.const_functions import clear_html + + +class ExistsUserMiddleware(BaseMiddleware): + # Создание кеша пользователей + def __init__(self, cache_ttl: int = BOT_USER_CACHE_TTL) -> None: + self.users = Userx() + self.cache = TTLCache(maxsize=10_000, ttl=cache_ttl) + + # Добавление или обновление пользователя перед handler-ом + async def __call__(self, handler, event, data): + this_user = data.get("event_from_user") + + if this_user is not None and not this_user.is_bot: + user_id = this_user.id + user_login = (this_user.username or "").lower() + user_name = clear_html(this_user.first_name) or "" + user_surname = clear_html(this_user.last_name) or "" + user_fullname = clear_html(this_user.full_name) or " ".join(filter(None, [user_name, user_surname])) + + user_data = ( + user_login, + user_name, + user_surname, + user_fullname, + ) + + cached_user = self.cache.get(user_id) + + if cached_user is None or cached_user["data"] != user_data: + user = await self.users.upsert( + user_id=user_id, + user_login=user_data[0], + user_name=user_data[1], + user_surname=user_data[2], + user_fullname=user_data[3], + ) + self.cache[user_id] = {"data": user_data, "user": user} + else: + user = cached_user["user"] + + data["User"] = user + + return await handler(event, data) diff --git a/tgbot/routers/__init__.py b/tgbot/routers/__init__.py new file mode 100644 index 0000000..0af4adc --- /dev/null +++ b/tgbot/routers/__init__.py @@ -0,0 +1,53 @@ +# - *- coding: utf- 8 - *- +from aiogram import Dispatcher + +from tgbot.routers import main_errors, main_start, main_missed +from tgbot.routers.admin import admin_menu, admin_functions, admin_payments, admin_products, admin_settings +from tgbot.routers.user import user_menu, user_transactions, user_products +from tgbot.utils.misc.bot_filters import IsAdmin, IsPrivate + + +# Регистрация всех роутеров +def register_all_routers(dp: Dispatcher): + # Подключение фильтров + main_errors.router.message.filter(IsPrivate()) + main_errors.router.callback_query.filter(IsPrivate()) + main_start.router.message.filter(IsPrivate()) + main_start.router.callback_query.filter(IsPrivate()) + main_missed.router.message.filter(IsPrivate()) + main_missed.router.callback_query.filter(IsPrivate()) + + user_menu.router.message.filter(IsPrivate()) + user_menu.router.callback_query.filter(IsPrivate()) + user_products.router.message.filter(IsPrivate()) + user_products.router.callback_query.filter(IsPrivate()) + user_transactions.router.message.filter(IsPrivate()) + user_transactions.router.callback_query.filter(IsPrivate()) + + admin_menu.router.message.filter(IsPrivate(), IsAdmin()) # Работа message роутера только для админов + admin_menu.router.callback_query.filter(IsPrivate(), IsAdmin()) # Работа callback роутера только для админов + admin_functions.router.message.filter(IsPrivate(), IsAdmin()) # Работа message роутера только для админов + admin_functions.router.callback_query.filter(IsPrivate(), IsAdmin()) # Работа callback роутера только для админов + admin_payments.router.message.filter(IsPrivate(), IsAdmin()) # Работа message роутера только для админов + admin_payments.router.callback_query.filter(IsPrivate(), IsAdmin()) # Работа callback роутера только для админов + admin_settings.router.message.filter(IsPrivate(), IsAdmin()) # Работа message роутера только для админов + admin_settings.router.callback_query.filter(IsPrivate(), IsAdmin()) # Работа callback роутера только для админов + admin_products.router.message.filter(IsPrivate(), IsAdmin()) # Работа message роутера только для админов + admin_products.router.callback_query.filter(IsPrivate(), IsAdmin()) # Работа callback роутера только для админов + + # Подключение обязательных роутеров + dp.include_router(main_errors.router) # Роутер ошибки + dp.include_router(main_start.router) # Роутер основных команд + + # Подключение пользовательских роутеров (юзеров и админов) + dp.include_router(user_menu.router) # Юзер роутер + dp.include_router(admin_menu.router) # Админ роутер + dp.include_router(user_products.router) # Юзер роутер + dp.include_router(user_transactions.router) # Юзер роутер + dp.include_router(admin_functions.router) # Админ роутер + dp.include_router(admin_payments.router) # Админ роутер + dp.include_router(admin_settings.router) # Админ роутер + dp.include_router(admin_products.router) # Админ роутер + + # Подключение обязательных роутеров + dp.include_router(main_missed.router) # Роутер пропущенных апдейтов diff --git a/tgbot/routers/admin/__init__.py b/tgbot/routers/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/routers/admin/admin_functions.py b/tgbot/routers/admin/admin_functions.py new file mode 100644 index 0000000..9eafb4c --- /dev/null +++ b/tgbot/routers/admin/admin_functions.py @@ -0,0 +1,327 @@ +# - *- coding: utf- 8 - *- +import asyncio + +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.database import Purchasesx, Refillx, Userx +from tgbot.keyboards.inline_admin import profile_edit_return_finl, mail_confirm_finl +from tgbot.services.api_hosting_text import HostingAPI +from tgbot.utils.const_functions import is_number, to_number, del_message, ded, clear_html, convert_date +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.misc_functions import functions_mail_make +from tgbot.utils.text_functions import open_profile_admin, refill_open_admin, purchase_open_admin + +router = Router(name=__name__) + + +# Поиск чеков и профилей +@router.message(F.text == "🔍 Поиск") +async def functions_find(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await state.set_state("here_find") + await message.answer("🔍 Отправьте айди/логин пользователя или номер чека") + + +# Рассылка +@router.message(F.text == "📢 Рассылка") +async def functions_mail(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await state.set_state("here_mail_message") + await message.answer( + "📢 Отправьте пост для рассылки пользователям\n" + "❕ Поддерживаются посты с любыми медиафайлами", + ) + + +################################################################################ +################################### РАССЫЛКА ################################### +# Принятие текста для рассылки +@router.message(StateFilter("here_mail_message")) +async def functions_mail_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.update_data(here_mail_message=message) + await state.set_state("here_mail_confirm") + + get_users = await Userx().get_all() + + await message.reply( + f"📢 Отправить {len(get_users)} юзерам данный пост?", + reply_markup=mail_confirm_finl(), + ) + + +# Подтверждение отправки рассылки +@router.callback_query(F.data.startswith("mail_confirm:"), StateFilter("here_mail_confirm")) +async def functions_mail_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + send_message = (await state.get_data())['here_mail_message'] + await state.clear() + + if get_status == "Yes": + get_users = await Userx().get_all() + + await call.message.edit_text(f"📢 Рассылка началась... (0/{len(get_users)})") + + await asyncio.create_task(functions_mail_make(bot, send_message, call)) + else: + await call.message.edit_text("📢 Вы отменили отправку рассылки ✅") + + +################################################################################ +##################################### ПОИСК #################################### +# Принятие айди/логина пользователя или чека для поиска +@router.message(F.text, StateFilter("here_find")) +@router.message(F.text.lower().startswith(('.find', 'find'))) +async def functions_find_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + find_data = message.text.lower() + + if ".find" in find_data or "find" in find_data: + if len(find_data.split(" ")) >= 2: + if ".find" in find_data or "find" in find_data: + find_data = message.text.split(" ")[1] + else: + return await message.answer( + "❌ Вы не указали поисковые данные\n" + "🔍 Отправьте айди/логин пользователя или номер чека", + ) + + if find_data.startswith("@") or find_data.startswith("#"): + find_data = find_data[1:] + + if find_data.isdigit(): + get_user = await Userx().get(user_id=find_data) + else: + get_user = await Userx().get(user_login=find_data.lower()) + + get_refill = await Refillx().get(refill_receipt=find_data) + get_purchase = await Purchasesx().get(purchase_receipt=find_data) + + if get_user is None and get_refill is None and get_purchase is None: + return await message.answer( + "❌ Данные не были найдены\n" + "🔍 Отправьте айди/логин пользователя или номер чека", + ) + + await state.clear() + + if get_user is not None: + return await open_profile_admin(bot, message.from_user.id, get_user) + + if get_refill is not None: + return await refill_open_admin(bot, message.from_user.id, get_refill) + + if get_purchase is not None: + return await purchase_open_admin(bot, arSession, message.from_user.id, get_purchase) + + +################################################################################ +############################## УПРАВЛЕНИЕ ПРОФИЛЕМ ############################# +# Обновление профиля пользователя +@router.callback_query(F.data.startswith("admin_user_refresh:")) +async def functions_user_refresh(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + user_id = int(call.data.split(":")[1]) + + get_user = await Userx().get_required(user_id=user_id) + + await state.clear() + + await del_message(call.message) + await open_profile_admin(bot, call.from_user.id, get_user) + + +# Покупки пользователя +@router.callback_query(F.data.startswith("admin_user_purchases:")) +async def functions_user_purchases(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + user_id = int(call.data.split(":")[1]) + + get_user = await Userx().get_required(user_id=user_id) + get_purchases = await Purchasesx().gets(user_id=user_id) + get_purchases = get_purchases[-10:] + + if len(get_purchases) < 1: + return await call.answer("❗ У пользователя отсутствуют покупки", True) + + await call.answer("🎁 Последние 10 покупок") + await del_message(call.message) + + for purchase in get_purchases: + link_items = await ( + await HostingAPI.connect( + bot=bot, + arSession=arSession, + ) + ).upload_text(purchase.purchase_data) + + await call.message.answer( + ded(f""" + 🧾 Чек: #{purchase.purchase_receipt} + 🎁 Товар: {purchase.purchase_position_name} | {purchase.purchase_count}шт | {purchase.purchase_price}₽ + 🕰 Дата покупки: {convert_date(purchase.purchase_unix)} + 🔗 Товары: кликабельно + """), + disable_web_page_preview=True, + ) + + await asyncio.sleep(0.2) + + await open_profile_admin(bot, call.from_user.id, get_user) + + +# Выдача баланса пользователю +@router.callback_query(F.data.startswith("admin_user_balance_add:")) +async def functions_user_balance_add(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + user_id = int(call.data.split(":")[1]) + + await state.update_data(here_user=user_id) + await state.set_state("here_user_add") + + await call.message.edit_text( + "💰 Введите сумму для выдачи баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + +# Принятие суммы для выдачи баланса пользователю +@router.message(F.text, StateFilter("here_user_add")) +async def functions_user_balance_add_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + user_id = (await state.get_data())['here_user'] + + if not is_number(message.text): + return await message.answer( + "❌ Данные были введены неверно\n" + "💰 Введите сумму для выдачи баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + get_amount = to_number(message.text) + + if get_amount <= 0 or get_amount > 1_000_000_000: + return await message.answer( + "❌ Сумма выдачи не может быть меньше 1 и больше 1 000 000 000\n" + "💰 Введите сумму для выдачи баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + await state.clear() + + get_user = await Userx().get_required(user_id=user_id) + await Userx().update( + user_id, + user_balance=round(get_user.user_balance + get_amount, 2), + user_give=round(get_user.user_give + get_amount, 2), + ) + + try: + await bot.send_message( + user_id, + f"💰 Вам было выдано {message.text}₽", + ) + except Exception: + bot_logger.debug("Не удалось уведомить пользователя %s о выдаче баланса", user_id, exc_info=True) + + await message.answer( + f"👤 Пользователь: {get_user.user_name}\n" + f"💰 Выдача баланса: {message.text}₽ | {get_user.user_balance}₽ -> {round(get_user.user_balance + get_amount, 2)}₽" + ) + + get_user = await Userx().get_required(user_id=user_id) + await open_profile_admin(bot, message.from_user.id, get_user) + + +# Изменение баланса пользователю +@router.callback_query(F.data.startswith("admin_user_balance_set:")) +async def functions_user_balance_set(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + user_id = int(call.data.split(":")[1]) + + await state.update_data(here_user=user_id) + await state.set_state("here_user_set") + + await call.message.edit_text( + "💰 Введите сумму для изменения баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + +# Принятие суммы для изменения баланса пользователя +@router.message(F.text, StateFilter("here_user_set")) +async def functions_user_balance_set_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + user_id = (await state.get_data())['here_user'] + + if not is_number(message.text): + return await message.answer( + "❌ Данные были введены неверно\n" + "💰 Введите сумму для изменения баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + get_amount = to_number(message.text) + + if get_amount < -1_000_000_000 or get_amount > 1_000_000_000: + return await message.answer( + "❌ Сумма изменения не может быть больше или меньше (-)1 000 000 000\n" + "💰 Введите сумму для изменения баланса", + reply_markup=profile_edit_return_finl(user_id), + ) + + await state.clear() + + get_user = await Userx().get_required(user_id=user_id) + + if get_amount > get_user.user_balance: + user_give = get_amount - get_user.user_give + else: + user_give = 0 + + await Userx().update( + user_id, + user_balance=get_amount, + user_give=round(get_user.user_give + user_give, 2), + ) + + await message.answer( + f"👤 Пользователь: {get_user.user_name}\n" + f"💰 Установка баланса: {message.text}₽ | {get_user.user_balance}₽ -> {get_amount}₽" + ) + + get_user = await Userx().get_required(user_id=user_id) + await open_profile_admin(bot, message.from_user.id, get_user) + + +# Отправка сообщения пользователю +@router.callback_query(F.data.startswith("admin_user_message:")) +async def functions_user_user_message(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + user_id = int(call.data.split(":")[1]) + + await state.update_data(here_user_id=user_id) + await state.set_state("here_user_message") + + await call.message.edit_text( + "💌 Введите сообщение для отправки\n" + "⚠️ Сообщение будет сразу отправлено пользователю.", + reply_markup=profile_edit_return_finl(user_id), + ) + + +# Принятие сообщения для отправки пользователю +@router.message(F.text, StateFilter("here_user_message")) +async def functions_user_user_message_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + user_id = (await state.get_data())['here_user_id'] + await state.clear() + + get_message = "💌 Сообщение от администратора:\n" + f"{clear_html(message.text)}" + get_user = await Userx().get_required(user_id=user_id) + + try: + await bot.send_message(user_id, get_message) + except Exception: + bot_logger.debug("Не удалось отправить сообщение пользователю %s", user_id, exc_info=True) + await message.reply("❌ Не удалось отправить сообщение") + else: + await message.reply("✅ Сообщение было успешно доставлено") + + await open_profile_admin(bot, message.from_user.id, get_user) diff --git a/tgbot/routers/admin/admin_menu.py b/tgbot/routers/admin/admin_menu.py new file mode 100644 index 0000000..bcebcd1 --- /dev/null +++ b/tgbot/routers/admin/admin_menu.py @@ -0,0 +1,120 @@ +# - *- coding: utf- 8 - *- +import os + +import aiofiles +from aiogram import Router, Bot, F +from aiogram.filters import Command +from aiogram.types import Message, FSInputFile +from aiogram.utils.media_group import MediaGroupBuilder + +from tgbot.data.config import PATH_LOGS, PATH_DATABASE +from tgbot.keyboards.reply_main import payments_frep, settings_frep, functions_frep, items_frep +from tgbot.utils.const_functions import get_date +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.misc_functions import get_statistics + +router = Router(name=__name__) + + +# Платежные системы +@router.message(F.text == "🔑 Платежные системы") +async def admin_payments(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🔑 Настройка платежных системы", + reply_markup=payments_frep(), + ) + + +# Настройки бота +@router.message(F.text == "⚙️ Настройки") +async def admin_settings(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "⚙️ Основные настройки бота", + reply_markup=settings_frep(), + ) + + +# Общие функции +@router.message(F.text == "🔆 Общие функции") +async def admin_functions(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🔆 Общие функции бота", + reply_markup=functions_frep(), + ) + + +# Управление товарами +@router.message(F.text == "🎁 Управление товарами") +async def admin_products(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🎁 Редактирование товаров", + reply_markup=items_frep(), + ) + + +# Статистика бота +@router.message(F.text == "📊 Статистика") +async def admin_statistics(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer(await get_statistics()) + + +# Получение базы данных +@router.message(Command(commands=['db', 'database'])) +async def admin_database(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer_document( + FSInputFile(PATH_DATABASE), + caption=f"📦 #BACKUP | {get_date()}", + ) + + +# Получение логов +@router.message(Command(commands=['log', 'logs'])) +async def admin_log(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + media_group = MediaGroupBuilder( + caption=f"🖨 #LOGS | {get_date()}", + ) + + if os.path.isfile(PATH_LOGS): + media_group.add_document(media=FSInputFile(PATH_LOGS)) + + if os.path.isfile("tgbot/data/sv_log_err.log"): + media_group.add_document(media=FSInputFile("tgbot/data/sv_log_err.log")) + + if os.path.isfile("tgbot/data/sv_log_out.log"): + media_group.add_document(media=FSInputFile("tgbot/data/sv_log_out.log")) + + await message.answer_media_group(media=media_group.build()) + + +# Очистка логов +@router.message(Command(commands=['clear_log', 'clear_logs', 'log_clear', 'logs_clear'])) +async def admin_log_clear(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + if os.path.isfile(PATH_LOGS): + async with aiofiles.open(PATH_LOGS, "w") as file: + await file.write(f"{get_date()} | LOGS WAS CLEAR") + + if os.path.isfile("tgbot/data/sv_log_err.log"): + async with aiofiles.open("tgbot/data/sv_log_err.log", "w") as file: + await file.write(f"{get_date()} | LOGS ERR WAS CLEAR") + + if os.path.isfile("tgbot/data/sv_log_out.log"): + async with aiofiles.open("tgbot/data/sv_log_out.log", "w") as file: + await file.write(f"{get_date()} | LOGS OUT WAS CLEAR") + + await message.answer("🖨 Логи были успешно очищены") diff --git a/tgbot/routers/admin/admin_payments.py b/tgbot/routers/admin/admin_payments.py new file mode 100644 index 0000000..0c8f2c7 --- /dev/null +++ b/tgbot/routers/admin/admin_payments.py @@ -0,0 +1,355 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.database import Paymentsx +from tgbot.keyboards.inline_admin import payment_yoomoney_finl, close_finl, payment_cryptobot_finl, payment_stars_finl +from tgbot.services.api_cryptobot import CryptobotAPI +from tgbot.services.api_stars import StarsAPI +from tgbot.services.api_yoomoney import YoomoneyAPI +from tgbot.utils.const_functions import ded, is_number, to_number +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) + + +# Управление - CryptoBot +@router.message(F.text == "🔷 CryptoBot") +async def payments_cryptobot_open(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🔷 Управление - CryptoBot", + reply_markup=await payment_cryptobot_finl(), + ) + + +# Управление - ЮMoney +@router.message(F.text == "🔮 ЮMoney") +async def payments_yoomoney_open(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🔮 Управление - ЮMoney", + reply_markup=await payment_yoomoney_finl(), + ) + + +# Управление - Звёзды +@router.message(F.text == "⭐️ Звёзды") +async def payments_stars_open(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "⭐️ Управление - Звёзды", + reply_markup=await payment_stars_finl(), + ) + + +################################################################################ +################################### CRYPTOBOT ################################## +# Баланс - CryptoBot +@router.callback_query(F.data == "payment_cryptobot_balance") +async def payments_cryptobot_balance(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + response = await ( + await CryptobotAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).balance() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Информация - CryptoBot +@router.callback_query(F.data == "payment_cryptobot_check") +async def payments_cryptobot_check(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + status, response = await ( + await CryptobotAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).check() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Изменение - CryptoBot +@router.callback_query(F.data == "payment_cryptobot_edit") +async def payments_cryptobot_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.set_state("here_cryptobot_token") + await call.message.edit_text( + ded(f""" + 🔷 Изменение @CryptoBot кошелька - Инструкция + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Создайте Приложение в "Crypto Pay" и отправьте токен + """), + disable_web_page_preview=True, + ) + + +# Выключатель - CryptoBot +@router.callback_query(F.data.startswith("payment_cryptobot_status:")) +async def payments_cryptobot_status(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_payments = await Paymentsx().get() + + if get_status == "True" and get_payments.cryptobot_token == "None": + return await call.answer("❌ Токен данной платежной системы не был добавлен", True) + + await Paymentsx().update(status_cryptobot=get_status) + + await call.message.edit_text( + "🔷 Управление - CryptoBot", + reply_markup=await payment_cryptobot_finl(), + ) + + +############################## ПРИНЯТИЕ CRYPTOBOT ############################## +# Принятие токена Cryptobot +@router.message(StateFilter("here_cryptobot_token")) +async def payments_cryptobot_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + get_token = message.text + + await state.clear() + + cache_message = await message.answer("🔷 Проверка введённых CryptoBot данных... 🔄") + + status, response = await ( + await CryptobotAPI.connect( + bot=bot, + arSession=arSession, + update=message, + skipping_error=True, + token=get_token, + ) + ).check() + + if status: + await Paymentsx().update(cryptobot_token=get_token) + await cache_message.edit_text("🔷 CryptoBot кошелёк был успешно изменён ✅") + else: + await cache_message.edit_text("🔷 Не удалось изменить CryptoBot кошелёк ❌") + + await message.answer( + "🔷 Управление - CryptoBot", + reply_markup=await payment_cryptobot_finl(), + ) + + +################################################################################ +#################################### ЮMoney #################################### +# Баланс - ЮMoney +@router.callback_query(F.data == "payment_yoomoney_balance") +async def payments_yoomoney_balance(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + response = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).balance() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Информация - ЮMoney +@router.callback_query(F.data == "payment_yoomoney_check") +async def payments_yoomoney_check(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + response = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).check() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Изменение - ЮMoney +@router.callback_query(F.data == "payment_yoomoney_edit") +async def payments_yoomoney_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + response = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + ) + ).authorization_get() + + await state.set_state("here_yoomoney_token") + await call.message.edit_text( + ded(f""" + 🔮 Изменение ЮMoney кошелька - Инструкция + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Отправьте ссылку/код из адресной строки + ▪️ {response} + """), + disable_web_page_preview=True, + ) + + +# Выключатель - ЮMoney +@router.callback_query(F.data.startswith("payment_yoomoney_status:")) +async def payments_yoomoney_status(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_payments = await Paymentsx().get() + + if get_status == "True" and get_payments.yoomoney_token == "None": + return await call.answer("❌ Токен данной платежной системы не был добавлен", True) + + await Paymentsx().update(status_yoomoney=get_status) + + await call.message.edit_text( + "🔮 Управление - ЮMoney", + reply_markup=await payment_yoomoney_finl(), + ) + + +################################ ПРИНЯТИЕ ЮMONEY ############################### +# Принятие токена ЮMoney +@router.message(StateFilter("here_yoomoney_token")) +async def payments_yoomoney_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + get_code = message.text + + try: + get_code = get_code[get_code.index("code=") + 5:].replace(" ", "") + except Exception: + bot_logger.debug("ЮMoney code= не найден в сообщении, пробую использовать текст как код", exc_info=True) + + cache_message = await message.answer("🔮 Проверка введённых ЮMoney данных... 🔄") + + status, token, response = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + ) + ).authorization_enter(str(get_code)) + + if status: + await Paymentsx().update(yoomoney_token=token) + + await cache_message.edit_text(response) + + await state.clear() + await message.answer( + "🔮 Управление - ЮMoney", + reply_markup=await payment_yoomoney_finl(), + ) + + +################################################################################ +#################################### ЗВЁЗДЫ #################################### +# Баланс - Звёзды +@router.callback_query(F.data == "payment_stars_balance") +async def payments_stars_balance(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + response = await ( + await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).balance() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Информация - Звёзды +@router.callback_query(F.data == "payment_stars_check") +async def payments_stars_check(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + status, response = await ( + await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=call, + skipping_error=True, + ) + ).check() + + await call.message.answer( + response, + reply_markup=close_finl(), + ) + + +# Изменение - Звёзды +@router.callback_query(F.data == "payment_stars_edit") +async def payments_stars_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_payments = await Paymentsx().get() + + await state.set_state("here_stars_course") + await call.message.edit_text( + ded(f""" + ⭐️ Изменение курса Telegram Stars + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Текущий курс: 1 ⭐️ = {get_payments.stars_course}₽ + ▪️ Отправьте новый курс в рублях за одну звезду + """), + disable_web_page_preview=True, + ) + + +# Выключатель - Звёзды +@router.callback_query(F.data.startswith("payment_stars_status:")) +async def payments_stars_status(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + await Paymentsx().update(status_stars=get_status) + + await call.message.edit_text( + "⭐️ Управление - Звёзды", + reply_markup=await payment_stars_finl(), + ) + + +############################# ПРИНЯТИЕ КУРСА ЗВЁЗД ############################# +# Принятие курса Telegram Stars +@router.message(StateFilter("here_stars_course")) +async def payments_stars_course_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + if not is_number(message.text): + return await message.answer( + "❌ Введите число. Например: 1.5" + ) + + stars_course = float(to_number(message.text)) + + if stars_course <= 0: + return await message.answer("❌ Курс должен быть больше нуля") + + await Paymentsx().update(stars_course=stars_course) + await state.clear() + + await message.answer( + f"⭐️ Курс Telegram Stars изменён: 1 ⭐️ = {stars_course}₽", + ) + + await message.answer( + "⭐️ Управление - Звёзды", + reply_markup=await payment_stars_finl(), + ) diff --git a/tgbot/routers/admin/admin_products.py b/tgbot/routers/admin/admin_products.py new file mode 100644 index 0000000..3ad280f --- /dev/null +++ b/tgbot/routers/admin/admin_products.py @@ -0,0 +1,969 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message, ReactionTypeEmoji + +from tgbot.database import Categoryx, Itemx, Positionx, Settingsx +from tgbot.keyboards.inline_admin import close_finl +from tgbot.keyboards.inline_admin_page import ( + category_edit_swipe_fp, + position_add_swipe_fp, + position_edit_category_swipe_fp, + position_edit_swipe_fp, + item_add_position_swipe_fp, + item_add_category_swipe_fp, + item_delete_swipe_fp, +) +from tgbot.keyboards.inline_admin_products import ( + category_edit_delete_finl, + position_edit_clear_finl, + position_edit_delete_finl, + position_edit_cancel_finl, + category_edit_cancel_finl, + products_removes_finl, + products_removes_categories_finl, + products_removes_positions_finl, + products_removes_items_finl, + item_add_finish_finl, +) +from tgbot.services.api_discord import DiscordAPI +from tgbot.services.api_hosting_text import HostingAPI +from tgbot.utils.const_functions import clear_list, is_number, to_number, del_message, ded, clear_html, gen_id +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.text_functions import category_open_admin, position_open_admin, item_open_admin + +router = Router(name=__name__) + + +# Создание новой категории +@router.message(F.text == "🗃 Создать категорию ➕") +async def prod_category_add(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await state.set_state("here_category_name") + await message.answer("🗃 Введите название для категории") + + +# Выбор категории для редактирования +@router.message(F.text == "🗃 Изменить категорию 🖍") +async def prod_category_edit(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_categories = await Categoryx().get_all() + + if len(get_categories) >= 1: + await message.answer( + "🗃 Выберите категорию для изменения 🖍", + reply_markup=await category_edit_swipe_fp(0), + ) + else: + await message.answer("❌ Отсутствуют категории для изменения категорий") + + +# Создание новой позиции +@router.message(F.text == "📁 Создать позицию ➕") +async def prod_position_add(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_categories = await Categoryx().get_all() + + if len(get_categories) >= 1: + await message.answer( + "📁 Выберите категорию для позиции ➕", + reply_markup=await position_add_swipe_fp(0), + ) + else: + await message.answer("❌ Отсутствуют категории для создания позиции") + + +# Выбор позиции для редактирования +@router.message(F.text == "📁 Изменить позицию 🖍") +async def prod_position_edit(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_categories = await Categoryx().get_all() + + if len(get_categories) >= 1: + await message.answer( + "📁 Выберите позицию для изменения 🖍", + reply_markup=await position_edit_category_swipe_fp(0), + ) + else: + await message.answer("❌ Отсутствуют категории для изменения позиций") + + +# Страницы товаров для добавления +@router.message(F.text == "🎁 Добавить товары ➕") +async def prod_item_add(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_categories = await Categoryx().get_all() + + if len(get_categories) >= 1: + await message.answer( + "🎁 Выберите позицию для товаров ➕", + reply_markup=await item_add_category_swipe_fp(0), + ) + else: + await message.answer("❌ Отсутствуют позиции для добавления товара") + + +# Удаление категорий, позиций или товаров +@router.message(F.text == "❌ Удаление") +async def prod_removes(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🎁 Выберите раздел который хотите удалить ❌\n", + reply_markup=products_removes_finl(), + ) + + +################################################################################ +############################### СОЗДАНИЕ КАТЕГОРИИ ############################# +# Принятие названия категории для её создания +@router.message(F.text, StateFilter('here_category_name')) +async def prod_category_add_name_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + if len(message.text) > 50: + return await message.answer( + "❌ Название не может превышать 50 символов\n" + "🗃 Введите название для категории", + ) + + await state.clear() + + category_id = gen_id(12) + await Categoryx().add(category_id, clear_html(message.text)) + + await category_open_admin(bot, message.from_user.id, category_id, 0) + + +################################################################################ +############################### ИЗМЕНЕНИЕ КАТЕГОРИИ ############################ +# Страница выбора категорий для редактирования +@router.callback_query(F.data.startswith("category_edit_swipe:")) +async def prod_category_edit_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + await call.message.edit_text( + "🗃 Выберите категорию для изменения 🖍", + reply_markup=await category_edit_swipe_fp(remover), + ) + + +# Выбор текущей категории для редактирования +@router.callback_query(F.data.startswith("category_edit_open:")) +async def prod_category_edit_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.clear() + + await del_message(call.message) + await category_open_admin(bot, call.from_user.id, category_id, remover) + + +############################ САМО ИЗМЕНЕНИЕ КАТЕГОРИИ ########################## +# Изменение названия категории +@router.callback_query(F.data.startswith("category_edit_name:")) +async def prod_category_edit_name(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.update_data(here_category_id=category_id) + await state.update_data(here_remover=remover) + await state.set_state("here_category_edit_name") + + await del_message(call.message) + + await call.message.answer( + "🗃 Введите новое название для категории", + reply_markup=category_edit_cancel_finl(category_id, remover), + ) + + +# Принятие нового названия для категории +@router.message(F.text, StateFilter('here_category_edit_name')) +async def prod_category_edit_name_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + category_id = (await state.get_data())['here_category_id'] + remover = (await state.get_data())['here_remover'] + + if len(message.text) > 50: + return await message.answer( + "❌ Название не может превышать 50 символов\n" + "🗃 Введите новое название для категории", + reply_markup=category_edit_cancel_finl(category_id, remover), + ) + + await state.clear() + + await Categoryx().update(category_id, category_name=clear_html(message.text)) + await category_open_admin(bot, message.from_user.id, category_id, remover) + + +# Удаление категории +@router.callback_query(F.data.startswith("category_edit_delete:")) +async def prod_category_edit_delete(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await call.message.edit_text( + "❗ Вы действительно хотите удалить категорию и все её данные?", + reply_markup=category_edit_delete_finl(category_id, remover), + ) + + +# Подтверждение удаления категории +@router.callback_query(F.data.startswith("category_edit_delete_confirm:")) +async def prod_category_edit_delete_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await Categoryx().delete(category_id=category_id) + await Positionx().delete(category_id=category_id) + await Itemx().delete(category_id=category_id) + + await call.answer("🗃 Категория и все её данные были успешно удалены ✅", True) + + get_categories = await Categoryx().get_all() + + if len(get_categories) >= 1: + await call.message.edit_text( + "🗃 Выберите категорию для изменения 🖍", + reply_markup=await category_edit_swipe_fp(remover), + ) + else: + await del_message(call.message) + + +################################################################################ +############################### ДОБАВЛЕНИЕ ПОЗИЦИИ ############################# +# Cтраницы выбора категорий для расположения позиции +@router.callback_query(F.data.startswith("position_add_swipe:")) +async def prod_position_add_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + await call.message.edit_text( + "📁 Выберите категорию для позиции ➕", + reply_markup=await position_add_swipe_fp(remover), + ) + + +# Выбор категории для создания позиции +@router.callback_query(F.data.startswith("position_add_open:")) +async def prod_position_add_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + + await state.update_data(here_category_id=category_id) + await state.set_state("here_position_name") + + await call.message.edit_text("📁 Введите название для позиции") + + +# Принятие названия для создания позиции +@router.message(F.text, StateFilter('here_position_name')) +async def prod_position_add_name_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + if len(message.text) > 50: + return await message.answer( + "❌ Название не может превышать 50 символов\n" + "📁 Введите название для позиции", + ) + + await state.update_data(here_position_name=clear_html(message.text)) + await state.set_state("here_position_price") + + await message.answer("📁 Введите цену для позиции") + + +# Принятие цены позиции для её создания +@router.message(F.text, StateFilter('here_position_price')) +async def prod_position_add_price_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + if not is_number(message.text): + return await message.answer( + "❌ Данные были введены неверно\n" + "📁 Введите цену для позиции", + ) + + if to_number(message.text) > 10_000_000 or to_number(message.text) < 0: + return await message.answer( + "❌ Цена не может быть меньше 0₽ или больше 10 000 000₽\n" + "📁 Введите цену для позиции", + ) + + category_id = (await state.get_data())['here_category_id'] + position_name = (await state.get_data())['here_position_name'] + position_price = to_number(message.text) + position_desc = "None" + position_photo = "None" + position_id = gen_id(12) + await state.clear() + + await Positionx().add( + category_id=category_id, + position_id=position_id, + position_name=position_name, + position_price=position_price, + position_desc=position_desc, + position_photo=position_photo, + ) + + await position_open_admin(bot, position_id, message.from_user.id) + + +################################################################################ +############################ РЕДАКТИРОВАНИЕ ПОЗИЦИИ ############################ +# Страницы выбора категории для редактирования позиции +@router.callback_query(F.data.startswith("position_edit_category_swipe:")) +async def prod_position_edit_category_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + await call.message.edit_text( + "📁 Выберите позицию для изменения 🖍", + reply_markup=await position_edit_category_swipe_fp(remover), + ) + + +# Открытие категории для выбора позиции +@router.callback_query(F.data.startswith("position_edit_category_open:")) +async def prod_position_edit_category_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + + get_category = await Categoryx().get_required(category_id=category_id) + get_positions = await Positionx().gets(category_id=category_id) + + if len(get_positions) >= 1: + await call.message.edit_text( + "📁 Выберите позицию для изменения 🖍", + reply_markup=await position_edit_swipe_fp(0, category_id), + ) + else: + await call.answer(f"📁 Позиции в категории {get_category.category_name} отсутствуют") + + +# Страницы выбора позиции для редактирования +@router.callback_query(F.data.startswith("position_edit_swipe:")) +async def prod_position_edit_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await del_message(call.message) + + await call.message.answer( + "📁 Выберите позицию для изменения 🖍", + reply_markup=await position_edit_swipe_fp(remover, category_id), + ) + + +# Выбор позиции для редактирования +@router.callback_query(F.data.startswith("position_edit_open:")) +async def prod_position_edit_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.clear() + + await del_message(call.message) + await position_open_admin(bot, position_id, call.from_user.id) + + +############################ САМО ИЗМЕНЕНИЕ ПОЗИЦИИ ############################ +# Изменение названия позиции +@router.callback_query(F.data.startswith("position_edit_name:")) +async def prod_position_edit_name(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.update_data(here_position_id=position_id) + await state.update_data(here_remover=remover) + await state.set_state("here_position_edit_name") + + await del_message(call.message) + await call.message.answer( + "📁 Введите новое название для позиции", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + +# Принятие названия позиции для её изменения +@router.message(F.text, StateFilter('here_position_edit_name')) +async def prod_position_edit_name_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + position_id = (await state.get_data())['here_position_id'] + remover = (await state.get_data())['here_remover'] + + if len(message.text) > 50: + return await message.answer( + "❌ Название не может превышать 50 символов\n" + "📁 Введите новое название для позиции", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + await state.clear() + + await Positionx().update(position_id, position_name=clear_html(message.text)) + await position_open_admin(bot, position_id, message.from_user.id) + + +# Изменение цены позиции +@router.callback_query(F.data.startswith("position_edit_price:")) +async def prod_position_edit_price(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.update_data(here_position_id=position_id) + await state.update_data(here_remover=remover) + await state.set_state("here_position_edit_price") + + await del_message(call.message) + await call.message.answer( + "📁 Введите новую цену для позиции", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + +# Принятие цены позиции для её изменения +@router.message(F.text, StateFilter('here_position_edit_price')) +async def prod_position_edit_price_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + position_id = (await state.get_data())['here_position_id'] + remover = (await state.get_data())['here_remover'] + + if not is_number(message.text): + return await message.answer( + "❌ Данные были введены неверно\n" + "📁 Введите новую цену для позиции", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + if to_number(message.text) > 10_000_000 or to_number(message.text) < 0: + return await message.answer( + "❌ Цена не может быть меньше 0₽ или больше 10 000 000₽\n" + "📁 Введите новую цену для позиции", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + await state.clear() + + await Positionx().update(position_id, position_price=to_number(message.text)) + await position_open_admin(bot, position_id, message.from_user.id) + + +# Изменение описания позиции +@router.callback_query(F.data.startswith("position_edit_desc:")) +async def prod_position_edit_desc(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.update_data(here_position_id=position_id) + await state.update_data(here_remover=remover) + await state.set_state("here_position_edit_desc") + + await del_message(call.message) + await call.message.answer( + ded(f""" + 📁 Введите новое описание для позиции + ❕ Вы можете использовать HTML разметку + ❕ Отправьте 0 чтобы пропустить + """), + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + +# Принятие описания позиции для её изменения +@router.message(F.text, StateFilter('here_position_edit_desc')) +async def prod_position_edit_desc_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + position_id = (await state.get_data())['here_position_id'] + remover = (await state.get_data())['here_remover'] + + if len(message.text) > 1200: + return await message.answer( + ded(f""" + ❌ Описание не может превышать 1200 символов + 📁 Введите новое описание для позиции + ❕ Вы можете использовать HTML разметку + ❕ Отправьте 0 чтобы пропустить + """), + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + try: + if message.text != "0": + await (await message.answer(message.text)).delete() + + position_desc = message.text + else: + position_desc = "None" + except Exception: + bot_logger.debug("Некорректная HTML-разметка описания позиции", exc_info=True) + return await message.answer( + ded(f""" + ❌ Ошибка синтаксиса HTML + 📁 Введите новое описание для позиции + ❕ Вы можете использовать HTML разметку + ❕ Отправьте 0 чтобы пропустить + """), + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + await state.clear() + + await Positionx().update(position_id, position_desc=position_desc) + await position_open_admin(bot, position_id, message.from_user.id) + + +# Изменение изображения позиции +@router.callback_query(F.data.startswith("position_edit_photo:")) +async def prod_position_edit_photo(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_settings = await Settingsx().get() + + if get_settings.misc_discord_webhook_url == "None": + return await call.answer("🧿 Отсутствует Дискорд вебхук, добавьте его в настройках", True) + + await state.update_data(here_position_id=position_id) + await state.update_data(here_remover=remover) + await state.set_state("here_position_edit_photo") + + await del_message(call.message) + await call.message.answer( + "📁 Отправьте новое изображение для позиции\n" + "❕ Отправьте 0 чтобы пропустить.", + reply_markup=position_edit_cancel_finl(position_id, remover), + ) + + +# Принятие нового фото для позиции +@router.message((F.text == "0") | F.photo, StateFilter('here_position_edit_photo')) +async def prod_position_edit_photo_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + position_id = (await state.get_data())['here_position_id'] + remover = (await state.get_data())['here_remover'] + + get_settings = await Settingsx().get() + + if get_settings.misc_discord_webhook_url == "None": + return await message.answer( + "🧿 Отсутствует Дискорд вебхук, добавьте его в настройках" + ) + + position_photo = "None" + + if message.photo is not None: + cache_message = await message.answer( + "♻️ Подождите, фотография загружается..." + ) + + file_path = (await bot.get_file(message.photo[-1].file_id)).file_path + photo_path = await bot.download_file(file_path) + + pay_image_status, pay_image_url = await ( + await DiscordAPI.connect( + bot=bot, + arSession=arSession, + update=message, + ) + ).upload_photo(photo_path.read()) + + if pay_image_status: + position_photo = pay_image_url + + await del_message(cache_message) + + await state.clear() + + await Positionx().update(position_id, position_photo=position_photo) + await position_open_admin(bot, position_id, message.from_user.id) + + +# Выгрузка товаров +@router.callback_query(F.data.startswith("position_edit_items:")) +async def prod_position_edit_items(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_position = await Positionx().get_required(position_id=position_id) + get_items = await Itemx().gets(position_id=position_id) + + if len(get_items) >= 1: + save_items = "\n\n".join([item.item_data for item in get_items]) + + link_items = await ( + await HostingAPI.connect( + bot=bot, + arSession=arSession, + ) + ).upload_text(save_items) + + await call.message.answer( + f"🎁 Все товары позиции: {get_position.position_name}\n" + f"🔗 Ссылка: кликабельно", + reply_markup=close_finl(), + disable_web_page_preview=True, + ) + await call.answer(cache_time=5) + else: + await call.answer("❕ В данной позиции отсутствуют товары", True) + + +# Удаление позиции +@router.callback_query(F.data.startswith("position_edit_delete:")) +async def prod_position_edit_delete(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await del_message(call.message) + + await call.message.answer( + "📁 Вы действительно хотите удалить позицию? ❌", + reply_markup=position_edit_delete_finl(position_id, remover), + ) + + +# Подтверждение удаления позиции +@router.callback_query(F.data.startswith("position_edit_delete_confirm:")) +async def prod_position_edit_delete_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_position = await Positionx().get_required(position_id=position_id) + get_positions = await Positionx().gets(category_id=get_position.category_id) + + await Itemx().delete(position_id=position_id) + await Positionx().delete(position_id=position_id) + + await call.answer("📁 Вы успешно удалили позицию и её товары ✅") + + if len(get_positions) >= 1: + await call.message.edit_text( + "📁 Выберите позицию для изменения 🖍", + reply_markup=await position_edit_swipe_fp(remover, get_position.category_id), + ) + else: + await del_message(call.message) + + +# Очистка позиции +@router.callback_query(F.data.startswith("position_edit_clear:")) +async def prod_position_edit_clear(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await del_message(call.message) + await call.message.answer( + "📁 Вы хотите удалить все товары в позиции?", + reply_markup=position_edit_clear_finl(position_id, remover), + ) + + +# Согласие на очистку позиции +@router.callback_query(F.data.startswith("position_edit_clear_confirm:")) +async def prod_position_edit_clear_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await Itemx().delete(position_id=position_id) + await call.answer("📁 Вы успешно удалили все товары в позиции ✅") + + await del_message(call.message) + await position_open_admin(bot, position_id, call.from_user.id) + + +################################################################################ +############################### ДОБАВЛЕНИЕ ТОВАРОВ ############################# +# Страницы выбора категории для добавления товара +@router.callback_query(F.data.startswith("item_add_category_swipe:")) +async def prod_item_add_category_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + await call.message.edit_text( + "🎁 Выберите позицию для товаров ➕", + reply_markup=await item_add_category_swipe_fp(remover), + ) + + +# Открытие категории для выбора позиции +@router.callback_query(F.data.startswith("item_add_category_open:")) +async def prod_item_add_category_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_category = await Categoryx().get_required(category_id=category_id) + get_positions = await Positionx().gets(category_id=category_id) + + await del_message(call.message) + + if len(get_positions) >= 1: + await call.message.answer( + "🎁 Выберите позицию для товаров ➕", + reply_markup=await item_add_position_swipe_fp(0, category_id), + ) + else: + await call.answer(f"🎁 Позиции в категории {get_category.category_name} отсутствуют") + + +# Страницы выбора позиции для добавления товара +@router.callback_query(F.data.startswith("item_add_position_swipe:")) +async def prod_item_add_position_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await call.message.edit_text( + "🎁 Выберите позицию для товаров ➕", + reply_markup=await item_add_position_swipe_fp(remover, category_id), + ) + + +# Выбор позиции для добавления товаров +@router.callback_query(F.data.startswith("item_add_position_open:"), flags={'rate': 0}) +async def prod_item_add_position_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + + get_position = await Positionx().get_required(position_id=position_id) + get_settings = await Settingsx().get() + + await state.update_data(here_add_item_category_id=get_position.category_id) + await state.update_data(here_add_item_position_id=get_position.position_id) + await state.update_data(here_add_item_count=0) + await state.set_state("here_add_items") + + await del_message(call.message) + + if get_settings.misc_method_prod == "skip": + await call.message.answer( + ded(f""" + 🎁 Отправляйте данные товаров + ❗ Товары разделяются одной пустой строчкой. Пример: + Данные товара... + + Данные товара... + + Данные товара... + """), + reply_markup=item_add_finish_finl(position_id), + ) + else: + await call.message.answer( + ded(f""" + 🎁 Отправляйте данные товаров + ❗ Товары каждой новой строчкой. Пример: + Данные товара... + Данные товара... + Данные товара... + """), + reply_markup=item_add_finish_finl(position_id), + ) + + +# Завершение загрузки товаров +@router.callback_query(F.data.startswith('item_add_position_finish:'), flags={'rate': 0}) +async def prod_item_add_finish(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + + try: + count_items = (await state.get_data())['here_add_item_count'] + except Exception: + bot_logger.debug("В state нет счетчика добавленных товаров", exc_info=True) + count_items = 0 + + await state.clear() + + await call.message.edit_reply_markup() + await call.message.answer( + "🎁 Загрузка товаров была успешно завершена ✅\n" + f"❕ Загружено товаров: {count_items}шт", + ) + + await position_open_admin(bot, position_id, call.from_user.id) + + +# Принятие данных товара +@router.message(F.text, StateFilter('here_add_items'), flags={'rate': 0}) +async def prod_item_add_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + cache_message = await message.answer("⌛ Ждите, товары добавляются..") + + get_settings = await Settingsx().get() + + if get_settings.misc_method_prod == "skip": + get_items = clear_list(message.text.split("\n\n")) + else: + get_items = clear_list(message.text.split("\n")) + + item_count = (await state.get_data())['here_add_item_count'] + category_id = (await state.get_data())['here_add_item_category_id'] + position_id = (await state.get_data())['here_add_item_position_id'] + + await state.update_data(here_add_item_count=item_count + len(get_items)) + + await Itemx().add( + user_id=message.from_user.id, + category_id=category_id, + position_id=position_id, + item_datas=get_items, + ) + + await cache_message.edit_text( + f"🎁 Товары в кол-ве {len(get_items)}шт были успешно добавлены ✅", + reply_markup=item_add_finish_finl(position_id), + ) + + +################################################################################ +############################### УДАЛЕНИЕ ТОВАРОВ ############################### +# Страницы удаления товаров +@router.callback_query(F.data.startswith("item_delete_swipe:")) +async def prod_item_delete_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_items = await Itemx().gets(position_id=position_id) + get_position = await Positionx().get_required(position_id=position_id) + + if len(get_items) >= 1: + await del_message(call.message) + + await call.message.answer( + "🎁 Выберите товар для удаления", + reply_markup=await item_delete_swipe_fp(remover, position_id), + ) + else: + await call.answer(f"🎁 Товары в позиции {get_position.position_name} отсутствуют", True) + + +# Удаление товара +@router.callback_query(F.data.startswith("item_delete_open:")) +async def prod_item_delete_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + item_id = int(call.data.split(":")[1]) + + await del_message(call.message) + await item_open_admin(bot, item_id, call.from_user.id) + + +# Подтверждение удаления товара +@router.callback_query(F.data.startswith("item_delete_confirm:")) +async def prod_item_delete_confirm_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + item_id = int(call.data.split(":")[1]) + + get_item = await Itemx().get_required(item_id=item_id) + get_items = await Itemx().gets(position_id=get_item.position_id) + + await Itemx().delete(item_id=item_id) + + await call.message.edit_reply_markup() + await call.message.react([ReactionTypeEmoji(emoji="🔥")]) + + if len(get_items) >= 1: + await call.message.answer( + "🎁 Выберите товар для удаления", + reply_markup=await item_delete_swipe_fp(0, get_item.position_id), + ) + + +################################################################################ +############################### УДАЛЕНИЕ РАЗДЕЛОВ ############################## +# Возвращение к меню удаления разделов +@router.callback_query(F.data == "prod_removes_return") +async def prod_removes_return(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await call.message.edit_text( + "🎁 Выберите раздел который хотите удалить ❌\n", + reply_markup=products_removes_finl(), + ) + + +# Удаление всех категорий +@router.callback_query(F.data == "prod_removes_categories") +async def prod_removes_categories(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_categories = len(await Categoryx().get_all()) + get_positions = len(await Positionx().get_all()) + get_items = len(await Itemx().get_all()) + + await call.message.edit_text( + ded(f""" + ❌ Вы действительно хотите удалить все категории, позиции и товары? + 🗃 Категорий: {get_categories}шт + 📁 Позиций: {get_positions}шт + 🎁 Товаров: {get_items}шт + """), + reply_markup=products_removes_categories_finl(), + ) + + +# Подтверждение удаления всех категорий (позиций и товаров включительно) +@router.callback_query(F.data == "prod_removes_categories_confirm") +async def prod_removes_categories_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_categories = len(await Categoryx().get_all()) + get_positions = len(await Positionx().get_all()) + get_items = len(await Itemx().get_all()) + + await Categoryx().clear() + await Positionx().clear() + await Itemx().clear() + + await call.message.edit_text( + ded(f""" + ✅ Вы успешно удалили все категории + 🗃 Категорий: {get_categories}шт + 📁 Позиций: {get_positions}шт + 🎁 Товаров: {get_items}шт + """) + ) + + +# Удаление всех позиций +@router.callback_query(F.data == "prod_removes_positions") +async def prod_removes_positions(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_positions = len(await Positionx().get_all()) + get_items = len(await Itemx().get_all()) + + await call.message.edit_text( + ded(f""" + ❌ Вы действительно хотите удалить все позиции и товары? + 📁 Позиций: {get_positions}шт + 🎁 Товаров: {get_items}шт + """), + reply_markup=products_removes_positions_finl(), + ) + + +# Подтверждение удаления всех позиций (товаров включительно) +@router.callback_query(F.data == "prod_removes_positions_confirm") +async def prod_position_remove(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_positions = len(await Positionx().get_all()) + get_items = len(await Itemx().get_all()) + + await Positionx().clear() + await Itemx().clear() + + await call.message.edit_text( + ded(f""" + ✅ Вы успешно удалили все позиции + 📁 Позиций: {get_positions}шт + 🎁 Товаров: {get_items}шт + """) + ) + + +# Удаление всех товаров +@router.callback_query(F.data == "prod_removes_items") +async def prod_removes_items(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_items = len(await Itemx().get_all()) + + await call.message.edit_text( + f"❌ Вы действительно хотите удалить все товары?\n" + f"🎁 Товаров: {get_items}шт", + reply_markup=products_removes_items_finl(), + ) + + +# Согласие на удаление всех товаров +@router.callback_query(F.data == "prod_removes_items_confirm") +async def prod_item_remove(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_items = len(await Itemx().get_all()) + + await Itemx().clear() + + await call.message.edit_text( + f"✅ Вы успешно удалили все товары\n" + f"🎁 Товаров: {get_items}шт" + ) diff --git a/tgbot/routers/admin/admin_settings.py b/tgbot/routers/admin/admin_settings.py new file mode 100644 index 0000000..b8ec83d --- /dev/null +++ b/tgbot/routers/admin/admin_settings.py @@ -0,0 +1,376 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.database import Settingsx, Userx +from tgbot.keyboards.inline_admin import settings_status_finl, settings_finl +from tgbot.services.api_discord import DiscordDJ, DiscordAPI +from tgbot.utils.const_functions import ded +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.misc_functions import send_admins, insert_tags + +router = Router(name=__name__) + + +# Изменение данных +@router.message(F.text == "🖍 Изменить данные") +async def settings_data_edit(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Выключатели бота +@router.message(F.text == "🕹 Выключатели") +async def settings_status_edit(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + "🕹 Включение и выключение основных функций", + reply_markup=await settings_status_finl(), + ) + + +################################################################################ +################################## ВЫКЛЮЧАТЕЛИ ################################# +# Включение/выключение тех работ +@router.callback_query(F.data.startswith("settings_status_work:")) +async def settings_status_work(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_user = await Userx().get_required(user_id=call.from_user.id) + await Settingsx().update(status_work=get_status) + + if get_status == "True": + send_text = "🔴 Отправил бота на технические работы" + else: + send_text = "🟢 Вывел бота из технических работ" + + await send_admins( + bot=bot, + text=ded(f""" + 👤 Администратор {get_user.user_name} + {send_text} + """), + not_me=get_user.user_id, + ) + + await call.message.edit_reply_markup(reply_markup=await settings_status_finl()) + + +# Включение/выключение покупок +@router.callback_query(F.data.startswith("settings_status_buy:")) +async def settings_status_buy(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_user = await Userx().get_required(user_id=call.from_user.id) + await Settingsx().update(status_buy=get_status) + + if get_status == "True": + send_text = "🟢 Включил покупки в боте" + else: + send_text = "🔴 Выключил покупки в боте" + + await send_admins( + bot=bot, + text=ded(f""" + 👤 Администратор {get_user.user_name} + {send_text} + """), + not_me=get_user.user_id, + ) + + await call.message.edit_reply_markup(reply_markup=await settings_status_finl()) + + +# Включение/выключение пополнений +@router.callback_query(F.data.startswith("settings_status_refill:")) +async def settings_status_refill(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_user = await Userx().get_required(user_id=call.from_user.id) + await Settingsx().update(status_refill=get_status) + + if get_status == "True": + send_text = "🟢 Включил пополнения в боте" + else: + send_text = "🔴 Выключил пополнения в боте" + + await send_admins( + bot, + f"👤 Администратор {get_user.user_name}\n" + f"{send_text}", + not_me=get_user.user_id, + ) + + await call.message.edit_reply_markup(reply_markup=await settings_status_finl()) + + +# Включение/выключение уведомлений о покупках +@router.callback_query(F.data.startswith("settings_notification_buy:")) +async def settings_notification_buy(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_user = await Userx().get_required(user_id=call.from_user.id) + await Settingsx().update(notification_buy=get_status) + + if get_status == "True": + send_text = "🟢 Включил уведомления о покупках в боте" + else: + send_text = "🔴 Выключил уведомления о покупках в боте" + + await send_admins( + bot, + f"👤 Администратор {get_user.user_name}\n" + f"{send_text}", + not_me=get_user.user_id, + ) + + await call.message.edit_reply_markup(reply_markup=await settings_status_finl()) + + +# Включение/выключение уведомлений о пополнениях +@router.callback_query(F.data.startswith("settings_notification_refill:")) +async def settings_notification_refill(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_status = call.data.split(":")[1] + + get_user = await Userx().get_required(user_id=call.from_user.id) + await Settingsx().update(notification_refill=get_status) + + if get_status == "True": + send_text = "🟢 Включил уведомления о пополнениях в боте" + else: + send_text = "🔴 Выключил уведомления о пополнениях в боте" + + await send_admins( + bot, + f"👤 Администратор {get_user.user_name}\n" + f"{send_text}", + not_me=get_user.user_id, + ) + + await call.message.edit_reply_markup(reply_markup=await settings_status_finl()) + + +################################################################################ +############################### ИЗМЕНЕНИЕ ДАННЫХ ############################### +# Изменение FAQ +@router.callback_query(F.data == "settings_edit_faq") +async def settings_faq_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await state.set_state("here_settings_faq") + await call.message.edit_text( + ded(""" + ❔ Введите новый текст для FAQ + ❕ Вы можете использовать заготовленный синтаксис и HTML разметку: + ▪️ {username} - логин пользоваля + ▪️ {user_id} - айди пользователя + ▪️ {firstname} - имя пользователя + """) + ) + + +# Изменение поддержки +@router.callback_query(F.data == "settings_edit_support") +async def settings_support_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await state.set_state("here_settings_support") + await call.message.edit_text( + "☎️ Отправьте юзернейм для поддержки\n" + "❕ Юзернейм пользователя/бота/канала/чата", + ) + + +# Изменение отображения/скрытия категорий без товаров +@router.callback_query(F.data.startswith("settings_edit_hide_category:")) +async def settings_edit_hide_category(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + status = call.data.split(":")[1] + + await Settingsx().update(misc_hide_category=status) + + await call.message.edit_text( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Изменение отображения/скрытия позиций без товаров +@router.callback_query(F.data.startswith("settings_edit_hide_position:")) +async def settings_edit_hide_position(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + status = call.data.split(":")[1] + + await Settingsx().update(misc_hide_position=status) + + await call.message.edit_text( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Изменение метода добавления товаров +@router.callback_query(F.data.startswith("settings_edit_method_prod:")) +async def settings_edit_method_prod(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + method = call.data.split(":")[1] + + await Settingsx().update(misc_method_prod=method) + + await call.message.edit_text( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Изменение дискорд вебхука +@router.callback_query(F.data == "settings_edit_discord_webhook") +async def settings_discord_edit(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_discord_public_webhook = await ( + DiscordDJ( + arSession=arSession, + bot=bot, + ) + ).export_webhook() + + await state.set_state("here_settings_discord_webhook") + await call.message.edit_text( + ded(f""" + 🧿 Отправьте новый вебхук дискорда + ❕ Для удаления вебхука введите 0 + ❕ Вы можете использовать публичный вебхук, но ответственность за его использование лежит только на вас + ▪️ Публичный вебхук: {get_discord_public_webhook} + """) + ) + + +# Изменение текстового хостинга по умолчанию +@router.callback_query(F.data == "settings_edit_hosting_text") +async def settings_edit_hosting_text(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_settings = await Settingsx().get() + + if get_settings.misc_hosting_text == "telegraph": + await Settingsx().update(misc_hosting_text="pastie") + elif get_settings.misc_hosting_text == "pastie": + await Settingsx().update(misc_hosting_text="friendpaste") + elif get_settings.misc_hosting_text == "friendpaste": + await Settingsx().update(misc_hosting_text="snippet") + elif get_settings.misc_hosting_text == "snippet": + await Settingsx().update(misc_hosting_text="telegraph") + + await call.message.edit_text( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +################################################################################ +################################ ПРИНЯТИЕ ДАННЫХ ############################### +# Принятие FAQ +@router.message(F.text, StateFilter("here_settings_faq")) +async def settings_faq_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + get_message = await insert_tags(message.from_user.id, message.text) + + try: + await (await message.answer(get_message)).delete() + except Exception: + bot_logger.debug("Некорректная HTML-разметка FAQ", exc_info=True) + return await message.answer( + "❌ Ошибка синтаксиса HTML\n" + "❔ Введите новый текст для FAQ", + ) + + await state.clear() + await Settingsx().update(misc_faq=message.text) + + await message.answer( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Принятие поддержки +@router.message(F.text, StateFilter("here_settings_support")) +async def settings_support_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + get_support = message.text + + if get_support.startswith("@"): + get_support = get_support[1:] + + await Settingsx().update(misc_support=get_support) + await state.clear() + + await message.answer( + "🖍 Изменение данных бота", + reply_markup=await settings_finl(), + ) + + +# Принятие дискорд вебхука +@router.message(F.text, StateFilter("here_settings_discord_webhook")) +async def settings_discord_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + get_discord_webhook = message.text + + # Удаление вебхука + if get_discord_webhook == "0": + await Settingsx().update( + misc_discord_webhook_url="None", + misc_discord_webhook_name="None", + ) + + return await message.answer( + "⚙️ Настройки бота", + reply_markup=await settings_finl(), + ) + + # Добавление нового вебхука + cache_message = await message.answer("♻️ Проверка дискорд вебхука..") + + if "api" in get_discord_webhook and "webhooks" in get_discord_webhook: + discord_webhook_status, discord_webhook_name = await ( + await DiscordAPI.connect( + bot=bot, + arSession=arSession, + update=message, + webhook_url=get_discord_webhook, + skipping_error=True, + ) + ).check() + + if discord_webhook_status: + await state.clear() + + await Settingsx().update( + misc_discord_webhook_url=message.text, + misc_discord_webhook_name=discord_webhook_name, + ) + + return await cache_message.edit_text( + "⚙️ Настройки бота", + reply_markup=await settings_finl(), + ) + + # Обработка ошибки добавления вебхука + get_discord_public_webhook = await ( + DiscordDJ( + arSession=arSession, + bot=bot, + ) + ).export_webhook() + + await cache_message.edit_text( + ded(f""" + ❌ Указан некорректный вебхук + ➖➖➖➖➖➖➖➖➖➖ + 🧿 Отправьте новый вебхук дискорда + ❕ Для удаления вебхука введите 0 + ❕ Вы можете использовать публичный вебхук, но ответственность за его использование лежит только на вас + ▪️ Публичный вебхук: {get_discord_public_webhook} + """) + ) diff --git a/tgbot/routers/main_errors.py b/tgbot/routers/main_errors.py new file mode 100644 index 0000000..cb6c59f --- /dev/null +++ b/tgbot/routers/main_errors.py @@ -0,0 +1,35 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router +from aiogram.filters import ExceptionMessageFilter +from aiogram.handlers import ErrorHandler + +from tgbot.utils.misc.bot_logging import bot_logger + +router = Router(name=__name__) + + +# Игнорирование повторного редактирования без падения handler-а +@router.errors(ExceptionMessageFilter( + "Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message") +) +class MessageNotModifiedHandler(ErrorHandler): + # Debug-запись о повторном edit + async def handle(self): + bot_logger.debug( + "Телеграм отказал в повторном редактировании сообщения: %s", + self.exception_message, + exc_info=True, + ) + + +# Логирование всех ошибок, которые дошли до aiogram error router +@router.errors() +class UnknownErrorHandler(ErrorHandler): + # Запись traceback неизвестной ошибки + async def handle(self): + bot_logger.error( + "Ошибка handler-а: %s | %s", + self.exception_name, + self.exception_message, + exc_info=True, + ) diff --git a/tgbot/routers/main_missed.py b/tgbot/routers/main_missed.py new file mode 100644 index 0000000..18ffb58 --- /dev/null +++ b/tgbot/routers/main_missed.py @@ -0,0 +1,37 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.types import CallbackQuery, Message + +from tgbot.utils.const_functions import del_message, ded +from tgbot.utils.misc.bot_models import FSM, ARS + +router = Router(name=__name__) + + +# Колбэк с удалением сообщения +@router.callback_query(F.data == "close_this") +async def main_missed_callback_close(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await del_message(call.message) + + +# Колбэк с обработкой кнопки +@router.callback_query(F.data == "...") +async def main_missed_callback_answer(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await call.answer(cache_time=30) + + +# Обработка всех колбэков которые потеряли стейты после перезапуска скрипта +@router.callback_query() +async def main_missed_callback(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await call.answer("❗️ Кнопка недействительна. Повторите действия заново", True) + + +# Обработка всех неизвестных команд +@router.message() +async def main_missed_message(message: Message, bot: Bot, state: FSM, arSession: ARS): + await message.answer( + ded(f""" + ♦️ Неизвестная команда + ♦️ Введите /start + """), + ) diff --git a/tgbot/routers/main_start.py b/tgbot/routers/main_start.py new file mode 100644 index 0000000..93ded8e --- /dev/null +++ b/tgbot/routers/main_start.py @@ -0,0 +1,146 @@ +# - *- coding: utf- 8 - *- +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import Message, CallbackQuery + +from tgbot.database import Settingsx, Positionx, Categoryx +from tgbot.keyboards.inline_user import user_support_finl +from tgbot.keyboards.inline_user_page import prod_item_position_swipe_fp +from tgbot.keyboards.reply_main import menu_frep +from tgbot.utils.const_functions import ded +from tgbot.utils.misc.bot_filters import IsBuy, IsRefill, IsWork +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.text_functions import position_open_user + +# Игнор-колбэки покупок +prohibit_buy = ( + 'buy_category_swipe', + 'buy_category_open', + 'buy_position_swipe', + 'buy_position_open', + 'buy_item_open', + 'buy_item_confirm', +) + +# Игнор-колбэки пополнений +prohibit_refill = ( + 'user_refill', + 'user_refill_method', + 'Pay:Cryptobot', + 'Pay:Yoomoney', + 'Pay:', +) + +router = Router(name=__name__) + + +################################################################################ +########################### СТАТУС ТЕХНИЧЕСКИХ РАБОТ ########################### +# Фильтр на технические работы - сообщение +@router.message(IsWork()) +async def filter_work_message(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_settings = await Settingsx().get() + + if get_settings.misc_support != "None": + return await message.answer( + "⛔ Бот находится на технических работах", + reply_markup=user_support_finl(get_settings.misc_support), + ) + + await message.answer("⛔ Бот находится на технических работах") + + +# Фильтр на технические работы - колбэк +@router.callback_query(IsWork()) +async def filter_work_callback(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await call.answer("⛔ Бот находится на технических работах.", True) + + +################################################################################ +################################# СТАТУС ПОКУПОК ############################### +# Фильтр на доступность покупок - сообщение +@router.message(IsBuy(), F.text == "🎁 Купить") +@router.message(IsBuy(), StateFilter('here_item_count')) +async def filter_buy_message(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer("⛔ Покупки временно отключены") + + +# Фильтр на доступность покупок - колбэк +@router.callback_query(IsBuy(), F.data.startswith(prohibit_buy)) +async def filter_buy_callback(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await call.answer("⛔ Покупки временно отключены.", True) + + +################################################################################ +############################### СТАТУС ПОПОЛНЕНИЙ ############################## +# Фильтр на доступность пополнения - сообщение +@router.message(IsRefill(), StateFilter('here_refill_amount')) +async def filter_refill_message(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer("⛔ Пополнение временно отключено") + + +# Фильтр на доступность пополнения - колбэк +@router.callback_query(IsRefill(), F.data.startswith(prohibit_refill)) +async def filter_refill_callback(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await call.answer("⛔ Пополнение временно отключено.", True) + + +################################################################################ +#################################### ПРОЧЕЕ #################################### +# Открытие главного меню +@router.message(F.text.in_(('🔙 Главное меню', '/start'))) +async def main_start(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer( + ded(""" + 🔸 Бот готов к использованию. + 🔸 Если не появились вспомогательные кнопки + 🔸 Введите /start + """), + reply_markup=menu_frep(message.from_user.id), + ) + + +# Открытие диплинков +@router.message(F.text.startswith('/start ')) +async def main_start_deeplink(message: Message, bot: Bot, state: FSM, arSession: ARS): + deepling_args = message.text[7:] + + if deepling_args.startswith("p_"): + position_id_raw = deepling_args[2:] + + if not position_id_raw.isdigit(): + return + + position_id = int(position_id_raw) + get_position = await Positionx().get(position_id=position_id) + + if get_position is not None: + await position_open_user(bot, message.from_user.id, position_id, 0) + elif deepling_args.startswith("c_"): + category_id_raw = deepling_args[2:] + + if not category_id_raw.isdigit(): + return + + category_id = int(category_id_raw) + get_category = await Categoryx().get(category_id=category_id) + + if get_category is not None: + await message.answer( + f"🎁 Текущая категория: {get_category.category_name}", + reply_markup=await prod_item_position_swipe_fp(0, category_id), + ) diff --git a/tgbot/routers/user/__init__.py b/tgbot/routers/user/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/routers/user/user_menu.py b/tgbot/routers/user/user_menu.py new file mode 100644 index 0000000..8060d9f --- /dev/null +++ b/tgbot/routers/user/user_menu.py @@ -0,0 +1,187 @@ +# - *- coding: utf- 8 - *- +import asyncio + +from aiogram import Router, Bot, F +from aiogram.filters import Command +from aiogram.types import CallbackQuery, Message + +from tgbot.data.config import BOT_VERSION, get_text_desc, get_text_warning +from tgbot.database import Purchasesx, Settingsx +from tgbot.keyboards.inline_user import user_support_finl +from tgbot.keyboards.inline_user_page import * +from tgbot.services.api_hosting_text import HostingAPI +from tgbot.utils.const_functions import ded, del_message, convert_date +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.misc_functions import insert_tags +from tgbot.utils.products_functions import get_items_available +from tgbot.utils.text_functions import open_profile_user + +router = Router(name=__name__) + + +# Открытие товаров +@router.message(F.text == "🎁 Купить") +async def user_shop(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_categories = await get_categories_items() + + if len(get_categories) >= 1: + await message.answer( + "🎁 Выберите нужный вам товар", + reply_markup=await prod_item_category_swipe_fp(0), + ) + else: + await message.answer("🎁 Увы, товары в данное время отсутствуют") + + +# Открытие профиля +@router.message(F.text == "👤 Профиль") +async def user_profile(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await open_profile_user(bot, message.from_user.id) + + +# Проверка товаров в наличии +@router.message(F.text == "🧮 Наличие товаров") +async def user_available(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + items_available, remover_max, remover_now = await get_items_available(0) + + if len(items_available) >= 1: + await message.answer( + items_available, + reply_markup=prod_available_swipe_fp(remover_now, remover_max), + ) + else: + await message.answer("🎁 Увы, товары в данное время отсутствуют") + + +# Открытие FAQ +@router.message(F.text.in_(('❔ FAQ', '/faq'))) +async def user_faq(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_settings = await Settingsx().get() + + if get_settings.misc_faq == "None": + return await message.answer( + ded(f""" + ❔ Текст FAQ не указан. Измените его в настройках бота. + + ➖➖➖➖➖➖➖➖➖➖ + {get_text_desc()} + + ➖➖➖➖➖➖➖➖➖➖ + {get_text_warning()} + """), + disable_web_page_preview=True, + ) + + await message.answer( + await insert_tags(message.from_user.id, get_settings.misc_faq), + disable_web_page_preview=True, + ) + + +# Открытие сообщения с ссылкой на поддержку +@router.message(F.text.in_(('☎️ Поддержка', '/support'))) +async def user_support(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + get_settings = await Settingsx().get() + + if get_settings.misc_support == "None": + return await message.answer( + ded(f""" + ☎️ Контакты поддержки не указаны. Измените их в настройках бота. + + ➖➖➖➖➖➖➖➖➖➖ + {get_text_desc()} + + ➖➖➖➖➖➖➖➖➖➖ + {get_text_warning()} + """), + disable_web_page_preview=True, + ) + + await message.answer( + "☎️ Нажмите кнопку ниже для связи с Администратором", + reply_markup=user_support_finl(get_settings.misc_support), + ) + + +# Получение версии бота +@router.message(Command(commands=['version'])) +async def admin_version(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer(f"❇️ Текущая версия бота: {BOT_VERSION}") + + +# Получение информации о боте +@router.message(Command(commands=['dj_desc'])) +async def admin_desc(message: Message, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await message.answer(get_text_desc(), disable_web_page_preview=True) + + +################################################################################ +# Переход к профилю +@router.callback_query(F.data == "user_profile") +async def user_profile_return(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + await state.clear() + + await del_message(call.message) + await open_profile_user(bot, call.from_user.id) + + +# Просмотр истории покупок +@router.callback_query(F.data == "user_purchases") +async def user_purchases(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_purchases = await Purchasesx().gets(user_id=call.from_user.id) + get_purchases = get_purchases[-5:] + + if len(get_purchases) >= 1: + await call.answer("🎁 Последние 5 покупок") + await del_message(call.message) + + for purchase in get_purchases: + link_items = await ( + await HostingAPI.connect( + bot=bot, + arSession=arSession, + ) + ).upload_text(purchase.purchase_data) + + await call.message.answer( + ded(f""" + 🧾 Чек: #{purchase.purchase_receipt} + ▪️ Товар: {purchase.purchase_position_name} | {purchase.purchase_count}шт | {purchase.purchase_price}₽ + ▪️ Дата покупки: {convert_date(purchase.purchase_unix)} + ▪️ Товары: кликабельно + """), + disable_web_page_preview=True, + ) + + await asyncio.sleep(0.2) + + await open_profile_user(bot, call.from_user.id) + else: + await call.answer("❗ У вас отсутствуют покупки", True) + + +# Страницы наличия товаров +@router.callback_query(F.data.startswith("user_available_swipe:")) +async def user_available_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + items_available, remover_max, remover_now = await get_items_available(remover) + + await call.message.edit_text( + items_available, + reply_markup=prod_available_swipe_fp(remover_now, remover_max), + ) diff --git a/tgbot/routers/user/user_products.py b/tgbot/routers/user/user_products.py new file mode 100644 index 0000000..5fd98a7 --- /dev/null +++ b/tgbot/routers/user/user_products.py @@ -0,0 +1,276 @@ +# - *- coding: utf- 8 - *- +import asyncio + +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.database import Positionx, Userx, Categoryx, Itemx, Paymentsx, Purchasesx, Settingsx +from tgbot.keyboards.inline_user import refill_method_buy_finl +from tgbot.keyboards.inline_user_page import * +from tgbot.keyboards.inline_user_products import products_buy_confirm_finl, products_return_finl +from tgbot.keyboards.reply_main import menu_frep +from tgbot.utils.const_functions import ded, del_message, convert_date, send_admins +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.products_functions import get_positions_items +from tgbot.utils.text_functions import position_open_user + +router = Router(name=__name__) + + +# Страницы выбора категории для покупки товара +@router.callback_query(F.data.startswith("buy_category_swipe:")) +async def user_buy_category_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + remover = int(call.data.split(":")[1]) + + await call.message.edit_text( + "🎁 Выберите нужный вам товар", + reply_markup=await prod_item_category_swipe_fp(remover), + ) + + +# Открытие категории с выбором позиции для покупки товара +@router.callback_query(F.data.startswith("buy_category_open:")) +async def user_buy_category_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_category = await Categoryx().get_required(category_id=category_id) + get_positions = await get_positions_items(category_id) + + if len(get_positions) >= 1: + await del_message(call.message) + + await call.message.answer( + f"🎁 Текущая категория: {get_category.category_name}", + reply_markup=await prod_item_position_swipe_fp(remover, category_id), + ) + else: + await call.answer( + f"❕ Товары в категории {get_category.category_name} отсутствуют", + True, + cache_time=5, + ) + + +# Страницы выбора позиции для покупки товара +@router.callback_query(F.data.startswith("buy_position_swipe:")) +async def user_buy_position_swipe(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + category_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_category = await Categoryx().get_required(category_id=category_id) + + await del_message(call.message) + await call.message.answer( + f"🎁 Текущая категория: {get_category.category_name}", + reply_markup=await prod_item_position_swipe_fp(remover, category_id), + ) + + +# Открытие позиции для покупки товара +@router.callback_query(F.data.startswith("buy_position_open:")) +async def user_buy_position_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + await state.clear() + + await del_message(call.message) + await position_open_user(bot, call.from_user.id, position_id, remover) + + +#################################### ПОКУПКА ################################### +# Покупка товара +@router.callback_query(F.data.startswith("buy_item_open:")) +async def user_buy_open(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + remover = int(call.data.split(":")[2]) + + get_payments = await Paymentsx().get() + get_position = await Positionx().get_required(position_id=position_id) + get_items = await Itemx().gets(position_id=position_id) + get_user = await Userx().get_required(user_id=call.from_user.id) + + # Проверка, имеется ли на балансе пользователя достаточно средств + if get_user.user_balance < get_position.position_price: + if get_payments.status_cryptobot == "True" or get_payments.status_yoomoney == "True": + await call.message.answer( + "❗ На вашем счёте недостаточно средств\n" + "💰 Выберите способ пополнения баланса", + reply_markup=await refill_method_buy_finl(), + ) + + return await call.answer(cache_time=5) + else: + return await call.answer("❗ У вас недостаточно средств. Пополните баланс", True) + + if len(get_items) < 1: + return await call.answer("❗ Товаров нет в наличии", True) + + # Максимальное количество товаров к покупке, подстроенные под баланс пользователя + if get_position.position_price != 0: + max_buy_count = int(get_user.user_balance / get_position.position_price) + + if max_buy_count > len(get_items): + available_count = len(get_items) + else: + available_count = max_buy_count + else: + available_count = len(get_items) + + # Если в наличии всего один товар, то пропустить ввод количества товаров к покупке + if available_count == 1: + await state.clear() + + await del_message(call.message) + + await call.message.answer( + ded(f""" + 🎁 Вы действительно хотите купить товар(ы)? + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Товар: {get_position.position_name} + ▪️ Количество: 1шт + ▪️ Сумма к покупке: {get_position.position_price}₽ + """), + reply_markup=products_buy_confirm_finl(position_id, get_position.category_id, 1), + ) + else: + await state.update_data(here_buy_position_id=position_id) + await state.set_state("here_item_count") + + await del_message(call.message) + await call.message.answer( + ded(f""" + 🎁 Введите количество товаров для покупки + ❕ От 1 до {available_count} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Товар: {get_position.position_name} - {get_position.position_price}₽ + ▪️ Ваш баланс: {get_user.user_balance}₽ + """), + reply_markup=products_return_finl(position_id, get_position.category_id), + ) + + +# Принятие количества товаров для покупки +@router.message(F.text, StateFilter("here_item_count")) +async def user_buy_count(message: Message, bot: Bot, state: FSM, arSession: ARS): + position_id = (await state.get_data())['here_buy_position_id'] + + get_position = await Positionx().get_required(position_id=position_id) + get_user = await Userx().get_required(user_id=message.from_user.id) + get_items = await Itemx().gets(position_id=position_id) + + # Максимальное количество товаров к покупке, подстроенные под баланс пользователя + if get_position.position_price != 0: + get_count = int(get_user.user_balance / get_position.position_price) + + if get_count > len(get_items): + get_count = len(get_items) + else: + get_count = len(get_items) + + send_message = ded(f""" + 🎁 Введите количество товаров для покупки + ❕ От 1 до {get_count} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Товар: {get_position.position_name} - {get_position.position_price}₽ + ▪️ Ваш баланс: {get_user.user_balance}₽ + """) + + # Если было введено не число + if not message.text.isdigit(): + return await message.answer( + f"❌ Данные были введены неверно\n" + send_message, + reply_markup=products_return_finl(position_id, get_position.category_id), + ) + + get_count = int(message.text) + amount_pay = round(get_position.position_price * get_count, 2) + + # Если товаров нет в наличии + if len(get_items) < 1: + await state.clear() + return await message.answer("🎁 Товар который вы хотели купить, закончился") + + # Если введено кол-во товаров меньше 1 или меньше кол-ва имеющегося в наличии + if get_count < 1 or get_count > len(get_items): + return await message.answer( + f"❌ Неверное количество товаров\n" + send_message, + reply_markup=products_return_finl(position_id, get_position.category_id), + ) + + # Если баланс пользователя меньше, чем общая цена покупки + if get_user.user_balance < amount_pay: + return await message.answer( + f"❌ Недостаточно средств на счете\n" + send_message, + reply_markup=products_return_finl(position_id, get_position.category_id), + ) + + await state.clear() + + await message.answer( + ded(f""" + 🎁 Вы действительно хотите купить товар(ы)? + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Товар: {get_position.position_name} + ▪️ Количество: {get_count}шт + ▪️ Сумма к покупке: {amount_pay}₽ + """), + reply_markup=products_buy_confirm_finl(position_id, get_position.category_id, get_count), + ) + + +# Подтверждение покупки товара +@router.callback_query(F.data.startswith("buy_item_confirm:")) +async def user_buy_confirm(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + position_id = int(call.data.split(":")[1]) + purchase_count = int(call.data.split(":")[2]) + + await call.message.edit_text("⏳") + + purchase_result = await Purchasesx().buy( + user_id=call.from_user.id, + position_id=position_id, + requested_count=purchase_count, + ) + + if purchase_result == "USER_NOT_FOUND": + return await call.message.edit_text("❌ Пользователь не был найден") + elif purchase_result == "POSITION_NOT_FOUND": + return await call.message.edit_text("❌ Позиция не была найдена") + elif purchase_result == "NOT_ENOUGH_ITEMS": + return await call.message.edit_text("❌ В наличии недостаточно товаров. Попробуйте другое кол-во") + elif purchase_result == "NOT_ENOUGH_BALANCE": + return await call.message.edit_text("❌ На вашем балансе недостаточно средств") + + get_user = await Userx().get_required(user_id=call.from_user.id) + get_settings = await Settingsx().get() + + for items in purchase_result.items: + await call.message.answer("\n\n".join(items), parse_mode="None") + await asyncio.sleep(0.3) + + await del_message(call.message) + await call.message.answer( + ded(f""" + ✅ Вы успешно купили товар(ы) + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Чек: #{purchase_result.receipt} + ▪️ Товар: {purchase_result.position_name} | {purchase_result.purchase_count}шт | {purchase_result.purchase_price}₽ + ▪️ Дата покупки: {convert_date(purchase_result.purchase_unix)} + """), + reply_markup=menu_frep(call.from_user.id), + ) + + if get_settings.notification_buy == "True": + await send_admins( + bot, + ded(f""" + 🎁 Покупка товара + + ▪️ Пользователь: @{get_user.user_login} | {get_user.user_name} | {get_user.user_id} + ▪️ Товар: {purchase_result.position_name} | {purchase_result.purchase_count}шт | {purchase_result.purchase_price}₽ + ▪️ Чек: #{purchase_result.receipt} + """) + ) diff --git a/tgbot/routers/user/user_transactions.py b/tgbot/routers/user/user_transactions.py new file mode 100644 index 0000000..92a4c17 --- /dev/null +++ b/tgbot/routers/user/user_transactions.py @@ -0,0 +1,407 @@ +# - *- coding: utf- 8 - *- +from typing import Optional, Tuple + +from aiogram import Router, Bot, F +from aiogram.filters import StateFilter +from aiogram.types import CallbackQuery, Message, PreCheckoutQuery + +from tgbot.database import Paymentsx, Refillx, Userx, Settingsx +from tgbot.keyboards.inline_user import refill_bill_finl, refill_method_finl +from tgbot.services.api_cryptobot import CryptobotAPI +from tgbot.services.api_stars import StarsAPI +from tgbot.services.api_yoomoney import YoomoneyAPI +from tgbot.utils.const_functions import is_number, to_number, gen_id, ded +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import FSM, ARS +from tgbot.utils.misc_functions import send_admins + +min_refill_rub = 5 # Минимальная сумма пополнения в рублях + +router = Router(name=__name__) + + +# Выбор способа пополнения +@router.callback_query(F.data == "user_refill") +async def refill_method_list(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + get_payments = await Paymentsx().get() + + if ( + get_payments.status_cryptobot == "False" and + get_payments.status_yoomoney == "False" and + get_payments.status_stars == "False" + ): + return await call.answer("❗️ Пополнения временно недоступны", True) + + await call.message.edit_text( + "💰 Выберите способ пополнения баланса", + reply_markup=await refill_method_finl(), + ) + + +# Выбор способа пополнения +@router.callback_query(F.data.startswith("user_refill_method:")) +async def refill_method_select(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + refill_method = call.data.split(":")[1] + + get_payments = await Paymentsx().get() + + if refill_method == "Cryptobot" and get_payments.status_cryptobot == "False": + return await call.answer("❌ Пополнение данным способом временно недоступно", True) + elif refill_method == "Yoomoney" and get_payments.status_yoomoney == "False": + return await call.answer("❌ Пополнение данным способом временно недоступно", True) + elif refill_method == "Stars" and get_payments.status_stars == "False": + return await call.answer("❌ Пополнение данным способом временно недоступно", True) + + await state.update_data(here_refill_method=refill_method) + + await state.set_state("here_refill_amount") + await call.message.edit_text("💰 Введите сумму пополнения") + + +################################################################################ +################################### ВВОД СУММЫ ################################# +# Принятие суммы для пополнения средств +@router.message(F.text, StateFilter("here_refill_amount")) +async def refill_amount_get(message: Message, bot: Bot, state: FSM, arSession: ARS): + if not is_number(message.text): + return await message.answer( + ded(f""" + ❌ Данные были введены неверно + 💰 Введите сумму для пополнения средств + """), + ) + + if to_number(message.text) < min_refill_rub or to_number(message.text) > 150_000: + return await message.answer( + ded(f""" + ❌ Неверная сумма пополнения + ❗️ Cумма не должна быть меньше {min_refill_rub}₽ и больше 150 000₽ + 💰 Введите сумму для пополнения средств + """), + ) + + cache_message = await message.answer("♻️ Подождите, платёж генерируется..") + + refill_amount = to_number(message.text) + refill_method = (await state.get_data())['here_refill_method'] + await state.clear() + + # Генерация платежа + if refill_method == "Cryptobot": + bill_message, bill_link, bill_receipt = await ( + await CryptobotAPI.connect( + bot=bot, + arSession=arSession, + update=cache_message, + ) + ).bill(refill_amount) + elif refill_method == "Yoomoney": + bill_message, bill_link, bill_receipt = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + update=cache_message, + ) + ).bill(refill_amount) + elif refill_method == "Stars": + bill_message, bill_link, bill_receipt = await ( + await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=cache_message, + ) + ).bill(refill_amount) + else: + return await cache_message.edit_text( + f"❌ Данный способ пополнения не найден. Попробуйте позже: {refill_method}" + ) + + # Обработка статуса генерации платежа + if bill_message: + await cache_message.edit_text( + bill_message, + reply_markup=refill_bill_finl(bill_link, bill_receipt, refill_method), + ) + else: + await cache_message.edit_text( + f"❌ Не удалось сгенерировать платёж. Попробуйте позже" + ) + + +################################################################################ +############################### ПРОВЕРКА ПЛАТЕЖЕЙ ############################## +# Проверка оплаты - ЮMoney +@router.callback_query(F.data.startswith('Pay:Yoomoney')) +async def refill_check_yoomoney(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + pay_method = call.data.split(":")[1] + pay_receipt = call.data.split(":")[2] + + pay_status, pay_amount = await ( + await YoomoneyAPI.connect( + bot=bot, + arSession=arSession, + update=call, + ) + ).bill_check(pay_receipt) + + if pay_status == 0: + refill_status = await refill_success( + bot=bot, + call=call, + pay_method=pay_method, + pay_amount=pay_amount, + pay_receipt=int(pay_receipt), + pay_comment=pay_receipt, + ) + + if refill_status == "ALREADY": + await call.answer("❗ Ваше пополнение уже зачислено.", True, cache_time=60) + await delete_refill_message(bot, call.message.chat.id, call.message.message_id) + elif refill_status == "USER_NOT_FOUND": + await call.answer("❗ Пользователь не найден. Напишите в поддержку.", True, cache_time=30) + elif pay_status == 1: + await call.answer("❗️ Не удалось проверить платёж. Попробуйте позже", True, cache_time=30) + elif pay_status == 2: + await call.answer("❗️ Оплата не была найдена. Попробуйте позже", True, cache_time=5) + elif pay_status == 3: + await call.answer("❗️ Оплата была произведена не в рублях", True, cache_time=5) + else: + await call.answer(f"❗ Неизвестная ошибка {pay_status}. Обратитесь в поддержку.", True, cache_time=5) + + +# Проверка оплаты - Cryptobot +@router.callback_query(F.data.startswith('Pay:Cryptobot')) +async def refill_check_cryptobot(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + pay_method = call.data.split(":")[1] + pay_comment = call.data.split(":")[2] + + pay_status, pay_amount = await ( + await CryptobotAPI.connect( + bot=bot, + arSession=arSession, + update=call, + ) + ).bill_check(pay_comment) + + if pay_status == 0: + refill_status = await refill_success( + bot=bot, + call=call, + pay_method=pay_method, + pay_amount=pay_amount, + pay_comment=pay_comment, + ) + + if refill_status == "ALREADY": + await call.answer("❗ Ваше пополнение уже зачислено.", True, cache_time=60) + await delete_refill_message(bot, call.message.chat.id, call.message.message_id) + elif refill_status == "USER_NOT_FOUND": + await call.answer("❗ Пользователь не найден. Напишите в поддержку.", True, cache_time=30) + elif pay_status == 1: + await call.answer("❗️ Не удалось проверить платёж. Попробуйте позже", True, cache_time=30) + elif pay_status == 2: + await call.answer("❗️ Оплата не была найдена. Попробуйте позже", True, cache_time=5) + elif pay_status == 3: + await call.answer("❗️ Вы не успели оплатить счёт", True, cache_time=5) + await call.message.edit_reply_markup() + else: + await call.answer(f"❗ Неизвестная ошибка {pay_status}. Обратитесь в поддержку.", True, cache_time=5) + + +# Проверка оплаты - Звёзды +@router.callback_query(F.data.startswith('Pay:Stars')) +async def refill_check_stars(call: CallbackQuery, bot: Bot, state: FSM, arSession: ARS): + pay_method = call.data.split(":")[1] + pay_receipt = int(call.data.split(":")[2]) + + pay_status, pay_amount = await ( + await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=call, + ) + ).bill_check(pay_receipt) + + if pay_status == 0: + refill_status = await refill_success( + bot=bot, + call=call, + pay_method=pay_method, + pay_amount=pay_amount, + pay_receipt=pay_receipt, + ) + + if refill_status == "ALREADY": + await call.answer("❗ Ваше пополнение уже зачислено.", True, cache_time=60) + await delete_refill_message(bot, call.message.chat.id, call.message.message_id) + elif refill_status == "USER_NOT_FOUND": + await call.answer("❗ Пользователь не найден. Напишите в поддержку.", True, cache_time=30) + elif pay_status == 1: + await call.answer("❗️ Не удалось проверить платёж. Попробуйте позже", True, cache_time=30) + elif pay_status == 2: + await call.answer("❗️ Оплата не была найдена. Попробуйте позже", True, cache_time=5) + else: + await call.answer(f"❗ Неизвестная ошибка {pay_status}. Обратитесь в поддержку.", True, cache_time=5) + + +################################################################################ +#################################### ЗВЁЗДЫ #################################### +# Подтверждение платежа Telegram Stars +@router.pre_checkout_query() +async def refill_stars_pre_checkout(query: PreCheckoutQuery, bot: Bot, state: FSM, arSession: ARS): + await ( + await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=query, + ) + ).answer_pre_checkout(query) + + +# Автоматическое зачисление Telegram Stars +@router.message(F.successful_payment) +async def refill_stars_success(message: Message, bot: Bot, state: FSM, arSession: ARS): + payment = message.successful_payment + + if payment is None: + return + + stars_api = await StarsAPI.connect( + bot=bot, + arSession=arSession, + update=message, + ) + + try: + pay_receipt, pay_amount, pay_comment, bill_chat_id, bill_message_id = ( + stars_api.parse_successful_payment(payment) + ) + except ValueError: + bot_logger.warning("Некорректная successful_payment для Telegram Stars", exc_info=True) + return + + refill_status, receipt = await save_refill_success( + bot=bot, + user_id=message.from_user.id, + pay_method="Stars", + pay_amount=pay_amount, + pay_receipt=pay_receipt, + pay_comment=pay_comment, + ) + + if refill_status == "ok": + if bill_chat_id == message.chat.id: + await delete_refill_message(bot, bill_chat_id, bill_message_id) + + await message.answer( + ded(f""" + 💰 Вы пополнили баланс на сумму {pay_amount}₽. Удачи ❤️ + 🧾 Чек: #{receipt} + """) + ) + elif refill_status == "ALREADY": + if bill_chat_id == message.chat.id: + await delete_refill_message(bot, bill_chat_id, bill_message_id) + + await message.answer("❗ Ваше пополнение уже было зачислено.") + elif refill_status == "USER_NOT_FOUND": + await message.answer("❗ Пользователь не найден. Напишите в поддержку.") + + +################################################################################ +#################################### ПРОЧЕЕ #################################### +# Зачисление средств +async def refill_success( + bot: Bot, + call: CallbackQuery, + pay_method: str, + pay_amount: float, + pay_receipt: Optional[int] = None, + pay_comment: Optional[str] = None, +): + user_id = call.from_user.id + + if pay_receipt is None: + pay_receipt = gen_id(12) + if pay_comment is None: + pay_comment = "" + + response_success, receipt = await save_refill_success( + bot=bot, + user_id=user_id, + pay_method=pay_method, + pay_amount=pay_amount, + pay_receipt=pay_receipt, + pay_comment=pay_comment, + ) + + if response_success != "ok": + return response_success + + await call.message.answer( + ded(f""" + 💰 Вы пополнили баланс на сумму {pay_amount}₽. Удачи ❤️ + 🧾 Чек: #{receipt} + """) + ) + await delete_refill_message(bot, call.message.chat.id, call.message.message_id) + + return response_success + + +# Удаление сообщения со счётом +async def delete_refill_message(bot: Bot, chat_id: Optional[int], message_id: Optional[int]) -> None: + if chat_id is None or message_id is None: + return + + try: + await bot.delete_message(chat_id=chat_id, message_id=message_id) + except Exception: + bot_logger.warning("Не удалось удалить сообщение со счётом", exc_info=True) + + +# Сохранение пополнения и уведомление админов +async def save_refill_success( + bot: Bot, + user_id: int, + pay_method: str, + pay_amount: float, + pay_receipt: int, + pay_comment: str, +) -> Tuple[str, int]: + if pay_method == "Yoomoney": + text_method = "ЮMoney" + elif pay_method == "Cryptobot": + text_method = "CryptoBot" + elif pay_method == "Stars": + text_method = "Telegram Stars" + else: + text_method = f"Unknown - {pay_method}" + + response_success = await Refillx().success( + user_id=user_id, + pay_receipt=pay_receipt, + pay_comment=pay_comment, + pay_amount=pay_amount, + pay_method=pay_method, + ) + + if response_success != "ok": + return response_success, pay_receipt + + get_user = await Userx().get_required(user_id=user_id) + get_settings = await Settingsx().get() + + if get_settings.notification_refill == "True": + await send_admins( + bot, + ded(f""" + 💰 Пополнение баланса + + ▪️ Пользователь: @{get_user.user_login} | {get_user.user_name} | {user_id} + ▪️ Сумма пополнения: {pay_amount}₽ ({text_method}) + ▪️ Чек: #{pay_receipt} + """) + ) + + return response_success, pay_receipt diff --git a/tgbot/services/__init__.py b/tgbot/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/services/api_cryptobot.py b/tgbot/services/api_cryptobot.py new file mode 100644 index 0000000..3ae3f02 --- /dev/null +++ b/tgbot/services/api_cryptobot.py @@ -0,0 +1,219 @@ +# - *- coding: utf- 8 - *- +import json +from typing import Any, Dict, Optional, Tuple, Union + +from aiogram import Bot +from aiogram.types import CallbackQuery, Message +from aiohttp import ClientConnectorCertificateError + +from tgbot.database import Paymentsx +from tgbot.utils.const_functions import ded, to_number +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.misc_functions import send_admins + +# Список поддерживаемых валют +ALLOW_CURRENCIES = ['BTC', 'ETH', 'LTC', 'USDT', 'USDC', 'TRX', 'TON', 'BNB', 'SOL', 'DOGE'] + + +# АПИ для работы с CryptoBot +class CryptobotAPI: + # Настройка клиента CryptoBot + def __init__( + self, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + token: str = "None", + adding: bool = False, + skipping_error: bool = False, + ): + self.bot = bot + self.arSession = arSession + self.update = update + self.token = token + self.adding = adding + self.skipping_error = skipping_error + + # Инициализация данных + @classmethod + async def connect( + cls, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + token: Optional[str] = None, + skipping_error: bool = False, + ) -> "CryptobotAPI": + adding = token is not None + + if token is None: + payments = await Paymentsx().get() + token = payments.cryptobot_token + adding = False + + return cls( + bot=bot, + arSession=arSession, + update=update, + token=token, + adding=adding, + skipping_error=skipping_error, + ) + + # Уведомления о неработоспособности кассы/кошелька + async def error_notification(self, error_code: str = "Unknown"): + bot_logger.warning("CryptoBot недоступен: %s", error_code) + + if not self.skipping_error: + if self.adding and self.update is not None: + await self.update.edit_text( + f"🔷 Не удалось добавить CryptoBot кассу ❌\n" + f"❗️ Ошибка: {error_code}" + ) + else: + await send_admins( + self.bot, + f"🔷 CryptoBot недоступен. Как можно быстрее его замените\n" + f"❗️ Ошибка: {error_code}" + ) + + # Проверка кассы/кошелька + async def check(self) -> Tuple[bool, str]: + status, response = await self._request("getMe") + + if status and response['ok']: + return True, ded(f""" + 🔷 CryptoBot кошелёк полностью функционирует ✅ + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Токен: {self.token} + ▪️ Айди: {response['result']['app_id']} + ▪️ Имя: {response['result']['name']} + """) + + return False, "🔷 Не удалось проверить CryptoBot кошелёк ❌" + + # Получение баланса + async def balance(self) -> str: + status, response = await self._request("getBalance") + + if status and response['ok']: + save_currencies = [] + + response_balances = sorted( + response['result'], + reverse=True, + key=lambda balance: to_number(balance['available']), + ) + + for currency in response_balances: + if currency['currency_code'] in ALLOW_CURRENCIES: + save_currencies.append( + f"▪️ {currency['currency_code']}: {currency['available']}" + ) + + save_currencies = "\n".join(save_currencies) + + return ded(f""" + 🔷 Баланс CryptoBot кошелька составляет + ➖➖➖➖➖➖➖➖➖➖ + {save_currencies} + """) + + return "🔷 Не удалось получить баланс CryptoBot кошелька ❌" + + # Создание счета на оплату + async def bill(self, pay_amount: Union[float, int]) -> Tuple[Union[str, bool], str, str]: + assets_currencies = ",".join(ALLOW_CURRENCIES) + + payload = { + 'currency_type': 'fiat', + 'fiat': 'RUB', + 'amount': str(pay_amount), + 'expires_in': 10800, + 'accepted_assets': assets_currencies + } + + status, response = await self._request("createInvoice", payload) + + if status and response['ok']: + bill_message = ded(f""" + 💰 Пополнение баланса + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Для пополнения баланса, нажмите на кнопку ниже + Перейти к оплате и оплатите выставленный вам счёт + ▪️ У вас имеется 3 часа на оплату счета + ▪️ Сумма пополнения: {pay_amount}₽ + ➖➖➖➖➖➖➖➖➖➖ + ❗️ После оплаты, нажмите на Проверить оплату + """) + + return bill_message, response['result']['mini_app_invoice_url'], response['result']['invoice_id'] + + return False, "", "" + + # Проверка счета на оплату + async def bill_check(self, bill_receipt: Optional[Union[str, int]] = None, records: int = 1) -> Tuple[int, float]: + payload = { + 'invoice_ids': f'{bill_receipt}', + 'fiat': 'RUB', + } + + status, response = await self._request("getInvoices", payload) + + pay_status, pay_amount = 1, 0 + + if status and response['ok']: + get_invoice = response['result']['items'][0] + + if get_invoice['status'] == "active": + pay_status = 2 + elif get_invoice['status'] == "expired": + pay_status = 3 + else: + pay_status = 0 + pay_amount = to_number(get_invoice['amount']) + + return pay_status, pay_amount + + # Генерация запроса + async def _request( + self, + method: str, + data: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Any]: + session = await self.arSession.get_session() + + base_url = 'https://pay.crypt.bot/api/' + headers = { + 'Crypto-Pay-API-Token': self.token, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + url = base_url + method + + try: + response = await session.post( + url=url, + headers=headers, + data=data, + ) + + response_data = json.loads((await response.read()).decode()) + + if response.status == 200: + return True, response_data + else: + await self.error_notification(f"{response.status} - {str(response_data)}") + + return False, response_data + except ClientConnectorCertificateError: + bot_logger.warning("Ошибка SSL при запросе CryptoBot", exc_info=True) + await self.error_notification("CERTIFICATE_VERIFY_FAILED") + + return False, "CERTIFICATE_VERIFY_FAILED" + except Exception as ex: + bot_logger.warning("Ошибка запроса CryptoBot", exc_info=True) + await self.error_notification(str(ex)) + + return False, str(ex) diff --git a/tgbot/services/api_discord.py b/tgbot/services/api_discord.py new file mode 100644 index 0000000..e2a4408 --- /dev/null +++ b/tgbot/services/api_discord.py @@ -0,0 +1,239 @@ +# - *- coding: utf- 8 - *- +import asyncio +import json +from io import BytesIO +from typing import Any, Dict, Optional, Tuple, Union + +import ujson +from aiogram import Bot +from aiogram.types import CallbackQuery, Message +from aiohttp import ClientConnectorCertificateError, FormData + +from tgbot.database import Settingsx +from tgbot.utils.const_functions import gen_id, send_errors +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.misc_functions import send_admins + + +class DiscordAPI: + # Настройка клиента Discord webhook + def __init__( + self, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + webhook_url: str = "None", + adding: bool = False, + skipping_error: bool = False, + ): + self.bot = bot + self.arSession = arSession + self.update = update + self.adding = adding + self.skipping_error = skipping_error + + self.webhook_username = "Djimbo Shop | Free Bot" + self.base_url = "https://djimbo.dev/dsapi" + + webhook_url = self._normalize_webhook_url(webhook_url) + + if "/" in webhook_url: + self.webhook_id = webhook_url.split("/")[0] + self.webhook_token = webhook_url.split("/")[1] + else: + self.webhook_id = "" + self.webhook_token = "" + + # Нормализация Discord webhook URL + @staticmethod + def _normalize_webhook_url(webhook_url: str) -> str: + if webhook_url is None: + return "None" + + if webhook_url.startswith("https://discord.com/api/"): + webhook_url = webhook_url[33:] + if webhook_url.startswith("discord.com/api/webhooks/"): + webhook_url = webhook_url[25:] + + return webhook_url + + # Инициализация данных + @classmethod + async def connect( + cls, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + webhook_url: Optional[str] = None, + skipping_error: bool = False, + ) -> "DiscordAPI": + adding = webhook_url is not None + + if webhook_url is None: + settings = await Settingsx().get() + webhook_url = settings.misc_discord_webhook_url + adding = False + + return cls( + bot=bot, + arSession=arSession, + update=update, + webhook_url=webhook_url, + adding=adding, + skipping_error=skipping_error, + ) + + # Рассылка админам о нерабочем вебхуке + async def error_account_admin(self, error_code: str = "Unknown"): + bot_logger.warning("Discord webhook недоступен: %s", error_code) + + if not self.skipping_error: + await send_admins( + self.bot, + f"🧿 Дискорд вебхук недоступен. Как можно быстрее его замените\n" + f"❗️ Ошибка: {error_code}" + ) + + # Проверка вебхука + async def check(self) -> Tuple[bool, str]: + request_url = f"{self.base_url}/webhooks/{self.webhook_id}/{self.webhook_token}" + + status, response = await self._request( + request_url=request_url, + request_method="GET", + ) + + if status and "channel_id" in response: + discord_channel_id = response['channel_id'] + discord_hook_name = response['name'] + + return True, discord_hook_name + + return False, "" + + # Загрузка фотографий + async def upload_photo(self, photo_data: Union[BytesIO, bytes], photo_name: Optional[str] = None) -> Tuple[ + bool, str]: + request_url = f"{self.base_url}/webhooks/{self.webhook_id}/{self.webhook_token}" + + if photo_name is None: + photo_name = str(gen_id(24)) + + if not photo_name.endswith(".png") and not photo_name.endswith(".jpg"): + photo_name = f"{photo_name}.png" + + send_json = { + 'username': self.webhook_username, + 'content': '', + } + + data = FormData() + data.add_field('file', photo_data, filename=photo_name) + data.add_field('payload_json', ujson.dumps(send_json)) + + await asyncio.sleep(1) + status, response = await self._request( + request_url=request_url, + request_method="POST", + request_data=data, + ) + + if "id" in response: + channel_id = response['channel_id'] + message_id = response['id'] + + get_discord_forevercdn = await ( + DiscordDJ( + arSession=self.arSession, + bot=self.bot, + ) + ).export_forevercdn() + + photo_url = f"{get_discord_forevercdn}/attachments/{channel_id}/{message_id}" + + return True, photo_url + + return False, "None" + + # Запрос + async def _request( + self, + request_url: str, + request_method: str, + request_data: Optional[Union[Dict[str, Any], FormData]] = None, + ) -> Tuple[bool, Any]: + session = await self.arSession.get_session() + + await asyncio.sleep(1) + + try: + response = await session.request( + method=request_method, + url=request_url, + data=request_data, + headers={"Accept-Encoding": "gzip, deflate"}, + ) + + response_data = json.loads((await response.read()).decode()) + + if response.status == 200: + return True, response_data + else: + await self.error_account_admin(f"{response.status} - {str(response_data)}") + + return False, response_data + except ClientConnectorCertificateError: + bot_logger.warning("Ошибка SSL при запросе Discord webhook", exc_info=True) + await self.error_account_admin("CERTIFICATE_VERIFY_FAILED") + + return False, "CERTIFICATE_VERIFY_FAILED" + except Exception as ex: + bot_logger.warning("Ошибка запроса Discord webhook", exc_info=True) + await self.error_account_admin(str(ex)) + + return False, str(ex) + + +# Извлечение ссылок для работы с локальным АПИ дискорда +class DiscordDJ: + # Настройка клиента сервисных ссылок Discord + def __init__(self, arSession: ARS, bot: Bot): + self.arSession = arSession + self.bot = bot + + self.const_url = "https://djimbo.dev/autoshop_discord.json" + + # Экспорт публичного дискорд вебхука + async def export_webhook(self) -> str: + session = await self.arSession.get_session() + + try: + response = await session.get( + self.const_url, + headers={"Accept-Encoding": "gzip, deflate"}, + ) + + response_data = json.loads((await response.read()).decode()) + except Exception as ex: + await send_errors(self.bot, 7729051, f"Error getting Discord Webhook - {ex}") + return "None" + else: + return response_data['webhook'] + + # Экспорт ссылки на постоянный CDN + async def export_forevercdn(self) -> str: + session = await self.arSession.get_session() + + try: + response = await session.get( + self.const_url, + headers={"Accept-Encoding": "gzip, deflate"}, + ) + + response_data = json.loads((await response.read()).decode()) + except Exception as ex: + await send_errors(self.bot, 7729051, f"Error getting Discord ForeverCDN - {ex}") + return "None" + else: + return response_data['forevercdn'] diff --git a/tgbot/services/api_hosting_text.py b/tgbot/services/api_hosting_text.py new file mode 100644 index 0000000..f6b06ad --- /dev/null +++ b/tgbot/services/api_hosting_text.py @@ -0,0 +1,157 @@ +# - *- coding: utf- 8 - *- +import asyncio +import json +from dataclasses import dataclass + +from aiogram import Bot + +from tgbot.database import Settingsx +from tgbot.services.api_telegraph import TelegraphAPI +from tgbot.utils.const_functions import send_errors +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS + + +# Model Hosting Text +@dataclass +class ModelHostingText: + telegraph: str = "telegraph" + pastie: str = "pastie" + friendpaste: str = "friendpaste" + snippet: str = "snippet" + + +# API для работы с текстовыми хостингами +class HostingAPI: + # Настройка клиента текстового хостинга + def __init__( + self, + arSession: ARS, + bot: Bot, + text_hosting: str, + ): + self.arSession = arSession + self.bot = bot + self.text_hosting = text_hosting + + # Инициализация данных + @classmethod + async def connect(cls, arSession: ARS, bot: Bot): + settings = await Settingsx().get() + + return cls( + arSession=arSession, + bot=bot, + text_hosting=settings.misc_hosting_text, # telegraph, pastie, friendpaste, snippet + ) + + # Загрузка текста на хостинг + async def upload_text(self, text: str) -> str: + session = await self.arSession.get_session() + + max_attempts = 10 + + for attempt in range(max_attempts): + status_success = True + return_link = "None" + + ############################################################ + ######################## TELEGRAPH ######################### + if self.text_hosting == ModelHostingText.telegraph: + telegraph_api = await TelegraphAPI.connect(arSession=self.arSession) + status, response = await telegraph_api.upload_text(text) + + if status: + return_link = response + else: + await send_errors(self.bot, 4044321, str(response)) + status_success = False + + ############################################################ + ########################## PASTIE ########################## + elif self.text_hosting == ModelHostingText.pastie: + try: + response = await session.request( + method="post", + url="http://pastie.org/pastes/create/", + data={ + 'language': 'plaintext', + 'content': text, + }, + ) + except Exception as ex: + bot_logger.warning("Ошибка загрузки текста в Pastie", exc_info=True) + await send_errors(self.bot, 4719012, str(ex)) + status_success = False + else: + cache_link = response.url + + if "create" in str(cache_link) or str(cache_link) == "http://pastie.org/": + status_success = False + else: + return_link = cache_link + + ############################################################ + ######################## FRIENDPASTE ####################### + elif self.text_hosting == ModelHostingText.friendpaste: + try: + response = await session.request( + method="post", + url="https://www.friendpaste.com/", + json={ + 'language': 'text', + 'title': '', + 'snippet': text, + }, + ) + cache_link = json.loads((await response.read()).decode())['url'] + except Exception as ex: + bot_logger.warning("Ошибка загрузки текста в FriendPaste", exc_info=True) + await send_errors(self.bot, 8434711, str(ex)) + status_success = False + else: + return_link = cache_link + + ############################################################ + ######################### SNIPPET ########################## + elif self.text_hosting == ModelHostingText.snippet: + try: + response = await session.request( + method="post", + url="https://snippet.host/", + data={ + 'title': '', + 'content': text, + 'visibility': '1', + 'expires': 'never', + 'language': 'plain text' + }, + ) + cache_link = response.url + except Exception as ex: + bot_logger.warning("Ошибка загрузки текста в Snippet", exc_info=True) + await send_errors(self.bot, 8900012, str(ex)) + status_success = False + else: + return_link = cache_link + + if status_success: + return str(return_link) + + # pastie -> friendpaste + if self.text_hosting == ModelHostingText.pastie: + self.text_hosting = ModelHostingText.friendpaste + # friendpaste -> snippet + elif self.text_hosting == ModelHostingText.friendpaste: + self.text_hosting = ModelHostingText.snippet + # snippet -> telegraph + elif self.text_hosting == ModelHostingText.snippet: + self.text_hosting = ModelHostingText.telegraph + # telegraph -> pastie + elif self.text_hosting == ModelHostingText.telegraph: + self.text_hosting = ModelHostingText.pastie + + if attempt < max_attempts - 1: + await asyncio.sleep(3) + + return "None" diff --git a/tgbot/services/api_session.py b/tgbot/services/api_session.py new file mode 100644 index 0000000..c27f74b --- /dev/null +++ b/tgbot/services/api_session.py @@ -0,0 +1,31 @@ +# - *- coding: utf- 8 - *- +import ssl +from typing import Optional + +import aiohttp +import certifi + + +# Асинхронная сессия для запросов +class AsyncRequestSession: + # Создание ленивой aiohttp-сессии + def __init__(self) -> None: + self._session: Optional[aiohttp.ClientSession] = None + + # Получение объекта сессии + async def get_session(self) -> aiohttp.ClientSession: + if self._session is None: + ssl_context = ssl.create_default_context(cafile=certifi.where()) + timeout = aiohttp.ClientTimeout(total=15, connect=5, sock_read=10) + connector = aiohttp.TCPConnector(ssl=ssl_context) + + self._session = aiohttp.ClientSession(timeout=timeout, connector=connector) + + return self._session + + # Закрытие сессии + async def close(self) -> None: + if self._session is None: + return None + + await self._session.close() diff --git a/tgbot/services/api_stars.py b/tgbot/services/api_stars.py new file mode 100644 index 0000000..bdb1295 --- /dev/null +++ b/tgbot/services/api_stars.py @@ -0,0 +1,297 @@ +# - *- coding: utf- 8 - *- +import math +from typing import Optional, Tuple, Union + +from aiogram import Bot +from aiogram.types import CallbackQuery, LabeledPrice, Message, PreCheckoutQuery, SuccessfulPayment + +from tgbot.database import Paymentsx +from tgbot.utils.const_functions import ded, gen_id, to_number +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.misc_functions import send_admins + +STARS_CURRENCY = "XTR" +STARS_PAYLOAD_PREFIX = "stars_refill" + + +# АПИ для работы с Telegram Stars +class StarsAPI: + # Настройка клиента Telegram Stars + def __init__( + self, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery, PreCheckoutQuery]] = None, + stars_course: float = 1.5, + skipping_error: bool = False, + ): + self.bot = bot + self.arSession = arSession + self.update = update + self.stars_course = float(stars_course or 1.5) + self.skipping_error = skipping_error + + # Инициализация данных + @classmethod + async def connect( + cls, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery, PreCheckoutQuery]] = None, + skipping_error: bool = False, + ) -> "StarsAPI": + payments = await Paymentsx().get() + + return cls( + bot=bot, + arSession=arSession, + update=update, + stars_course=payments.stars_course, + skipping_error=skipping_error, + ) + + # Уведомление админов о проблеме со Stars + async def error_notification(self, error_code: str = "Unknown") -> None: + bot_logger.warning("Telegram Stars недоступны: %s", error_code) + + if self.skipping_error: + return + + await send_admins( + self.bot, + f"⭐️ Telegram Stars недоступны\n" + f"❗️ Ошибка: {error_code}" + ) + + # Проверка доступности Stars для бота + async def check(self) -> Tuple[bool, str]: + try: + bot_info = await self.bot.get_me() + balance = await self.bot.get_my_star_balance() + except Exception as ex: + bot_logger.warning("Не удалось проверить Telegram Stars", exc_info=True) + await self.error_notification(str(ex)) + + return False, "⭐️ Не удалось проверить Telegram Stars ❌" + + rub_balance = self.stars_to_rub(balance.amount) + + return True, ded(f""" + ⭐️ Telegram Stars полностью функционируют ✅ + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Бот: @{bot_info.username} + ▪️ Баланс: {balance.amount} ⭐️{rub_balance}₽ + ▪️ Курс: 1 ⭐️ = {self.stars_course}₽ + """) + + # Получение баланса Stars у бота + async def balance(self) -> str: + try: + balance = await self.bot.get_my_star_balance() + except Exception as ex: + bot_logger.warning("Не удалось получить баланс Telegram Stars", exc_info=True) + await self.error_notification(str(ex)) + + return "⭐️ Не удалось получить баланс Telegram Stars ❌" + + rub_balance = self.stars_to_rub(balance.amount) + nanostars = balance.nanostar_amount or 0 + + return ded(f""" + ⭐️ Баланс Telegram Stars + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Звёзды: {balance.amount} ⭐️ + ▪️ Нанозвёзды: {nanostars} + ▪️ В рублях по курсу: {rub_balance}₽ + ▪️ Курс: 1 ⭐️ = {self.stars_course}₽ + """) + + # Создание ссылки на счёт Telegram Stars + async def bill(self, pay_amount: Union[float, int]) -> Tuple[Union[str, bool], str, int]: + rub_amount = round(float(pay_amount), 2) + stars_amount = self.rub_to_stars(rub_amount) + bill_chat_id, bill_message_id = self.get_bill_message() + receipt = gen_id(12) + + payload = self.make_payload( + receipt, + rub_amount, + stars_amount, + bill_chat_id, + bill_message_id, + ) + + try: + bill_link = await self.bot.create_invoice_link( + title="Пополнение баланса", + description=f"Пополнение баланса на {rub_amount}₽ через Telegram Stars", + payload=payload, + provider_token="", + currency=STARS_CURRENCY, + prices=[ + LabeledPrice( + label=f"Пополнение на {rub_amount}₽", + amount=stars_amount, + ) + ], + ) + except Exception as ex: + bot_logger.warning("Не удалось создать счёт Telegram Stars", exc_info=True) + await self.error_notification(str(ex)) + + return False, "", 0 + + bill_message = ded(f""" + 💰 Пополнение баланса через Telegram Stars + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Сумма пополнения: {rub_amount}₽ + ▪️ К оплате: {stars_amount} ⭐️ + ▪️ Курс: 1 ⭐️ = {self.stars_course}₽ + ➖➖➖➖➖➖➖➖➖➖ + ❗️ После оплаты средства автоматически зачислятся на ваш баланс + Если сообщение не обновилось, нажмите Проверить оплату + """) + + return bill_message, bill_link, receipt + + # Проверка оплаты по истории Stars + async def bill_check(self, bill_receipt: Optional[Union[str, int]] = None, records: int = 100) -> Tuple[int, float]: + if bill_receipt is None: + return 1, 0 + + try: + transactions = await self.bot.get_star_transactions(limit=records) + except Exception as ex: + bot_logger.warning("Не удалось проверить оплату Telegram Stars", exc_info=True) + await self.error_notification(str(ex)) + + return 1, 0 + + receipt = str(bill_receipt) + + for transaction in transactions.transactions: + source = transaction.source + payload = getattr(source, "invoice_payload", None) + + if not payload: + continue + + payload_data = self.parse_payload(payload) + + if payload_data is None or str(payload_data[0]) != receipt: + continue + + _receipt, rub_amount, stars_amount, _bill_chat_id, _bill_message_id = payload_data + + if transaction.amount != stars_amount: + bot_logger.warning( + "Сумма Telegram Stars не совпала с payload: receipt=%s expected=%s actual=%s", + receipt, + stars_amount, + transaction.amount, + ) + return 1, 0 + + return 0, rub_amount + + return 2, 0 + + # Подтверждение автоматической оплаты + async def answer_pre_checkout(self, query: PreCheckoutQuery) -> None: + payload_data = self.parse_payload(query.invoice_payload) + + if query.currency != STARS_CURRENCY or payload_data is None or query.total_amount != payload_data[2]: + await self.bot.answer_pre_checkout_query( + pre_checkout_query_id=query.id, + ok=False, + error_message="Некорректный счёт Telegram Stars", + ) + return + + await self.bot.answer_pre_checkout_query( + pre_checkout_query_id=query.id, + ok=True, + ) + + # Получение данных успешной оплаты + def parse_successful_payment( + self, + payment: SuccessfulPayment, + ) -> Tuple[int, float, str, Optional[int], Optional[int]]: + payload_data = self.parse_payload(payment.invoice_payload) + + if payment.currency != STARS_CURRENCY or payload_data is None or payment.total_amount != payload_data[2]: + raise ValueError("Некорректная успешная оплата Telegram Stars") + + receipt, rub_amount, _stars_amount, bill_chat_id, bill_message_id = payload_data + + return receipt, rub_amount, payment.telegram_payment_charge_id, bill_chat_id, bill_message_id + + # Возврат оплаты пользователю + async def refund(self, user_id: int, telegram_payment_charge_id: str) -> bool: + try: + return await self.bot.refund_star_payment( + user_id=user_id, + telegram_payment_charge_id=telegram_payment_charge_id, + ) + except Exception as ex: + bot_logger.warning("Не удалось вернуть оплату Telegram Stars", exc_info=True) + await self.error_notification(str(ex)) + + return False + + # Конвертация рублей в звёзды + def rub_to_stars(self, rub_amount: Union[float, int]) -> int: + if self.stars_course <= 0: + raise ValueError("Курс Telegram Stars должен быть больше нуля") + + return max(1, math.ceil(float(rub_amount) / self.stars_course)) + + # Конвертация звёзд в рубли + def stars_to_rub(self, stars_amount: Union[float, int]) -> float: + return round(float(stars_amount) * self.stars_course, 2) + + # Получение данных сообщения со счётом + def get_bill_message(self) -> Tuple[Optional[int], Optional[int]]: + if isinstance(self.update, Message): + return self.update.chat.id, self.update.message_id + + return None, None + + # Сбор payload для телеграм инвойса + @staticmethod + def make_payload( + receipt: int, + rub_amount: float, + stars_amount: int, + bill_chat_id: Optional[int] = None, + bill_message_id: Optional[int] = None, + ) -> str: + payload = f"{STARS_PAYLOAD_PREFIX}:{receipt}:{rub_amount}:{stars_amount}" + + if bill_chat_id is not None and bill_message_id is not None: + payload += f":{bill_chat_id}:{bill_message_id}" + + return payload + + # Разбор payload телеграм инвойса + @staticmethod + def parse_payload( + payload: str, + ) -> Optional[Tuple[int, float, int, Optional[int], Optional[int]]]: + payload_parts = str(payload).split(":") + + if len(payload_parts) not in (4, 6) or payload_parts[0] != STARS_PAYLOAD_PREFIX: + return None + + try: + receipt = int(payload_parts[1]) + rub_amount = float(to_number(payload_parts[2])) + stars_amount = int(payload_parts[3]) + bill_chat_id = int(payload_parts[4]) if len(payload_parts) == 6 else None + bill_message_id = int(payload_parts[5]) if len(payload_parts) == 6 else None + except (TypeError, ValueError): + return None + + return receipt, rub_amount, stars_amount, bill_chat_id, bill_message_id diff --git a/tgbot/services/api_telegraph.py b/tgbot/services/api_telegraph.py new file mode 100644 index 0000000..b1c4ca2 --- /dev/null +++ b/tgbot/services/api_telegraph.py @@ -0,0 +1,87 @@ +# - *- coding: utf- 8 - *- +import json +from typing import Optional, Tuple + +from tgbot.database import Settingsx +from tgbot.utils.const_functions import gen_id +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS + + +# API для работы с телеграфом +class TelegraphAPI: + # Настройка клиента Telegraph + def __init__(self, arSession: ARS, token: Optional[str] = None): + self.arSession = arSession + self.token = token + + self.domain = "https://api.telegra.ph" + + # Инициализация данных + @classmethod + async def connect(cls, arSession: ARS, token: Optional[str] = None): + if token is None: + settings = await Settingsx().get() + token = settings.misc_token_telegraph + + return cls(arSession=arSession, token=token) + + # Загрузка текста в телеграф + async def upload_text(self, text: str) -> Tuple[bool, str]: + session = await self.arSession.get_session() + + if self.token == "None" or self.token is None: + status, response = await self.generate_token() + + if status: + self.token = response + else: + return False, "Error generate token" + + try: + response = await session.request( + method="POST", + url=f"{self.domain}/createPage", + data={ + 'access_token': self.token, + 'title': f'shop{gen_id(18)}', + 'content': json.dumps([{'tag': 'p', 'children': [text]}]), + } + ) + + response_data = json.loads((await response.read()).decode()) + except Exception: + bot_logger.warning("Не удалось загрузить текст в Telegraph", exc_info=True) + return False, "None" + else: + if "ok" in response_data and response_data['ok']: + return True, response_data['result']['url'].replace("\\\\", "") + else: + return False, response_data + + # Генерация нового Telegraph-токена + async def generate_token(self) -> Tuple[bool, str]: + session = await self.arSession.get_session() + + try: + response = await session.request( + method="POST", + url=f"{self.domain}/createAccount", + data={ + 'short_name': 'dj shop', + } + ) + + response_data = json.loads((await response.read()).decode()) + except Exception: + bot_logger.warning("Не удалось создать Telegraph token", exc_info=True) + return False, "None" + else: + if "ok" in response_data and response_data['ok']: + await Settingsx().update( + misc_token_telegraph=response_data['result']['access_token'] + ) + + return True, response_data['result']['access_token'] + else: + return False, response_data diff --git a/tgbot/services/api_yoomoney.py b/tgbot/services/api_yoomoney.py new file mode 100644 index 0000000..ab4c5e9 --- /dev/null +++ b/tgbot/services/api_yoomoney.py @@ -0,0 +1,339 @@ +# - *- coding: utf- 8 - *- +import json +from typing import Any, Dict, Optional, Tuple, Union +from urllib.parse import urlencode + +from aiogram import Bot +from aiogram.types import CallbackQuery, Message +from aiohttp import ClientConnectorCertificateError + +from tgbot.data.config import YOOMONEY_CLIENT_ID +from tgbot.database import Paymentsx +from tgbot.utils.const_functions import ded, gen_id +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.misc_functions import send_admins + + +# АПИ для работы с ЮMoney +class YoomoneyAPI: + # Настройка клиента ЮMoney + def __init__( + self, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + token: str = "None", + adding: bool = False, + skipping_error: bool = False, + ): + self.token = token + self.adding = adding + + self.base_url = 'https://yoomoney.ru/api/' + self.headers = { + 'Authorization': f'Bearer {self.token}', + 'Content-Type': 'application/x-www-form-urlencoded', + } + + self.bot = bot + self.arSession = arSession + self.update = update + self.skipping_error = skipping_error + + # Инициализация данных + @classmethod + async def connect( + cls, + bot: Bot, + arSession: ARS, + update: Optional[Union[Message, CallbackQuery]] = None, + token: Optional[str] = None, + skipping_error: bool = False, + ) -> "YoomoneyAPI": + adding = token is not None + + if token is None: + payments = await Paymentsx().get() + token = payments.yoomoney_token + adding = False + + return cls( + bot=bot, + arSession=arSession, + update=update, + token=token, + adding=adding, + skipping_error=skipping_error, + ) + + # Уведомления о неработоспособности кассы/кошелька + async def error_notification(self, error_code: str = "Unknown"): + bot_logger.warning("ЮMoney недоступен: %s", error_code) + + if not self.skipping_error: + if self.adding and self.update is not None: + await self.update.edit_text( + f"🔮 Не удалось добавить ЮMoney кассу ❌\n" + f"❗️ Ошибка: {error_code}" + ) + else: + await send_admins( + self.bot, + f"🔮 ЮMoney недоступен. Как можно быстрее его замените\n" + f"❗️ Ошибка: {error_code}" + ) + + # Проверка кассы/кошелька + async def check(self) -> str: + status, response = await self._request("account-info") + + if status: + if len(response) >= 1: + if response['identified']: + text_identified = "Присутствует" + else: + text_identified = "Отсутствует" + + if response['account_status'] == "identified": + text_status = "Идентифицированный счет" + elif response['account_status'] == "anonymous": + text_status = "Анонимный счет" + elif response['account_status'] == "named": + text_status = "Именной счет" + else: + text_status = response['account_status'] + + if response['account_type'] == "personal": + text_type = "Пользовательский счет" + elif response['account_type'] == "professional": + text_type = "Профессиональный счет" + else: + text_type = response['account_type'] + + return ded(f""" + 🔮 ЮMoney кошелёк полностью функционирует ✅ + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Кошелёк: {response['account']} + ▪️ Идентификация: {text_identified} + ▪️ Статус аккаунта: {text_status} + ▪️ Тип счета: {text_type} + """) + + return "🔮 Не удалось проверить ЮMoney кошелёк ❌" + + # Получение баланса + async def balance(self) -> str: + status, response = await self._request("account-info") + + if status: + wallet_balance = response['balance'] + + wallet_status, wallet_number = await self.account_info() + + if wallet_status: + return ded(f""" + 🔮 Баланс ЮMoney кошелька составляет + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Кошелёк: {wallet_number} + ▪️ Баланс: {wallet_balance}₽ + """) + + return "🔮 Не удалось получить баланс ЮMoney кошелька ❌" + + # Информация об аккаунте + async def account_info(self) -> Tuple[bool, str]: + status, response = await self._request("account-info") + + try: + return True, response['account'] + except Exception: + bot_logger.warning("Не удалось получить номер ЮMoney", exc_info=True) + return False, "" + + # Получение ссылки на авторизацию + async def authorization_get(self) -> str: + if not YOOMONEY_CLIENT_ID: + return "В .env не заполнен YOOMONEY_CLIENT_ID" + + session = await self.arSession.get_session() + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + + url = "https://yoomoney.ru/oauth/authorize?" + urlencode({ + "client_id": YOOMONEY_CLIENT_ID, + "response_type": "code", + "redirect_uri": "https://yoomoney.ru", + "scope": "account-info operation-history operation-details", + }) + + response = await session.post( + url=url, + headers=headers, + ) + + return str(response.url) + + # Принятие кода авторизации и получение токена + async def authorization_enter(self, get_code: str) -> Tuple[bool, str, str]: + if not YOOMONEY_CLIENT_ID: + return False, "", "❌ В .env не заполнен YOOMONEY_CLIENT_ID" + + session = await self.arSession.get_session() + + headers = { + 'Content-Type': 'application/x-www-form-urlencoded' + } + + url = "https://yoomoney.ru/oauth/token?" + urlencode({ + "code": get_code, + "client_id": YOOMONEY_CLIENT_ID, + "grant_type": "authorization_code", + "redirect_uri": "https://yoomoney.ru", + }) + + response = await session.post( + url=url, + headers=headers, + ) + response_data = json.loads((await response.read()).decode()) + + if "error" in response_data: + error = response_data['error'] + + if error == "invalid_request": + return_message = ded(f""" + ❌ Требуемые параметры запроса отсутствуют или имеют неправильные или недопустимые значения + """) + elif error == "unauthorized_client": + return_message = ded(f""" + ❌ Недопустимое значение параметра 'client_id' или 'client_secret', или приложение + не имеет права запрашивать авторизацию (например, ЮMoney заблокировал его 'client_id') + """) + elif error == "invalid_grant": + return_message = ded(f""" + ❌ В выпуске 'access_token' отказано. ЮMoney не выпускал временный токен, + срок действия токена истек или этот временный токен уже выдан + 'access_token' (повторный запрос токена авторизации с тем же временным токеном) + """) + else: + return_message = f"Unknown error: {error}" + + return False, "", return_message + elif response_data['access_token'] == "": + return False, "", "❌ Не удалось получить токен. Попробуйте всё снова" + + return True, response_data['access_token'], "🔮 ЮMoney кошелёк был успешно изменён ✅" + + # Создание счета на оплату + async def bill(self, pay_amount: Union[float, int]) -> Tuple[Union[str, bool], str, str]: + session = await self.arSession.get_session() + + bill_receipt = str(gen_id(12)) + url = "https://yoomoney.ru/quickpay/confirm.xml?" + + wallet_status, wallet_number = await self.account_info() + + if wallet_status: + pay_amount_bill = pay_amount + (pay_amount * 0.031) + + if float(pay_amount_bill) < 2: + pay_amount_bill = 2.04 + + payload = { + 'receiver': wallet_number, + 'quickpay_form': 'button', + 'targets': 'Добровольное пожертвование', + 'paymentType': 'SB', + 'sum': pay_amount_bill, + 'label': bill_receipt, + } + + for value in payload: + url += str(value).replace("_", "-") + "=" + str(payload[value]) + url += "&" + + bill_link = str((await session.post(url[:-1].replace(" ", "%20"))).url) + + bill_message = ded(f""" + 💰 Пополнение баланса + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Для пополнения баланса, нажмите на кнопку ниже + Перейти к оплате и оплатите выставленный вам счёт + ▪️ У вас имеется 60 минут на оплату счета + ▪️ Сумма пополнения: {pay_amount}₽ + ➖➖➖➖➖➖➖➖➖➖ + ❗️ После оплаты, нажмите на Проверить оплату + """) + + return bill_message, bill_link, bill_receipt + + return False, "", "" + + # Проверка счета на оплату + async def bill_check(self, bill_receipt: Optional[Union[str, int]] = None, records: int = 1) -> Tuple[int, float]: + data = { + 'type': 'deposition', + 'details': 'true', + } + + if bill_receipt is not None: + data['label'] = bill_receipt + if records is not None: + data['records'] = str(records) + + status, response = await self._request("operation-history", data) + + pay_status, pay_amount, pay_currency = 1, 0, 0 + + if status: + pay_status = 2 + + if len(response['operations']) >= 1: + pay_currency = response['operations'][0]['amount_currency'] + pay_amount = response['operations'][0]['amount'] + + pay_status = 3 + + if pay_currency == "RUB": + pay_status = 0 + + return pay_status, pay_amount + + # Генерация запроса + async def _request( + self, + method: str, + data: Optional[Dict[str, Any]] = None, + ) -> Tuple[bool, Any]: + session = await self.arSession.get_session() + + url = self.base_url + method + + try: + response = await session.post( + url=url, + headers=self.headers, + data=data, + ) + + response_data = json.loads((await response.read()).decode()) + + if response.status == 200: + return True, response_data + else: + await self.error_notification(f"{response.status} - {str(response_data)}") + + return False, response_data + except ClientConnectorCertificateError: + bot_logger.warning("Ошибка SSL при запросе ЮMoney", exc_info=True) + await self.error_notification("CERTIFICATE_VERIFY_FAILED") + + return False, "CERTIFICATE_VERIFY_FAILED" + except Exception as ex: + bot_logger.warning("Ошибка запроса ЮMoney", exc_info=True) + await self.error_notification(str(ex)) + + return False, str(ex) diff --git a/tgbot/utils/__init__.py b/tgbot/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/utils/const_functions.py b/tgbot/utils/const_functions.py new file mode 100644 index 0000000..34e468b --- /dev/null +++ b/tgbot/utils/const_functions.py @@ -0,0 +1,388 @@ +# - *- coding: utf- 8 - *- +import html +import secrets +import string +import time +from datetime import datetime +from typing import List, Optional, Union + +from aiogram import Bot +from aiogram.types import ( + CopyTextButton, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + Message, + ReplyKeyboardMarkup, + WebAppInfo, +) +from pytz import timezone + +from tgbot.data.config import BOT_TIMEZONE, get_admins +from tgbot.utils.misc.bot_logging import bot_logger + + +# Быстрая сборка reply-кнопки +def rkb(text: str) -> KeyboardButton: + return KeyboardButton(text=text) + + +# Быстрая сборка inline-кнопки +def ikb( + text: str, + data: Optional[str] = None, + url: Optional[str] = None, + switch: Optional[str] = None, + web: Optional[str] = None, + copy: Optional[str] = None, + login: Optional[str] = None, +) -> InlineKeyboardButton: + if data is not None: + return InlineKeyboardButton(text=text, callback_data=data) + if url is not None: + return InlineKeyboardButton(text=text, url=url) + if switch is not None: + return InlineKeyboardButton(text=text, switch_inline_query=switch) + if web is not None: + return InlineKeyboardButton(text=text, web_app=WebAppInfo(url=web)) + if copy is not None: + return InlineKeyboardButton(text=text, copy_text=CopyTextButton(text=copy)) + if login is not None: + return InlineKeyboardButton(text=text, url=f"https://t.me/{login}") + + raise ValueError("Не указано действие для inline-кнопки") + + +# Удаление сообщения с логированием ошибок +async def del_message(message: Message) -> None: + try: + await message.delete() + except Exception: + bot_logger.debug("Не удалось удалить сообщение", exc_info=True) + + +# Отправка фото или обычного сообщения +async def smart_message( + bot: Bot, + user_id: int, + text: str, + keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + photo: Optional[str] = None, +) -> None: + if photo is not None and photo.title() != "None": + await bot.send_photo( + chat_id=user_id, + photo=photo, + caption=text, + reply_markup=keyboard, + ) + else: + await bot.send_message( + chat_id=user_id, + text=text, + reply_markup=keyboard, + ) + + +# Отправка сообщения всем админам +async def send_admins( + bot: Bot, + text: str, + keyboard: Optional[InlineKeyboardMarkup] = None, + markup: Optional[InlineKeyboardMarkup] = None, + not_me: int = 0, +) -> None: + reply_markup = markup or keyboard + + for admin in get_admins(): + try: + if str(admin) != str(not_me): + await bot.send_message( + admin, + text, + reply_markup=reply_markup, + disable_web_page_preview=True, + ) + except Exception: + bot_logger.warning("Не удалось отправить сообщение админу %s", admin, exc_info=True) + + +# Логирование ошибки и отправка админам +async def send_errors(bot: Bot, error_code: int, error_text: str = "") -> None: + text_error = f"myError {error_code}: {error_text}" + + bot_logger.warning(text_error) + await send_admins(bot, text_error) + + +# Очистка лишних отступов в многострочном тексте +def ded(get_text: str) -> str: + if get_text is not None: + split_text = get_text.split("\n") + + if split_text[0] == "": split_text.pop(0) + if split_text[-1] == "": split_text.pop() + save_text = [] + + for text in split_text: + while text.startswith(" "): + text = text[1:] + + save_text.append(text) + get_text = "\n".join(save_text) + else: + get_text = "" + + return get_text + + +# Экранирование HTML-символов для телеграм разметки +def clear_html(get_text: Optional[str]) -> str: + return html.escape(get_text or "", quote=False) + + +# Очистка пустых и мусорных элементов из списка +def clear_list(get_list: list) -> list: + trash = {"", " ", ".", ",", "\r", "\n"} + + return [value for value in get_list if value not in trash] + + +# Склейка нескольких списков в один +def convert_list(get_lists: List[list]) -> list: + save_lists = [] + + for select_list in get_lists: + cache_list = clear_list(select_list) + + if len(cache_list) > 0: + save_lists += cache_list + + return save_lists + + +# Разделение списка на части нужного размера +def split_list(get_list: list, count: int) -> List[list]: + return [get_list[i:i + count] for i in range(0, len(get_list), count)] + + +# Старое имя для разделения сообщений +def split_messages(get_list: list, count: int) -> List[list]: + return split_list(get_list, count) + + +# Получение текущей даты +def get_date(full: bool = True) -> str: + bot_timezone = timezone(BOT_TIMEZONE) + + if full: + return datetime.now(bot_timezone).strftime("%d.%m.%Y %H:%M:%S") + + return datetime.now(bot_timezone).strftime("%d.%m.%Y") + + +# Получение Unix-времени в секундах или наносекундах +def get_unix(full: bool = False, nano: bool = False) -> int: + if full or nano: + return time.time_ns() + + return int(time.time()) + + +# Конвертация даты в Unix и обратно +def convert_date(from_time, full=True, second=True) -> Union[str, int]: + bot_timezone = timezone(BOT_TIMEZONE) + from_time = str(from_time).strip().replace("-", ".") + + if from_time.isdigit(): + from_timestamp = int(from_time) + + if full: + return datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M:%S") + if second: + return datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M") + + return datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y") + + parts = from_time.split() + + if len(parts) == 2 and ":" in parts[0]: + time_part, date_part = parts + elif len(parts) == 2: + date_part, time_part = parts + else: + date_part, time_part = from_time, "00:00:00" + + date_values = date_part.split(".") + time_values = time_part.split(":") + + if len(time_values) == 2: + time_values.append("0") + + if len(date_values[0]) == 4: + x_year, x_month, x_day = date_values[0], date_values[1], date_values[2] + else: + x_day, x_month, x_year = date_values[0], date_values[1], date_values[2] + + date_time = datetime( + int(x_year), + int(x_month), + int(x_day), + int(time_values[0]), + int(time_values[1]), + int(time_values[2]), + ) + date_time = bot_timezone.localize(date_time) + + return int(date_time.timestamp()) + + +# Генерация числового ID +def gen_id(len_id: int = 16) -> int: + if len_id <= 0: + raise ValueError("Длина ID должна быть больше нуля") + + first_digit = secrets.choice("123456789") + other_digits = "".join(secrets.choice(string.digits) for _ in range(len_id - 1)) + + return int(f"{first_digit}{other_digits}") + + +# Генерация пароля под разные сценарии +def gen_password(len_password: int = 16, type_password: str = "default") -> str: + if len_password <= 0: + raise ValueError("Длина пароля должна быть больше нуля") + + if type_password == "default": + alphabet = string.ascii_letters + string.digits + elif type_password == "letter": + alphabet = string.ascii_letters + elif type_password == "number": + alphabet = string.digits + elif type_password == "onechar": + alphabet = string.digits + else: + raise ValueError("Неизвестный тип пароля") + + random_chars = "".join(secrets.choice(alphabet) for _ in range(len_password)) + + if type_password == "onechar": + random_chars = f"{secrets.choice(string.ascii_letters)}{random_chars[1:]}" + + return random_chars + + +# Склонение единицы времени под число +def convert_times(get_time: int, get_type: str = "day") -> str: + get_time = int(get_time) + + if get_time < 0: + get_time = 0 + + if get_type == "second": + values = ["секунда", "секунды", "секунд"] + elif get_type == "minute": + values = ["минута", "минуты", "минут"] + elif get_type == "hour": + values = ["час", "часа", "часов"] + elif get_type == "day": + values = ["день", "дня", "дней"] + elif get_type == "month": + values = ["месяц", "месяца", "месяцев"] + else: + values = ["год", "года", "лет"] + + if get_time % 10 == 1 and get_time % 100 != 11: + count = 0 + elif 2 <= get_time % 10 <= 4 and (get_time % 100 < 10 or get_time % 100 >= 20): + count = 1 + else: + count = 2 + + return f"{get_time} {values[count]}" + + +# Старое имя для склонения дней +def convert_day(day: int) -> str: + return convert_times(day) + + +# Приведение строки или числа к bool +def is_bool(value: Union[bool, str, int]) -> bool: + value = str(value).strip().lower() + + if value in ("y", "yes", "t", "true", "on", "1"): + return True + if value in ("n", "no", "f", "false", "off", "0"): + return False + + raise ValueError(f"Некорректное bool-значение: {value}") + + +# Форматирование числа без лишних нулей +def snum(amount: Union[int, float], remains: int = 2) -> str: + format_str = "{:." + str(remains) + "f}" + str_amount = format_str.format(float(amount)) + + if remains != 0 and "." in str_amount: + remains_find = str_amount.find(".") + remains_save = remains_find + 8 - (8 - remains) + 1 + str_amount = str_amount[:remains_save] + + if "." in str(str_amount): + while str(str_amount).endswith("0"): + str_amount = str(str_amount)[:-1] + + if str(str_amount).endswith("."): + str_amount = str(str_amount)[:-1] + + return str(str_amount) + + +# Приведение значения к int или float +def to_float(get_number, remains: int = 2) -> Union[int, float]: + value = str(get_number).strip().replace(" ", "").replace(",", ".") + number = round(float(value), remains) + + if number.is_integer(): + return int(number) + + return number + + +# Старое имя для приведения числа +def to_number(get_number, remains: int = 2) -> Union[int, float]: + return to_float(get_number, remains) + + +# Округление числа до int +def to_int(get_number: float) -> int: + value = str(get_number).replace(",", ".") + + return int(round(float(value))) + + +# Проверка, является ли значение числом +def is_number(get_number: Union[str, int, float]) -> bool: + if str(get_number).isdigit(): + return True + + value = str(get_number).replace(",", ".") + + try: + float(value) + return True + except (TypeError, ValueError): + return False + + +# Форматирование числа с разделением тысяч +def format_rate(amount: Union[float, int], around: int = 2) -> str: + value = str(amount).strip().replace(" ", "").replace(",", ".") + number = round(float(value), around) + response = f"{number:,.{around}f}".replace(",", " ") + + if "." in response: + response = response.rstrip("0").rstrip(".") + + return response diff --git a/tgbot/utils/misc/__init__.py b/tgbot/utils/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tgbot/utils/misc/bot_commands.py b/tgbot/utils/misc/bot_commands.py new file mode 100644 index 0000000..717cad4 --- /dev/null +++ b/tgbot/utils/misc/bot_commands.py @@ -0,0 +1,33 @@ +# - *- coding: utf- 8 - *- +from aiogram import Bot +from aiogram.types import BotCommand, BotCommandScopeChat, BotCommandScopeDefault + +from tgbot.data.config import get_admins +from tgbot.utils.misc.bot_logging import bot_logger + +# Команды для юзеров +user_commands = [ + BotCommand(command='start', description='♻️ Перезапустить бота'), + BotCommand(command='support', description='☎️ Поддержка'), + BotCommand(command='faq', description='❔ FAQ'), +] + +# Команды для админов +admin_commands = [ + BotCommand(command='start', description='♻️ Перезапустить бота'), + BotCommand(command='support', description='☎️ Поддержка'), + BotCommand(command='faq', description='❔ FAQ'), + BotCommand(command='db', description='📦 Получить Базу Данных'), + BotCommand(command='log', description='🖨 Получить логи'), +] + + +# Установка команд +async def set_commands(bot: Bot): + await bot.set_my_commands(user_commands, scope=BotCommandScopeDefault()) + + for admin in get_admins(): + try: + await bot.set_my_commands(admin_commands, scope=BotCommandScopeChat(chat_id=admin)) + except Exception: + bot_logger.warning("Не удалось установить команды админу %s", admin, exc_info=True) diff --git a/tgbot/utils/misc/bot_filters.py b/tgbot/utils/misc/bot_filters.py new file mode 100644 index 0000000..c23acad --- /dev/null +++ b/tgbot/utils/misc/bot_filters.py @@ -0,0 +1,67 @@ +# - *- coding: utf- 8 - *- +from typing import Union + +from aiogram.filters import BaseFilter +from aiogram.types import CallbackQuery, Message + +from tgbot.data.config import get_admins +from tgbot.database import Settingsx + + +class IsAdmin(BaseFilter): + # Проверка, что пользователь админ + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + user = getattr(event, "from_user", None) + + return bool(user and user.id in get_admins()) + + +class IsPrivate(BaseFilter): + # Проверка приватного чата + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + chat = getattr(event, "chat", None) + message = getattr(event, "message", None) + + if chat is None and message is not None: + chat = message.chat + + if chat is None: + return True + + return chat.type == "private" + + +class IsWork(BaseFilter): + # Проверка режима тех. работ + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + user = getattr(event, "from_user", None) + settings = await Settingsx().get() + + if settings.status_work == "False" or (user and user.id in get_admins()): + return False + + return True + + +class IsRefill(BaseFilter): + # Проверка доступности пополнений + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + user = getattr(event, "from_user", None) + settings = await Settingsx().get() + + if settings.status_refill == "True" or (user and user.id in get_admins()): + return False + + return True + + +class IsBuy(BaseFilter): + # Проверка доступности покупок + async def __call__(self, event: Union[Message, CallbackQuery]) -> bool: + user = getattr(event, "from_user", None) + settings = await Settingsx().get() + + if settings.status_buy == "True" or (user and user.id in get_admins()): + return False + + return True diff --git a/tgbot/utils/misc/bot_logging.py b/tgbot/utils/misc/bot_logging.py new file mode 100644 index 0000000..540ef26 --- /dev/null +++ b/tgbot/utils/misc/bot_logging.py @@ -0,0 +1,52 @@ +# - *- coding: utf- 8 - *- +import logging +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import colorlog + +from tgbot.data.config import PATH_LOGS + +LOG_FILE_MAX_BYTES = 5 * 1024 * 1024 +LOG_FILE_BACKUP_COUNT = 5 + +log_path = Path(PATH_LOGS) +log_path.parent.mkdir(parents=True, exist_ok=True) + +bot_logger = logging.getLogger("tgbot") +bot_logger.setLevel(logging.INFO) +bot_logger.propagate = False + +if not bot_logger.handlers: + file_formatter = logging.Formatter( + "%(levelname)s | %(asctime)s | %(name)s | %(filename)s:%(lineno)d | %(message)s", + datefmt="%d-%m-%Y %H:%M:%S", + ) + console_formatter = colorlog.ColoredFormatter( + "%(log_color)s%(levelname)s%(reset)s | %(blue)s%(asctime)s%(reset)s | " + "%(purple)s%(filename)s:%(lineno)d%(reset)s | %(message)s", + datefmt="%d-%m-%Y %H:%M:%S", + log_colors={ + "DEBUG": "cyan", + "INFO": "green", + "WARNING": "yellow", + "ERROR": "red", + "CRITICAL": "bold_red", + }, + ) + + file_handler = RotatingFileHandler( + log_path, + maxBytes=LOG_FILE_MAX_BYTES, + backupCount=LOG_FILE_BACKUP_COUNT, + encoding="utf-8", + ) + file_handler.setFormatter(file_formatter) + file_handler.setLevel(logging.INFO) + + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + console_handler.setLevel(logging.INFO) + + bot_logger.addHandler(file_handler) + bot_logger.addHandler(console_handler) diff --git a/tgbot/utils/misc/bot_models.py b/tgbot/utils/misc/bot_models.py new file mode 100644 index 0000000..0e726de --- /dev/null +++ b/tgbot/utils/misc/bot_models.py @@ -0,0 +1,7 @@ +# - *- coding: utf- 8 - *- +from aiogram.fsm.context import FSMContext + +from tgbot.services.api_session import AsyncRequestSession + +FSM = FSMContext +ARS = AsyncRequestSession diff --git a/tgbot/utils/misc_functions.py b/tgbot/utils/misc_functions.py new file mode 100644 index 0000000..3fc7b2b --- /dev/null +++ b/tgbot/utils/misc_functions.py @@ -0,0 +1,196 @@ +# - *- coding: utf- 8 - *- +import asyncio +import json +from datetime import datetime +from typing import Union + +from aiogram import Bot +from aiogram.types import FSInputFile, CallbackQuery, Message + +from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_STATUS_NOTIFICATION, BOT_VERSION, PATH_DATABASE, get_admins, \ + get_text_desc +from tgbot.database import Userx, Settingsx +from tgbot.utils.const_functions import get_unix, get_date, ded, send_admins +from tgbot.utils.misc.bot_logging import bot_logger +from tgbot.utils.misc.bot_models import ARS +from tgbot.utils.text_functions import get_statistics + + +# Автоматическая очистка ежедневной статистики после 00:00:15 +async def update_profit_day(bot: Bot): + await send_admins(bot, await get_statistics()) + + await Settingsx().update(misc_profit_day=get_unix()) + + +# Автоматическая очистка еженедельной статистики в понедельник 00:00:10 +async def update_profit_week(): + await Settingsx().update(misc_profit_week=get_unix()) + + +# Автоматическое обновление счётчика каждый месяц первого числа в 00:00:05 +async def update_profit_month(): + await Settingsx().update(misc_profit_month=get_unix()) + + +# Автонастройка UNIX времени в БД +async def autosettings_unix(): + now_day = datetime.now().day + now_week = datetime.now().weekday() + now_month = datetime.now().month + now_year = datetime.now().year + + unix_day = int(datetime.strptime(f"{now_day}.{now_month}.{now_year} 0:0:0", "%d.%m.%Y %H:%M:%S").timestamp()) + unix_week = unix_day - (now_week * 86400) + unix_month = int(datetime.strptime(f"1.{now_month}.{now_year} 0:0:0", "%d.%m.%Y %H:%M:%S").timestamp()) + + await Settingsx().update( + misc_profit_day=unix_day, + misc_profit_week=unix_week, + misc_profit_month=unix_month, + ) + + +# Проверка на перенесение БД из старого бота в нового или указание токена нового бота +async def check_bot_username(bot: Bot): + get_login = await Settingsx().get() + get_bot = await bot.get_me() + + if get_bot.username != get_login.misc_bot: + await Settingsx().update(misc_bot=get_bot.username) + + +# Уведомление и проверка обновления при запуске бота +async def startup_notify(bot: Bot, arSession: ARS): + if len(get_admins()) >= 1 and BOT_STATUS_NOTIFICATION: + await send_admins( + bot, + ded(f""" + ✅ Бот был успешно запущен + ➖➖➖➖➖➖➖➖➖➖ + {get_text_desc()} + ➖➖➖➖➖➖➖➖➖➖ + ❗ Данное сообщение видят только администраторы бота. + """), + ) + + await check_update(bot, arSession) + + +# Автобэкапы БД для админов +async def autobackup_admin(bot: Bot): + if not BOT_DATABASE_EXPORT: + return + + for admin in get_admins(): + try: + await bot.send_document( + admin, + FSInputFile(PATH_DATABASE), + caption=f"📦 #BACKUP | {get_date(full=False)}", + disable_notification=True, + ) + except Exception: + bot_logger.warning("Не удалось отправить автобэкап админу %s", admin, exc_info=True) + + +# Проверка наличия обновлений бота +async def check_update(bot: Bot, arSession: ARS): + session = await arSession.get_session() + + try: + response = await session.get( + "https://djimbo.dev/autoshop_update.json", + headers={"Accept-Encoding": "gzip, deflate"}, + ) + + response_data = json.loads((await response.read()).decode()) + + if float(response_data['version']) > float(BOT_VERSION): + await send_admins( + bot, + ded(f""" + ❇️ Вышло обновление: Скачать + ➖➖➖➖➖➖➖➖➖➖ + {response_data['text']} + ➖➖➖➖➖➖➖➖➖➖ + ❗ Данное сообщение видят только администраторы бота. + """), + ) + except Exception: + bot_logger.warning("Не удалось проверить обновления", exc_info=True) + + +# Расссылка админам о критических ошибках и обновлениях +async def check_mail(bot: Bot, arSession: ARS): + session = await arSession.get_session() + + try: + response = await session.get( + "https://djimbo.dev/autoshop_mail.json", + headers={"Accept-Encoding": "gzip, deflate"}, + ) + response_data = json.loads((await response.read()).decode()) + + if response_data['status']: + await send_admins( + bot, + ded(f""" + {response_data['text']} + ➖➖➖➖➖➖➖➖➖➖ + ❗ Данное сообщение видят только администраторы бота. + """), + ) + except Exception: + bot_logger.warning("Не удалось проверить сервисные уведомления", exc_info=True) + + +# Вставка кастомных тэгов юзера в текст +async def insert_tags(user_id: Union[int, str], text: str) -> str: + get_user = await Userx().get_required(user_id=user_id) + + if "{user_id}" in text: + text = text.replace("{user_id}", f"{get_user.user_id}") + if "{username}" in text: + text = text.replace("{username}", f"{get_user.user_login}") + if "{firstname}" in text: + text = text.replace("{firstname}", f"{get_user.user_name}") + + return text + + +# Отправка рассылки +async def functions_mail_make(bot: Bot, message: Message, call: CallbackQuery): + users_receive, users_block, users_count = 0, 0, 0 + + get_users = await Userx().get_all() + get_time = get_unix() + + for user in get_users: + try: + await bot.copy_message( + chat_id=user.user_id, + from_chat_id=message.from_user.id, + message_id=message.message_id, + ) + users_receive += 1 + except Exception: + users_block += 1 + bot_logger.debug("Пользователь %s не получил рассылку", user.user_id, exc_info=True) + + users_count += 1 + + if users_count % 10 == 0: + await call.message.edit_text(f"📢 Рассылка началась... ({users_count}/{len(get_users)})") + + await asyncio.sleep(0.07) + + await call.message.edit_text( + ded(f""" + 📢 Рассылка была завершена за {get_unix() - get_time}сек + ➖➖➖➖➖➖➖➖➖➖ + 👤 Всего пользователей: {len(get_users)} + ✅ Пользователей получило сообщение: {users_receive} + ❌ Пользователей не получило сообщение: {users_block} + """) + ) diff --git a/tgbot/utils/products_functions.py b/tgbot/utils/products_functions.py new file mode 100644 index 0000000..a883ef1 --- /dev/null +++ b/tgbot/utils/products_functions.py @@ -0,0 +1,143 @@ +# - *- coding: utf- 8 - *- +from collections import defaultdict +from typing import DefaultDict, List, Optional, Tuple + +from tgbot.database import Categoryx, Itemx, Settingsx, Positionx, ModelCategory, ModelPosition + +SYMBOLS_LIMIT = 4_000 # Кол-во макс символов в сообщение + + +# Наличие товаров +async def get_items_available(remover: int) -> Tuple[str, int, int]: + get_categories = await Categoryx().get_all() + get_positions = await Positionx().get_all() + + item_counts = await Positionx().item_counts() + + positions_by_category: DefaultDict[int, List[Tuple[str, float, int]]] = defaultdict(list) + for position in get_positions: + in_stock = item_counts.get(position.position_id, 0) + + if in_stock > 0: + positions_by_category[position.category_id].append( + (position.position_name, position.position_price, in_stock) + ) + + category_sections: List[Tuple[str, List[Tuple[str, float, int]]]] = [] + for category in get_categories: + category_positions = positions_by_category.get(category.category_id, []) + + if len(category_positions) == 0: + continue + + category_sections.append((category.category_name, category_positions)) + + pages = parse_items_available(category_sections) + max_page = len(pages) + + if max_page == 0: + return "", 0, 0 + + remover = max(0, min(remover, max_page - 1)) + + return pages[remover], max_page, remover + + +# Форматирование товаров для вывода наличия +def _format_position_line( + position_name: str, + position_price: float, + in_stock: int, + max_len: Optional[int] = None, +) -> str: + prefix = "• " + suffix = f" - {position_price}₽ - {in_stock} шт" + full_line = f"{prefix}{position_name}{suffix}" + + if max_len is None or len(full_line) <= max_len: + return full_line + + max_name_len = max(1, max_len - len(prefix) - len(suffix) - 1) + short_name = f"{position_name[:max_name_len]}..." + + return f"{prefix}{short_name}{suffix}" + + +# Перебор товаров в списке для вывода +def parse_items_available(category_sections: List[Tuple[str, List[Tuple[str, float, int]]]]) -> List[str]: + current_page = "" + pages: List[str] = [] + + for category_name, positions in category_sections: + header_category = f"➖➖➖ {category_name} ➖➖➖" + + header_block = header_category if current_page == "" else f"\n\n{header_category}" + candidate_with_header = current_page + header_block + + if len(candidate_with_header) > SYMBOLS_LIMIT: + if current_page != "": + pages.append(current_page) + current_page = header_category + else: + current_page = candidate_with_header + + for position_name, position_price, in_stock in positions: + line = _format_position_line(position_name, position_price, in_stock) + candidate_with_line = current_page + f"\n{line}" + + if len(candidate_with_line) > SYMBOLS_LIMIT: + if current_page != "": + pages.append(current_page) + + current_page = header_category + + remain_len = SYMBOLS_LIMIT - len(current_page) - 1 + line = _format_position_line(position_name, position_price, in_stock, max_len=remain_len) + current_page += f"\n{line}" + else: + current_page = candidate_with_line + + if current_page != "": + pages.append(current_page) + + return pages + + +# Получение категорий с товарами +async def get_categories_items() -> List[ModelCategory]: + get_settings = await Settingsx().get() + + get_categories = await Categoryx().get_all() + + save_categories = [] + + if get_settings.misc_hide_category == "True": + for category in get_categories: + get_positions = await get_positions_items(category.category_id) + + if len(get_positions) >= 1: + save_categories.append(category) + else: + save_categories = get_categories + + return save_categories + + +# Получение позиций с товарами +async def get_positions_items(category_id: int) -> List[ModelPosition]: + get_settings = await Settingsx().get() + + get_positions = await Positionx().gets(category_id=category_id) + + save_positions = [] + + if get_settings.misc_hide_position == "True": + for position in get_positions: + get_items = await Itemx().gets(position_id=position.position_id) + + if len(get_items) >= 1: + save_positions.append(position) + else: + save_positions = get_positions + + return save_positions diff --git a/tgbot/utils/text_functions.py b/tgbot/utils/text_functions.py new file mode 100644 index 0000000..4634da4 --- /dev/null +++ b/tgbot/utils/text_functions.py @@ -0,0 +1,438 @@ +# - *- coding: utf- 8 - *- +from datetime import datetime +from typing import Union + +import pytz +from aiogram import Bot +from aiogram.types import LinkPreviewOptions +from aiogram.utils.markdown import hide_link + +from tgbot.data.config import BOT_TIMEZONE +from tgbot.database import ( + Categoryx, + Positionx, + Itemx, + Purchasesx, + Refillx, + Settingsx, + Userx, + ModelPurchases, + ModelRefill, + ModelUser, +) +from tgbot.keyboards.inline_admin import profile_edit_finl +from tgbot.keyboards.inline_admin_products import position_edit_open_finl, category_edit_open_finl, item_delete_finl +from tgbot.keyboards.inline_user import user_profile_finl +from tgbot.keyboards.inline_user_products import products_open_finl +from tgbot.services.api_hosting_text import HostingAPI +from tgbot.utils.const_functions import ded, get_unix, convert_day, convert_date +from tgbot.utils.misc.bot_models import ARS + + +################################################################################ +################################# ПОЛЬЗОВАТЕЛЬ ################################# +# Открытие профиля пользователем +async def open_profile_user(bot: Bot, user_id: Union[int, str]): + get_purchases = await Purchasesx().gets(user_id=user_id) + get_user = await Userx().get_required(user_id=user_id) + + how_days = int(get_unix() - get_user.user_unix) // 60 // 60 // 24 + count_items = sum([purchase.purchase_count for purchase in get_purchases]) + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 👤 Ваш профиль + ➖➖➖➖➖➖➖➖➖➖ + 🆔 ID: {get_user.user_id} + 💰 Баланс: {get_user.user_balance}₽ + 🎁 Куплено товаров: {count_items}шт + + 🕰 Регистрация: {convert_date(get_user.user_unix, False, False)} ({convert_day(how_days)}) + """), + reply_markup=user_profile_finl(), + ) + + +# Открытие позиции пользователем +async def position_open_user(bot: Bot, user_id: int, position_id: int, remover: int): + get_items = await Itemx().gets(position_id=position_id) + get_position = await Positionx().get_required(position_id=position_id) + get_category = await Categoryx().get_required(category_id=get_position.category_id) + + if get_position.position_desc != "None": + text_desc = f"▪️ Описание: {get_position.position_desc}" + else: + text_desc = "" + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 🎁 Покупка товара{hide_link(get_position.position_photo)} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Название: {get_position.position_name} + ▪️ Категория: {get_category.category_name} + ▪️ Стоимость: {get_position.position_price}₽ + ▪️ Количество: {len(get_items)}шт + {text_desc} + """), + link_preview_options=LinkPreviewOptions(show_above_text=True), + reply_markup=products_open_finl(position_id, get_position.category_id, remover), + + ) + + +################################################################################ +#################################### АДМИН ##################################### +# Открытие профиля админом +async def open_profile_admin(bot: Bot, user_id: int, get_user: ModelUser): + get_purchases = await Purchasesx().gets(user_id=get_user.user_id) + + how_days = int(get_unix() - get_user.user_unix) // 60 // 60 // 24 + count_items = sum([purchase.purchase_count for purchase in get_purchases]) + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 👤 Профиль пользователя: {get_user.user_name} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ ID: {get_user.user_id} + ▪️ Логин: @{get_user.user_login} + ▪️ Имя: {get_user.user_name} + ▪️ Регистрация: {convert_date(get_user.user_unix, False, False)} ({convert_day(how_days)}) + + ▪️ Баланс: {get_user.user_balance}₽ + ▪️ Всего выдано: {get_user.user_give}₽ + ▪️ Всего пополнено: {get_user.user_refill}₽ + ▪️ Куплено товаров: {count_items}шт + """), + reply_markup=profile_edit_finl(get_user.user_id), + ) + + +# Открытие пополнения админом +async def refill_open_admin(bot: Bot, user_id: int, get_refill: ModelRefill): + get_user = await Userx().get_required(user_id=get_refill.user_id) + + if get_refill.refill_method in ['Form', 'Nickname', 'Number', 'QIWI']: + pay_method = "QIWI 🥝" + elif get_refill.refill_method == "Yoomoney": + pay_method = "ЮMoney 🔮" + elif get_refill.refill_method == "Cryptobot": + pay_method = "CryptoBot 🔷" + else: + pay_method = f"{get_refill.refill_method}" + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 🧾 Чек: #{get_refill.refill_receipt} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Пользователь: {get_user.user_name} | {get_user.user_id} + ▪️ Сумма пополнения: {get_refill.refill_amount}₽ + ▪️ Способ пополнения: {pay_method} + ▪️ Комментарий: {get_refill.refill_comment} + ▪️ Дата пополнения: {convert_date(get_refill.refill_unix)} + """), + ) + + +# Открытие покупки админом +async def purchase_open_admin(bot: Bot, arSession: ARS, user_id: int, get_purchase: ModelPurchases): + get_user = await Userx().get_required(user_id=get_purchase.user_id) + + link_items = await ( + await HostingAPI.connect( + bot=bot, + arSession=arSession, + ) + ).upload_text(get_purchase.purchase_data) + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 🧾 Чек: #{get_purchase.purchase_receipt} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Пользователь: {get_user.user_name} | {get_user.user_id} + + ▪️ Название товара: {get_purchase.purchase_position_name} + ▪️ Куплено товаров: {get_purchase.purchase_count}шт + ▪️ Цена одного товара: {get_purchase.purchase_price_one}₽ + ▪️ Сумма покупки: {get_purchase.purchase_price}₽ + + ▪️ Баланс до покупки: {get_purchase.user_balance_before}₽ + ▪️ Баланс после покупки: {get_purchase.user_balance_after}₽ + + ▪️ Товары: кликабельно + ▪️ Дата покупки: {convert_date(get_purchase.purchase_unix)} + """), + disable_web_page_preview=True, + ) + + +# Открытие категории админом +async def category_open_admin(bot: Bot, user_id: int, category_id: int, remover: int): + profit_amount_all, profit_amount_day, profit_amount_week, profit_amount_month = 0, 0, 0, 0 + profit_count_all, profit_count_day, profit_count_week, profit_count_month = 0, 0, 0, 0 + + get_items = await Itemx().gets(category_id=category_id) + get_category = await Categoryx().get_required(category_id=category_id) + get_positions = await Positionx().gets(category_id=category_id) + + get_purchases = await Purchasesx().gets(purchase_category_id=category_id) + get_settings = await Settingsx().get() + + for purchase in get_purchases: + profit_amount_all += purchase.purchase_price + profit_count_all += purchase.purchase_count + + if purchase.purchase_unix - get_settings.misc_profit_day >= 0: + profit_amount_day += purchase.purchase_price + profit_count_day += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_week >= 0: + profit_amount_week += purchase.purchase_price + profit_count_week += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_month >= 0: + profit_amount_month += purchase.purchase_price + profit_count_month += purchase.purchase_count + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 🗃️ Редактирование категории + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Категория: {get_category.category_name} + ▪️ Кол-во позиций: {len(get_positions)}шт + ▪️ Кол-во товаров: {len(get_items)}шт + ▪️ Дата создания: {convert_date(get_category.category_unix)}шт + + 💸 Продаж за День: {profit_count_day}шт - {round(profit_amount_day, 2)}₽ + 💸 Продаж за Неделю: {profit_count_week}шт - {round(profit_amount_week, 2)}₽ + 💸 Продаж за Месяц: {profit_count_month}шт - {round(profit_amount_month, 2)}₽ + 💸 Продаж за Всё время: {profit_count_all}шт - {round(profit_amount_all, 2)}₽ + """), + reply_markup=await category_edit_open_finl(bot, category_id, remover), + ) + + +# Открытие позиции админом +async def position_open_admin(bot: Bot, position_id: int, user_id: int): + profit_amount_all, profit_amount_day, profit_amount_week, profit_amount_month = 0, 0, 0, 0 + profit_count_all, profit_count_day, profit_count_week, profit_count_month = 0, 0, 0, 0 + + get_items = await Itemx().gets(position_id=position_id) + get_position = await Positionx().get_required(position_id=position_id) + get_category = await Categoryx().get_required(category_id=get_position.category_id) + + get_settings = await Settingsx().get() + get_purchases = await Purchasesx().gets(purchase_position_id=position_id) + + # Наличие фото + if get_position.position_photo != "None": + position_photo_text = "Присутствует ✅" + else: + position_photo_text = "Отсутствует ❌" + + # Наличие описания + if get_position.position_desc != "None": + position_desc = f"{get_position.position_desc}" + else: + position_desc = "Отсутствует ❌" + + # Статистика позиции + for purchase in get_purchases: + profit_amount_all += purchase.purchase_price + profit_count_all += purchase.purchase_count + + if purchase.purchase_unix - get_settings.misc_profit_day >= 0: + profit_amount_day += purchase.purchase_price + profit_count_day += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_week >= 0: + profit_amount_week += purchase.purchase_price + profit_count_week += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_month >= 0: + profit_amount_month += purchase.purchase_price + profit_count_month += purchase.purchase_count + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 📁 Редактирование позиции{hide_link(get_position.position_photo)} + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Категория: {get_category.category_name} + ▪️ Позиция: {get_position.position_name} + ▪️ Стоимость: {get_position.position_price}₽ + ▪️ Количество: {len(get_items)}шт + ▪️ Дата создания: {convert_date(get_category.category_unix)} + ▪️ Изображение: {position_photo_text} + ▪️ Описание: {position_desc} + + 💸 Продаж за День: {profit_count_day}шт - {round(profit_amount_day, 2)}₽ + 💸 Продаж за Неделю: {profit_count_week}шт - {round(profit_amount_week, 2)}₽ + 💸 Продаж за Месяц: {profit_count_month}шт - {round(profit_amount_month, 2)}₽ + 💸 Продаж за Всё время: {profit_count_all}шт - {round(profit_amount_all, 2)}₽ + """), + link_preview_options=LinkPreviewOptions(show_above_text=True), + reply_markup=await position_edit_open_finl(bot, position_id, 0), + + ) + + +# Открытие товара админом +async def item_open_admin(bot: Bot, item_id: int, user_id: int): + get_item = await Itemx().get_required(item_id=item_id) + + get_position = await Positionx().get_required(position_id=get_item.position_id) + get_category = await Categoryx().get_required(category_id=get_item.category_id) + + await bot.send_message( + chat_id=user_id, + text=ded(f""" + 🎁️ Редактирование товара + ➖➖➖➖➖➖➖➖➖➖ + ▪️ Категория: {get_category.category_name} + ▪️ Позиция: {get_position.position_name} + ▪️ Дата добавления: {convert_date(get_item.item_unix)} + ▪️ Товар: {get_item.item_data} + """), + reply_markup=item_delete_finl(get_item.item_id, get_item.position_id), + ) + + +################################################################################ +################################################################################ +# Статистика бота +async def get_statistics() -> str: + refill_amount_all, refill_amount_day, refill_amount_week, refill_amount_month = 0, 0, 0, 0 + refill_count_all, refill_count_day, refill_count_week, refill_count_month = 0, 0, 0, 0 + profit_amount_all, profit_amount_day, profit_amount_week, profit_amount_month = 0, 0, 0, 0 + profit_count_all, profit_count_day, profit_count_week, profit_count_month = 0, 0, 0, 0 + users_all, users_day, users_week, users_month, users_money_have, users_money_give = 0, 0, 0, 0, 0, 0 + refill_cryptobot_count, refill_cryptobot_amount = 0, 0 + refill_yoomoney_count, refill_yoomoney_amount = 0, 0 + refill_stars_count, refill_stars_amount = 0, 0 + + get_categories = await Categoryx().get_all() + get_positions = await Positionx().get_all() + get_purchases = await Purchasesx().get_all() + get_refill = await Refillx().get_all() + get_items = await Itemx().get_all() + get_users = await Userx().get_all() + get_settings = await Settingsx().get() + + # Покупки + for purchase in get_purchases: + profit_amount_all += purchase.purchase_price + profit_count_all += purchase.purchase_count + + if purchase.purchase_unix - get_settings.misc_profit_day >= 0: + profit_amount_day += purchase.purchase_price + profit_count_day += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_week >= 0: + profit_amount_week += purchase.purchase_price + profit_count_week += purchase.purchase_count + if purchase.purchase_unix - get_settings.misc_profit_month >= 0: + profit_amount_month += purchase.purchase_price + profit_count_month += purchase.purchase_count + + # Пополнения + for refill in get_refill: + refill_amount_all += refill.refill_amount + refill_count_all += 1 + + if refill.refill_method == "Yoomoney": + refill_yoomoney_count += 1 + refill_yoomoney_amount += refill.refill_amount + elif refill.refill_method == "Cryptobot": + refill_cryptobot_count += 1 + refill_cryptobot_amount += refill.refill_amount + elif refill.refill_method == "Stars": + refill_stars_count += 1 + refill_stars_amount += refill.refill_amount + + if refill.refill_unix - get_settings.misc_profit_day >= 0: + refill_amount_day += refill.refill_amount + refill_count_day += 1 + if refill.refill_unix - get_settings.misc_profit_week >= 0: + refill_amount_week += refill.refill_amount + refill_count_week += 1 + if refill.refill_unix - get_settings.misc_profit_month >= 0: + refill_amount_month += refill.refill_amount + refill_count_month += 1 + + # Пользователи и средства + for user in get_users: + users_money_have += user.user_balance + users_money_give += user.user_give + users_all += 1 + + if user.user_unix - get_settings.misc_profit_day >= 0: + users_day += 1 + if user.user_unix - get_settings.misc_profit_week >= 0: + users_week += 1 + if user.user_unix - get_settings.misc_profit_month >= 0: + users_month += 1 + + # Даты обновления статистики + all_days = [ + 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье', + ] + + all_months = [ + 'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', + 'Октябрь', 'Ноябрь', 'Декабрь' + ] + + now_day = datetime.now().day + now_week = datetime.now().weekday() + now_month = datetime.now().month + now_year = datetime.now().year + + unix_day = int(datetime.strptime(f"{now_day}.{now_month}.{now_year} 0:0:0", "%d.%m.%Y %H:%M:%S").timestamp()) + unix_week = unix_day - (now_week * 86400) + + week_day = int(datetime.fromtimestamp(unix_week, pytz.timezone(BOT_TIMEZONE)).strftime("%d")) + week_month = int(datetime.fromtimestamp(unix_week, pytz.timezone(BOT_TIMEZONE)).strftime("%m")) + week_week = int(datetime.fromtimestamp(unix_week, pytz.timezone(BOT_TIMEZONE)).weekday()) + + return ded(f""" + 📊 СТАТИСТИКА БОТА + ➖➖➖➖➖➖➖➖➖➖ + 👤 Пользователи + ┣ Юзеров за День: {users_day} + ┣ Юзеров за Неделю: {users_week} + ┣ Юзеров за Месяц: {users_month} + ┗ Юзеров за Всё время: {users_all} + + 💰 Средства + ┣‒ Продажи (кол-во, сумма) + ┣ За День: {profit_count_day}шт - {round(profit_amount_day, 2)}₽ + ┣ За Неделю: {profit_count_week}шт - {round(profit_amount_week, 2)}₽ + ┣ За Месяц: {profit_count_month}шт - {round(profit_amount_month, 2)}₽ + ┣ За Всё время: {profit_count_all}шт - {round(profit_amount_all, 2)}₽ + ┃ + ┣‒ Пополнения (кол-во, сумма) + ┣ За День: {refill_count_day}шт - {round(refill_amount_day, 2)}₽ + ┣ За Неделю: {refill_count_week}шт - {round(refill_amount_week, 2)}₽ + ┣ За Месяц: {refill_count_month}шт - {round(refill_amount_month, 2)}₽ + ┣ За Всё время: {refill_count_all}шт - {round(refill_amount_all, 2)}₽ + ┃ + ┣‒ Платежные системы (всего) + ┣ CryptoBot: {refill_cryptobot_count}шт - {round(refill_cryptobot_amount, 2)}₽ + ┣ TG Stars: {refill_stars_count}шт - {round(refill_stars_amount, 2)}₽ + ┣ ЮMoney: {refill_yoomoney_count}шт - {round(refill_yoomoney_amount, 2)}₽ + ┃ + ┣‒ Остальные + ┣ Средств выдано: {round(users_money_give, 2)}₽ + ┗ Средств в системе: {round(users_money_have, 2)}₽ + + 🎁 Товары + ┣ Товаров: {len(get_items)}шт + ┣ Позиций: {len(get_positions)}шт + ┗ Категорий: {len(get_categories)}шт + + 🕰 Даты статистики + ┣ Дневная: {now_day} {all_months[now_month - 1].title()} + ┣ Недельная: {week_day} {all_months[week_month - 1].title()}, {all_days[week_week]} + ┗ Месячная: 1 {all_months[now_month - 1].title()}, {now_year}г + """)