├── .github └── workflows │ └── lint_and_types.yml ├── .gitignore ├── .pre-commit-config.yaml ├── DEPLOY.md ├── README.md ├── botanim-logo.svg ├── botanim_bot ├── .env.example ├── __main__.py ├── config.py ├── db.py ├── db.sql ├── handlers │ ├── __init__.py │ ├── all_books.py │ ├── already.py │ ├── cancel.py │ ├── help.py │ ├── keyboards.py │ ├── now.py │ ├── response.py │ ├── start.py │ ├── vote.py │ ├── vote_process.py │ └── vote_results.py ├── services │ ├── books.py │ ├── exceptions.py │ ├── num_to_words.py │ ├── schulze.py │ ├── users.py │ ├── validation.py │ ├── vote_mode.py │ ├── vote_results.py │ └── votings.py ├── templates.py └── templates │ ├── already.j2 │ ├── already_for_member.j2 │ ├── category_with_books.j2 │ ├── help.j2 │ ├── now.j2 │ ├── now_for_member.j2 │ ├── start.j2 │ ├── vote_cant_vote.j2 │ ├── vote_description.j2 │ ├── vote_incorrect_books.j2 │ ├── vote_incorrect_input.j2 │ ├── vote_no_actual_voting.j2 │ ├── vote_results.j2 │ ├── vote_results_no_data.j2 │ ├── vote_success.j2 │ └── vote_user_not_in_right_mode.j2 ├── poetry.lock └── pyproject.toml /.github/workflows/lint_and_types.yml: -------------------------------------------------------------------------------- 1 | name: Lint and check types 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v3 11 | - name: Set up Python 3.11 12 | uses: actions/setup-python@v4 13 | with: 14 | python-version: "3.11" 15 | 16 | - name: Install poetry 17 | run: | 18 | pip install pipx 19 | pipx install poetry 20 | 21 | - name: Validate the structure of the pyproject.toml 22 | run: | 23 | poetry check 24 | 25 | - name: Verify that poetry.lock is consistent with pyproject.toml 26 | run: | 27 | poetry lock --check 28 | 29 | - name: Install dependencies 30 | run: | 31 | poetry install 32 | 33 | - name: Check code formatting by black 34 | run: | 35 | poetry run black . --check 36 | 37 | - name: Lint code by ruff 38 | run: | 39 | poetry run ruff . 40 | 41 | - name: Check types by pyright 42 | run: | 43 | poetry run pyright 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ide folders 2 | .idea/ 3 | .vscode/ 4 | 5 | # not project files 6 | __pycache__ 7 | db.sqlite3 8 | .DS_Store 9 | .env 10 | .ruff_cache 11 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 22.12.0 4 | hooks: 5 | - id: black 6 | language_version: python3.11 7 | 8 | - repo: https://github.com/charliermarsh/ruff-pre-commit 9 | rev: "v0.0.278" 10 | hooks: 11 | - id: ruff 12 | args: [--fix, --exit-non-zero-on-fix] 13 | -------------------------------------------------------------------------------- /DEPLOY.md: -------------------------------------------------------------------------------- 1 | ## Деплой бота на сервере 2 | 3 | Протестировано на Debian 10. 4 | 5 | Обновляем систему 6 | 7 | ```bash 8 | sudo apt update && sudo apt upgrade 9 | ``` 10 | 11 | Устанавливаем Python 3.11 сборкой из исходников и sqlite3: 12 | 13 | ```bash 14 | cd 15 | sudo apt install -y sqlite3 pkg-config 16 | wget https://www.python.org/ftp/python/3.11.1/Python-3.11.1.tgz 17 | tar -xzvf Python-3.11.1.tgz 18 | cd Python-3.11.1 19 | ./configure --enable-optimizations --prefix=/home/www/.python3.11 20 | sudo make altinstall 21 | ``` 22 | 23 | Устанавливаем Poetry: 24 | 25 | ```basj 26 | curl -sSL https://install.python-poetry.org | python3 - 27 | ``` 28 | 29 | Клонируем репозиторий в `~/code/botanim_bot`: 30 | 31 | ```bash 32 | mkdir -p ~/code/ 33 | cd ~/code 34 | git clone https://github.com/alexey-goloburdin/botanim-bot.git 35 | cd botanim-bot 36 | ``` 37 | 38 | Создаём переменные окружения: 39 | 40 | ``` 41 | cp botanim_bot/.env.example botanim_bot/.env 42 | vim botanim_bot/.env 43 | ``` 44 | 45 | `TELEGRAM_BOT_TOKEN` — токен бота, полученный в BotFather, `TELEGRAM_BOTANIM_CHANNEL_ID` — идентификатор группы книжного клуба, участие в котором будет проверять бот в процессе голосования. 46 | 47 | Заполняем БД начальными данными: 48 | 49 | ```bash 50 | cat botanim_bot/db.sql | sqlite3 botanim_bot/db.sqlite3 51 | ``` 52 | 53 | Устанавливаем зависимости Poetry и запускаем бота вручную: 54 | 55 | ```bash 56 | poetry install 57 | poetry run python -m botanim_bot 58 | ``` 59 | 60 | Можно проверить работу бота. Для остановки, жмём `CTRL`+`C`. 61 | 62 | Получим текущий адрес до Pytnon-интерпретатора в poetry виртуальном окружении Poetry: 63 | 64 | ```bash 65 | poetry shell 66 | which python 67 | ``` 68 | 69 | Скопируем путь до интерпретатора Python в виртуальном окружении. 70 | 71 | Настроим systemd-юнит для автоматического запуска бота, подставив скопированный путь в ExecStart, а также убедившись, 72 | что директория до проекта (в данном случае `/home/www/code/botanim_bot`) у вас такая же: 73 | 74 | ``` 75 | sudo tee /etc/systemd/system/botanimbot.service << END 76 | [Unit] 77 | Description=Botanim Telegram bot 78 | After=network.target 79 | 80 | [Service] 81 | User=www 82 | Group=www-data 83 | WorkingDirectory=/home/www/code/botanim-bot 84 | Restart=on-failure 85 | RestartSec=2s 86 | ExecStart=/home/www/.cache/pypoetry/virtualenvs/botanim-bot-dRxws4wE-py3.11/bin/python -m botanim_bot 87 | 88 | [Install] 89 | WantedBy=multi-user.target 90 | END 91 | 92 | sudo systemctl daemon-reload 93 | sudo systemctl enable botanimbot.service 94 | sudo systemctl start botanimbot.service 95 | ``` 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Telegram-бот для книжного клуба [Ботаним!](https://botanim.to.digital) 2 | 3 | [![alt text](botanim-logo.svg)](https://botanim.to.digital) 4 | 5 | ## Команды бота: 6 | 7 | - `/start` — приветственное сообщение 8 | - `/help` — справка 9 | - `/allbooks` — все книги, которые есть в нашем списке 10 | - `/already` — прочитанные книги 11 | - `/now` — книга, которую сейчас читаем 12 | - `/vote` — проголосовать за следующую книгу 13 | - `/voteresults` — текущие результаты текущего голосования 14 | 15 | Голосование доступно только для участников клуба, остальные команды — для всех. 16 | 17 | ## Запуск 18 | 19 | Скопируйте `.env.example` в `.env` и отредактируйте `.env` файл, заполнив в нём все переменные окружения: 20 | 21 | ```bash 22 | cp botanim_bot/.env.example botanim_bot/.env 23 | ``` 24 | 25 | Для управления зависимостями используется [poetry](https://python-poetry.org/), 26 | требуется Python 3.11. 27 | 28 | Установка зависимостей и запуск бота: 29 | 30 | ```bash 31 | poetry install 32 | poetry run python -m botanim_bot 33 | ``` 34 | 35 | ## Ideas 36 | 37 | - Сделать возможность напоминаний тем, кто еще не проголосовал о том, что голосование заканчивается через N часов 38 | - Добавить отзывы 39 | - Возможность поиска книг — возможно с автодополнением 40 | - Возможность предлагать книги для добавления 41 | 42 | ## Деплой 43 | 44 | [Описание того, как можно развернуть бота на сервере](DEPLOY.md) 45 | -------------------------------------------------------------------------------- /botanim-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /botanim_bot/.env.example: -------------------------------------------------------------------------------- 1 | TELEGRAM_BOT_TOKEN=... 2 | TELEGRAM_BOTANIM_CHANNEL_ID=... 3 | -------------------------------------------------------------------------------- /botanim_bot/__main__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from telegram.ext import ( 4 | ApplicationBuilder, 5 | CallbackQueryHandler, 6 | CommandHandler, 7 | MessageHandler, 8 | filters, 9 | ) 10 | 11 | from botanim_bot import config, handlers 12 | from botanim_bot.db import close_db 13 | 14 | COMMAND_HANDLERS = { 15 | "start": handlers.start, 16 | "help": handlers.help_, 17 | "allbooks": handlers.all_books, 18 | "already": handlers.already, 19 | "now": handlers.now, 20 | "vote": handlers.vote, 21 | "cancel": handlers.cancel, 22 | "voteresults": handlers.vote_results, 23 | } 24 | 25 | CALLBACK_QUERY_HANDLERS = { 26 | rf"^{config.ALL_BOOKS_CALLBACK_PATTERN}(\d+)$": handlers.all_books_button, 27 | rf"^{config.VOTE_BOOKS_CALLBACK_PATTERN}(\d+)$": handlers.vote_button, 28 | } 29 | 30 | 31 | logging.basicConfig( 32 | format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO 33 | ) 34 | logger = logging.getLogger(__name__) 35 | 36 | 37 | if not config.TELEGRAM_BOT_TOKEN or not config.TELEGRAM_BOTANIM_CHANNEL_ID: 38 | raise ValueError( 39 | "TELEGRAM_BOT_TOKEN and TELEGRAM_BOTANIM_CHANNEL_ID env variables " 40 | "wasn't implemented in .env (both should be initialized)." 41 | ) 42 | 43 | 44 | def main(): 45 | application = ApplicationBuilder().token(config.TELEGRAM_BOT_TOKEN).build() 46 | 47 | for command_name, command_handler in COMMAND_HANDLERS.items(): 48 | application.add_handler(CommandHandler(command_name, command_handler)) 49 | 50 | for pattern, handler in CALLBACK_QUERY_HANDLERS.items(): 51 | application.add_handler(CallbackQueryHandler(handler, pattern=pattern)) 52 | 53 | application.add_handler( 54 | MessageHandler(filters.TEXT & (~filters.COMMAND), handlers.vote_process) 55 | ) 56 | 57 | application.run_polling() 58 | 59 | 60 | if __name__ == "__main__": 61 | try: 62 | main() 63 | except Exception: 64 | import traceback 65 | 66 | logger.warning(traceback.format_exc()) 67 | finally: 68 | close_db() 69 | -------------------------------------------------------------------------------- /botanim_bot/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | 9 | TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") 10 | TELEGRAM_BOTANIM_CHANNEL_ID = int(os.getenv("TELEGRAM_BOTANIM_CHANNEL_ID", "0")) 11 | 12 | 13 | BASE_DIR = Path(__file__).resolve().parent 14 | SQLITE_DB_FILE = BASE_DIR / "db.sqlite3" 15 | TEMPLATES_DIR = BASE_DIR / "templates" 16 | 17 | DATE_FORMAT = "%d.%m.%Y" 18 | VOTE_ELEMENTS_COUNT = 3 19 | 20 | VOTE_RESULTS_TOP = 10 21 | 22 | ALL_BOOKS_CALLBACK_PATTERN = "all_books_" 23 | VOTE_BOOKS_CALLBACK_PATTERN = "vote_" 24 | -------------------------------------------------------------------------------- /botanim_bot/db.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import Iterable 3 | from typing import Any, LiteralString 4 | 5 | import aiosqlite 6 | 7 | from botanim_bot import config 8 | 9 | 10 | async def get_db() -> aiosqlite.Connection: 11 | if not getattr(get_db, "db", None): 12 | db = await aiosqlite.connect(config.SQLITE_DB_FILE) 13 | get_db.db = db 14 | 15 | return get_db.db 16 | 17 | 18 | async def fetch_all( 19 | sql: LiteralString, params: Iterable[Any] | None = None 20 | ) -> list[dict]: 21 | cursor = await _get_cursor(sql, params) 22 | rows = await cursor.fetchall() 23 | results = [] 24 | for row_ in rows: 25 | results.append(_get_result_with_column_names(cursor, row_)) 26 | await cursor.close() 27 | return results 28 | 29 | 30 | async def fetch_one( 31 | sql: LiteralString, params: Iterable[Any] | None = None 32 | ) -> dict | None: 33 | cursor = await _get_cursor(sql, params) 34 | row_ = await cursor.fetchone() 35 | if not row_: 36 | await cursor.close() 37 | return None 38 | row = _get_result_with_column_names(cursor, row_) 39 | await cursor.close() 40 | return row 41 | 42 | 43 | async def execute( 44 | sql: LiteralString, params: Iterable[Any] | None = None, *, autocommit: bool = True 45 | ) -> None: 46 | db = await get_db() 47 | args: tuple[LiteralString, Iterable[Any] | None] = (sql, params) 48 | await db.execute(*args) 49 | if autocommit: 50 | await db.commit() 51 | 52 | 53 | def close_db() -> None: 54 | asyncio.run(_async_close_db()) 55 | 56 | 57 | async def _async_close_db() -> None: 58 | await (await get_db()).close() 59 | 60 | 61 | async def _get_cursor( 62 | sql: LiteralString, params: Iterable[Any] | None 63 | ) -> aiosqlite.Cursor: 64 | db = await get_db() 65 | args: tuple[LiteralString, Iterable[Any] | None] = (sql, params) 66 | cursor = await db.execute(*args) 67 | db.row_factory = aiosqlite.Row 68 | return cursor 69 | 70 | 71 | def _get_result_with_column_names(cursor: aiosqlite.Cursor, row: aiosqlite.Row) -> dict: 72 | column_names = [d[0] for d in cursor.description] 73 | resulting_row = {} 74 | for index, column_name in enumerate(column_names): 75 | resulting_row[column_name] = row[index] 76 | return resulting_row 77 | -------------------------------------------------------------------------------- /botanim_bot/db.sql: -------------------------------------------------------------------------------- 1 | create table bot_user ( 2 | telegram_id bigint primary key, 3 | created_at timestamp default current_timestamp not null 4 | ); 5 | 6 | create table book_category ( 7 | id integer primary key, 8 | created_at timestamp default current_timestamp not null, 9 | name varchar(60) not null unique, 10 | ordering integer not null unique 11 | ); 12 | 13 | create table book ( 14 | id integer primary key, 15 | created_at timestamp default current_timestamp not null, 16 | name text, 17 | category_id integer, 18 | ordering integer not null, 19 | read_start date, 20 | read_finish date, 21 | read_comments text, 22 | foreign key(category_id) references book_category(id), 23 | check ( 24 | ( 25 | read_finish > read_start 26 | and read_finish is not null 27 | and read_start is not null 28 | ) or ( 29 | read_finish is null or read_start is null 30 | ) 31 | ), 32 | unique(category_id, ordering) 33 | ); 34 | 35 | create table voting ( 36 | id integer primary key, 37 | voting_start date not null unique, 38 | voting_finish date not null unique, 39 | check (voting_finish > voting_start) 40 | ); 41 | 42 | create table vote ( 43 | vote_id integer, 44 | user_id bigint, 45 | first_book_id integer, 46 | second_book_id integer, 47 | third_book_id integer, 48 | foreign key(vote_id) references voting(id), 49 | foreign key(user_id) references bot_user(telegram_id), 50 | foreign key(first_book_id) references book(id), 51 | foreign key(second_book_id) references book(id), 52 | foreign key(third_book_id) references book(id), 53 | primary key(vote_id, user_id) 54 | ); 55 | 56 | create table bot_user_in_vote_mode ( 57 | user_id bigint 58 | ); 59 | 60 | insert into book_category (name, ordering) values 61 | ('Как писать хорошо, а нехорошо не писать', 10), 62 | ('Тестирование', 20), 63 | ('Python', 30), 64 | ('Go', 40), 65 | ('Rust', 50), 66 | ('JavaScript', 60), 67 | ('Linux ', 70), 68 | ('Алгоритмы', 80), 69 | ('БД', 90), 70 | ('Безопасность', 100), 71 | ('Большие системы', 110), 72 | ('Фронтенд', 120), 73 | ('Machine Learning', 130), 74 | ('Another interesting', 140), 75 | ('Софт-скилы, проектная работа', 150); 76 | 77 | insert into book (name, category_id, ordering) values 78 | ('Чистый код :: Роберт Мартин', 1, 1), 79 | ('Идеальный программист :: Роберт Мартин', 1, 2), 80 | ('Чистая архитектура :: Роберт Мартин', 1, 3), 81 | ('Идеальная работа :: Роберт Мартин', 1, 4), 82 | ('Совершенный код :: Стив Макконнелл', 1, 5), 83 | ('Паттерны объектно-ориентированного проектирования :: Гамма Эрих, Хелм Ричард, Джонсон Роберт, Влиссидес Джон', 1, 6), 84 | ('Head First. Паттерны проектирования. 2-е издание :: Эрик Фримен, Элизабет Робсон', 1, 7), 85 | ('Шаблоны корпоративных приложений :: Мартин Фаулер', 1, 8), 86 | ('Шаблоны интеграции корпоративных приложений :: Бобби Вульф, Грегор Хоп', 1, 9), 87 | ('Предметно-ориентированное проектирование :: Эрик Эванс', 1, 10), 88 | ('Реализация методов предметно-ориентированного проектирования :: Вон Вернон', 1, 11), 89 | ('Пять строк кода :: Кристиан Клаусен', 1, 12), 90 | ('Рефакторинг. Улучшение существующего кода :: Мартин Фаулер ', 1, 13), 91 | ('Программируй & типизируй :: Влад Ришкуция', 1, 14), 92 | ('A Philosophy of Software Design, 2nd edition :: John Ousterhout', 1, 15), 93 | ('Эффективная работа с унаследованным кодом :: Майкл Физерс', 1, 16), 94 | 95 | ('Экстремальное программирование: разработка через тестирование :: Бек Кент', 2, 1), 96 | ('Принципы юнит-тестирования :: Хориков Владимир', 2, 2), 97 | ('Python. Разработка на основе тестирования :: Персиваль Гарри', 2, 3), 98 | ('Эффективное тестирование программного обеспечения :: Аниче Маурисио', 2, 4), 99 | 100 | ('Начинаем Программировать на Python. 5 издание :: Тонни Гэддис', 3, 1), 101 | ('Простой Python. 2 издание :: Билл Любанович', 3, 2), 102 | ('Effective Python: 90 Specific Ways to Write Better Python :: Brett Slatkin', 3, 3), 103 | ('Python на практике :: Марк Саммерфильд', 3, 4), 104 | ('Python к вершинам мастерства :: Лучано Рамальо', 3, 5), 105 | ('Asyncio и конкурентное программирование :: Мэттью Фаулер', 3, 6), 106 | ('Паттерны разработки на Python :: Гарри Персиваль. Боб Грегори', 3, 7), 107 | ('Clean Code in Python, Second Edition :: Mariano Anaya', 3, 8), 108 | ('Python Tricks :: Dan Bader, он же Чистый Python тонкости программирования для профи', 3, 9), 109 | ('Высокопроизводительные Python-приложения. Практическое руководство по эффективному программированию, 2 издание :: Горелик Миша', 3, 10), 110 | ('Автоматизация рутинных задач с помощью Python. 2 издание :: Эл Свейгарт', 3, 11), 111 | ('Внутри CPYTHON: гид по интерпретатору Python :: Энтони Шоу', 3, 12), 112 | ('Стандартная библиотека Python 3. Справочник с примерами :: Хеллман Даг', 3, 13), 113 | 114 | ('Язык программирования Go :: Алан Донован, Брайан Керниган', 4, 1), 115 | ('Go на практике :: Мэтт Батчер, Мэтт Фарина', 4, 2), 116 | ('Go. Идиомы и паттерны проектирования :: Джон Боднер', 4, 3), 117 | 118 | ('Программирование на Rust. Официальный гайд', 5, 1), 119 | ('Программирование на языке Rust :: Джейсон Орендорф, Джим Блэнди', 5, 2), 120 | ('Rust в действии :: Тим Макнамара', 5, 3), 121 | ('Zero To Production In Rust :: Luca Palmieri', 5, 4), 122 | 123 | ('Выразительный JavaScript. Современное веб-программирование :: Хавербеке Марейн', 6, 1), 124 | ('Вы не знаете JS: Начните и Совершенствуйтесь :: Kyle Simpson', 6, 2), 125 | ('Вы не знаете JS: Область видимости и замыкания :: Kyle Simpson', 6, 3), 126 | ('Вы не знаете JS: this и Прототипы Объектов :: Kyle Simpson', 6, 4), 127 | ('Вы не знаете JS: Типы и грамматика :: Kyle Simpson', 6, 5), 128 | ('Вы не знаете JS: Асинхронность и Производительность :: Kyle Simpson', 6, 6), 129 | ('Вы не знаете JS: ES6 и не только :: Kyle Simpson', 6, 7), 130 | ('Тестирование JavaScript :: Лукас Коста', 6, 8), 131 | 132 | ('Командная строка Linux. Полное руководство :: Шоттс Уильям', 7, 1), 133 | ('Linux. Необходимый код и команды :: Граннеман Скотт', 7, 2), 134 | ('Библия Linux. 10-е издание :: Негус Кристофер', 7, 3), 135 | 136 | ('Грокаем алгоритмы :: Бхаргава Адитья', 8, 1), 137 | ('Алгоритмы для начинающих. Теория и практика для разработчика :: Луридас Панос (проще Кормена, глубже, чем Грокаем)', 8, 2), 138 | ('Алгоритмы: построение и анализ. 3-е издание :: Томас Кормен', 8, 3), 139 | ('Тим Рафгарден, серия Совершенный алгоритм', 8, 4), 140 | 141 | ('Основы технологий баз данных :: Борис Новиков, Екатерина Горшкова', 9, 1), 142 | ('PostgreSQL 14 изнутри :: Егор Рогов', 9, 2), 143 | ('Оптимизация запросов в PostgreSQL :: Борис Новиков, Генриэтта Домбровская', 9, 3), 144 | ('PostgreSQL. Основы языка SQL :: Евгений Моргунов', 9, 4), 145 | ('PostgreSQL 11. Мастерство разработки :: Ганс-Юрген Шениг', 9, 5), 146 | ('NoSQL Distilled :: Мартин Фаулер', 9, 6), 147 | 148 | ('Hacking for Dummies :: Kevin Beaver', 10, 1), 149 | ('Безопасность web-приложений :: Эндрю Хоффман', 10, 2), 150 | ('Хакинг: искусство эксплойта. 2-е изд. :: Эриксон Джон', 10, 3), 151 | ('Высоконагруженные приложения. Программирование, масштабирование, поддержка :: Мартин Клеппман', 11, 1), 152 | ('Облачные архитектуры. Разработка устойчивых и экономичных облачных приложений :: Том Лащевски, Камаль Арора, Эрик Фарр, Пийюм Зонуз', 11, 2), 153 | ('System Design :: Алекс Сюй', 11, 3), 154 | 155 | ('Разработка интерфейсов. Паттерны проектирования. 3-е издание :: Дженифер Тидвелл, Чарли Брюэр, Эйнн Валенсия', 12, 1), 156 | ('Accessibility for Everyone :: Laura Kalbag', 12, 2), 157 | ('Refactoring UI :: Adam Wathan, Steve Schoger', 12, 3), 158 | ('Не заставляйте меня думать. Веб-юзабилити и здравый смысл. 3-е издание :: Стив Круг', 12, 4), 159 | ('Pro HTML5 Accessibility :: Joshue O. Connor', 12, 5), 160 | ('CSS для профи :: Грант Кит', 12, 6), 161 | ('Интерфейс. Новые направления в проектировании компьютерных систем :: Джеф Раскин', 12, 7), 162 | 163 | ('Hands-On Machine Learning with Scikit-Learn, Keras, and Tensorflow: Concepts, Tools, and Techniques to Build Intelligent Systems. 2nd Edition :: Aurélien Géron', 13, 1), 164 | ('Python и машинное обучение :: Себастьян Рашка', 13, 2), 165 | ('Python и машинное обучение. Машинное и глубокое обучение с использованием Python, scikit-learn и TensorFlow 2 :: Мирджалили Вахид, Рашка Себастьян', 13, 3), 166 | ('Практическая статистика для специалистов Data Science. 2-е изд. :: Брюс Питер', 13, 4), 167 | ('Глубокое обучение на Python. 2 издание :: Шолле Франсуа', 13, 5), 168 | ('Deep Learning for Vision Systems :: Mohamed Elgendy', 13, 6), 169 | ('Python для сложных задач: наука о данных и машинное обучение :: Вандер Плас Дж.', 13, 7), 170 | ('Data Science Наука о данных с нуля :: Грас Джоэл', 13, 8), 171 | ('Python и анализ данных :: Маккини Уэс', 13, 9), 172 | ('An Introduction to Statistical Learning :: Gareth James, Daniela Witten, Trevor Hastie, Rob Tibshirani (для новичков с матбазой)', 13, 10), 173 | ('Bayesian Reasoning and Machine Learning :: David Barber (для продвинутых)', 13, 11), 174 | ('Pattern Recognition and Machine Learning :: Кристофер Бишоп (для продвинутых)', 13, 12), 175 | 176 | ('LLVM. Инфраструктура для разработки компиляторов :: Аулер Рафаэль, Лопес Бруно Кардос', 14, 1), 177 | ('Время UNIX. A History and a Memoir :: Брайан Керниган', 14, 2), 178 | ('Git для профессионального программиста :: Штрауб Бен, Чакон Скотт', 14, 3), 179 | ('Теоретический минимум по Computer Science. Все что нужно программисту и разработчику :: Фило Владстон Феррейра', 14, 4), 180 | ('Микросервисы и контейнеры Docker :: Парминдер Сингх Кочер', 14, 5), 181 | ('Практическое использование Vim :: Дрю Нейл', 14, 6), 182 | ('IT как оружие :: Брэд Смит, Кэрол Энн Браун', 14, 7), 183 | ('Ум программиста. Как понять и осмыслить любой код :: Фелин Херманс', 14, 8), 184 | ('Делай как в Google. Разработка программного обеспечения :: Райт Хайрам, Маншрек Том', 14, 9), 185 | ('Код: тайный язык информатики :: Чарльз Петцольд', 14, 10), 186 | ('Структура и Интерпретация Компьютерных Программ :: Сассман Джеральд Джей, Абельсон Харольд', 14, 11), 187 | ('Проект «Феникс». Роман о том, как DevOps меняет бизнес к лучшему :: Спаффорд Джордж, Бер Кевин', 14, 12), 188 | ('Microservices Patterns :: Chris Richardson', 14, 13), 189 | 190 | ('Наш код. Ремесло, профессия, искусство :: Егор Бугаенко', 15, 1), 191 | ('Программист-прагматик. Путь от подмастерья к мастеру :: Э. Хант, Д. Томас', 15, 2), 192 | ('Джедайские техники :: Дорофеев Максим', 15, 3), 193 | ('Визуализируйте работу :: Доминика Деграндис', 15, 4), 194 | ('Как пасти котов :: Рейнвотер Дж. Ханк', 15, 5), 195 | ('Мифический человеко-месяц, или Как создаются программные системы :: Брукс Фредерик', 15, 6), 196 | ('Deadline. Роман об управлении проектами :: Том Демарко', 15, 7), 197 | ('Сделано. Проектный менеджмент на практике :: Скотт Беркун', 15, 8), 198 | ('Думай медленно… решай быстро :: Даниэль Канеман', 15, 9), 199 | ('Стартап: Настольная книга основателя :: Стив Бланк, Боб Дорф', 15, 10), 200 | ('От нуля к единице :: Питер Тиль', 15, 11), 201 | ('Бизнес с нуля :: Эрик Рис', 15, 12), 202 | ('Rework: бизнес без предрассудков :: Джейсон Фрайд, Дэвид Хайнемайер Хенссон', 15, 13), 203 | ('Как привести дела в порядок :: Дэвид Аллен', 15, 14); 204 | 205 | 206 | update book 207 | set 208 | read_start='2022-11-21', 209 | read_finish='2022-12-18', 210 | read_comments='книга огонь, в группе доступно 4.5 часа видео-комментариев' 211 | where name='Чистый код :: Роберт Мартин'; 212 | 213 | 214 | update book 215 | set 216 | read_start='2022-12-18', 217 | read_finish='2022-12-31', 218 | read_comments='неплохой вводный материал по CS, в группе доступно 2 часа видео-комментариев' 219 | where name='Теоретический минимум по Computer Science. Все что нужно программисту и разработчику :: Фило Владстон Феррейра'; 220 | 221 | update book 222 | set 223 | read_start='2023-01-01', 224 | read_finish='2023-02-12', 225 | read_comments='отличная книга по SQL в исполнении постгреса' 226 | where name='PostgreSQL. Основы языка SQL :: Евгений Моргунов'; 227 | 228 | 229 | insert into voting (voting_start, voting_finish) values ('2023-01-26', '2023-01-30'); 230 | 231 | alter table book add column group_post_link varchar(60); 232 | -------------------------------------------------------------------------------- /botanim_bot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .all_books import all_books, all_books_button 2 | from .already import already 3 | from .cancel import cancel 4 | from .help import help_ 5 | from .now import now 6 | from .start import start 7 | from .vote import vote, vote_button 8 | from .vote_process import vote_process 9 | from .vote_results import vote_results 10 | 11 | __all__ = [ 12 | "start", 13 | "help_", 14 | "already", 15 | "now", 16 | "all_books", 17 | "all_books_button", 18 | "vote_results", 19 | "vote_process", 20 | "vote_button", 21 | "vote", 22 | "cancel", 23 | ] 24 | -------------------------------------------------------------------------------- /botanim_bot/handlers/all_books.py: -------------------------------------------------------------------------------- 1 | import telegram 2 | from telegram import Update 3 | from telegram.ext import ContextTypes 4 | 5 | from botanim_bot import config 6 | from botanim_bot.handlers.keyboards import get_categories_keyboard 7 | from botanim_bot.handlers.response import send_response 8 | from botanim_bot.services.books import get_all_books 9 | from botanim_bot.templates import render_template 10 | 11 | 12 | async def all_books(update: Update, context: ContextTypes.DEFAULT_TYPE): 13 | categories_with_books = list(await get_all_books()) 14 | if not update.message: 15 | return 16 | 17 | await send_response( 18 | update, 19 | context, 20 | render_template( 21 | "category_with_books.j2", 22 | {"category": categories_with_books[0], "start_index": None}, 23 | ), 24 | get_categories_keyboard( 25 | current_category_index=0, 26 | categories_count=len(categories_with_books), 27 | callback_prefix=config.ALL_BOOKS_CALLBACK_PATTERN, 28 | ), 29 | ) 30 | 31 | 32 | async def all_books_button(update: Update, _: ContextTypes.DEFAULT_TYPE): 33 | query = update.callback_query 34 | await query.answer() 35 | if not query.data or not query.data.strip(): 36 | return 37 | categories_with_books = list(await get_all_books()) 38 | current_category_index = _get_current_category_index(query.data) 39 | await query.edit_message_text( 40 | text=render_template( 41 | "category_with_books.j2", 42 | { 43 | "category": categories_with_books[current_category_index], 44 | "start_index": None, 45 | }, 46 | ), 47 | reply_markup=get_categories_keyboard( 48 | current_category_index=current_category_index, 49 | categories_count=len(categories_with_books), 50 | callback_prefix=config.ALL_BOOKS_CALLBACK_PATTERN, 51 | ), 52 | parse_mode=telegram.constants.ParseMode.HTML, 53 | ) 54 | 55 | 56 | def _get_current_category_index(query_data) -> int: 57 | pattern_prefix_length = len(config.ALL_BOOKS_CALLBACK_PATTERN) 58 | return int(query_data[pattern_prefix_length:]) 59 | -------------------------------------------------------------------------------- /botanim_bot/handlers/already.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from telegram import Update, User 4 | from telegram.ext import ContextTypes 5 | 6 | from botanim_bot import config 7 | from botanim_bot.handlers.response import send_response 8 | from botanim_bot.services.books import get_already_read_books 9 | from botanim_bot.services.validation import is_user_in_channel 10 | from botanim_bot.templates import render_template 11 | 12 | 13 | async def already(update: Update, context: ContextTypes.DEFAULT_TYPE): 14 | already_read_books = await get_already_read_books() 15 | 16 | user_id = cast(User, update.effective_user).id 17 | if not await is_user_in_channel(user_id, config.TELEGRAM_BOTANIM_CHANNEL_ID): 18 | template = "already.j2" 19 | else: 20 | template = "already_for_member.j2" 21 | await send_response( 22 | update, 23 | context, 24 | response=render_template(template, {"already_read_books": already_read_books}), 25 | ) 26 | -------------------------------------------------------------------------------- /botanim_bot/handlers/cancel.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from telegram import Update, User 4 | from telegram.ext import ContextTypes 5 | 6 | from botanim_bot.handlers.response import send_response 7 | from botanim_bot.services.vote_mode import remove_user_from_vote_mode 8 | from botanim_bot.templates import render_template 9 | 10 | 11 | async def cancel(update: Update, context: ContextTypes.DEFAULT_TYPE): 12 | await remove_user_from_vote_mode(cast(User, update.effective_user).id) 13 | await send_response(update, context, response=render_template("start.j2")) 14 | -------------------------------------------------------------------------------- /botanim_bot/handlers/help.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.ext import ContextTypes 3 | 4 | from botanim_bot.handlers.response import send_response 5 | from botanim_bot.templates import render_template 6 | 7 | 8 | async def help_(update: Update, context: ContextTypes.DEFAULT_TYPE): 9 | await send_response(update, context, response=render_template("help.j2")) 10 | -------------------------------------------------------------------------------- /botanim_bot/handlers/keyboards.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardButton, InlineKeyboardMarkup 2 | 3 | 4 | def get_categories_keyboard( 5 | current_category_index: int, categories_count: int, callback_prefix: str 6 | ) -> InlineKeyboardMarkup: 7 | prev_index = current_category_index - 1 8 | if prev_index < 0: 9 | prev_index = categories_count - 1 10 | next_index = current_category_index + 1 11 | if next_index > categories_count - 1: 12 | next_index = 0 13 | keyboard = [ 14 | [ 15 | InlineKeyboardButton("<", callback_data=f"{callback_prefix}{prev_index}"), 16 | InlineKeyboardButton( 17 | f"{current_category_index + 1}/{categories_count}", callback_data=" " 18 | ), 19 | InlineKeyboardButton( 20 | ">", 21 | callback_data=f"{callback_prefix}{next_index}", 22 | ), 23 | ] 24 | ] 25 | return InlineKeyboardMarkup(keyboard) 26 | -------------------------------------------------------------------------------- /botanim_bot/handlers/now.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from telegram import Update, User 4 | from telegram.ext import ContextTypes 5 | 6 | from botanim_bot import config 7 | from botanim_bot.handlers.response import send_response 8 | from botanim_bot.services.books import get_next_book, get_now_reading_books 9 | from botanim_bot.services.validation import is_user_in_channel 10 | from botanim_bot.templates import render_template 11 | 12 | 13 | async def now(update: Update, context: ContextTypes.DEFAULT_TYPE): 14 | now_read_books = await get_now_reading_books() 15 | next_book = await get_next_book() 16 | 17 | user_id = cast(User, update.effective_user).id 18 | if not await is_user_in_channel(user_id, config.TELEGRAM_BOTANIM_CHANNEL_ID): 19 | template = "now.j2" 20 | else: 21 | template = "now_for_member.j2" 22 | await send_response( 23 | update, 24 | context, 25 | response=render_template( 26 | template, {"now_read_books": now_read_books, "next_book": next_book} 27 | ), 28 | ) 29 | -------------------------------------------------------------------------------- /botanim_bot/handlers/response.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import telegram 4 | from telegram import Chat, InlineKeyboardMarkup, Update 5 | from telegram.ext import ContextTypes 6 | 7 | 8 | async def send_response( 9 | update: Update, 10 | context: ContextTypes.DEFAULT_TYPE, 11 | response: str, 12 | keyboard: InlineKeyboardMarkup | None = None, 13 | ) -> None: 14 | args = { 15 | "chat_id": _get_chat_id(update), 16 | "disable_web_page_preview": True, 17 | "text": response, 18 | "parse_mode": telegram.constants.ParseMode.HTML, 19 | } 20 | if keyboard: 21 | args["reply_markup"] = keyboard 22 | 23 | await context.bot.send_message(**args) 24 | 25 | 26 | def _get_chat_id(update: Update) -> int: 27 | return cast(Chat, update.effective_chat).id 28 | -------------------------------------------------------------------------------- /botanim_bot/handlers/start.py: -------------------------------------------------------------------------------- 1 | from telegram import Update 2 | from telegram.ext import ContextTypes 3 | 4 | from botanim_bot.handlers.response import send_response 5 | from botanim_bot.templates import render_template 6 | 7 | 8 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): 9 | await send_response(update, context, response=render_template("start.j2")) 10 | -------------------------------------------------------------------------------- /botanim_bot/handlers/vote.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | import telegram 4 | from telegram import Update, User 5 | from telegram.ext import ContextTypes 6 | 7 | from botanim_bot import config 8 | from botanim_bot.handlers.keyboards import get_categories_keyboard 9 | from botanim_bot.handlers.response import send_response 10 | from botanim_bot.services.books import ( 11 | calculate_category_books_start_index, 12 | get_not_started_books, 13 | ) 14 | from botanim_bot.services.validation import is_user_in_channel 15 | from botanim_bot.services.vote_mode import set_user_in_vote_mode 16 | from botanim_bot.services.votings import get_actual_voting 17 | from botanim_bot.templates import render_template 18 | 19 | 20 | def validate_user(handler): 21 | async def wrapped(update: Update, context: ContextTypes.DEFAULT_TYPE): 22 | user_id = cast(User, update.effective_user).id 23 | if not await is_user_in_channel(user_id, config.TELEGRAM_BOTANIM_CHANNEL_ID): 24 | await send_response( 25 | update, context, response=render_template("vote_cant_vote.j2") 26 | ) 27 | return 28 | await handler(update, context) 29 | 30 | return wrapped 31 | 32 | 33 | @validate_user 34 | async def vote(update: Update, context: ContextTypes.DEFAULT_TYPE): 35 | if await get_actual_voting() is None: 36 | await send_response( 37 | update, context, response=render_template("vote_no_actual_voting.j2") 38 | ) 39 | return 40 | 41 | if not update.message: 42 | return 43 | 44 | categories_with_books = tuple(await get_not_started_books()) 45 | current_category = categories_with_books[0] 46 | 47 | category_books_start_index = calculate_category_books_start_index( 48 | categories_with_books, current_category 49 | ) 50 | 51 | await set_user_in_vote_mode(cast(User, update.effective_user).id) 52 | await update.message.reply_text( 53 | render_template( 54 | "category_with_books.j2", 55 | {"category": current_category, "start_index": category_books_start_index}, 56 | ), 57 | reply_markup=get_categories_keyboard( 58 | 0, len(categories_with_books), config.VOTE_BOOKS_CALLBACK_PATTERN 59 | ), 60 | parse_mode=telegram.constants.ParseMode.HTML, 61 | ) 62 | await send_response( 63 | update, context, response=render_template("vote_description.j2") 64 | ) 65 | 66 | 67 | @validate_user 68 | async def vote_button(update: Update, _: ContextTypes.DEFAULT_TYPE): 69 | query = update.callback_query 70 | await query.answer() 71 | if not query.data or not query.data.strip(): 72 | return 73 | categories_with_books = list(await get_not_started_books()) 74 | 75 | current_category_index = _get_current_category_index(query.data) 76 | current_category = categories_with_books[current_category_index] 77 | 78 | category_books_start_index = calculate_category_books_start_index( 79 | categories_with_books, current_category 80 | ) 81 | await query.edit_message_text( 82 | render_template( 83 | "category_with_books.j2", 84 | {"category": current_category, "start_index": category_books_start_index}, 85 | ), 86 | reply_markup=get_categories_keyboard( 87 | current_category_index, 88 | len(categories_with_books), 89 | config.VOTE_BOOKS_CALLBACK_PATTERN, 90 | ), 91 | parse_mode=telegram.constants.ParseMode.HTML, 92 | ) 93 | 94 | 95 | def _get_current_category_index(query_data) -> int: 96 | pattern_prefix_length = len(config.VOTE_BOOKS_CALLBACK_PATTERN) 97 | return int(query_data[pattern_prefix_length:]) 98 | -------------------------------------------------------------------------------- /botanim_bot/handlers/vote_process.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import cast 3 | 4 | from telegram import Update, User 5 | from telegram.ext import ContextTypes 6 | 7 | from botanim_bot import config 8 | from botanim_bot.handlers.response import send_response 9 | from botanim_bot.handlers.vote import validate_user 10 | from botanim_bot.services.books import Book, get_books_by_positional_numbers 11 | from botanim_bot.services.exceptions import NoActualVotingError, UserInNotVoteModeError 12 | from botanim_bot.services.num_to_words import num_to_words 13 | from botanim_bot.services.vote_mode import is_user_in_vote_mode 14 | from botanim_bot.services.votings import save_vote 15 | from botanim_bot.templates import render_template 16 | 17 | 18 | @validate_user 19 | async def vote_process(update: Update, context: ContextTypes.DEFAULT_TYPE): 20 | if not await is_user_in_vote_mode(cast(User, update.effective_user).id): 21 | await send_response( 22 | update, context, response=render_template("vote_user_not_in_right_mode.j2") 23 | ) 24 | return 25 | 26 | user_message = update.message.text 27 | 28 | books_positional_numbers = _get_numbers_from_text(user_message) 29 | if not _is_numbers_sufficient(books_positional_numbers): 30 | await send_response( 31 | update, context, response=render_template("vote_incorrect_input.j2") 32 | ) 33 | return 34 | 35 | selected_books = await get_books_by_positional_numbers(books_positional_numbers) 36 | if not _is_finded_books_count_sufficient(selected_books): 37 | await send_response(update, context, render_template("vote_incorrect_books.j2")) 38 | return 39 | 40 | try: 41 | await save_vote(cast(User, update.effective_user).id, selected_books) 42 | except NoActualVotingError: 43 | await send_response( 44 | update, context, response=render_template("vote_no_actual_voting.j2") 45 | ) 46 | return 47 | except UserInNotVoteModeError: 48 | await send_response( 49 | update, context, response=render_template("vote_user_not_in_right_mode.j2") 50 | ) 51 | return 52 | 53 | books_count = len(selected_books) 54 | word_in_correct_form = num_to_words(books_count, ("книга", "книги", "книг")) 55 | 56 | await send_response( 57 | update, 58 | context, 59 | response=render_template( 60 | "vote_success.j2", 61 | { 62 | "selected_books": selected_books, 63 | "books_count": f"{books_count} {word_in_correct_form}", 64 | }, 65 | ), 66 | ) 67 | 68 | 69 | def _get_numbers_from_text(message: str) -> list[int]: 70 | return list(map(int, re.findall(r"\d+", message))) 71 | 72 | 73 | def _is_numbers_sufficient(numbers: list[int]) -> bool: 74 | return len(numbers) == config.VOTE_ELEMENTS_COUNT 75 | 76 | 77 | def _is_finded_books_count_sufficient(finded_books: tuple[Book]) -> bool: 78 | return len(finded_books) == config.VOTE_ELEMENTS_COUNT 79 | -------------------------------------------------------------------------------- /botanim_bot/handlers/vote_results.py: -------------------------------------------------------------------------------- 1 | from typing import cast 2 | 3 | from telegram import Update, User 4 | from telegram.ext import ContextTypes 5 | 6 | from botanim_bot.handlers.response import send_response 7 | from botanim_bot.services.vote_results import get_leaders 8 | from botanim_bot.services.votings import get_user_vote 9 | from botanim_bot.templates import render_template 10 | 11 | 12 | async def vote_results(update: Update, context: ContextTypes.DEFAULT_TYPE): 13 | leaders = await get_leaders() 14 | if leaders is None: 15 | await send_response( 16 | update, context, response=render_template("vote_results_no_data.j2") 17 | ) 18 | return 19 | 20 | your_vote = await get_user_vote( 21 | cast(User, update.effective_user).id, leaders.voting.id 22 | ) 23 | await send_response( 24 | update, 25 | context, 26 | response=render_template( 27 | "vote_results.j2", {"leaders": leaders, "your_vote": your_vote} 28 | ), 29 | ) 30 | -------------------------------------------------------------------------------- /botanim_bot/services/books.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Iterable 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from typing import LiteralString, cast 5 | 6 | from botanim_bot import config 7 | from botanim_bot.db import fetch_all 8 | 9 | 10 | @dataclass 11 | class Book: 12 | id: int 13 | name: str 14 | category_id: int 15 | category_name: str 16 | read_start: str | None 17 | read_finish: str | None 18 | read_comments: str | None 19 | positional_number: int 20 | group_post_link: str | None 21 | 22 | def is_started(self) -> bool: 23 | if self.read_start is not None: 24 | now_date = datetime.now().date() 25 | start_read_date = datetime.strptime( 26 | self.read_start, config.DATE_FORMAT 27 | ).date() 28 | 29 | return start_read_date >= now_date 30 | 31 | return False 32 | 33 | def is_finished(self) -> bool: 34 | if self.read_finish is not None: 35 | now_date = datetime.now().date() 36 | finish_read_date = datetime.strptime( 37 | self.read_finish, config.DATE_FORMAT 38 | ).date() 39 | 40 | return finish_read_date <= now_date 41 | 42 | return False 43 | 44 | def is_planned(self) -> bool: 45 | if self.read_start is not None: 46 | now_date = datetime.now().date() 47 | start_read_date = datetime.strptime( 48 | self.read_start, config.DATE_FORMAT 49 | ).date() 50 | 51 | return start_read_date >= now_date 52 | 53 | return False 54 | 55 | def __post_init__(self): 56 | """Set up read_start and read_finish to needed string format""" 57 | for field in ("read_start", "read_finish"): 58 | value = getattr(self, field) 59 | if value is None: 60 | continue 61 | value = datetime.strptime(value, "%Y-%m-%d").strftime(config.DATE_FORMAT) 62 | setattr(self, field, value) 63 | 64 | self.name = format_book_name(self.name) 65 | 66 | 67 | @dataclass 68 | class Category: 69 | id: int 70 | name: str 71 | books: list[Book] 72 | 73 | 74 | async def get_all_books() -> Iterable[Category]: 75 | sql = f"""{_get_books_base_sql()} 76 | ORDER BY c."ordering", b."ordering" """ 77 | books = await _get_books_from_db(sql) 78 | return _group_books_by_categories(books) 79 | 80 | 81 | async def get_not_started_books() -> Iterable[Category]: 82 | sql = f"""{_get_books_base_sql()} 83 | WHERE b.read_start IS NULL 84 | ORDER BY c."ordering", b."ordering" """ 85 | books = await _get_books_from_db(sql) 86 | return _group_books_by_categories(books) 87 | 88 | 89 | async def get_already_read_books() -> Iterable[Book]: 90 | sql = f"""{_get_books_base_sql()} 91 | WHERE read_start Iterable[Book]: 98 | sql = f"""{_get_books_base_sql()} 99 | WHERE read_start<=current_date 100 | AND read_finish>=current_date 101 | ORDER BY b.read_start""" 102 | return await _get_books_from_db(sql) 103 | 104 | 105 | async def get_next_book() -> Book | None: 106 | sql = f"""{_get_books_base_sql()} 107 | WHERE b.read_start > current_date 108 | ORDER BY b.read_start 109 | LIMIT 1""" 110 | books = await _get_books_from_db(sql) 111 | if not books: 112 | return None 113 | return books[0] 114 | 115 | 116 | def calculate_category_books_start_index( 117 | categories: Iterable[Category], current_category: Category 118 | ) -> int | None: 119 | start_index = 0 120 | for category in categories: 121 | if category.id != current_category.id: 122 | start_index += len(tuple(category.books)) 123 | else: 124 | break 125 | 126 | return start_index 127 | 128 | 129 | async def get_books_by_positional_numbers(numbers: Iterable[int]) -> tuple[Book]: 130 | numbers_joined = ", ".join(map(str, map(int, numbers))) 131 | 132 | hardcoded_sql_values = [] 133 | for index, number in enumerate(numbers, 1): 134 | hardcoded_sql_values.append(f"({number}, {index})") 135 | 136 | output_hardcoded_sql_values = ", ".join(hardcoded_sql_values) 137 | 138 | base_sql = _get_books_base_sql( 139 | 'ROW_NUMBER() over (order by c."ordering", b."ordering") as idx' 140 | ) 141 | sql = f""" 142 | SELECT t2.* FROM ( 143 | VALUES {output_hardcoded_sql_values} 144 | ) t0 145 | INNER JOIN 146 | ( 147 | SELECT t.* FROM ( 148 | {base_sql} 149 | WHERE read_start IS NULL 150 | ) t 151 | WHERE t.idx IN ({numbers_joined}) 152 | ) t2 153 | ON t0.column1 = t2.idx 154 | ORDER BY t0.column2 155 | """ 156 | return tuple(await _get_books_from_db(cast(LiteralString, sql))) 157 | 158 | 159 | async def get_books_info_by_ids(ids: Iterable[int]) -> dict[int, Book]: 160 | int_ids = tuple(str(int(id_)) for id_ in ids) 161 | sql = f""" 162 | {_get_books_base_sql()} 163 | WHERE b.id IN ({",".join(int_ids)}) 164 | ORDER BY c."name", b."name" 165 | """ 166 | books = await _get_books_from_db(cast(LiteralString, sql)) 167 | return {b.id: b for b in books} 168 | 169 | 170 | def format_book_name(book_name: str) -> str: 171 | try: 172 | book_name, author = tuple(map(str.strip, book_name.split("::"))) 173 | except ValueError: 174 | return book_name 175 | return f"{book_name}. {author}" 176 | 177 | 178 | def _group_books_by_categories(books: Iterable[Book]) -> Iterable[Category]: 179 | categories = [] 180 | category_id = None 181 | for book in books: 182 | if category_id != book.category_id: 183 | categories.append( 184 | Category(id=book.category_id, name=book.category_name, books=[book]) 185 | ) 186 | category_id = book.category_id 187 | continue 188 | categories[-1].books.append(book) 189 | return categories 190 | 191 | 192 | def _get_books_base_sql(select_param: LiteralString | None = None) -> LiteralString: 193 | return f""" 194 | WITH pos_numbers AS ( 195 | SELECT 196 | ROW_NUMBER() OVER (ORDER BY c.ordering, b.ordering) AS rn, 197 | b.id AS book_id 198 | FROM book b LEFT JOIN book_category c ON c.id=b.category_id 199 | WHERE b.read_start IS NULL 200 | ) 201 | SELECT 202 | b.id as book_id, 203 | b.name as book_name, 204 | b.group_post_link, 205 | c.id as category_id, 206 | c.name as category_name, 207 | pos_number.rn as positional_number, 208 | {select_param + "," if select_param else ""} 209 | b.read_start, b.read_finish, 210 | read_comments 211 | FROM book b 212 | LEFT JOIN book_category c ON c.id=b.category_id 213 | LEFT JOIN pos_numbers AS pos_number ON pos_number.book_id=b.id 214 | """ 215 | 216 | 217 | async def _get_books_from_db(sql: LiteralString) -> list[Book]: 218 | books_raw = await fetch_all(sql) 219 | return [ 220 | Book( 221 | id=book["book_id"], 222 | name=book["book_name"], 223 | category_id=book["category_id"], 224 | category_name=book["category_name"], 225 | read_start=book["read_start"], 226 | read_finish=book["read_finish"], 227 | read_comments=book["read_comments"], 228 | positional_number=book["positional_number"], 229 | group_post_link=book["group_post_link"], 230 | ) 231 | for book in books_raw 232 | ] 233 | -------------------------------------------------------------------------------- /botanim_bot/services/exceptions.py: -------------------------------------------------------------------------------- 1 | class UserInNotVoteModeError(Exception): 2 | pass 3 | 4 | 5 | class NoActualVotingError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /botanim_bot/services/num_to_words.py: -------------------------------------------------------------------------------- 1 | def num_to_words(count: int, word_forms: tuple[str, str, str]) -> str: 2 | if count % 10 == 1 and count % 100 != 11: 3 | p = 0 4 | elif 2 <= count % 10 <= 4 and (count % 100 < 10 or count % 100 >= 20): 5 | p = 1 6 | else: 7 | p = 2 8 | 9 | return word_forms[p] 10 | -------------------------------------------------------------------------------- /botanim_bot/services/schulze.py: -------------------------------------------------------------------------------- 1 | """Ranks candidates by the Schulze method. 2 | 3 | For more information read http://en.wikipedia.org/wiki/Schulze_method. 4 | """ 5 | import itertools 6 | 7 | __author__ = "Michael G. Parker" 8 | __contact__ = "http://omgitsmgp.com/" 9 | 10 | from collections import defaultdict 11 | 12 | 13 | def _add_remaining_ranks(d, candidate, remaining_ranks, weight): 14 | for remaining_rank in remaining_ranks: 15 | for other_candidate in remaining_rank: 16 | d[candidate, other_candidate] += weight 17 | 18 | 19 | def _add_ranks_to_d(d, ranks, weight): 20 | for i, rank in enumerate(ranks): 21 | remaining_ranks = ranks[(i + 1) :] 22 | for candidate in rank: 23 | _add_remaining_ranks(d, candidate, remaining_ranks, weight) 24 | 25 | 26 | def _fill_missed_candidates(weighted_ranks, candidates): 27 | weighted_ranks = list(weighted_ranks) 28 | for index, rank in enumerate(weighted_ranks): 29 | flatten_ranks = list(itertools.chain(*rank[0])) 30 | if len(flatten_ranks) == len(candidates): 31 | continue 32 | rest_candidates = [c for c in candidates if c not in flatten_ranks] 33 | if rest_candidates: 34 | weighted_ranks[index] = list(rank) 35 | weighted_ranks[index][0] = list(rank[0]) 36 | weighted_ranks[index][0].append(rest_candidates) 37 | return weighted_ranks 38 | 39 | 40 | def _compute_d(weighted_ranks, candidates): 41 | """Computes the d array in the Schulze method. 42 | 43 | d[V,W] is the number of voters who prefer candidate V over W. 44 | """ 45 | weighted_ranks = _fill_missed_candidates(weighted_ranks, candidates) 46 | d = defaultdict(int) 47 | for ranks, weight in weighted_ranks: 48 | _add_ranks_to_d(d, ranks, weight) 49 | return d 50 | 51 | 52 | def _compute_p(d, candidates): # noqa: C901 TODO: need refactor, large nesting 53 | """Computes the p array in the Schulze method. 54 | 55 | p[V,W] is the strength of the strongest path from candidate V to W. 56 | """ 57 | p = {} 58 | for candidate_1 in candidates: 59 | for candidate_2 in candidates: 60 | if candidate_1 != candidate_2: 61 | strength = d.get((candidate_1, candidate_2), 0) 62 | if strength > d.get((candidate_2, candidate_1), 0): 63 | p[candidate_1, candidate_2] = strength 64 | 65 | for candidate_1 in candidates: 66 | for candidate_2 in candidates: 67 | if candidate_1 != candidate_2: 68 | for candidate_3 in candidates: 69 | if (candidate_1 != candidate_3) and (candidate_2 != candidate_3): 70 | curr_value = p.get((candidate_2, candidate_3), 0) 71 | new_value = min( 72 | p.get((candidate_2, candidate_1), 0), 73 | p.get((candidate_1, candidate_3), 0), 74 | ) 75 | if new_value > curr_value: 76 | p[candidate_2, candidate_3] = new_value 77 | 78 | return p 79 | 80 | 81 | def _rank_p(candidates, p): 82 | """Ranks the candidates by p.""" 83 | candidate_wins = defaultdict(list) 84 | 85 | for candidate_1 in candidates: 86 | num_wins = 0 87 | 88 | # Compute the number of wins this candidate has over all other candidates. 89 | for candidate_2 in candidates: 90 | if candidate_1 == candidate_2: 91 | continue 92 | candidate1_score = p.get((candidate_1, candidate_2), 0) 93 | candidate2_score = p.get((candidate_2, candidate_1), 0) 94 | if candidate1_score > candidate2_score: 95 | num_wins += 1 96 | 97 | candidate_wins[num_wins].append(candidate_1) 98 | 99 | sorted_wins = sorted(candidate_wins.keys(), reverse=True) 100 | return [candidate_wins[num_wins] for num_wins in sorted_wins] 101 | 102 | 103 | def compute_ranks(candidates, weighted_ranks): 104 | """Returns the candidates ranked by the Schulze method. 105 | 106 | See http://en.wikipedia.org/wiki/Schulze_method for details. 107 | 108 | Parameter candidates is a sequence containing all the candidates. 109 | 110 | Parameter weighted_ranks is a sequence of (ranks, weight) pairs. 111 | The first element, ranks, is a ranking of the candidates. 112 | It is an array of arrays so that we 113 | can express ties. For example, [[a, b], [c], [d, e]] represents a = b > c > d = e. 114 | The second element, weight, is typically the number of voters that chose 115 | this ranking. 116 | """ 117 | d = _compute_d(weighted_ranks, candidates) 118 | p = _compute_p(d, candidates) 119 | return _rank_p(candidates, p) 120 | -------------------------------------------------------------------------------- /botanim_bot/services/users.py: -------------------------------------------------------------------------------- 1 | from botanim_bot.db import execute 2 | 3 | 4 | async def insert_user(telegram_user_id: int) -> None: 5 | await execute( 6 | "INSERT OR IGNORE INTO bot_user (telegram_id) VALUES (:telegram_id)", 7 | {"telegram_id": telegram_user_id}, 8 | ) 9 | -------------------------------------------------------------------------------- /botanim_bot/services/validation.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import httpx 4 | 5 | from botanim_bot import config 6 | 7 | 8 | async def is_user_in_channel(user_id: int, channel_id: int) -> bool: 9 | """Returns True if user `user_id` in `channel_id` now""" 10 | url = _get_tg_url(method="getChatMember", chat_id=channel_id, user_id=user_id) 11 | async with httpx.AsyncClient() as client: 12 | json_response = (await client.get(url)).json() 13 | try: 14 | return json_response["result"]["status"] in ( 15 | "member", 16 | "creator", 17 | "administrator", 18 | ) 19 | except KeyError: 20 | return False 21 | 22 | 23 | def _get_tg_url(method: str, **params) -> str: 24 | """Returns URL for Telegram Bot API method `method` 25 | and optional key=value `params`""" 26 | url = f"https://api.telegram.org/bot{config.TELEGRAM_BOT_TOKEN}/{method}" 27 | if params: 28 | url += "?" + urllib.parse.urlencode(params) 29 | return url 30 | -------------------------------------------------------------------------------- /botanim_bot/services/vote_mode.py: -------------------------------------------------------------------------------- 1 | from botanim_bot.db import execute, fetch_one 2 | 3 | 4 | async def is_user_in_vote_mode(user_id: int) -> bool: 5 | user_exists = await fetch_one( 6 | "select user_id from bot_user_in_vote_mode where user_id=:user_id", 7 | {"user_id": user_id}, 8 | ) 9 | return user_exists is not None 10 | 11 | 12 | async def set_user_in_vote_mode(user_id: int) -> None: 13 | await execute( 14 | "insert or ignore into bot_user_in_vote_mode (user_id) values (:user_id)", 15 | {"user_id": user_id}, 16 | ) 17 | 18 | 19 | async def remove_user_from_vote_mode(user_id: int) -> None: 20 | await execute( 21 | "delete from bot_user_in_vote_mode where user_id=:user_id", 22 | {"user_id": user_id}, 23 | autocommit=False, 24 | ) 25 | -------------------------------------------------------------------------------- /botanim_bot/services/vote_results.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Iterable, TypedDict, TypeVar, cast 3 | 4 | from botanim_bot import config 5 | from botanim_bot.db import fetch_all 6 | from botanim_bot.services import schulze 7 | from botanim_bot.services.books import ( 8 | get_books_info_by_ids, 9 | ) 10 | from botanim_bot.services.votings import Voting, get_actual_or_last_voting 11 | 12 | 13 | @dataclass 14 | class BookVoteResult: 15 | book_name: str 16 | positional_number: int 17 | 18 | 19 | @dataclass 20 | class BooksList: 21 | books: list[BookVoteResult] 22 | 23 | 24 | @dataclass 25 | class VoteLeaders: 26 | voting: Voting 27 | leaders: list[BooksList] 28 | votes_count: int 29 | 30 | 31 | async def get_leaders() -> VoteLeaders | None: 32 | actual_voting = await get_actual_or_last_voting() 33 | if actual_voting is None: 34 | return None 35 | 36 | vote_results_raw = await _get_vote_results(actual_voting.id) 37 | 38 | leaders_ids, candidates_ids = _get_top_leaders_with_schulze(vote_results_raw) 39 | 40 | vote_leaders = await _build_vote_leaders(actual_voting, candidates_ids, leaders_ids) 41 | vote_leaders.votes_count = _calculate_overall_votes(vote_results_raw) 42 | return vote_leaders 43 | 44 | 45 | class VoteRow(TypedDict): 46 | first_book_id: int 47 | second_book_id: int 48 | third_book_id: int 49 | votes_count: int 50 | 51 | 52 | def _get_top_leaders_with_schulze( 53 | vote_results_raw: list[VoteRow], 54 | ) -> tuple[list[list[int]], set[int]]: 55 | candidates, weighted_ranks = _build_data_for_schulze(vote_results_raw) 56 | leaders = schulze.compute_ranks(candidates, weighted_ranks) 57 | return leaders[: config.VOTE_RESULTS_TOP], candidates 58 | 59 | 60 | async def _build_vote_leaders( 61 | voting: Voting, books_candidates: Iterable[int], leaders: list[list[int]] 62 | ) -> VoteLeaders: 63 | book_id_to_book = await get_books_info_by_ids(books_candidates) 64 | vote_leaders = _init_vote_results(voting) 65 | 66 | for books_set in leaders: 67 | book_names = [ 68 | BookVoteResult( 69 | book_name=book_id_to_book[book].name, 70 | positional_number=cast(int, book_id_to_book[book].positional_number), 71 | ) 72 | for book in books_set 73 | ] 74 | vote_leaders.leaders.append(BooksList(books=book_names)) 75 | return vote_leaders 76 | 77 | 78 | def _init_vote_results(voting: Voting) -> VoteLeaders: 79 | return VoteLeaders( 80 | voting=Voting( 81 | voting_start=voting.voting_start, 82 | voting_finish=voting.voting_finish, 83 | id=voting.id, 84 | ), 85 | leaders=[], 86 | votes_count=0, 87 | ) 88 | 89 | 90 | def _calculate_overall_votes(vote_results_rows: list[VoteRow]) -> int: 91 | return sum((vote["votes_count"] for vote in vote_results_rows)) 92 | 93 | 94 | async def _get_vote_results(vote_id: int) -> list[VoteRow]: 95 | sql = """ 96 | select 97 | first_book_id, 98 | second_book_id, 99 | third_book_id, 100 | count(*) as votes_count 101 | from vote 102 | where vote_id=:vote_id 103 | group by 1, 2, 3 104 | """ 105 | return cast(list[VoteRow], await fetch_all(sql, {"vote_id": vote_id})) 106 | 107 | 108 | def _build_data_for_schulze( 109 | rows, 110 | ) -> tuple[set[int], list[tuple[list[list[int]], int]]]: 111 | candidate_books = set() 112 | weighted_ranks = [] 113 | 114 | for row in rows: 115 | candidate_books.update( 116 | [row["first_book_id"], row["second_book_id"], row["third_book_id"]] 117 | ) 118 | 119 | weighted_ranks.append((_prepare_weighted_ranks(row), row["votes_count"])) 120 | 121 | return candidate_books, weighted_ranks 122 | 123 | 124 | def _prepare_weighted_ranks(row: VoteRow) -> list[list[int]]: 125 | book_ids = [row["first_book_id"], row["second_book_id"], row["third_book_id"]] 126 | book_ids_withot_duplicates = _remove_duplicates_with_save_order(book_ids) 127 | return [[book_id] for book_id in book_ids_withot_duplicates] 128 | 129 | 130 | T = TypeVar("T") 131 | 132 | 133 | def _remove_duplicates_with_save_order(elements: Iterable[T]) -> Iterable[T]: 134 | return list(dict.fromkeys(elements)) 135 | -------------------------------------------------------------------------------- /botanim_bot/services/votings.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from datetime import datetime 4 | from typing import Iterable 5 | 6 | from botanim_bot import config 7 | from botanim_bot.db import execute, fetch_one 8 | from botanim_bot.services.books import ( 9 | Book, 10 | format_book_name, 11 | ) 12 | from botanim_bot.services.exceptions import NoActualVotingError, UserInNotVoteModeError 13 | from botanim_bot.services.users import insert_user 14 | from botanim_bot.services.vote_mode import ( 15 | is_user_in_vote_mode, 16 | remove_user_from_vote_mode, 17 | ) 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | @dataclass 23 | class Voting: 24 | id: int 25 | voting_start: str 26 | voting_finish: str 27 | 28 | def is_voting_has_passed(self) -> bool: 29 | now_date = datetime.now().date() 30 | finish_voting_date = datetime.strptime( 31 | self.voting_finish, config.DATE_FORMAT 32 | ).date() 33 | 34 | return finish_voting_date < now_date 35 | 36 | def __post_init__(self): 37 | """Set up voting_start and voting_finish to needed string format""" 38 | for field in ("voting_start", "voting_finish"): 39 | value = getattr(self, field) 40 | if value is None: 41 | continue 42 | try: 43 | value = datetime.strptime(value, "%Y-%m-%d").strftime( 44 | config.DATE_FORMAT 45 | ) 46 | except ValueError: 47 | continue 48 | setattr(self, field, value) 49 | 50 | 51 | @dataclass 52 | class Vote: 53 | first_book_name: str 54 | first_book_positional_number: str 55 | 56 | second_book_name: str 57 | second_book_positional_number: str 58 | 59 | third_book_name: str 60 | third_book_positional_number: str 61 | 62 | 63 | async def get_actual_voting() -> Voting | None: 64 | sql = """ 65 | SELECT id, voting_start, voting_finish 66 | FROM voting 67 | WHERE voting_start <= current_date 68 | AND voting_finish >= current_date 69 | ORDER BY voting_start 70 | LIMIT 1 71 | """ 72 | voting = await fetch_one(sql) 73 | if not voting: 74 | return None 75 | 76 | return _build_voting(voting) 77 | 78 | 79 | async def get_actual_or_last_voting() -> Voting | None: 80 | return await get_actual_voting() or await _get_last_voting() 81 | 82 | 83 | async def save_vote(telegram_user_id: int, books: Iterable[Book]) -> None: 84 | await insert_user(telegram_user_id) 85 | if not await is_user_in_vote_mode(telegram_user_id): 86 | raise UserInNotVoteModeError 87 | 88 | actual_voting = await get_actual_voting() 89 | if actual_voting is None: 90 | raise NoActualVotingError 91 | sql = """ 92 | INSERT OR REPLACE INTO vote 93 | (vote_id, user_id, first_book_id, second_book_id, third_book_id) 94 | VALUES (:vote_id, :user_id, :first_book, :second_book, :third_book) 95 | """ 96 | books = tuple(books) 97 | await execute("begin") 98 | await execute( 99 | sql, 100 | { 101 | "vote_id": actual_voting.id, 102 | "user_id": telegram_user_id, 103 | "first_book": books[0].id, 104 | "second_book": books[1].id, 105 | "third_book": books[2].id, 106 | }, 107 | autocommit=False, 108 | ) 109 | await remove_user_from_vote_mode(telegram_user_id) 110 | await execute("commit") 111 | 112 | 113 | async def get_user_vote(user_id: int, voting_id: int) -> Vote | None: 114 | sql = """ 115 | WITH pos_numbers AS ( 116 | SELECT 117 | ROW_NUMBER() OVER (ORDER BY c.ordering, b.ordering) AS rn, 118 | b.id AS book_id 119 | FROM book b LEFT JOIN book_category c ON c.id=b.category_id 120 | WHERE b.read_start IS NULL 121 | ) 122 | SELECT 123 | b1.name AS first_book_name, 124 | b1_pos_number.rn AS first_book_positional_number, 125 | b2.name AS second_book_name, 126 | b2_pos_number.rn AS second_book_positional_number, 127 | b3.name AS third_book_name, 128 | b3_pos_number.rn AS third_book_positional_number 129 | FROM vote v 130 | LEFT JOIN book b1 ON v.first_book_id = b1.id 131 | LEFT JOIN book b2 ON v.second_book_id = b2.id 132 | LEFT JOIN book b3 ON v.third_book_id = b3.id 133 | LEFT JOIN pos_numbers AS b1_pos_number ON b1_pos_number.book_id=b1.id 134 | LEFT JOIN pos_numbers AS b2_pos_number ON b2_pos_number.book_id=b2.id 135 | LEFT JOIN pos_numbers AS b3_pos_number ON b3_pos_number.book_id=b3.id 136 | WHERE v.user_id=:user_id 137 | AND v.vote_id=:voting_id 138 | """ 139 | vote = await fetch_one(sql, {"user_id": user_id, "voting_id": voting_id}) 140 | if not vote: 141 | return None 142 | user_vote = { 143 | field: format_book_name(vote[field]) 144 | for field in ( 145 | "first_book_name", 146 | "second_book_name", 147 | "third_book_name", 148 | ) 149 | } 150 | user_vote.update( 151 | { 152 | k: vote[k] 153 | for k in ( 154 | "first_book_positional_number", 155 | "second_book_positional_number", 156 | "third_book_positional_number", 157 | ) 158 | } 159 | ) 160 | return Vote(**user_vote) 161 | 162 | 163 | def _build_voting(voting_db_row: dict) -> Voting: 164 | return Voting( 165 | id=voting_db_row["id"], 166 | voting_start=voting_db_row["voting_start"], 167 | voting_finish=voting_db_row["voting_finish"], 168 | ) 169 | 170 | 171 | async def _get_last_voting() -> Voting | None: 172 | sql = """ 173 | SELECT id, voting_start, voting_finish 174 | FROM voting 175 | WHERE voting_finish < current_date 176 | ORDER BY voting_finish desc 177 | LIMIT 1 178 | """ 179 | last_voting = await fetch_one(sql) 180 | if not last_voting: 181 | return None 182 | 183 | return _build_voting(last_voting) 184 | -------------------------------------------------------------------------------- /botanim_bot/templates.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import jinja2 4 | 5 | from botanim_bot import config 6 | 7 | 8 | def render_template(template_name: str, data: dict | None = None) -> str: 9 | if data is None: 10 | data = {} 11 | template = _get_template_env().get_template(template_name) 12 | rendered = template.render(**data).replace("\n", " ") 13 | rendered = rendered.replace("
", "\n") 14 | rendered = re.sub(" +", " ", rendered).replace(" .", ".").replace(" ,", ",") 15 | rendered = "\n".join(line.strip() for line in rendered.split("\n")) 16 | rendered = rendered.replace("{FOURPACES}", " ") 17 | return rendered 18 | 19 | 20 | def _get_template_env(): 21 | if not getattr(_get_template_env, "template_env", None): 22 | template_loader = jinja2.FileSystemLoader(searchpath=config.TEMPLATES_DIR) 23 | env = jinja2.Environment( 24 | loader=template_loader, 25 | trim_blocks=True, 26 | lstrip_blocks=True, 27 | autoescape=True, 28 | ) 29 | 30 | _get_template_env.template_env = env 31 | 32 | return _get_template_env.template_env 33 | -------------------------------------------------------------------------------- /botanim_bot/templates/already.j2: -------------------------------------------------------------------------------- 1 | Прочитанные книги:
2 |
3 | {% for book in already_read_books %} 4 | {{ loop.index }}. {{ book.name | safe }}

5 | {{ book.read_comments }}. Читали с {{ book.read_start }} по {{ book.read_finish }}. 6 |

7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /botanim_bot/templates/already_for_member.j2: -------------------------------------------------------------------------------- 1 | Прочитанные книги для участника Ботаним:
2 |
3 | {% for book in already_read_books %} 4 | {{ loop.index }}. {{ book.name | safe }}

5 | {{ book.read_comments }}. Читали с {{ book.read_start }} по {{ book.read_finish }}. 6 |

7 | {% endfor %} 8 | 9 | -------------------------------------------------------------------------------- /botanim_bot/templates/category_with_books.j2: -------------------------------------------------------------------------------- 1 | {{ category.name }}
2 |
3 | {% for book in category.books %} 4 | {% if start_index is none: %}◦{% else %}{{ start_index + loop.index }}.{% endif %} {{ book.name | safe }} 5 | {% if book.is_started() %} 6 | — 7 | {% if book.is_finished() %} 8 | прочитана 9 | {% else %} 10 | читаем сейчас 11 | {% endif %} 12 | . {{ book.read_comments }} 13 | 14 | {% elif book.is_planned(): %} 15 | — 16 | будем читать с {{ book.read_start }} по {{ book.read_finish }}. 17 | {{ book.read_comments }} 18 | 19 | {% endif %} 20 |
21 | {% endfor %} 22 | -------------------------------------------------------------------------------- /botanim_bot/templates/help.j2: -------------------------------------------------------------------------------- 1 | Наш книжный клуб работает по ежемесячной подписке, которая стоит 1500 руб/мес. 2 | Подписка работает через бот @donate, для того, чтобы подписаться, перейди по этой 3 | ссылке: https://t.me/+IyGKU9EIGP5jMTky
4 |
5 | Ежемесячно мы выбираем здесь голосованием книги и читаем их. По каждой книге 6 | я (Алексей, автор Диджитализируй) делаю видео и текстовые комментарии, как правило 7 | по каждой главе, эти материалы попадают в группу в Telegram.
8 |
9 | Также мы обсуждаем возникающие по ходу чтения вопросы, сложности и отмечаем наиболее 10 | полезные части книги, имеющие наибольшее значение для нас.
11 |
12 | Присоединяйся! 13 |
14 | Если не получается подписаться или есть иные вопросы — пиши на sterx@rl6.ru. 15 | -------------------------------------------------------------------------------- /botanim_bot/templates/now.j2: -------------------------------------------------------------------------------- 1 | Сейчас мы читаем:
2 |
3 | {% for book in now_read_books %} 4 | {% if now_read_books|length > 1 %}{{ loop.index }}. {% endif %} 5 | {{book.name | safe}}
6 | Читаем с {{ book.read_start }} по {{ book.read_finish }}.
7 | {{ book.read_comments }}
8 | {% endfor %} 9 |
10 | {% if next_book %} 11 | Следующая книга:
12 |
13 | {{ next_book.name | safe }}
14 | Читаем с {{ next_book.read_start }} по {{ next_book.read_finish }}.
15 | {{ next_book.read_comments }} 16 | {% endif %} 17 | 18 | -------------------------------------------------------------------------------- /botanim_bot/templates/now_for_member.j2: -------------------------------------------------------------------------------- 1 | Сейчас мы читаем:
2 |
3 | {% for book in now_read_books %} 4 | {% if now_read_books|length > 1 %}{{ loop.index }}. {% endif %} 5 | {{book.name | safe}}
6 | Читаем с {{ book.read_start }} по {{ book.read_finish }}.
7 | {{ book.read_comments }}
8 | {% endfor %} 9 |
10 | {% if next_book %} 11 | Следующая книга:
12 |
13 | {{ next_book.name | safe }}
14 | Читаем с {{ next_book.read_start }} по {{ next_book.read_finish }}.
15 | {{ next_book.read_comments }} 16 | {% endif %} 17 | 18 | 19 | -------------------------------------------------------------------------------- /botanim_bot/templates/start.j2: -------------------------------------------------------------------------------- 1 | Привееет!
2 |
3 | Это Telegram-бот книжного клуба Ботаним.
4 |
5 | Здесь можно посмотреть список книг, которые мы читали и планируем читать, а также 6 | проголосовать за следующую книгу.
7 |
8 | Присоединяйся к клубу:
9 | - https://botanim.to.digital — информация о клубе
10 | - https://t.me/+IyGKU9EIGP5jMTky — вход в клуб
11 | - /help — помощь
12 |
13 | Команды бота:
14 |
15 | /start — приветственное сообщение
16 | /help — справка
17 | /allbooks — все книги, которые есть в нашем списке
18 | /already — прочитанные книги
19 | /now — книга, которую сейчас читаем
20 | /vote — проголосовать за следующую книгу
21 | /voteresults — текущие результаты текущего голосования 22 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_cant_vote.j2: -------------------------------------------------------------------------------- 1 | Упс, голосование доступно только активным участникам Ботаним!
2 |
3 | Подключайтесь: https://t.me/+IyGKU9EIGP5jMTky 4 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_description.j2: -------------------------------------------------------------------------------- 1 | Выше я отправил тебе список книг по всем категориям, которые ты можешь 2 | листать.
3 |
4 | Тебе нужно выбрать три книги из всего списка.
5 |
6 | Пришли в ответном сообщении номера книг, которые ты хочешь прочитать. Номера 7 | можно разделить пробелами, запятыми или переносами строк.
8 |
9 | Обрати внимание, что порядок важен — на первом месте книга, которую ты максимально 10 | хочешь прочесть сейчас.
11 |
12 | Например:
13 |
14 | 53, 8, 102
15 |
16 | Победители голосования будут выбраны методом Шульце.
17 |
18 | Чтобы выйти из режима голосования, нажми /cancel 19 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_incorrect_books.j2: -------------------------------------------------------------------------------- 1 | Переданы некорректные номера книг, пожалуйста, проверь их.
2 |
3 | Нужно передать номера книг из списка выше. 4 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_incorrect_input.j2: -------------------------------------------------------------------------------- 1 | Не смог прочесть твоё сообщение.
2 |
3 | Напиши три разных номера книги в одном сообщении, наример, так:
4 |
5 | 53, 8, 102
6 |
7 | Чтобы выйти из режима голосования, нажми /cancel 8 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_no_actual_voting.j2: -------------------------------------------------------------------------------- 1 | Сейчас нет активного голосования.
2 |
3 | Голосование обычно запускается на ограниченное время на несколько дней.
4 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_results.j2: -------------------------------------------------------------------------------- 1 | ТОП книг 2 | {% if leaders.voting.is_voting_has_passed() %}прошедшего{% else %}текущего{% endif %} 3 | голосования
4 |
5 | {% if leaders.leaders|length == 0 %} 6 | Пока никто не проголосовал, ты можешь стать первым, вжух!
7 | {% else %} 8 | {% for books_in_rank in leaders.leaders %} 9 | {{ loop.index }}. 10 | {% if books_in_rank.books|length > 1 %} 11 | Несколько книг занимают это место:
12 | {% endif %} 13 | {% for book in books_in_rank.books %} 14 | {% if books_in_rank.books|length > 1 %}{FOURPACES}{% endif %}{{ book.book_name | safe }}. Номер книги: {{ book.positional_number }} 15 |
16 | {% endfor %} 17 | {% endfor %} 18 | {% endif %} 19 |
20 | Даты голосования: с {{ leaders.voting.voting_start }} по 21 | {{ leaders.voting.voting_finish }}
22 | Голосов: {{ leaders.votes_count }}

23 |
24 | {% if not your_vote %} 25 | Ты ещё не проголосовал. 26 | {% else %} 27 | Твой выбор
28 |
29 | 1. {{ your_vote.first_book_name | safe }}. Номер книги: {{ your_vote.first_book_positional_number }}
30 | 2. {{ your_vote.second_book_name | safe }}. Номер книги: {{ your_vote.second_book_positional_number }}
31 | 3. {{ your_vote.third_book_name | safe }}. Номер книги: {{ your_vote.third_book_positional_number }}
32 | {% endif %} 33 |
34 | {% if not leaders.voting.is_voting_has_passed() %} 35 | Переголосовать: /vote, обновить результаты голосования: /voteresults 36 | {% endif %} 37 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_results_no_data.j2: -------------------------------------------------------------------------------- 1 | Пока нет данных о голосовании. Но вероятно скоро они появятся! 2 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_success.j2: -------------------------------------------------------------------------------- 1 | Ура, ты выбрал {{ books_count }}:
2 |
3 | {% for book in selected_books %} 4 | {{ loop.index }}. {{ book.name | safe }}
5 | {% endfor %} 6 |
7 | Ты можешь переголосовать до тех пор, пока голосование активно. Для этого просто 8 | проголосуй повторно с командой
9 | /vote
10 |
11 | Посмотреть текущие результаты: /voteresults 12 | -------------------------------------------------------------------------------- /botanim_bot/templates/vote_user_not_in_right_mode.j2: -------------------------------------------------------------------------------- 1 | Мой искусственный интеллект пока ещё не слишком интеллект и слишком искусственный. 2 | Моя твоя не понимать в общем. Давай начнём сначала?
3 |
4 | Нажми /start 5 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "aiosqlite" 5 | version = "0.18.0" 6 | description = "asyncio bridge to the standard sqlite3 module" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "aiosqlite-0.18.0-py3-none-any.whl", hash = "sha256:c3511b841e3a2c5614900ba1d179f366826857586f78abd75e7cbeb88e75a557"}, 12 | {file = "aiosqlite-0.18.0.tar.gz", hash = "sha256:faa843ef5fb08bafe9a9b3859012d3d9d6f77ce3637899de20606b7fc39aa213"}, 13 | ] 14 | 15 | [[package]] 16 | name = "anyio" 17 | version = "3.6.2" 18 | description = "High level compatibility layer for multiple asynchronous event loop implementations" 19 | category = "main" 20 | optional = false 21 | python-versions = ">=3.6.2" 22 | files = [ 23 | {file = "anyio-3.6.2-py3-none-any.whl", hash = "sha256:fbbe32bd270d2a2ef3ed1c5d45041250284e31fc0a4df4a5a6071842051a51e3"}, 24 | {file = "anyio-3.6.2.tar.gz", hash = "sha256:25ea0d673ae30af41a0c442f81cf3b38c7e79fdc7b60335a4c14e05eb0947421"}, 25 | ] 26 | 27 | [package.dependencies] 28 | idna = ">=2.8" 29 | sniffio = ">=1.1" 30 | 31 | [package.extras] 32 | doc = ["packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] 33 | test = ["contextlib2", "coverage[toml] (>=4.5)", "hypothesis (>=4.0)", "mock (>=4)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (<0.15)", "uvloop (>=0.15)"] 34 | trio = ["trio (>=0.16,<0.22)"] 35 | 36 | [[package]] 37 | name = "black" 38 | version = "23.1.0" 39 | description = "The uncompromising code formatter." 40 | category = "dev" 41 | optional = false 42 | python-versions = ">=3.7" 43 | files = [ 44 | {file = "black-23.1.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:b6a92a41ee34b883b359998f0c8e6eb8e99803aa8bf3123bf2b2e6fec505a221"}, 45 | {file = "black-23.1.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:57c18c5165c1dbe291d5306e53fb3988122890e57bd9b3dcb75f967f13411a26"}, 46 | {file = "black-23.1.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:9880d7d419bb7e709b37e28deb5e68a49227713b623c72b2b931028ea65f619b"}, 47 | {file = "black-23.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6663f91b6feca5d06f2ccd49a10f254f9298cc1f7f49c46e498a0771b507104"}, 48 | {file = "black-23.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:9afd3f493666a0cd8f8df9a0200c6359ac53940cbde049dcb1a7eb6ee2dd7074"}, 49 | {file = "black-23.1.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:bfffba28dc52a58f04492181392ee380e95262af14ee01d4bc7bb1b1c6ca8d27"}, 50 | {file = "black-23.1.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:c1c476bc7b7d021321e7d93dc2cbd78ce103b84d5a4cf97ed535fbc0d6660648"}, 51 | {file = "black-23.1.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:382998821f58e5c8238d3166c492139573325287820963d2f7de4d518bd76958"}, 52 | {file = "black-23.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bf649fda611c8550ca9d7592b69f0637218c2369b7744694c5e4902873b2f3a"}, 53 | {file = "black-23.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:121ca7f10b4a01fd99951234abdbd97728e1240be89fde18480ffac16503d481"}, 54 | {file = "black-23.1.0-cp37-cp37m-macosx_10_16_x86_64.whl", hash = "sha256:a8471939da5e824b891b25751955be52ee7f8a30a916d570a5ba8e0f2eb2ecad"}, 55 | {file = "black-23.1.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8178318cb74f98bc571eef19068f6ab5613b3e59d4f47771582f04e175570ed8"}, 56 | {file = "black-23.1.0-cp37-cp37m-win_amd64.whl", hash = "sha256:a436e7881d33acaf2536c46a454bb964a50eff59b21b51c6ccf5a40601fbef24"}, 57 | {file = "black-23.1.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:a59db0a2094d2259c554676403fa2fac3473ccf1354c1c63eccf7ae65aac8ab6"}, 58 | {file = "black-23.1.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:0052dba51dec07ed029ed61b18183942043e00008ec65d5028814afaab9a22fd"}, 59 | {file = "black-23.1.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:49f7b39e30f326a34b5c9a4213213a6b221d7ae9d58ec70df1c4a307cf2a1580"}, 60 | {file = "black-23.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:162e37d49e93bd6eb6f1afc3e17a3d23a823042530c37c3c42eeeaf026f38468"}, 61 | {file = "black-23.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:8b70eb40a78dfac24842458476135f9b99ab952dd3f2dab738c1881a9b38b753"}, 62 | {file = "black-23.1.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:a29650759a6a0944e7cca036674655c2f0f63806ddecc45ed40b7b8aa314b651"}, 63 | {file = "black-23.1.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:bb460c8561c8c1bec7824ecbc3ce085eb50005883a6203dcfb0122e95797ee06"}, 64 | {file = "black-23.1.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c91dfc2c2a4e50df0026f88d2215e166616e0c80e86004d0003ece0488db2739"}, 65 | {file = "black-23.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a951cc83ab535d248c89f300eccbd625e80ab880fbcfb5ac8afb5f01a258ac9"}, 66 | {file = "black-23.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:0680d4380db3719ebcfb2613f34e86c8e6d15ffeabcf8ec59355c5e7b85bb555"}, 67 | {file = "black-23.1.0-py3-none-any.whl", hash = "sha256:7a0f701d314cfa0896b9001df70a530eb2472babb76086344e688829efd97d32"}, 68 | {file = "black-23.1.0.tar.gz", hash = "sha256:b0bd97bea8903f5a2ba7219257a44e3f1f9d00073d6cc1add68f0beec69692ac"}, 69 | ] 70 | 71 | [package.dependencies] 72 | click = ">=8.0.0" 73 | mypy-extensions = ">=0.4.3" 74 | packaging = ">=22.0" 75 | pathspec = ">=0.9.0" 76 | platformdirs = ">=2" 77 | 78 | [package.extras] 79 | colorama = ["colorama (>=0.4.3)"] 80 | d = ["aiohttp (>=3.7.4)"] 81 | jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] 82 | uvloop = ["uvloop (>=0.15.2)"] 83 | 84 | [[package]] 85 | name = "certifi" 86 | version = "2022.12.7" 87 | description = "Python package for providing Mozilla's CA Bundle." 88 | category = "main" 89 | optional = false 90 | python-versions = ">=3.6" 91 | files = [ 92 | {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, 93 | {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, 94 | ] 95 | 96 | [[package]] 97 | name = "click" 98 | version = "8.1.3" 99 | description = "Composable command line interface toolkit" 100 | category = "dev" 101 | optional = false 102 | python-versions = ">=3.7" 103 | files = [ 104 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 105 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 106 | ] 107 | 108 | [package.dependencies] 109 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 110 | 111 | [[package]] 112 | name = "colorama" 113 | version = "0.4.6" 114 | description = "Cross-platform colored terminal text." 115 | category = "dev" 116 | optional = false 117 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 118 | files = [ 119 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 120 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 121 | ] 122 | 123 | [[package]] 124 | name = "h11" 125 | version = "0.14.0" 126 | description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" 127 | category = "main" 128 | optional = false 129 | python-versions = ">=3.7" 130 | files = [ 131 | {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, 132 | {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, 133 | ] 134 | 135 | [[package]] 136 | name = "httpcore" 137 | version = "0.16.3" 138 | description = "A minimal low-level HTTP client." 139 | category = "main" 140 | optional = false 141 | python-versions = ">=3.7" 142 | files = [ 143 | {file = "httpcore-0.16.3-py3-none-any.whl", hash = "sha256:da1fb708784a938aa084bde4feb8317056c55037247c787bd7e19eb2c2949dc0"}, 144 | {file = "httpcore-0.16.3.tar.gz", hash = "sha256:c5d6f04e2fc530f39e0c077e6a30caa53f1451096120f1f38b954afd0b17c0cb"}, 145 | ] 146 | 147 | [package.dependencies] 148 | anyio = ">=3.0,<5.0" 149 | certifi = "*" 150 | h11 = ">=0.13,<0.15" 151 | sniffio = ">=1.0.0,<2.0.0" 152 | 153 | [package.extras] 154 | http2 = ["h2 (>=3,<5)"] 155 | socks = ["socksio (>=1.0.0,<2.0.0)"] 156 | 157 | [[package]] 158 | name = "httpx" 159 | version = "0.23.3" 160 | description = "The next generation HTTP client." 161 | category = "main" 162 | optional = false 163 | python-versions = ">=3.7" 164 | files = [ 165 | {file = "httpx-0.23.3-py3-none-any.whl", hash = "sha256:a211fcce9b1254ea24f0cd6af9869b3d29aba40154e947d2a07bb499b3e310d6"}, 166 | {file = "httpx-0.23.3.tar.gz", hash = "sha256:9818458eb565bb54898ccb9b8b251a28785dd4a55afbc23d0eb410754fe7d0f9"}, 167 | ] 168 | 169 | [package.dependencies] 170 | certifi = "*" 171 | httpcore = ">=0.15.0,<0.17.0" 172 | rfc3986 = {version = ">=1.3,<2", extras = ["idna2008"]} 173 | sniffio = "*" 174 | 175 | [package.extras] 176 | brotli = ["brotli", "brotlicffi"] 177 | cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"] 178 | http2 = ["h2 (>=3,<5)"] 179 | socks = ["socksio (>=1.0.0,<2.0.0)"] 180 | 181 | [[package]] 182 | name = "idna" 183 | version = "3.4" 184 | description = "Internationalized Domain Names in Applications (IDNA)" 185 | category = "main" 186 | optional = false 187 | python-versions = ">=3.5" 188 | files = [ 189 | {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, 190 | {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, 191 | ] 192 | 193 | [[package]] 194 | name = "jinja2" 195 | version = "3.1.2" 196 | description = "A very fast and expressive template engine." 197 | category = "main" 198 | optional = false 199 | python-versions = ">=3.7" 200 | files = [ 201 | {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, 202 | {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, 203 | ] 204 | 205 | [package.dependencies] 206 | MarkupSafe = ">=2.0" 207 | 208 | [package.extras] 209 | i18n = ["Babel (>=2.7)"] 210 | 211 | [[package]] 212 | name = "markupsafe" 213 | version = "2.1.2" 214 | description = "Safely add untrusted strings to HTML/XML markup." 215 | category = "main" 216 | optional = false 217 | python-versions = ">=3.7" 218 | files = [ 219 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7"}, 220 | {file = "MarkupSafe-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036"}, 221 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1"}, 222 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323"}, 223 | {file = "MarkupSafe-2.1.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601"}, 224 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1"}, 225 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff"}, 226 | {file = "MarkupSafe-2.1.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65"}, 227 | {file = "MarkupSafe-2.1.2-cp310-cp310-win32.whl", hash = "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603"}, 228 | {file = "MarkupSafe-2.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156"}, 229 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013"}, 230 | {file = "MarkupSafe-2.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a"}, 231 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd"}, 232 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6"}, 233 | {file = "MarkupSafe-2.1.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d"}, 234 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1"}, 235 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc"}, 236 | {file = "MarkupSafe-2.1.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0"}, 237 | {file = "MarkupSafe-2.1.2-cp311-cp311-win32.whl", hash = "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625"}, 238 | {file = "MarkupSafe-2.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3"}, 239 | {file = "MarkupSafe-2.1.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a"}, 240 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a"}, 241 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a"}, 242 | {file = "MarkupSafe-2.1.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2"}, 243 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619"}, 244 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513"}, 245 | {file = "MarkupSafe-2.1.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460"}, 246 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win32.whl", hash = "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859"}, 247 | {file = "MarkupSafe-2.1.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666"}, 248 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed"}, 249 | {file = "MarkupSafe-2.1.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094"}, 250 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54"}, 251 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419"}, 252 | {file = "MarkupSafe-2.1.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa"}, 253 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58"}, 254 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba"}, 255 | {file = "MarkupSafe-2.1.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03"}, 256 | {file = "MarkupSafe-2.1.2-cp38-cp38-win32.whl", hash = "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2"}, 257 | {file = "MarkupSafe-2.1.2-cp38-cp38-win_amd64.whl", hash = "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147"}, 258 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f"}, 259 | {file = "MarkupSafe-2.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd"}, 260 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f"}, 261 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4"}, 262 | {file = "MarkupSafe-2.1.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2"}, 263 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65"}, 264 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c"}, 265 | {file = "MarkupSafe-2.1.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3"}, 266 | {file = "MarkupSafe-2.1.2-cp39-cp39-win32.whl", hash = "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7"}, 267 | {file = "MarkupSafe-2.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed"}, 268 | {file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"}, 269 | ] 270 | 271 | [[package]] 272 | name = "mypy-extensions" 273 | version = "1.0.0" 274 | description = "Type system extensions for programs checked with the mypy type checker." 275 | category = "dev" 276 | optional = false 277 | python-versions = ">=3.5" 278 | files = [ 279 | {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, 280 | {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, 281 | ] 282 | 283 | [[package]] 284 | name = "nodeenv" 285 | version = "1.7.0" 286 | description = "Node.js virtual environment builder" 287 | category = "dev" 288 | optional = false 289 | python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" 290 | files = [ 291 | {file = "nodeenv-1.7.0-py2.py3-none-any.whl", hash = "sha256:27083a7b96a25f2f5e1d8cb4b6317ee8aeda3bdd121394e5ac54e498028a042e"}, 292 | {file = "nodeenv-1.7.0.tar.gz", hash = "sha256:e0e7f7dfb85fc5394c6fe1e8fa98131a2473e04311a45afb6508f7cf1836fa2b"}, 293 | ] 294 | 295 | [package.dependencies] 296 | setuptools = "*" 297 | 298 | [[package]] 299 | name = "packaging" 300 | version = "23.0" 301 | description = "Core utilities for Python packages" 302 | category = "dev" 303 | optional = false 304 | python-versions = ">=3.7" 305 | files = [ 306 | {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, 307 | {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, 308 | ] 309 | 310 | [[package]] 311 | name = "pathspec" 312 | version = "0.11.0" 313 | description = "Utility library for gitignore style pattern matching of file paths." 314 | category = "dev" 315 | optional = false 316 | python-versions = ">=3.7" 317 | files = [ 318 | {file = "pathspec-0.11.0-py3-none-any.whl", hash = "sha256:3a66eb970cbac598f9e5ccb5b2cf58930cd8e3ed86d393d541eaf2d8b1705229"}, 319 | {file = "pathspec-0.11.0.tar.gz", hash = "sha256:64d338d4e0914e91c1792321e6907b5a593f1ab1851de7fc269557a21b30ebbc"}, 320 | ] 321 | 322 | [[package]] 323 | name = "platformdirs" 324 | version = "3.0.0" 325 | description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." 326 | category = "dev" 327 | optional = false 328 | python-versions = ">=3.7" 329 | files = [ 330 | {file = "platformdirs-3.0.0-py3-none-any.whl", hash = "sha256:b1d5eb14f221506f50d6604a561f4c5786d9e80355219694a1b244bcd96f4567"}, 331 | {file = "platformdirs-3.0.0.tar.gz", hash = "sha256:8a1228abb1ef82d788f74139988b137e78692984ec7b08eaa6c65f1723af28f9"}, 332 | ] 333 | 334 | [package.extras] 335 | docs = ["furo (>=2022.12.7)", "proselint (>=0.13)", "sphinx (>=6.1.3)", "sphinx-autodoc-typehints (>=1.22,!=1.23.4)"] 336 | test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytest-cov (>=4)", "pytest-mock (>=3.10)"] 337 | 338 | [[package]] 339 | name = "pyright" 340 | version = "1.1.291" 341 | description = "Command line wrapper for pyright" 342 | category = "dev" 343 | optional = false 344 | python-versions = ">=3.7" 345 | files = [ 346 | {file = "pyright-1.1.291-py3-none-any.whl", hash = "sha256:2dbd133ee400e81ff319d0701bc47545a7ab1cd7dcfcf46c7de37d9b0fbbb76f"}, 347 | {file = "pyright-1.1.291.tar.gz", hash = "sha256:3946f43f1a99f8e438eee1738ad0649d8bd7ac36dded25849245993ab02671bd"}, 348 | ] 349 | 350 | [package.dependencies] 351 | nodeenv = ">=1.6.0" 352 | 353 | [package.extras] 354 | all = ["twine (>=3.4.1)"] 355 | dev = ["twine (>=3.4.1)"] 356 | 357 | [[package]] 358 | name = "python-dotenv" 359 | version = "0.21.1" 360 | description = "Read key-value pairs from a .env file and set them as environment variables" 361 | category = "main" 362 | optional = false 363 | python-versions = ">=3.7" 364 | files = [ 365 | {file = "python-dotenv-0.21.1.tar.gz", hash = "sha256:1c93de8f636cde3ce377292818d0e440b6e45a82f215c3744979151fa8151c49"}, 366 | {file = "python_dotenv-0.21.1-py3-none-any.whl", hash = "sha256:41e12e0318bebc859fcc4d97d4db8d20ad21721a6aa5047dd59f090391cb549a"}, 367 | ] 368 | 369 | [package.extras] 370 | cli = ["click (>=5.0)"] 371 | 372 | [[package]] 373 | name = "python-telegram-bot" 374 | version = "20.0" 375 | description = "We have made you a wrapper you can't refuse" 376 | category = "main" 377 | optional = false 378 | python-versions = ">=3.7" 379 | files = [ 380 | {file = "python-telegram-bot-20.0.tar.gz", hash = "sha256:9ff3d7b03d0e621df6c903622338e30d761e121c27179e13f62ba2216b7c6d32"}, 381 | {file = "python_telegram_bot-20.0-py3-none-any.whl", hash = "sha256:2f7f0a0eee6517ffb3e9732b622b5c97c107517f35dc2a97273c74cb5ed39e9d"}, 382 | ] 383 | 384 | [package.dependencies] 385 | httpx = ">=0.23.1,<0.24.0" 386 | 387 | [package.extras] 388 | all = ["APScheduler (>=3.9.1,<3.10.0)", "aiolimiter (>=1.0.0,<1.1.0)", "cachetools (>=5.2.0,<5.3.0)", "cryptography (>=3.0,!=3.4,!=3.4.1,!=3.4.2,!=3.4.3)", "httpx[socks]", "pytz (>=2018.6)", "tornado (>=6.2,<7.0)"] 389 | callback-data = ["cachetools (>=5.2.0,<5.3.0)"] 390 | ext = ["APScheduler (>=3.9.1,<3.10.0)", "aiolimiter (>=1.0.0,<1.1.0)", "cachetools (>=5.2.0,<5.3.0)", "pytz (>=2018.6)", "tornado (>=6.2,<7.0)"] 391 | job-queue = ["APScheduler (>=3.9.1,<3.10.0)", "pytz (>=2018.6)"] 392 | passport = ["cryptography (>=3.0,!=3.4,!=3.4.1,!=3.4.2,!=3.4.3)"] 393 | rate-limiter = ["aiolimiter (>=1.0.0,<1.1.0)"] 394 | socks = ["httpx[socks]"] 395 | webhooks = ["tornado (>=6.2,<7.0)"] 396 | 397 | [[package]] 398 | name = "rfc3986" 399 | version = "1.5.0" 400 | description = "Validating URI References per RFC 3986" 401 | category = "main" 402 | optional = false 403 | python-versions = "*" 404 | files = [ 405 | {file = "rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97"}, 406 | {file = "rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835"}, 407 | ] 408 | 409 | [package.dependencies] 410 | idna = {version = "*", optional = true, markers = "extra == \"idna2008\""} 411 | 412 | [package.extras] 413 | idna2008 = ["idna"] 414 | 415 | [[package]] 416 | name = "ruff" 417 | version = "0.0.240" 418 | description = "An extremely fast Python linter, written in Rust." 419 | category = "dev" 420 | optional = false 421 | python-versions = ">=3.7" 422 | files = [ 423 | {file = "ruff-0.0.240-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:222dd5a5f7cf2f155d7bb77ac484b9afd6f8aaecd963a91c8dbb93355ef42fd2"}, 424 | {file = "ruff-0.0.240-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:2c956a037671b5ab81546346f3e7f0b3f0e13d0b2e5a3e88c1b2227a1e9aae82"}, 425 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b43c73fc165f8c7de7c095208d05653744aee6fb0a71680449c2ff1cf59183ea"}, 426 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f58f1122001150d70909885ccf43d869237be814d4cfc74bb60b3883635e440a"}, 427 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b427050336b8967755e305f506e84e550591fa47766b5b0cb0c8bcb5c8ca9e7"}, 428 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0fe8cc47c4c3423548a074e163388f943a14b1e349be88e5dc4cd43df81b6344"}, 429 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f40f07d030e7a8cbe365a62fe8543e146b9bcd2a31f5625c2beaccad0d1b8c1"}, 430 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c222ad12e4bf795e3cec64d56178af1bfbc5d97929a0abf685564937e52c9862"}, 431 | {file = "ruff-0.0.240-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a26eb3cd68527bcae2543027a0a674d37d03f239f6f025049149115c9775438d"}, 432 | {file = "ruff-0.0.240-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4591c9104b6898cbd0df57f6b6f8e2907b08fa85ff5196750f0a7b370ae9f78e"}, 433 | {file = "ruff-0.0.240-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7fed973319ca0a8c2e5c80732217b9b1ec069305839f480907469791e596b150"}, 434 | {file = "ruff-0.0.240-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4ce049d1fedb1b785fef29403d26e6109b77287b51afd10b74edc986f609c4af"}, 435 | {file = "ruff-0.0.240-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5127cfaec1f78bd7104174eeacee85dea64796905812b448efd60f504cfa5eec"}, 436 | {file = "ruff-0.0.240-py3-none-win32.whl", hash = "sha256:071e01a980ffd638a5ce7960ce662fa9b434962f78e7c575478c64e5f147aac8"}, 437 | {file = "ruff-0.0.240-py3-none-win_amd64.whl", hash = "sha256:d0b1ac5d1d882db25ca4b7dff8aa813ecc7912bdde4ad8f59f2d922b1996cbc7"}, 438 | {file = "ruff-0.0.240.tar.gz", hash = "sha256:0f1a0b04ce6f3d59894c64f3c3a5a0a35ff4803b8dc51e962d7de42fdb0f5eb1"}, 439 | ] 440 | 441 | [[package]] 442 | name = "schulze" 443 | version = "0.1" 444 | description = "Implementation of the Schulze method for ranking candidates" 445 | category = "main" 446 | optional = false 447 | python-versions = "*" 448 | files = [ 449 | {file = "schulze-0.1.tar.gz", hash = "sha256:d7ee5b8dd076e376a312a9bd80993c8c9c2b25e99085e6ee6c928c1f260d68f3"}, 450 | ] 451 | 452 | [[package]] 453 | name = "setuptools" 454 | version = "67.3.2" 455 | description = "Easily download, build, install, upgrade, and uninstall Python packages" 456 | category = "dev" 457 | optional = false 458 | python-versions = ">=3.7" 459 | files = [ 460 | {file = "setuptools-67.3.2-py3-none-any.whl", hash = "sha256:bb6d8e508de562768f2027902929f8523932fcd1fb784e6d573d2cafac995a48"}, 461 | {file = "setuptools-67.3.2.tar.gz", hash = "sha256:95f00380ef2ffa41d9bba85d95b27689d923c93dfbafed4aecd7cf988a25e012"}, 462 | ] 463 | 464 | [package.extras] 465 | docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] 466 | testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8 (<5)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pip-run (>=8.8)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] 467 | testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] 468 | 469 | [[package]] 470 | name = "sniffio" 471 | version = "1.3.0" 472 | description = "Sniff out which async library your code is running under" 473 | category = "main" 474 | optional = false 475 | python-versions = ">=3.7" 476 | files = [ 477 | {file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, 478 | {file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, 479 | ] 480 | 481 | [metadata] 482 | lock-version = "2.0" 483 | python-versions = "^3.11" 484 | content-hash = "6ec21280c5bceae3a9e45eb6763731535898b01583b2d0d80db30e8ca03852ac" 485 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "botanim-bot" 3 | version = "0.1.0" 4 | description = "" 5 | authors = ["Alexey Goloburdin "] 6 | readme = "README.md" 7 | packages = [{include = "botanim_bot"}] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | 12 | python-telegram-bot = "==20.0" 13 | aiosqlite = "==0.18.0" 14 | python-dotenv = "==0.21.1" 15 | schulze = "==0.1" 16 | jinja2 = "==3.1.2" 17 | 18 | [tool.poetry.group.dev.dependencies] 19 | ruff = "==0.0.240" 20 | pyright = "==1.1.291" 21 | black = "==23.1.0" 22 | 23 | [build-system] 24 | requires = ["poetry-core"] 25 | build-backend = "poetry.core.masonry.api" 26 | 27 | [tool.ruff] 28 | select = ["F", "E", "W", "C90", 29 | "I", "N", "S", "B", "A", 30 | "ISC", "T20", "Q", "PTH"] 31 | 32 | ignore = ["A003"] 33 | 34 | [tool.pyright] 35 | reportUnnecessaryTypeIgnoreComment="warning" 36 | --------------------------------------------------------------------------------