matele 0.1.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.
Files changed (96) hide show
  1. matele-0.1.0/.editorconfig +35 -0
  2. matele-0.1.0/.gitattributes +1 -0
  3. matele-0.1.0/.github/dependabot.yml +7 -0
  4. matele-0.1.0/.github/workflows/ci.yml +38 -0
  5. matele-0.1.0/.github/workflows/docs.yml +42 -0
  6. matele-0.1.0/.github/workflows/release.yml +40 -0
  7. matele-0.1.0/.gitignore +12 -0
  8. matele-0.1.0/CHANGELOG.md +28 -0
  9. matele-0.1.0/LICENSE +21 -0
  10. matele-0.1.0/PKG-INFO +310 -0
  11. matele-0.1.0/README.md +267 -0
  12. matele-0.1.0/RELEASING.md +19 -0
  13. matele-0.1.0/ROADMAP.md +35 -0
  14. matele-0.1.0/docs/broadcasts.md +224 -0
  15. matele-0.1.0/docs/buttons.md +229 -0
  16. matele-0.1.0/docs/changelog.md +3 -0
  17. matele-0.1.0/docs/concepts.md +99 -0
  18. matele-0.1.0/docs/deploy.md +225 -0
  19. matele-0.1.0/docs/errors.md +144 -0
  20. matele-0.1.0/docs/files.md +215 -0
  21. matele-0.1.0/docs/filters.md +205 -0
  22. matele-0.1.0/docs/first-bot.md +171 -0
  23. matele-0.1.0/docs/groups.md +151 -0
  24. matele-0.1.0/docs/handlers.md +211 -0
  25. matele-0.1.0/docs/index.md +89 -0
  26. matele-0.1.0/docs/menus.md +129 -0
  27. matele-0.1.0/docs/mini-apps.md +190 -0
  28. matele-0.1.0/docs/platform-api.md +175 -0
  29. matele-0.1.0/docs/reference.md +225 -0
  30. matele-0.1.0/docs/routers.md +183 -0
  31. matele-0.1.0/docs/states.md +232 -0
  32. matele-0.1.0/docs/stylesheets/extra.css +3 -0
  33. matele-0.1.0/docs/testing.md +241 -0
  34. matele-0.1.0/docs/webhooks.md +228 -0
  35. matele-0.1.0/examples/mini_app/app.py +82 -0
  36. matele-0.1.0/examples/mini_app/index.html +54 -0
  37. matele-0.1.0/examples/two_messengers.py +98 -0
  38. matele-0.1.0/mkdocs.yml +77 -0
  39. matele-0.1.0/pyproject.toml +77 -0
  40. matele-0.1.0/schema/max/README.md +20 -0
  41. matele-0.1.0/schema/max/schema.json +5388 -0
  42. matele-0.1.0/src/matele/__init__.py +96 -0
  43. matele-0.1.0/src/matele/_http.py +39 -0
  44. matele-0.1.0/src/matele/callbacks.py +225 -0
  45. matele-0.1.0/src/matele/dispatch/__init__.py +19 -0
  46. matele-0.1.0/src/matele/dispatch/context.py +226 -0
  47. matele-0.1.0/src/matele/dispatch/dispatcher.py +400 -0
  48. matele-0.1.0/src/matele/dispatch/filters.py +167 -0
  49. matele-0.1.0/src/matele/dispatch/panel.py +119 -0
  50. matele-0.1.0/src/matele/dispatch/polling.py +151 -0
  51. matele-0.1.0/src/matele/dispatch/router.py +190 -0
  52. matele-0.1.0/src/matele/errors.py +103 -0
  53. matele-0.1.0/src/matele/fsm/__init__.py +20 -0
  54. matele-0.1.0/src/matele/fsm/context.py +54 -0
  55. matele-0.1.0/src/matele/fsm/redis.py +114 -0
  56. matele-0.1.0/src/matele/fsm/sqlite.py +130 -0
  57. matele-0.1.0/src/matele/fsm/state.py +93 -0
  58. matele-0.1.0/src/matele/fsm/storage.py +76 -0
  59. matele-0.1.0/src/matele/hub.py +217 -0
  60. matele-0.1.0/src/matele/max/__init__.py +7 -0
  61. matele-0.1.0/src/matele/max/_model.py +122 -0
  62. matele-0.1.0/src/matele/max/api.py +516 -0
  63. matele-0.1.0/src/matele/max/bot.py +460 -0
  64. matele-0.1.0/src/matele/max/certs/README.md +6 -0
  65. matele-0.1.0/src/matele/max/certs/russian_trusted_root_ca.pem +33 -0
  66. matele-0.1.0/src/matele/max/models.py +1539 -0
  67. matele-0.1.0/src/matele/py.typed +0 -0
  68. matele-0.1.0/src/matele/telegram/__init__.py +6 -0
  69. matele-0.1.0/src/matele/telegram/api.py +255 -0
  70. matele-0.1.0/src/matele/telegram/bot.py +628 -0
  71. matele-0.1.0/src/matele/testing.py +289 -0
  72. matele-0.1.0/src/matele/types.py +253 -0
  73. matele-0.1.0/src/matele/webapp.py +127 -0
  74. matele-0.1.0/src/matele/webhook.py +267 -0
  75. matele-0.1.0/tests/__init__.py +0 -0
  76. matele-0.1.0/tests/max_fake.py +100 -0
  77. matele-0.1.0/tests/test_callbacks.py +138 -0
  78. matele-0.1.0/tests/test_commands_menu.py +100 -0
  79. matele-0.1.0/tests/test_dispatch.py +605 -0
  80. matele-0.1.0/tests/test_docs.py +45 -0
  81. matele-0.1.0/tests/test_errors.py +70 -0
  82. matele-0.1.0/tests/test_examples.py +50 -0
  83. matele-0.1.0/tests/test_fsm.py +78 -0
  84. matele-0.1.0/tests/test_groups.py +245 -0
  85. matele-0.1.0/tests/test_hub.py +151 -0
  86. matele-0.1.0/tests/test_max_api.py +262 -0
  87. matele-0.1.0/tests/test_max_bot.py +271 -0
  88. matele-0.1.0/tests/test_max_models.py +87 -0
  89. matele-0.1.0/tests/test_media.py +132 -0
  90. matele-0.1.0/tests/test_panel.py +125 -0
  91. matele-0.1.0/tests/test_review_fixes.py +296 -0
  92. matele-0.1.0/tests/test_storages.py +186 -0
  93. matele-0.1.0/tests/test_telegram.py +379 -0
  94. matele-0.1.0/tests/test_webapp.py +135 -0
  95. matele-0.1.0/tests/test_webhook.py +268 -0
  96. matele-0.1.0/tools/gen_max_models.py +231 -0
@@ -0,0 +1,35 @@
1
+ # https://editorconfig.org — отступы табами во всём коде проекта.
2
+ root = true
3
+
4
+ [*]
5
+ charset = utf-8
6
+ end_of_line = lf
7
+ insert_final_newline = true
8
+ trim_trailing_whitespace = true
9
+ indent_style = tab
10
+ indent_size = tab
11
+ tab_width = 4
12
+
13
+ # Фронтенд исторически с шириной отступа 2.
14
+ [*.{js,css,html,json}]
15
+ tab_width = 2
16
+
17
+ # YAML запрещает табы в отступах (спецификация), поэтому здесь пробелы.
18
+ [*.{yml,yaml}]
19
+ indent_style = space
20
+ indent_size = 2
21
+
22
+ # В Markdown таб означает блок кода, а два пробела в конце строки — перенос.
23
+ [*.md]
24
+ indent_style = space
25
+ indent_size = 2
26
+ trim_trailing_whitespace = false
27
+
28
+ # Бинарные и вендорные файлы не трогаем.
29
+ [*.{pem,db,png,ico}]
30
+ charset = unset
31
+ end_of_line = unset
32
+ insert_final_newline = unset
33
+ trim_trailing_whitespace = unset
34
+ indent_style = unset
35
+ indent_size = unset
@@ -0,0 +1 @@
1
+ * text=auto eol=lf
@@ -0,0 +1,7 @@
1
+ # Actions are pinned to commit SHAs; this keeps the pins up to date.
2
+ version: 2
3
+ updates:
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: monthly
@@ -0,0 +1,38 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ${{ matrix.os }}
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest]
14
+ python: ["3.11", "3.12", "3.13"]
15
+ include:
16
+ - os: windows-latest
17
+ python: "3.13"
18
+ steps:
19
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
20
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
21
+ with:
22
+ python-version: ${{ matrix.python }}
23
+ cache: pip
24
+ - run: pip install -e ".[dev]"
25
+ - name: Lint
26
+ run: ruff check src tests tools examples
27
+ - name: Formatting (tabs, see .editorconfig)
28
+ run: ruff format --check src tests tools examples
29
+ - name: Types
30
+ if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
31
+ run: basedpyright --pythonpath "$(which python)"
32
+ - name: Tests
33
+ run: python -m pytest -q --cov=matele --cov-report=term-missing:skip-covered --cov-fail-under=90
34
+ - name: Generated MAX models match the schema
35
+ if: matrix.os == 'ubuntu-latest' && matrix.python == '3.12'
36
+ run: |
37
+ python tools/gen_max_models.py
38
+ git diff --exit-code src/matele/max/models.py
@@ -0,0 +1,42 @@
1
+ # Builds the documentation site on every push and publishes it to GitHub
2
+ # Pages from main.
3
+ name: Docs
4
+
5
+ on:
6
+ push:
7
+ pull_request:
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
15
+ with:
16
+ python-version: "3.12"
17
+ cache: pip
18
+ - run: pip install ".[docs]"
19
+ - run: mkdocs build --strict
20
+ env:
21
+ NO_MKDOCS_2_WARNING: "1"
22
+ - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
23
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
24
+ with:
25
+ path: site
26
+
27
+ deploy:
28
+ needs: build
29
+ if: github.ref == 'refs/heads/main' && github.event_name == 'push'
30
+ runs-on: ubuntu-latest
31
+ permissions:
32
+ pages: write
33
+ id-token: write
34
+ environment:
35
+ name: github-pages
36
+ url: ${{ steps.deployment.outputs.page_url }}
37
+ concurrency:
38
+ group: pages
39
+ cancel-in-progress: false
40
+ steps:
41
+ - id: deployment
42
+ uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
@@ -0,0 +1,40 @@
1
+ # Publishes to PyPI when a version tag (v0.1.0, ...) is pushed.
2
+ # Uses PyPI Trusted Publishing: no token is stored in the repository.
3
+ name: Release
4
+
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14
+ - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
15
+ with:
16
+ python-version: "3.12"
17
+ - run: pip install build twine
18
+ - name: The tag matches the package version
19
+ run: |
20
+ version=$(python -c "import re; print(re.search(r'__version__ = \"(.+)\"', open('src/matele/__init__.py').read()).group(1))")
21
+ test "v$version" = "$GITHUB_REF_NAME" || { echo "tag $GITHUB_REF_NAME != version $version"; exit 1; }
22
+ - run: python -m build
23
+ - run: twine check --strict dist/*
24
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
25
+ with:
26
+ name: dist
27
+ path: dist/
28
+
29
+ publish:
30
+ needs: build
31
+ runs-on: ubuntu-latest
32
+ environment: pypi
33
+ permissions:
34
+ id-token: write
35
+ steps:
36
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
37
+ with:
38
+ name: dist
39
+ path: dist/
40
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
@@ -0,0 +1,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ .env
10
+ site/
11
+ .coverage
12
+ htmlcov/
@@ -0,0 +1,28 @@
1
+ # Изменения
2
+
3
+ Версии нумеруются как `MAJOR.MINOR.PATCH`. До 1.0 ломающие изменения возможны в любой версии `0.x`, но каждое такое изменение записывается здесь в разделе «Ломающие изменения» вместе с тем, что нужно поправить в своём коде. Исправления без изменения API выходят как `PATCH`.
4
+
5
+ ## 0.1.0 (2026-11-10)
6
+
7
+ Первая версия. Что в ней есть:
8
+
9
+ - общий слой для MAX и Telegram: сообщения, кнопки, файлы, ответы на сообщения, статус «печатает…»
10
+ - роутеры, фильтры, middleware, состояния с хранилищами в памяти, SQLite и Redis
11
+ - типизированные данные кнопок
12
+ - групповые чаты и события входа и выхода участников
13
+ - меню в одном сообщении (`ctx.show()`)
14
+ - меню команд из `@dp.command(..., description=...)`
15
+ - мини-приложения: кнопка открытия и проверка `initData`
16
+ - рассылки во все мессенджеры человека (`Hub`)
17
+ - long polling и вебхуки (ASGI)
18
+ - весь MAX Bot API с моделями из официальной схемы, `TelegramApi.call()` для любого метода Telegram
19
+ - `FakeBot` для тестов без сети
20
+ - `escape()` для текста, который пришёл снаружи: сообщения уходят как HTML
21
+ - подпись данных кнопок (`callback_secret`), чтобы бот принимал только свои кнопки
22
+ - доставка «минимум один раз» для polling и вебхуков (`at_least_once=True`)
23
+ - ограничение числа одновременно обрабатываемых событий (`concurrency`, `max_pending`)
24
+ - остановка по Ctrl+C и SIGTERM с ожиданием начатых хендлеров
25
+ - блокировка разговора между процессами в `RedisStorage`
26
+ - продолжение рассылки после перезапуска (`on_sent`)
27
+ - лимит на размер скачиваемых файлов (`max_download`, по умолчанию 50 МБ)
28
+ - документация на сайте https://cvsqwe.github.io/matele/
matele-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cvsqwe
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.
matele-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,310 @@
1
+ Metadata-Version: 2.5
2
+ Name: matele
3
+ Version: 0.1.0
4
+ Summary: One bot, two messengers: an async framework for MAX and Telegram bots
5
+ Project-URL: Homepage, https://github.com/cvsqwe/matele
6
+ Project-URL: Documentation, https://cvsqwe.github.io/matele/
7
+ Project-URL: Source, https://github.com/cvsqwe/matele
8
+ Project-URL: Issues, https://github.com/cvsqwe/matele/issues
9
+ Project-URL: Changelog, https://github.com/cvsqwe/matele/blob/main/CHANGELOG.md
10
+ Author: cvsqwe
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: asyncio,bot,framework,max,telegram
14
+ Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Framework :: AsyncIO
16
+ Classifier: Intended Audience :: Developers
17
+ Classifier: Natural Language :: Russian
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Communications :: Chat
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: certifi>=2024.2.2
26
+ Requires-Dist: httpx>=0.27
27
+ Provides-Extra: dev
28
+ Requires-Dist: basedpyright==1.40.2; extra == 'dev'
29
+ Requires-Dist: fastapi>=0.110; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
31
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
32
+ Requires-Dist: pytest>=8; extra == 'dev'
33
+ Requires-Dist: redis>=5; extra == 'dev'
34
+ Requires-Dist: ruff>=0.17.4; extra == 'dev'
35
+ Requires-Dist: uvicorn>=0.29; extra == 'dev'
36
+ Provides-Extra: docs
37
+ Requires-Dist: mkdocs-material<10,>=9.6; extra == 'docs'
38
+ Provides-Extra: redis
39
+ Requires-Dist: redis>=5; extra == 'redis'
40
+ Provides-Extra: webhook
41
+ Requires-Dist: uvicorn>=0.29; extra == 'webhook'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # matele
45
+
46
+ Фреймворк на Python для ботов, которые работают сразу в MAX и Telegram. Хендлеры пишутся один раз, а разницу между мессенджерами matele берёт на себя.
47
+
48
+ Проект на ранней стадии: до версии 1.0 API ещё может меняться. Все изменения записываются в [CHANGELOG.md](https://github.com/cvsqwe/matele/blob/main/CHANGELOG.md).
49
+
50
+ Документация с руководством: https://cvsqwe.github.io/matele/
51
+
52
+ ## Установка
53
+
54
+ ```bash
55
+ pip install matele
56
+ ```
57
+
58
+ Нужен Python 3.11 или новее. Для вебхуков добавьте `[webhook]`, для хранения состояния в Redis `[redis]`:
59
+
60
+ ```bash
61
+ pip install "matele[webhook,redis]"
62
+ ```
63
+
64
+ ## Пример
65
+
66
+ Бот, который здоровается, спрашивает имя и запоминает его. Один и тот же код работает в обоих мессенджерах:
67
+
68
+ ```python
69
+ from matele import Button, CallbackData, Context, Dispatcher, Keyboard, State, callback, escape
70
+ from matele.max import MaxBot
71
+ from matele.telegram import TelegramBot
72
+
73
+ dp = Dispatcher()
74
+ ASK_NAME = State("ask_name")
75
+
76
+
77
+ @callback("hello")
78
+ class Hello(CallbackData):
79
+ lang: str
80
+
81
+
82
+ @dp.command("start")
83
+ async def start(ctx: Context):
84
+ keyboard = Keyboard([[Button("Русский", Hello("ru").pack()), Button("English", Hello("en").pack())]])
85
+ await ctx.reply("Привет! На каком языке говорим?", keyboard)
86
+
87
+
88
+ @dp.callback(Hello)
89
+ async def chose_language(ctx: Context, payload: Hello):
90
+ ctx.state.set(ASK_NAME)
91
+ ctx.state.update(lang=payload.lang)
92
+ await ctx.edit("Как тебя зовут?" if payload.lang == "ru" else "What's your name?")
93
+
94
+
95
+ @dp.message(state=ASK_NAME)
96
+ async def got_name(ctx: Context):
97
+ lang = ctx.state.get("lang")
98
+ ctx.state.clear()
99
+ await ctx.reply(f"Приятно познакомиться, {escape(ctx.text)}!" if lang == "ru" else f"Nice to meet you, {escape(ctx.text)}!")
100
+
101
+
102
+ dp.run_polling(MaxBot(MAX_TOKEN), TelegramBot(TG_TOKEN))
103
+ ```
104
+
105
+ Можно запустить только один из ботов, если второй мессенджер не нужен. Пример побольше лежит в [examples/two_messengers.py](https://github.com/cvsqwe/matele/blob/main/examples/two_messengers.py).
106
+
107
+ ## Кнопки
108
+
109
+ Данные кнопки описываются классом, а не строкой, которую потом приходится резать вручную. В хендлер приходит уже готовый объект с нужными типами:
110
+
111
+ ```python
112
+ @callback("order:set")
113
+ class SetStatus(CallbackData):
114
+ order_id: int
115
+ status: Literal["paid", "shipped"]
116
+
117
+
118
+ @dp.callback(SetStatus)
119
+ async def set_status(ctx: Context, payload: SetStatus):
120
+ await ctx.edit(f"Заказ {payload.order_id}: {payload.status}")
121
+ ```
122
+
123
+ Telegram ограничивает данные кнопки 64 байтами. Если payload длиннее, matele подменяет его коротким ключом, а при нажатии возвращает исходное значение. Нажатия на кнопки из старых версий бота, которые уже не разбираются, приходят в `@dp.raw_callback()`, и бот не падает.
124
+
125
+ ## Меню в одном сообщении
126
+
127
+ `ctx.show()` рисует экран в одном и том же сообщении: при нажатии кнопки оно редактируется, а не появляется новое. Если человек что-то напечатал, его сообщение удаляется, а меню обновляется прямо под ним, так что чат не засоряется.
128
+
129
+ ```python
130
+ @dp.command("menu")
131
+ async def menu(ctx: Context):
132
+ await ctx.show("Главное меню", main_keyboard)
133
+
134
+
135
+ @dp.callback(OpenOrders)
136
+ async def orders(ctx: Context, payload: OpenOrders):
137
+ await ctx.show("Ваши заказы", orders_keyboard, toast="Загружено")
138
+ ```
139
+
140
+ Уведомления, которые не являются экранами меню, лучше отправлять обычным `ctx.reply()`.
141
+
142
+ ## Фильтры и порядок хендлеров
143
+
144
+ Фильтр это любая функция от контекста, обычная или async. Фильтры комбинируются через `&`, `|` и `~`:
145
+
146
+ ```python
147
+ is_admin = as_filter(lambda ctx: ctx.user.id in ADMINS)
148
+
149
+ @dp.command("ban", is_admin)
150
+ async def ban(ctx: Context): ...
151
+ ```
152
+
153
+ Встроенные фильтры: `Text`, `Regex`, `HasAttachment`, `OnPlatform`, `ChatType`.
154
+
155
+ Если у команды есть описание, при запуске она попадёт в меню команд в обоих мессенджерах. Команды без описания работают, но в меню не видны:
156
+
157
+ ```python
158
+ @dp.command("start", description="Начать заново")
159
+ async def start(ctx: Context): ...
160
+ ```
161
+
162
+ Хендлер выбирается в предсказуемом порядке: сначала команды, потом хендлеры текущего состояния, потом все остальные. Поэтому `/cancel` срабатывает посреди любого сценария, и не нужно следить, в каком порядке что зарегистрировано.
163
+
164
+ ## Состояния
165
+
166
+ `ctx.state` хранит, на каком шаге сценария находится человек, и данные, которые он успел ввести. По умолчанию всё лежит в памяти и теряется при перезапуске. Чтобы переживало перезапуск, подключите хранилище:
167
+
168
+ ```python
169
+ from matele.fsm import SQLiteStorage, RedisStorage
170
+
171
+ dp = Dispatcher(storage=SQLiteStorage("bot.db"))
172
+ # или, если процессов несколько:
173
+ dp = Dispatcher(storage=RedisStorage.from_url("redis://localhost:6379/0"))
174
+ ```
175
+
176
+ SQLite работает на стандартной библиотеке, ничего ставить не нужно.
177
+
178
+ ## Группы
179
+
180
+ Бот работает и в личке, и в групповых чатах. `ctx.reply()` отвечает туда, откуда пришло сообщение, а `ctx.send_private()` пишет человеку в личку. Состояние хранится отдельно для каждого человека в каждом чате, так что форма, начатая в одной группе, не мешает в другой.
181
+
182
+ ```python
183
+ @dp.member_update()
184
+ async def welcome(ctx: Context):
185
+ update = ctx.member_update
186
+ if update.joined and not update.is_me:
187
+ await ctx.reply(f"Добро пожаловать, {escape(update.member.name)}!")
188
+ ```
189
+
190
+ Команды вроде `/help@other_bot` бот пропускает: они адресованы не ему.
191
+
192
+ Чтобы бот видел в группе все сообщения, а не только команды: в Telegram выключите privacy mode у бота в BotFather, в MAX сделайте бота администратором с правом читать все сообщения.
193
+
194
+ ## Файлы и ответы
195
+
196
+ ```python
197
+ await ctx.reply_file(Path("cat.jpg"), kind="image", caption="Кот")
198
+ await ctx.reply("Принято", quote=True) # ответом на сообщение человека
199
+
200
+ async with ctx.typing(): # «печатает…», пока идёт долгая работа
201
+ report = await build_report()
202
+ ```
203
+
204
+ Файл можно передать байтами, путём или открытым файлом. Большие файлы уходят потоком, целиком в память не читаются.
205
+
206
+ ## Мини-приложения
207
+
208
+ Кнопка с `app=` открывает мини-приложение бота. Значение кнопки приложение получает как параметр запуска, по нему удобно сразу открыть нужную страницу:
209
+
210
+ ```python
211
+ keyboard = Keyboard([[Button("Открыть магазин", app=""), Button("Сразу к кофе", app="coffee")]])
212
+ ```
213
+
214
+ В MAX адрес мини-приложения задаётся в настройках бота на business.max.ru. В Telegram его передают в `TelegramBot(token, app_url="https://...")`, и там такие кнопки работают только в личных чатах.
215
+
216
+ Мини-приложение присылает на ваш сервер `initData`, подписанные мессенджером данные о человеке. Перед тем как чему-то верить, подпись нужно проверить:
217
+
218
+ ```python
219
+ data = bot.verify_init_data(request.headers["X-Init-Data"]) # иначе InitDataError
220
+ await bot.send_message("Заказ принят", user_id=data.user.id)
221
+ ```
222
+
223
+ Проверка одна на оба мессенджера: подпись и срок (по умолчанию час, как советует MAX). Готовый пример со страницей и сервером на FastAPI лежит в [examples/mini_app](https://github.com/cvsqwe/matele/tree/main/examples/mini_app).
224
+
225
+ ## Рассылки
226
+
227
+ Если человек есть в обоих мессенджерах, `Hub` напишет ему в каждый. Отказ одного мессенджера не мешает второму:
228
+
229
+ ```python
230
+ from matele.hub import Hub
231
+
232
+ hub = Hub(max_bot, telegram_bot)
233
+ await hub.send({"max": 123, "telegram": 456}, "Заказ готов")
234
+
235
+ report = await hub.broadcast(subscribers(), "Мы открылись!")
236
+ print(report.reached, report.unreachable)
237
+ ```
238
+
239
+ Скорость отправки ограничена под лимиты мессенджеров (по умолчанию 25 сообщений в секунду на каждый). Если мессенджер ненадолго пропал, отправка подождёт и повторит, а при долгом сбое сразу вернёт ошибку и не застопорит рассылку. Тех, кто заблокировал бота, видно в `report.unreachable`, а `on_unreachable` позволяет сразу отметить их у себя. Получателей можно отдавать генератором, например постранично из базы.
240
+
241
+ ## Вебхуки
242
+
243
+ Вместо постоянного опроса мессенджеры могут сами присылать события на ваш сервер:
244
+
245
+ ```python
246
+ from matele.webhook import Webhook
247
+
248
+ webhook = Webhook(dp, base_url="https://bot.example.com")
249
+ webhook.add(MaxBot(MAX_TOKEN), "/max")
250
+ webhook.add(TelegramBot(TG_TOKEN), "/telegram")
251
+ webhook.run(port=8080)
252
+ ```
253
+
254
+ `Webhook` это обычное ASGI-приложение, его можно запустить и через `uvicorn app:webhook`. При старте он сам регистрирует адреса в мессенджерах со случайным секретом и проверяет этот секрет в каждом запросе. Нужен публичный HTTPS-адрес: MAX принимает только порт 443, Telegram ещё 80, 88 и 8443.
255
+
256
+ ## Весь MAX Bot API
257
+
258
+ Помимо общего слоя есть типизированный клиент ко всем 33 методам MAX Bot API: чаты, участники, закрепы, загрузка файлов, вебхуки, комментарии.
259
+
260
+ ```python
261
+ from matele.max import MaxApi
262
+
263
+ async with MaxApi(token) as api:
264
+ photo = await api.upload(Path("cat.jpg"), type="image")
265
+ sent = await api.send_message(chat_id=chat_id, text="<b>Кот</b>", format="html", attachments=[photo])
266
+ await api.pin_message(chat_id, sent.body.mid)
267
+ ```
268
+
269
+ Модели генерируются из [официальной схемы](https://github.com/max-messenger/api-schema), поэтому совпадают с документацией MAX. Если MAX добавит новое поле или новый тип события, бот не упадёт: неизвестное просто приходит как базовый объект с исходными данными в `.raw`. Сертификат Минцифры, которым подписан MAX API, уже встроен и используется только для запросов к MAX.
270
+
271
+ Из хендлера клиент доступен как `ctx.bot.api`. Для Telegram есть `TelegramApi.call()`, через него можно вызвать любой метод Bot API.
272
+
273
+ ## Тесты
274
+
275
+ Ботов можно тестировать без сети и токенов:
276
+
277
+ ```python
278
+ from matele.testing import FakeBot
279
+
280
+ bot = FakeBot()
281
+ await dp.feed(bot.message("/start"))
282
+ assert bot.sent[-1].text == "Привет! На каком языке говорим?"
283
+ ```
284
+
285
+ ## Что дальше
286
+
287
+ Ближайшее: проверка на живых ботах в обоих мессенджерах. Подробнее в [ROADMAP.md](https://github.com/cvsqwe/matele/blob/main/ROADMAP.md).
288
+
289
+ ## Разработка
290
+
291
+ ```bash
292
+ python -m venv .venv
293
+ .venv/bin/python -m pip install -e ".[dev]"
294
+ .venv/bin/python -m pytest
295
+ .venv/bin/ruff check src tests tools examples
296
+ .venv/bin/basedpyright
297
+ ```
298
+
299
+ Документация лежит в `docs/` и собирается MkDocs:
300
+
301
+ ```bash
302
+ .venv/bin/python -m pip install -e ".[docs]"
303
+ .venv/bin/mkdocs serve
304
+ ```
305
+
306
+ В коде отступы табами, настройки в `.editorconfig`.
307
+
308
+ ## Лицензия
309
+
310
+ MIT