Контракт плагинов
Типизированный источник истины — ../../backend/bot/plugins/spec.py.
Эта страница помогает сориентироваться, но не дублирует весь контракт.
Версия API и совместимость
Заголовок раздела «Версия API и совместимость»Текущая публичная версия 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 до использования, а выпуск фиксируйте отдельно.
Стабильная identity установки
Заголовок раздела «Стабильная identity установки»Плагин, которому нужен нейтральный идентификатор локальной установки, вызывает
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 никогда не откатывается к массовой аудитории.
Персональные промокоды в checkout
Заголовок раздела «Персональные промокоды в checkout»Плагин может в 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;
читатели продолжают принимать старые версии без переинтерпретации периода.