tg-tree-wizard 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tg_tree_wizard-1.0.0/LICENSE +21 -0
- tg_tree_wizard-1.0.0/PKG-INFO +188 -0
- tg_tree_wizard-1.0.0/README.md +140 -0
- tg_tree_wizard-1.0.0/pyproject.toml +43 -0
- tg_tree_wizard-1.0.0/setup.cfg +4 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard/__init__.py +15 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard/aiogram_adapter.py +182 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard/core.py +186 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard/py.typed +0 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard.egg-info/PKG-INFO +188 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard.egg-info/SOURCES.txt +14 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard.egg-info/dependency_links.txt +1 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard.egg-info/requires.txt +4 -0
- tg_tree_wizard-1.0.0/src/tg_tree_wizard.egg-info/top_level.txt +1 -0
- tg_tree_wizard-1.0.0/tests/test_adapter.py +72 -0
- tg_tree_wizard-1.0.0/tests/test_core.py +147 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Артем Самарин
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-tree-wizard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3
|
|
5
|
+
Author: Артем Самарин
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Артем Самарин
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/ваш-ник/tg-tree-wizard
|
|
29
|
+
Project-URL: Repository, https://github.com/ваш-ник/tg-tree-wizard
|
|
30
|
+
Project-URL: Issues, https://github.com/ваш-ник/tg-tree-wizard/issues
|
|
31
|
+
Keywords: aiogram,telegram,telegram-bot,inline-keyboard,fsm,wizard
|
|
32
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Framework :: AsyncIO
|
|
40
|
+
Classifier: Topic :: Communications :: Chat
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: aiogram>=3.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# tg-tree-wizard
|
|
50
|
+
|
|
51
|
+
Лёгкий движок древовидных inline-опросников для aiogram 3.
|
|
52
|
+
Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
|
|
53
|
+
каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
|
|
54
|
+
независимо от глубины дерева.
|
|
55
|
+
|
|
56
|
+
## Сколько кода экономит
|
|
57
|
+
|
|
58
|
+
Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
|
|
59
|
+
возраст → уровень владения), с 2-7 вариантами ответа на каждом —
|
|
60
|
+
навигационная часть кода, без учёта бизнес-логики финального шага
|
|
61
|
+
(сохранение заявки и т.п. — она одинакова в обоих случаях):
|
|
62
|
+
|
|
63
|
+
| | Строк кода |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
|
|
66
|
+
| `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
|
|
67
|
+
|
|
68
|
+
Разница держится линейно — не растёт и не убывает в зависимости от того,
|
|
69
|
+
сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
|
|
70
|
+
~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
|
|
71
|
+
превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
|
|
72
|
+
внутри самой библиотеки.
|
|
73
|
+
|
|
74
|
+
## Структура пакета
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
tg_tree_wizard/
|
|
78
|
+
├── pyproject.toml # метаданные пакета, как его ставить
|
|
79
|
+
├── src/tg_tree_wizard/
|
|
80
|
+
│ ├── __init__.py # публичный API (что видно снаружи через import)
|
|
81
|
+
│ ├── core.py # чистая логика дерева, БЕЗ aiogram
|
|
82
|
+
│ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
|
|
83
|
+
├── tests/
|
|
84
|
+
│ └── test_core.py # тесты ядра, без сети и без Telegram
|
|
85
|
+
└── examples/
|
|
86
|
+
├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
|
|
87
|
+
└── language_school_bot.py # реальный бот — запускается с настоящим токеном
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
|
|
91
|
+
делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
|
|
92
|
+
его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
|
|
93
|
+
доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
|
|
94
|
+
только рендерит клавиатуры и дёргает ядро.
|
|
95
|
+
|
|
96
|
+
## Установка
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cd tg_tree_wizard
|
|
100
|
+
pip install -e . # editable-режим: правите код — сразу видно в проекте
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
|
|
104
|
+
копируется — удобно, пока сами дорабатываете библиотеку.
|
|
105
|
+
|
|
106
|
+
## Тесты ядра (без Telegram)
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install pytest
|
|
110
|
+
pytest tests/ -v
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Прогон через настоящий aiogram, но без сети
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python examples/simulate_flow.py
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
|
|
120
|
+
реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
|
|
121
|
+
кроме собственно доставки сообщений.
|
|
122
|
+
|
|
123
|
+
## Запуск с реальным ботом
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
export BOT_TOKEN=ваш_настоящий_токен
|
|
127
|
+
python examples/language_school_bot.py
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
|
|
131
|
+
проверка через живой Telegram.
|
|
132
|
+
|
|
133
|
+
## Как описать своё дерево
|
|
134
|
+
|
|
135
|
+
**Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
|
|
136
|
+
Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
|
|
137
|
+
сторону:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from tg_tree_wizard import linear_wizard, TreeWizard
|
|
141
|
+
|
|
142
|
+
TREE, ROOT = linear_wizard([
|
|
143
|
+
("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
|
|
144
|
+
("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
|
|
145
|
+
("topping", "Начинка:", [
|
|
146
|
+
("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
|
|
147
|
+
("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
|
|
148
|
+
]),
|
|
149
|
+
("spicy", "Поострее?", ["Да", "Нет"]),
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
|
|
153
|
+
dp.include_router(wizard.router)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Правила для варианта ответа:
|
|
157
|
+
- `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
|
|
158
|
+
- `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
|
|
159
|
+
- `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
|
|
160
|
+
для перехода не на следующий, а на произвольный узел (в т.ч. в обход
|
|
161
|
+
промежуточных шагов).
|
|
162
|
+
|
|
163
|
+
Последний шаг в списке — финальный по умолчанию (после него опрос
|
|
164
|
+
завершается), если явно не переопределить переход у его вариантов.
|
|
165
|
+
|
|
166
|
+
Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
|
|
167
|
+
дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
|
|
168
|
+
работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from tg_tree_wizard import Node, Option, TreeWizard
|
|
172
|
+
|
|
173
|
+
TREE = {
|
|
174
|
+
"start": Node(
|
|
175
|
+
text="Первый вопрос:",
|
|
176
|
+
options=(
|
|
177
|
+
Option("Вариант A", "a", "next_node_id"),
|
|
178
|
+
Option("Вариант B", "b", None), # None = опрос завершается
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
"next_node_id": Node(...),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
|
|
188
|
+
опечатались в id узла, бот упадёт при старте с понятной ошибкой.
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# tg-tree-wizard
|
|
2
|
+
|
|
3
|
+
Лёгкий движок древовидных inline-опросников для aiogram 3.
|
|
4
|
+
Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
|
|
5
|
+
каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
|
|
6
|
+
независимо от глубины дерева.
|
|
7
|
+
|
|
8
|
+
## Сколько кода экономит
|
|
9
|
+
|
|
10
|
+
Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
|
|
11
|
+
возраст → уровень владения), с 2-7 вариантами ответа на каждом —
|
|
12
|
+
навигационная часть кода, без учёта бизнес-логики финального шага
|
|
13
|
+
(сохранение заявки и т.п. — она одинакова в обоих случаях):
|
|
14
|
+
|
|
15
|
+
| | Строк кода |
|
|
16
|
+
|---|---|
|
|
17
|
+
| Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
|
|
18
|
+
| `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
|
|
19
|
+
|
|
20
|
+
Разница держится линейно — не растёт и не убывает в зависимости от того,
|
|
21
|
+
сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
|
|
22
|
+
~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
|
|
23
|
+
превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
|
|
24
|
+
внутри самой библиотеки.
|
|
25
|
+
|
|
26
|
+
## Структура пакета
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
tg_tree_wizard/
|
|
30
|
+
├── pyproject.toml # метаданные пакета, как его ставить
|
|
31
|
+
├── src/tg_tree_wizard/
|
|
32
|
+
│ ├── __init__.py # публичный API (что видно снаружи через import)
|
|
33
|
+
│ ├── core.py # чистая логика дерева, БЕЗ aiogram
|
|
34
|
+
│ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
|
|
35
|
+
├── tests/
|
|
36
|
+
│ └── test_core.py # тесты ядра, без сети и без Telegram
|
|
37
|
+
└── examples/
|
|
38
|
+
├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
|
|
39
|
+
└── language_school_bot.py # реальный бот — запускается с настоящим токеном
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
|
|
43
|
+
делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
|
|
44
|
+
его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
|
|
45
|
+
доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
|
|
46
|
+
только рендерит клавиатуры и дёргает ядро.
|
|
47
|
+
|
|
48
|
+
## Установка
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
cd tg_tree_wizard
|
|
52
|
+
pip install -e . # editable-режим: правите код — сразу видно в проекте
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
|
|
56
|
+
копируется — удобно, пока сами дорабатываете библиотеку.
|
|
57
|
+
|
|
58
|
+
## Тесты ядра (без Telegram)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install pytest
|
|
62
|
+
pytest tests/ -v
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Прогон через настоящий aiogram, но без сети
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python examples/simulate_flow.py
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
|
|
72
|
+
реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
|
|
73
|
+
кроме собственно доставки сообщений.
|
|
74
|
+
|
|
75
|
+
## Запуск с реальным ботом
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
export BOT_TOKEN=ваш_настоящий_токен
|
|
79
|
+
python examples/language_school_bot.py
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
|
|
83
|
+
проверка через живой Telegram.
|
|
84
|
+
|
|
85
|
+
## Как описать своё дерево
|
|
86
|
+
|
|
87
|
+
**Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
|
|
88
|
+
Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
|
|
89
|
+
сторону:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
from tg_tree_wizard import linear_wizard, TreeWizard
|
|
93
|
+
|
|
94
|
+
TREE, ROOT = linear_wizard([
|
|
95
|
+
("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
|
|
96
|
+
("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
|
|
97
|
+
("topping", "Начинка:", [
|
|
98
|
+
("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
|
|
99
|
+
("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
|
|
100
|
+
]),
|
|
101
|
+
("spicy", "Поострее?", ["Да", "Нет"]),
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
|
|
105
|
+
dp.include_router(wizard.router)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Правила для варианта ответа:
|
|
109
|
+
- `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
|
|
110
|
+
- `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
|
|
111
|
+
- `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
|
|
112
|
+
для перехода не на следующий, а на произвольный узел (в т.ч. в обход
|
|
113
|
+
промежуточных шагов).
|
|
114
|
+
|
|
115
|
+
Последний шаг в списке — финальный по умолчанию (после него опрос
|
|
116
|
+
завершается), если явно не переопределить переход у его вариантов.
|
|
117
|
+
|
|
118
|
+
Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
|
|
119
|
+
дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
|
|
120
|
+
работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
from tg_tree_wizard import Node, Option, TreeWizard
|
|
124
|
+
|
|
125
|
+
TREE = {
|
|
126
|
+
"start": Node(
|
|
127
|
+
text="Первый вопрос:",
|
|
128
|
+
options=(
|
|
129
|
+
Option("Вариант A", "a", "next_node_id"),
|
|
130
|
+
Option("Вариант B", "b", None), # None = опрос завершается
|
|
131
|
+
),
|
|
132
|
+
),
|
|
133
|
+
"next_node_id": Node(...),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
|
|
140
|
+
опечатались в id узла, бот упадёт при старте с понятной ошибкой.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tg-tree-wizard"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { file = "LICENSE" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Артем Самарин" },
|
|
14
|
+
]
|
|
15
|
+
keywords = ["aiogram", "telegram", "telegram-bot", "inline-keyboard", "fsm", "wizard"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 5 - Production/Stable",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Framework :: AsyncIO",
|
|
25
|
+
"Topic :: Communications :: Chat",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"aiogram>=3.0",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
dev = ["pytest>=8.0"]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/ваш-ник/tg-tree-wizard"
|
|
36
|
+
Repository = "https://github.com/ваш-ник/tg-tree-wizard"
|
|
37
|
+
Issues = "https://github.com/ваш-ник/tg-tree-wizard/issues"
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.packages.find]
|
|
40
|
+
where = ["src"]
|
|
41
|
+
|
|
42
|
+
[tool.setuptools.package-data]
|
|
43
|
+
tg_tree_wizard = ["py.typed"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .core import Node, Option, WizardState, TreeError, StaleChoiceError, linear_wizard
|
|
2
|
+
from .aiogram_adapter import TreeWizard, WizardStates, pack_buttons, check_callback_data_limits
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Node",
|
|
6
|
+
"Option",
|
|
7
|
+
"WizardState",
|
|
8
|
+
"TreeError",
|
|
9
|
+
"StaleChoiceError",
|
|
10
|
+
"linear_wizard",
|
|
11
|
+
"TreeWizard",
|
|
12
|
+
"WizardStates",
|
|
13
|
+
"pack_buttons",
|
|
14
|
+
"check_callback_data_limits",
|
|
15
|
+
]
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Тонкий адаптер над core.py: рендер клавиатур, хендлеры aiogram,
|
|
3
|
+
хранение WizardState в FSMContext. Логики дерева здесь нет —
|
|
4
|
+
она уже проверена в core.py, тут только Telegram I/O.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from aiogram import Router, F
|
|
8
|
+
from aiogram.filters import StateFilter
|
|
9
|
+
from aiogram.fsm.context import FSMContext
|
|
10
|
+
from aiogram.fsm.state import State, StatesGroup
|
|
11
|
+
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
|
12
|
+
|
|
13
|
+
from .core import Node, WizardState, choose, go_back, validate_tree, StaleChoiceError, TreeError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class WizardStates(StatesGroup):
|
|
17
|
+
active = State() # один State на весь опрос, независимо от глубины дерева
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Официальный лимит Telegram Bot API на длину callback_data.
|
|
21
|
+
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES = 64
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def check_callback_data_limits(tree: dict[str, Node], callback_prefix: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Проверяет, что callback_data ни для одного узла/варианта не превысит
|
|
27
|
+
лимит Telegram в 64 байта, и что id узлов не содержат ':' (используется
|
|
28
|
+
как разделитель в callback_data и сломает парсинг).
|
|
29
|
+
|
|
30
|
+
В отличие от исходного бота, где в callback_data кодировался весь
|
|
31
|
+
пройденный путь (и лимит рос вместе с глубиной дерева), здесь в
|
|
32
|
+
callback_data передаётся только текущий узел + индекс варианта — путь
|
|
33
|
+
хранится в FSMContext. Поэтому лимит практически не зависит от глубины
|
|
34
|
+
дерева, но всё ещё зависит от длины id узлов и callback_prefix.
|
|
35
|
+
"""
|
|
36
|
+
if ":" in callback_prefix:
|
|
37
|
+
raise TreeError(f"callback_prefix {callback_prefix!r} не может содержать ':'")
|
|
38
|
+
|
|
39
|
+
back_data = f"{callback_prefix}_back"
|
|
40
|
+
back_len = len(back_data.encode("utf-8"))
|
|
41
|
+
if back_len > TELEGRAM_CALLBACK_DATA_LIMIT_BYTES:
|
|
42
|
+
raise TreeError(
|
|
43
|
+
f"callback_data кнопки 'Назад' ({back_data!r}) занимает {back_len} байт, "
|
|
44
|
+
f"это больше лимита Telegram в {TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}. "
|
|
45
|
+
f"Сократите callback_prefix."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
for node_id, node in tree.items():
|
|
49
|
+
if ":" in node_id:
|
|
50
|
+
raise TreeError(
|
|
51
|
+
f"id узла {node_id!r} содержит ':' — это разделитель в callback_data, "
|
|
52
|
+
f"переименуйте узел."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
max_index = len(node.options) - 1
|
|
56
|
+
candidate = f"{callback_prefix}:{node_id}:{max_index}"
|
|
57
|
+
length = len(candidate.encode("utf-8"))
|
|
58
|
+
if length > TELEGRAM_CALLBACK_DATA_LIMIT_BYTES:
|
|
59
|
+
raise TreeError(
|
|
60
|
+
f"callback_data для узла {node_id!r} займёт {length} байт "
|
|
61
|
+
f"({candidate!r}), это больше лимита Telegram в "
|
|
62
|
+
f"{TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}. Сократите id узла "
|
|
63
|
+
f"или callback_prefix."
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pack_buttons(
|
|
68
|
+
buttons: list[InlineKeyboardButton],
|
|
69
|
+
max_row_length: int = 30,
|
|
70
|
+
) -> list[list[InlineKeyboardButton]]:
|
|
71
|
+
"""Авто-раскладка кнопок по рядам с учётом длины текста."""
|
|
72
|
+
rows: list[list[InlineKeyboardButton]] = []
|
|
73
|
+
current_row: list[InlineKeyboardButton] = []
|
|
74
|
+
current_length = 0
|
|
75
|
+
|
|
76
|
+
for button in buttons:
|
|
77
|
+
button_length = len(button.text) + 5
|
|
78
|
+
if current_row and current_length + button_length > max_row_length:
|
|
79
|
+
rows.append(current_row)
|
|
80
|
+
current_row = [button]
|
|
81
|
+
current_length = button_length
|
|
82
|
+
else:
|
|
83
|
+
current_row.append(button)
|
|
84
|
+
current_length += button_length
|
|
85
|
+
|
|
86
|
+
if current_row:
|
|
87
|
+
rows.append(current_row)
|
|
88
|
+
return rows
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TreeWizard:
|
|
92
|
+
"""
|
|
93
|
+
Собирает Router для дерева. Использование:
|
|
94
|
+
|
|
95
|
+
wizard = TreeWizard(TREE, root="lang", callback_prefix="wz")
|
|
96
|
+
dp.include_router(wizard.router)
|
|
97
|
+
|
|
98
|
+
@dp.message(Command("survey"))
|
|
99
|
+
async def cmd_survey(message: Message, state: FSMContext):
|
|
100
|
+
await wizard.start(message, state)
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
tree: dict[str, Node],
|
|
106
|
+
root: str,
|
|
107
|
+
callback_prefix: str = "wz",
|
|
108
|
+
max_row_length: int = 30,
|
|
109
|
+
on_finish=None, # async def on_finish(message_or_call, state, answers): ...
|
|
110
|
+
):
|
|
111
|
+
validate_tree(tree, root) # падаем при старте бота, а не в рантайме
|
|
112
|
+
check_callback_data_limits(tree, callback_prefix)
|
|
113
|
+
self.tree = tree
|
|
114
|
+
self.root = root
|
|
115
|
+
self.prefix = callback_prefix
|
|
116
|
+
self.max_row_length = max_row_length
|
|
117
|
+
self.on_finish = on_finish
|
|
118
|
+
self.router = Router()
|
|
119
|
+
self._register_handlers()
|
|
120
|
+
|
|
121
|
+
def _build_keyboard(self, node_id: str, node: Node) -> InlineKeyboardMarkup:
|
|
122
|
+
buttons = [
|
|
123
|
+
InlineKeyboardButton(
|
|
124
|
+
text=opt.label, callback_data=f"{self.prefix}:{node_id}:{i}"
|
|
125
|
+
)
|
|
126
|
+
for i, opt in enumerate(node.options)
|
|
127
|
+
]
|
|
128
|
+
rows = pack_buttons(buttons, self.max_row_length)
|
|
129
|
+
if node_id != self.root:
|
|
130
|
+
rows.append(
|
|
131
|
+
[InlineKeyboardButton(text="⬅️ Назад", callback_data=f"{self.prefix}_back")]
|
|
132
|
+
)
|
|
133
|
+
return InlineKeyboardMarkup(inline_keyboard=rows)
|
|
134
|
+
|
|
135
|
+
async def _render(self, target, node_id: str):
|
|
136
|
+
node = self.tree[node_id]
|
|
137
|
+
kb = self._build_keyboard(node_id, node)
|
|
138
|
+
if isinstance(target, CallbackQuery):
|
|
139
|
+
await target.message.edit_text(node.text, reply_markup=kb)
|
|
140
|
+
else:
|
|
141
|
+
await target.answer(node.text, reply_markup=kb)
|
|
142
|
+
|
|
143
|
+
async def start(self, message: Message, state: FSMContext):
|
|
144
|
+
wz = WizardState.start(self.root)
|
|
145
|
+
await state.set_state(WizardStates.active)
|
|
146
|
+
await state.update_data(**wz.to_dict())
|
|
147
|
+
await self._render(message, self.root)
|
|
148
|
+
|
|
149
|
+
def _register_handlers(self):
|
|
150
|
+
@self.router.callback_query(
|
|
151
|
+
StateFilter(WizardStates.active), F.data.startswith(f"{self.prefix}:")
|
|
152
|
+
)
|
|
153
|
+
async def handle_choice(call: CallbackQuery, state: FSMContext):
|
|
154
|
+
_, node_id, idx_str = call.data.split(":")
|
|
155
|
+
data = await state.get_data()
|
|
156
|
+
wz = WizardState.from_dict(data)
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
new_wz, opt, is_final = choose(self.tree, wz, node_id, int(idx_str))
|
|
160
|
+
except StaleChoiceError:
|
|
161
|
+
await call.answer("Это меню устарело, начните заново.", show_alert=True)
|
|
162
|
+
return
|
|
163
|
+
|
|
164
|
+
await state.update_data(**new_wz.to_dict())
|
|
165
|
+
|
|
166
|
+
if is_final:
|
|
167
|
+
if self.on_finish:
|
|
168
|
+
await self.on_finish(call, state, new_wz.answers)
|
|
169
|
+
await state.clear()
|
|
170
|
+
else:
|
|
171
|
+
await self._render(call, opt.next_node)
|
|
172
|
+
await call.answer()
|
|
173
|
+
|
|
174
|
+
@self.router.callback_query(
|
|
175
|
+
StateFilter(WizardStates.active), F.data == f"{self.prefix}_back"
|
|
176
|
+
)
|
|
177
|
+
async def handle_back(call: CallbackQuery, state: FSMContext):
|
|
178
|
+
data = await state.get_data()
|
|
179
|
+
wz = go_back(WizardState.from_dict(data))
|
|
180
|
+
await state.update_data(**wz.to_dict())
|
|
181
|
+
await self._render(call, wz.current_node)
|
|
182
|
+
await call.answer()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ядро дерева-опросника. Никаких импортов aiogram здесь нет и не должно быть —
|
|
3
|
+
это чистая логика, которую можно тестировать без Telegram и без сети.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field, replace
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TreeError(Exception):
|
|
10
|
+
"""Ошибка в описании дерева (обнаруживается один раз, при старте бота)."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StaleChoiceError(Exception):
|
|
14
|
+
"""Пользователь нажал на кнопку из уже неактуального (старого) сообщения."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Option:
|
|
19
|
+
label: str
|
|
20
|
+
value: str
|
|
21
|
+
next_node: str | None = None # None = после выбора опрос завершается
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Node:
|
|
26
|
+
text: str
|
|
27
|
+
options: tuple[Option, ...] = field(default_factory=tuple)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_tree(tree: dict[str, Node], root: str) -> None:
|
|
31
|
+
"""
|
|
32
|
+
Проверяет дерево один раз при старте бота, а не в рантайме на живом
|
|
33
|
+
пользователе. Ловит: отсутствующий root, ссылки на несуществующие
|
|
34
|
+
узлы, узлы без вариантов ответа (тупик).
|
|
35
|
+
"""
|
|
36
|
+
if root not in tree:
|
|
37
|
+
raise TreeError(f"Корневой узел {root!r} отсутствует в дереве")
|
|
38
|
+
|
|
39
|
+
for node_id, node in tree.items():
|
|
40
|
+
if not node.options:
|
|
41
|
+
raise TreeError(f"Узел {node_id!r} не содержит вариантов ответа")
|
|
42
|
+
for opt in node.options:
|
|
43
|
+
if opt.next_node is not None and opt.next_node not in tree:
|
|
44
|
+
raise TreeError(
|
|
45
|
+
f"Узел {node_id!r}: вариант {opt.label!r} ссылается "
|
|
46
|
+
f"на несуществующий узел {opt.next_node!r}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Answer:
|
|
52
|
+
node: str
|
|
53
|
+
label: str
|
|
54
|
+
value: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class WizardState:
|
|
59
|
+
"""
|
|
60
|
+
Всё состояние одного прохождения опроса. Неизменяемое (frozen) —
|
|
61
|
+
каждая операция возвращает НОВЫЙ WizardState, старый не трогается.
|
|
62
|
+
Это то, что вы будете класть целиком в FSMContext.data.
|
|
63
|
+
"""
|
|
64
|
+
stack: tuple[str, ...]
|
|
65
|
+
answers: tuple[Answer, ...] = field(default_factory=tuple)
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def start(cls, root: str) -> "WizardState":
|
|
69
|
+
return cls(stack=(root,), answers=())
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def current_node(self) -> str:
|
|
73
|
+
return self.stack[-1]
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict:
|
|
76
|
+
"""Для сохранения в FSMContext.data (там нужны сериализуемые типы)."""
|
|
77
|
+
return {
|
|
78
|
+
"stack": list(self.stack),
|
|
79
|
+
"answers": [a.__dict__ for a in self.answers],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: dict) -> "WizardState":
|
|
84
|
+
return cls(
|
|
85
|
+
stack=tuple(data["stack"]),
|
|
86
|
+
answers=tuple(Answer(**a) for a in data["answers"]),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def choose(
|
|
91
|
+
tree: dict[str, Node],
|
|
92
|
+
state: WizardState,
|
|
93
|
+
node_id: str,
|
|
94
|
+
option_index: int,
|
|
95
|
+
) -> tuple[WizardState, Option, bool]:
|
|
96
|
+
"""
|
|
97
|
+
Обрабатывает выбор пользователя.
|
|
98
|
+
Возвращает (новое_состояние, выбранная_опция, завершён_ли_опрос).
|
|
99
|
+
"""
|
|
100
|
+
if state.current_node != node_id:
|
|
101
|
+
# Пользователь нажал кнопку из уже неактуального сообщения
|
|
102
|
+
# (например, после /start заново или параллельного диалога).
|
|
103
|
+
raise StaleChoiceError(
|
|
104
|
+
f"Ожидался узел {state.current_node!r}, получен {node_id!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
node = tree[node_id]
|
|
108
|
+
if not (0 <= option_index < len(node.options)):
|
|
109
|
+
raise TreeError(f"Индекс {option_index} вне диапазона для узла {node_id!r}")
|
|
110
|
+
|
|
111
|
+
opt = node.options[option_index]
|
|
112
|
+
new_answers = state.answers + (Answer(node_id, opt.label, opt.value),)
|
|
113
|
+
|
|
114
|
+
if opt.next_node is None:
|
|
115
|
+
return replace(state, answers=new_answers), opt, True
|
|
116
|
+
|
|
117
|
+
new_stack = state.stack + (opt.next_node,)
|
|
118
|
+
return replace(state, stack=new_stack, answers=new_answers), opt, False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def go_back(state: WizardState) -> WizardState:
|
|
122
|
+
"""Отменяет последний шаг. Если мы уже в корне — ничего не делает."""
|
|
123
|
+
if len(state.stack) <= 1:
|
|
124
|
+
return state
|
|
125
|
+
return WizardState(stack=state.stack[:-1], answers=state.answers[:-1])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# Один вариант ответа в коротком синтаксисе linear_wizard:
|
|
129
|
+
# "Текст кнопки" -> label == value, переход на следующий шаг
|
|
130
|
+
# ("Текст кнопки", "значение") -> явное значение, переход на следующий шаг
|
|
131
|
+
# ("Текст кнопки", "значение", "узел") -> явное значение + явный переход (ветвление)
|
|
132
|
+
ShortOption = str | tuple[str, str] | tuple[str, str, str]
|
|
133
|
+
|
|
134
|
+
# Один шаг: (id_узла, текст_вопроса, список_вариантов)
|
|
135
|
+
Step = tuple[str, str, list[ShortOption]]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def linear_wizard(steps: list[Step]) -> tuple[dict[str, Node], str]:
|
|
139
|
+
"""
|
|
140
|
+
Короткий способ описать дерево для типового случая — линейной цепочки
|
|
141
|
+
вопросов, где почти все варианты ведут на следующий шаг по порядку.
|
|
142
|
+
Возвращает (tree, root_id), готовые для validate_tree()/TreeWizard().
|
|
143
|
+
|
|
144
|
+
Последний шаг по умолчанию — финальный (next_node=None у всех его
|
|
145
|
+
вариантов, если не переопределено явно).
|
|
146
|
+
|
|
147
|
+
Точечное ветвление — там, где часть вариантов уходит не на следующий
|
|
148
|
+
шаг, а куда-то ещё (или сразу в финал, минуя промежуточные шаги) —
|
|
149
|
+
делается явным третьим элементом кортежа с id нужного узла.
|
|
150
|
+
|
|
151
|
+
Пример:
|
|
152
|
+
TREE, ROOT = linear_wizard([
|
|
153
|
+
("size", "Выберите размер:", ["Маленькая", "Средняя", "Большая"]),
|
|
154
|
+
("topping", "Начинка:", [
|
|
155
|
+
("Пепперони", "pepperoni", "spicy"), # ветка в сторону
|
|
156
|
+
"Маргарита", # обычный переход дальше
|
|
157
|
+
]),
|
|
158
|
+
("spicy", "Поострее?", ["Да", "Нет"]),
|
|
159
|
+
("confirm", "Подтвердить заказ?", ["Да"]),
|
|
160
|
+
])
|
|
161
|
+
"""
|
|
162
|
+
if not steps:
|
|
163
|
+
raise TreeError("Нужен хотя бы один шаг")
|
|
164
|
+
|
|
165
|
+
node_ids = [step[0] for step in steps]
|
|
166
|
+
tree: dict[str, Node] = {}
|
|
167
|
+
|
|
168
|
+
for i, (node_id, text, options) in enumerate(steps):
|
|
169
|
+
default_next = node_ids[i + 1] if i + 1 < len(node_ids) else None
|
|
170
|
+
built_options = []
|
|
171
|
+
|
|
172
|
+
for opt in options:
|
|
173
|
+
if isinstance(opt, str):
|
|
174
|
+
label, value, next_node = opt, opt, default_next
|
|
175
|
+
elif len(opt) == 2:
|
|
176
|
+
label, value = opt
|
|
177
|
+
next_node = default_next
|
|
178
|
+
elif len(opt) == 3:
|
|
179
|
+
label, value, next_node = opt
|
|
180
|
+
else:
|
|
181
|
+
raise TreeError(f"Некорректный вариант ответа: {opt!r}")
|
|
182
|
+
built_options.append(Option(label, value, next_node))
|
|
183
|
+
|
|
184
|
+
tree[node_id] = Node(text=text, options=tuple(built_options))
|
|
185
|
+
|
|
186
|
+
return tree, node_ids[0]
|
|
File without changes
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tg-tree-wizard
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Лёгкий движок древовидных inline-опросников (wizard) для aiogram 3
|
|
5
|
+
Author: Артем Самарин
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Артем Самарин
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/ваш-ник/tg-tree-wizard
|
|
29
|
+
Project-URL: Repository, https://github.com/ваш-ник/tg-tree-wizard
|
|
30
|
+
Project-URL: Issues, https://github.com/ваш-ник/tg-tree-wizard/issues
|
|
31
|
+
Keywords: aiogram,telegram,telegram-bot,inline-keyboard,fsm,wizard
|
|
32
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
33
|
+
Classifier: Intended Audience :: Developers
|
|
34
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
39
|
+
Classifier: Framework :: AsyncIO
|
|
40
|
+
Classifier: Topic :: Communications :: Chat
|
|
41
|
+
Requires-Python: >=3.10
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
License-File: LICENSE
|
|
44
|
+
Requires-Dist: aiogram>=3.0
|
|
45
|
+
Provides-Extra: dev
|
|
46
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
47
|
+
Dynamic: license-file
|
|
48
|
+
|
|
49
|
+
# tg-tree-wizard
|
|
50
|
+
|
|
51
|
+
Лёгкий движок древовидных inline-опросников для aiogram 3.
|
|
52
|
+
Дерево описывается данными (`Node`/`Option`), а не отдельным хендлером на
|
|
53
|
+
каждый уровень; "назад" и переходы обрабатываются двумя общими хендлерами
|
|
54
|
+
независимо от глубины дерева.
|
|
55
|
+
|
|
56
|
+
## Сколько кода экономит
|
|
57
|
+
|
|
58
|
+
Замер на дереве из 6 уровней (язык → формат → цель → индивидуально/группа →
|
|
59
|
+
возраст → уровень владения), с 2-7 вариантами ответа на каждом —
|
|
60
|
+
навигационная часть кода, без учёта бизнес-логики финального шага
|
|
61
|
+
(сохранение заявки и т.п. — она одинакова в обоих случаях):
|
|
62
|
+
|
|
63
|
+
| | Строк кода |
|
|
64
|
+
|---|---|
|
|
65
|
+
| Вручную (клавиатура + парсинг callback_data + кнопка "назад" на каждый уровень) | ~169 |
|
|
66
|
+
| `tg-tree-wizard` (`linear_wizard` + подключение роутера) | ~26 |
|
|
67
|
+
|
|
68
|
+
Разница держится линейно — не растёт и не убывает в зависимости от того,
|
|
69
|
+
сколько таких деревьев в проекте: каждое новое дерево стоит фиксированные
|
|
70
|
+
~26 строк вместо ~169, потому что логика навигации, "назад" и защита от
|
|
71
|
+
превышения лимита `callback_data` уже написаны и покрыты тестами один раз,
|
|
72
|
+
внутри самой библиотеки.
|
|
73
|
+
|
|
74
|
+
## Структура пакета
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
tg_tree_wizard/
|
|
78
|
+
├── pyproject.toml # метаданные пакета, как его ставить
|
|
79
|
+
├── src/tg_tree_wizard/
|
|
80
|
+
│ ├── __init__.py # публичный API (что видно снаружи через import)
|
|
81
|
+
│ ├── core.py # чистая логика дерева, БЕЗ aiogram
|
|
82
|
+
│ └── aiogram_adapter.py # клавиатуры + хендлеры aiogram поверх core.py
|
|
83
|
+
├── tests/
|
|
84
|
+
│ └── test_core.py # тесты ядра, без сети и без Telegram
|
|
85
|
+
└── examples/
|
|
86
|
+
├── simulate_flow.py # прогон через настоящий aiogram Dispatcher, без сети
|
|
87
|
+
└── language_school_bot.py # реальный бот — запускается с настоящим токеном
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Почему `core.py` отдельно от `aiogram_adapter.py`: это приём, который
|
|
91
|
+
делает библиотеку тестируемой. Ядро ничего не знает про Telegram — значит,
|
|
92
|
+
его логику (переходы, "назад", ошибки) можно проверить обычным `pytest` за
|
|
93
|
+
доли секунды, без сети и без живого бота. Адаптер — тонкий слой, который
|
|
94
|
+
только рендерит клавиатуры и дёргает ядро.
|
|
95
|
+
|
|
96
|
+
## Установка
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
cd tg_tree_wizard
|
|
100
|
+
pip install -e . # editable-режим: правите код — сразу видно в проекте
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`-e` (editable) значит, что пакет ставится "по ссылке" на исходники, а не
|
|
104
|
+
копируется — удобно, пока сами дорабатываете библиотеку.
|
|
105
|
+
|
|
106
|
+
## Тесты ядра (без Telegram)
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
pip install pytest
|
|
110
|
+
pytest tests/ -v
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Прогон через настоящий aiogram, но без сети
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python examples/simulate_flow.py
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Использует настоящий `Dispatcher` и `MemoryStorage`, подменяет только
|
|
120
|
+
реальный HTTP-запрос к `api.telegram.org` — так что проверяется всё,
|
|
121
|
+
кроме собственно доставки сообщений.
|
|
122
|
+
|
|
123
|
+
## Запуск с реальным ботом
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
export BOT_TOKEN=ваш_настоящий_токен
|
|
127
|
+
python examples/language_school_bot.py
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Дальше пишите `/survey` в бота и проходите дерево — это уже 100% реальная
|
|
131
|
+
проверка через живой Telegram.
|
|
132
|
+
|
|
133
|
+
## Как описать своё дерево
|
|
134
|
+
|
|
135
|
+
**Короткий способ (рекомендуется для большинства случаев)** — `linear_wizard`.
|
|
136
|
+
Подходит, когда вопросы идут по порядку и лишь изредка нужно свернуть в
|
|
137
|
+
сторону:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
from tg_tree_wizard import linear_wizard, TreeWizard
|
|
141
|
+
|
|
142
|
+
TREE, ROOT = linear_wizard([
|
|
143
|
+
("lang", "Выберите язык:", ["Английский", "Немецкий"]), # label == value
|
|
144
|
+
("delivery", "Формат:", [("Очно", "offline"), ("Онлайн", "online")]),
|
|
145
|
+
("topping", "Начинка:", [
|
|
146
|
+
("Пепперони", "pepperoni", "spicy"), # явный переход = ветвление
|
|
147
|
+
("Маргарита", "margherita"), # без 3-го элемента = следующий шаг по порядку
|
|
148
|
+
]),
|
|
149
|
+
("spicy", "Поострее?", ["Да", "Нет"]),
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
wizard = TreeWizard(TREE, root=ROOT, on_finish=my_finish_callback)
|
|
153
|
+
dp.include_router(wizard.router)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Правила для варианта ответа:
|
|
157
|
+
- `"Текст"` — метка и значение совпадают, переход на следующий шаг по списку;
|
|
158
|
+
- `("Текст", "значение")` — то же самое, но значение отдельно от текста кнопки;
|
|
159
|
+
- `("Текст", "значение", "id_узла")` — явный переход, для ветвления или
|
|
160
|
+
для перехода не на следующий, а на произвольный узел (в т.ч. в обход
|
|
161
|
+
промежуточных шагов).
|
|
162
|
+
|
|
163
|
+
Последний шаг в списке — финальный по умолчанию (после него опрос
|
|
164
|
+
завершается), если явно не переопределить переход у его вариантов.
|
|
165
|
+
|
|
166
|
+
Полный способ — `Node`/`Option` напрямую. Нужен, когда переходов в
|
|
167
|
+
дерево больше, чем шагов, и связь "шаг → следующий по умолчанию" не
|
|
168
|
+
работает (сложные ветвящиеся графы, несколько независимых корней и т.п.):
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from tg_tree_wizard import Node, Option, TreeWizard
|
|
172
|
+
|
|
173
|
+
TREE = {
|
|
174
|
+
"start": Node(
|
|
175
|
+
text="Первый вопрос:",
|
|
176
|
+
options=(
|
|
177
|
+
Option("Вариант A", "a", "next_node_id"),
|
|
178
|
+
Option("Вариант B", "b", None), # None = опрос завершается
|
|
179
|
+
),
|
|
180
|
+
),
|
|
181
|
+
"next_node_id": Node(...),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
wizard = TreeWizard(TREE, root="start", on_finish=my_finish_callback)
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`TreeWizard(...)` сам вызывает `validate_tree` при создании — если где-то
|
|
188
|
+
опечатались в id узла, бот упадёт при старте с понятной ошибкой.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/tg_tree_wizard/__init__.py
|
|
5
|
+
src/tg_tree_wizard/aiogram_adapter.py
|
|
6
|
+
src/tg_tree_wizard/core.py
|
|
7
|
+
src/tg_tree_wizard/py.typed
|
|
8
|
+
src/tg_tree_wizard.egg-info/PKG-INFO
|
|
9
|
+
src/tg_tree_wizard.egg-info/SOURCES.txt
|
|
10
|
+
src/tg_tree_wizard.egg-info/dependency_links.txt
|
|
11
|
+
src/tg_tree_wizard.egg-info/requires.txt
|
|
12
|
+
src/tg_tree_wizard.egg-info/top_level.txt
|
|
13
|
+
tests/test_adapter.py
|
|
14
|
+
tests/test_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tg_tree_wizard
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from tg_tree_wizard.core import Node, Option, TreeError
|
|
7
|
+
from tg_tree_wizard.aiogram_adapter import (
|
|
8
|
+
TreeWizard,
|
|
9
|
+
check_callback_data_limits,
|
|
10
|
+
TELEGRAM_CALLBACK_DATA_LIMIT_BYTES,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def make_node(n_options: int = 2) -> Node:
|
|
15
|
+
return Node(
|
|
16
|
+
text="x",
|
|
17
|
+
options=tuple(Option(f"opt{i}", f"v{i}", None) for i in range(n_options)),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_normal_tree_passes_callback_limit_check():
|
|
22
|
+
tree = {"lang": make_node(), "delivery": make_node()}
|
|
23
|
+
check_callback_data_limits(tree, callback_prefix="wz") # не должно кинуть
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_long_node_id_exceeds_limit_and_is_caught():
|
|
27
|
+
long_id = "a" * 60 # заведомо длинный id узла
|
|
28
|
+
tree = {long_id: make_node()}
|
|
29
|
+
with pytest.raises(TreeError):
|
|
30
|
+
check_callback_data_limits(tree, callback_prefix="wz")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_colon_in_node_id_is_rejected():
|
|
34
|
+
tree = {"bad:id": make_node()}
|
|
35
|
+
with pytest.raises(TreeError):
|
|
36
|
+
check_callback_data_limits(tree, callback_prefix="wz")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_treewizard_raises_at_construction_not_at_runtime():
|
|
40
|
+
"""
|
|
41
|
+
Ключевое свойство: ошибка ловится при СОЗДАНИИ TreeWizard (то есть при
|
|
42
|
+
старте бота), а не когда до этого узла случайно дойдёт живой пользователь.
|
|
43
|
+
"""
|
|
44
|
+
long_id = "b" * 60
|
|
45
|
+
tree = {long_id: make_node()}
|
|
46
|
+
with pytest.raises(TreeError):
|
|
47
|
+
TreeWizard(tree, root=long_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_realistic_short_ids_stay_well_under_limit():
|
|
51
|
+
"""
|
|
52
|
+
Проверка на здравый смысл: обычные короткие id узлов (как в примерах
|
|
53
|
+
из README) даже близко не подходят к лимиту, независимо от того,
|
|
54
|
+
сколько шагов в дереве — потому что путь не кодируется в callback_data.
|
|
55
|
+
"""
|
|
56
|
+
tree = {
|
|
57
|
+
"lang": make_node(7), # 7 вариантов языков, как в исходном боте
|
|
58
|
+
"delivery": make_node(2),
|
|
59
|
+
"goal": make_node(7),
|
|
60
|
+
"group": make_node(2),
|
|
61
|
+
"age": make_node(4),
|
|
62
|
+
"level": make_node(7),
|
|
63
|
+
}
|
|
64
|
+
check_callback_data_limits(tree, callback_prefix="wz")
|
|
65
|
+
|
|
66
|
+
max_len = 0
|
|
67
|
+
for node_id, node in tree.items():
|
|
68
|
+
candidate = f"wz:{node_id}:{len(node.options) - 1}"
|
|
69
|
+
max_len = max(max_len, len(candidate.encode("utf-8")))
|
|
70
|
+
|
|
71
|
+
assert max_len < TELEGRAM_CALLBACK_DATA_LIMIT_BYTES
|
|
72
|
+
print(f"Максимальная длина callback_data в этом дереве: {max_len} байт из {TELEGRAM_CALLBACK_DATA_LIMIT_BYTES}")
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from tg_tree_wizard.core import (
|
|
7
|
+
linear_wizard,
|
|
8
|
+
Node, Option, WizardState, TreeError, StaleChoiceError,
|
|
9
|
+
validate_tree, choose, go_back,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# Небольшое тестовое дерево: язык -> формат -> финал
|
|
14
|
+
TREE = {
|
|
15
|
+
"lang": Node(
|
|
16
|
+
text="Выберите язык:",
|
|
17
|
+
options=(
|
|
18
|
+
Option("Английский", "en", "delivery"),
|
|
19
|
+
Option("Немецкий", "de", "delivery"),
|
|
20
|
+
),
|
|
21
|
+
),
|
|
22
|
+
"delivery": Node(
|
|
23
|
+
text="Выберите формат:",
|
|
24
|
+
options=(
|
|
25
|
+
Option("Очно", "offline", None),
|
|
26
|
+
Option("Онлайн", "online", None),
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_valid_tree_passes_validation():
|
|
33
|
+
validate_tree(TREE, root="lang") # не должно кинуть исключение
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_tree_with_missing_root_fails():
|
|
37
|
+
with pytest.raises(TreeError):
|
|
38
|
+
validate_tree(TREE, root="does_not_exist")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_tree_with_broken_reference_fails():
|
|
42
|
+
broken = {
|
|
43
|
+
"lang": Node(text="x", options=(Option("a", "a", "nowhere"),)),
|
|
44
|
+
}
|
|
45
|
+
with pytest.raises(TreeError):
|
|
46
|
+
validate_tree(broken, root="lang")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_happy_path_reaches_final_and_records_answers():
|
|
50
|
+
state = WizardState.start("lang")
|
|
51
|
+
assert state.current_node == "lang"
|
|
52
|
+
|
|
53
|
+
state, opt, is_final = choose(TREE, state, "lang", 0) # выбрали "Английский"
|
|
54
|
+
assert opt.value == "en"
|
|
55
|
+
assert is_final is False
|
|
56
|
+
assert state.current_node == "delivery"
|
|
57
|
+
|
|
58
|
+
state, opt, is_final = choose(TREE, state, "delivery", 1) # "Онлайн"
|
|
59
|
+
assert opt.value == "online"
|
|
60
|
+
assert is_final is True
|
|
61
|
+
|
|
62
|
+
assert [a.value for a in state.answers] == ["en", "online"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_back_restores_previous_node_and_drops_last_answer():
|
|
66
|
+
state = WizardState.start("lang")
|
|
67
|
+
state, _, _ = choose(TREE, state, "lang", 0)
|
|
68
|
+
assert state.current_node == "delivery"
|
|
69
|
+
|
|
70
|
+
state = go_back(state)
|
|
71
|
+
assert state.current_node == "lang"
|
|
72
|
+
assert state.answers == () # ответ про язык тоже откатился
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_back_at_root_is_noop():
|
|
76
|
+
state = WizardState.start("lang")
|
|
77
|
+
state2 = go_back(state)
|
|
78
|
+
assert state2 == state
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_stale_choice_from_old_message_is_rejected():
|
|
82
|
+
"""
|
|
83
|
+
Пользователь дошёл до 'delivery', но нажал кнопку со старого
|
|
84
|
+
сообщения 'lang' (например, открыл старый чат). Это должно
|
|
85
|
+
падать с понятной ошибкой, а не молча ломать состояние.
|
|
86
|
+
"""
|
|
87
|
+
state = WizardState.start("lang")
|
|
88
|
+
state, _, _ = choose(TREE, state, "lang", 0) # теперь мы на delivery
|
|
89
|
+
|
|
90
|
+
with pytest.raises(StaleChoiceError):
|
|
91
|
+
choose(TREE, state, "lang", 1) # а жмём на устаревшую кнопку lang
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_state_roundtrips_through_dict_like_fsmcontext_would_store_it():
|
|
95
|
+
"""Ровно то, что будет происходить в FSMContext.data на реальном боте."""
|
|
96
|
+
state = WizardState.start("lang")
|
|
97
|
+
state, _, _ = choose(TREE, state, "lang", 0)
|
|
98
|
+
|
|
99
|
+
as_dict = state.to_dict()
|
|
100
|
+
restored = WizardState.from_dict(as_dict)
|
|
101
|
+
|
|
102
|
+
assert restored == state
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_linear_wizard_plain_strings_chain_to_next_step():
|
|
106
|
+
tree, root = linear_wizard([
|
|
107
|
+
("a", "Шаг A", ["X", "Y"]),
|
|
108
|
+
("b", "Шаг B", ["Z"]),
|
|
109
|
+
])
|
|
110
|
+
assert root == "a"
|
|
111
|
+
validate_tree(tree, root)
|
|
112
|
+
|
|
113
|
+
state = WizardState.start(root)
|
|
114
|
+
state, opt, is_final = choose(tree, state, "a", 0)
|
|
115
|
+
assert opt.label == opt.value == "X"
|
|
116
|
+
assert is_final is False
|
|
117
|
+
assert state.current_node == "b"
|
|
118
|
+
|
|
119
|
+
state, opt, is_final = choose(tree, state, "b", 0)
|
|
120
|
+
assert is_final is True # последний шаг - финальный по умолчанию
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_linear_wizard_explicit_next_node_enables_branching():
|
|
124
|
+
tree, root = linear_wizard([
|
|
125
|
+
("topping", "Начинка", [
|
|
126
|
+
("Пепперони", "pepperoni", "spicy"),
|
|
127
|
+
("Маргарита", "margherita", "confirm"),
|
|
128
|
+
]),
|
|
129
|
+
("spicy", "Острота", ["Да", "Нет"]),
|
|
130
|
+
("confirm", "Финал", ["OK"]),
|
|
131
|
+
])
|
|
132
|
+
validate_tree(tree, root)
|
|
133
|
+
|
|
134
|
+
state = WizardState.start(root)
|
|
135
|
+
state, _, is_final = choose(tree, state, "topping", 0)
|
|
136
|
+
assert state.current_node == "spicy"
|
|
137
|
+
assert is_final is False
|
|
138
|
+
|
|
139
|
+
state2 = WizardState.start(root)
|
|
140
|
+
state2, _, is_final2 = choose(tree, state2, "topping", 1)
|
|
141
|
+
assert state2.current_node == "confirm"
|
|
142
|
+
assert is_final2 is False
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_linear_wizard_rejects_empty_steps():
|
|
146
|
+
with pytest.raises(TreeError):
|
|
147
|
+
linear_wizard([])
|