├── .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 | [](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 |
--------------------------------------------------------------------------------