From c14683a35c62054a1caa8321e5fa875585baed74 Mon Sep 17 00:00:00 2001 From: Beesquit Date: Mon, 15 Sep 2025 17:45:55 +0300 Subject: [PATCH] Initial commit --- .gitignore | 175 +++++++++++++++++++++++++++++++++++ README.md | 15 +++ requirements.txt | 26 ++++++ src/app/createApp.py | 9 ++ src/app/internal/internal.py | 8 ++ src/app/jsonData/__init__.py | 11 +++ src/app/jsonData/connect.py | 47 ++++++++++ src/app/jsonData/utils.py | 69 ++++++++++++++ src/app/settings.py | 14 +++ src/app/telegram/__init__.py | 3 + src/app/telegram/strings.py | 32 +++++++ src/app/telegram/telegram.py | 131 ++++++++++++++++++++++++++ src/main.py | 3 + 13 files changed, 543 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 src/app/createApp.py create mode 100644 src/app/internal/internal.py create mode 100644 src/app/jsonData/__init__.py create mode 100644 src/app/jsonData/connect.py create mode 100644 src/app/jsonData/utils.py create mode 100644 src/app/settings.py create mode 100644 src/app/telegram/__init__.py create mode 100644 src/app/telegram/strings.py create mode 100644 src/app/telegram/telegram.py create mode 100644 src/main.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69169fd --- /dev/null +++ b/.gitignore @@ -0,0 +1,175 @@ +# ---> Python +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# 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/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc diff --git a/README.md b/README.md new file mode 100644 index 0000000..c2d02e5 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +Project template for simple telegram bots + +Features: +- Base commands with FSM (no redis) +- JSON data storage +- Async worker function +- Cute project structure + +.env example: +```.env +botToken="token:from-botfather" +whitelist=["userid"] +dataPath="data/" +jsonFilename="data.json" +``` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8312ec4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,26 @@ +aiofiles==24.1.0 +aiogram==3.22.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +annotated-types==0.7.0 +anyio==4.10.0 +attrs==25.3.0 +certifi==2025.8.3 +frozenlist==1.7.0 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.10 +loguru==0.7.3 +magic-filter==1.0.12 +multidict==6.6.4 +propcache==0.3.2 +pydantic==2.11.7 +pydantic-settings==2.10.1 +pydantic_core==2.33.2 +python-dotenv==1.1.1 +sniffio==1.3.1 +typing-inspection==0.4.1 +typing_extensions==4.15.0 +yarl==1.20.1 diff --git a/src/app/createApp.py b/src/app/createApp.py new file mode 100644 index 0000000..77ffca4 --- /dev/null +++ b/src/app/createApp.py @@ -0,0 +1,9 @@ +import asyncio + +from app.jsonData import start as start_json +from app.telegram import start_bot + + +def createApp(): + start_json() + asyncio.run(start_bot()) diff --git a/src/app/internal/internal.py b/src/app/internal/internal.py new file mode 100644 index 0000000..fc2839c --- /dev/null +++ b/src/app/internal/internal.py @@ -0,0 +1,8 @@ +import asyncio + +from loguru import logger + + +async def internal(): + logger.info("Internal") + await asyncio.sleep(4) diff --git a/src/app/jsonData/__init__.py b/src/app/jsonData/__init__.py new file mode 100644 index 0000000..cda2c47 --- /dev/null +++ b/src/app/jsonData/__init__.py @@ -0,0 +1,11 @@ +from .connect import load, start +from .utils import add, check, removeById, removeByParam + +__all__ = [ + "load", + "start", + "add", + "check", + "removeById", + "removeByParam", +] diff --git a/src/app/jsonData/connect.py b/src/app/jsonData/connect.py new file mode 100644 index 0000000..0e4b8f5 --- /dev/null +++ b/src/app/jsonData/connect.py @@ -0,0 +1,47 @@ +import json +import os +import shutil +import sys +from datetime import datetime + +from loguru import logger + +from app.settings import settings + +dataPath = settings.dataPath.removesuffix("/") +jsonRawFilename = settings.jsonFilename.removesuffix(".json") +backupPath = dataPath + "/backup" +filePath = dataPath + "/" + jsonRawFilename + ".json" + + +def start(): + # Inits JSON file + try: + if not(os.path.exists(dataPath)): + os.mkdir(dataPath) + if not(os.path.exists(backupPath)): + os.mkdir(backupPath) + + if os.path.exists(filePath): + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + backupFilePath = backupPath + "/" + jsonRawFilename + f"_backup_{timestamp}.json" + shutil.copy(filePath, backupFilePath) + logger.info(f"JSON backup was created: {backupFilePath}") + + with open(filePath, "w") as f: + json.dump({}, f, ensure_ascii = False, indent=4) + logger.success(f"Successfully initialized JSON with path: {filePath}") + except Exception as e: + logger.error(f"Failed to initialize JSON: {e}") + sys.exit(1) + + +def load(): + # Returnes the contents of the JSON file as a dictionary + data = {} + try: + with open(filePath, "r") as f: + data = json.load(f) + except json.decoder.JSONDecodeError: + logger.warning("JSON file is empty!") + return data diff --git a/src/app/jsonData/utils.py b/src/app/jsonData/utils.py new file mode 100644 index 0000000..754115d --- /dev/null +++ b/src/app/jsonData/utils.py @@ -0,0 +1,69 @@ +import json + +from loguru import logger + +from app.jsonData.connect import load +from app.settings import settings + + +def getParamId(param): + # Returnes -1 if param not found and id if found + currentData = load() + + id = -1 + for (i, j) in [(i[0], i[1]) for i in currentData]: + if j == param: + id = i + break + return id + + +def check(param): + # Returnes 1 if param exists and 0 if not + currentData = load() + + if param in [i[1] for i in currentData]: + return 1 + else: + return 0 + + +def removeById(id: int): + # Returnes 0 if deleted successfully and -1 if not + currentData = load() + + if id in currentData: + del currentData[id] + with open(settings.jsonFilename, 'w', encoding = 'utf-8') as f: + json.dump(currentData, f, ensure_ascii = False, indent = 4) + logger.info(f'Id {id} was deleted successfully!') + return 0 + else: + logger.info(f'Id {id} was not found in the data file when trying to delete it.') + return -1 + +def removeByParam(param): + # Returnes 0 if deleted successfully and -1 if not + id = getParamId(param) + + if id != -1: + return removeById(id) + else: + logger.info(f'Param {param} was not found in the data file when trying to delete it.') + return -1 + + +def add(param): + # Saves or updates data in JSON + currentData = load() + if currentData == {}: + id = 0 + else: + id = currentData[-1][0] + 1 + newData = {id: param} + + with open(settings.jsonFilename, 'w', encoding = 'utf-8') as f: + currentData.update(newData) + json.dump(currentData, f, ensure_ascii = False, indent = 4) + logger.info(f"Param {param} was added with id {id}") + return id diff --git a/src/app/settings.py b/src/app/settings.py new file mode 100644 index 0000000..49c66b2 --- /dev/null +++ b/src/app/settings.py @@ -0,0 +1,14 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + botToken: str + whitelist: list + + dataPath: str + jsonFilename: str + + model_config = SettingsConfigDict(env_file = ".env", env_file_encoding="utf-8") + + +settings = Settings() # type: ignore diff --git a/src/app/telegram/__init__.py b/src/app/telegram/__init__.py new file mode 100644 index 0000000..8386961 --- /dev/null +++ b/src/app/telegram/__init__.py @@ -0,0 +1,3 @@ +from .telegram import start_bot + +__all__ = ["start_bot"] diff --git a/src/app/telegram/strings.py b/src/app/telegram/strings.py new file mode 100644 index 0000000..a98f8e8 --- /dev/null +++ b/src/app/telegram/strings.py @@ -0,0 +1,32 @@ +# Bot Statuses + +startBot = "Bot started" + +stopBot = "Bot stopped" + +unexpectedError = "Unexpected error" + + +# Commands + +startCommand = "Hi" + +helpCommand = "This is help" + + +# Route status + +askParam = "Enter param:" + +successAdd = "Added successfully" + +successRemove = "Removed successfully" + +alreadyExists = "Key already exists" + + +# Data status + +noData = "No data" + +foundData = "Founde data:" diff --git a/src/app/telegram/telegram.py b/src/app/telegram/telegram.py new file mode 100644 index 0000000..0df287b --- /dev/null +++ b/src/app/telegram/telegram.py @@ -0,0 +1,131 @@ +import asyncio + +from aiogram import Bot, Dispatcher, F, Router +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode +from aiogram.filters import Command +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.fsm.storage.memory import MemoryStorage +from aiogram.types import BotCommand, BotCommandScopeDefault, Message +from loguru import logger + +from app import jsonData +from app.internal.internal import internal +from app.settings import settings +from app.telegram import strings + + +async def setCommands(): + commands = [ + BotCommand(command='start', description='Start'), + BotCommand(command='help', description='Help'), + BotCommand(command='info', description='Info'), + BotCommand(command='add', description='Add'), + BotCommand(command='remove', description='Remove') + ] + await bot.set_my_commands(commands, BotCommandScopeDefault()) + + +class addForm(StatesGroup): + param = State() + +class removeForm(StatesGroup): + param = State() + + +dp = Dispatcher(storage=MemoryStorage()) +bot = Bot( + token=settings.botToken, + default=DefaultBotProperties(parse_mode=ParseMode.HTML), +) + +strategyRouter = Router() +removeRouter = Router() + + +@dp.message(Command("start")) +async def commandStart(message: Message) -> None: + await message.answer(strings.startCommand) + +@dp.message(Command("help"), F.chat.id.in_(settings.whitelist)) +async def commandHelp(message: Message) -> None: + await message.answer(strings.helpCommand) + +@dp.message(Command("info"), F.chat.id.in_(settings.whitelist)) +async def commandInfo(message: Message) -> None: + data = jsonData.load() + msgText = '' + if data == {}: + msgText = strings.noData + else: + msgText = strings.foundData + for i in data: + msgText += (f"{str(i[0])}: {str(i[1])}\n") + await message.answer(msgText) + + +@strategyRouter.message(Command("add"), F.chat.id.in_(settings.whitelist)) +async def commandAdd(message: Message, state: FSMContext): + await message.answer(strings.askParam) + await state.set_state(addForm.param) + +@strategyRouter.message(F.text, addForm.param) +async def captureStartPair(message: Message, state: FSMContext): + await state.update_data(pair=message.text) + data = await state.get_data() + param = data.get("param") + + t = jsonData.add(param) + msgText = strings.successAdd + str(t) + await asyncio.to_thread(internal) + + await message.answer(msgText) + await state.clear() + + +@removeRouter.message(Command("remove"), F.chat.id.in_(settings.whitelist)) +async def commandRemove(message: Message, state: FSMContext): + await message.answer(strings.askParam) + await state.set_state(removeForm.param) + +@removeRouter.message(F.text, removeForm.param) +async def captureRemoveParam(message: Message, state: FSMContext): + await state.update_data(pair=message.text) + data = await state.get_data() + param = data.get("param") + + t = jsonData.removeByParam(param) + if t == 0: + msgText = strings.successRemove + else: + msgText = strings.noData + + await message.answer(msgText) + await state.clear() + + +async def startBot(): + await setCommands() + try: + for i in settings.whitelist: + await bot.send_message(chat_id=i, text=strings.startBot) + except Exception as e: + logger.error(e) + logger.success("Started bot") + +async def stopBot(): + try: + for i in settings.whitelist: + await bot.send_message(chat_id=i, text=strings.stopBot) + except Exception as e: + logger.error(e) + logger.success("Stopped bot") + + +async def start_bot() -> None: + dp.startup.register(startBot) + dp.include_router(strategyRouter) + dp.include_router(removeRouter) + dp.shutdown.register(stopBot) + await dp.start_polling(bot) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..be05b51 --- /dev/null +++ b/src/main.py @@ -0,0 +1,3 @@ +from app.createApp import createApp + +createApp()