Перейти к содержимому

Контракт плагинов

Типизированный источник истины — ../../backend/bot/plugins/spec.py. Эта страница помогает сориентироваться, но не дублирует весь контракт.

Текущая публичная версия API плагинов — PLUGIN_API_VERSION = 1. Новый внешний плагин должен объявить включающий диапазон plugin_api_min_version и plugin_api_max_version на своем классе Plugin. Loader останавливает запуск до вызова hook’ов, если текущая версия ядра не входит в этот диапазон или диапазон описан некорректно. Неверсионированные плагины остаются совместимы в API v1 только для обратной совместимости; их следует обновить с явным диапазоном до следующего compatibility-breaking релиза.

Плагин наследуется от Plugin и переопределяет только нужные хуки:

  • setup(ctx) — регистрация сервисов и подписок на события;
  • setup_bot(ctx, user_root, admin_root) — подключение aiogram-роутеров;
  • setup_web(ctx, app, scope) — добавление aiohttp routes;
  • worker_tasks(ctx) — долгоживущие coroutine-задачи worker-процесса;
  • queue_handlers(ctx) — обработчики webhook-очереди;
  • migrations() — цепочка миграций плагина;
  • locales_dir() — дополнительные JSON-каталоги локалей;
  • locale_groups() — необязательная иерархия групп для переводов плагина в админке;
  • entitlements_provider() — интеграция feature flags.

locale_groups() возвращает PluginLocaleGroup: path задаёт вложенные названия, prefixes — префиксы ключей локалей, а необязательный path_keys — ключ перевода для каждого названия. Длина path_keys должна совпадать с длиной path; при отсутствии перевода показывается название из path. При пересечении префиксов выигрывает самый длинный. Строки без подходящей группы остаются в группе «Прочие строки» своего плагина.

entitlements_provider() — авторитетный источник: может быть только один. Несколько плагинов, вернувших provider, — детерминированная startup configuration error, а не порядок-зависимый выбор.

PluginContext содержит настройки, необязательные bot/dispatcher/session factory, i18n и общий словарь services. Словарь services — публичная поверхность расширения; ключи должны быть строками, а хуки должны терпеть отсутствие необязательных полей во вспомогательных entrypoint’ах.

Для внутренних core-сервисов и типизированного кода плагинов предпочитай методы-помощники на контексте:

  • ctx.require_bot(), ctx.require_i18n(), ctx.require_session_factory() — когда entrypoint обязан иметь runtime-объект;
  • ctx.panel_service, ctx.subscription_service, ctx.notification_service и парные ctx.require_* helpers — типизированный доступ к core-сервисам без строковых ключей;
  • ctx.audience_segmentation_service и ctx.require_audience_segmentation_service() — регистрация дополнительных аудиторий рассылки через AudienceProvider;
  • ctx.get_service("my_service", MyService) / ctx.require_service("my_service", MyService) — runtime-проверяемый доступ к сервисам, добавленным плагином.

Прямой ctx.services[...] остается совместимым API для внешних плагинов и динамических ключей, но новый внутренний код должен идти через типизированные helpers, если ключ заранее известен.

Web-плагины получают один из двух scope из bot.plugins.spec:

  • WEB_SCOPE_WEBAPP — Mini App и admin API;
  • WEB_SCOPE_WEBHOOKS — payment, panel, health и Telegram webhook routes.

Подписчики доменных событий сохраняют публичную сигнатуру (event_name, dict). Формы payload’ов описаны в ../architecture/events.md и проверяются pydantic-моделями в bot.infra.event_payloads, но внешние подписчики получают обычный плоский dict.

Минимальный запускаемый пример лежит в ../../examples/plugins/audit_logger_plugin. Он показывает setup, setup_web и подписку через bot.infra.events.subscribe.

Capability support_connector=3 дополняет экспорты версий 1 и 2 из bot.plugins.support_connector. SupportService.get_ticket_context(ticket_id, expected_user_id=...) проверяет владельца и возвращает скалярный SupportTicketContext: ревизию, статус, последнюю публичную историю (до 100 сообщений), наличие изображения и разрешённые сведения о собственной подписке и десяти последних платежах, последнем подключении, HWID, модели устройства, ОС, приложении и последнем сервере. Актуальные данные панели и сохранённая дата подключения имеют отдельные источники; неизвестное устройство или сервер не выводятся из даты обновления записи. Проекция включает собственную ссылку подписки, существующую публичную инструкцию, разделы Mini App и настроенные ссылки загрузки приложений. Конфигурация приложений разрешается по тем же правилам, что и пользовательская инструкция: назначенная конфигурация подписки, конфигурация внешнего сквада владельца, встроенная конфигурация или значение по умолчанию. Ошибка чтения устройств или названия узла не скрывает доступные ссылки; название последнего сервера определяется по каталогу узлов без передачи сетевых адресов. Чтение панели ограничено восьмью секундами и выполняется вне транзакции; затем владелец и ревизия проверяются снова. Внутренние заметки, контакты, платёжные идентификаторы и данные других владельцев не входят в проекцию. Чтение не помечает сообщения прочитанными. Сессия закрывается до внешней обработки.

support_dal.get_change_cursor(session) возвращает начальную границу включения обработчика; support_dal.list_changed_tickets(session, after_cursor=..., limit=...) возвращает SupportTicketChange в порядке устойчивого целочисленного курсора (страница 1–500 записей). Курсор сериализован транзакционной строкой, поэтому более поздний commit не скрывает предшествующее изменение. Сверка возвращает актуальное состояние изменившихся обращений, а не журнал каждого перехода. События support.message_created и support.ticket_updated публикуются после commit и сохраняют сигнатуру (event_name, dict).

SupportService.reply_from_connector(ticket_id, body, expected_user_id=..., expected_revision=..., idempotency_key=..., source=..., author_label=..., buttons=..., expected_account_binding=..., replay_only=False) атомарно проверяет владельца, ревизию, доступность поддержки, статус и назначенного оператора под той же блокировкой, что используется обычными отправителями. Новые сообщения и внутренние заметки, изменения статуса или назначения инвалидируют прежнюю ревизию. Повтор одного ключа и источника возвращает тот же message_id; другое содержимое или кнопки с этим ключом отклоняются. Необязательные buttons используют публичный MessageButtonInput и обычные ограничения композиции сообщений. SupportService.encode_reply_buttons() предоставляет ту же композицию для согласованных ответов оператора. Кнопка kind="webapp_section" с section="install" может дополнительно передавать platform (ключ ОС в конфигурации инструкций) и app (стабильный id приложения этой ОС). Оба поля необязательны; приложение выбирается после ОС. При сохранении или преобразовании дескрипторов сохраняйте эти поля вместе с section. Существующие дескрипторы без них продолжают открывать обычный раздел. facts.account_binding содержит необратимый идентификатор текущей привязки; передача expected_account_binding проверяет её под блокировкой владельца. replay_only=True возвращает существующую квитанцию и никогда не создаёт новое сообщение, позволяя безопасно восстановить отправку при изменившемся диагностическом контексте. Ответ имеет author_role=connector, author_user_id=None, отдельные источник и подпись. Непрочитанные сообщения клиента для оператора сохраняются. Отправка внешних уведомлений остаётся best-effort и не гарантирует exactly-once транспортную доставку.

Capability ui_composition=3 добавляет защищённый slot admin.support.ticket.content в диалог выбранного обращения перед редактором ответа; разрешены только before/after. context содержит ticketId, ticket, числовую revision, insertReplyDraft(text, buttons?) и refreshTicket(). Вставка заполняет редактор публичного ответа, соблюдает его лимит и не отправляет сообщение; возвращает false, если выбранный тикет или его ревизия уже изменились или кнопки невалидны, и true при вставке. replyButtonsSupported: true явно объявляет вставку текста и до четырёх обычных кнопок одним действием. Вызов с одним текстом сохраняет ранее выбранные кнопки; некорректная вставка сохраняет и текст, и кнопки редактора. Более ранние Core-релизы этих контрактов не предоставляют: проверяйте capability до использования, а выпуск фиксируйте отдельно.

Плагин, которому нужен нейтральный идентификатор локальной установки, вызывает bot.services.installation_identity.get_or_create_installation_identity(session). Функция возвращает persistent UUID и не зависит от включенной telemetry. Плагин получает session через ctx.require_session_factory() и сам владеет commit транзакции.

Ядро предоставляет заглушки наблюдаемости в bot.infra.observability. Плагин может в своем хуке setup(ctx) положить в ctx.services["error_reporter"] реализацию ErrorReporter и/или в ctx.services["metrics"] реализацию Metrics. Свойства PluginContext.error_reporter и PluginContext.metrics возвращают реализацию плагина, если она есть, иначе откатываются к безопасным no-op значениям по умолчанию.

Глобальные обработчики ошибок web- и worker-процессов вызывают найденный ErrorReporter для необработанных исключений в handlers. Если reporter сам падает, ошибка логируется и не заменяет исходное исключение. Ключи сервисов остаются additive extension points: существующие сигнатуры хуков плагинов не меняются.

Плагин может в setup(ctx) зарегистрировать AudienceProvider в ctx.require_audience_segmentation_service(). Provider задаёт стабильный target, ключ и fallback локализованной подписи, async resolver идентификаторов пользователей и, опционально, отдельный async count resolver. Callback is_available позволяет динамически скрыть аудиторию и запретить её разрешение. Неизвестный или недоступный target никогда не откатывается к массовой аудитории.

Плагин может в setup(ctx) зарегистрировать sync или async provider через bot.infra.promo_policies.register_promo_checkout_suggestion_provider. Provider получает PromoCheckoutSuggestionContext с session, пользователем и, когда они известны, типом покупки, сроком и объёмом трафика. Возвращаемое значение — один код, последовательность кодов или None.

Предложения считаются неавторитетными подсказками. Core нормализует и дедуплицирует до 50 кодов, пропускает упавший provider и при каждом checkout заново проверяет код обычными promo policies. Mini App использует первое предложение, а Telegram-бот может сравнить несколько кодов и показать лучший валидный вариант для каждого периода подписки.

Новые заказы используют duration_days и period_semantics: fixed_days. Период подписки не следует выводить из дат доступа: там могут быть несколько покупок и бонусы. В публичных событиях добавлены nullable-поля duration_days / purchased_days; старые month-поля сохраняются как алиасы только при точной обратной конвертации. Для 365 дней алиас равен 12, для 7 или 360 дней алиаса нет. Новое nullable-поле может отсутствовать у старого события. Сигнатура подписчика (event_name, dict) не меняется.

В HTTP-запросах покупки/котировки передавайте duration_days. Старый вход months преобразуется по формуле (months // 12) * 365 + (months % 12) * 30. Если переданы оба поля, значения должны совпадать после преобразования, иначе ответ 400. Новый Mini App отправляет X-Billing-Period-Unit: day; без этого заголовка сервер скрывает из витрины периоды без точного месячного алиаса. Состояние API сообщает billing_period_unit: day и billing_period_schema_version: 2.

Внутренние адаптеры старых провайдеров и Telegram могут сохранять универсальный слот months для ключа предложения/GB/числа устройств. Дневной заказ в таком адаптере обязательно содержит маркер |d<D> в sale mode и сохраняется с явными дневными полями. Исполнение читает локальный заказ; возвращённая провайдером metadata не выбирает срок. Снимок checkout имеет версию 3, снимок локального автосписания — версию 2; читатели продолжают принимать старые версии без переинтерпретации периода.