Skip to content

Added custom http client, update webhook, new download methods - #167

Open
m-xim wants to merge 113 commits into
K1rL3s:masterfrom
m-xim:webhook3
Open

Added custom http client, update webhook, new download methods#167
m-xim wants to merge 113 commits into
K1rL3s:masterfrom
m-xim:webhook3

Conversation

@m-xim

@m-xim m-xim commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Описание

Introduced a custom HTTP transport and updated the webhook to the latest version of aiogram-webhook 3.

Closes #74
Closes #121

Тип изменения

  • Документация (опечатки, примеры кода или любые другие обновления документации)
  • Исправление бага (некритическое изменение, которое устраняет проблему)
  • Новый функционал (некритическое изменение, добавляющее функциональность)
  • Критические изменения (исправление или функционал, из-за которого существующая функциональность не будет работать должным образом)
  • Это изменение требует обновления документации

Контрольный список:

  • Мой код соответствует рекомендациям по стилю этого проекта
  • Я выполнил самопроверку своего кода
  • Я внёс соответствующие изменения в документацию
  • Я добавил тесты, которые доказывают, что моё исправление эффективно или моя функция работает
  • Новые и существующие модульные тесты проходят локально с моими изменениями
  • Код полностью написан мной без использования нейросетей
  • Код частично или полностью написан нейросетями, но прошёл полный контроль со стороны человека

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Too many files!

This PR contains 213 files, which is 113 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

This review couldn't start because sufficient usage credits or metered capacity aren't available. Add credits or update usage-based reviews in the billing tab, then retry.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c45fda8e-8492-414d-b5d1-1591bfe81c13

📥 Commits

Reviewing files that changed from the base of the PR and between 3f2b9ba and 43667ee.

📒 Files selected for processing (213)
  • .agents/skills/butcher-update/references/manual-layer.md
  • AGENTS.md
  • README.md
  • butcher/AGENTS.md
  • butcher/overrides.py
  • butcher/tests/test_render.py
  • docs/pages/botapi/download.rst
  • docs/pages/botapi/index.rst
  • docs/pages/event-handling/bot-mixin.rst
  • docs/pages/event-handling/errors.rst
  • docs/pages/event-handling/facades.rst
  • docs/pages/event-handling/signals.rst
  • docs/pages/event-handling/webhooks.rst
  • docs/pages/getting-started.rst
  • examples/bot_middlewares.py
  • examples/dialogs_parallel_callback.py
  • examples/swagger_watcher.py
  • examples/webhook_aiohttp.py
  • examples/webhook_fastapi.py
  • pyproject.toml
  • src/maxo/__init__.py
  • src/maxo/bot/api_client.py
  • src/maxo/bot/bot.py
  • src/maxo/bot/client.py
  • src/maxo/bot/defaults.py
  • src/maxo/bot/methods/base.py
  • src/maxo/bot/methods/chats/add_members.py
  • src/maxo/bot/methods/download.py
  • src/maxo/bot/methods/subscriptions/get_updates.py
  • src/maxo/bot/methods/upload/chunk_upload.py
  • src/maxo/bot/methods/upload/upload_media.py
  • src/maxo/bot/middlewares/__init__.py
  • src/maxo/bot/middlewares/attachment_retry.py
  • src/maxo/bot/middlewares/auth.py
  • src/maxo/bot/middlewares/chunk_upload_retry.py
  • src/maxo/bot/middlewares/network_error.py
  • src/maxo/bot/state.py
  • src/maxo/bot/upload.py
  • src/maxo/bot/warming_up.py
  • src/maxo/dialogs/api/entities/update_event.py
  • src/maxo/dialogs/context/storage.py
  • src/maxo/dialogs/dialog.py
  • src/maxo/dialogs/manager/message_manager.py
  • src/maxo/dialogs/test_tools/bot_client.py
  • src/maxo/dialogs/test_tools/mock_message_manager.py
  • src/maxo/dialogs/tools/web_preview.py
  • src/maxo/dialogs/widgets/kbd/list_group.py
  • src/maxo/loggers.py
  • src/maxo/routing/dispatcher.py
  • src/maxo/routing/facades/base.py
  • src/maxo/routing/facades/bot_added_to_chat.py
  • src/maxo/routing/facades/bot_removed_from_chat.py
  • src/maxo/routing/facades/bot_started.py
  • src/maxo/routing/facades/bot_stopped.py
  • src/maxo/routing/facades/chat_title_changed.py
  • src/maxo/routing/facades/comment_created.py
  • src/maxo/routing/facades/comment_edited.py
  • src/maxo/routing/facades/comment_removed.py
  • src/maxo/routing/facades/dialog_cleared.py
  • src/maxo/routing/facades/dialog_muted.py
  • src/maxo/routing/facades/dialog_removed.py
  • src/maxo/routing/facades/dialog_unmuted.py
  • src/maxo/routing/facades/message_callback.py
  • src/maxo/routing/facades/message_created.py
  • src/maxo/routing/facades/message_edited.py
  • src/maxo/routing/facades/message_removed.py
  • src/maxo/routing/facades/user_added_to_chat.py
  • src/maxo/routing/facades/user_removed_from_chat.py
  • src/maxo/routing/filters/command.py
  • src/maxo/routing/middlewares/error.py
  • src/maxo/routing/middlewares/fsm_context.py
  • src/maxo/routing/mixins/__init__.py
  • src/maxo/routing/updates/mixins/__init__.py
  • src/maxo/serialization.py
  • src/maxo/transport/long_polling.py
  • src/maxo/transport/webhook/__init__.py
  • src/maxo/transport/webhook/adapters/aiohttp/__init__.py
  • src/maxo/transport/webhook/adapters/aiohttp/adapter.py
  • src/maxo/transport/webhook/adapters/aiohttp/mapping.py
  • src/maxo/transport/webhook/adapters/base_adapter.py
  • src/maxo/transport/webhook/adapters/base_mapping.py
  • src/maxo/transport/webhook/adapters/fastapi/__init__.py
  • src/maxo/transport/webhook/adapters/fastapi/adapter.py
  • src/maxo/transport/webhook/adapters/fastapi/mapping.py
  • src/maxo/transport/webhook/config/bot.py
  • src/maxo/transport/webhook/configs/__init__.py
  • src/maxo/transport/webhook/configs/bot.py
  • src/maxo/transport/webhook/configs/webhook.py
  • src/maxo/transport/webhook/engines/__init__.py
  • src/maxo/transport/webhook/engines/base.py
  • src/maxo/transport/webhook/engines/errors.py
  • src/maxo/transport/webhook/engines/multi.py
  • src/maxo/transport/webhook/engines/simple.py
  • src/maxo/transport/webhook/engines/single.py
  • src/maxo/transport/webhook/engines/token.py
  • src/maxo/transport/webhook/errors.py
  • src/maxo/transport/webhook/route/__init__.py
  • src/maxo/transport/webhook/route/config.py
  • src/maxo/transport/webhook/route/errors.py
  • src/maxo/transport/webhook/route/params.py
  • src/maxo/transport/webhook/route/path.py
  • src/maxo/transport/webhook/route/query.py
  • src/maxo/transport/webhook/route/route.py
  • src/maxo/transport/webhook/route/url.py
  • src/maxo/transport/webhook/routing/__init__.py
  • src/maxo/transport/webhook/routing/base.py
  • src/maxo/transport/webhook/routing/path.py
  • src/maxo/transport/webhook/routing/query.py
  • src/maxo/transport/webhook/routing/static.py
  • src/maxo/transport/webhook/security/__init__.py
  • src/maxo/transport/webhook/security/base_check.py
  • src/maxo/transport/webhook/security/checks/__init__.py
  • src/maxo/transport/webhook/security/checks/check.py
  • src/maxo/transport/webhook/security/errors.py
  • src/maxo/transport/webhook/security/secret.py
  • src/maxo/transport/webhook/security/secret_token.py
  • src/maxo/transport/webhook/security/security.py
  • src/maxo/transport/webhook/tasks.py
  • src/maxo/transport/webhook/web/__init__.py
  • src/maxo/transport/webhook/web/aiohttp.py
  • src/maxo/transport/webhook/web/base.py
  • src/maxo/transport/webhook/web/fastapi.py
  • src/maxo/types/__init__.py
  • src/maxo/types/base.py
  • src/maxo/types/binding.py
  • src/maxo/types/bot_added_to_chat.py
  • src/maxo/types/bot_removed_from_chat.py
  • src/maxo/types/bot_started.py
  • src/maxo/types/bot_stopped.py
  • src/maxo/types/chat.py
  • src/maxo/types/chat_list.py
  • src/maxo/types/chat_title_changed.py
  • src/maxo/types/comment_created.py
  • src/maxo/types/comment_edited.py
  • src/maxo/types/comment_message.py
  • src/maxo/types/comment_removed.py
  • src/maxo/types/dialog_cleared.py
  • src/maxo/types/dialog_muted.py
  • src/maxo/types/dialog_removed.py
  • src/maxo/types/dialog_unmuted.py
  • src/maxo/types/facades/__init__.py
  • src/maxo/types/facades/attachments.py
  • src/maxo/types/facades/base.py
  • src/maxo/types/facades/bot.py
  • src/maxo/types/facades/callback.py
  • src/maxo/types/facades/chat.py
  • src/maxo/types/facades/comment.py
  • src/maxo/types/facades/message.py
  • src/maxo/types/facades/subscription.py
  • src/maxo/types/get_pinned_message_result.py
  • src/maxo/types/message.py
  • src/maxo/types/message_callback.py
  • src/maxo/types/message_created.py
  • src/maxo/types/message_edited.py
  • src/maxo/types/message_list.py
  • src/maxo/types/message_removed.py
  • src/maxo/types/send_message_result.py
  • src/maxo/types/update_list.py
  • src/maxo/types/user_added_to_chat.py
  • src/maxo/types/user_removed_from_chat.py
  • src/maxo/utils/deeplink.py
  • src/maxo/utils/facades/__init__.py
  • tests/maxo/bot/conftest.py
  • tests/maxo/bot/methods/test_base.py
  • tests/maxo/bot/methods/test_comments.py
  • tests/maxo/bot/methods/test_delete_admins.py
  • tests/maxo/bot/methods/test_edit_chat.py
  • tests/maxo/bot/test_api_client.py
  • tests/maxo/bot/test_bot.py
  • tests/maxo/bot/test_download.py
  • tests/maxo/bot/test_network_error_middleware.py
  • tests/maxo/bot/test_state.py
  • tests/maxo/bot/test_upload.py
  • tests/maxo/errors/test_api.py
  • tests/maxo/routing/filters/test_command.py
  • tests/maxo/routing/updates/test_mixins.py
  • tests/maxo/test_binding.py
  • tests/maxo/test_serialization.py
  • tests/maxo/transport/test_long_polling.py
  • tests/maxo/types/facades/__init__.py
  • tests/maxo/types/facades/test_chat.py
  • tests/maxo/types/facades/test_comment.py
  • tests/maxo/types/facades/test_message.py
  • tests/maxo/types/facades/test_subscription.py
  • tests/maxo/types/test_accessors.py
  • tests/maxo/utils/builders/test_attachment_request.py
  • tests/maxo/utils/facades/test_facades_init.py
  • tests/maxo/utils/test_attachments_facade.py
  • tests/maxo_dialog/widgets/test_operators.py
  • tests/maxo_webhook/conftest.py
  • tests/maxo_webhook/fixtures/__init__.py
  • tests/maxo_webhook/fixtures/fixtures_bound_request.py
  • tests/maxo_webhook/fixtures/fixtures_checks.py
  • tests/maxo_webhook/fixtures/security_checks.py
  • tests/maxo_webhook/fixtures/shutdown.py
  • tests/maxo_webhook/fixtures/web_request.py
  • tests/maxo_webhook/fixtures/webhook_engine.py
  • tests/maxo_webhook/test_aiohttp_adapter.py
  • tests/maxo_webhook/test_aiohttp_web.py
  • tests/maxo_webhook/test_base_engine.py
  • tests/maxo_webhook/test_base_webhook_engine.py
  • tests/maxo_webhook/test_config.py
  • tests/maxo_webhook/test_engines.py
  • tests/maxo_webhook/test_fastapi_adapter.py
  • tests/maxo_webhook/test_fastapi_web.py
  • tests/maxo_webhook/test_route_urls_and_params.py
  • tests/maxo_webhook/test_routing.py
  • tests/maxo_webhook/test_secret_security.py
  • tests/maxo_webhook/test_secret_token.py
  • tests/maxo_webhook/test_security.py
  • tests/maxo_webhook/test_single_bot_engine.py
  • tests/maxo_webhook/test_token_webhook_engine.py
  • tests/mocks.py

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@K1rL3s K1rL3s added enhancement New feature or request breaking_changes Something will break labels Jul 26, 2026

@K1rL3s K1rL3s left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Очень поверхностно пробежался

Comment thread src/maxo/bot/bot.py Outdated
Comment thread src/maxo/bot/methods/upload/chunk_upload.py
Comment thread src/maxo/bot/methods/chats/add_members.py Outdated
Comment thread src/maxo/bot/methods/base.py
Comment thread src/maxo/bot/methods/download.py Outdated
Comment thread src/maxo/bot/upload.py Outdated
Comment thread src/maxo/transport/webhook/configs/bot.py Outdated
Comment thread src/maxo/transport/webhook/configs/webhook.py Outdated
Comment thread pyproject.toml Outdated
Comment thread pyproject.toml Outdated
Comment thread src/maxo/bot/methods/download.py
Comment thread src/maxo/routing/facades/message_callback.py Outdated
@m-xim
m-xim requested a review from K1rL3s August 24, 2026 16:37
@m-xim m-xim changed the title Added custom http client, update webhook Added custom http client, update webhook, new download methods Aug 24, 2026
K1rL3s and others added 4 commits August 25, 2026 23:08
Разрешение конфликтов:
- сохранена архитектура webhook3 с общим retort и фасадами в maxo.types.facades
- comment API из master перенесен в новый слой; обновлены warming up, defaults, serialization и настройки butcher
- удаленный webhook routing не восстановлен; skill-пути сведены к .agents

Проверки:
- ruff, codespell, slotscheck, bandit и mypy
- 1736 passed, 1 rerun
- butcher: 63 passed
chore: merge master into webhook3
@K1rL3s

K1rL3s commented Aug 29, 2026

Copy link
Copy Markdown
Owner

P1: нормализовать сетевые ошибки при разрешении бота

При ленивом разрешении TokenEngine вызов get_my_info() через NetworkErrorMiddleware может выбросить MaxBotNetworkError, но в handle_request() перехватывается только MaxBotApiError (src/maxo/transport/webhook/engines/token.py:112-123). В результате ошибка выходит необработанной и webhook отвечает HTTP 500 вместо заявленного безопасного HTTP 502. Добавьте обработку MaxBotNetworkError в этот путь.

Comment on lines +101 to +113
def warm_up(
*,
loaded: Iterable[type] | None = None,
dumped: Iterable[type] | None = None,
) -> None:
from maxo.serialization import get_retort # noqa: PLC0415 - avoids import cycle

for tp in types:
retort_method(tp) # type: ignore[arg-type]
retort = get_retort()

return retort
for type_ in _LOADED_ROOTS if loaded is None else loaded:
retort.get_loader(type_)
for type_ in _DUMPED_ROOTS if dumped is None else dumped:
retort.get_dumper(type_)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

С варм_апом точно что-то не так. Нельзя прогреть реторту, полученную из create_retort. Если у нас реторта одна и глобальная, то надо сделать и create_retort кэширующим одну реторту, как делает get_retort. Либо оставить один метод получения реторты, либо передавать реторту в warm_up

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

У нас есть create_retort, которая создаёт реторту. Есть get_retort, которая вызывает create_retort и кэширует результат. В warm_up использует строго get_retort, то есть юзер, создавший реторту через create_retort, не сможет прогреть её

@@ -1,8 +1,16 @@
from __future__ import annotations

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Увидел здесь, прокомментирую здесь. Запрещаю from __future__ import annotations, убрать отовсюду

Comment thread src/maxo/bot/bot.py
json_dumps: Callable[[Any], str] = json.dumps,
json_loads: Callable[[str | bytes | bytearray], Any] = json.loads,
client: BaseAsyncClient | None = None,
middlewares: Sequence[AsyncMiddleware] = (),

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Я передумал, надо обратно назвать его middleware, потому что он везде middleware =(

Comment on lines +26 to +29
class SingleBotEngine(
BaseWebhookEngine[AppT, RawRequestT, FrameworkResponseT],
Generic[AppT, RawRequestT, FrameworkResponseT],
):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

В чате была мысль, что Single и Simple легко перепутать, поэтому надо добавить SimpleBotEngine: TypeAlias = SingeBotEngine и импортировать его в те же __init__.py, что и SingeBotEngine

Comment thread src/maxo/bot/bot.py
Comment on lines -147 to -163
async def start(self) -> None:
if self.state.started:
return

api_client = MaxApiClient(
token=self._token,
request_dumper=self._retort,
response_loader=self._retort,
middleware=self._middleware,
upload_config=self._upload_config,
json_dumps=self._json_dumps,
json_loads=self._json_loads,
)
self._state = ConnectingBotState(api_client=api_client)

info = await self.get_my_info()
self._state = RunningBotState(info=info, api_client=api_client)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Щас метод start пропал вообще, и логика started=True -> self._info is not None неочевидна. Можно добавить алиас start = get_my_info, чтобы человекам было понятнее и была минимальная обратная совместимость

Comment thread src/maxo/__init__.py

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Сюда можно и bind_bot добавить

Comment on lines +24 to +27
class TokenEngine(
BaseMultiBotEngine[AppT, RawRequestT, FrameworkResponseT],
Generic[AppT, RawRequestT, FrameworkResponseT],
):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Если SingleBotEngine и TokenEngine, то надо либо SingleEngine, либо TokenBotEngine

Comment thread src/maxo/bot/bot.py
Comment on lines -178 to -183
async def silent_call_method(self, method: MaxoMethod[_MethodResultT]) -> None:
try:
await self.call_method(method)
except MaxBotApiError as e:
# Webhook-ответ не позволяет вернуть ошибку вызывающему коду.
loggers.bot.error("Failed to make answer: %s: %s", e.__class__.__name__, e)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Удалён Bot.silent_call_method(). Webhook больше не интерпретирует возвращённый хендлером MaxoMethod как отложенный ответ

Почему?

both synchronous and background processing.
"""

class BaseWebhookEngine(ABC, Generic[AppT, RawRequestT, FrameworkResponseT]):

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_in_background удалён, Webhook updates всегда обрабатываются в фоне

А обязательно всегда в фоне? Нет флага на последовательную обработку?

@K1rL3s

K1rL3s commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Я склонировал репозиторий, изучил дифф (214 файлов, +5937/−4289 относительно master) и прогнал тесты локально. Вот моё ревью.

Общее впечатление

PR добротный: архитектура вебхуков в стиле aiogram-webhook3 (разделение на Route / Security / WebAdapter / engines) чистая и расширяемая, TaskTracker с drain + cancel по таймауту корректно закрывает #74, а рекурсивный bind_bot с кэшированием _bot_fields — аккуратное решение #121. Тестовое покрытие серьёзное: у меня локально прошло 1698 тестов (исключая test_preview/test_web_preview, которым нужны дополнительные зависимости). Обработка гонки при shutdown сделана грамотно — между вторым _ensure_accepting_requests(bot) и spawn() нет await, так что проверка и запуск таски атомарны в event loop.

Блокирующие замечания

1. Даунгрейд версии. В master version = "0.8.3", а PR откатывает на 0.8.2 (и в pyproject.toml, и в meta.py). Это явно артефакт мержа. С учётом лейбла breaking_changes тут вообще напрашивается минимум 0.9.0.

2. Утечка токена при скачивании произвольных URL. Download принимает любой str («по произвольному URL» прямо в докстринге), а download_stream гонит запрос через self.middleware, где AuthMiddleware ставит Authorization: <token> на любой запрос. Если пользователь скачает вложение с URL вне доменов MAX (или атакующий подсунет такой URL в сообщении), токен бота уедет третьей стороне. Стоит либо не добавлять Authorization для Download, либо ставить его только для доверенных хостов.

3. Утечка сессии в TokenEngine.add_bot. Если get_my_info(), _build_webhook_kwargs() или subscribe() бросят исключение, созданный bot (и его aiohttp-сессия) не закрывается — в отличие от _resolve_bot, где bot.close() вызывается по всем ошибочным веткам. Нужен try/except с await bot.close().

4. DoS-вектор в авторегистрации TokenEngine._resolve_bot. Каждый запрос с неизвестным токеном в пути порождает исходящий get_my_info под глобальным _bots_lock. Нет негативного кэша, и все такие запросы сериализуются: поток мусорных токенов и замедлит легитимные вебхуки, и создаст нагрузку на API. Стоит добавить кэш неудачных токенов (TTL) или сделать авторегистрацию опциональной.

Некритичные замечания

Bot.close() при self._client is None возвращается, не выставляя _closed = True — бот, который ни разу не сделал запрос, после close() остаётся closed == False. Семантически странно, хотя движки страхуются флагом _is_shutting_down.

В BaseSecret.verify compare_digest бросит TypeError, если в заголовке придёт не-ASCII строка — это неперехваченное исключение и 500 от фреймворка. Дешёвая защита: обернуть или проверить isascii().

В BaseMultiBotEngine._on_shutdown трекеры закрываются последовательно: при N ботах и зависших тасках shutdown займёт до N × shutdown_timeout. Можно закрывать через asyncio.gather.

Мелочь: в download.py комментарий # WARMING: Download — самодельный метода — опечатки («WARNING», «метод»).

По процессу

104 коммита и 214 файлов — CodeRabbit даже отказался ревьюить из-за лимита в 100 файлов. Кастомный HTTP-клиент, download-методы и переписывание вебхуков — три логически независимых блока, которые стоило разнести по отдельным PR; на будущее это сильно ускорит ревью. И в чек-листе не отмечены «самопроверка кода» и «изменения в документации», а два пункта про авторство (нейросети/человек) оставлены оба пустыми — стоит привести в порядок перед мержем.

Ответ автора про unihttp.BaseMethod вместо MaxoMethod для _ChunkUpload, кстати, выглядит обоснованным: MaxoMethod.on_error бросает исключение до мидлварей, и ChunkUploadRetryMiddleware действительно не увидел бы 5xx для ретраев.

Если хочешь, могу глубже разобрать какой-то конкретный модуль — например, route/ с парсингом параметров или новую логику upload_media_resumable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking_changes Something will break enhancement New feature or request

Projects

None yet

2 participants